Coverage for src/keel/git.py: 100%

109 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-02 20:26 +0000

1"""Thin, fail-soft ``git`` wrappers (argv, no shell). 

2 

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""" 

8 

9from __future__ import annotations 

10 

11import os 

12import re 

13 

14from . import tdd 

15from .runner import CommandResult, run_argv 

16 

17 

18def remote_tracking_ref(remote: str, branch: str) -> str: 

19 """``refs/remotes/<remote>/<branch>``: the remote-tracking ref, spelled in full. 

20 

21 **Never the short ``<remote>/<branch>``.** git resolves a short name through 

22 ``refs/tags/`` and ``refs/heads/`` *before* ``refs/remotes/``, so a tag or a local branch 

23 literally named ``origin/main`` answers in its place — and a tag arrives unasked with any 

24 fetch of a remote whose history carries it. The warning git prints goes to stderr, which 

25 :func:`rev_parse` does not read, so nothing notices. 

26 

27 Spelled in full, the name finds that ref whenever it exists. When it does **not**, git 

28 still falls back through ``refs/tags/`` and ``refs/heads/`` — a local branch literally 

29 named ``refs/remotes/origin/main`` answers ``rev-parse`` then (measured) — so ask 

30 :func:`resolve_ref`, which matches the exact ref or nothing (#1223). 

31 """ 

32 return f"refs/remotes/{remote}/{branch}" 

33 

34 

35def resolve_ref(ref: str, *, cwd: str | None = None, _run=None) -> str | None: 

36 """The object a fully spelled ``ref`` names, or ``None`` when that exact ref is absent. 

37 

38 ``show-ref --verify`` takes the name as the complete ref, with no fallback: when that ref 

39 is missing it answers nothing, where :func:`rev_parse` goes on to try ``refs/tags/<ref>`` 

40 and ``refs/heads/<ref>`` (#1223). 

41 

42 The answer is whatever object the ref names. That is usually a commit, but git does not 

43 guarantee it: a symbolic ref, a ref set by hand, or a fetched branch that is itself one of 

44 those can name an annotated tag (each measured). A caller that needs a commit must still 

45 get one; the landing's ``commit-tree`` refuses a parent that is not (#1225). 

46 """ 

47 result = run_argv(["git", "show-ref", "--verify", "--hash", ref], cwd=cwd, **_kw(_run)) 

48 output = result.stdout.strip() 

49 return output if result.ok and _SHA_RE.match(output) else None 

50 

51 

52def remote_url(remote: str, *, cwd: str | None = None, _run=None) -> str | None: 

53 """The fetch URL configured for ``remote``; ``None`` when no such remote is configured. 

54 

55 git reads an unconfigured remote name as a **path**: ``git fetch origin`` with no remote 

56 called ``origin`` fetches from a directory of that name, and ``git push`` to it runs that 

57 repository's hooks. A command that means "the remote" asks this first (#1223). 

58 """ 

59 result = run_argv(["git", "config", "--get", f"remote.{remote}.url"], cwd=cwd, **_kw(_run)) 

60 output = result.stdout.strip() 

61 return output if result.ok and output else None 

62 

63 

64def owner_repo_from_url(url: str | None) -> tuple[str, str] | None: 

65 """``(owner, repo)`` parsed from a git remote URL, or ``None`` when it holds no such pair. 

66 

67 Handles the three shapes git hands out — scp-style ``git@host:owner/repo.git``, and 

68 ``https://host/owner/repo(.git)`` / ``ssh://git@host/owner/repo(.git)`` — by taking the 

69 last two path segments after dropping a single trailing ``.git``. Host-agnostic on 

70 purpose: GitHub, a GHE host and a self-hosted forge all read the same, and the result only 

71 fills ``owner``/``repo`` in a scaffolded config for the operator to see and correct. 

72 """ 

73 if not isinstance(url, str) or not url.strip(): 

74 return None 

75 text = url.strip() 

76 if text.endswith(".git"): 

77 text = text[:-4] 

78 scp = re.match(r"^[^/@]+@[^:/]+:(?P<path>.+)$", text) # git@host:owner/repo 

79 path = scp.group("path") if scp else re.sub(r"^[a-zA-Z][a-zA-Z0-9+.-]*://[^/]+", "", text) 

80 parts = [segment for segment in path.split("/") if segment] 

81 if len(parts) < 2: 

82 return None 

83 return parts[-2], parts[-1] 

84 

85 

86def fetch(remote: str, ref: str, *, cwd: str | None = None, _run=None) -> CommandResult: 

87 """Fetch one branch of ``remote`` into :func:`remote_tracking_ref`. 

88 

89 The branch goes as ``refs/heads/<ref>``, never bare: a positional argument that begins 

90 with ``-`` is an *option* to git, and ``--upload-pack=<program>`` among those runs a 

91 program. The landing plan already refuses such a name; this keeps the wrapper from being 

92 the thing that makes a stray value dangerous. 

93 

94 **The destination is named, not left to the remote's configured refspec.** The landing 

95 resolves exactly that ref next, so what it builds on is what this fetch downloaded on any 

96 configuration — a remote whose ``fetch`` refspec does not map the branch would otherwise 

97 leave the ref where an older fetch put it. ``+`` because the branch may have been 

98 rewritten, which is what the default refspec allows too. 

99 """ 

100 return run_argv( 

101 [ 

102 "git", 

103 "fetch", 

104 "--quiet", 

105 remote, 

106 f"+refs/heads/{ref}:{remote_tracking_ref(remote, ref)}", 

107 ], 

108 cwd=cwd, 

109 **_kw(_run), 

110 ) 

111 

112 

113def worktree_add( 

114 base: str, branch: str, path: str, *, cwd: str | None = None, _run=None 

115) -> CommandResult: 

116 return run_argv(["git", "worktree", "add", "-b", branch, path, base], cwd=cwd, **_kw(_run)) 

117 

118 

119def worktree_remove(path: str, *, cwd: str | None = None, _run=None) -> CommandResult: 

120 return run_argv(["git", "worktree", "remove", path, "--force"], cwd=cwd, **_kw(_run)) 

121 

122 

123def worktree_list(*, cwd: str | None = None, _run=None) -> CommandResult: 

124 return run_argv(["git", "worktree", "list", "--porcelain"], cwd=cwd, **_kw(_run)) 

125 

126 

127def current_branch(*, cwd: str | None = None, _run=None) -> str | None: 

128 result = run_argv(["git", "rev-parse", "--abbrev-ref", "HEAD"], cwd=cwd, **_kw(_run)) 

129 return result.stdout.strip() if result.ok else None 

130 

131 

132def list_branches(*, cwd: str | None = None, _run=None) -> CommandResult: 

133 """List local + remote branch short names (one per line) as a ``CommandResult``. 

134 

135 Returns the raw result (like :func:`worktree_list`) rather than a parsed 

136 fail-soft list, so a caller that needs to *distinguish a git error from an 

137 empty repo* — e.g. dry-run integrity verification, which must fail closed 

138 when it cannot observe — can inspect ``result.ok``. Parsing is the caller's. 

139 """ 

140 return run_argv( 

141 ["git", "for-each-ref", "--format=%(refname:short)", "refs/heads", "refs/remotes"], 

142 cwd=cwd, 

143 **_kw(_run), 

144 ) 

145 

146 

147#: A 40- or 64-hex object name (SHA-1 / SHA-256). git may print a ``warning:`` to 

148#: stderr while still succeeding; reading ``stdout`` avoids the contamination, and 

149#: validating the shape is a second line of defence so a stray token never poses as a SHA. 

150_SHA_RE = re.compile(r"\A[0-9a-f]{40}(?:[0-9a-f]{24})?\Z") 

151 

152 

153def rev_parse(ref: str, *, cwd: str | None = None, _run=None) -> str | None: 

