Coverage for src/keel/git.py: 100%
43 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
1"""Thin, fail-soft ``git`` wrappers (argv, no shell).
3These build the exact git command for each backbone operation and run it via the
4injectable ``_run`` seam, so the command construction is unit-tested offline; live
5behaviour is exercised opt-in against a real repo. Each returns a
6:class:`keel.runner.CommandResult` (or a parsed value), never raising.
7"""
9from __future__ import annotations
11import re
13from .runner import CommandResult, run_argv
16def fetch(remote: str, ref: str, *, cwd: str | None = None, _run=None) -> CommandResult:
17 return run_argv(["git", "fetch", remote, ref, "--quiet"], cwd=cwd, **_kw(_run))
20def worktree_add(
21 base: str, branch: str, path: str, *, cwd: str | None = None, _run=None
22) -> CommandResult:
23 return run_argv(["git", "worktree", "add", "-b", branch, path, base], cwd=cwd, **_kw(_run))
26def worktree_remove(path: str, *, cwd: str | None = None, _run=None) -> CommandResult:
27 return run_argv(["git", "worktree", "remove", path, "--force"], cwd=cwd, **_kw(_run))
30def worktree_list(*, cwd: str | None = None, _run=None) -> CommandResult:
31 return run_argv(["git", "worktree", "list", "--porcelain"], cwd=cwd, **_kw(_run))
34def current_branch(*, cwd: str | None = None, _run=None) -> str | None:
35 result = run_argv(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd, **_kw(_run))
36 return result.stdout.strip() if result.ok else None
39def list_branches(*, cwd: str | None = None, _run=None) -> CommandResult:
40 """List local + remote branch short names (one per line) as a ``CommandResult``.
42 Returns the raw result (like :func:`worktree_list`) rather than a parsed
43 fail-soft list, so a caller that needs to *distinguish a git error from an
44 empty repo* — e.g. dry-run integrity verification, which must fail closed
45 when it cannot observe — can inspect ``result.ok``. Parsing is the caller's.
46 """
47 return run_argv(
48 ["git", "for-each-ref", "--format=%(refname:short)",
49 "refs/heads", "refs/remotes"],
50 cwd=cwd, **_kw(_run),
51 )
54#: A 40- or 64-hex object name (SHA-1 / SHA-256). git may print a ``warning:`` to
55#: stderr while still succeeding; reading ``stdout`` avoids the contamination, and
56#: validating the shape is a second line of defence so a stray token never poses as a SHA.
57_SHA_RE = re.compile(r"\A[0-9a-f]{40}(?:[0-9a-f]{24})?\Z")
60def rev_parse(ref: str, *, cwd: str | None = None, _run=None) -> str | None:
61 """Resolve ``ref`` to a full commit SHA; ``None`` when it cannot be resolved."""
62 result = run_argv(["git", "rev-parse", "--verify", "--quiet", ref], cwd=cwd, **_kw(_run))
63 output = result.stdout.strip()
64 return output if result.ok and _SHA_RE.match(output) else None
67def merge_base(a: str, b: str, *, cwd: str | None = None, _run=None) -> str | None:
68 """Best common ancestor of ``a`` and ``b``; ``None`` when there is none/on error."""
69 result = run_argv(["git", "merge-base", a, b], cwd=cwd, **_kw(_run))
70 output = result.stdout.strip()
71 return output if result.ok and _SHA_RE.match(output) else None
74def rev_count(base: str, head: str, *, cwd: str | None = None, _run=None) -> int | None:
75 """Commits in ``base..head`` (how far ``head`` is ahead of ``base``); ``None`` on error."""
76 result = run_argv(
77 ["git", "rev-list", "--count", f"{base}..{head}"], cwd=cwd, **_kw(_run)
78 )
79 if not result.ok:
80 return None
81 output = result.stdout.strip()
82 if not output.isdigit():
83 return None
84 return int(output)
87def changed_files(base: str, head: str, *, cwd: str | None = None, _run=None) -> list[str] | None:
88 """Files changed between ``base`` and ``head`` (``base...head``).
90 ``None`` when the git command failed — deliberately distinct from ``[]`` (the
91 command ran and there were no changes), so a caller classifying risk or checking
92 scope can tell "could not read the diff" apart from "the diff is empty" instead of
93 treating an unreadable diff as a clean, empty one.
94 """
95 result = run_argv(["git", "diff", "--name-only", f"{base}...{head}"], cwd=cwd, **_kw(_run))
96 if not result.ok:
97 return None
98 return [line for line in result.stdout.splitlines() if line.strip()]
101def diff(base: str, head: str, *, cwd: str | None = None, _run=None) -> str | None:
102 """The unified diff between ``base`` and ``head`` (``base...head``).
104 ``None`` when the git command failed — distinct from ``""`` (the command ran and
105 the diff is empty), so a review/gate caller can refuse to treat an unreadable diff
106 as "nothing to review".
107 """
108 result = run_argv(["git", "diff", f"{base}...{head}"], cwd=cwd, **_kw(_run))
109 return result.stdout if result.ok else None
112def _kw(_run):
113 """Pass ``_run`` through only when provided (so the default subprocess is used otherwise)."""
114 return {"_run": _run} if _run is not None else {}