oc_chat
Loading...
Searching...
No Matches
git_shim.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/round/git_shim.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/test_round_*.py"
6"""
7Mockable git shell-out seam for round-close orchestration.
8
9Purpose
10-------
11Wrap the handful of ``git`` invocations the round-close executors need
12behind one injectable seam so tests never shell out to a real repo.
13
14Responsibilities
15----------------
16- Run git subcommands in a fixed repository directory.
17- Surface conflict state from ``git merge`` without raising.
18- Expose ancestry and fast-forward helpers used by ff_main / consolidate.
19
20Diagnostics
21-----------
22Domain: OC_CHAT.MAIN
23Levels:
24 L2 - lifecycle events (branch create, merge, ff)
25 L3 - command and exit-code details
26 L4 - deep tracing
27
28Contracts
29---------
30- No method mutates the registry database; git is the only side effect.
31- The injected ``runner`` matches the ``subprocess.run`` signature.
32- ``merge`` never raises on conflict; callers inspect ``MergeResult``.
33
34Dependencies
35------------
36- oc_diagnostics (structured debug logging via _dbg)
37- subprocess (subprocess.run - the default git runner)
38- pathlib (the repository directory)
39"""
40from __future__ import annotations
41
42from dataclasses import dataclass, field
43from pathlib import Path
44import subprocess
45from typing import Callable, Sequence
46
47from oc_diagnostics import _dbg
48
49
50_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
51
52Runner = Callable[..., "subprocess.CompletedProcess[str]"]
53
54
55@dataclass(frozen=True)
57 """Outcome of a single git invocation."""
58
59 returncode: int
60 stdout: str
61 stderr: str
62
63 @property
64 def ok(self) -> bool:
65 """True when the git command exited zero."""
66
67 return self.returncode == 0
68
69
70@dataclass(frozen=True)
72 """Result of a ``git merge`` attempt."""
73
74 ok: bool
75 head_sha: str
76 conflicted: bool = False
77 conflicted_paths: tuple[str, ...] = field(default_factory=tuple)
78 already_up_to_date: bool = False
79
80
81class GitError(RuntimeError):
82 """Raised when a git command that must succeed exits non-zero."""
83
84 def __init__(self, args: Sequence[str], result: GitResult) -> None:
85 self.args_run = tuple(args)
86 self.result = result
87 super().__init__(
88 f"git {' '.join(args)} failed ({result.returncode}): "
89 f"{result.stderr.strip() or result.stdout.strip()}"
90 )
91
92
93class GitShim:
94 """Thin, injectable wrapper over the git CLI for round-close."""
95
96 def __init__(self, repo_dir: Path, *, runner: Runner | None = None) -> None:
97 self.repo_dir = Path(repo_dir)
98 self._runner: Runner = runner if runner is not None else subprocess.run
99
100 def run(self, *args: str) -> GitResult:
101 """Run ``git <args>`` in the repo dir and capture the result."""
102
103 func = "run"
104 completed = self._runner(
105 ["git", *args],
106 cwd=str(self.repo_dir),
107 capture_output=True,
108 text=True,
109 check=False,
110 )
111 result = GitResult(
112 returncode=int(getattr(completed, "returncode", 0)),
113 stdout=str(getattr(completed, "stdout", "") or ""),
114 stderr=str(getattr(completed, "stderr", "") or ""),
115 )
116 _dbg("OC_CHAT.MAIN", func, f"git {' '.join(args)} -> {result.returncode}", 3)
117 return result
118
119 def _checked(self, *args: str) -> GitResult:
120 func = "_checked"
121 result = self.run(*args)
122 if not result.ok:
123 _dbg("OC_CHAT.MAIN", func, f"git {' '.join(args)} non-zero", 2)
124 raise GitError(args, result)
125 return result
126
127 def rev_parse(self, ref: str = "HEAD") -> str:
128 """Return the full SHA for ``ref``."""
129
130 func = "rev_parse"
131 result = self._checked("rev-parse", ref)
132 _dbg("OC_CHAT.MAIN", func, f"resolved {ref}", 4)
133 return result.stdout.strip()
134
135 def current_branch(self) -> str:
136 """Return the current branch name (or empty for detached HEAD)."""
137
138 func = "current_branch"
139 result = self.run("rev-parse", "--abbrev-ref", "HEAD")
140 _dbg("OC_CHAT.MAIN", func, "queried branch", 4)
141 return result.stdout.strip()
142
143 def branch_exists(self, name: str) -> bool:
144 """True when a local branch ``name`` exists."""
145
146 func = "branch_exists"
147 result = self.run("rev-parse", "--verify", "--quiet", f"refs/heads/{name}")
148 _dbg("OC_CHAT.MAIN", func, f"exists={result.ok} {name}", 4)
149 return result.ok
150
151 def create_branch(self, name: str, start_point: str) -> str:
152 """Create ``name`` at ``start_point`` and check it out; return HEAD."""
153
154 func = "create_branch"
155 if self.branch_exists(name):
156 self._checked("checkout", name)
157 else:
158 self._checked("checkout", "-b", name, start_point)
159 _dbg("OC_CHAT.MAIN", func, f"on {name}", 2)
160 return self.rev_parse("HEAD")
161
162 def checkout(self, ref: str) -> None:
163 """Check out ``ref``."""
164
165 func = "checkout"
166 self._checked("checkout", ref)
167 _dbg("OC_CHAT.MAIN", func, f"checked out {ref}", 3)
168
169 def merge(
170 self,
171 branch: str,
172 *,
173 no_ff: bool = True,
174 message: str | None = None,
175 ) -> MergeResult:
176 """Merge ``branch`` into the current branch; report conflicts."""
177
178 func = "merge"
179 args = ["merge"]
180 if no_ff:
181 args.append("--no-ff")
182 if message is not None:
183 args.extend(["-m", message])
184 args.append(branch)
185 result = self.run(*args)
186 if result.ok:
187 up_to_date = "Already up to date" in result.stdout
188 _dbg("OC_CHAT.MAIN", func, f"merged {branch} up_to_date={up_to_date}", 2)
189 return MergeResult(
190 ok=True,
191 head_sha=self.rev_parse("HEAD"),
192 already_up_to_date=up_to_date,
193 )
194 conflicted_paths = self._conflicted_paths()
195 _dbg(
196 "OC_CHAT.MAIN",
197 func,
198 f"merge {branch} failed conflicts={len(conflicted_paths)}",
199 2,
200 )
201 return MergeResult(
202 ok=False,
203 head_sha=self.rev_parse("HEAD"),
204 conflicted=bool(conflicted_paths),
205 conflicted_paths=conflicted_paths,
206 )
207
208 def _conflicted_paths(self) -> tuple[str, ...]:
209 func = "_conflicted_paths"
210 result = self.run("diff", "--name-only", "--diff-filter=U")
211 paths = tuple(p for p in result.stdout.splitlines() if p.strip())
212 _dbg("OC_CHAT.MAIN", func, f"{len(paths)} conflicted", 4)
213 return paths
214
215 def merge_abort(self) -> None:
216 """Abort an in-progress merge."""
217
218 func = "merge_abort"
219 self.run("merge", "--abort")
220 _dbg("OC_CHAT.MAIN", func, "merge aborted", 2)
221
222 def checkout_path(self, side: str, path: str) -> None:
223 """Resolve a single conflicted ``path`` by taking ``side``."""
224
225 func = "checkout_path"
226 flag = "--theirs" if side == "current" else "--ours"
227 self._checked("checkout", flag, "--", path)
228 self._checked("add", "--", path)
229 _dbg("OC_CHAT.MAIN", func, f"took {side} for {path}", 3)
230
231 def is_ancestor(self, maybe_ancestor: str, descendant: str) -> bool:
232 """True when ``maybe_ancestor`` is an ancestor of ``descendant``."""
233
234 func = "is_ancestor"
235 result = self.run(
236 "merge-base", "--is-ancestor", maybe_ancestor, descendant
237 )
238 _dbg("OC_CHAT.MAIN", func, f"ancestor={result.ok}", 4)
239 return result.ok
240
241 def merge_base(self, a: str, b: str) -> str:
242 """Return the merge base SHA of two refs."""
243
244 func = "merge_base"
245 result = self._checked("merge-base", a, b)
246 _dbg("OC_CHAT.MAIN", func, "computed merge-base", 4)
247 return result.stdout.strip()
248
249 def fast_forward(self, branch: str, to_sha: str) -> str:
250 """Fast-forward ``branch`` to ``to_sha``; return the new HEAD."""
251
252 func = "fast_forward"
253 self._checked("checkout", branch)
254 self._checked("merge", "--ff-only", to_sha)
255 head = self.rev_parse("HEAD")
256 _dbg("OC_CHAT.MAIN", func, f"{branch} -> {head}", 2)
257 return head
258
259 def branch_delete(self, name: str, *, force: bool = False) -> bool:
260 """Delete a local branch; return True on success."""
261
262 func = "branch_delete"
263 flag = "-D" if force else "-d"
264 result = self.run("branch", flag, name)
265 _dbg("OC_CHAT.MAIN", func, f"delete {name} ok={result.ok}", 2)
266 return result.ok
267
268 def worktree_remove(self, path: str, *, force: bool = False) -> bool:
269 """Remove a git worktree; return True on success."""
270
271 func = "worktree_remove"
272 args = ["worktree", "remove"]
273 if force:
274 args.append("--force")
275 args.append(path)
276 result = self.run(*args)
277 _dbg("OC_CHAT.MAIN", func, f"remove worktree {path} ok={result.ok}", 2)
278 return result.ok
None __init__(self, Sequence[str] args, GitResult result)
Definition git_shim.py:84
bool worktree_remove(self, str path, *, bool force=False)
Definition git_shim.py:268
GitResult run(self, *str args)
Definition git_shim.py:100
str merge_base(self, str a, str b)
Definition git_shim.py:241
GitResult _checked(self, *str args)
Definition git_shim.py:119
bool is_ancestor(self, str maybe_ancestor, str descendant)
Definition git_shim.py:231
MergeResult merge(self, str branch, *, bool no_ff=True, str|None message=None)
Definition git_shim.py:175
str fast_forward(self, str branch, str to_sha)
Definition git_shim.py:249
None __init__(self, Path repo_dir, *, Runner|None runner=None)
Definition git_shim.py:96
str create_branch(self, str name, str start_point)
Definition git_shim.py:151
None checkout_path(self, str side, str path)
Definition git_shim.py:222
bool branch_delete(self, str name, *, bool force=False)
Definition git_shim.py:259
tuple[str,...] _conflicted_paths(self)
Definition git_shim.py:208
str rev_parse(self, str ref="HEAD")
Definition git_shim.py:127