From b6fafac6045fd20825e80066b14de216b3dc1b4d Mon Sep 17 00:00:00 2001 From: Alexander Whitestone Date: Mon, 23 Mar 2026 18:07:08 -0400 Subject: [PATCH] refactor: break up git.py::run() into helpers (#538) --- src/infrastructure/hands/git.py | 93 +++++++++++++++++++++------------ 1 file changed, 59 insertions(+), 34 deletions(-) diff --git a/src/infrastructure/hands/git.py b/src/infrastructure/hands/git.py index 7d51cd22..f62cc9c3 100644 --- a/src/infrastructure/hands/git.py +++ b/src/infrastructure/hands/git.py @@ -71,6 +71,50 @@ class GitHand: return True return False + async def _exec_subprocess( + self, args: str, timeout: int, + ) -> tuple[bytes, bytes, int]: + """Run git as a subprocess, return (stdout, stderr, returncode). + + Raises TimeoutError if the process exceeds *timeout* seconds. + """ + proc = await asyncio.create_subprocess_exec( + "git", + *args.split(), + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=self._repo_dir, + ) + try: + stdout, stderr = await asyncio.wait_for( + proc.communicate(), timeout=timeout, + ) + except TimeoutError: + proc.kill() + await proc.wait() + raise + return stdout, stderr, proc.returncode or 0 + + @staticmethod + def _parse_output( + command: str, + stdout_bytes: bytes, + stderr_bytes: bytes, + returncode: int | None, + latency_ms: float, + ) -> GitResult: + """Decode subprocess output into a GitResult.""" + exit_code = returncode or 0 + stdout = stdout_bytes.decode("utf-8", errors="replace").strip() + stderr = stderr_bytes.decode("utf-8", errors="replace").strip() + return GitResult( + operation=command, + success=exit_code == 0, + output=stdout, + error=stderr if exit_code != 0 else "", + latency_ms=latency_ms, + ) + async def run( self, args: str, @@ -88,14 +132,15 @@ class GitHand: GitResult with output or error details. """ start = time.time() + command = f"git {args}" # Gate destructive operations if self._is_destructive(args) and not allow_destructive: return GitResult( - operation=f"git {args}", + operation=command, success=False, error=( - f"Destructive operation blocked: 'git {args}'. " + f"Destructive operation blocked: '{command}'. " "Set allow_destructive=True to override." ), requires_confirmation=True, @@ -103,46 +148,20 @@ class GitHand: ) effective_timeout = timeout or self._timeout - command = f"git {args}" try: - proc = await asyncio.create_subprocess_exec( - "git", - *args.split(), - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=self._repo_dir, + stdout_bytes, stderr_bytes, returncode = await self._exec_subprocess( + args, effective_timeout, ) - - try: - stdout_bytes, stderr_bytes = await asyncio.wait_for( - proc.communicate(), timeout=effective_timeout - ) - except TimeoutError: - proc.kill() - await proc.wait() - latency = (time.time() - start) * 1000 - logger.warning("Git command timed out after %ds: %s", effective_timeout, command) - return GitResult( - operation=command, - success=False, - error=f"Command timed out after {effective_timeout}s", - latency_ms=latency, - ) - + except TimeoutError: latency = (time.time() - start) * 1000 - exit_code = proc.returncode or 0 - stdout = stdout_bytes.decode("utf-8", errors="replace").strip() - stderr = stderr_bytes.decode("utf-8", errors="replace").strip() - + logger.warning("Git command timed out after %ds: %s", effective_timeout, command) return GitResult( operation=command, - success=exit_code == 0, - output=stdout, - error=stderr if exit_code != 0 else "", + success=False, + error=f"Command timed out after {effective_timeout}s", latency_ms=latency, ) - except FileNotFoundError: latency = (time.time() - start) * 1000 logger.warning("git binary not found") @@ -162,6 +181,12 @@ class GitHand: latency_ms=latency, ) + return self._parse_output( + command, stdout_bytes, stderr_bytes, + returncode=returncode, + latency_ms=(time.time() - start) * 1000, + ) + # ── Convenience wrappers ───────────────────────────────────────────────── async def status(self) -> GitResult: