feat(FN-011): complete Step 5 — Git operations interface and LocalGitOperations

Fusion-Task-Id: FN-011
Fusion-Task-Lineage: 4a9aca6f-9d91-43aa-8d2a-d59657c1541a
This commit is contained in:
Fusion
2026-05-14 08:05:34 +02:00
parent 4214b48c37
commit 3532bec00d
2 changed files with 104 additions and 0 deletions
+3
View File
@@ -2,6 +2,7 @@
from app.git.connection import ConnectionManager, RepositoryConnectionData from app.git.connection import ConnectionManager, RepositoryConnectionData
from app.git.credentials import AccessTokenCredential, CredentialStorage, GitCredential from app.git.credentials import AccessTokenCredential, CredentialStorage, GitCredential
from app.git.operations import GitOperations, LocalGitOperations
from app.git.provider import GitProvider from app.git.provider import GitProvider
from app.git.ssh_key import SshKeyLifecycle, SshKeyPair from app.git.ssh_key import SshKeyLifecycle, SshKeyPair
from app.git.types import ConnectionStatus, CredentialKind, ProviderKind, SshKeyStatus from app.git.types import ConnectionStatus, CredentialKind, ProviderKind, SshKeyStatus
@@ -13,7 +14,9 @@ __all__ = [
"CredentialKind", "CredentialKind",
"CredentialStorage", "CredentialStorage",
"GitCredential", "GitCredential",
"GitOperations",
"GitProvider", "GitProvider",
"LocalGitOperations",
"ProviderKind", "ProviderKind",
"RepositoryConnectionData", "RepositoryConnectionData",
"SshKeyLifecycle", "SshKeyLifecycle",
+101
View File
@@ -0,0 +1,101 @@
"""Local Git subprocess interface."""
import abc
import subprocess
from pathlib import Path
from typing import Any
class GitOperations(abc.ABC):
"""Local Git subprocess interface.
This abstraction is separate from :class:`~app.git.provider.GitProvider`,
which handles remote provider API operations.
"""
@abc.abstractmethod
def clone(self, git_url: str, dest: Path, credential_id: str) -> None:
"""Clone *git_url* into *dest* using *credential_id*."""
@abc.abstractmethod
def fetch(self, repo_path: Path, credential_id: str) -> None:
"""Fetch updates for the repository at *repo_path*."""
@abc.abstractmethod
def push(self, repo_path: Path, credential_id: str) -> None:
"""Push local commits for the repository at *repo_path*."""
@abc.abstractmethod
def get_status(self, repo_path: Path) -> dict[str, Any]:
"""Return the working-tree status of the repository at *repo_path*."""
class LocalGitOperations(GitOperations):
"""Git operations backed by the local ``git`` CLI."""
def clone(self, git_url: str, dest: Path, credential_id: str) -> None:
raise NotImplementedError(
"Credential-aware subprocess invocation will be implemented in a follow-up task"
)
def fetch(self, repo_path: Path, credential_id: str) -> None:
raise NotImplementedError(
"Credential-aware subprocess invocation will be implemented in a follow-up task"
)
def push(self, repo_path: Path, credential_id: str) -> None:
raise NotImplementedError(
"Credential-aware subprocess invocation will be implemented in a follow-up task"
)
def get_status(self, repo_path: Path) -> dict[str, Any]:
if not repo_path.exists() or not (repo_path / ".git").is_dir():
raise RuntimeError("Not a git repository")
branch_result = subprocess.run(
["git", "-C", str(repo_path), "branch", "--show-current"],
capture_output=True,
text=True,
check=True,
)
branch = branch_result.stdout.strip()
status_result = subprocess.run(
["git", "-C", str(repo_path), "status", "--porcelain"],
capture_output=True,
text=True,
check=True,
)
untracked: list[str] = []
modified: list[str] = []
staged: list[str] = []
deleted: list[str] = []
for line in status_result.stdout.splitlines():
if len(line) < 3:
continue
index_status = line[0]
worktree_status = line[1]
filename = line[3:]
if index_status == "?" and worktree_status == "?":
untracked.append(filename)
elif index_status in ("M", "A"):
staged.append(filename)
elif index_status == "D" or worktree_status == "D":
deleted.append(filename)
if worktree_status == "M":
modified.append(filename)
clean = not (untracked or modified or staged or deleted)
return {
"branch": branch,
"clean": clean,
"untracked": untracked,
"modified": modified,
"staged": staged,
"deleted": deleted,
}