""" Git command execution utility. Single source of truth for running git commands across all Trellis scripts. """ from __future__ import annotations import subprocess import time from pathlib import Path # Bounded retry for transient `.git/index.lock` contention. Another process # (IDE git integration, status daemon, a concurrent Trellis session) can hold # the lock for a fraction of a second; three attempts spread over ~1.5s ride # that out without making a genuinely stuck lock hang the command. One sleep # per retry, so the attempt count follows the backoff tuple. INDEX_LOCK_RETRY_BACKOFF = (0.5, 1.0) INDEX_LOCK_RETRY_ATTEMPTS = len(INDEX_LOCK_RETRY_BACKOFF) + 1 # Whether a checkout is a linked worktree cannot change while a script runs, and # the answer costs two subprocesses. Developer-identity resolution asks several # times per command whenever the local `.developer` file is absent, so memoize. _CACHE_MISS = object() _MAIN_WORKTREE_CACHE: dict[Path, Path | None] = {} def run_git( args: list[str], cwd: Path | None = None, timeout: float | None = None, ) -> tuple[int, str, str]: """Run a git command and return (returncode, stdout, stderr). Uses UTF-8 encoding with -c i18n.logOutputEncoding=UTF-8 to ensure consistent output across all platforms (Windows, macOS, Linux). Callers may provide a timeout for best-effort probes; normal Git operations remain unbounded by default. """ try: git_args = ["git", "-c", "i18n.logOutputEncoding=UTF-8"] + args result = subprocess.run( git_args, cwd=cwd, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout, ) return result.returncode, result.stdout, result.stderr except Exception as e: return 1, "", str(e) def stderr_indicates_index_lock(stderr: str) -> bool: """git failed because another process holds `.git/index.lock`.""" if not stderr: return False return "index.lock" in stderr.lower() def run_git_retry_index_lock( args: list[str], cwd: Path | None = None, timeout: float | None = None, ) -> tuple[int, str, str]: """Run a git command, retrying only while `.git/index.lock` is held. Any other non-zero exit returns immediately — a retry loop around real failures (bad path, nothing to commit, hook rejection) just delays the error. Returns the last (returncode, stdout, stderr). """ rc, out, err = run_git(args, cwd=cwd, timeout=timeout) attempt = 1 while ( rc != 0 and attempt < INDEX_LOCK_RETRY_ATTEMPTS and stderr_indicates_index_lock(err) ): time.sleep(INDEX_LOCK_RETRY_BACKOFF[attempt - 1]) attempt += 1 rc, out, err = run_git(args, cwd=cwd, timeout=timeout) return rc, out, err def index_lock_path(repo_root: Path) -> str: """Path of the lock file git is contending on, for diagnostics. Asks git so worktrees and `GIT_DIR` setups name the real file rather than a `.git/` guess that does not exist there. """ rc, out, _ = run_git(["rev-parse", "--git-path", "index.lock"], cwd=repo_root) if rc == 0 and out.strip(): return out.strip() return ".git/index.lock" def resolve_default_branch(repo_root: Path) -> str | None: """Resolve the repository's default branch (origin/HEAD target). Tries the local `refs/remotes/origin/HEAD` symbolic ref first (no network access), then falls back to `git remote show origin` (which may hit the network but also repairs a missing/stale symbolic-ref). Returns None when neither resolves, so callers can fall back to their own pre-existing behavior. """ rc, out, _ = run_git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd=repo_root) if rc == 0 and out.strip(): return out.strip().rsplit("/", 1)[-1] rc, out, _ = run_git(["remote", "show", "origin"], cwd=repo_root) if rc == 0: for line in out.splitlines(): line = line.strip() if line.startswith("HEAD branch:"): branch = line.split(":", 1)[1].strip() if branch and branch != "(unknown)": return branch return None def current_branch_name(repo_root: Path) -> str | None: """Return the checked-out branch name, or None when there isn't one. Empty output covers detached HEAD and "not a git repository" alike, and callers treat both the same way: there is no branch worth recording. """ rc, out, _ = run_git(["branch", "--show-current"], cwd=repo_root) if rc != 0: return None return out.strip() or None def has_git_remote(repo_root: Path) -> bool: """Whether the repository has at least one configured remote.""" rc, out, _ = run_git(["remote"], cwd=repo_root) return rc == 0 and bool(out.strip()) def main_worktree_root(repo_root: Path) -> Path | None: """Root of the main working tree when `repo_root` is a linked worktree. Returns None in the main working tree itself, outside a git repository, and for a linked worktree of a bare repository (no main checkout to point at). `git worktree list --porcelain` reports the main working tree as its first record, so git identifies it rather than this code deriving it from the `.git` layout. Deriving it — taking the parent of `--git-common-dir` — is wrong for a bare repository that happens to sit inside an unrelated checkout (`~/repos/project.git` under a `~/repos` that is itself a repo): the parent is a real checkout with a real `.developer`, so the guess is indistinguishable from a hit and identity leaks across repositories. """ cached = _MAIN_WORKTREE_CACHE.get(repo_root, _CACHE_MISS) if cached is not _CACHE_MISS: return cached # type: ignore[return-value] result = _probe_main_worktree_root(repo_root) _MAIN_WORKTREE_CACHE[repo_root] = result return result def _probe_main_worktree_root(repo_root: Path) -> Path | None: rc_list, listing, _ = run_git(["worktree", "list", "--porcelain"], cwd=repo_root) rc_top, toplevel, _ = run_git(["rev-parse", "--show-toplevel"], cwd=repo_root) if rc_list != 0 or rc_top != 0: return None lines = listing.splitlines() if not lines or not lines[0].startswith("worktree "): return None # Records are blank-line separated; a `bare` attribute on the first one # means the "main working tree" is a bare repo with nothing to inherit. for line in lines[1:]: if not line.strip(): break if line.strip() == "bare": return None try: main_root = Path(lines[0][len("worktree ") :].strip()).resolve() current_root = Path(toplevel.strip()).resolve() except (OSError, ValueError): return None if main_root == current_root: return None return main_root def branch_exists_locally(branch: str, repo_root: Path) -> bool: """Check whether a local branch ref exists in the repository.""" if not branch: return False rc, _, _ = run_git( ["rev-parse", "--verify", "--quiet", f"refs/heads/{branch}"], cwd=repo_root, ) return rc == 0