154 """Resolve ``ref`` to a full commit SHA; ``None`` when it cannot be resolved.""" 

155 result = run_argv(["git", "rev-parse", "--verify", "--quiet", ref], cwd=cwd, **_kw(_run)) 

156 output = result.stdout.strip() 

157 return output if result.ok and _SHA_RE.match(output) else None 

158 

159 

160def merge_base(a: str, b: str, *, cwd: str | None = None, _run=None) -> str | None: 

161 """Best common ancestor of ``a`` and ``b``; ``None`` when there is none/on error.""" 

162 result = run_argv(["git", "merge-base", a, b], cwd=cwd, **_kw(_run)) 

163 output = result.stdout.strip() 

164 return output if result.ok and _SHA_RE.match(output) else None 

165 

166 

167def rev_count(base: str, head: str, *, cwd: str | None = None, _run=None) -> int | None: 

168 """Commits in ``base..head`` (how far ``head`` is ahead of ``base``); ``None`` on error.""" 

169 result = run_argv(["git", "rev-list", "--count", f"{base}..{head}"], cwd=cwd, **_kw(_run)) 

170 if not result.ok: 

171 return None 

172 output = result.stdout.strip() 

173 if not output.isdigit(): 

174 return None 

175 return int(output) 

176 

177 

178def changed_files(base: str, head: str, *, cwd: str | None = None, _run=None) -> list[str] | None: 

179 """Files changed between ``base`` and ``head`` (``base...head``). 

180 

181 ``None`` when the git command failed — deliberately distinct from ``[]`` (the 

182 command ran and there were no changes), so a caller classifying risk or checking 

183 scope can tell "could not read the diff" apart from "the diff is empty" instead of 

184 treating an unreadable diff as a clean, empty one. 

185 """ 

186 result = run_argv(["git", "diff", "--name-only", f"{base}...{head}"], cwd=cwd, **_kw(_run)) 

187 if not result.ok: 

188 return None 

189 return [line for line in result.stdout.splitlines() if line.strip()] 

190 

191 

192def commit_log(base: str, head: str, *, cwd: str | None = None, _run=None) -> str | None: 

193 """Raw ``git log`` for ``base..head``: this branch's own commits, oldest first. 

194 

195 Returns git's stdout verbatim — :func:`keel.tdd.parse_commits` turns it into records, 

196 so the parsing is pure and unit-tested rather than living behind a subprocess. ``None`` 

197 when the command failed, deliberately distinct from ``""`` (the range is empty): the 

198 ``tdd-order`` gate must be able to block on "we could not read the history" instead of 

199 reading it as "this branch has no commits". 

200 

201 Four flags carry the whole meaning of "this implementer's commit order", and each is 

202 load-bearing: 

203 

204 ``base..head`` 

205 not ``base...head`` — the symmetric form would also list the base's side. 

206 ``--topo-order`` 

207 git's default is *commit-date* order. Once the branch integrates its base (ship 

208 s10), a base commit dated before the tests commit sorts ahead of it and becomes 

209 the "first commit" — so the same topology passed or blocked depending on nothing 

210 but timestamps. Topological order asks about ancestry, which is what was meant. 

211 ``--first-parent`` 

212 follows only this branch's own line through its merges, dropping the commits a 

213 base merge brought in. Without it a stale local ``base`` ref leaves those commits 

214 inside the range, and one of them can be judged as this implementer's first 

215 commit. The merge commits themselves stay on the chain and 

216 :func:`keel.tdd.check_order` skips them. 

217 ``--name-status`` 

218 not ``--name-only`` — a name alone cannot tell an addition from a deletion, and 

219 the gate has to separate "wrote the failing tests" from ``git rm`` over them. 

220 

221 ``--reverse`` is applied after ordering and selection, so the output is oldest-first 

222 within the topological order. 

223 """ 

