"""Shared helpers for interacting with git and the Claude sandbox.""" from __future__ import annotations import os import subprocess import sys from typing import Iterable, Sequence class GitError(RuntimeError): """Raised when a git subprocess returns a non-zero exit status.""" def run_git( *args: str, check: bool = True, capture: bool = True, quiet: bool = False, ) -> subprocess.CompletedProcess: """Run `git ` and return the completed-process handle. `check=True` raises `GitError` on non-zero exit. `capture=True` routes stdout/stderr through pipes so callers can inspect output; setting it to False lets git write straight to the terminal (useful for long-running commands like `git push` or `git rebase`). """ cmd: list[str] = ["git", *args] kwargs: dict = {} if capture: kwargs["stdout"] = subprocess.PIPE kwargs["stderr"] = subprocess.PIPE kwargs["text"] = True proc = subprocess.run(cmd, **kwargs) if check and proc.returncode != 0: stderr = (proc.stderr or "") if capture else "" if not quiet: sys.stderr.write(stderr) raise GitError( f"git {' '.join(args)} exited {proc.returncode}: {stderr.strip()}" ) return proc def git_out(*args: str) -> str: """Run a git command that is expected to succeed and return stdout (stripped).""" return run_git(*args).stdout.strip() def has_remote(name: str) -> bool: try: run_git("remote", "get-url", name, quiet=True) return True except GitError: return False def remote_url(name: str) -> str | None: if not has_remote(name): return None return git_out("remote", "get-url", name) def ensure_not_sandboxed(tool_name: str, reason: str) -> None: """Abort the CLI when invoked inside the Claude sandbox. Claude Code exports `CLAUDE_CODE` / `CLAUDECODE` inside the sandbox. Writes to `.git/config` (and `~/.gnupg` access) are blocked there per the Git Safety Protocol, so running these tools inside would always fail late. Fail fast instead. """ if os.environ.get("CLAUDE_CODE") or os.environ.get("CLAUDECODE"): sys.stderr.write( f"ERROR: {tool_name} must run outside the Claude sandbox ({reason}).\n" ) sys.exit(1) def ensure_clean_tree() -> None: if run_git("diff", "--quiet", check=False, capture=False).returncode != 0 \ or run_git("diff", "--cached", "--quiet", check=False, capture=False).returncode != 0: sys.stderr.write( "ERROR: uncommitted changes present. Commit or stash before signing.\n" ) sys.exit(1) def current_branch() -> str: branch = git_out("rev-parse", "--abbrev-ref", "HEAD") if branch == "HEAD": sys.stderr.write("ERROR: detached HEAD.\n") sys.exit(1) return branch def first_existing_rev(candidates: Iterable[str]) -> str | None: for cand in candidates: proc = run_git("rev-parse", "--verify", "--quiet", cand, check=False) if proc.returncode == 0: return cand return None def resolve_env_or_arg( arg_value: str | None, env_var: str, label: str ) -> str | None: """Return the first non-empty value from the CLI arg or the env var.""" if arg_value: return arg_value value = os.environ.get(env_var, "").strip() return value or None def abbrev_ref(rev: str) -> str | None: proc = run_git( "rev-parse", "--abbrev-ref", "--symbolic-full-name", rev, check=False ) if proc.returncode != 0: return None value = (proc.stdout or "").strip() return value or None def rev_count(range_spec: str) -> int: return int(git_out("rev-list", "--count", range_spec)) def format_cmd_preview(cmd: Sequence[str]) -> str: """Render a command list for the help messages that tell the user what ran.""" return " ".join(cmd)