"""Git commands scoped to a workspace directory.""" import asyncio import logging from dataclasses import dataclass from src.models.workspace import Workspace logger = logging.getLogger(__name__) @dataclass class GitStatus: """Parsed git status output.""" branch: str modified: list[str] added: list[str] deleted: list[str] untracked: list[str] ahead: int = 0 behind: int = 0 @dataclass class Commit: """A single git commit.""" hash: str message: str author: str date: str class GitOperations: """Run git commands within a workspace directory.""" def __init__(self, workspace: Workspace) -> None: self.cwd = workspace.path self.branch = workspace.branch async def _run(self, *cmd: str) -> tuple[int, str, str]: """Run a git command and return (returncode, stdout, stderr).""" proc = await asyncio.create_subprocess_exec( *cmd, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) stdout, stderr = await proc.communicate() return proc.returncode or 0, stdout.decode(), stderr.decode() async def status(self) -> GitStatus: """Get git status for the workspace.""" returncode, stdout, _ = await self._run( "git", "-C", self.cwd, "status", "--porcelain", "-b" ) modified: list[str] = [] added: list[str] = [] deleted: list[str] = [] untracked: list[str] = [] branch = self.branch ahead = 0 behind = 0 for line in stdout.splitlines(): if line.startswith("##"): # Branch info line branch_info = line[3:].strip() if "..." in branch_info: branch = branch_info.split("...")[0] if "[ahead " in branch_info: ahead_str = branch_info.split("[ahead ")[1].split("]")[0] ahead = int(ahead_str.split(",")[0]) if "[behind " in branch_info: behind_str = branch_info.split("[behind ")[1].split("]")[0] behind = int(behind_str.split(",")[0]) else: branch = branch_info continue if len(line) < 3: continue status_code = line[:2] file_path = line[3:] # XY format: X = index status, Y = working tree status if status_code == "??": untracked.append(file_path) elif status_code[1] == "D" or status_code[0] == "D": deleted.append(file_path) elif status_code[0] == "A" or status_code[1] == "A": added.append(file_path) else: modified.append(file_path) return GitStatus( branch=branch, modified=modified, added=added, deleted=deleted, untracked=untracked, ahead=ahead, behind=behind, ) async def commit(self, message: str) -> None: """Stage all changes and commit.""" rc, _, err = await self._run("git", "-C", self.cwd, "add", "-A") if rc != 0: raise RuntimeError(f"Git add failed: {err}") rc, _, err = await self._run("git", "-C", self.cwd, "commit", "-m", message) if rc != 0: raise RuntimeError(f"Git commit failed: {err}") logger.info("Committed in workspace: %s", self.cwd) async def push(self) -> None: """Push current branch to origin.""" rc, _, err = await self._run( "git", "-C", self.cwd, "push", "origin", self.branch ) if rc != 0: raise RuntimeError(f"Git push failed: {err}") logger.info("Pushed branch %s from workspace: %s", self.branch, self.cwd) async def pull(self) -> None: """Pull current branch from origin.""" rc, _, err = await self._run( "git", "-C", self.cwd, "pull", "origin", self.branch ) if rc != 0: raise RuntimeError(f"Git pull failed: {err}") logger.info("Pulled branch %s in workspace: %s", self.branch, self.cwd) async def fetch(self) -> None: """Fetch from origin.""" rc, _, err = await self._run("git", "-C", self.cwd, "fetch", "origin") if rc != 0: raise RuntimeError(f"Git fetch failed: {err}") logger.info("Fetched origin for workspace: %s", self.cwd) async def checkout(self, branch: str) -> None: """Checkout a branch.""" rc, _, err = await self._run("git", "-C", self.cwd, "checkout", branch) if rc != 0: raise RuntimeError(f"Git checkout failed: {err}") self.branch = branch logger.info("Checked out branch %s in workspace: %s", branch, self.cwd) async def history(self, path: str | None = None, limit: int = 50) -> list[Commit]: """Get commit history. Args: path: Optional file path to filter history. limit: Maximum number of commits. Returns: List of commits. """ cmd = [ "git", "-C", self.cwd, "log", f"--max-count={limit}", "--pretty=format:%H|%s|%an|%ad", "--date=iso", ] if path: cmd.extend(["--", path]) rc, stdout, err = await self._run(*cmd) if rc != 0: raise RuntimeError(f"Git log failed: {err}") commits = [] for line in stdout.strip().splitlines(): parts = line.split("|", 3) if len(parts) >= 4: commits.append( Commit( hash=parts[0], message=parts[1], author=parts[2], date=parts[3], ) ) return commits async def branches(self) -> tuple[list[str], str]: """List all branches and current branch. Returns: Tuple of (all_branches, current_branch). """ rc, stdout, err = await self._run( "git", "-C", self.cwd, "branch", "-a", "--format=%(refname:short)" ) if rc != 0: raise RuntimeError(f"Git branch failed: {err}") branches = [] current = self.branch for line in stdout.strip().splitlines(): line = line.strip() if line.startswith("HEAD") or line.endswith("/HEAD"): continue if line.startswith("remotes/origin/"): branch_name = line.replace("remotes/origin/", "") if branch_name not in branches: branches.append(branch_name) elif line and line not in branches: branches.append(line) return branches, current