224 result = run_argv( 

225 [ 

226 "git", 

227 "log", 

228 "--topo-order", 

229 "--first-parent", 

230 "--reverse", 

231 "--no-color", 

232 f"--format={tdd.LOG_FORMAT}", 

233 "--name-status", 

234 f"{base}..{head}", 

235 ], 

236 cwd=cwd, 

237 **_kw(_run), 

238 ) 

239 return result.stdout if result.ok else None 

240 

241 

242def diff(base: str, head: str, *, cwd: str | None = None, _run=None) -> str | None: 

243 """The unified diff between ``base`` and ``head`` (``base...head``). 

244 

245 ``None`` when the git command failed — distinct from ``""`` (the command ran and 

246 the diff is empty), so a review/gate caller can refuse to treat an unreadable diff 

247 as "nothing to review". 

248 """ 

249 result = run_argv(["git", "diff", f"{base}...{head}"], cwd=cwd, **_kw(_run)) 

250 return result.stdout if result.ok else None 

251 

252 

253def revert_diff(base: str, head: str, *, cwd: str | None = None, _run=None) -> str | None: 

254 """The diff the ``revert-check`` gate splits into changes (``base...head``, #1289). 

255 

256 Every setting that changes the *shape* of the output is pinned, because the parser is 

257 :func:`keel.revertcheck.parse_diff` and each hunk is handed back to ``git apply -R``: 

258 no colour or external diff driver, ``a/``/``b/`` prefixes whatever ``diff.noprefix`` or 

259 ``diff.mnemonicPrefix`` say, no rename detection (a rename is a deletion and an 

260 addition, each revertible alone), unquoted non-ASCII paths, and a blank context line 

261 written as a space. ``None`` when git failed — distinct from ``""``, an empty diff. 

262 

263 **No context lines** (``--unified=0``): with git's default three, two edits six lines 

264 apart merge into one hunk, and a per-hunk check then passes when a test notices either 

265 of them — the #871 shape, measured on this gate's own first smoke run. Zero context 

266 splits every contiguous edit into its own hunk; :func:`apply_reverse` applies them. 

267 

268 **The user's configuration cannot widen or rename a hunk.** ``--unified=0`` does not 

269 override ``diff.interHunkContext`` (a value of 10000 merged every edit in a file into 

270 one change), so it is pinned too, with every other setting the parser depends on: 

271 prefixes (``diff.noprefix``, ``diff.mnemonicPrefix``), paths (``diff.relative``, 

272 ``core.quotePath``), the diff algorithm, the submodule format, textconv and external 

273 drivers. ``GIT_DIFF_OPTS`` outranks even the command line, so it is removed from the 

274 child's environment. :func:`keel.revertcheck.context_problem` still refuses a diff 

275 whose hunks carry context, whatever widened them. 

276 

277 **Line endings are kept** (``keep_line_endings``): read in text mode, a CRLF source's 

278 ``\\r`` was dropped, and the reverse patch no longer matched ``HEAD``'s bytes. 

279 """ 

280 result = run_argv( 

281 [ 

282 "git", 

283 "-c", 

284 "core.quotePath=off", 

285 "-c", 

286 "diff.suppressBlankEmpty=false", 

287 "-c", 

288 "diff.interHunkContext=0", 

289 "-c", 

290 "diff.noprefix=false", 

291 "-c", 

292 "diff.mnemonicPrefix=false", 

293 "-c", 

294 "diff.relative=false", 

295 "diff", 

296 "--no-color", 

297 "--no-ext-diff", 

298 "--no-textconv", 

299 "--no-renames", 

300 "--no-relative", 

301 "--unified=0", 

302 "--inter-hunk-context=0", 

303 "--diff-algorithm=myers", 

304 "--submodule=short", 

305 "--src-prefix=a/", 

306 "--dst-prefix=b/", 

307 f"{base}...{head}", 

308 ], 

309 cwd=cwd, 

310 env={key: value for key, value in os.environ.items() if key != "GIT_DIFF_OPTS"}, 

311 keep_line_endings=True, 

312 **_kw(_run), 

313 ) 

314 return result.stdout if result.ok else None 

315 

316 

317#: What ``git rev-parse --local-env-vars`` lists: the variables that tie a git command to 

318#: one repository, work tree or index instead of the directory it runs in. keel run from a 

319#: git hook inherits ``GIT_DIR``, and a wrapper may set ``GIT_WORK_TREE``; passed to a 

320#: command meant for the scratch worktree, they aimed ``reset --hard`` and ``clean -fdx`` 

321#: at the operator's checkout (#1289 review). 

322REPO_ENV_VARS = frozenset( 

323 { 

324 "GIT_ALTERNATE_OBJECT_DIRECTORIES", 

325 "GIT_CONFIG", 

326 "GIT_CONFIG_PARAMETERS", 

327 "GIT_CONFIG_COUNT", 

328 "GIT_OBJECT_DIRECTORY", 

329 "GIT_DIR", 

330 "GIT_WORK_TREE", 

331 "GIT_IMPLICIT_WORK_TREE", 

332 "GIT_GRAFT_FILE", 

333 "GIT_INDEX_FILE", 

334 "GIT_NO_REPLACE_OBJECTS", 

335 "GIT_REPLACE_REF_BASE", 

336 "GIT_PREFIX", 

337 "GIT_SHALLOW_FILE", 

338 "GIT_COMMON_DIR", 

339 } 

340) 

341#: The subset that picks a work tree or an index: dropped even where the repository the 

342#: variables name is the right one (``git worktree add``, run in the operator's repository). 

343CHECKOUT_ENV_VARS = frozenset( 

344 {"GIT_WORK_TREE", "GIT_IMPLICIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_PREFIX"} 

345) 

346 

347 

348def scratch_env(drop: frozenset[str] = REPO_ENV_VARS) -> dict[str, str]: 

349 """``os.environ`` without ``drop``: for a command that must act where it runs.""" 

350 return {key: value for key, value in os.environ.items() if key not in drop} 

351 

352 

353def worktree_add_detached( 

354 path: str, commit: str, *, hooks_path: str, cwd: str | None = None, _run=None 

355) -> CommandResult: 

356 """Check ``commit`` out at ``path`` as a detached worktree — no branch is created. 

357 

358 ``hooks_path`` replaces the repository's hooks for this one command: a worktree shares 

359 its repository's hooks, and a ``post-checkout`` hook would otherwise run in the scratch 

360 tree. Pass an empty directory. :data:`CHECKOUT_ENV_VARS` are dropped, so an inherited 

361 work tree or index cannot receive the checkout. 

362 """ 

363 return run_argv( 

364 [ 

365 "git", 

366 "-c", 

367 f"core.hooksPath={hooks_path}", 

368 "worktree", 

369 "add", 

370 "--detach", 

371 "--quiet", 

372 path, 

373 commit, 

374 ], 

375 cwd=cwd, 

376 env=scratch_env(CHECKOUT_ENV_VARS), 

377 **_kw(_run), 

378 ) 

379 

380 

381def apply_reverse(patch_path: str, *, cwd: str | None = None, _run=None) -> CommandResult: 

382 """Undo the patch at ``patch_path`` in the working tree at ``cwd`` (``git apply -R``). 

383 

384 ``--unidiff-zero`` because :func:`revert_diff` writes hunks with no context lines, which 

385 git refuses to apply by default. It is exact here: the patch is undone on the very 

386 commit it was taken from, so every line number it carries is the tree's own. 

387 

388 The user's ``apply.*`` settings are pinned: ``apply.whitespace=error`` would refuse a 

389 change whose lines carry trailing blanks (a false "could not undo"), and 

390 ``apply.ignoreWhitespace`` or ``apply.3way`` would let a patch land where it does not 

391 match exactly. It runs with :func:`scratch_env`, so it patches the tree at ``cwd``. 

392 """ 

393 return run_argv( 

394 [ 

395 "git", 

396 "-c", 

397 "apply.ignoreWhitespace=no", 

398 "apply", 

399 "-R", 

400 "--unidiff-zero", 

401 "--whitespace=nowarn", 

402 "--no-3way", 

403 "--", 

404 patch_path, 

405 ], 

406 cwd=cwd, 

407 env=scratch_env(), 

408 **_kw(_run), 

409 ) 

410 

411 

412def reset_clean(*, cwd: str | None = None, _run=None) -> bool: 

413 """Put the working tree at ``cwd`` back to ``HEAD`` exactly; ``True`` when both steps ran. 

414 

415 ``clean -x`` removes *ignored* files too, and that is the point: a ``__pycache__`` 

416 written while one change was reverted must not answer for the next run, since a 

417 ``.pyc`` whose source was restored within the same second can be served stale. 

418 

419 Both run with :func:`scratch_env`: an inherited ``GIT_DIR`` or ``GIT_WORK_TREE`` would 

420 otherwise send them to the operator's checkout. 

421 """ 

422 env = scratch_env() 

423 reset = run_argv(["git", "reset", "--hard", "--quiet", "HEAD"], cwd=cwd, env=env, **_kw(_run)) 

424 if not reset.ok: 

425 return False 

426 return run_argv(["git", "clean", "-fdxq"], cwd=cwd, env=env, **_kw(_run)).ok 

427 

428 

429def hash_object(path: str, *, cwd: str | None = None, _run=None) -> str | None: 

430 """Write ``path``'s content into the object database; return its blob SHA. 

431 

432 ``-w`` is what makes the landing possible without a checkout: the blob exists in 

433 the repository before any tree references it, so the commit can be assembled with 

434 plumbing and pushed, and a failed push leaves nothing but an unreferenced object 

435 that ``git gc`` collects. 

436 """ 

437 result = run_argv(["git", "hash-object", "-w", "--", path], cwd=cwd, **_kw(_run)) 

438 output = result.stdout.strip() 

439 return output if result.ok and _SHA_RE.match(output) else None 

440 

441 

442def ls_tree(treeish: str, *, cwd: str | None = None, _run=None) -> str | None: 

443 """List one tree's own entries (not recursive); ``None`` when it cannot be read. 

444 

445 ``None`` and ``""`` are different answers and both are ordinary here: a sink 

446 directory that does not exist on the base branch yet cannot be read (``None``), 

447 and an existing but empty one reads as no entries. The caller treats the first as 

448 "start a new directory" rather than as an error, which is what makes the very 

449 first lesson land as cleanly as the hundredth. 

450 

451 ``-z`` for the same reason :func:`mktree` takes it: entries are NUL-terminated, 

452 so a name is returned raw instead of C-quoted, and the round trip back through 

453 ``mktree`` cannot re-encode one. 

454 """ 

455 result = run_argv(["git", "ls-tree", "-z", treeish], cwd=cwd, **_kw(_run)) 

456 return result.stdout if result.ok else None 

457 

458 

459def mktree(listing: str, *, cwd: str | None = None, _run=None) -> str | None: 

460 """Write a tree object from NUL-terminated ``ls-tree``-shaped ``listing``. 

461 

462 The listing arrives on **stdin**, never in an argv: it carries object names and 

463 file names, and an argv is world-readable in ``ps`` for the life of the process. 

464 

465 **``-z``, because a text-mode pipe rewrites newlines on Windows.** Python opens a 

466 subprocess's stdin with ``newline=None`` under ``text=True``, which translates 

467 every ``\n`` to ``os.linesep`` — so a LF-terminated listing reaches git as CRLF 

468 there, and `mktree` does not complain: it writes a tree whose entry is named 

469 ``<name>\r``, exits 0, and hands back a different SHA (measured). NUL-terminated 

470 input has no newline to translate, so the same bytes arrive on every platform. 

471 """ 

472 result = run_argv(["git", "mktree", "-z"], cwd=cwd, stdin_text=listing, **_kw(_run)) 

473 output = result.stdout.strip() 

474 return output if result.ok and _SHA_RE.match(output) else None 

475 

476 

477def commit_tree( 

478 tree: str, *, parent: str, message: str, cwd: str | None = None, _run=None 

479) -> str | None: 

480 """Commit ``tree`` with a single ``parent``; return the new commit SHA. 

481 

482 The message goes in the **argv**, not on stdin, for the newline reason in 

483 :func:`mktree`: a text-mode pipe turns every ``\n`` into CRLF on Windows, and a 

484 commit message is content — it would land on the base branch carrying stray 

485 carriage returns and stop being byte-identical across platforms. It is safe 

486 there in a way a tree listing is not: this message is a fixed subject plus the 

487 artifact path, which is about to be published on the base branch anyway. 

488 """ 

489 result = run_argv( 

490 ["git", "commit-tree", tree, "-p", parent, "-m", message], 

491 cwd=cwd, 

492 **_kw(_run), 

493 ) 

494 output = result.stdout.strip() 

495 return output if result.ok and _SHA_RE.match(output) else None 

496 

497 

498def diff_names(a: str, b: str, *, cwd: str | None = None, _run=None) -> list[str] | None: 

499 """Paths differing between two tree-ish objects (two-dot); ``None`` on error. 

500 

501 Two-dot on purpose, unlike :func:`changed_files`: the landing compares a commit 

502 against the parent it was *just built on*, so "what did this commit add" is the 

503 literal difference between the two trees and not a merge-base question. ``None`` 

504 stays distinct from ``[]`` so a caller that must fail closed when it cannot 

505 observe — the landing's own "this commit changes exactly one file" check — can 

506 tell an unreadable diff from an empty one. 

507 """ 

508 # `-z` with `core.quotePath=false`, for the same reason `ls_tree`/`mktree` use it. 

509 # Under the default `quotePath=true` git renders a non-ASCII name as a C-quoted 

510 # escape — `".keel/learning/caf\\303\\251.md"` — which can never equal the raw path 

511 # the landing planned, so the one live safety check refused every such artifact 

512 # permanently and blamed the commit for changing a file nobody asked for. 

513 # 

514 # `--no-renames`, because this is that "exactly one file" check. With rename detection 

515 # on — git's default — a commit that deleted a file and added the lesson with the same 

516 # content prints as one rename, named by where the file went, so the deletion never 

517 # showed. Every path either side touches is listed separately here, the one it left too. 

518 result = run_argv( 

519 [ 

520 "git", 

521 "-c", 

522 "core.quotePath=false", 

523 "diff", 

524 "--no-renames", 

525 "--name-only", 

526 "-z", 

527 a, 

528 b, 

529 ], 

530 cwd=cwd, 

531 **_kw(_run), 

532 ) 

533 if not result.ok: 

534 return None 

535 # Only the empty record after the final NUL is dropped. `name.strip()` also dropped a 

536 # path made of whitespace, which is a legal filename and exactly as much of a change. 

537 return [name for name in result.stdout.split("\0") if name] 

538 

539 

540def push_commit( 

541 remote: str, commit: str, ref: str, *, cwd: str | None = None, _run=None 

542) -> CommandResult: 

543 """Fast-forward ``ref`` on ``remote`` to ``commit``. 

544 

545 Deliberately **not** forced. A rejected push is the concurrency signal the 

546 landing is built around: another ship pushed its own lesson first, so this one 

547 re-reads the branch and rebuilds its commit on top. Forcing here would discard 

548 that ship's lesson — and, on a base branch, whatever else arrived with it. 

549 """ 

550 return run_argv(["git", "push", remote, f"{commit}:{ref}"], cwd=cwd, **_kw(_run)) 

551 

552 

553def _kw(_run): 

554 """Pass ``_run`` through only when provided (so the default subprocess is used otherwise).""" 

555 return {"_run": _run} if _run is not None else {}