froid-loop 0.11.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. froid_loop/__init__.py +11 -0
  2. froid_loop/__main__.py +12 -0
  3. froid_loop/adapters/__init__.py +3 -0
  4. froid_loop/adapters/base.py +254 -0
  5. froid_loop/adapters/entrypoints.py +63 -0
  6. froid_loop/adapters/env_fault.py +290 -0
  7. froid_loop/adapters/generic.py +2013 -0
  8. froid_loop/adapters/mock.py +49 -0
  9. froid_loop/adapters/multiplexer.py +914 -0
  10. froid_loop/adapters/opencode_http.py +1687 -0
  11. froid_loop/adapters/profile.py +650 -0
  12. froid_loop/adapters/psmux_backend.py +1428 -0
  13. froid_loop/adapters/registry.py +322 -0
  14. froid_loop/adapters/tmux_backend.py +35 -0
  15. froid_loop/adapters/tmux_base.py +630 -0
  16. froid_loop/checks.py +187 -0
  17. froid_loop/cli.py +5041 -0
  18. froid_loop/data/__init__.py +0 -0
  19. froid_loop/data/froid_loop_hook.py +228 -0
  20. froid_loop/data/froid_loop_probe_hook.py +88 -0
  21. froid_loop/data/plugins/example/plugin.toml +21 -0
  22. froid_loop/data/plugins/tea/plugin.toml +184 -0
  23. froid_loop/data/plugins/tea/tea_plugin.py +258 -0
  24. froid_loop/data/plugins/unity/plugin.toml +140 -0
  25. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
  26. froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
  27. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
  28. froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
  29. froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
  30. froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
  31. froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
  32. froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
  33. froid_loop/data/plugins/unity/unity_facts.md +17 -0
  34. froid_loop/data/plugins/unity/unity_plugin.py +415 -0
  35. froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
  36. froid_loop/data/plugins/unity/unity_ready.py +230 -0
  37. froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
  38. froid_loop/data/plugins/unity/unity_setup.py +551 -0
  39. froid_loop/data/plugins/unity/unity_teardown.py +362 -0
  40. froid_loop/data/profiles/antigravity.toml +52 -0
  41. froid_loop/data/profiles/claude.toml +85 -0
  42. froid_loop/data/profiles/codex.toml +22 -0
  43. froid_loop/data/profiles/copilot.toml +52 -0
  44. froid_loop/data/profiles/gemini.toml +26 -0
  45. froid_loop/data/profiles/opencode.toml +54 -0
  46. froid_loop/data/settings/core.toml +458 -0
  47. froid_loop/data/skills/README.md +93 -0
  48. froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
  49. froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
  50. froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
  51. froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
  52. froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
  53. froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
  54. froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
  55. froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
  56. froid_loop/decisions.py +202 -0
  57. froid_loop/deferredwork.py +2282 -0
  58. froid_loop/devcontract.py +892 -0
  59. froid_loop/diagnostics.py +1104 -0
  60. froid_loop/documents.py +532 -0
  61. froid_loop/engine.py +7732 -0
  62. froid_loop/envvars.py +111 -0
  63. froid_loop/escalation.py +225 -0
  64. froid_loop/events.py +266 -0
  65. froid_loop/fences.py +103 -0
  66. froid_loop/froidconfig.py +226 -0
  67. froid_loop/frontmatter.py +526 -0
  68. froid_loop/gates.py +133 -0
  69. froid_loop/install.py +2936 -0
  70. froid_loop/journal.py +178 -0
  71. froid_loop/machine.py +148 -0
  72. froid_loop/model.py +898 -0
  73. froid_loop/operatoractions.py +474 -0
  74. froid_loop/platform_util.py +1490 -0
  75. froid_loop/plugins/__init__.py +64 -0
  76. froid_loop/plugins/bus.py +259 -0
  77. froid_loop/plugins/context.py +319 -0
  78. froid_loop/plugins/loader.py +145 -0
  79. froid_loop/plugins/manifest.py +279 -0
  80. froid_loop/plugins/model.py +296 -0
  81. froid_loop/plugins/registry.py +245 -0
  82. froid_loop/plugins/trust.py +75 -0
  83. froid_loop/policy.py +1569 -0
  84. froid_loop/probe.py +1044 -0
  85. froid_loop/process_host.py +408 -0
  86. froid_loop/recovery_flow.py +1561 -0
  87. froid_loop/resolve.py +283 -0
  88. froid_loop/runs.py +4715 -0
  89. froid_loop/runsetup.py +1293 -0
  90. froid_loop/sanitize.py +593 -0
  91. froid_loop/settings_schema.py +276 -0
  92. froid_loop/signals.py +160 -0
  93. froid_loop/sprintstatus.py +609 -0
  94. froid_loop/statemachine.py +57 -0
  95. froid_loop/stories.py +615 -0
  96. froid_loop/stories_engine.py +796 -0
  97. froid_loop/sweep.py +1892 -0
  98. froid_loop/tokens.py +196 -0
  99. froid_loop/tui/__init__.py +11 -0
  100. froid_loop/tui/app.py +1584 -0
  101. froid_loop/tui/data.py +840 -0
  102. froid_loop/tui/launch.py +1003 -0
  103. froid_loop/tui/screens/__init__.py +1 -0
  104. froid_loop/tui/screens/dashboard.py +1071 -0
  105. froid_loop/tui/screens/modals.py +943 -0
  106. froid_loop/tui/screens/settings_screen.py +477 -0
  107. froid_loop/tui/settings.py +135 -0
  108. froid_loop/tui/widgets.py +981 -0
  109. froid_loop/verify.py +4545 -0
  110. froid_loop/workspace.py +320 -0
  111. froid_loop/worktree_flow.py +2301 -0
  112. froid_loop-0.11.1.dist-info/METADATA +728 -0
  113. froid_loop-0.11.1.dist-info/RECORD +116 -0
  114. froid_loop-0.11.1.dist-info/WHEEL +4 -0
  115. froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
  116. froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
froid_loop/verify.py ADDED
@@ -0,0 +1,4545 @@
1
+ """Deterministic post-session verification. Never trust LLM self-reports.
2
+
3
+ verify_dev / verify_review check artifacts on disk and git state against
4
+ what the session's result.json claims; run_verify_commands executes the
5
+ policy's test/lint gates with the orchestrator's own subprocess calls.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import locale
11
+ import os
12
+ import re
13
+ import shlex
14
+ import shutil
15
+ import subprocess
16
+ import sys
17
+ import tempfile
18
+ from collections.abc import Callable
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Any, Literal, overload
22
+
23
+ import yaml
24
+
25
+ from . import deferredwork
26
+ from .froidconfig import ProjectPaths
27
+ from .frontmatter import FrontmatterWriteError # noqa: F401 — re-export
28
+ from .frontmatter import set_frontmatter_status # noqa: F401 — re-export
29
+ from .frontmatter import (
30
+ _edit_frontmatter_block,
31
+ _split_frontmatter,
32
+ auto_dev_baseline_of,
33
+ operator_actions_of,
34
+ read_frontmatter,
35
+ status_of,
36
+ )
37
+ from .model import StoryTask, VerifyOutcome
38
+ from .platform_util import atomic_write_bytes, atomic_write_bytes_confined
39
+ from .policy import POLICY_FILE, Policy
40
+ from .sprintstatus import STATUS_ORDER, story_status
41
+
42
+ GIT_TIMEOUT_S = 120
43
+ COMMAND_TIMEOUT_S = 30 * 60
44
+
45
+ # The oldest git froid-loop SUPPORTS — the version this project tests against, writes
46
+ # code against, and will help you with. INCLUSIVE: 2.34 itself clears it. (The
47
+ # neighbouring multiplexer constant is not the same shape — psmux's
48
+ # `_LAST_UNSUPPORTED` names the newest REFUSED build, exclusive. Do not read one as
49
+ # the other.)
50
+ #
51
+ # This is a SUPPORT floor, deliberately above the capability floor. Nothing in this
52
+ # module's git argv needs 2.34; the highest capability in use is `git config
53
+ # --worktree`, which arrived in 2.20. The floor exists so the project stops carrying
54
+ # accommodations for gits nobody here runs — every version claim below may assume it,
55
+ # and code need not degrade to reach older git.
56
+ #
57
+ # Enforced in two places, both fail-closed: `cli._reject_under_floor_git` aborts a
58
+ # run/sweep/resume, and `install._shield_enable_worktree_config` refuses the
59
+ # git-add shield's permanent repo-format write. `froid-loop validate` reports it as a
60
+ # `git.version` problem, and `froid-loop diagnose` records the host's version.
61
+ GIT_FLOOR = (2, 34)
62
+
63
+ # Current bound on a single git subprocess. Module state rather than a per-call
64
+ # parameter so the ~40 git helpers need no threading; the engine overrides it
65
+ # from `limits.git_timeout_s` at startup, everything else keeps the default.
66
+ # Interactive callers with a deadline of their own (a TUI render, install's
67
+ # best-effort probe) pass `timeout_s=` through `git_bytes` instead — a per-call
68
+ # override, never a rebind of this state.
69
+ _git_timeout_s = GIT_TIMEOUT_S
70
+
71
+
72
+ def configure_git_timeout(seconds: int) -> None:
73
+ """Set the per-git-call timeout (`limits.git_timeout_s`). Called once by the
74
+ engine when it binds its policy; standalone verify users keep GIT_TIMEOUT_S."""
75
+ global _git_timeout_s
76
+ _git_timeout_s = seconds
77
+
78
+
79
+ # How git's own diff format names the absent side of a creation/deletion. A
80
+ # protocol token git emits verbatim on every platform, Windows included — never
81
+ # opened, never joined onto. Only `patch_new_files` reads it.
82
+ _DIFF_ABSENT = "/dev/null" # portability: git diff-format token, not a real path
83
+
84
+ # result.json `workflow` value for the dev pass. A machine contract: the
85
+ # orchestrator forges this value in `devcontract` when synthesizing the dev
86
+ # result from the spec the froid-build-auto session leaves on disk; a mismatch
87
+ # means the wrong artifacts, so we reject rather than trust them. (Sweep's
88
+ # triage/migrate workflows have their
89
+ # own constants in sweep.py; the review skill is verified by on-disk artifacts
90
+ # only and is not handed its result.json.)
91
+ DEV_WORKFLOW = "auto-dev"
92
+
93
+ # Repo-relative posix path of the orchestrator config, for git pathspecs.
94
+ POLICY_FILE_REL = POLICY_FILE.as_posix()
95
+ # The orchestrator's own working dir (.froid-loop/) — config, ledger, run state,
96
+ # engine plugins. Excluded wholesale from merge-collision detection: none of it
97
+ # is ever a unit branch's merged content, so a dirty .froid-loop/ must neither
98
+ # block a merge as "stray work" nor be auto-cleaned.
99
+ AUTOMATOR_DIR_REL = POLICY_FILE.parent.as_posix()
100
+
101
+
102
+ class GitError(Exception):
103
+ pass
104
+
105
+
106
+ class GitSpawnError(GitError):
107
+ """The git child could not be spawned at all (an OSError out of
108
+ `subprocess.run` — EMFILE, ENOMEM, ENOENT on the git binary). A GitError so
109
+ every existing guard treats it like any other git failure; a distinct type
110
+ so the rare caller can tell "git said no" from "the machine is broken"
111
+ (#343). The underlying errno stays reachable via ``exc.__cause__.errno``."""
112
+
113
+
114
+ class GitTimeoutError(GitError):
115
+ """The git child was spawned but never returned inside the deadline (a
116
+ `subprocess.TimeoutExpired` out of `_run_git`). Same shape and same reason as
117
+ `GitSpawnError`: a GitError so every existing guard is unchanged, a distinct
118
+ type so a caller that is about to spawn ANOTHER git can tell "git ran and
119
+ said no" — a non-zero rc, which the next command will answer promptly — from
120
+ "git does not return", which the next command will pay the full timeout for
121
+ all over again. `cmd_validate` is that caller: three probes in a row against
122
+ one hung binary cost three deadlines, and only the first one told the
123
+ operator anything."""
124
+
125
+
126
+ class RollbackPreflightError(GitError):
127
+ """Rollback cleanup paths could not be proven safe before mutation."""
128
+
129
+
130
+ class MergePreflightError(GitError):
131
+ """Git refused a merge BEFORE starting it: the working tree was never
132
+ touched, no merge is in progress, and there is nothing to resolve. Covers an
133
+ untracked file the merge would overwrite, a staged change on an incoming
134
+ path, a file/directory shape clash, and an `--ff-only` target that cannot
135
+ fast-forward. A GitError so every existing `except verify.GitError` guard is
136
+ unchanged; a distinct type so a caller can stop telling the operator to
137
+ resolve a content conflict that never happened (#619)."""
138
+
139
+
140
+ class MergeConflictError(GitError):
141
+ """The merge ran and the CONTENT collided: unmerged index stages exist (or
142
+ did, before the leg's own rollback), and resolving them by hand is the
143
+ remedy.
144
+
145
+ Measured, not inferred: `_index_unmerged` (`ls-files -u`) is what earns this
146
+ class, under both `--no-ff` and `--squash` — the one probe that answers
147
+ content for both, since a conflicted `--squash` writes three unmerged stages
148
+ while creating no MERGE_HEAD at all. A GitError so every existing
149
+ `except verify.GitError` guard is unchanged; a distinct type so the caller's
150
+ LAST arm no longer has to read "bare GitError" as "conflict" — with the
151
+ conflict typed, whatever arrives untyped is a state nothing measured, and
152
+ the caller can say that instead of prescribing conflict resolution for it
153
+ (#619)."""
154
+
155
+
156
+ class MergeCommitRefusedError(GitError):
157
+ """The merge itself ran and resolved; git would not COMMIT the result.
158
+
159
+ Measured causes: a `pre-merge-commit` or `commit-msg` hook exiting non-zero,
160
+ and a `commit.gpgsign` that cannot produce a signature. Neither sibling's
161
+ remedy fits — there is no content conflict to resolve and no target state to
162
+ clear, only a policy or a key the operator's own repo configures — which is
163
+ the whole reason this is a third type rather than either of theirs.
164
+
165
+ Two legs reach it, through different commits. `--no-ff` is refused at the
166
+ merge's own commit and leaves MERGE_HEAD, which is what parts it from a
167
+ genuine pre-flight refusal — `merge_branch` reads that BEFORE its abort
168
+ rather than only to decide whether to abort at all. (`_index_unmerged`
169
+ cannot see this state: a merge that resolved cleanly leaves no unmerged
170
+ stages whether or not the commit that would have sealed it was allowed.)
171
+ The squash leg is refused at its OWN plain `git commit`, after
172
+ `merge --squash` already staged the result — hooks and signing run there
173
+ like anywhere else — so no MERGE_HEAD is involved and no classification is
174
+ needed: the merge step succeeded, and the failed call identifies the state
175
+ by itself.
176
+
177
+ ``restored`` says whether the rollback that follows actually put the
178
+ checkout back — `merge --abort` on the `--no-ff` leg, `reset --hard HEAD`
179
+ on the squash leg (gated on the leg's pre-merge dirtiness snapshot: a
180
+ checkout that already carried uncommitted work is never reset, #619). The
181
+ squash rollback is deliberately whole-tree — it is undoing a SUCCEEDED
182
+ merge whose staged result spans the entire incoming set — which leaves one
183
+ stated ceiling: an operator edit landing after the pre-merge reading found
184
+ the tree clean sits inside the reset's blast radius (see
185
+ `_reset_hard_head`).
186
+ It is an attribute rather than a type of its own because the operator's
187
+ CAUSE is the same either way — a policy declined the commit — and only the first
188
+ step of their remedy differs: a checkout left mid-merge has to be recovered
189
+ before fixing that policy is worth anything, and a resume attempted before then
190
+ fails again on the merge state rather than on the policy. A caller that ignores
191
+ the flag still gets a true statement of the cause; one that reads it can order
192
+ the two steps (#619).
193
+
194
+ ``staged`` names WHERE an unrestored checkout stands, because the two legs
195
+ strand differently and the operator's first step differs with them: False
196
+ means mid-merge (MERGE_HEAD set, `git merge --abort` recovers it), True
197
+ means the squash result is still sitting staged (`reset --hard HEAD` clears
198
+ it — after their own uncommitted work, if that is what blocked the rollback,
199
+ is stashed or committed). Always False when ``restored`` is True: a checkout
200
+ that was put back holds nothing."""
201
+
202
+ def __init__(self, message: str, *, restored: bool = True, staged: bool = False) -> None:
203
+ super().__init__(message)
204
+ self.restored = restored
205
+ self.staged = staged
206
+
207
+
208
+ class MergeHalfAppliedError(GitError):
209
+ """Git died PART-WAY through checking the merge out: some incoming files are
210
+ already sitting in the target checkout, and no restore removes them.
211
+
212
+ A sibling of `MergePreflightError`, never a subclass, because it falsifies
213
+ that class's central claim — the working tree was never touched. Measured
214
+ cause (git 2.55.0, both `--no-ff` and `--squash`): a **required** clean/smudge
215
+ filter that fails. git materializes the incoming paths in index order, so the
216
+ ones sorting before the filtered path are written to the working tree and the
217
+ ones after it are not; git then rolls the INDEX back and exits, leaving the
218
+ written files behind as UNTRACKED. Nothing in the failure's own shape
219
+ distinguishes it from a genuine pre-flight refusal: no unmerged stages, no
220
+ MERGE_HEAD, and — because `git diff --quiet HEAD --` cannot see untracked
221
+ files — a tree that reads clean against HEAD.
222
+
223
+ That residue is why this is a type and not a message tweak. It blocks the
224
+ NEXT merge as an untracked-overwrite pre-flight refusal, so a run told its
225
+ checkout was unchanged fails the same way on every resume, over paths the
226
+ error never named.
227
+
228
+ The residue has TWO axes and they are not interchangeable. An incoming path
229
+ the target did not already track lands as an untracked file, which no restore
230
+ reaches. An incoming path it DID track is modified in place, which a
231
+ path-scoped `git checkout HEAD --` over exactly the attributed paths does
232
+ undo. So the tracked axis is repaired and the untracked axis is reported, and
233
+ the class carries one field for each — plus ``rewritten``, naming the tracked
234
+ paths the repair covered (or failed to).
235
+
236
+ Attribution, on both axes, is a per-path AND of two proofs: the path changed
237
+ during the merge window (a before/after delta, never an absolute reading) AND
238
+ the merge could have written it (the branch's incoming set). Each proof rules
239
+ out the misattribution the other cannot: the delta keeps the operator's
240
+ pre-existing strays and edits out, the intersection keeps their CONCURRENT
241
+ writes out — an edit landing on a bystander path mid-merge was once swept
242
+ into a repo-wide "the tree is dirty now" reading and destroyed by the
243
+ repo-wide reset riding on it. Two ceilings remain, per path: a path already
244
+ dirty before the merge stays unattributable, and a concurrent write to a
245
+ path INSIDE the incoming set is indistinguishable from git's and is restored
246
+ with it.
247
+
248
+ ``paths`` carries the untracked residue — what the operator still has to
249
+ clear. Deliberately not cleaned: `reset --hard` and `merge --abort` both
250
+ leave untracked files alone (measured), and deleting them is precisely the
251
+ destruction #619's before-snapshot exists to prevent — the attribution
252
+ proves git wrote *a* path, not that the bytes there are git's.
253
+
254
+ ``rewritten`` carries the tracked residue — the paths whose restore
255
+ ``restored`` reports on, so a failed repair can be finished by hand
256
+ path-scoped (`git checkout HEAD -- <path>`) instead of by the repo-wide
257
+ reset whose blast radius the attribution exists to avoid.
258
+
259
+ ``restored`` says whether the TRACKED half was rolled back, and is True when
260
+ there was none to roll back. It matters because it changes the operator's
261
+ FIRST step, exactly as its namesake on `MergeCommitRefusedError` does: a
262
+ checkout still holding incoming content on tracked paths refuses the next
263
+ merge over those paths ("Your local changes would be overwritten"), so a resume
264
+ attempted before restoring it fails on the tree rather than on whatever stopped
265
+ the checkout.
266
+
267
+ An empty ``paths`` with ``restored`` True is still this class and not
268
+ `MergePreflightError`. The checkout ends up in the same place, but the CAUSE
269
+ the operator has to act on is a different one — something stopped git mid-write,
270
+ not a target-state clash — and sending them to clear a clash that does not
271
+ exist is the #619 defect this taxonomy exists to prevent."""
272
+
273
+ def __init__(
274
+ self,
275
+ message: str,
276
+ *,
277
+ paths: tuple[str, ...] = (),
278
+ restored: bool = True,
279
+ rewritten: tuple[str, ...] = (),
280
+ ) -> None:
281
+ super().__init__(message)
282
+ self.paths = paths
283
+ self.restored = restored
284
+ self.rewritten = rewritten
285
+
286
+
287
+ class MergeResidueUnreadError(GitError):
288
+ """A post-merge reading the classification rests on failed, so the
289
+ checkout's state is UNVERIFIED — neither sibling's claim survives.
290
+
291
+ The terminal arm of the classification, present so a probe failure can never
292
+ impersonate a verdict. Unwrapped, the probe's raise escapes between the
293
+ failed merge and its cleanup — stranding a started `--no-ff` mid-merge with
294
+ MERGE_HEAD set — and lands in the caller's content-conflict arm wearing a
295
+ probe error's text, with a remedy that is fiction here. Degraded SILENTLY
296
+ instead, the empty reading routes to `MergePreflightError`, whose
297
+ load-bearing clause — the working tree was never touched — the dead probe
298
+ can no longer back. Both neighbours state what was measured; this class
299
+ states that the measurement is missing.
300
+
301
+ Reachable from the corner where nothing else answered. Over dead RESIDUE
302
+ readings, unmerged stages (conflict) and MERGE_HEAD (commit refused) are read
303
+ independently, so either still claims its own class — only the choice
304
+ between "refused before starting" and "failed part-way through checkout"
305
+ rests on that reading, and with it gone the honest answer is neither. The
306
+ index and merge-state readings can die in the same window (#619): a dead
307
+ index reading surrenders every class resting on "did not collide" —
308
+ commit-refused and half-applied as much as pre-flight, since a state it
309
+ cannot rule a conflict out of must not be dressed as either — and a dead
310
+ merge-state reading additionally skips the abort it gates, with the
311
+ message saying so, because uncertainty never authorizes a repair write.
312
+ The squash replay reading joins from the far side of a merge that
313
+ SUCCEEDED: with `allow_empty_squash`'s staged-result reading dead, neither
314
+ the no-op return nor a result to commit can be claimed, so nothing is
315
+ committed, nothing is reset, and the message names the dead reading.
316
+
317
+ No repair rides on it: `reset --hard` stays gated on a PROVEN
318
+ tracked-residue attribution, because uncertainty must not be what authorizes
319
+ rewriting the operator's checkout. Their own `git status` is not degraded —
320
+ the message carries the probe's failure alongside git's own and sends them
321
+ there."""
322
+
323
+
324
+ @overload
325
+ def _run_git(
326
+ cmd: list[str],
327
+ repo: Path,
328
+ *,
329
+ env: dict[str, str] | None = ...,
330
+ binary: Literal[False] = ...,
331
+ timeout_s: int | None = ...,
332
+ ) -> subprocess.CompletedProcess[str]: ...
333
+
334
+
335
+ @overload
336
+ def _run_git(
337
+ cmd: list[str],
338
+ repo: Path,
339
+ *,
340
+ env: dict[str, str] | None = ...,
341
+ binary: Literal[True],
342
+ timeout_s: int | None = ...,
343
+ ) -> subprocess.CompletedProcess[bytes]: ...
344
+
345
+
346
+ def _run_git(
347
+ cmd: list[str],
348
+ repo: Path,
349
+ *,
350
+ env: dict[str, str] | None = None,
351
+ binary: bool = False,
352
+ timeout_s: int | None = None,
353
+ ) -> subprocess.CompletedProcess[str] | subprocess.CompletedProcess[bytes]:
354
+ """Sole spawn point for git subprocesses. Three failures are raised by
355
+ `subprocess.run` *before* any return code exists — a timeout (#156), a
356
+ spawn-level OSError (#343), and a strict-decode fault on the child's output
357
+ (#377) — so left uncaught any of them would bypass every `except GitError`
358
+ guard and crash the run. All are translated here into the GitError taxonomy
359
+ — observation guards degrade, unguarded paths fail typed — with the two that
360
+ mean the binary never answered marked as `GitSpawnError` and
361
+ `GitTimeoutError` for the callers that must distinguish an environment fault
362
+ from git refusing. The decode fault carries no class of its own: git ran and
363
+ returned, so it is a fact about the repository's bytes, not about the host.
364
+
365
+ The decode fault is real, not theoretical: POSIX filenames are arbitrary
366
+ bytes, and while `core.quotePath` C-quotes them to ASCII for ordinary
367
+ porcelain, `-z` disables that quoting (`dirty_paths`, `branch_incoming_paths`,
368
+ `commit_paths`), `worktree list --porcelain` never applied it, and `git diff`
369
+ emits file *content* verbatim (`capture_diff`) — so one latin-1 file is
370
+ enough. Translating is deliberately all this does; making such paths usable
371
+ (`errors="surrogateescape"`) is a separate call, since surrogates would then
372
+ flow into the UTF-8 journal and JSON writes downstream.
373
+
374
+ `binary=True` skips the decode entirely and hands back the raw
375
+ `CompletedProcess[bytes]` — see `git_bytes`, the public accessor for it.
376
+
377
+ Every git child runs with `LC_ALL=C` so messages stay stable English: the one
378
+ place that inspects git *text* rather than a return code — `safe_rollback`'s
379
+ benign "pathspec did not match" tolerance — must not misread a translated
380
+ message under a localized git (#236). Merged last so it wins over both the
381
+ inherited environment and any explicit `env` (the `_git_env` callers' throwaway
382
+ `GIT_INDEX_FILE` / synthetic identity vars are preserved by the spread).
383
+
384
+ `timeout_s` overrides the module bound for this one call — the interactive
385
+ callers' seam (#390): a TUI render or install's best-effort probe keeps its
386
+ own short deadline while standing inside the chokepoint."""
387
+ effective_timeout_s = _git_timeout_s if timeout_s is None else timeout_s
388
+ try:
389
+ return subprocess.run(
390
+ cmd,
391
+ capture_output=True,
392
+ text=not binary,
393
+ timeout=effective_timeout_s,
394
+ env={**(env if env is not None else os.environ), "LC_ALL": "C"},
395
+ )
396
+ except subprocess.TimeoutExpired as exc:
397
+ raise GitTimeoutError(
398
+ f"git {cmd[3]} timed out after {effective_timeout_s}s in {repo}"
399
+ ) from exc
400
+ except UnicodeDecodeError as exc:
401
+ raise GitError(f"git {cmd[3]} returned undecodable output in {repo}: {exc}") from exc
402
+ except OSError as exc:
403
+ raise GitSpawnError(f"git {cmd[3]} failed to spawn in {repo}: {exc}") from exc
404
+
405
+
406
+ def _git(repo: Path, *args: str) -> tuple[int, str]:
407
+ proc = _run_git(["git", "-C", str(repo), *args], repo)
408
+ return proc.returncode, (proc.stdout + proc.stderr).strip()
409
+
410
+
411
+ def _git_raw(repo: Path, *args: str) -> tuple[int, str]:
412
+ """Like `_git` but returns stdout verbatim (no strip, no stderr merge) — for
413
+ NUL-delimited (`-z`) output whose records can begin with a space (porcelain
414
+ status codes like ' M'), which `_git`'s strip() would corrupt."""
415
+ proc = _run_git(["git", "-C", str(repo), *args], repo)
416
+ return proc.returncode, proc.stdout
417
+
418
+
419
+ def _git_out(repo: Path, *args: str, env: dict[str, str] | None = None) -> tuple[int, str, str]:
420
+ """Like `_git`, but hands the VALUE and the DIAGNOSTIC back separately —
421
+ `(returncode, stdout.strip(), (stdout + stderr).strip())`.
422
+
423
+ For every caller that reads git's text as the ANSWER rather than only checking
424
+ the return code. `_git`'s merge is right for the "raise with a message" callers,
425
+ where stderr is the informative half, and wrong for these: git writes advisories
426
+ to stderr while still exiting 0 — an unknown `core.fsyncMethod` value, a
427
+ `core.fsmonitor` hook that cannot exec, a stale-index advisory, `core.hooksPath`
428
+ pointing at a missing directory — so against the merged stream a warning is
429
+ indistinguishable from data (#442). A sha probe answers "<sha>\\nwarning: ...", an
430
+ emptiness read answers non-empty, and a line-splitting read grows a phantom record.
431
+ None of that is an error path; it is the normal path on a host whose git config the
432
+ orchestrator does not control.
433
+
434
+ The third element keeps the error messages unchanged: a caller raises with the
435
+ merged text exactly as `_git` did, so a failure still carries stderr. Reach for
436
+ this whenever the text is the answer; leave `_git` to the rc-only callers.
437
+ `worktree_clean` and `path_tracked` (#441) predate this helper and spell the same
438
+ split inline against `_run_git`; `_git_raw` is the third variant, for `-z` output
439
+ whose records can begin with a space and which `.strip()` would corrupt.
440
+
441
+ `env` mirrors `_git_env`, for the snapshot path's throwaway `GIT_INDEX_FILE` and
442
+ synthetic-identity calls that also read a sha back."""
443
+ proc = _run_git(["git", "-C", str(repo), *args], repo, env=env)
444
+ return proc.returncode, proc.stdout.strip(), (proc.stdout + proc.stderr).strip()
445
+
446
+
447
+ def _git_env(repo: Path, *args: str, env: dict[str, str]) -> tuple[int, str]:
448
+ """Like `_git` but runs with an explicit environment — used to point git at a
449
+ throwaway `GIT_INDEX_FILE` so a snapshot can stage the tree without touching
450
+ the real index."""
451
+ proc = _run_git(["git", "-C", str(repo), *args], repo, env=env)
452
+ return proc.returncode, (proc.stdout + proc.stderr).strip()
453
+
454
+
455
+ def git_bytes(
456
+ repo: Path, *args: str, timeout_s: int | None = None
457
+ ) -> subprocess.CompletedProcess[bytes]:
458
+ """Run one `git -C <repo> …` through the chokepoint, capturing raw BYTES.
459
+
460
+ For the callers the `(rc, str)` wrappers above cannot serve, on two counts:
461
+
462
+ * **bytes, not a strict decode.** POSIX filenames are arbitrary bytes, so a
463
+ repo path invalid in the locale codec reaches a `text=True` call on an
464
+ ordinary box. `_run_git` now translates that into `GitError` (#377) rather
465
+ than letting it escape untyped — but a caller whose contract is "answer the
466
+ question or skip silently" wants the bytes themselves, not a raised
467
+ taxonomy member. Decode at the point of use with `os.fsdecode`.
468
+ * **the returncode is an answer, not a fault.** The `CompletedProcess` is
469
+ returned whatever the rc, never `check=True`: `git config --get` of an unset
470
+ key exits 1, and that *is* the reply. Callers branch on `returncode`.
471
+
472
+ Standing inside the chokepoint is what buys the rest: the `LC_ALL=C` pin so
473
+ git's message text stays stable English (#236), and the `_git_timeout_s` bound
474
+ the engine sets from `limits.git_timeout_s` (#156) — or, for an interactive
475
+ caller whose surface must not appear hung, the shorter per-call `timeout_s`
476
+ (#390). The two faults with no rc to return still raise — a timeout as
477
+ `GitError`, a spawn failure as `GitSpawnError` — since neither can be
478
+ expressed as a `CompletedProcess`."""
479
+ return _run_git(["git", "-C", str(repo), *args], repo, binary=True, timeout_s=timeout_s)
480
+
481
+
482
+ def git_version_at_least(reported: str, want: tuple[int, int]) -> bool:
483
+ """Is this `git version …` line at least `want`? Anything unreadable is NO.
484
+
485
+ Only `major.minor` is compared, and only from a line that actually starts
486
+ `git version` — the tail is vendor soup (`2.44.0.windows.1`,
487
+ `2.39.5 (Apple Git-154)`) and searching the whole string for the first two
488
+ dotted numbers would happily read a version out of a build tag.
489
+
490
+ Refusing an unparseable answer is the point rather than a fallback: both callers
491
+ use this to gate something they must not do optimistically — abort a run, or make
492
+ a PERMANENT repo-format change — so the failure it must not have is the generous
493
+ one. A git that will not say what it is does not clear the floor.
494
+
495
+ The minor must END at a delimiter, which is what keeps that promise against
496
+ trailing garbage: without the lookahead `git version 2.34broken` reads as 2.34
497
+ and CLEARS the floor, exactly the generous failure above. A dot or whitespace
498
+ covers every real form — the vendor tails all continue `.` (`2.44.0.windows.1`)
499
+ or break to a space (`2.39.5 (Apple Git-154)`), and a bare `2.34` ends the
500
+ string. Anything else is refused rather than guessed at, which for an unknown
501
+ vendor spelling is a visible refusal instead of a silent pass.
502
+ """
503
+ match = re.match(r"git version (\d+)\.(\d+)(?=[.\s]|$)", reported.strip())
504
+ return match is not None and (int(match[1]), int(match[2])) >= want
505
+
506
+
507
+ def git_below_floor(
508
+ repo: Path, floor: tuple[int, int] = GIT_FLOOR, *, timeout_s: int | None = None
509
+ ) -> str | None:
510
+ """What git called itself, when that is below `floor` or unreadable — else None.
511
+
512
+ Returning the REPORTED TEXT rather than a bool is what lets every caller name the
513
+ version it refused in its own message; `None` is the only "fine" answer, so
514
+ callers test `is not None` and never truthiness (an empty-but-present answer is a
515
+ refusal, not a pass).
516
+
517
+ Split from :func:`git_version_at_least` on purpose. This is the WIRING — probe,
518
+ decode, delegate — and that is the PREDICATE. A test that fakes this one proves a
519
+ call site is reached; a test that drives that one proves the comparison is right.
520
+ Ablating either leaves the other's test green (#464), so they need separate seams
521
+ to be separately provable.
522
+
523
+ `git version` is safe against any path: it does no repository setup, so it exits
524
+ 0 where `rev-parse` fatals 128 on a malformed `.git/config` — the probe answers
525
+ for the git BINARY, never for the repo. A non-zero rc is therefore already a
526
+ fault, and is reported as an unreadable answer rather than swallowed.
527
+
528
+ Raises `GitError` untouched when git could not be run at all — absent or
529
+ unspawnable as `GitSpawnError`, hung as `GitTimeoutError`. That is a different
530
+ fact from "too old" and each caller dispositions it differently, so it is
531
+ deliberately not folded in here.
532
+
533
+ `timeout_s` is the #390 per-call seam, forwarded verbatim: the CLI gates keep
534
+ the engine bound, while a caller that must not stall — the TUI guard, on the
535
+ event loop — asks with its own short deadline and treats the resulting
536
+ `GitTimeoutError` as "could not look" rather than as a refusal, since a bound
537
+ the CLI does not share must not decide a launch."""
538
+ probed = git_bytes(repo, "version", timeout_s=timeout_s)
539
+ reported = os.fsdecode(probed.stdout).strip()
540
+ if probed.returncode != 0:
541
+ return reported or f"git exited {probed.returncode}"
542
+ return None if git_version_at_least(reported, floor) else (reported or "no version reported")
543
+
544
+
545
+ def git_floor_text(floor: tuple[int, int] = GIT_FLOOR) -> str:
546
+ """`GIT_FLOOR` as operators read it — `"2.34"`. One formatter so the four
547
+ messages that name the floor cannot drift apart from each other or from the
548
+ constant."""
549
+ return f"{floor[0]}.{floor[1]}"
550
+
551
+
552
+ def under_floor_git_message(found: str) -> str:
553
+ """The one wording for "this git is below `GIT_FLOOR`", rendered by every
554
+ surface that says it: `cli._reject_under_floor_git`'s abort, `validate`'s
555
+ `git.version` finding, `--dry-run`'s "NOT runnable" banner, and the TUI's
556
+ pre-launch guard.
557
+
558
+ Shared on purpose — those four dispositions (abort, report, preview, toast) are
559
+ verdicts about ONE host fact, and must not read as different findings about it.
560
+ Lives here rather than in `cli` because that is what lets the TUI render it: the
561
+ TUI is an observer over the core modules and importing the CLI into it would
562
+ invert the layering, so the alternative was a second copy of the sentence, which
563
+ is the drift this function exists to make impossible. `GIT_FLOOR`,
564
+ `git_floor_text` and `git_below_floor` are all here too."""
565
+ # `found` is git's own answer, verbatim — usually a whole `git version 2.25.1`
566
+ # line, but also `git exited 127` or `no version reported` when the probe could
567
+ # not read one. Quoted and introduced rather than dropped mid-sentence, so all
568
+ # three shapes read as English (and so "git git version …" cannot happen).
569
+ return (
570
+ f"git reported {found!r}, which is below the floor froid-loop supports — "
571
+ f"git {git_floor_text()} or newer is required. Install a newer git "
572
+ "and re-run (`git --version` reports what is on PATH)."
573
+ )
574
+
575
+
576
+ def rev_parse_head(repo: Path) -> str:
577
+ """The sha HEAD resolves to. Reads stdout alone (`_git_out`): git exits 0 while
578
+ still warning on stderr, and a warning-suffixed "sha" flows into every commit
579
+ comparison and into persisted run baselines (#442)."""
580
+ rc, out, detail = _git_out(repo, "rev-parse", "HEAD")
581
+ if rc != 0:
582
+ raise GitError(f"git rev-parse HEAD failed in {repo}: {detail}")
583
+ return out
584
+
585
+
586
+ def last_commit_for(repo: Path, path: Path) -> str:
587
+ """Sha of the most recent commit touching ``path``, or ``""`` when no commit
588
+ does (an untracked or deleted-without-history file) or the path lies outside
589
+ the repo. Backs the derived provenance of an operator park record, which is
590
+ written into the very commit it rides and so cannot store its own sha. Git
591
+ failures raise :class:`GitError` like every sibling; only the path relation
592
+ degrades silently, mirroring `commit_paths`' outside-the-repo contract. Reads
593
+ stdout alone (`_git_out`), since a git that warns at rc 0 would otherwise make
594
+ this answer a warning-suffixed "sha" (#442)."""
595
+ try:
596
+ rel = Path(path).resolve().relative_to(repo.resolve()).as_posix()
597
+ except (OSError, RuntimeError, ValueError):
598
+ return ""
599
+ rc, out, detail = _git_out(repo, "log", "-n", "1", "--format=%H", "--", rel)
600
+ if rc != 0:
601
+ raise GitError(f"git log failed in {repo}: {detail}")
602
+ return out
603
+
604
+
605
+ def worktree_clean(repo: Path) -> bool:
606
+ """True when the tree holds no change git would report.
607
+
608
+ The orchestrator's own config file (.froid-loop/policy.toml) is excluded: the TUI
609
+ settings editor rewrites it, and a tracked config edit must not count as a "dirty
610
+ tree" that blocks run/sweep/validate or forces a commit. Scope is policy.toml only
611
+ — the deferred-work ledger also lives under .froid-loop/ and is meant to be
612
+ committed (see sweep._commit_ledger).
613
+
614
+ Reads `stdout` ALONE rather than `_git`'s stdout+stderr merge, for the reason
615
+ :func:`path_tracked` spells out: `status` exits 0 while still writing to stderr (a
616
+ `core.fsmonitor` hook that cannot exec, an unknown `core.fsyncMethod`, a stale
617
+ index advisory), and against the merged stream that chatter is indistinguishable
618
+ from a porcelain record — a pristine tree answers DIRTY. That direction is not
619
+ benign here: seven callers gate on it, and `cli.py`'s three refuse the command
620
+ outright, so a host with a noisy git config could never start a run and the
621
+ message would name no file. The error path keeps the merge, where stderr is the
622
+ only informative half."""
623
+ proc = _run_git(
624
+ [
625
+ "git",
626
+ "-C",
627
+ str(repo),
628
+ "status",
629
+ "--porcelain",
630
+ "--",
631
+ ".",
632
+ f":(exclude){POLICY_FILE_REL}",
633
+ ],
634
+ repo,
635
+ )
636
+ if proc.returncode != 0:
637
+ merged = (proc.stdout + proc.stderr).strip()
638
+ raise GitError(f"git status failed in {repo}: {merged}")
639
+ return proc.stdout.strip() == ""
640
+
641
+
642
+ def is_ancestor(repo: Path, ancestor: str, descendant: str) -> bool:
643
+ """True when `ancestor` is an ancestor of (or equal to) `descendant`.
644
+
645
+ Any git failure — unknown ref, shallow history, not a repo, a timeout
646
+ (surfacing as GitError since `_run_git` translates it, #156) — reads as
647
+ False: callers use this to *relax* a gate, so uncertainty must keep the
648
+ gate strict."""
649
+ try:
650
+ code, _ = _git(repo, "merge-base", "--is-ancestor", ancestor, descendant)
651
+ except (OSError, GitError):
652
+ return False
653
+ return code == 0
654
+
655
+
656
+ # A `baseline_revision` that is a Git revision expression rather than an object id
657
+ # (`HEAD`, a branch or tag name, `main~2`) resolves at verification time, not when the
658
+ # session stamped it. Hex spelling is necessary but insufficient: Git also permits
659
+ # all-hex ref names. The stamp is `git rev-parse HEAD` output by contract, so requiring
660
+ # a uniquely disambiguated direct commit costs a well-behaved session nothing. Length
661
+ # floor is git's shortest auto abbreviation: with `core.abbrev` unset the length scales
662
+ # with the repository's object count and clamps upward to 7 only for small repos, so
663
+ # nothing git abbreviates on its own is shorter. Ceiling admits sha256.
664
+ _OBJECT_ID = re.compile(r"\A[0-9a-fA-F]{7,64}\Z")
665
+
666
+
667
+ def _canonical_commit_oid(repo: Path, claim: str) -> str | None:
668
+ """Resolve one immutable commit object id, independent of Git refs.
669
+
670
+ ``claim`` must be 7–64 hexadecimal characters and uniquely identify one
671
+ object through ``rev-parse --disambiguate``. The object itself must be a
672
+ direct commit: blob, tree, and annotated-tag objects are refused rather
673
+ than peeled. The returned value is Git's canonical full object id.
674
+
675
+ Invalid, unresolved, ambiguous, and non-commit claims read as ``None``.
676
+ Operational Git failures retain their typed :class:`GitError` so the
677
+ verification boundary can escalate instead of misreporting a mismatch.
678
+ """
679
+ if not _OBJECT_ID.fullmatch(claim):
680
+ return None
681
+ rc, out, _ = _git_out(repo, "rev-parse", f"--disambiguate={claim}")
682
+ objects = out.splitlines()
683
+ if rc != 0 or len(objects) != 1:
684
+ return None
685
+ oid = objects[0]
686
+ rc, object_type, _ = _git_out(repo, "cat-file", "-t", oid)
687
+ return oid if rc == 0 and object_type == "commit" else None
688
+
689
+
690
+ def commit_reachable_above_baseline(repo: Path, claimed_oid: str, baseline: str) -> bool:
691
+ """Whether a canonical claimed commit descends from ``baseline`` and is
692
+ reachable from this checkout's current ``HEAD``.
693
+
694
+ The caller handles equality first, so a successful call represents the
695
+ strictly newer shape needed when step-03 stamps ``baseline_revision`` after
696
+ an intervening commit. Older, diverged, and off-HEAD commits stay refused.
697
+
698
+ With worktree isolation, the isolated history preserves provenance for the
699
+ unit. In the default shared checkout, reachability cannot identify which
700
+ session produced a commit; the caller therefore re-anchors proof to this
701
+ claim and requires a later tracked, staged, or committed change. The shared
702
+ mode proves only that such work exists after the claim, not who made it.
703
+
704
+ Any Git failure reads as ``False`` because this result relaxes a gate and
705
+ uncertainty must keep the stricter path.
706
+ """
707
+ if not is_ancestor(repo, baseline, claimed_oid):
708
+ return False # older, diverged, or unknown -> not an accepted descendant
709
+ try:
710
+ head = rev_parse_head(repo)
711
+ except (OSError, GitError):
712
+ return False
713
+ return is_ancestor(repo, claimed_oid, head)
714
+
715
+
716
+ def has_changes_since(
717
+ repo: Path,
718
+ baseline: str,
719
+ exclude: tuple[str, ...] = (),
720
+ *,
721
+ baseline_untracked: list[str] | None = None,
722
+ include_untracked: bool = True,
723
+ ) -> bool:
724
+ """True if tracked changes since baseline, or allowed untracked files exist.
725
+
726
+ `exclude` is repo-relative posix dir prefixes whose changes don't count —
727
+ used by the dev/bundle proof-of-work gate to ignore the orchestrator-owned
728
+ FROID artifacts (composed by `verify_dev_exclude_relpaths`, relative to the same
729
+ root this is invoked against), so a session that only rewrites its own spec
730
+ (e.g. the frontmatter-status reconcile) under them doesn't register as real
731
+ implementation work. Mirrors
732
+ `attempt_dirty`'s exclusion. Default `()` keeps the unscoped behavior.
733
+
734
+ `baseline_untracked` is the untracked-file snapshot taken when the baseline
735
+ was recorded; when given, those files already existed before the session ran
736
+ and are subtracted, so pre-session residue (e.g. an earlier halt's saved
737
+ intent-gap patch, which `_protected_relpaths` shields from every reset) can
738
+ never masquerade as this session's work.
739
+
740
+ ``include_untracked=False`` restricts proof to tracked, staged, or committed
741
+ changes. It is used when verification adopts a later descendant baseline:
742
+ the launch-time snapshot cannot establish whether an untracked file appeared
743
+ before or after that later commit. The default preserves every established
744
+ caller and exact-baseline proof.
745
+
746
+ `None` means count EVERY untracked file — deliberately the *opposite* of
747
+ `attempt_dirty`'s `None` = ignore-all, and not an oversight. The two gates
748
+ fail open in opposite directions: a proof-of-work gate must fail open toward
749
+ "work happened" (a pre-snapshot run must not have its gate silently
750
+ weakened into never seeing new files), while a rollback gate must fail open
751
+ toward "nothing to remove" (never delete a file it cannot prove this attempt
752
+ created). Keep it that way."""
753
+ rc, _ = _git(repo, "diff", "--quiet", baseline, "--", ".", *_exclude_specs(exclude))
754
+ if rc != 0:
755
+ return True
756
+ if not include_untracked:
757
+ return False
758
+ created = untracked_files(repo)
759
+ if baseline_untracked is not None:
760
+ created -= set(baseline_untracked)
761
+ created = {p for p in created if not _path_under_any(p, exclude)}
762
+ return bool(created)
763
+
764
+
765
+ def path_changed_since(
766
+ repo: Path,
767
+ baseline: str,
768
+ rel: str,
769
+ *,
770
+ baseline_untracked: list[str] | None = None,
771
+ ) -> bool:
772
+ """Whether one literal repo-relative path changed since ``baseline``.
773
+
774
+ This is the single-path form of :func:`has_changes_since`: tracked content
775
+ is compared to the recorded commit, while an ordinary untracked path counts
776
+ only when the attempt's baseline snapshot did not already contain it.
777
+ ``baseline_untracked=None`` keeps the proof gate's legacy behavior of
778
+ counting every ordinary untracked path. Ignored paths are absent from
779
+ :func:`untracked_files` and therefore cannot become proof of work here.
780
+
781
+ Any non-zero diff result fails open toward "changed", matching the
782
+ authoritative :func:`has_changes_since` gate. The literal pathspec is
783
+ required for operator-configured ledger paths containing Git wildmatch
784
+ characters.
785
+ """
786
+ rc, _ = _git(repo, "diff", "--quiet", baseline, "--", f":(literal){rel}")
787
+ if rc != 0:
788
+ return True
789
+ untracked = untracked_files(repo)
790
+ if rel not in untracked:
791
+ return False
792
+ return baseline_untracked is None or rel not in set(baseline_untracked)
793
+
794
+
795
+ def attempt_dirty(
796
+ repo: Path,
797
+ baseline: str,
798
+ baseline_untracked: list[str] | None,
799
+ exclude: tuple[str, ...] = (),
800
+ ) -> bool:
801
+ """True if a `safe_rollback` to `baseline` would change anything: tracked
802
+ changes since baseline, or untracked files created since the baseline
803
+ snapshot. `baseline_untracked=None` (a pre-snapshot run) means untracked
804
+ files are never this attempt's to remove, so only tracked diff counts. This
805
+ mirrors `safe_rollback`'s notion of what *this attempt* touched, so callers
806
+ can skip a no-op reset/pause when the tree is already at baseline.
807
+
808
+ `exclude` is repo-relative posix dir prefixes (e.g. the FROID artifact
809
+ folders) whose changes are orchestrator-owned and never count as a dev
810
+ attempt's dirtiness — they pair with `safe_rollback`'s `preserve`, so a
811
+ change confined to those folders reads as clean.
812
+
813
+ `policy.toml` (the operator's orchestration config) is *always* excluded: it
814
+ is never a dev attempt's change, `safe_rollback` always restores it, and a
815
+ lone policy edit must not read as dirtiness — otherwise the manual-recovery
816
+ loop could never terminate. Mirrors `worktree_clean`'s exclusion."""
817
+ exclude = (POLICY_FILE_REL, *exclude)
818
+ rc, _ = _git(repo, "diff", "--quiet", baseline, "--", ".", *_exclude_specs(exclude))
819
+ if rc != 0:
820
+ return True
821
+ if baseline_untracked is None:
822
+ return False
823
+ created = untracked_files(repo) - set(baseline_untracked)
824
+ created = {p for p in created if not _path_under_any(p, exclude)}
825
+ return bool(created)
826
+
827
+
828
+ def _entry_at_revision(repo: Path, revision: str, rel: str) -> tuple[str, str, str] | None:
829
+ """Return ``(mode, type, oid)`` for one literal path at ``revision``.
830
+
831
+ ``ls-tree`` gives absence as an empty successful result while keeping an
832
+ invalid revision or object-database fault as a non-zero command. That
833
+ distinction is load-bearing for recovery: absence is a proven baseline
834
+ ownership state; a Git failure is not authority to reset.
835
+ """
836
+ proc = _run_git(
837
+ [
838
+ "git",
839
+ "-C",
840
+ str(repo),
841
+ "ls-tree",
842
+ "-z",
843
+ "--full-tree",
844
+ revision,
845
+ "--",
846
+ *_literal_specs([rel]),
847
+ ],
848
+ repo,
849
+ binary=True,
850
+ )
851
+ if proc.returncode != 0:
852
+ detail = (proc.stdout + proc.stderr).decode("utf-8", "replace").strip()
853
+ raise GitError(f"git ls-tree {revision[:12]} -- {rel} failed in {repo}: {detail}")
854
+ records = [record for record in proc.stdout.split(b"\0") if record]
855
+ if not records:
856
+ return None
857
+ if len(records) != 1 or b"\t" not in records[0]:
858
+ raise GitError(f"git ls-tree returned an ambiguous entry for {rel!r} in {repo}")
859
+ header, path = records[0].split(b"\t", 1)
860
+ if path != os.fsencode(rel):
861
+ raise GitError(f"git ls-tree returned the wrong literal path for {rel!r} in {repo}")
862
+ fields = header.decode("ascii", "strict").split()
863
+ if len(fields) != 3:
864
+ raise GitError(f"git ls-tree returned a malformed entry for {rel!r} in {repo}")
865
+ return fields[0], fields[1], fields[2]
866
+
867
+
868
+ def file_bytes_at_revision(repo: Path, revision: str, rel: str) -> bytes | None:
869
+ """Read one blob byte-exactly from ``revision``.
870
+
871
+ ``None`` means the literal path is absent or names a non-blob (for example a
872
+ directory) at that revision. Git command/object failures raise, so recovery
873
+ can distinguish a proven absent baseline file from an unproven observation.
874
+ Symlinks are blobs too; callers that care about path authority separately
875
+ validate the live regular file and its ancestors.
876
+ """
877
+ entry = _entry_at_revision(repo, revision, rel)
878
+ if entry is None or entry[1] != "blob":
879
+ return None
880
+ oid = entry[2]
881
+ proc = _run_git(
882
+ ["git", "-C", str(repo), "cat-file", "blob", oid],
883
+ repo,
884
+ binary=True,
885
+ )
886
+ if proc.returncode != 0:
887
+ detail = (proc.stdout + proc.stderr).decode("utf-8", "replace").strip()
888
+ raise GitError(f"git cat-file blob {oid[:12]} failed in {repo}: {detail}")
889
+ return proc.stdout
890
+
891
+
892
+ def worktree_file_bytes_at_revision(repo: Path, revision: str, rel: str) -> bytes | None:
893
+ """Materialize one revision blob with the path's working-tree filters.
894
+
895
+ Unlike :func:`file_bytes_at_revision`, this applies Git's smudge, EOL, and
896
+ working-tree-encoding conversions. Recovery uses it only when comparing a
897
+ live checkout file to its baseline: on Git for Windows, a byte-exact LF blob
898
+ may legitimately be a CRLF working-tree file under ``core.autocrlf=true``.
899
+ Absence and non-blobs return ``None``; observation failures raise so callers
900
+ cannot mistake an unproven baseline for restoration authority.
901
+ """
902
+ entry = _entry_at_revision(repo, revision, rel)
903
+ if entry is None or entry[1] != "blob":
904
+ return None
905
+ oid = entry[2]
906
+ proc = _run_git(
907
+ [
908
+ "git",
909
+ "-C",
910
+ str(repo),
911
+ "cat-file",
912
+ "--filters",
913
+ f"--path={rel}",
914
+ oid,
915
+ ],
916
+ repo,
917
+ binary=True,
918
+ )
919
+ if proc.returncode != 0:
920
+ detail = (proc.stdout + proc.stderr).decode("utf-8", "replace").strip()
921
+ raise GitError(f"git cat-file --filters {oid[:12]} for {rel!r} failed in {repo}: {detail}")
922
+ return proc.stdout
923
+
924
+
925
+ def path_has_non_tree_ancestor_at_revision(repo: Path, revision: str, rel: str) -> bool:
926
+ """Whether a parent of ``rel`` is a tracked non-directory at ``revision``.
927
+
928
+ Resetting such a baseline can replace a currently real directory with a
929
+ symlink, file, or submodule. A pre-reset canonical child path is therefore no
930
+ longer safe restoration authority after the reset.
931
+ """
932
+ parts = Path(rel).parts
933
+ for end in range(1, len(parts)):
934
+ entry = _entry_at_revision(repo, revision, Path(*parts[:end]).as_posix())
935
+ if entry is not None and entry[1] != "tree":
936
+ return True
937
+ return False
938
+
939
+
940
+ def path_is_non_regular_at_revision(repo: Path, revision: str, rel: str) -> bool:
941
+ """Whether ``rel`` exists at ``revision`` but is not a regular Git file.
942
+
943
+ An absent final path is safe for snapshot restoration: reset may remove the
944
+ live file and recovery recreates it. A regular blob (mode ``100644`` or
945
+ ``100755``) is safe for the same reason. Trees, symlinks, gitlinks, and any
946
+ unknown mode would change the meaning of the canonical live path during a
947
+ reset, so recovery must refuse before mutating the checkout.
948
+ """
949
+ entry = _entry_at_revision(repo, revision, rel)
950
+ return entry is not None and (entry[1] != "blob" or entry[0] not in {"100644", "100755"})
951
+
952
+
953
+ def index_path_changed_since(repo: Path, revision: str, rel: str) -> bool:
954
+ """Whether one literal index entry differs from ``revision``.
955
+
956
+ This sees index-only ownership mutations that a byte snapshot cannot: a
957
+ failed child force-adding an ignored input, removing a tracked input from the
958
+ index, or staging different content before restoring the working-tree bytes.
959
+ """
960
+ rc, out = _git(
961
+ repo,
962
+ "diff",
963
+ "--cached",
964
+ "--quiet",
965
+ revision,
966
+ "--",
967
+ *_literal_specs([rel]),
968
+ )
969
+ if rc not in (0, 1):
970
+ raise GitError(f"git diff --cached {revision[:12]} -- {rel} failed in {repo}: {out}")
971
+ return rc == 1
972
+
973
+
974
+ def frontmatter_status_at_revision(repo: Path, revision: str, rel: str) -> str | None:
975
+ """Read one tracked file's normalized frontmatter status from ``revision``.
976
+
977
+ This is the baseline oracle for attempt-owned lifecycle recovery. The file
978
+ content is read through Git's object database, not the mutable checkout, and
979
+ decoded from bytes so an undecodable historical blob degrades to no usable
980
+ status instead of escaping the git subprocess chokepoint. Missing files,
981
+ malformed YAML, non-mapping frontmatter, and missing/blank statuses likewise
982
+ return ``None``; a caller must not repair from an unproven baseline.
983
+ """
984
+ raw = file_bytes_at_revision(repo, revision, rel)
985
+ if raw is None:
986
+ return None
987
+ try:
988
+ text = raw.decode("utf-8")
989
+ except UnicodeDecodeError:
990
+ return None
991
+ split = _split_frontmatter(text)
992
+ if split is None:
993
+ return None
994
+ try:
995
+ doc = yaml.safe_load(split[1])
996
+ except yaml.YAMLError:
997
+ return None
998
+ if not isinstance(doc, dict) or "status" not in doc:
999
+ return None
1000
+ status = status_of(doc)
1001
+ return status or None
1002
+
1003
+
1004
+ def reset_index_path(repo: Path, revision: str, rel: str) -> None:
1005
+ """Restore one literal path's index ownership from ``revision`` only.
1006
+
1007
+ The working-tree file is deliberately left in place. Recovery uses this
1008
+ after a preserved-folder checkout when the attempt baseline had no blob at
1009
+ ``rel``: the pre-launch ignored/untracked spec must not become a staged add
1010
+ merely because a failed child force-added or committed that same name.
1011
+ """
1012
+ proc = _run_git(
1013
+ [
1014
+ "git",
1015
+ "-C",
1016
+ str(repo),
1017
+ "reset",
1018
+ "--quiet",
1019
+ revision,
1020
+ "--",
1021
+ *_literal_specs([rel]),
1022
+ ],
1023
+ repo,
1024
+ )
1025
+ if proc.returncode != 0:
1026
+ detail = (proc.stdout + proc.stderr).strip()
1027
+ raise GitError(f"git reset {revision[:12]} -- {rel} failed in {repo}: {detail}")
1028
+
1029
+
1030
+ def _exclude_specs(dirs: tuple[str, ...]) -> list[str]:
1031
+ """git pathspec `:(exclude,literal)<dir>` args for each repo-relative dir prefix.
1032
+
1033
+ `literal` for the same reason as :func:`_literal_specs` — git reads a positional
1034
+ operand as a PATHSPEC, so `[`, `]`, `*` and `?` in an operator-configured dir are
1035
+ wildmatch metacharacters — but the harm here runs the other way: an over-matching
1036
+ exclusion HIDES a diff instead of exposing a file. `has_changes_since` and
1037
+ `attempt_dirty` both spend these on `diff --quiet . :(exclude)<dir>`, so a dir
1038
+ whose name carries a `*` excludes a sibling tree as well and the attempt reads
1039
+ CLEAN when it changed — the same false "no changes" that a dev attempt's dirtiness
1040
+ check exists to prevent (#423 item 3).
1041
+
1042
+ It also realigns this half with :func:`_path_under_any`, the Python `startswith`
1043
+ that filters the untracked half of the very same `has_changes_since` call. The two
1044
+ disagreed on exactly the shapes that glob (#423 item 4): the tracked half excluded
1045
+ a path the untracked half still counted, so one function's two branches answered
1046
+ differently about what "under the artifact dir" means. Literal is the reading
1047
+ `_path_under_any` already implements, which is why fixing the git half is the right
1048
+ direction and not a coin flip.
1049
+
1050
+ `*` and `?` both cross `/` here — `FNM_PATHNAME` is opt-in via `:(glob)`, which this
1051
+ never sets — but only a `*` reaches a sibling DIRECTORY's contents; a same-length
1052
+ `[…]`/`?` collision reaches a sibling FILE only, because the pathspec has to match
1053
+ the whole path. Both magic words go in ONE comma-separated `:(...)` prefix — the
1054
+ global `--literal-pathspecs` / `GIT_LITERAL_PATHSPECS` form would disarm the
1055
+ `:(exclude)` magic too and silently stop excluding anything at all. Stable across
1056
+ the supported range, `GIT_FLOOR` to current."""
1057
+ return [f":(exclude,literal){d}" for d in dirs]
1058
+
1059
+
1060
+ def _literal_specs(rels: list[str]) -> list[str]:
1061
+ """git pathspec `:(literal)<rel>` for each repo-relative posix path — the operand
1062
+ form that means "this path", not "this glob". See `path_tracked` for why."""
1063
+ return [f":(literal){r}" for r in rels]
1064
+
1065
+
1066
+ def _path_under_any(path: str, prefixes: tuple[str, ...]) -> bool:
1067
+ """True if repo-relative posix `path` equals or sits under any `prefixes` dir.
1068
+
1069
+ The literal reading of "under", and since #423 item 4 the one `_exclude_specs`
1070
+ agrees with — the two filter the tracked and untracked halves of a single
1071
+ `has_changes_since` answer and must not disagree."""
1072
+ return any(path == p or path.startswith(p.rstrip("/") + "/") for p in prefixes)
1073
+
1074
+
1075
+ def untracked_files(repo: Path) -> set[str]:
1076
+ """Untracked, non-ignored paths (repo-relative posix), mirroring what a
1077
+ plain `git clean -fd` (no -x) treats as removable. Ignored files are
1078
+ excluded, so they are never rollback candidates.
1079
+
1080
+ Reads stdout ALONE (`_git_out`): `ls-files` exits 0 while still writing to
1081
+ stderr, and against `_git`'s merged stream that chatter splits into a phantom
1082
+ untracked path — a PRISTINE tree answers with one, and because this function's
1083
+ contract is what `git clean -fd` would remove, the phantom is a rollback
1084
+ candidate. Silent, on every host whose git config warns (#442)."""
1085
+ rc, out, detail = _git_out(repo, "ls-files", "--others", "--exclude-standard")
1086
+ if rc != 0:
1087
+ raise GitError(f"git ls-files --others failed in {repo}: {detail}")
1088
+ return {line.strip() for line in out.splitlines() if line.strip()}
1089
+
1090
+
1091
+ def path_tracked(repo: Path, rel: str) -> bool:
1092
+ """True when repo-relative posix ``rel`` has an index entry — i.e. git OWNS the
1093
+ path, so a `reset --hard` restores it and no caller should delete it by hand.
1094
+
1095
+ The single-path complement of :func:`untracked_files`, and the pair is what makes
1096
+ the third state legible: neither tracked nor in that set means IGNORED, which no
1097
+ rollback step touches at all (`reset --hard` skips it, and this module never runs
1098
+ `git clean`). A caller that reasons only over "tracked vs untracked" silently
1099
+ files every ignored path under whichever branch it wrote last.
1100
+
1101
+ Only the output's EMPTINESS is read, never its text: `core.quotePath` mangles
1102
+ non-ASCII names, and a tracked-but-deleted-from-the-worktree path still lists (the
1103
+ index entry outlives the file), which is exactly the state a caller must not
1104
+ mistake for "not git's". Not `--error-unmatch`, which reports "not tracked" and
1105
+ "git blew up" with the same non-zero rc; not `check-ignore`, which answers whether
1106
+ a RULE matches rather than whether git owns the path — a `git add -f`'d file under
1107
+ an ignore rule has to read tracked here.
1108
+
1109
+ Reads `stdout` ALONE, not `_git`'s stdout+stderr merge: `ls-files` exits 0 while
1110
+ still writing to stderr — a `core.fsmonitor` hook that cannot exec, an unknown
1111
+ `core.fsyncMethod` — and against the merged stream that chatter reads as an index
1112
+ entry for a path git does not track at all. The failure is silent and inverted
1113
+ (untracked answers "tracked"), so callers act on the opposite of the truth. The
1114
+ error path keeps the merge, where stderr is the only informative half.
1115
+
1116
+ The pathspec is forced LITERAL, because git reads a positional operand as a
1117
+ PATHSPEC and not as a path: `[`, `]`, `*` and `?` in ``rel`` are wildmatch
1118
+ metacharacters, so a probe for an ABSENT path answers True the moment some OTHER
1119
+ tracked path happens to match the glob. The error is one-directional —
1120
+ `match_pathspec_item` compares literally before it falls through to fnmatch, so a
1121
+ genuinely tracked path never reads untracked — and it runs toward the answer that
1122
+ authorizes leaving a file alone, which is how a harvested ledger under an
1123
+ operator-named `implementation_artifacts` (`froidconfig._resolve` takes that key
1124
+ verbatim, metacharacters and all) outlived the rollback that discarded the code it
1125
+ described. Not the global `--literal-pathspecs` / `GIT_LITERAL_PATHSPECS` form,
1126
+ which would also disarm the `:(exclude)` magic `worktree_clean`, `has_changes_since`
1127
+ and `attempt_dirty` are built on; the per-operand prefix is scoped to this call. It
1128
+ costs the callers nothing: that same literal comparison is what matches a DIRECTORY
1129
+ prefix, so `_froid/render` still lists everything beneath it (`cmd_validate`'s
1130
+ render-tracked warning), and it additionally disarms a ``rel`` that itself begins
1131
+ with `:`, which git would otherwise parse as magic and answer the empty set for.
1132
+ Stable across the supported range, `GIT_FLOOR` to current;
1133
+ below a version that understands the prefix it would read as a literal FILENAME,
1134
+ match nothing and answer False, which is the one direction that authorizes a
1135
+ delete.
1136
+
1137
+ Raises GitError like every other probe in this module. Callers inside a rollback
1138
+ `finally` catch it and degrade toward leaving the file alone: uncertainty must
1139
+ never authorize a delete. The message keeps the BARE ``rel``: the operator's path is
1140
+ its informative half and the magic prefix is our own plumbing.
1141
+
1142
+ Three live callers, all reached through this one chokepoint: `git.render-tracked`
1143
+ (`cmd_validate`), `_ledger_is_gits_to_restore` (the harvest revert) and
1144
+ `_harvest_carry_commit_may_degrade` (the isolation carry)."""
1145
+ proc = _run_git(["git", "-C", str(repo), "ls-files", "--", *_literal_specs([rel])], repo)
1146
+ if proc.returncode != 0:
1147
+ merged = (proc.stdout + proc.stderr).strip()
1148
+ raise GitError(f"git ls-files -- {rel} failed in {repo}: {merged}")
1149
+ return bool(proc.stdout.strip())
1150
+
1151
+
1152
+ def path_tracked_kind(repo: Path, rel: str) -> Literal["untracked", "file", "dir"]:
1153
+ """Which of three states repo-relative posix ``rel`` holds in the index: absent from
1154
+ it, a tracked regular FILE, or a tracked DIRECTORY prefix.
1155
+
1156
+ The distinction :func:`path_tracked` deliberately does not draw. Its literal
1157
+ pathspec matches a directory prefix too — that is load-bearing there, which is why
1158
+ `_froid/render` answers True for the whole tree beneath it — so a caller that must
1159
+ know *which* of the three it holds cannot get it from that boolean.
1160
+
1161
+ ONE `ls-files` spawn answers all three, because the pathspec's literal comparison is
1162
+ itself what separates them: a tracked file lists exactly the name asked for, a
1163
+ tracked directory lists the entries BENEATH it (never the directory's own name), and
1164
+ a path with no index entry lists nothing. So the empty set is "untracked", the
1165
+ singleton `{rel}` is "file", and any other non-empty set is "dir". A D/F-conflicted
1166
+ index — one name carrying both a file entry and entries beneath it — therefore
1167
+ answers "dir", which degrades toward substituting per-file patterns for what
1168
+ provisioning actually wrote: still a shield over our own files, and wrong in the
1169
+ spare-a-pattern direction rather than the leaking one.
1170
+
1171
+ The worktree git-add shield is what needs the three apart, because they want three
1172
+ different treatments (#392, #484). Measured, git 2.55.0:
1173
+
1174
+ * An exclude pattern naming a tracked regular FILE suppresses NOTHING. git consults
1175
+ ignore rules only for untracked paths, so `git add -A` stages a modification to it
1176
+ regardless. The pattern's only effect is to make the file answer
1177
+ `ls-files -ci --exclude-standard`, i.e. read as tracked-and-ignored — which is a
1178
+ state repo-hygiene gates reject, and how a shield meant to keep the orchestrator's
1179
+ files OUT of a story commit came to block one instead. The pattern is dropped.
1180
+ * The same pattern over a tracked DIRECTORY really does hide new children, and no
1181
+ pattern shape keeps that AND clears the `-ci` report: `dir/*`, `dir/**` and a
1182
+ trailing negation all measured identical to `dir`, because gitignore cannot
1183
+ re-include anything under an excluded parent. That measurement stands; the verdict
1184
+ it once carried — keep the dir pattern, accept the report — is REVERSED (#484).
1185
+ Over a tracked directory the protection is already mostly inert, since
1186
+ modifications to tracked children stage regardless, so the pattern was buying only
1187
+ new-child coverage at the price of a false tracked-and-ignored report across the
1188
+ whole tree. It is replaced by one pattern per untracked file provisioning wrote.
1189
+ The residual — a session-created NEW child under a tracked tool directory can be
1190
+ staged — is accepted, and matches the project's own decision to track that tree.
1191
+ * An UNTRACKED path keeps its pattern unchanged: full protection, and nothing
1192
+ beneath it can answer `-ci` in the first place.
1193
+
1194
+ `-z` and the BYTES accessor, unlike :func:`path_tracked`. This reads the output's
1195
+ TEXT rather than only its emptiness, so that function's reason for never looking —
1196
+ `core.quotePath` mangling non-ASCII names — becomes this one's problem instead.
1197
+ NUL-delimited output is never quoted, and comparing `os.fsencode(rel)` keeps a POSIX
1198
+ name that is undecodable in the locale codec comparable rather than raising (#377).
1199
+
1200
+ The pathspec is forced LITERAL for BOTH directions of the metacharacter hazard, not
1201
+ just the sibling's one. Reading the text already refuses a glob's false positive on
1202
+ an ABSENT ``rel``: the stray match comes back under the NEIGHBOUR'S name, which is
1203
+ not the name asked for, so the set differs whatever the pathspec. What needs
1204
+ `:(literal)` is the opposite direction — when ``rel`` carries `[`, `]`, `*` or `?`
1205
+ and a glob-colliding neighbour is tracked too, a bare pathspec returns BOTH names.
1206
+ The set then exceeds the singleton and a genuine tracked FILE reads "dir", so the
1207
+ shield substitutes patterns for a tree it never wrote, for any project whose hook
1208
+ config or skill tree carries a metacharacter.
1209
+
1210
+ Raises GitError like every other probe in this module; the shield's caller degrades
1211
+ by KEEPING the pattern it already holds, since a leaked seed file in a story commit
1212
+ is the worse of the two failures."""
1213
+ proc = git_bytes(repo, "ls-files", "-z", "--", *_literal_specs([rel]))
1214
+ if proc.returncode != 0:
1215
+ merged = (proc.stdout + proc.stderr).decode("utf-8", "replace").strip()
1216
+ raise GitError(f"git ls-files -z -- {rel} failed in {repo}: {merged}")
1217
+ entries = {entry for entry in proc.stdout.split(b"\0") if entry}
1218
+ if not entries:
1219
+ return "untracked"
1220
+ return "file" if entries == {os.fsencode(rel)} else "dir"
1221
+
1222
+
1223
+ def path_tracked_file(repo: Path, rel: str) -> bool:
1224
+ """True when repo-relative posix ``rel`` is tracked AND names a regular FILE rather
1225
+ than a directory prefix.
1226
+
1227
+ The two-state read of :func:`path_tracked_kind`, which owns the mechanics and the
1228
+ doctrine. Kept for `_pin_tracked_config_rewrite` (`worktree_flow`), whose question
1229
+ really is yes/no: only a tracked file can carry the skip-worktree bit the pin
1230
+ depends on, and both other kinds mean there is nothing to pin. A caller that has to
1231
+ tell a tracked DIRECTORY from an untracked path asks the tri-state probe itself."""
1232
+ return path_tracked_kind(repo, rel) == "file"
1233
+
1234
+
1235
+ def _blob_oid_for_file(repo: Path, rel: str, path: Path) -> str:
1236
+ """The object id git would record for the bytes in ``path``, taken as content for
1237
+ repo-relative posix ``rel``.
1238
+
1239
+ `--path=` is what makes ``path`` and ``rel`` separable: it drives the attribute
1240
+ lookup, so a file living anywhere — a shadow copy outside the repo included — is
1241
+ hashed under the rules that govern ``rel``. Verified load-bearing at git 2.55.0:
1242
+ with `board.yaml text eol=crlf`, a CRLF twin named something else hashes to HEAD's
1243
+ id with the flag and to a different id without it.
1244
+
1245
+ Raises rather than answering a sentinel. The one caller gates a repair write on the
1246
+ comparison, and an id it could not compute must not read as "these differ" any more
1247
+ than as "these match"."""
1248
+ proc = git_bytes(repo, "hash-object", "-t", "blob", f"--path={rel}", "--", str(path))
1249
+ if proc.returncode != 0:
1250
+ detail = (proc.stdout + proc.stderr).decode("utf-8", "replace").strip()
1251
+ raise GitError(f"git hash-object --path={rel} for {path} failed in {repo}: {detail}")
1252
+ return proc.stdout.decode("ascii", "strict").strip()
1253
+
1254
+
1255
+ def file_holds_content(repo: Path, rel: str, path: Path, data: bytes) -> bool:
1256
+ """Whether the file at ``path`` holds ``data``, as GIT counts sameness for ``rel``.
1257
+
1258
+ Asks git's own question instead of guessing a domain. A byte compare has to know
1259
+ which end of the checkin/checkout round trip the file on disk sits at, and there is
1260
+ no answer that holds: under `core.autocrlf=true` — Git for Windows' system default,
1261
+ which the suite deliberately leaves reachable — a freshly checked-out board is CRLF
1262
+ while one an editor or a byte-writing tool left is LF, and git calls the tree clean
1263
+ either way. Measured both ways at git 2.55.0: a baseline read raw refuses the CRLF
1264
+ checkout, a baseline read through the smudge refuses the LF one, and the two
1265
+ failures are the same mistake pointing opposite directions. Hashing both sides
1266
+ through the CLEAN filter collapses that distinction, because it is precisely the
1267
+ distinction git itself does not draw.
1268
+
1269
+ What it does NOT collapse is content: an operator's added row survives cleaning and
1270
+ still answers False, which is the only discrimination the caller wants.
1271
+
1272
+ ``data`` is hashed from a shadow file rather than stdin because the git chokepoint
1273
+ spawns without one, and widening it for a single caller would put a stdin path
1274
+ through every git call in the module."""
1275
+ return _blob_oid_for_file(repo, rel, path) == _blob_oid_for_bytes(repo, rel, data)
1276
+
1277
+
1278
+ def _blob_oid_for_bytes(repo: Path, rel: str, data: bytes) -> str:
1279
+ """``_blob_oid_for_file`` for bytes that are not on disk, via a shadow copy — the
1280
+ git chokepoint spawns without a stdin, and widening it for one caller would put a
1281
+ stdin path through every git call in the module."""
1282
+ with tempfile.TemporaryDirectory() as tmp:
1283
+ shadow = Path(tmp) / "intended"
1284
+ shadow.write_bytes(data)
1285
+ return _blob_oid_for_file(repo, rel, shadow)
1286
+
1287
+
1288
+ def index_holds_no_foreign_content(repo: Path, rel: str, data: bytes) -> bool:
1289
+ """Whether the INDEX entry for ``rel`` is safe to overwrite with ``data``.
1290
+
1291
+ Git holds a path in two places and `commit_paths` writes both: `git add` copies the
1292
+ WORKING TREE into the commit and overwrites the INDEX in place. A staged version
1293
+ distinct from both HEAD and ``data`` therefore exists nowhere afterwards — not in
1294
+ the commit, which took the working tree, and not on disk — so proving only the
1295
+ working tree is not proving the carry destroys nothing. Measured: with the operator's
1296
+ edit staged and the working tree restored, the carry commits, the row is absent from
1297
+ HEAD and from disk, and the tree reads clean, the bytes surviving only as a dangling
1298
+ blob (#618).
1299
+
1300
+ True when the index holds HEAD's own content, or already holds ``data``, or holds
1301
+ no entry for a path HEAD does not carry either — the first loses nothing git cannot
1302
+ still reach, the second is the write itself, and the third is empty in both places
1303
+ at once. Unmerged stages raise rather than answer: a half-resolved index is not a
1304
+ state this can prove anything about.
1305
+
1306
+ An absent entry is NOT by itself "nothing to overwrite", which is why HEAD is
1307
+ consulted before accepting one. With HEAD carrying the path, no index entry is a
1308
+ staged DELETION — `git rm --cached`, the operator untracking a board they are
1309
+ about to gitignore, which is a shape this project documents rather than an exotic
1310
+ one — and the carry's `git add` restores the entry, leaving that intent nowhere:
1311
+ not in HEAD, which never had it, and not in the index that just lost it. Measured.
1312
+
1313
+ CONTENT is the whole of what this proves, and that ceiling is deliberate.
1314
+ ``ls-files -s`` reports the entry's MODE too and this reads only the oid beside
1315
+ it, so an operator who stages nothing but an exec-bit flip
1316
+ (``update-index --chmod=+x``) leaves the blob identical, is approved here, and has
1317
+ that staged mode reset by the carry's ``git add``. 100644/100755 is the only pair
1318
+ that can reach it — a symlink entry (120000) carries a different blob, which the
1319
+ oid compare already catches — so the exposure is exactly an exec bit on a YAML
1320
+ data file that nothing reads and nothing runs. Widening the proof to index
1321
+ metadata no workflow here sets would protect nothing and hand the carry one more
1322
+ way to refuse an ordinary board. A caller must not read this as "the index entry
1323
+ is untouched"; it says "the index holds no content this write would lose".
1324
+ """
1325
+ proc = git_bytes(repo, "ls-files", "-s", "-z", "--", *_literal_specs([rel]))
1326
+ if proc.returncode != 0:
1327
+ detail = (proc.stdout + proc.stderr).decode("utf-8", "replace").strip()
1328
+ raise GitError(f"git ls-files -s -- {rel} failed in {repo}: {detail}")
1329
+ # Read once, up here: BOTH branches below need HEAD, because whether an index
1330
+ # state is safe to overwrite is never answerable from the index alone.
1331
+ head = _entry_at_revision(repo, "HEAD", rel)
1332
+ head_oid = head[2] if head is not None and head[1] == "blob" else None
1333
+ records = [r for r in proc.stdout.split(b"\0") if r]
1334
+ if not records:
1335
+ return head_oid is None # empty in both places; otherwise a staged deletion
1336
+ if len(records) != 1:
1337
+ raise GitError(f"git ls-files -s returned unmerged stages for {rel!r} in {repo}")
1338
+ fields = records[0].split(b"\t", 1)[0].decode("ascii", "strict").split()
1339
+ if len(fields) != 3:
1340
+ raise GitError(f"git ls-files -s returned a malformed entry for {rel!r} in {repo}")
1341
+ staged = fields[1]
1342
+ return staged in {head_oid, _blob_oid_for_bytes(repo, rel, data)}
1343
+
1344
+
1345
+ def path_ignored(repo: Path, path: Path) -> bool:
1346
+ """True when `git add` would REFUSE ``path`` for being ignored — i.e. an ignore
1347
+ rule matches it AND git does not already track it (#577).
1348
+
1349
+ Both halves matter, and `check-ignore` answers both in one call: it consults the
1350
+ INDEX unless told not to (`--no-index` exists precisely to turn that off), so a
1351
+ tracked file under a matching rule reads NOT ignored here — rc 1 — which is
1352
+ exactly what `git add` does with it. That asymmetry is the #392 one seen from the
1353
+ other side: a rule over a tracked regular file suppresses nothing, because git
1354
+ consults ignore rules only for untracked paths. A probe that read the RULE alone
1355
+ would answer "ignored" for a `git add -f`'d board and drop it from a commit git
1356
+ would have taken. Measured, git 2.55.0.
1357
+
1358
+ Takes an absolute `Path` rather than a repo-relative rel like its two siblings
1359
+ above, because its caller holds a CONFIGURED path (`ProjectPaths.sprint_status`,
1360
+ resolved out of the operator's `_froid/bmm/config.yaml`) rather than a git-derived
1361
+ relpath, and the answer has to be about the same rel :func:`commit_paths` will
1362
+ derive from that same `Path`. Deriving it twice in two modules is how the two
1363
+ drift apart. Out-of-repo answers False for the same reason `commit_paths` skips
1364
+ such a path: there is nothing here to omit from a commit that will not contain it.
1365
+
1366
+ The one probe in this module that CANNOT force a literal operand: `check-ignore`
1367
+ parses pathspec magic and then rejects it outright — `:(literal)x` exits 128 with
1368
+ "pathspec magic not supported by this command" (measured, git 2.55.0) — so the
1369
+ hardening `path_tracked` documents at length is unavailable. It is also not
1370
+ needed for the glob half: gitignore matching makes the PATTERN the wildmatch and
1371
+ the pathname a literal, so `[`, `]`, `*` and `?` in ``rel`` are inert (verified
1372
+ against a repo holding both `d[a]/b.yaml` and `da/b.yaml` under a `da/` rule —
1373
+ the metacharacter path correctly reads not-ignored).
1374
+
1375
+ The leading `./` is what stands in for it, and it is load-bearing: magic is
1376
+ recognized only on an operand that STARTS with `:`, so a prefix that is a no-op
1377
+ as a path is enough to make git read the whole rel as a pathname. Without it a
1378
+ ``rel`` beginning with `:` fails TWO ways, not one, and the quiet way is the
1379
+ reason this is not merely cosmetic (both measured, git 2.55.0):
1380
+
1381
+ - UNSUPPORTED magic (`:(literal)`, `:(icase)`, `:!`, `:^`) exits 128, which
1382
+ surfaces as `GitError` and degrades to "not ignored" — a gitignored board then
1383
+ stays in `confirm`'s operand list and `git add` refuses the WHOLE list with it,
1384
+ losing the spec and park-record writes that #577 exists to keep.
1385
+ - SUPPORTED magic (`:(top)`, `:/`) is accepted, and git answers about the path
1386
+ the magic DENOTES rather than the file named on disk: `:(top)board.yaml` reads
1387
+ rc 0 whenever a plain `board.yaml` is ignored, even though nothing matches the
1388
+ real file. That is a silent wrong answer in the drop direction — `confirm`
1389
+ would omit a perfectly committable board and never commit its advance.
1390
+
1391
+ Verified that `./` costs neither contract above: the index consultation still
1392
+ reads a tracked-and-ignored board as not-ignored, and `d[a]/f.md` still reads
1393
+ literally beside an ignored `da/`.
1394
+
1395
+ Raises GitError like every other probe in this module. Its caller degrades by
1396
+ treating the path as NOT ignored, which keeps it in the commit — the behavior
1397
+ before this function existed, and the direction that cannot lose a write."""
1398
+ try:
1399
+ repo_root = repo.resolve()
1400
+ rel = Path(path).resolve().relative_to(repo_root).as_posix()
1401
+ except (OSError, RuntimeError, ValueError):
1402
+ return False
1403
+ # `./` disarms pathspec magic on a rel beginning with `:` — see the docstring;
1404
+ # it is not decoration. `rel` is already posix-separated and relative, so the
1405
+ # prefix is a pure no-op as a path on every platform.
1406
+ operand = f"./{rel}"
1407
+ proc = _run_git(["git", "-C", str(repo), "check-ignore", "-q", "--", operand], repo)
1408
+ # 0 = ignored, 1 = not; anything else is git failing rather than answering, and
1409
+ # `-q` means there is no output to misread either way.
1410
+ if proc.returncode not in (0, 1):
1411
+ merged = (proc.stdout + proc.stderr).strip()
1412
+ raise GitError(f"git check-ignore -- {operand} failed in {repo}: {merged}")
1413
+ return proc.returncode == 0
1414
+
1415
+
1416
+ def commits_above(repo: Path, baseline: str) -> list[str]:
1417
+ """Commit shas reachable from HEAD but not from ``baseline`` — the commits an
1418
+ attempt added on top of its pre-attempt baseline, in ``git rev-list`` order (do
1419
+ not assume a strict newest-first / HEAD-first ordering across merges or clock
1420
+ skew; callers that need the tip should read HEAD directly). Empty when HEAD is
1421
+ at or behind baseline. Raises GitError on a git failure (a bad baseline is a
1422
+ real error, never quietly "no commits").
1423
+
1424
+ Reads stdout ALONE (`_git_out`): git exits 0 while still warning on stderr, and
1425
+ against the merged stream that warning is a phantom sha handed to
1426
+ :func:`preserve_commits` — "Empty when HEAD is at or behind baseline" stops
1427
+ holding on any host whose git config warns (#442)."""
1428
+ rc, out, detail = _git_out(repo, "rev-list", f"{baseline}..HEAD")
1429
+ if rc != 0:
1430
+ raise GitError(f"git rev-list {baseline}..HEAD failed in {repo}: {detail}")
1431
+ return [line for line in out.splitlines() if line]
1432
+
1433
+
1434
+ def preserve_commits(
1435
+ repo: Path, baseline: str, ref_name: str, commits: list[str] | None = None
1436
+ ) -> str | None:
1437
+ """Park the commits an attempt made above ``baseline`` under a branch at HEAD
1438
+ so a following ``git reset --hard baseline`` cannot orphan them — they survive
1439
+ `git gc` and are recoverable by name, not just via the reflog. Returns
1440
+ ``ref_name`` on success; ``None`` when there is nothing to preserve (HEAD at/
1441
+ below baseline) or the branch could not be created (the caller must then refuse
1442
+ to reset rather than silently destroy committed work). ``-f`` because a retry
1443
+ within the same run may re-preserve the same head under the same name.
1444
+
1445
+ ``commits`` lets a caller that already ran :func:`commits_above` pass the result
1446
+ in to skip a second ``git rev-list`` subprocess; ``None`` self-fetches (keeps the
1447
+ helper standalone/testable).
1448
+
1449
+ ``None`` means *nothing to preserve* — never a failure. If commits exist but the
1450
+ branch cannot be created this raises :class:`GitError` (consistent with the rest
1451
+ of this module), so a caller can never mistake a preservation failure for a
1452
+ harmless no-op and reset past committed work."""
1453
+ if commits is None:
1454
+ commits = commits_above(repo, baseline)
1455
+ if not commits:
1456
+ return None
1457
+ rc, out = _git(repo, "branch", "-f", ref_name, "HEAD")
1458
+ if rc != 0:
1459
+ raise GitError(f"git branch -f {ref_name} HEAD failed in {repo}: {out}")
1460
+ return ref_name
1461
+
1462
+
1463
+ class PrunePreserveError(GitError):
1464
+ """Partial :func:`prune_preserve_refs` / :func:`prune_preserve_dirty_refs`
1465
+ failure. The prune is per-ref best-effort, so refs may already be gone when
1466
+ a later one sticks — the ``deleted`` list keeps that destructive half
1467
+ structurally auditable (a caller can journal it, not just grep the message)
1468
+ and ``failed`` names each stuck ref with its git detail."""
1469
+
1470
+ def __init__(self, message: str, *, deleted: list[str], failed: list[str]) -> None:
1471
+ super().__init__(message)
1472
+ self.deleted = deleted
1473
+ self.failed = failed
1474
+
1475
+
1476
+ def _prune_refs(
1477
+ repo: Path,
1478
+ keep: int,
1479
+ prefix: str,
1480
+ *,
1481
+ label: str,
1482
+ strip: str,
1483
+ delete: Callable[[str], None],
1484
+ ) -> list[str]:
1485
+ """Shared retention loop behind the per-family pruners: list the refs under
1486
+ ``prefix``, keep the ``keep`` newest by committer date, best-effort delete
1487
+ the tail via ``delete``, and return the deleted names. ``strip`` is removed
1488
+ from each refname before it is deleted/reported (``refs/heads/`` for the
1489
+ branch family, nothing for bare refs). ``keep <= 0`` means "never prune" —
1490
+ returns ``[]`` without running git.
1491
+
1492
+ Raises :class:`GitError` when the listing fails, or
1493
+ :class:`PrunePreserveError` — after attempting every tail ref — when any
1494
+ individual delete failed. One stuck ref must not wedge the retention for
1495
+ everything behind it, so deletes are per-ref best-effort and the error
1496
+ carries both what was deleted and what was not.
1497
+
1498
+ Reads stdout ALONE (`_git_out`): `for-each-ref` exits 0 while still warning on
1499
+ stderr, and against `_git`'s merged stream that warning enters ``refs``, lands
1500
+ in the ``refs[keep:]`` tail and is handed to ``delete`` — which fails, so
1501
+ retention raises :class:`PrunePreserveError` on every host whose git config
1502
+ warns (#442)."""
1503
+ if keep <= 0:
1504
+ return []
1505
+ rc, out, detail = _git_out(
1506
+ repo,
1507
+ "for-each-ref",
1508
+ # ties on committerdate (same-second rollbacks) break by ascending
1509
+ # refname — an explicit, observable order rather than git's implicit
1510
+ # stable-sort fallback. Last --sort key is the primary one.
1511
+ "--sort=refname",
1512
+ "--sort=-committerdate",
1513
+ # full refname, not :short — a tag or remote ref sharing the name would
1514
+ # make :short emit an ambiguous form the deleter can't use
1515
+ "--format=%(refname)",
1516
+ prefix,
1517
+ )
1518
+ if rc != 0:
1519
+ raise GitError(f"git for-each-ref {label} failed in {repo}: {detail}")
1520
+ refs = [line.removeprefix(strip) for line in out.splitlines() if line]
1521
+ deleted: list[str] = []
1522
+ failed: list[str] = []
1523
+ for name in refs[keep:]:
1524
+ try:
1525
+ delete(name)
1526
+ except Exception as exc: # a git timeout/OSError on one ref
1527
+ # must not wedge the tail behind it any more than a GitError does; the
1528
+ # per-ref best-effort contract holds for the whole subprocess surface
1529
+ failed.append(f"{name} ({exc})")
1530
+ continue
1531
+ deleted.append(name)
1532
+ if failed:
1533
+ raise PrunePreserveError(
1534
+ f"{label} prune in {repo}: deleted {deleted or 'nothing'}, "
1535
+ f"could not delete {'; '.join(failed)}",
1536
+ deleted=deleted,
1537
+ failed=failed,
1538
+ )
1539
+ return deleted
1540
+
1541
+
1542
+ def prune_preserve_refs(repo: Path, keep: int) -> list[str]:
1543
+ """Bounded retention for the ``attempt-preserve/*`` recovery branches that
1544
+ :func:`preserve_commits` parks before an auto-rollback reset: keep the
1545
+ ``keep`` most recent refs by committer date, force-delete the rest, and
1546
+ return the deleted branch names (empty when nothing is over budget). Only
1547
+ ``refs/heads/attempt-preserve/`` is ever listed, so branches outside that
1548
+ prefix and the ``refs/attempt-preserve-dirty/*`` snapshot refs are
1549
+ untouchable by construction — but the prefix itself is owned by the pruner:
1550
+ anything parked under it, however it got there, is subject to deletion.
1551
+ ``keep <= 0`` means "never prune" — returns ``[]`` without running git.
1552
+
1553
+ Raises :class:`GitError` when the listing fails, or
1554
+ :class:`PrunePreserveError` — after attempting every tail ref — when any
1555
+ individual delete failed (e.g. the ref is checked out here or in a
1556
+ worktree); see :func:`_prune_refs` for the best-effort contract."""
1557
+ return _prune_refs(
1558
+ repo,
1559
+ keep,
1560
+ "refs/heads/attempt-preserve/",
1561
+ label="attempt-preserve",
1562
+ strip="refs/heads/",
1563
+ delete=lambda name: delete_branch(repo, name, force=True),
1564
+ )
1565
+
1566
+
1567
+ def prune_preserve_dirty_refs(repo: Path, keep: int) -> list[str]:
1568
+ """Bounded retention for the ``refs/attempt-preserve-dirty/*`` worktree
1569
+ snapshots that :func:`snapshot_worktree` parks before an auto-rollback
1570
+ reset: keep the ``keep`` most recent by committer date (the snapshot
1571
+ commit's committer date is its park time), delete the rest via
1572
+ ``git update-ref -d``, and return the deleted names. These refs live
1573
+ outside ``refs/heads/`` — they are not branches, so ``branch -D`` cannot
1574
+ touch them and the reported names are full refnames (there is no
1575
+ ``refs/heads/`` to strip). Only ``refs/attempt-preserve-dirty/`` is ever
1576
+ listed, so branches and every other ref are untouchable by construction.
1577
+ ``keep <= 0`` means "never prune" — returns ``[]`` without running git.
1578
+
1579
+ Raises :class:`GitError` when the listing fails, or
1580
+ :class:`PrunePreserveError` on a partial delete failure; see
1581
+ :func:`_prune_refs` for the best-effort contract."""
1582
+
1583
+ def _delete(refname: str) -> None:
1584
+ rc, out = _git(repo, "update-ref", "-d", refname)
1585
+ if rc != 0:
1586
+ raise GitError(f"git update-ref -d {refname} failed in {repo}: {out}")
1587
+
1588
+ return _prune_refs(
1589
+ repo,
1590
+ keep,
1591
+ "refs/attempt-preserve-dirty/",
1592
+ label="attempt-preserve-dirty",
1593
+ strip="",
1594
+ delete=_delete,
1595
+ )
1596
+
1597
+
1598
+ def snapshot_worktree(
1599
+ repo: Path,
1600
+ ref_name: str,
1601
+ *,
1602
+ baseline_untracked: list[str] | None,
1603
+ force_include: tuple[str, ...] = (),
1604
+ ) -> str | None:
1605
+ """Park the current *uncommitted* working-tree state — tracked edits/deletions
1606
+ AND run-created untracked files — under ``ref_name`` as a commit object, so a
1607
+ following ``git reset --hard`` (whose post-reset cleanup in
1608
+ :func:`safe_rollback` also deletes run-created untracked files) cannot
1609
+ silently destroy an attempt's in-progress work. The snapshot survives
1610
+ ``git gc`` and is recoverable by name (``git checkout <ref> -- .`` or
1611
+ ``git diff HEAD <ref>``).
1612
+
1613
+ Captured through a throwaway temp index so the real index and working tree
1614
+ are left untouched: seed the temp index from HEAD, ``add -u`` the tracked
1615
+ edits/deletions, then stage only the untracked files *this run* created —
1616
+ ``untracked_files(repo)`` minus ``baseline_untracked`` (the snapshot taken
1617
+ when the baseline was captured). This mirrors :func:`safe_rollback`'s scope
1618
+ exactly: the snapshot holds precisely what the reset would destroy and never
1619
+ a pre-existing user untracked file. When ``baseline_untracked`` is ``None`` (a
1620
+ pre-upgrade/resumed run with no snapshot) no untracked file is staged — matching
1621
+ :func:`safe_rollback`, which then deletes none — so tracked edits are still
1622
+ parked but untracked files are left untouched. Ignored files are excluded
1623
+ throughout unless a caller supplies a trusted literal ``force_include`` path.
1624
+ Recovery uses that narrow exception for an attempt-bound spec whose durable
1625
+ byte snapshot proves the child changed a baseline-untracked or ignored file;
1626
+ ``git add -f`` then parks the child bytes before recovery restores the input.
1627
+ The caller must validate those paths as trusted regular files first. A tree is
1628
+ written and ``commit-tree``'d parented at HEAD
1629
+ under a synthetic ``froid-loop`` identity so the snapshot commit succeeds even
1630
+ when no local/global git ``user.name``/``user.email`` is configured, then
1631
+ ``ref_name`` is pointed at the result. Compares only against HEAD — committed
1632
+ work above baseline is already parked by :func:`preserve_commits`, so this
1633
+ captures exactly what is not yet committed.
1634
+
1635
+ Returns ``ref_name`` on success, or ``None`` when the tree is clean relative
1636
+ to HEAD (nothing to preserve — the intended non-destructive uncommitted-revert
1637
+ case). Raises :class:`GitError` on any git failure — the raise *surfaces* the
1638
+ capture failure so the caller can decide. Since #340 the recovery ref is a
1639
+ gate, not a safety net: the worktree caller's old best-effort "journal the
1640
+ failure and proceed" contract is gone, and a plain rollback now refuses to
1641
+ reset past work it could not park (only a re-drive, whose caller contract
1642
+ forbids pausing, still journals and lets the human-directed reset run).
1643
+
1644
+ The three reads whose text IS the answer — ``write-tree``,
1645
+ ``rev-parse <head>^{tree}`` and ``commit-tree`` — take stdout ALONE
1646
+ (``_git_out``, #442). Git exits 0 while still warning on stderr, and against
1647
+ the merged stream all three answer a warning-suffixed "sha": the
1648
+ ``tree == head_tree`` comparison then reads unequal on a tree identical to
1649
+ HEAD, and ``update-ref`` is handed a non-ref. Because of the gate above, that
1650
+ leaves a host whose git config warns with no working rollback at all. The
1651
+ rc-only ``_git_env`` calls stay on the merge — they spend their output on a
1652
+ raise and never read it as a value.
1653
+
1654
+ Not every failure here is a :class:`GitError`: spawn faults arrive typed as
1655
+ :class:`GitSpawnError` since #343, but the ``TemporaryDirectory`` below can
1656
+ raise a plain ``OSError`` outright (ENOSPC/EMFILE) — a filesystem fault no
1657
+ git chokepoint can translate. Callers keep guarding ``(GitError, OSError)``."""
1658
+ head = rev_parse_head(repo)
1659
+ with tempfile.TemporaryDirectory() as td:
1660
+ env = {**os.environ, "GIT_INDEX_FILE": str(Path(td) / "index")}
1661
+ for args in (("read-tree", head), ("add", "-u")):
1662
+ rc, out = _git_env(repo, *args, env=env)
1663
+ if rc != 0:
1664
+ raise GitError(f"git {args[0]} (snapshot) failed in {repo}: {out}")
1665
+ # None baseline (pre-upgrade/resumed run, no snapshot): safe_rollback deletes
1666
+ # no untracked files, so park none either — coercing None to [] would instead
1667
+ # stage every current untracked file, including the user's pre-existing ones.
1668
+ if baseline_untracked is None:
1669
+ new: list[str] = []
1670
+ else:
1671
+ new = sorted(untracked_files(repo) - set(baseline_untracked))
1672
+ if new:
1673
+ rc, out = _git_env(repo, "add", "--", *new, env=env)
1674
+ if rc != 0:
1675
+ raise GitError(f"git add (snapshot untracked) failed in {repo}: {out}")
1676
+ if force_include:
1677
+ rc, out = _git_env(
1678
+ repo,
1679
+ "add",
1680
+ "-f",
1681
+ "--",
1682
+ *_literal_specs(list(force_include)),
1683
+ env=env,
1684
+ )
1685
+ if rc != 0:
1686
+ raise GitError(f"git add (snapshot forced path) failed in {repo}: {out}")
1687
+ rc, tree, detail = _git_out(repo, "write-tree", env=env)
1688
+ if rc != 0:
1689
+ raise GitError(f"git write-tree (snapshot) failed in {repo}: {detail}")
1690
+ rc, head_tree, detail = _git_out(repo, "rev-parse", f"{head}^{{tree}}")
1691
+ if rc != 0:
1692
+ raise GitError(f"git rev-parse {head}^{{tree}} failed in {repo}: {detail}")
1693
+ if tree == head_tree:
1694
+ return None # working tree identical to HEAD — nothing uncommitted to park
1695
+ # A synthetic identity (merged over os.environ) so the snapshot commit succeeds
1696
+ # with no git user.name/user.email configured — else the best-effort caller would
1697
+ # catch the GitError and reset past the very work this ref exists to preserve.
1698
+ ident = {
1699
+ **os.environ,
1700
+ "GIT_AUTHOR_NAME": "froid-loop",
1701
+ "GIT_AUTHOR_EMAIL": "froid-loop@localhost",
1702
+ "GIT_COMMITTER_NAME": "froid-loop",
1703
+ "GIT_COMMITTER_EMAIL": "froid-loop@localhost",
1704
+ }
1705
+ rc, snap, detail = _git_out(
1706
+ repo, "commit-tree", tree, "-p", head, "-m", "attempt worktree snapshot", env=ident
1707
+ )
1708
+ if rc != 0:
1709
+ raise GitError(f"git commit-tree (snapshot) failed in {repo}: {detail}")
1710
+ rc, out = _git(repo, "update-ref", ref_name, snap)
1711
+ if rc != 0:
1712
+ raise GitError(f"git update-ref {ref_name} {snap[:12]} failed in {repo}: {out}")
1713
+ return ref_name
1714
+
1715
+
1716
+ class PreserveRefExhaustedError(GitError):
1717
+ """Every candidate snapshot refname in a probe's bounded range was already
1718
+ taken. A GitError so the preservation handlers that already guard
1719
+ ``(GitError, OSError)`` degrade instead of crashing; a distinct type so the
1720
+ caller can tell "the namespace is full" from "git said no" — the two want
1721
+ different remedies (prune the namespace, or re-enable pruning by setting
1722
+ ``scm.preserve_keep`` to a positive value, vs. fix the repo). Note the
1723
+ remedy is *not* "lower ``preserve_keep``": 0 means never prune, so the
1724
+ setting most likely to exhaust a probe is the one that cannot go lower.
1725
+
1726
+ Raised rather than falling through to the last candidate on purpose: reusing
1727
+ an occupied name is the exact data loss the probe exists to prevent (#349)."""
1728
+
1729
+
1730
+ def ref_exists(repo: Path, refname: str) -> bool:
1731
+ """Whether ``refname`` — a FULL refname, e.g. ``refs/attempt-preserve-dirty/…``
1732
+ — currently exists. Sibling of :func:`branch_exists`, which prepends
1733
+ ``refs/heads/`` and so cannot see the snapshot refs that live outside it.
1734
+
1735
+ A non-zero exit reads as "absent" — `show-ref --verify` returns 1 for both a
1736
+ missing ref and a malformed name, and the caller's subsequent ref write
1737
+ surfaces any real error. Spawn and timeout faults are NOT swallowed: they
1738
+ arrive typed from `_run_git` as GitError/GitSpawnError and propagate, so a
1739
+ caller that must not overwrite an existing ref cannot mistake "git could not
1740
+ run" for "the name is free"."""
1741
+ rc, _ = _git(repo, "show-ref", "--verify", "--quiet", refname)
1742
+ return rc == 0
1743
+
1744
+
1745
+ @dataclass(frozen=True)
1746
+ class _RollbackCleanupTarget:
1747
+ """One canonical, confined untracked path and its canonical prune bounds."""
1748
+
1749
+ path: Path
1750
+ prune_start: Path
1751
+ prune_stop: Path
1752
+
1753
+
1754
+ @dataclass(frozen=True)
1755
+ class _RollbackCleanupPlan:
1756
+ """Canonical cleanup inputs computed before rollback mutates the checkout."""
1757
+
1758
+ repo_root: Path | None
1759
+ keep_roots: tuple[Path, ...]
1760
+ targets: tuple[_RollbackCleanupTarget, ...]
1761
+
1762
+
1763
+ def _rollback_cleanup_plan(
1764
+ repo: Path,
1765
+ *,
1766
+ baseline_untracked: list[str] | None,
1767
+ keep: tuple[str, ...],
1768
+ ) -> _RollbackCleanupPlan:
1769
+ """Resolve every later cleanup operand before the rollback mutation boundary."""
1770
+ if baseline_untracked is None:
1771
+ return _RollbackCleanupPlan(repo_root=None, keep_roots=(), targets=())
1772
+
1773
+ created = untracked_files(repo) - set(baseline_untracked)
1774
+ try:
1775
+ repo_root = repo.resolve()
1776
+ keep_roots = tuple((repo_root / rel).resolve() for rel in keep)
1777
+ targets: list[_RollbackCleanupTarget] = []
1778
+ for rel in sorted(created):
1779
+ path = (repo_root / rel).resolve()
1780
+ # A created path reached through a symlinked parent can canonicalize
1781
+ # outside the checkout. Never turn that uncertainty into an external
1782
+ # deletion; the rollback cleanup is confined to descendants of root.
1783
+ if path == repo_root or not path.is_relative_to(repo_root):
1784
+ continue
1785
+ if any(path == root or path.is_relative_to(root) for root in keep_roots):
1786
+ continue
1787
+ targets.append(
1788
+ _RollbackCleanupTarget(
1789
+ path=path,
1790
+ prune_start=path.parent,
1791
+ prune_stop=repo_root,
1792
+ )
1793
+ )
1794
+ except (OSError, RuntimeError) as e:
1795
+ raise RollbackPreflightError(
1796
+ f"cannot preflight rollback cleanup paths safely in {repo}: {e}"
1797
+ ) from e
1798
+ return _RollbackCleanupPlan(
1799
+ repo_root=repo_root,
1800
+ keep_roots=keep_roots,
1801
+ targets=tuple(targets),
1802
+ )
1803
+
1804
+
1805
+ def safe_rollback(
1806
+ repo: Path,
1807
+ baseline: str,
1808
+ *,
1809
+ baseline_untracked: list[str] | None,
1810
+ keep: tuple[str, ...] = (".froid-loop",),
1811
+ preserve: tuple[str, ...] = (),
1812
+ ) -> None:
1813
+ """Undo a failed attempt WITHOUT a blanket `git clean`.
1814
+
1815
+ Reverts tracked changes to `baseline` (the dev attempt's commits/edits),
1816
+ then removes only untracked files that appeared since `baseline` — i.e.
1817
+ files this run created. Untracked files already present at baseline, every
1818
+ ignored file, and anything under a `keep` dir are preserved. The orchestrator
1819
+ therefore never runs `git clean -fd`, so it can't eat a user's pre-existing
1820
+ untracked work. `baseline_untracked` is the snapshot taken when the baseline
1821
+ was captured; None (a pre-upgrade run with no snapshot) removes nothing.
1822
+
1823
+ `preserve` is repo-relative posix dir prefixes (the FROID artifact folders)
1824
+ whose *tracked* content must survive the hard reset — e.g. a frozen spec the
1825
+ resolve workflow just corrected, or a sentinel it deliberately deleted. The
1826
+ `git reset --hard` would otherwise revert them (keep only guards untracked
1827
+ deletion). We snapshot the current tree with `git stash create`, enumerate
1828
+ tracked deletions inside the preserved prefixes, reset, then restore the
1829
+ snapshot's present paths and replay its deletions. If the snapshot or deletion
1830
+ inventory cannot be read we raise *before* the reset rather than proceed with
1831
+ an incomplete restore: that would revert exactly what `preserve` names, and it
1832
+ would do so silently. Untracked artifacts need no special handling: the reset
1833
+ leaves them alone and the cleanup below skips `keep` dirs.
1834
+
1835
+ `policy.toml` (the operator's orchestration config) is *always* restored,
1836
+ regardless of `preserve`. It lives inside the kept `.froid-loop` dir but is
1837
+ *tracked*, so a plain `git reset --hard` would silently revert it — an
1838
+ uncommitted edit (e.g. a freshly enabled `scm.rollback_on_failure`, gone
1839
+ before it ever takes effect) or a change committed after `baseline`. `keep`
1840
+ only guards untracked deletion, not tracked reverts. We can't ride the stash
1841
+ snapshot for it: `git stash create` emits an empty snapshot for a clean tree,
1842
+ so a policy change living in a *commit* (with no other working-tree dirt)
1843
+ would skip the restore and be lost. Instead we read policy.toml's on-disk
1844
+ content before the reset and write it straight back after — independent of
1845
+ the snapshot, covering both the uncommitted and committed cases.
1846
+
1847
+ Before either stash creation or reset, every path needed by the later
1848
+ untracked cleanup is canonicalized into a confined plan, including its prune
1849
+ bounds. Resolution uncertainty raises ``RollbackPreflightError`` with the
1850
+ filesystem fault as its cause, so the caller can pause while the tree is
1851
+ untouched; the post-reset cleanup consumes the plan without resolving again.
1852
+ """
1853
+ # policy.toml: capture on-disk content now, restore unconditionally below.
1854
+ policy_path = repo / POLICY_FILE_REL
1855
+ policy_content = policy_path.read_bytes() if policy_path.is_file() else None
1856
+ cleanup = _rollback_cleanup_plan(
1857
+ repo,
1858
+ baseline_untracked=baseline_untracked,
1859
+ keep=keep,
1860
+ )
1861
+
1862
+ rc, out, detail = _git_out(repo, "stash", "create")
1863
+ # A failed `stash create` silently empties `snapshot`, which disables the whole
1864
+ # preserve restore below — the reset would then revert the very paths the caller
1865
+ # asked to keep (a resolved re-drive's corrected spec), with no error anywhere.
1866
+ # Raise before the reset, and only when a restore was actually requested: with
1867
+ # no `preserve` the snapshot is unused, so the degrade stays correct there (both
1868
+ # sweep callers rely on it). A clean tree is not a failure — it exits rc 0 with
1869
+ # empty output, which the line below keeps handling as "nothing to restore from".
1870
+ # Reading stdout ALONE is what makes that last sentence true on a host whose git
1871
+ # warns at rc 0 (#442): against the merge the warning IS the "snapshot", so the
1872
+ # restore below ran `checkout warning:… -- <dir>` after the reset had already
1873
+ # destroyed the preserved content — destructive first, then loud.
1874
+ if rc != 0 and preserve:
1875
+ raise GitError(f"git stash create failed in {repo}: {detail}")
1876
+ snapshot = out if rc == 0 else ""
1877
+ deleted_preserve_paths: tuple[str, ...] = ()
1878
+ if snapshot and preserve:
1879
+ proc = _run_git(
1880
+ [
1881
+ "git",
1882
+ "-C",
1883
+ str(repo),
1884
+ "diff",
1885
+ "--no-renames",
1886
+ "--name-only",
1887
+ "--diff-filter=D",
1888
+ "-z",
1889
+ baseline,
1890
+ snapshot,
1891
+ "--",
1892
+ *_literal_specs(list(preserve)),
1893
+ ],
1894
+ repo,
1895
+ binary=True,
1896
+ )
1897
+ if proc.returncode != 0:
1898
+ detail = (proc.stdout + proc.stderr).decode("utf-8", "replace").strip()
1899
+ raise GitError(
1900
+ f"git diff {baseline[:12]}..{snapshot[:12]} for preserved deletions "
1901
+ f"failed in {repo}: {detail}"
1902
+ )
1903
+ deleted_preserve_paths = tuple(
1904
+ os.fsdecode(path) for path in proc.stdout.split(b"\0") if path
1905
+ )
1906
+ if deleted_preserve_paths:
1907
+ # A blob-to-tree replacement is reported as deletion of the baseline
1908
+ # blob plus additions below that same path. The later snapshot
1909
+ # checkout already installs the replacement tree; replaying the blob
1910
+ # deletion with `git rm -f` would then fail because recursive removal
1911
+ # was neither intended nor authorized. Ask the snapshot which deleted
1912
+ # names still exist there (as a tree or another replacement object)
1913
+ # and leave those exact paths to checkout. This inventory is also
1914
+ # pre-reset so an observation failure remains non-destructive.
1915
+ proc = _run_git(
1916
+ [
1917
+ "git",
1918
+ "-C",
1919
+ str(repo),
1920
+ "ls-tree",
1921
+ "--name-only",
1922
+ "-z",
1923
+ snapshot,
1924
+ "--",
1925
+ *_literal_specs(list(deleted_preserve_paths)),
1926
+ ],
1927
+ repo,
1928
+ binary=True,
1929
+ )
1930
+ if proc.returncode != 0:
1931
+ detail = (proc.stdout + proc.stderr).decode("utf-8", "replace").strip()
1932
+ raise GitError(
1933
+ f"git ls-tree {snapshot[:12]} for preserved replacements "
1934
+ f"failed in {repo}: {detail}"
1935
+ )
1936
+ snapshot_replacements = frozenset(
1937
+ os.fsdecode(path) for path in proc.stdout.split(b"\0") if path
1938
+ )
1939
+ deleted_preserve_paths = tuple(
1940
+ path for path in deleted_preserve_paths if path not in snapshot_replacements
1941
+ )
1942
+ rc, out = _git(repo, "reset", "--hard", baseline)
1943
+ if rc != 0:
1944
+ raise GitError(f"git reset --hard {baseline} failed: {out}")
1945
+ if snapshot:
1946
+ # Restore each preserve dir's pre-reset content from the snapshot tree. A
1947
+ # path with no tracked content in the snapshot makes `git checkout` exit
1948
+ # non-zero ("pathspec did not match") — benign (a preserve dir holding
1949
+ # only untracked files). Any other failure means a protected path wasn't
1950
+ # restored: raise instead of silently dropping a resolved re-drive's
1951
+ # corrected spec (which would regress the re-drive into a recovery loop).
1952
+ # `_run_git` pins LC_ALL=C, so this English substring is stable under a
1953
+ # localized git (#236) — never translated out from under the match.
1954
+ #
1955
+ # The operand is LITERAL (#423 item 5). `preserve` carries operator-configured
1956
+ # dirs, and this is a WRITE: a glob-matching neighbour is not merely restored
1957
+ # alongside the target, it is reverted to the snapshot — measured, a plain
1958
+ # `checkout <snap> -- 'doc*'` reverts an unrelated `docsa/f.md` edit, which is
1959
+ # the operator's own uncommitted work destroyed by a step whose entire job is
1960
+ # to preserve. `:(literal)` still matches a directory prefix, so each preserve
1961
+ # dir restores everything beneath it exactly as before.
1962
+ for d in preserve:
1963
+ rc, out = _git(repo, "checkout", snapshot, "--", *_literal_specs([d]))
1964
+ # Deliberately still the MERGED read (#442), unlike the `stash create`
1965
+ # above: this inspects text on the ERROR path, where git's "did not
1966
+ # match" lands on STDERR — stdout alone can never contain it, so the
1967
+ # substring would stop matching and a benign empty preserve dir would
1968
+ # raise. INVERSE ablation: convert this `_git` to `_git_out` and read
1969
+ # the stdout half here, and `test_safe_rollback_tolerates_empty_preserve_dir`
1970
+ # fails on an unexpected GitError.
1971
+ if rc != 0 and "did not match" not in out:
1972
+ raise GitError(f"git checkout {snapshot[:12]} -- {d} failed: {out}")
1973
+ # `checkout <tree> -- <dir>` writes every path PRESENT in that tree but
1974
+ # does not remove a baseline path ABSENT from it. Replay those exact,
1975
+ # pre-reset-inventoried deletions so preservation reproduces the snapshot
1976
+ # rather than resurrecting a deliberately cleared sentinel. The operands
1977
+ # remain literal for the same reason as the checkout above.
1978
+ if deleted_preserve_paths:
1979
+ proc = _run_git(
1980
+ [
1981
+ "git",
1982
+ "-C",
1983
+ str(repo),
1984
+ "rm",
1985
+ "-f",
1986
+ "--ignore-unmatch",
1987
+ "--",
1988
+ *_literal_specs(list(deleted_preserve_paths)),
1989
+ ],
1990
+ repo,
1991
+ binary=True,
1992
+ )
1993
+ if proc.returncode != 0:
1994
+ detail = (proc.stdout + proc.stderr).decode("utf-8", "replace").strip()
1995
+ raise GitError(f"git rm of preserved snapshot deletions failed in {repo}: {detail}")
1996
+ if policy_content is not None:
1997
+ current = policy_path.read_bytes() if policy_path.is_file() else None
1998
+ if current != policy_content:
1999
+ policy_path.parent.mkdir(parents=True, exist_ok=True)
2000
+ # Atomic, and by NAME (#379). This is a put-back after a rollback has
2001
+ # already discarded the run's work, so a torn write costs the operator
2002
+ # their orchestration config on top of it — and a truncated policy.toml
2003
+ # is not a smaller config but a parse error the next `froid-loop run`
2004
+ # refuses on, which is the failure the whole restore exists to avoid.
2005
+ # Refusing to follow a link was a real change here (a bare
2006
+ # `write_bytes` opens the name and so writes THROUGH one), and it is
2007
+ # the right one twice over: `policy.write_mux_backend` already replaces
2008
+ # this same file by name, so no link at this path survives the
2009
+ # orchestrator anyway; and `runsetup` states a driven session can write
2010
+ # `.froid-loop/policy.toml`, so honouring a link planted there would aim
2011
+ # a host-side write at a path of that session's choosing. Confined to
2012
+ # `repo` (#593) because that refusal stopped at the final component:
2013
+ # `policy_path` is built lexically from `repo` at the capture above, so
2014
+ # the walk re-derives exactly the components that join was spelled
2015
+ # from, and a link planted at `.froid-loop/` no longer redirects the
2016
+ # restore out of the repo. require_writable_target (#597) gives back
2017
+ # the PermissionError a bare `write_bytes` raised on an operator's
2018
+ # read-only policy.toml — this is their config, not machine state.
2019
+ atomic_write_bytes_confined(
2020
+ policy_path,
2021
+ policy_content,
2022
+ confine_root=repo,
2023
+ require_writable_target=True,
2024
+ )
2025
+ for target in cleanup.targets:
2026
+ try:
2027
+ target.path.unlink(missing_ok=True)
2028
+ except OSError:
2029
+ continue
2030
+ _prune_empty_parents(target.prune_start, target.prune_stop)
2031
+
2032
+
2033
+ def _prune_empty_parents(start: Path, repo: Path) -> None:
2034
+ """Prune canonical parents supplied by the pre-mutation cleanup plan."""
2035
+ d = start
2036
+ while d != repo and d.is_relative_to(repo):
2037
+ try:
2038
+ d.rmdir() # succeeds only when empty
2039
+ except OSError:
2040
+ break
2041
+ d = d.parent
2042
+
2043
+
2044
+ # --------------------------------------------------------------------------
2045
+ # git worktree / branch / merge / diff primitives (Phase 2)
2046
+ #
2047
+ # Low-level helpers for the worktree-isolation pipeline. Each raises GitError
2048
+ # on failure. No engine wiring yet — these are unit-tested in isolation and
2049
+ # wired into open/close_unit_workspace + merge-back in Phase 3.
2050
+ # --------------------------------------------------------------------------
2051
+
2052
+
2053
+ def current_branch(repo: Path) -> str:
2054
+ """The branch name HEAD points at, or "HEAD" when detached. Reads stdout alone
2055
+ (`_git_out`): git exits 0 while still warning on stderr, and the merged stream
2056
+ would answer a branch name with the warning appended (#442)."""
2057
+ rc, out, detail = _git_out(repo, "rev-parse", "--abbrev-ref", "HEAD")
2058
+ if rc != 0:
2059
+ raise GitError(f"git rev-parse --abbrev-ref HEAD failed in {repo}: {detail}")
2060
+ return out
2061
+
2062
+
2063
+ def branch_exists(repo: Path, name: str) -> bool:
2064
+ rc, _ = _git(repo, "show-ref", "--verify", "--quiet", f"refs/heads/{name}")
2065
+ return rc == 0
2066
+
2067
+
2068
+ def create_branch(repo: Path, name: str, base: str) -> None:
2069
+ """Create branch `name` at `base` without checking it out."""
2070
+ rc, out = _git(repo, "branch", name, base)
2071
+ if rc != 0:
2072
+ raise GitError(f"git branch {name} {base} failed in {repo}: {out}")
2073
+
2074
+
2075
+ def delete_branch(repo: Path, name: str, force: bool = False) -> None:
2076
+ rc, out = _git(repo, "branch", "-D" if force else "-d", name)
2077
+ if rc != 0:
2078
+ raise GitError(f"git branch -d {name} failed in {repo}: {out}")
2079
+
2080
+
2081
+ def worktree_add(
2082
+ repo: Path, path: Path, branch: str, base: str | None = None, *, create: bool = True
2083
+ ) -> None:
2084
+ """Check `branch` out in a new worktree at `path` (which must not exist).
2085
+
2086
+ create=True (default) cuts a fresh `branch` at `base`, or from HEAD when
2087
+ `base` is None (git's own default start-point). create=False mounts an
2088
+ existing `branch` (used to re-mount a shared run branch across serial units);
2089
+ `base` is ignored. Either way the branch must not already be checked out in
2090
+ another worktree — git refuses that.
2091
+ """
2092
+ if create:
2093
+ # `git worktree add -b <branch> <path> [<base>]`: cut the new branch at the
2094
+ # caller's start-point, or from HEAD when none is given (git's own default).
2095
+ cmd = ["worktree", "add", "-b", branch, str(path)]
2096
+ if base is not None:
2097
+ cmd.append(base)
2098
+ rc, out = _git(repo, *cmd)
2099
+ else:
2100
+ rc, out = _git(repo, "worktree", "add", str(path), branch)
2101
+ if rc != 0:
2102
+ raise GitError(f"git worktree add {path} ({branch} from {base}) failed: {out}")
2103
+
2104
+
2105
+ def checkout_branch(repo: Path, name: str) -> None:
2106
+ """Switch the repo's checkout to `name`. Requires a clean tree."""
2107
+ rc, out = _git(repo, "checkout", name)
2108
+ if rc != 0:
2109
+ raise GitError(f"git checkout {name} failed in {repo}: {out}")
2110
+
2111
+
2112
+ def checkout_detach(repo: Path) -> None:
2113
+ """Detach HEAD at its current commit, leaving working tree + index untouched.
2114
+
2115
+ Frees a shared branch name held by a kept worktree so a sibling worktree can
2116
+ check that branch out (git refuses a branch checked out in another worktree).
2117
+ """
2118
+ rc, out = _git(repo, "checkout", "--detach")
2119
+ if rc != 0:
2120
+ raise GitError(f"git checkout --detach failed in {repo}: {out}")
2121
+
2122
+
2123
+ def worktree_remove(repo: Path, path: Path, force: bool = False) -> None:
2124
+ args = ["worktree", "remove"]
2125
+ if force:
2126
+ args.append("--force")
2127
+ args.append(str(path))
2128
+ rc, out = _git(repo, *args)
2129
+ if rc != 0:
2130
+ raise GitError(f"git worktree remove {path} failed: {out}")
2131
+
2132
+
2133
+ def worktree_prune(repo: Path) -> None:
2134
+ """Drop administrative entries for worktrees whose directories are gone.
2135
+ Best-effort housekeeping — never raises. The return code is already ignored,
2136
+ but `_git` can *raise* — GitError on a timeout (#156) or GitSpawnError on a
2137
+ spawn fault (#343) — which would bypass this never-raise contract (and the
2138
+ teardown degrade paths that lean on it — close_unit_workspace /
2139
+ discard_worktree call prune from inside their own GitError guards). The
2140
+ OSError in the net predates the #343 translation and stays as the belt for
2141
+ any non-spawn fault: the contract holds at its source."""
2142
+ try:
2143
+ _git(repo, "worktree", "prune")
2144
+ except (GitError, OSError):
2145
+ pass
2146
+
2147
+
2148
+ def worktree_list(repo: Path) -> list[Path]:
2149
+ """Paths of every worktree attached to `repo` (the main checkout first).
2150
+
2151
+ Reads stdout ALONE (`_git_out`) so the record parse does not depend on no
2152
+ stderr line ever starting with ``"worktree "``. The advisories measured for
2153
+ #442 — an unknown `core.fsyncMethod` value and its family — do NOT start that
2154
+ way, so the `startswith` filter screens them out and this parse was correct by
2155
+ accident rather than by construction; the filter stays as a second, independent
2156
+ screen."""
2157
+ rc, out, detail = _git_out(repo, "worktree", "list", "--porcelain")
2158
+ if rc != 0:
2159
+ raise GitError(f"git worktree list failed in {repo}: {detail}")
2160
+ paths = []
2161
+ for line in out.splitlines():
2162
+ if line.startswith("worktree "):
2163
+ paths.append(Path(line[len("worktree ") :]))
2164
+ return paths
2165
+
2166
+
2167
+ def dirty_paths(repo: Path) -> dict[str, str]:
2168
+ """Repo-relative posix path -> two-char porcelain XY status for every dirty
2169
+ entry in `repo`'s working tree. Excludes the orchestrator's own working dir
2170
+ (.froid-loop/) — config, ledger, run state, engine plugins — none of which is
2171
+ ever a unit's merged content. NUL-delimited (`-z`) so paths with spaces/unicode
2172
+ and rename forms parse without C-quoting; for a rename the *destination* path
2173
+ (the one now on disk) is what's recorded. `-uall` lists individual untracked
2174
+ files (not a collapsed parent dir) so each entry can be matched 1:1 against a
2175
+ branch's incoming paths."""
2176
+ rc, out = _git_raw(
2177
+ repo, "status", "--porcelain", "-z", "-uall", "--", ".", f":(exclude){AUTOMATOR_DIR_REL}"
2178
+ )
2179
+ if rc != 0:
2180
+ raise GitError(f"git status failed in {repo}")
2181
+ tokens = out.split("\0")
2182
+ result: dict[str, str] = {}
2183
+ i = 0
2184
+ while i < len(tokens):
2185
+ tok = tokens[i]
2186
+ if not tok:
2187
+ i += 1
2188
+ continue
2189
+ xy, path = tok[:2], tok[3:]
2190
+ # rename/copy entries carry the original path as the next NUL field; the
2191
+ # destination (`path` above) is what's on disk, so consume and skip it.
2192
+ if "R" in xy or "C" in xy:
2193
+ i += 1
2194
+ result[path] = xy
2195
+ i += 1
2196
+ return result
2197
+
2198
+
2199
+ def branch_incoming_paths(repo: Path, target: str, branch: str) -> set[str]:
2200
+ """The set of repo-relative posix paths a merge of `branch` into `target`
2201
+ would introduce or modify (`git diff --name-only target branch`)."""
2202
+ rc, out = _git_raw(repo, "diff", "--name-only", "-z", target, branch)
2203
+ if rc != 0:
2204
+ raise GitError(f"git diff --name-only {target} {branch} failed in {repo}")
2205
+ return {p for p in out.split("\0") if p}
2206
+
2207
+
2208
+ def clean_incoming_collisions(
2209
+ repo: Path,
2210
+ target: str,
2211
+ branch: str,
2212
+ *,
2213
+ protected: tuple[str, ...] = (),
2214
+ on_tolerated: Callable[[list[str]], None] | None = None,
2215
+ ) -> list[str]:
2216
+ """Reconcile a target checkout dirtied by a per-worktree Unity Editor so the
2217
+ merge of `branch` can proceed, returning the cleaned paths (empty when the
2218
+ tree was already clean).
2219
+
2220
+ Background: with engine `editor_mode = "per_worktree"`, a competing Editor
2221
+ can leak asset writes (`.cs.meta` GUIDs, asmdef auto-edits) into the *main*
2222
+ checkout. The merge then aborts pre-flight ("local changes / untracked files
2223
+ would be overwritten"). Those leaked copies are Editor-generated duplicates of
2224
+ content already committed on `branch`, so cleaning them is safe — the merge
2225
+ re-creates the canonical versions.
2226
+
2227
+ Guard: only paths that lie within the branch's incoming set are cleaned. A dirty
2228
+ path *outside* that set is real operator work and is never touched. Whether it also
2229
+ BLOCKS the merge is decided per path by the INDEX column, not by trackedness
2230
+ (#618): the merge writes only paths that differ between `target` and `branch`, so
2231
+ a stray git has nothing staged for — untracked, or tracked and edited in the
2232
+ working tree only — can be neither overwritten by the merge nor written into its
2233
+ commit. Measured on both topologies and both strategies: rc 0, the edit survives
2234
+ uncommitted, and it is absent from the resulting commit. A STAGED stray is the
2235
+ real hazard, and it is one under both strategies — `merge --no-ff` refuses it
2236
+ outright, and so does `merge --squash` against a diverged target; only a
2237
+ fast-forwardable `--squash` accepts it, and that one FOLDS the operator's staged
2238
+ work into the story's commit.
2239
+
2240
+ ``protected`` names repo-relative posix paths the ORCHESTRATOR itself commits
2241
+ after the merge, and a stray among them blocks whatever its index column says.
2242
+ The merge's own inertness does not protect them, because the merge is not what
2243
+ would commit them: ``commit_paths`` runs `git add -- :(literal)<path>` and then a
2244
+ pathspec commit, so ANY working-tree change to a named path is committed no
2245
+ matter who wrote it. The run's carry bookkeeping passes the sprint board and the
2246
+ deferred-work ledger through that call — ``_carry_board_advance``
2247
+ unconditionally — so an operator's private unstaged edit to one of those files
2248
+ lands in git history under a `chore(sprint-status): carry ...` message, leaving
2249
+ the tree clean and no trace of the substitution. The blast radius is strictly
2250
+ SAME-PATH: `git commit -- <pathspec>` is implicitly `--only`, so dirt on any
2251
+ other path is never swept in, which is why this list is a set of exact paths and
2252
+ not a policy. Default empty — the wiring is the caller's.
2253
+
2254
+ ``on_tolerated``, when given, is called once with the sorted list of stray paths
2255
+ the guard walked past — the exact complement of the blocking ones within the
2256
+ strays, and the mirror of the returned ``cleaned`` list — so a merge that
2257
+ proceeded over operator dirt leaves the same kind of trace as one that cleaned a
2258
+ leak. Not called when there are no such paths.
2259
+ """
2260
+ dirty = dirty_paths(repo)
2261
+ if not dirty:
2262
+ return []
2263
+ incoming = branch_incoming_paths(repo, target, branch)
2264
+ stray = sorted(p for p in dirty if p not in incoming)
2265
+ # Trackedness was the wrong axis (#618). What a merge can write into its commit is
2266
+ # what git has STAGED, so the index column alone decides: a stray with nothing
2267
+ # staged is inert under both strategies and both topologies, and refusing over one
2268
+ # stopped unattended runs with no hazard to point at. Everything else this method
2269
+ # can see is a hazard the operator has to resolve first — a staged change, and the
2270
+ # unmerged stages of a half-resolved conflict, which carry a letter in that column
2271
+ # for every one of git's seven combinations.
2272
+ #
2273
+ # `protected` is the second half, and it is not about the merge at all: the run's
2274
+ # own post-merge carry commits those paths by pathspec, sweeping in whatever the
2275
+ # working tree holds. Inert-under-merge and safe-to-proceed stopped being the same
2276
+ # question the moment a path was on both lists.
2277
+ guarded = set(protected)
2278
+ staged = [p for p in stray if dirty[p][0] not in " ?"]
2279
+ blocking = [p for p in stray if dirty[p][0] not in " ?" or p in guarded]
2280
+ if blocking:
2281
+ # One raise, two remedies: staged work has to be committed or unstaged, while
2282
+ # dirt on a carried path has to leave the path entirely. A single undifferentiated
2283
+ # list would send the operator to the wrong one.
2284
+ clauses: list[str] = []
2285
+ if staged:
2286
+ clauses.append(
2287
+ "staged changes to tracked files outside this branch's files "
2288
+ f"(not introduced by the merge): {', '.join(staged)}"
2289
+ )
2290
+ # Membership in `guarded`, NOT the `staged` complement. A path can be both,
2291
+ # and the two clauses carry DIFFERENT remedies — so subtracting the staged ones
2292
+ # here would name a staged-and-carried path under "commit or unstage it" alone,
2293
+ # which does not remove the hazard: the carry stages whatever the working tree
2294
+ # holds either way. Overlap means it is listed twice, which is the honest answer.
2295
+ swept = [p for p in blocking if p in guarded]
2296
+ if swept:
2297
+ clauses.append(
2298
+ "uncommitted changes to paths this run commits for itself after the "
2299
+ f"merge, which it would sweep into its own bookkeeping commit: {', '.join(swept)}"
2300
+ )
2301
+ raise GitError("the target checkout has " + "; and ".join(clauses))
2302
+ # Every stray that survives the raise above is tolerated — the exact complement of
2303
+ # `blocking`, not a second independent predicate. Recomputing one here is how the
2304
+ # two lists drift: an unstaged tracked stray answering neither test would proceed
2305
+ # with no journal trace at all, which is the silent half of #618.
2306
+ tolerated = list(stray)
2307
+ if tolerated and on_tolerated is not None:
2308
+ on_tolerated(tolerated)
2309
+ # Resolve every untracked cleanup parent before deleting or checking out any
2310
+ # path. A later resolution fault must not leave an earlier collision cleaned
2311
+ # and the checkout only partly reconciled.
2312
+ repo_res = repo.resolve()
2313
+ prune_starts: dict[str, Path] = {}
2314
+ for path, xy in sorted(dirty.items()):
2315
+ if path not in incoming or not xy.startswith("??"):
2316
+ continue
2317
+ parent = (repo / path).parent.resolve()
2318
+ if parent != repo_res and not parent.is_relative_to(repo_res):
2319
+ raise OSError(
2320
+ f"refusing to clean incoming collision outside repository {repo_res}: "
2321
+ f"{repo / path}"
2322
+ )
2323
+ prune_starts[path] = parent
2324
+ cleaned: list[str] = []
2325
+ for path, xy in sorted(dirty.items()):
2326
+ if path not in incoming:
2327
+ continue # tolerated untracked stray — never cleaned, never reported (#460)
2328
+ if xy.startswith("??"): # untracked: delete it, then prune emptied dirs
2329
+ fp = repo / path
2330
+ fp.unlink(missing_ok=True)
2331
+ parent = prune_starts[path]
2332
+ while parent != repo_res and parent.is_dir() and not any(parent.iterdir()):
2333
+ parent.rmdir()
2334
+ parent = parent.parent
2335
+ else: # tracked-modified: restore to the target's committed version
2336
+ rc, out = _git(repo, "checkout", "--", path)
2337
+ if rc != 0:
2338
+ raise GitError(f"git checkout -- {path} failed in {repo}: {out}")
2339
+ cleaned.append(path)
2340
+ return cleaned
2341
+
2342
+
2343
+ def _merge_in_progress(repo: Path) -> tuple[bool, GitError | None]:
2344
+ """`(a merge is mid-flight — MERGE_HEAD exists, the probe failure when the
2345
+ reading itself failed)`. A merge git refused at pre-flight (e.g. untracked
2346
+ files would be overwritten) leaves no MERGE_HEAD, so there is nothing to
2347
+ `--abort`.
2348
+
2349
+ `-q --verify` spends rc 1 on exactly "the name does not resolve" — the
2350
+ legitimate no — and the environment-fault family lands at 128 (measured),
2351
+ so rc 0 and rc 1 are the answers and anything else is an unread, as are
2352
+ the three faults `_run_git` raises with no rc at all (#343/#377/#156).
2353
+ Read in the same post-mutation window as `_index_unmerged`, and degrading
2354
+ for the same reason: a raise here escapes `merge_branch` between the
2355
+ failed merge and its cleanup. False WITH the marker set means unmeasured,
2356
+ not "no merge" — and the caller must not let it authorize the abort this
2357
+ value gates: `merge --abort` is a repair write, and uncertainty never
2358
+ authorizes one, so the unread case skips the abort and says so."""
2359
+ try:
2360
+ rc, out = _git(repo, "rev-parse", "-q", "--verify", "MERGE_HEAD")
2361
+ except GitError as unread:
2362
+ return False, unread
2363
+ if rc in (0, 1):
2364
+ return rc == 0, None
2365
+ return False, GitError(f"git rev-parse --verify MERGE_HEAD failed in {repo}: {out}")
2366
+
2367
+
2368
+ def _dirty_tracked_paths(repo: Path) -> frozenset[str]:
2369
+ """The tracked paths whose working-tree content differs from HEAD.
2370
+
2371
+ A TREE-STATE probe, not a did-the-merge-act probe. A path carrying a
2372
+ pre-existing unstaged edit is in this set whether or not git touched it,
2373
+ so a single post-merge reading cannot tell a refused merge from one that
2374
+ half-applied — which is why `merge_branch` samples it BEFORE the merge and
2375
+ differences after, per PATH (#619). The set form is what makes the answer
2376
+ per-path at all: the boolean this used to be (`git diff --quiet HEAD`)
2377
+ attributed a concurrent operator edit to ANY tracked file to git, and the
2378
+ repo-wide `reset --hard` riding on that attribution destroyed it.
2379
+
2380
+ `--no-renames` so both samples spell a rename as its delete and its add —
2381
+ two names, each usable as a pathspec — rather than whichever single name
2382
+ rename detection happens to keep. NUL-delimited verbatim stdout for
2383
+ `_untracked_paths`'s reason: these names are compared as sets, handed to
2384
+ the operator, and passed back to git as pathspecs, so a C-quoted or
2385
+ stripped name is one the restore cannot act on. RAISES on a failed read
2386
+ like its snapshot sibling; the post-merge caller (`_merge_residue`)
2387
+ catches and degrades."""
2388
+ proc = _run_git(
2389
+ ["git", "-C", str(repo), "diff", "--name-only", "-z", "--no-renames", "HEAD", "--"], repo
2390
+ )
2391
+ if proc.returncode != 0:
2392
+ raise GitError(f"git diff --name-only HEAD failed in {repo}: {proc.stderr.strip()}")
2393
+ return frozenset(rel for rel in proc.stdout.split("\0") if rel)
2394
+
2395
+
2396
+ def _index_dirty_vs_head(repo: Path) -> bool:
2397
+ """True when the INDEX differs from HEAD — i.e. a squash actually staged
2398
+ something to commit. Blind, unlike `_dirty_tracked_paths`, to pre-existing
2399
+ UNSTAGED edits in the checkout, which are none of a replay's business: with
2400
+ such an edit present the tree probe reports dirt a valid `allow_empty_squash`
2401
+ replay never staged, so the clean early return is skipped and the ensuing
2402
+ `git commit` fails with "no changes added to commit" (#619).
2403
+
2404
+ `--quiet` rides `--exit-code`'s contract, which spends rc 1 on exactly
2405
+ "there are differences" — so rc 0 and rc 1 are the answers and anything
2406
+ else RAISES, like the snapshot siblings: read as "dirty", an unreadable
2407
+ index skipped the replay's no-op return, and the doomed `git commit` that
2408
+ followed dressed the probe failure as a commit refusal, with
2409
+ `_reset_hard_head`'s rollback riding on the fiction. The one caller reads
2410
+ post-merge and catches, degrading to `MergeResidueUnreadError` — nothing
2411
+ committed, nothing reset."""
2412
+ rc, out = _git(repo, "diff", "--cached", "--quiet", "HEAD")
2413
+ if rc in (0, 1):
2414
+ return rc == 1
2415
+ raise GitError(f"git diff --cached HEAD failed in {repo}: {out}")
2416
+
2417
+
2418
+ def _untracked_paths(repo: Path) -> frozenset[str]:
2419
+ """The repo's untracked, non-ignored paths — the one dirt axis both
2420
+ `_dirty_tracked_paths` and `_index_dirty_vs_head` are blind to.
2421
+
2422
+ Neither of those is a substitute: a merge that dies part-way through checkout
2423
+ rolls its INDEX back but leaves the files it already wrote in the working
2424
+ tree, and an untracked file is by definition absent from HEAD and from the
2425
+ index, so both diffs read CLEAN over it. Sampled before and after the merge
2426
+ and differenced, so the answer is "git wrote this", not "this is here" — the
2427
+ same before/after discipline the tracked half uses, and for the same reason:
2428
+ an absolute post-merge reading would attribute the operator's own strays to
2429
+ git.
2430
+
2431
+ `--exclude-standard` deliberately keeps ignored files out. They are not
2432
+ residue this can act on: an ignored path is invisible to the next merge's
2433
+ pre-flight too, so it cannot produce the resume failure this probe exists to
2434
+ name, and reporting one would send the operator after a file their own
2435
+ `.gitignore` says is theirs.
2436
+
2437
+ NUL-delimited, and read through `_run_git` directly for the same reason
2438
+ `capture_diff` does: this needs stdout VERBATIM and stderr for the error text,
2439
+ which no single `_git*` wrapper hands back together. `ls-files --others`
2440
+ applies `core.quotePath` C-quoting to non-ASCII paths, and a line-splitting
2441
+ read with `.strip()` also eats leading and trailing spaces from a filename —
2442
+ harmless to the delta, since both samples would be mangled identically, but
2443
+ not to the ANSWER: these names are handed to the operator with an instruction
2444
+ to clear them, and a quoted or trimmed name is one they cannot act on. Two
2445
+ distinct paths can also strip to the same string, which would let a
2446
+ pre-existing stray mask a real materialization. `dirty_paths` and
2447
+ `branch_incoming_paths` already read path lists this way.
2448
+
2449
+ Taking stdout verbatim also covers the #442 advisory hazard the merged stream
2450
+ has: git writes warnings to stderr at rc 0, and against `_git`'s merge a
2451
+ warning line would become a phantom path in the set.
2452
+
2453
+ RAISES on a failed read rather than degrading the way its neighbours
2454
+ `_index_unmerged` and `_merge_in_progress` do (each hands back an unread
2455
+ marker), and the difference is position, not importance: this is one half
2456
+ of a PAIR, so a silent empty set is not a neutral answer — an empty BEFORE
2457
+ against a real AFTER reports every stray already in the checkout as
2458
+ something git just wrote, and the message tells the operator to clear what
2459
+ it names. Failing the merge outright is the smaller harm, and the
2460
+ before-read that would produce that asymmetry runs while nothing has been
2461
+ mutated yet.
2462
+
2463
+ That argument covers only the BEFORE reading, which is why the AFTER reading
2464
+ goes through `_merge_residue`: post-merge, this same raise would bypass the
2465
+ cleanup it stands ahead of, so that caller catches it and hands it back as
2466
+ an unread marker instead of letting it escape or answer empty."""
2467
+ proc = _run_git(
2468
+ ["git", "-C", str(repo), "ls-files", "-z", "--others", "--exclude-standard"], repo
2469
+ )
2470
+ if proc.returncode != 0:
2471
+ raise GitError(f"git ls-files --others failed in {repo}: {proc.stderr.strip()}")
2472
+ return frozenset(rel for rel in proc.stdout.split("\0") if rel)
2473
+
2474
+
2475
+ def _incoming_paths(repo: Path, branch: str) -> frozenset[str]:
2476
+ """The paths a merge of `branch` could have WRITTEN: everything that differs
2477
+ between HEAD and `branch`'s tip (`git diff --name-only HEAD <branch>`).
2478
+
2479
+ The attribution boundary `_merge_residue` intersects its deltas with: a merge
2480
+ updates a working-tree path only where the merge result differs from HEAD,
2481
+ and the result can differ from HEAD only where `branch` does — a path both
2482
+ sides changed identically is already at the result — so this two-dot diff is
2483
+ a superset of what any of the three strategies can touch, with no merge base
2484
+ consulted (criss-cross topologies and `branch_incoming_paths`'s two-dot
2485
+ precedent both argue for tips over a base). A concurrent operator edit landing
2486
+ on a path INSIDE this set during the merge window is indistinguishable from
2487
+ git's own write — that residual ceiling is `_merge_residue`'s to state.
2488
+
2489
+ `--no-renames` and NUL-delimited verbatim stdout for `_dirty_tracked_paths`'s
2490
+ reasons: a rename must contribute BOTH its names (the deleted old name is
2491
+ exactly the kind of tracked residue a mid-checkout death leaves), and these
2492
+ names gate a repair write. Read only through `_merge_residue`'s catch — this
2493
+ runs post-merge, where a raise would bypass the cleanup — and only LAZILY,
2494
+ when there is a non-empty delta to attribute: a refusal that left no new dirt
2495
+ never consults `branch` at all, so an unresolvable ref (a raced-away branch,
2496
+ unrelated histories) still classifies as the pre-flight refusal it is."""
2497
+ proc = _run_git(
2498
+ ["git", "-C", str(repo), "diff", "--name-only", "-z", "--no-renames", "HEAD", branch, "--"],
2499
+ repo,
2500
+ )
2501
+ if proc.returncode != 0:
2502
+ raise GitError(
2503
+ f"git diff --name-only HEAD {branch} failed in {repo}: {proc.stderr.strip()}"
2504
+ )
2505
+ return frozenset(rel for rel in proc.stdout.split("\0") if rel)
2506
+
2507
+
2508
+ def _residue_snapshot(repo: Path) -> tuple[frozenset[str], frozenset[str]]:
2509
+ """The pre-merge reading every leg takes: `(dirty tracked paths, untracked
2510
+ paths)`.
2511
+
2512
+ Two axes because a failed checkout leaves two kinds of residue, and neither
2513
+ probe sees the other's. Taken BEFORE the merge for the reason #619 established
2514
+ on the squash leg: a checkout already carrying an unstaged edit reads dirty
2515
+ whether or not git touched a byte, so only a before/after comparison can say
2516
+ what GIT did — and only paths proven clean beforehand may be restored. Both
2517
+ halves are path SETS so that comparison is per path: one operator edit
2518
+ anywhere no longer surrenders the whole tracked axis, and — the other way
2519
+ around — a concurrent edit is no longer swept into a repo-wide attribution."""
2520
+ return _dirty_tracked_paths(repo), _untracked_paths(repo)
2521
+
2522
+
2523
+ def _merge_residue(
2524
+ repo: Path, branch: str, pre_dirty_paths: frozenset[str], pre_untracked: frozenset[str]
2525
+ ) -> tuple[tuple[str, ...], tuple[str, ...], GitError | None]:
2526
+ """What a failed merge left behind: `(untracked paths git wrote, tracked
2527
+ paths git rewrote, the probe failure when a reading itself failed)`.
2528
+
2529
+ Attribution is a per-path AND of two proofs, and both are load-bearing. The
2530
+ before/after DELTA proves a path changed during the merge window — without
2531
+ it every pre-existing stray and edit is git's. The INCOMING-set intersection
2532
+ proves the merge could have written it — without it a concurrent operator
2533
+ edit landing during the window is git's too, and the restore riding on the
2534
+ tracked half destroys it (the seventh mislabeled state; measured on all
2535
+ three legs). Both readings fail to the same, safe side: a path this cannot
2536
+ attribute is reported to nobody and restored over never.
2537
+
2538
+ Two ceilings survive, stated rather than patched around. A path already
2539
+ dirty BEFORE the merge stays unattributable — per path now, not per tree —
2540
+ because the delta cannot say which bytes are whose. And a concurrent edit
2541
+ to a path INSIDE the incoming set is indistinguishable from git's own
2542
+ write, so it is attributed and restored; an edit racing the very paths a
2543
+ merge is rewriting has no safe reading at all, and the alternative —
2544
+ trusting nothing — would strand genuine residue on every half-applied
2545
+ checkout.
2546
+
2547
+ The POST-mutation reading this is, a probe failure here must DEGRADE, never
2548
+ raise: the raise would escape `merge_branch` between the failed merge and its
2549
+ cleanup — stranding a started merge mid-flight with MERGE_HEAD set — and
2550
+ reach `merge_local`'s content-conflict arm wearing a probe error's text.
2551
+ Degrading does not mean going silent, which would route the empty reading to
2552
+ `MergePreflightError` and claim a tree nothing verified: the caught error is
2553
+ handed back so the terminal arm raises `MergeResidueUnreadError` instead,
2554
+ and both residue axes fail to the no-action side — nothing reported, nothing
2555
+ restored. The incoming read shares the catch and is taken LAZILY, only when
2556
+ a delta exists to attribute (see `_incoming_paths`). The BEFORE reading
2557
+ (`_residue_snapshot`) keeps its raise: it runs while nothing has been
2558
+ mutated yet, where failing the merge outright is the smaller harm."""
2559
+ try:
2560
+ materialized = _untracked_paths(repo) - pre_untracked
2561
+ rewritten = _dirty_tracked_paths(repo) - pre_dirty_paths
2562
+ if materialized or rewritten:
2563
+ incoming = _incoming_paths(repo, branch)
2564
+ materialized &= incoming
2565
+ rewritten &= incoming
2566
+ except GitError as unread:
2567
+ return (), (), unread
2568
+ return tuple(sorted(materialized)), tuple(sorted(rewritten)), None
2569
+
2570
+
2571
+ def _restore_rewritten_paths(repo: Path, paths: tuple[str, ...]) -> tuple[bool, str]:
2572
+ """`git checkout HEAD -- <paths>` over the tracked paths a merge rewrote.
2573
+ Returns `(restored, note)`, the note being the clause to append to the raised
2574
+ message when it failed — so the error never claims a repair that did not
2575
+ happen. Path-scoped ON PURPOSE, never `reset --hard`: the attribution that
2576
+ authorizes this write is per path, so the write must be too — a repo-wide
2577
+ reset would flatten the operator's own dirt on every path the attribution
2578
+ deliberately left alone. `:(literal)` because these names came out of git
2579
+ verbatim and go back in as pathspecs — a `*` or leading `:` in a filename
2580
+ must match itself, not glob. Restores the index entry along with the
2581
+ working-tree bytes, which is the same path-scoped claim `reset --hard HEAD`
2582
+ made repo-wide, and resolves an unmerged entry to HEAD's version exactly as
2583
+ the reset did (measured; a path HEAD does not carry fails the whole write
2584
+ instead, and the note carries that). Callers must gate this on
2585
+ `_merge_residue`'s proven attribution; it is unconditional here on purpose,
2586
+ so the gate lives at one readable place per leg rather than inside the
2587
+ write."""
2588
+ rc, out = _git(repo, "checkout", "HEAD", "--", *(f":(literal){p}" for p in paths))
2589
+ if rc != 0:
2590
+ return False, (
2591
+ f"; AND git checkout HEAD -- <paths> failed (tracked residue not restored): {out}"
2592
+ )
2593
+ return True, ""
2594
+
2595
+
2596
+ def _reset_hard_head(repo: Path) -> tuple[bool, str]:
2597
+ """`reset --hard HEAD`, the squash COMMIT step's rollback — the one remaining
2598
+ caller, and the one place a whole-tree write is still the right shape: the
2599
+ thing being rolled back is a SUCCEEDED `merge --squash`, whose staged result
2600
+ spans the entire incoming set, and the gate is the leg's pre-merge clean
2601
+ reading. Returns `(restored, note)` like its path-scoped sibling above.
2602
+
2603
+ The ceiling that gate leaves, stated: a concurrent operator edit landing
2604
+ AFTER the pre-merge reading found the tree clean — during the squash or its
2605
+ commit attempt — sits inside this reset's blast radius and is flattened with
2606
+ the staged result. Narrowing that would mean unpicking a successful merge
2607
+ path by path against an attribution the commit step never takes; the bound
2608
+ is the deliberate trade, not an oversight."""
2609
+ rc, out = _git(repo, "reset", "--hard", "HEAD")
2610
+ if rc != 0:
2611
+ return False, f"; AND git reset --hard HEAD failed (tree not restored): {out}"
2612
+ return True, ""
2613
+
2614
+
2615
+ def _index_unmerged(repo: Path) -> tuple[bool, GitError | None]:
2616
+ """`(the index carries unmerged stages — i.e. a merge really ran and left a
2617
+ content conflict to resolve, the probe failure when the reading itself
2618
+ failed)`.
2619
+
2620
+ Deliberately NOT `.git/MERGE_HEAD`: a conflicted `git merge --squash` writes
2621
+ three unmerged stages and conflict markers while creating no MERGE_HEAD at
2622
+ all, so MERGE_HEAD would call every squash conflict a pre-flight refusal.
2623
+ `ls-files -u` discriminates across the whole matrix — empty for every
2624
+ pre-flight refusal and for success, three stages for a content conflict under
2625
+ both `--no-ff` and `--squash`. Neither git's exit code nor its wording can
2626
+ stand in: the same refusal shape yields rc 2 or rc 1 depending on whether the
2627
+ merge was fast-forwardable, rc 1 is also a content conflict, and one message
2628
+ line covers three distinct causes and is fully translated (#619).
2629
+
2630
+ Read from stdout alone (#442): this is an emptiness read, and git writes
2631
+ advisories to stderr while still exiting 0, which against `_git`'s merged
2632
+ stream would read as unmerged entries.
2633
+
2634
+ A failed reading DEGRADES to `(False, the failure)` rather than raising or
2635
+ silently answering "no conflict". The raise would escape `merge_branch`
2636
+ between the failed merge and its cleanup — the same post-mutation window
2637
+ `_merge_residue` already degrades in — and the silent False this used to
2638
+ give is no better: every NEGATIVE arm of the classification (commit-
2639
+ refused, half-applied, pre-flight) rests on `not unmerged`, so an
2640
+ unmeasured "no" lets a probe failure pick one of their classes. rc != 0 is
2641
+ the same unread — an `ls-files` that failed printed nothing, and an
2642
+ emptiness read over nothing is not a measurement. False WITH the marker
2643
+ set means unmeasured; callers claim no class resting on this reading and
2644
+ route to `MergeResidueUnreadError` instead."""
2645
+ try:
2646
+ rc, value, diag = _git_out(repo, "ls-files", "-u")
2647
+ except GitError as unread:
2648
+ return False, unread
2649
+ if rc != 0:
2650
+ return False, GitError(f"git ls-files -u failed in {repo}: {diag}")
2651
+ return bool(value), None
2652
+
2653
+
2654
+ def merge_branch(
2655
+ repo: Path,
2656
+ branch: str,
2657
+ *,
2658
+ strategy: str = "merge",
2659
+ message: str | None = None,
2660
+ allow_empty_squash: bool = False,
2661
+ ) -> None:
2662
+ """Merge `branch` into the branch currently checked out in `repo`.
2663
+
2664
+ strategy: "ff" (fast-forward only), "merge" (always a merge commit), or
2665
+ "squash" (collapse to one commit). Raises MergeConflictError on conflict and
2666
+ MergePreflightError when an ff-only merge can't fast-forward, restoring the
2667
+ tree to its pre-merge state.
2668
+ Expects the target checkout to be clean; the worktree pipeline reconciles
2669
+ Editor-induced dirt first via `clean_incoming_collisions`.
2670
+
2671
+ A failure git raised at PRE-FLIGHT — an untracked file the merge would
2672
+ overwrite, a staged change on an incoming path, a file/directory shape clash,
2673
+ an `--ff-only` target that cannot fast-forward — never started a merge and
2674
+ left the tree untouched, so it raises the `MergePreflightError` subclass with
2675
+ git's own text passed through verbatim (#619). "git said no" is not enough to
2676
+ earn that class, though; see the fourth state below, where git says no after
2677
+ having already written part of the incoming tree.
2678
+
2679
+ Four measured states, one terminal fallback, and no one probe orders them.
2680
+ `_index_unmerged` leads and answers
2681
+ CONTENT: unmerged stages mean the merge ran and collided (raised as
2682
+ `MergeConflictError`, so the caller never has to read "bare GitError" as
2683
+ "conflict"). Its absence does not
2684
+ mean the merge never ran — a `--no-ff` whose commit was refused leaves a
2685
+ cleanly merged index, no unmerged stages, and MERGE_HEAD set (measured for
2686
+ `pre-merge-commit`, `commit-msg`, and an unsignable `commit.gpgsign`).
2687
+ MERGE_HEAD is the second question and parts those two, which is why it is read
2688
+ BEFORE the abort that erases it rather than only to decide whether to abort at
2689
+ all.
2690
+
2691
+ MERGE_HEAD alone would be wrong in the other direction — the error the old
2692
+ "no MERGE_HEAD created" framing made — because a conflicted `--squash` leaves
2693
+ unmerged stages and no MERGE_HEAD. The squash MERGE INVOCATION cannot be
2694
+ commit-refused — `--squash` stops before committing by design (measured:
2695
+ rc 0 with a rejecting `pre-merge-commit` hook installed) — which is why only
2696
+ the `merge` leg reads MERGE_HEAD below. The LEG still reaches that state:
2697
+ it seals the staged result with its own plain `git commit`, where hooks and
2698
+ `commit.gpgsign` run like anywhere else, and a refusal there raises the same
2699
+ `MergeCommitRefusedError` — rolled back by `reset --hard HEAD` under the
2700
+ leg's pre-merge dirtiness gate rather than by an abort, and reported
2701
+ ``staged`` when that gate or the reset itself declines. The scope error to
2702
+ not repeat: "no hook runs" was measured of the merge invocation and is false
2703
+ of the leg, whose commit step is a second, later place git can say no.
2704
+
2705
+ The fourth state is the one ALL THREE legs reach and no index probe can see:
2706
+ git dying part-way through the CHECKOUT. It leaves no unmerged stages, no
2707
+ MERGE_HEAD, and an index rolled back to HEAD, so every index- and HEAD-based
2708
+ reading calls it "refused before starting" and tells the operator their
2709
+ checkout is untouched — while the residue blocks the next merge's pre-flight,
2710
+ and the run fails identically on every resume over paths nothing named.
2711
+ `--ff-only` is not exempt, and the "it never starts a merge" premise this
2712
+ module used to carry was simply wrong: `--ff-only` declines the TOPOLOGY
2713
+ question only, and once the fast-forward is possible it checks the incoming
2714
+ tree out like any other merge (measured under a required smudge filter, all
2715
+ three strategies; HEAD does not move).
2716
+
2717
+ The residue has two axes, which is why `_residue_snapshot` reads two probes
2718
+ and not one — and attribution on both is per PATH: a before/after delta
2719
+ intersected with the branch's incoming set, so neither the operator's
2720
+ pre-existing dirt nor a CONCURRENT edit of theirs landing during the merge
2721
+ window is ever called git's (the latter was the seventh mislabeled state: a
2722
+ repo-wide dirtiness boolean attributed the bystander edit to git and the
2723
+ repo-wide reset riding on it destroyed the edit — measured on all three
2724
+ legs). An incoming path the target did not already track lands as an
2725
+ UNTRACKED file, which no restore reaches — `reset --hard` and `merge --abort`
2726
+ both leave untracked files alone, and the latter exits 128 here besides, there
2727
+ being no merge to abort — so it is reported for the operator to clear. An
2728
+ incoming path the target DID track is modified in place, which a path-scoped
2729
+ `git checkout HEAD --` over exactly the attributed paths does undo.
2730
+ Either axis raises `MergeHalfAppliedError` — a SIBLING of `MergePreflightError`,
2731
+ since the two are mutually exclusive by construction (a refusal git makes at
2732
+ pre-flight is made before any file is written).
2733
+
2734
+ The terminal fallback is a post-merge reading that itself FAILED. Every
2735
+ probe in that window degrades to an unread marker rather than raising —
2736
+ the raise would escape between the failed merge and its cleanup — and a
2737
+ verdict may only stand on readings that are live. Dead residue readings —
2738
+ either delta's, or the incoming set's that attributes them — surrender
2739
+ only the choice between "refused before starting" and "failed
2740
+ part-way" (conflict and commit-refused stand on their own measurements
2741
+ over it). A dead index reading surrenders every claim resting on "did not
2742
+ collide" — commit-refused, half-applied, and pre-flight alike. A dead
2743
+ merge-state reading additionally skips the abort, which that reading
2744
+ gates: `merge --abort` is a repair write, and uncertainty never
2745
+ authorizes one, so the message says none was attempted. Whatever cannot
2746
+ be claimed raises `MergeResidueUnreadError`, and every dead probe is
2747
+ named in the message whichever class raises.
2748
+
2749
+ ``allow_empty_squash`` is recovery-only: re-running a squash that committed
2750
+ before a host loss stages nothing because the target already has the merged
2751
+ tree. That clean result confirms the replay without manufacturing an empty
2752
+ commit; ordinary squash calls keep commit failures strict.
2753
+ """
2754
+ if strategy == "ff":
2755
+ pre_dirty_paths, pre_untracked = _residue_snapshot(repo)
2756
+ rc, out = _git(repo, "merge", "--ff-only", branch)
2757
+ if rc != 0:
2758
+ # "--ff-only either fast-forwards or declines, so it never touches the
2759
+ # tree" was the standing premise here, and it is FALSE: `--ff-only`
2760
+ # declines only the topology question. Once the fast-forward IS possible
2761
+ # it checks the incoming tree out, and a failure during that write —
2762
+ # measured under a required smudge filter — leaves HEAD where it was and
2763
+ # the residue behind. There are no index stages and no MERGE_HEAD to read,
2764
+ # so the residue snapshot is this leg's ONLY discriminator.
2765
+ materialized, rewritten, unread = _merge_residue(
2766
+ repo, branch, pre_dirty_paths, pre_untracked
2767
+ )
2768
+ half_applied = bool(materialized or rewritten)
2769
+ if half_applied:
2770
+ kind = "failed part-way through checkout"
2771
+ elif unread is not None:
2772
+ kind = "checkout state unverified"
2773
+ else:
2774
+ kind = "refused before starting"
2775
+ detail = f"git merge --ff-only {branch} failed in {repo} ({kind}): {out}"
2776
+ restored = True
2777
+ if rewritten:
2778
+ restored, note = _restore_rewritten_paths(repo, rewritten)
2779
+ detail += note
2780
+ if materialized:
2781
+ detail += f"; left untracked in {repo}: {', '.join(materialized)}"
2782
+ if half_applied:
2783
+ raise MergeHalfAppliedError(
2784
+ detail, paths=materialized, restored=restored, rewritten=rewritten
2785
+ )
2786
+ if unread is not None:
2787
+ raise MergeResidueUnreadError(f"{detail}; AND the residue probe failed: {unread}")
2788
+ raise MergePreflightError(detail)
2789
+ return
2790
+ if strategy == "merge":
2791
+ msg = message or f"Merge branch '{branch}'"
2792
+ pre_dirty_paths, pre_untracked = _residue_snapshot(repo)
2793
+ rc, out = _git(repo, "merge", "--no-ff", "-m", msg, branch)
2794
+ if rc != 0:
2795
+ # All three questions BEFORE the abort, which erases the evidence for each.
2796
+ # The index stages say whether content collided; MERGE_HEAD says whether
2797
+ # a merge started at all, and it is asked here rather than inline below
2798
+ # so that one reading serves both the classification and the abort. The
2799
+ # residue deltas answer a question neither can: whether git wrote any
2800
+ # incoming file to the tree before dying (#619). All three degrade to an
2801
+ # unread marker rather than raising — they stand between the failed
2802
+ # merge and its cleanup, where an escape strands a started merge.
2803
+ unmerged, index_unread = _index_unmerged(repo)
2804
+ started, head_unread = _merge_in_progress(repo)
2805
+ materialized, rewritten, unread = _merge_residue(
2806
+ repo, branch, pre_dirty_paths, pre_untracked
2807
+ )
2808
+ # Only a failure that neither collided nor started can be a half-applied
2809
+ # checkout: a conflict and a refused commit both leave residue too, but
2810
+ # each already has a restore of its own below (`merge --abort`) and a
2811
+ # remedy of its own, so neither may be re-routed through this arm. The
2812
+ # two unread gates are that same rule asked negatively: with either
2813
+ # reading dead, "neither collided nor started" is a claim nothing
2814
+ # measured.
2815
+ half_applied = bool(
2816
+ index_unread is None
2817
+ and head_unread is None
2818
+ and not unmerged
2819
+ and not started
2820
+ and (materialized or rewritten)
2821
+ )
2822
+ if unmerged:
2823
+ kind = "conflict"
2824
+ elif index_unread is None and started:
2825
+ # `started` alone cannot claim this over a dead index reading: a
2826
+ # `--no-ff` conflict sits mid-merge too, and parting the two is
2827
+ # exactly the reading that failed.
2828
+ kind = "merged, but git refused the commit"
2829
+ elif half_applied:
2830
+ kind = "failed part-way through checkout"
2831
+ elif index_unread is not None or head_unread is not None or unread is not None:
2832
+ kind = "checkout state unverified"
2833
+ else:
2834
+ kind = "refused before starting"
2835
+ detail = f"git merge --no-ff {branch} failed in {repo} ({kind}): {out}"
2836
+ restored = True
2837
+ if half_applied and rewritten:
2838
+ # This leg has no `--abort` to reach for — that needs a MERGE_HEAD,
2839
+ # and there is none — so the restore is the path-scoped checkout,
2840
+ # over exactly the paths `_merge_residue` attributed.
2841
+ restored, note = _restore_rewritten_paths(repo, rewritten)
2842
+ detail += note
2843
+ if materialized and half_applied:
2844
+ detail += f"; left untracked in {repo}: {', '.join(materialized)}"
2845
+ if started: # only abort a merge that actually started
2846
+ abort_rc, abort_out = _git(repo, "merge", "--abort") # restore pre-merge HEAD
2847
+ if abort_rc != 0:
2848
+ # The repair write failed, so the claim the caller's message would
2849
+ # otherwise make — "the checkout is back as it was" — is now false.
2850
+ # Carry that, rather than letting the classification imply it.
2851
+ restored = False
2852
+ detail += f"; AND git merge --abort failed (repo left mid-merge): {abort_out}"
2853
+ # Every dead probe is named in whatever raises, not only in the unread
2854
+ # class: a verdict standing on its own live measurement still owes the
2855
+ # operator which reading it does NOT have.
2856
+ if index_unread is not None:
2857
+ detail += f"; AND the index probe failed: {index_unread}"
2858
+ if head_unread is not None:
2859
+ # The abort is gated on the reading that just died, and uncertainty
2860
+ # must not authorize a repair write — so none was attempted, and the
2861
+ # message says that instead of implying a restore.
2862
+ detail += (
2863
+ "; AND the merge-state probe failed, so no `git merge --abort` was"
2864
+ " attempted — if `git status` shows a merge in progress, run it by"
2865
+ f" hand: {head_unread}"
2866
+ )
2867
+ if unread is not None:
2868
+ detail += f"; AND the residue probe failed: {unread}"
2869
+ if unmerged:
2870
+ raise MergeConflictError(detail)
2871
+ if index_unread is None and started:
2872
+ raise MergeCommitRefusedError(detail, restored=restored)
2873
+ if half_applied:
2874
+ raise MergeHalfAppliedError(
2875
+ detail, paths=materialized, restored=restored, rewritten=rewritten
2876
+ )
2877
+ if index_unread is not None or head_unread is not None or unread is not None:
2878
+ raise MergeResidueUnreadError(detail)
2879
+ raise MergePreflightError(detail)
2880
+ return
2881
+ if strategy == "squash":
2882
+ # `--squash` has no `--abort`, so the restore is a path-scoped
2883
+ # `checkout HEAD --` over whatever the residue deltas attribute to git.
2884
+ # A single post-merge dirtiness reading cannot say whether the squash
2885
+ # caused the dirt it sees, so both axes are read BEFORE and differenced
2886
+ # after: a checkout already carrying an unstaged edit reads dirty even
2887
+ # when git refused and touched nothing, and a restore fired on that
2888
+ # reading destroyed it (#619). The untracked half covers the axis the
2889
+ # tracked one cannot see: a part-way merge rolls the index back and the
2890
+ # files it already wrote are untracked, so they are in neither HEAD nor
2891
+ # the index — which is exactly how such a failure came to be labelled
2892
+ # "refused before starting".
2893
+ pre_dirty_paths, pre_untracked = _residue_snapshot(repo)
2894
+ rc, out = _git(repo, "merge", "--squash", branch)
2895
+ if rc != 0:
2896
+ unmerged, index_unread = _index_unmerged(repo) # before any restore clears the stages
2897
+ materialized, rewritten, unread = _merge_residue(
2898
+ repo, branch, pre_dirty_paths, pre_untracked
2899
+ )
2900
+ # A conflict keeps its own class and its own remedy even though it leaves
2901
+ # residue too — its attributed paths are still restored below, exactly as
2902
+ # before. The unread gate is the conflict question asked negatively: with
2903
+ # the index reading dead, "did not collide" is a claim nothing measured.
2904
+ half_applied = bool(
2905
+ index_unread is None and not unmerged and (materialized or rewritten)
2906
+ )
2907
+ if unmerged:
2908
+ kind = "conflict"
2909
+ elif half_applied:
2910
+ kind = "failed part-way through checkout"
2911
+ elif index_unread is not None or unread is not None:
2912
+ kind = "checkout state unverified"
2913
+ else:
2914
+ kind = "refused before starting"
2915
+ detail = f"git merge --squash {branch} failed in {repo} ({kind}): {out}"
2916
+ if materialized and half_applied:
2917
+ detail += f"; left untracked in {repo}: {', '.join(materialized)}"
2918
+ restored = True
2919
+ if rewritten:
2920
+ # Gated on the proven per-path attribution, not on the class — so it
2921
+ # still runs when the index reading died and the class degraded: the
2922
+ # same restore a classified conflict gets, authorized by the same
2923
+ # measurement.
2924
+ restored, note = _restore_rewritten_paths(repo, rewritten)
2925
+ detail += note
2926
+ if index_unread is not None:
2927
+ detail += f"; AND the index probe failed: {index_unread}"
2928
+ if unread is not None:
2929
+ detail += f"; AND the residue probe failed: {unread}"
2930
+ if unmerged:
2931
+ raise MergeConflictError(detail)
2932
+ if half_applied:
2933
+ raise MergeHalfAppliedError(
2934
+ detail, paths=materialized, restored=restored, rewritten=rewritten
2935
+ )
2936
+ if index_unread is not None or unread is not None:
2937
+ raise MergeResidueUnreadError(detail)
2938
+ raise MergePreflightError(detail)
2939
+ if allow_empty_squash:
2940
+ # Post-mutation read on the far side of SUCCESS: an escaping raise
2941
+ # would land in the caller's unclassified arm, and pressing on with
2942
+ # the reading dead manufactures a "nothing to commit" refusal plus
2943
+ # its rollback. Neither the no-op return nor the commit can be
2944
+ # claimed; say so and stop.
2945
+ try:
2946
+ staged = _index_dirty_vs_head(repo)
2947
+ except GitError as unread:
2948
+ raise MergeResidueUnreadError(
2949
+ f"git merge --squash {branch} succeeded in {repo}, but the index"
2950
+ f" reading that tells a no-op replay from a result to commit failed"
2951
+ f" (index state unverified): nothing was committed and nothing was"
2952
+ f" reset — any staged squash result is left in place; run"
2953
+ f" `git status`: {unread}"
2954
+ ) from unread
2955
+ if not staged:
2956
+ return
2957
+ msg = message or f"Squash-merge branch '{branch}'"
2958
+ rc, out = _git(repo, "commit", "-m", msg)
2959
+ if rc != 0:
2960
+ # The leg's own commit — hooks and commit.gpgsign run HERE, not at the
2961
+ # `merge --squash` above, so this is where the squash reaches the
2962
+ # commit-refused state. No probe is needed to classify it: the merge
2963
+ # step already succeeded, so the failed call names the state by
2964
+ # itself. What needs deciding is the rollback — the deliberate undo
2965
+ # of a merge that SUCCEEDED, whose staged result spans the whole
2966
+ # incoming set, so it stays `reset --hard HEAD` rather than the
2967
+ # failure arm's path-scoped restore — and the gate is the snapshot's
2968
+ # tracked half: the staged result sits in a tree that reset would
2969
+ # flatten, so only a tree proven clean beforehand may be reset —
2970
+ # over a dirty one the operator's own uncommitted work is in the
2971
+ # blast radius, and the result is left staged and SAID so instead
2972
+ # (#619). The ceiling that gate leaves — an edit landing AFTER the
2973
+ # clean reading rides the reset — is `_reset_hard_head`'s to state.
2974
+ detail = (
2975
+ f"git commit (squash {branch}) failed in {repo} "
2976
+ f"(merged, but git refused the commit): {out}"
2977
+ )
2978
+ if pre_dirty_paths:
2979
+ restored = False
2980
+ detail += (
2981
+ "; the squash result is left staged (not rolled back: the "
2982
+ "checkout already carried uncommitted work, which "
2983
+ "`reset --hard` would destroy with it)"
2984
+ )
2985
+ else:
2986
+ restored, note = _reset_hard_head(repo)
2987
+ detail += note
2988
+ if not restored:
2989
+ detail += "; the squash result is left staged"
2990
+ raise MergeCommitRefusedError(detail, restored=restored, staged=not restored)
2991
+ return
2992
+ raise GitError(f"unknown merge strategy: {strategy!r}")
2993
+
2994
+
2995
+ def capture_diff(repo: Path, baseline: str, *, max_file_bytes: int | None = None) -> str:
2996
+ """Full unified diff of `repo`'s working tree against `baseline`, including
2997
+ untracked (but not ignored) files. Used to preserve a failed unit's changes
2998
+ for forensics. Returns "" when there is nothing to capture.
2999
+
3000
+ Unlike `_git`, the tracked diff is read from stdout alone and left verbatim
3001
+ (no strip, no stderr merge) so the patch stays applyable, as is the
3002
+ `--no-index` spawn below it. The untracked leg now matches those two
3003
+ (`_git_out`, #442): its `ls-files` exits 0 while still
3004
+ warning on stderr, so against the merged stream the warning splits off as a
3005
+ phantom rel. Measured, that phantom is inert here — `diff --no-index` cannot
3006
+ access it and exits 1, exactly the code the loop below already tolerates as
3007
+ "the files differ", with empty stdout — so this leg is converted for the same
3008
+ reason its two neighbours read stdout alone, not on a demonstrated corruption.
3009
+
3010
+ max_file_bytes caps the size of each *untracked* file included: a file larger
3011
+ than the cap is skipped and replaced with a one-line marker naming it and its
3012
+ size, so a stray build dir or huge log can't balloon the patch. None lifts the
3013
+ cap (capture everything regardless of size).
3014
+ """
3015
+ proc = _run_git(["git", "-C", str(repo), "diff", baseline, "--"], repo)
3016
+ if proc.returncode != 0:
3017
+ raise GitError(f"git diff {baseline} failed in {repo}: {proc.stderr.strip()}")
3018
+ parts = [proc.stdout]
3019
+
3020
+ rc, out, detail = _git_out(repo, "ls-files", "--others", "--exclude-standard")
3021
+ if rc != 0:
3022
+ raise GitError(f"git ls-files --others failed in {repo}: {detail}")
3023
+ for rel in out.splitlines():
3024
+ rel = rel.strip()
3025
+ if not rel:
3026
+ continue
3027
+ if max_file_bytes is not None:
3028
+ try:
3029
+ size = (repo / rel).stat().st_size
3030
+ except OSError:
3031
+ size = 0
3032
+ if size > max_file_bytes:
3033
+ parts.append(
3034
+ f"# froid-loop: skipped untracked file {rel!r} — "
3035
+ f"{size / 1_048_576:.1f} MB exceeds the {max_file_bytes / 1_048_576:.1f} MB "
3036
+ "cap (raise scm.failed_diff_max_mb or set scm.failed_diff_unlimited = true)\n"
3037
+ )
3038
+ continue
3039
+ # --no-index synthesizes an add-from-empty diff for the untracked file;
3040
+ # it exits 1 precisely because the files differ — expected here. Any other
3041
+ # non-zero code is a real failure (bad path, internal error), not "files
3042
+ # differ", so don't silently fold it into the patch.
3043
+ u = _run_git(["git", "-C", str(repo), "diff", "--no-index", "--", os.devnull, rel], repo)
3044
+ if u.returncode not in (0, 1):
3045
+ raise GitError(
3046
+ f"git diff --no-index for untracked {rel!r} failed in {repo}: {u.stderr.strip()}"
3047
+ )
3048
+ parts.append(u.stdout)
3049
+ return "".join(parts)
3050
+
3051
+
3052
+ def set_frontmatter_field(path: Path, key: str, value: str, *, confine_root: Path) -> bool:
3053
+ """Rewrite (or insert) a scalar ``<key>:`` line in a spec's `---`…`---`
3054
+ frontmatter block.
3055
+
3056
+ Same verified in-place line surgery as `set_frontmatter_status` (no YAML
3057
+ round-trip) so the spec's formatting, comments, and field order survive, and
3058
+ the same three-way return: True on a landed rewrite, False for **nothing to
3059
+ change** (no file, no frontmatter block, already at the value), and
3060
+ `FrontmatterWriteError` when the reader can see the key in a shape no line
3061
+ edit can safely move. "Comments survive" includes a trailing inline comment
3062
+ on the edited line itself: this shares `frontmatter._replace_value` with the
3063
+ status helper, so it inherits that renderer's certified-boundary carry (and
3064
+ its quote drop) rather than restating either.
3065
+
3066
+ Unlike the status helper, a missing key is INSERTED as the block's last
3067
+ line: callers assert a field's value whether or not the skill wrote one
3068
+ (the patch-restore re-arm re-stamps ``baseline_revision``, which only the
3069
+ skill's step-03 writes). The insert is now gated on what `read_frontmatter`
3070
+ SEES rather than on a line-scan miss, which was a defect of its own — a
3071
+ quoted ``"baseline_revision":`` was missed by the scan and a second one
3072
+ appended, so the spec carried the key twice and the reader resolved the
3073
+ wrong one.
3074
+
3075
+ Byte-preserving on the same terms as its sibling: ``read_bytes().decode`` in
3076
+ and bytes out, so a CRLF spec is not relaid to LF (nor an LF one to CRLF on
3077
+ Windows) by a write contracted to move one field. The INSERTED line takes the
3078
+ block's own ending, not a bare ``\\n``.
3079
+
3080
+ Atomic on the same terms too (#379), and CONFINED on the same terms (#593):
3081
+ the spec-writer chokepoint rule — confined write in-tree, plain no-follow
3082
+ write for an artifacts folder configured outside the checkout — is stated
3083
+ once, in `frontmatter.set_frontmatter_status`, and this site implements it
3084
+ identically. ``confine_root`` is required for the reason it is required
3085
+ there. So is ``require_writable_target=True`` (#597): this rewrites an
3086
+ operator-editable spec, and a read-only one is answered rather than routed
3087
+ around by a replace that only needs the directory writable.
3088
+
3089
+ Use the BYTES helper and not the text one:
3090
+ `atomic_write_text` keeps ``Path.write_text``'s translating newline default,
3091
+ which would relay ``\\n``→``\\r\\n`` on Windows and undo the paragraph above.
3092
+ """
3093
+ if not path.is_file():
3094
+ return False
3095
+ text = path.read_bytes().decode("utf-8")
3096
+ split = _split_frontmatter(text)
3097
+ if split is None:
3098
+ return False
3099
+ before, block, after = split
3100
+ edited = _edit_frontmatter_block(block, key, value, insert=True)
3101
+ if edited is None:
3102
+ return False
3103
+ payload = (before + edited + after).encode("utf-8")
3104
+ if path.is_relative_to(confine_root):
3105
+ atomic_write_bytes_confined(
3106
+ path, payload, confine_root=confine_root, require_writable_target=True
3107
+ )
3108
+ else:
3109
+ atomic_write_bytes(path, payload, follow_symlinks=False, require_writable_target=True)
3110
+ return True
3111
+
3112
+
3113
+ def artifact_relpaths(paths: ProjectPaths) -> tuple[str, ...]:
3114
+ """Repo-relative posix prefixes of the orchestrator-owned FROID artifact
3115
+ folders (the output root and the implementation/planning artifact dirs),
3116
+ relative to ``paths.project``. Folders configured outside the project tree
3117
+ are skipped — nothing to exclude there.
3118
+
3119
+ NO PRODUCTION CALLER. Both consumers it was written for have moved: the
3120
+ dev/bundle proof-of-work gate now composes its excludes file-granularly through
3121
+ ``verify_dev_exclude_relpaths``, rooted on ``paths.repo_root`` where git runs
3122
+ (#716), and rollback protection builds its own list against the workspace root in
3123
+ ``RecoveryFlow.protected_relpaths``. Its ``paths.project`` anchor is therefore
3124
+ inert rather than correct — do not cite it as evidence that project-rooting is
3125
+ right for anything, and re-derive the root if a caller is ever added."""
3126
+ out: list[str] = []
3127
+ for folder in (
3128
+ paths.output_folder,
3129
+ paths.implementation_artifacts,
3130
+ paths.planning_artifacts,
3131
+ ):
3132
+ try:
3133
+ rel = folder.relative_to(paths.project).as_posix()
3134
+ except ValueError:
3135
+ continue # configured outside the project tree; nothing to exclude here
3136
+ # A folder == project root yields ".", which as an exclude prefix would
3137
+ # disable change detection for the whole tree — drop it.
3138
+ if rel and rel != ".":
3139
+ out.append(rel)
3140
+ return tuple(out)
3141
+
3142
+
3143
+ def verify_dev_exclude_relpaths(
3144
+ paths: ProjectPaths,
3145
+ spec_path: Path,
3146
+ restore_patch: str | None = None,
3147
+ *,
3148
+ root: Path,
3149
+ ) -> tuple[str, ...]:
3150
+ """Repo-relative posix paths the dev/bundle proof-of-work gate excludes from
3151
+ `has_changes_since` — file-granularity, unlike `artifact_relpaths`' whole-folder
3152
+ exclusion. `artifact_relpaths` has NO production caller left: rollback
3153
+ protection builds its own list in `recovery_flow.protected_relpaths` against
3154
+ `workspace.root`, and `Engine._protected_relpaths` merely delegates there. Do
3155
+ not adopt it as a shortcut — it is still anchored on `paths.project`, which is
3156
+ #716's root cause. Deliberately does NOT exclude `output_folder`:
3157
+ in the standard layout it is the parent directory of `implementation_artifacts`/
3158
+ `planning_artifacts`, so excluding it as a directory prefix would swallow those
3159
+ two folders' content right back out of view via the same git-pathspec prefix
3160
+ match this function exists to avoid.
3161
+
3162
+ Excludes only what a session rewrites regardless of whether it did any real
3163
+ work: `paths.sprint_status` (every session advances it as routine bookkeeping)
3164
+ and the session's own claimed `spec_path` (so a bare frontmatter status flip on
3165
+ it doesn't count). Sibling content under the implementation/planning artifact
3166
+ dirs — the deferred-work ledger, other stories' specs — is deliberately left
3167
+ un-excluded, so a story whose entire authorized scope is ledger/spec
3168
+ reconciliation registers as real work instead of a permanent false "no changes
3169
+ since baseline".
3170
+
3171
+ `restore_patch` (the task's latched intent-gap patch file, Froid Plane #2564)
3172
+ is excluded too when set: the patch is untracked halt residue under the
3173
+ protected artifact dirs that survives every reset, so counting it would let a
3174
+ restore re-drive whose session produced nothing pass the gate on the patch
3175
+ file's mere presence — the gate must key on the APPLIED work (the tracked diff
3176
+ from baseline), not on the orchestrator-owned patch that carried it.
3177
+
3178
+ `spec_path` comes from a session-reported (untrusted) `spec_file` string, so
3179
+ it is `.resolve()`d before deriving the relpath, same as `spec_within_roots`:
3180
+ an un-normalized `..`/`.` segment would still resolve to the real on-disk
3181
+ file (the OS resolves it), but as a raw string it wouldn't match git's own
3182
+ normalized path output, silently defeating this exclude and letting a bare
3183
+ status flip on the session's own spec count as real work.
3184
+
3185
+ ``root`` is the tree the resulting pathspecs are relative to, and MUST be the
3186
+ same root the caller invokes git against — `paths.repo_root` for the
3187
+ proof-of-work gate, which is where `has_changes_since` runs. REQUIRED, with no
3188
+ default: an implicit `paths.project` anchor is #716's own root cause, and the
3189
+ two roots collapse in every configuration but the `repo_root` override, so a
3190
+ defaulted caller would look correct everywhere it was tested and be wrong only
3191
+ on the one config that matters. Requiring it turns OMITTING the root into a
3192
+ type error; it does not police a WRONG one — ``root=paths.project`` type-checks
3193
+ cleanly and silently excludes nothing, which is the failure the next paragraph
3194
+ describes. The requirement buys a caller who must think about the root, not a
3195
+ checker that knows the right answer.
3196
+
3197
+ A relpath computed against the wrong root does not raise: it simply
3198
+ matches nothing on git's side, so the exclusion silently disappears and a bare
3199
+ status flip starts counting as real work. The latched `restore_patch` is
3200
+ anchored on the SAME root for the same reason (a relative latch names a path
3201
+ in the tree it will be applied to)."""
3202
+ candidates: list[Path] = [paths.sprint_status, spec_path]
3203
+ if restore_patch:
3204
+ candidates.append(resolve_restore_path(restore_patch, root))
3205
+ out: list[str] = []
3206
+ for path in candidates:
3207
+ try:
3208
+ rel = path.resolve().relative_to(root).as_posix()
3209
+ except (OSError, RuntimeError, ValueError):
3210
+ continue # outside or uncertain; nothing safe to exclude here
3211
+ if rel and rel != ".":
3212
+ out.append(rel)
3213
+ return tuple(out)
3214
+
3215
+
3216
+ def spec_within_roots(spec_path: Path, paths: ProjectPaths) -> bool:
3217
+ """True if ``spec_path`` is, or sits under, an orchestrator-owned root (the
3218
+ project root or an artifact dir). A mutating repair (the frontmatter-status
3219
+ reconcile) must refuse a session-reported ``spec_file`` that resolves outside
3220
+ these roots, so a surprising path can never be silently rewritten. Artifact
3221
+ dirs configured outside ``project`` are roots too, so a legitimately
3222
+ out-of-project spec is still allowed."""
3223
+ try:
3224
+ sp = spec_path.resolve()
3225
+ roots = (
3226
+ paths.project,
3227
+ paths.output_folder,
3228
+ paths.implementation_artifacts,
3229
+ paths.planning_artifacts,
3230
+ )
3231
+ return any(sp == r.resolve() or sp.is_relative_to(r.resolve()) for r in roots)
3232
+ except (OSError, RuntimeError):
3233
+ return False
3234
+
3235
+
3236
+ def resolve_spec_path(spec_file: str, paths: ProjectPaths) -> Path:
3237
+ """A session-reported ``spec_file`` as a concrete path: an absolute value passes
3238
+ through untouched, a relative one is probed against ``paths.project`` and falls
3239
+ back to ``paths.implementation_artifacts``.
3240
+
3241
+ Neither branch promises the result exists — the fallback is returned unprobed
3242
+ when the project candidate is not a file — so every caller re-tests
3243
+ ``.is_file()`` itself. Deliberately does NOT ``.resolve()``: callers needing
3244
+ symlink and ``..`` normalization get it from :func:`spec_within_roots`, which
3245
+ resolves both sides itself.
3246
+
3247
+ The rule its call sites follow: a caller that goes on to REWRITE the spec must
3248
+ pair this with :func:`spec_within_roots` first. The value is session-reported
3249
+ and this function hands back whatever it spells, so the containment check is
3250
+ what stands between an untrusted string and a write to it. The frontmatter
3251
+ reconcile, the marker repair and the repair/review spec resets all pair it; so
3252
+ do the two attempt-binding observations, which write nothing themselves but
3253
+ establish the binding ``recovery_flow`` later restores bytes through — the
3254
+ check belongs at the site conferring the authority, not only at the write.
3255
+
3256
+ The rule is about writes to the SPEC, not writes in general, and two callers sit
3257
+ outside it deliberately: the post-dev board sync and the sweep bundle's ledger
3258
+ close each read a ``status:`` from an unchecked path and then write to a
3259
+ deterministic orchestrator-owned target of their own (the sprint board, the
3260
+ deferred-work ledger). An out-of-tree spec can influence what those write, never
3261
+ where. A caller that only reads — the ``--json`` read-model, the dev-verify
3262
+ gates — pairs it with nothing."""
3263
+ p = Path(spec_file)
3264
+ if p.is_absolute():
3265
+ return p
3266
+ candidate = paths.project / p
3267
+ if candidate.is_file():
3268
+ return candidate
3269
+ return paths.implementation_artifacts / p
3270
+
3271
+
3272
+ def _gate_frontmatter(spec_path: Path) -> dict[str, Any] | VerifyOutcome:
3273
+ """Read a spec's frontmatter for a verify gate, degrading an unreadable spec
3274
+ to a retryable :class:`VerifyOutcome` instead of a whole-run crash.
3275
+
3276
+ Every verify gate reads the spec back while the dev skill may still be
3277
+ rewriting it, so an OSError here (a TOCTOU truncation, a transient lock, a
3278
+ momentarily unsearchable parent) is a fault with a *designed* transient
3279
+ producer — not a broken orchestrator. `read_frontmatter` itself keeps raising
3280
+ (repair callers depend on that); only these observation gates degrade.
3281
+
3282
+ The reason is deliberately distinct from a status mismatch: returning ``{}``
3283
+ here would read as status ``""`` and let a read fault masquerade as "the
3284
+ skill forgot to set the status", sending a repair session after a bug that
3285
+ is not there. Retries are not silent — the reason lands in the journal via
3286
+ `dev-decision` / `review-verify-failed`, and a persistent fault is bounded
3287
+ into DEFER (or PAUSE) by `escalation.decide_dev` / `decide_review_session`.
3288
+ """
3289
+ try:
3290
+ return read_frontmatter(spec_path)
3291
+ except OSError as e:
3292
+ return VerifyOutcome.retry(f"spec unreadable ({e.__class__.__name__}: {e}): {spec_path}")
3293
+
3294
+
3295
+ def _verify_shared_gates(
3296
+ spec_path: Path,
3297
+ rj: dict[str, Any],
3298
+ task: StoryTask,
3299
+ paths: ProjectPaths,
3300
+ *,
3301
+ expected_status: str,
3302
+ extra_exclude: tuple[str, ...] | None,
3303
+ allow_ancestor_baseline: bool = False,
3304
+ fm: dict[str, Any] | None = None,
3305
+ ) -> VerifyOutcome | None:
3306
+ """The workflow-tag, expected-status, baseline-match, and proof-of-work gates
3307
+ shared verbatim by :func:`verify_dev`, :func:`verify_dev_bundle`, and
3308
+ :func:`verify_dev_stories` — factored out so the sprint-mode and stories-mode
3309
+ gates can't silently drift. Reads frontmatter once; a caller that had to read
3310
+ it first to *choose* ``expected_status`` passes what it read as ``fm`` so the
3311
+ single-read contract still holds (no caller re-reads it). Returns a failing
3312
+ :class:`VerifyOutcome`, or ``None`` when every gate passes and the caller may
3313
+ run its mode-specific tail.
3314
+
3315
+ The proof-of-work exclude is derived here from the `task` this gate already
3316
+ receives (`verify_dev_exclude_relpaths`, which needs the latched restore patch);
3317
+ ``extra_exclude`` carries only what a mode adds on top — the engine-written
3318
+ paths for sprint and bundle, those plus the story record + manifest for
3319
+ stories, and ``None`` on the two legs that skip the gate outright (sprint's
3320
+ park, stories' plan halt). Threading the restore patch in
3321
+ from three call sites instead left a default-None foot-gun for a future fourth
3322
+ mode, which would silently let a restore re-drive pass proof-of-work on the
3323
+ patch file's mere presence. ``extra_exclude=None`` still skips the gate
3324
+ outright, and two callers now spell it for two different reasons: a plan-halt
3325
+ leg produced only its own spec (structurally spec-only), and a park may
3326
+ legitimately have produced no code at all because its remaining work is a
3327
+ human's (#676). Both mean "there is no diff to demand here"; neither
3328
+ generalizes to the other's leg, so keep them named separately."""
3329
+ workflow = rj.get("workflow")
3330
+ if workflow != DEV_WORKFLOW:
3331
+ return VerifyOutcome.retry(
3332
+ f"dev result.json workflow is {workflow!r}, expected {DEV_WORKFLOW!r}"
3333
+ )
3334
+
3335
+ if fm is None:
3336
+ read = _gate_frontmatter(spec_path)
3337
+ if isinstance(read, VerifyOutcome):
3338
+ return read
3339
+ fm = read
3340
+ status = status_of(fm)
3341
+ if status != expected_status:
3342
+ return VerifyOutcome.retry(
3343
+ f"spec status is {status!r}, expected {expected_status!r}: {spec_path}"
3344
+ )
3345
+
3346
+ # The generic froid-build-auto skill stamps `baseline_revision`, never
3347
+ # `baseline_commit` — that name exists only in the result.json devcontract
3348
+ # synthesizes, which this gate does not consult (it re-reads frontmatter).
3349
+ # An absent key skips the check below, so reading `baseline_commit` alone
3350
+ # made this gate dead code for every generic-skill session. Both keys are read
3351
+ # through the one shared reader `devcontract.synthesize_result` also calls, so
3352
+ # the value this gate judges and the value the result.json reports are the same
3353
+ # value by construction rather than by two expressions agreeing (#716).
3354
+ claimed_baseline = auto_dev_baseline_of(fm)
3355
+ proof_baseline: str = task.baseline_commit or ""
3356
+ include_untracked_proof = True
3357
+ # Every probe below runs against `paths.repo_root`, the CODE tree, never
3358
+ # `paths.project`. Both baseline writers stamp `workspace.root`
3359
+ # (`Engine._dev_phase`, `SweepEngine`'s migration task) and re-arm now does the
3360
+ # same, and `Workspace.default` sets `root = paths.repo_root` while
3361
+ # `ProjectPaths.rebased` sets both roots to the worktree — so `repo_root` is
3362
+ # the one root that names the same repository as the recorded baseline in every
3363
+ # configuration. Under the `repo_root` override (`isolation = "none"` plus a
3364
+ # `repo_root:` config key, the only shape where the two differ —
3365
+ # `froidconfig.worktree_isolation_conflict` refuses the other) the session's cwd
3366
+ # IS the code tree, so a `project`-anchored probe judged a tree the session never
3367
+ # touched. WHICH probe burned the attempt depends on the layout, and the burn is
3368
+ # not `has_changes_since` in both: it fails OPEN (`rc != 0` -> True), so wherever
3369
+ # `project` is not a checkout the failing git call PASSES that gate. Nested
3370
+ # (`project` a subdirectory of the code tree) the call succeeds but is scoped to
3371
+ # that subdirectory, and the "no changes" forever-burn is real. Disjoint
3372
+ # (`project` beside the checkout) git fails and the burn moves to the probes that
3373
+ # fail CLOSED: `_canonical_commit_oid` returns None -> "does not match", and
3374
+ # `is_ancestor` / `commit_reachable_above_baseline` read the failure as False.
3375
+ if task.baseline_commit and claimed_baseline not in ("", "NO_VCS"):
3376
+ try:
3377
+ canonical_claimed = _canonical_commit_oid(paths.repo_root, claimed_baseline)
3378
+ except GitError as e:
3379
+ return VerifyOutcome.escalate(str(e))
3380
+ if canonical_claimed is None:
3381
+ return VerifyOutcome.retry(
3382
+ f"spec baseline {claimed_baseline[:12]} does not match "
3383
+ f"orchestrator-recorded baseline {task.baseline_commit[:12]}"
3384
+ )
3385
+ if canonical_claimed != task.baseline_commit:
3386
+ # A deferred-work bundle may legitimately adopt a pre-existing story
3387
+ # spec: froid-build-auto routes a "follow-up review of story X" bundle
3388
+ # into that story's done spec, whose baseline_revision is the
3389
+ # story's original dev baseline — necessarily older than the unit
3390
+ # worktree cut for the bundle (#161). An *ancestor* baseline means
3391
+ # the session diffed from an earlier commit on the unit's own
3392
+ # history (a superset of the unit's changes), which is sound; a
3393
+ # diverged or unknown baseline still fails.
3394
+ older_ok = allow_ancestor_baseline and is_ancestor(
3395
+ paths.repo_root, canonical_claimed, task.baseline_commit
3396
+ )
3397
+ # The other direction needs no opt-in flag: an intervening commit
3398
+ # before step-03 stamps `baseline_revision` makes the claim newer
3399
+ # than the recorded baseline. Accept it only when this checkout's
3400
+ # HEAD reaches that canonical descendant; stale, diverged, unknown,
3401
+ # and off-HEAD commits still fail.
3402
+ newer_ok = commit_reachable_above_baseline(
3403
+ paths.repo_root, canonical_claimed, task.baseline_commit
3404
+ )
3405
+ # Accepting a newer claim moves the proof-of-work reference onto it:
3406
+ # under `isolation = "none"` the claimed commit may have arrived in
3407
+ # the shared checkout from outside the session, and measuring from
3408
+ # the recorded baseline would let that commit satisfy proof-of-work
3409
+ # on its own — passing an attempt that implemented nothing. Ignore
3410
+ # untracked proof here because the launch snapshot cannot establish
3411
+ # whether it appeared before or after this later claimed commit.
3412
+ proof_baseline = canonical_claimed if newer_ok else proof_baseline
3413
+ include_untracked_proof = not newer_ok
3414
+ if not (older_ok or newer_ok):
3415
+ return VerifyOutcome.retry(
3416
+ f"spec baseline {claimed_baseline[:12]} does not match "
3417
+ f"orchestrator-recorded baseline {task.baseline_commit[:12]}"
3418
+ )
3419
+
3420
+ if extra_exclude is not None and task.baseline_commit:
3421
+ # The exclude pathspecs are rooted where git is invoked: `repo_root` here
3422
+ # and `repo_root` in every producer that composes into `extra_exclude`
3423
+ # (`Engine._harvest_gate_exclude`, `_stories_relpaths`). A pathspec relative
3424
+ # to a different root is not merely wrong, it is SILENTLY wrong — git
3425
+ # matches nothing and the exclusion evaporates.
3426
+ exclude = (
3427
+ verify_dev_exclude_relpaths(paths, spec_path, task.restore_patch, root=paths.repo_root)
3428
+ + extra_exclude
3429
+ )
3430
+ try:
3431
+ if not has_changes_since(
3432
+ paths.repo_root,
3433
+ proof_baseline,
3434
+ exclude=exclude,
3435
+ baseline_untracked=task.baseline_untracked,
3436
+ include_untracked=include_untracked_proof,
3437
+ ):
3438
+ return VerifyOutcome.retry("no changes in worktree since baseline commit")
3439
+ except GitError as e:
3440
+ return VerifyOutcome.escalate(str(e))
3441
+
3442
+ return None
3443
+
3444
+
3445
+ # The terminal spec status of a story whose agent-doable work is finished but
3446
+ # whose acceptance criteria include external actions only a human can perform
3447
+ # (#335). Mirrors devcontract.AWAITING_OPERATOR, kept literal here for the same
3448
+ # reason PLAN_HALT_STATUS is: devcontract imports verify, never the reverse.
3449
+ AWAITING_OPERATOR = "awaiting-operator"
3450
+
3451
+
3452
+ def _operator_actions_gate(fm: dict[str, Any], story_key: str) -> VerifyOutcome | None:
3453
+ """Refuse a park that enumerates nothing, with feedback a repair session can
3454
+ act on. ``None`` when the spec declares at least one usable action.
3455
+
3456
+ A park is *defined* by owing external work: a spec at ``awaiting-operator``
3457
+ with no readable ``operator_actions:`` names no obligation, so confirming it
3458
+ later would be a human acknowledging a blank. Every malformed shape reaches
3459
+ here as an empty reading (:func:`frontmatter.operator_actions_of`), and all
3460
+ of them have the same remedy, so one message covers them: name the actions,
3461
+ or finalize the status that matches reality. ``fixable=True`` — the tree is
3462
+ real work and the defect is one frontmatter block, so the reason goes to a
3463
+ repair session as feedback rather than throwing the attempt away.
3464
+ """
3465
+ if operator_actions_of(fm):
3466
+ return None
3467
+ return VerifyOutcome.retry(
3468
+ f"spec for {story_key} is 'awaiting-operator' but declares no usable "
3469
+ f"operator_actions: add a YAML list of strings naming each external "
3470
+ f"action a human must perform, or finalize the status the work actually "
3471
+ f"reached ('done' when nothing is owed, 'blocked' when the story cannot "
3472
+ f"proceed)",
3473
+ fixable=True,
3474
+ )
3475
+
3476
+
3477
+ def verify_dev(
3478
+ task: StoryTask,
3479
+ paths: ProjectPaths,
3480
+ result_json: dict[str, Any] | None,
3481
+ review_enabled: bool = True,
3482
+ *,
3483
+ operator_park: bool = False,
3484
+ engine_written: tuple[str, ...] = (),
3485
+ ) -> VerifyOutcome:
3486
+ """Verify a dev session's on-disk artifacts against its result.json claims.
3487
+
3488
+ Checks the claimed spec exists, carries the fixed ``auto-dev`` workflow tag,
3489
+ sits at the expected status (``in-review`` when a separate review session
3490
+ follows, ``done`` when review is disabled), records a baseline matching the
3491
+ orchestrator's, has produced changes since that baseline (every leg but the
3492
+ park — see ``operator_park`` below), and that the story's sprint-status was
3493
+ advanced to the matching stage. Returns a retryable VerifyOutcome on any
3494
+ mismatch, escalates on git failure, passes otherwise.
3495
+
3496
+ ``operator_park`` (``[operator] enabled``, engine-supplied) adds one more
3497
+ accepted spec/sprint pair: ``(awaiting-operator, awaiting-operator)``, the
3498
+ park a dev session declares when the story's remaining work is a human's
3499
+ (#335). The OBSERVED spec status selects which pair is demanded — the skill
3500
+ decides whether it parked, and the gate then holds it to the matching board
3501
+ state and to a non-empty action list. Off by policy, the token is simply not
3502
+ a terminal the gate knows, so it fails the ordinary status check and the
3503
+ session is retried with that mismatch as feedback.
3504
+
3505
+ On the park leg the proof-of-work gate is skipped, the same way the plan-halt
3506
+ leg of :func:`verify_dev_stories` skips it and by the same ``extra_exclude=None``
3507
+ spelling: a park's whole output can legitimately be its own spec's park
3508
+ declaration plus the board sync, both of which proof-of-work already excludes,
3509
+ so demanding a diff read a correct park as "no changes since baseline commit"
3510
+ and refused it (#676) — costing the attempt, and with it the park declaration:
3511
+ reverted outright under ``isolation = "worktree"`` or
3512
+ ``scm.rollback_on_failure = true``, and a paused run with manual-recovery steps
3513
+ on the default in-place config. What is still pending here is the
3514
+ ORCHESTRATOR's commit — the squash plus the park record land only after this
3515
+ gate passes — not the session's own work: ``froid-build-auto`` commits each
3516
+ iteration, so a skill commit chain usually already sits above baseline
3517
+ (``Engine._finalize_commit_phase``), and a reset discards that too, onto an
3518
+ ``attempt-preserve/*`` ref. Nothing else relaxes — the
3519
+ ``operator_actions`` gate above still refuses a park that enumerates nothing,
3520
+ and the workflow-tag, status, baseline-match and sprint-pair gates all still
3521
+ run. Two of those four are not independent evidence on this leg, and saying so
3522
+ is the point: the status check is tautological here (the same ``fm`` that
3523
+ selected ``parked`` is threaded in as ``fm=fm``, so the shared gate compares it
3524
+ against an ``expected_status`` derived from itself), and the sprint pair was
3525
+ written from that same frontmatter by ``Engine._post_dev_state_sync`` a dozen
3526
+ lines before this gate runs, so it confirms the orchestrator's own write landed
3527
+ rather than anything the session did. What still binds a park to the attempt
3528
+ the orchestrator actually launched is the workflow tag, the baseline match, and
3529
+ a non-empty actions list — and the middle one is weaker on this leg than its
3530
+ name suggests. Baseline-match also accepts a claim NEWER than the recorded
3531
+ baseline whenever it is a HEAD-reachable descendant, and the comment guarding
3532
+ that branch names the compensating control: such a commit "may have arrived in
3533
+ the shared checkout from outside the session", so the check re-anchors
3534
+ proof-of-work onto the claimed commit rather than trusting the match alone.
3535
+ Proof-of-work is precisely what this leg skips, so on a park that re-anchoring
3536
+ is inert and the newer-claim branch tightens nothing. The trade is recorded rather than hidden: the skip
3537
+ covers EVERY park, including one that wrote nothing and listed plausible
3538
+ actions, because the actions gate tests list non-emptiness and never content.
3539
+
3540
+ ``engine_written`` names paths the orchestrator itself wrote above this gate
3541
+ during the attempt, relative to ``paths.repo_root`` — the tree the gate invokes
3542
+ git in, and therefore the root every pathspec composed into this exclusion set
3543
+ must share (#716). They compose with the mode's normal proof-of-work exclusions
3544
+ so engine bookkeeping cannot masquerade as session work; see
3545
+ :meth:`Engine._harvest_gate_exclude`, which is their producer and states what a
3546
+ ledger outside the code tree resolves to. On the parked leg they are not passed
3547
+ at all — proof-of-work is skipped there, so there is no exclusion set left for
3548
+ them to compose with.
3549
+ """
3550
+ rj = result_json or {}
3551
+ spec_file = rj.get("spec_file")
3552
+ if not spec_file:
3553
+ return VerifyOutcome.retry("dev result.json missing spec_file")
3554
+ spec_path = resolve_spec_path(str(spec_file), paths)
3555
+ if not spec_path.is_file():
3556
+ return VerifyOutcome.retry(f"claimed spec file does not exist: {spec_path}")
3557
+
3558
+ fm = _gate_frontmatter(spec_path)
3559
+ if isinstance(fm, VerifyOutcome):
3560
+ return fm
3561
+ parked = operator_park and status_of(fm) == AWAITING_OPERATOR
3562
+ if parked:
3563
+ actions = _operator_actions_gate(fm, task.story_key)
3564
+ if actions is not None:
3565
+ return actions
3566
+
3567
+ # With review disabled, the dev session runs its own internal review and
3568
+ # finalizes straight to done; otherwise it hands off at in-review. A park
3569
+ # short-circuits both: the story is finished as far as any agent can take it.
3570
+ gate = _verify_shared_gates(
3571
+ spec_path,
3572
+ rj,
3573
+ task,
3574
+ paths,
3575
+ expected_status=(
3576
+ AWAITING_OPERATOR if parked else ("in-review" if review_enabled else "done")
3577
+ ),
3578
+ # Proof-of-work is the one gate the parked leg skips (``extra_exclude=None``,
3579
+ # the callee-blessed spelling): a park's whole residue can legitimately be
3580
+ # the spec and the board, both already excluded (#676). The park paragraph
3581
+ # in this function's docstring carries the reasoning and, more importantly,
3582
+ # what the skip does NOT relax.
3583
+ extra_exclude=None if parked else engine_written,
3584
+ fm=fm,
3585
+ )
3586
+ if gate is not None:
3587
+ return gate
3588
+
3589
+ expected_sprint = AWAITING_OPERATOR if parked else ("review" if review_enabled else "done")
3590
+ sprint = story_status(paths.sprint_status, task.story_key)
3591
+ if sprint != expected_sprint:
3592
+ return VerifyOutcome.retry(
3593
+ f"sprint-status for {task.story_key} is {sprint!r}, expected {expected_sprint!r}"
3594
+ )
3595
+
3596
+ task.spec_file = str(spec_path)
3597
+ return VerifyOutcome.passed()
3598
+
3599
+
3600
+ def verify_dev_bundle(
3601
+ task: StoryTask,
3602
+ paths: ProjectPaths,
3603
+ result_json: dict[str, Any] | None,
3604
+ review_enabled: bool = True,
3605
+ *,
3606
+ engine_written: tuple[str, ...] = (),
3607
+ ) -> VerifyOutcome:
3608
+ """verify_dev for a deferred-work bundle: bundles have no sprint-status
3609
+ entry. The orchestrator owns the bundle→dw-id binding (``task.dw_ids``,
3610
+ marked done by ``SweepEngine``'s ledger sync); the generic ``froid-build-auto``
3611
+ primitive never authors dw ids. So the dw_ids cross-check is enforced only
3612
+ when the session actually claims them — an empty/absent claim is the normal
3613
+ generic path and passes.
3614
+
3615
+ ``engine_written`` has the same contract as :func:`verify_dev`."""
3616
+ rj = result_json or {}
3617
+ spec_file = rj.get("spec_file")
3618
+ if not spec_file:
3619
+ return VerifyOutcome.retry("dev result.json missing spec_file")
3620
+ spec_path = resolve_spec_path(str(spec_file), paths)
3621
+ if not spec_path.is_file():
3622
+ return VerifyOutcome.retry(f"claimed spec file does not exist: {spec_path}")
3623
+
3624
+ # With review disabled, the dev session finalizes the bundle straight to done.
3625
+ # allow_ancestor_baseline: a bundle that adopts a pre-existing story spec
3626
+ # (follow-up review) carries that spec's older-but-ancestral baseline (#161).
3627
+ gate = _verify_shared_gates(
3628
+ spec_path,
3629
+ rj,
3630
+ task,
3631
+ paths,
3632
+ expected_status="in-review" if review_enabled else "done",
3633
+ extra_exclude=engine_written,
3634
+ allow_ancestor_baseline=True,
3635
+ )
3636
+ if gate is not None:
3637
+ return gate
3638
+
3639
+ claimed_ids = {str(i) for i in (rj.get("dw_ids") or [])}
3640
+ if claimed_ids and claimed_ids != set(task.dw_ids):
3641
+ return VerifyOutcome.retry(
3642
+ f"result.json dw_ids {sorted(claimed_ids)} do not match the bundle's "
3643
+ f"{sorted(task.dw_ids)}"
3644
+ )
3645
+
3646
+ task.spec_file = str(spec_path)
3647
+ return VerifyOutcome.passed()
3648
+
3649
+
3650
+ # A spec_checkpoint story's plan-halt leg leaves the spec at this status (the
3651
+ # skill HALTs after the Ready-for-Development gate); mirrors
3652
+ # devcontract.PLAN_HALT_STATUS, kept literal here to avoid a verify<-devcontract
3653
+ # import cycle (devcontract imports verify).
3654
+ PLAN_HALT_STATUS = "ready-for-dev"
3655
+
3656
+
3657
+ def verify_dev_stories(
3658
+ task: StoryTask,
3659
+ paths: ProjectPaths,
3660
+ result_json: dict[str, Any] | None,
3661
+ *,
3662
+ spec_folder: Path,
3663
+ review_enabled: bool = True,
3664
+ plan_halt: bool = False,
3665
+ engine_written: tuple[str, ...] = (),
3666
+ ) -> VerifyOutcome:
3667
+ """verify_dev for stories mode: the story spec lives at the id-keyed path
3668
+ ``<spec-folder>/stories/<id>-<slug>.md`` and there is no sprint-status entry.
3669
+
3670
+ Same gates as :func:`verify_dev` — workflow tag, expected frontmatter status,
3671
+ baseline match, proof-of-work since baseline — with two differences: the spec
3672
+ is resolved **deterministically by id** (``task.story_key``) via
3673
+ ``stories.resolve_story_spec`` rather than trusting the session-claimed path,
3674
+ and the sprint-status gate is dropped (stories mode has no sprint board).
3675
+ A resolution that is pending / ambiguous / a sentinel is a retryable failure,
3676
+ and the resolved filename's id prefix is asserted to equal the task id.
3677
+
3678
+ ``plan_halt`` verifies a spec_checkpoint story's plan-halt leg instead of an
3679
+ implementation: the expected status is ``ready-for-dev`` (the plan is done,
3680
+ not the code) and the proof-of-work gate is skipped — a plan writes only its
3681
+ own spec, which proof-of-work already excludes, so requiring code changes
3682
+ would spuriously fail every plan leg. The spec-resolution, id-prefix, workflow,
3683
+ and baseline gates still run, and ``task.spec_file`` is still recorded. A
3684
+ ``plan_halt`` leg also requires the ``result_json`` to carry the ``plan_halt``
3685
+ marker ``devcontract`` emits on a clean plan-halt, so a died-mid-flight
3686
+ ``ready-for-dev`` can't be mistaken for a successful plan.
3687
+ """
3688
+ # Deferred to avoid a verify<->stories import cycle: stories imports
3689
+ # read_frontmatter/status_of from this module at top level, so verify must not
3690
+ # import stories at module scope (keep this local on any future refactor).
3691
+ from . import stories
3692
+
3693
+ rj = result_json or {}
3694
+ story_id = str(task.story_key).strip()
3695
+ state = stories.resolve_story_spec(spec_folder, story_id)
3696
+ if state.kind == stories.KIND_PENDING:
3697
+ return VerifyOutcome.retry(f"no story spec found for id {story_id!r} under {spec_folder}")
3698
+ if state.kind == stories.KIND_AMBIGUOUS:
3699
+ names = ", ".join(p.name for p in state.paths)
3700
+ return VerifyOutcome.retry(f"ambiguous story file match for id {story_id!r}: {names}")
3701
+ if state.kind == stories.KIND_SENTINEL:
3702
+ return VerifyOutcome.retry(
3703
+ f"story {story_id!r} resolved to a {state.sentinel_kind} sentinel: {state.path}"
3704
+ )
3705
+ spec_path = state.path
3706
+ # The glob is `<id>-*.md`, so this holds by construction — assert it anyway as
3707
+ # a defensive gate against a future resolver change silently widening the match.
3708
+ if spec_path is None or not spec_path.name.startswith(f"{story_id}-"):
3709
+ return VerifyOutcome.retry(
3710
+ f"resolved story spec {spec_path} does not match id {story_id!r}"
3711
+ )
3712
+ if not spec_path.is_file():
3713
+ return VerifyOutcome.retry(f"claimed spec file does not exist: {spec_path}")
3714
+
3715
+ # Generic path always self-finalizes to done (no in-review handoff); the
3716
+ # review_enabled arm mirrors verify_dev for symmetry. A plan-halt leg instead
3717
+ # expects the ready-for-dev plan gate (the plan is done, not the code).
3718
+ if plan_halt:
3719
+ # A clean plan-halt also carries devcontract's plan_halt marker; a
3720
+ # died-mid-flight ready-for-dev (synthesized without plan_halt) never
3721
+ # does. Cross-check the verify-side flag against the synth-side result so
3722
+ # a caller can't unilaterally promote a mid-flight spec to a "successful
3723
+ # plan" — mirrors the defensive id-prefix gate above.
3724
+ if rj.get("plan_halt") is not True:
3725
+ return VerifyOutcome.retry(
3726
+ "plan_halt verification requested but result.json carries no plan_halt marker"
3727
+ )
3728
+ expected = PLAN_HALT_STATUS
3729
+ else:
3730
+ expected = "in-review" if review_enabled else "done"
3731
+
3732
+ # A plan-halt leg produced only its own spec (the plan), which proof-of-work
3733
+ # already excludes; skip it (extra_exclude=None) and record the plan spec.
3734
+ # Otherwise stories mode adds the spec folder's stories/ subdir + stories.yaml
3735
+ # on top of the gate's own file-granular exclude — NOT the whole-folder
3736
+ # artifact_relpaths, so a story whose entire authorized scope is ledger/spec
3737
+ # reconciliation doesn't register as a false "no changes". Engine-written
3738
+ # paths compose only on that live-gate leg; ``None`` must remain ``None`` for
3739
+ # plan halt rather than being combined with a tuple.
3740
+ gate = _verify_shared_gates(
3741
+ spec_path,
3742
+ rj,
3743
+ task,
3744
+ paths,
3745
+ expected_status=expected,
3746
+ extra_exclude=(
3747
+ None
3748
+ if plan_halt
3749
+ # Rooted where the proof-of-work gate invokes git (`paths.repo_root`),
3750
+ # not on `paths.project`: a pathspec relative to the other root matches
3751
+ # nothing and the exclusion evaporates without an error (#716).
3752
+ else _stories_relpaths(paths.repo_root, spec_folder) + engine_written
3753
+ ),
3754
+ )
3755
+ if gate is not None:
3756
+ return gate
3757
+
3758
+ task.spec_file = str(spec_path)
3759
+ return VerifyOutcome.passed()
3760
+
3761
+
3762
+ def _stories_relpaths(root: Path, spec_folder: Path) -> tuple[str, ...]:
3763
+ """Proof-of-work exclude prefixes for the story record + manifest: the spec
3764
+ folder's ``stories/`` subdir and its ``stories.yaml``, relative to ``root``.
3765
+ Empty when the spec folder is outside that tree (nothing to exclude there).
3766
+
3767
+ ``root`` is the tree git is invoked against — `paths.repo_root` at the one
3768
+ production call site, which under the `repo_root` override is NOT
3769
+ `paths.project` (the spec folder then sits outside the code tree and this
3770
+ correctly returns ``()``)."""
3771
+ from .stories import STORIES_FILENAME, STORIES_SUBDIR
3772
+
3773
+ try:
3774
+ rel = spec_folder.resolve().relative_to(root.resolve()).as_posix()
3775
+ except (OSError, RuntimeError, ValueError):
3776
+ return ()
3777
+ base = "" if rel == "." else f"{rel}/"
3778
+ return (f"{base}{STORIES_SUBDIR}", f"{base}{STORIES_FILENAME}")
3779
+
3780
+
3781
+ # A hard ceiling on how much of one verifier stream is held in memory, separate
3782
+ # from and far above `[verify] stream_capture_kb` (which bounds what reaches
3783
+ # disk). `subprocess.run(capture_output=True)` already materialises a command's
3784
+ # whole output, but before this bound the full streams were then RETAINED in the
3785
+ # results list while every later command ran, so peak memory grew with the number
3786
+ # of configured verify commands rather than with the largest one. Plugins are
3787
+ # meant to see the streams essentially whole, so this is a backstop against a
3788
+ # pathologically chatty suite, not a tuning knob — deliberately a constant, and
3789
+ # deliberately high enough that ordinary suites never reach it.
3790
+ #
3791
+ # It bounds retention, not capture: while command N runs, memory still holds the
3792
+ # capped earlier results plus whatever N itself emits.
3793
+ MAX_STREAM_MEMORY_BYTES = 32 * 1024 * 1024
3794
+
3795
+
3796
+ def byte_tail(text: str, max_bytes: int) -> tuple[str, int]:
3797
+ """``(tail, full_bytes)`` — ``text`` cut to its last ``max_bytes`` UTF-8 bytes.
3798
+
3799
+ The one implementation of a rule this feature applies at two different
3800
+ bounds (this in-memory ceiling and the engine's `stream_capture_kb` disk
3801
+ cap), because the subtle half is easy to get wrong twice: a byte cut can
3802
+ land mid-character, and the leading partial is DROPPED rather than decoded
3803
+ into a ``\ufffd`` this function would be inventing. Decoding with
3804
+ ``errors="replace"`` instead would also break the cap it is enforcing —
3805
+ ``\ufffd`` is three UTF-8 bytes standing in for the one it replaces, so the
3806
+ result can exceed ``max_bytes``.
3807
+
3808
+ ``full_bytes`` always measures the input, so a caller can report what was
3809
+ emitted even after keeping less of it. The TAIL is kept: a failing suite
3810
+ puts its failure at the end. ``max_bytes <= 0`` needs no branch — the slice
3811
+ is empty by construction, which is exactly "keep nothing".
3812
+ """
3813
+ encoded = text.encode("utf-8")
3814
+ full_bytes = len(encoded)
3815
+ if full_bytes <= max_bytes:
3816
+ return text, full_bytes
3817
+ return encoded[full_bytes - max_bytes :].decode("utf-8", errors="ignore"), full_bytes
3818
+
3819
+
3820
+ @dataclass(frozen=True)
3821
+ class CommandResult:
3822
+ """One verifier subprocess result.
3823
+
3824
+ ``output_tail`` remains the merged, bounded compatibility field used by the
3825
+ existing failure classifiers and repair feedback. ``stdout`` and ``stderr``
3826
+ retain the separate streams observed at the subprocess boundary so the
3827
+ engine can expose them to trusted plugins and retain them by journal pointer.
3828
+
3829
+ ``*_full_bytes`` is what the command EMITTED, which is only interesting when
3830
+ it differs from the stream beside it — i.e. when ``MAX_STREAM_MEMORY_BYTES``
3831
+ cut one. ``None`` means nothing was cut and the stream is the whole of it, so
3832
+ the many callers that build a result from three fields stay correct without
3833
+ knowing this exists.
3834
+ """
3835
+
3836
+ command: str
3837
+ returncode: int
3838
+ output_tail: str
3839
+ stdout: str = ""
3840
+ stderr: str = ""
3841
+ stdout_full_bytes: int | None = None
3842
+ stderr_full_bytes: int | None = None
3843
+
3844
+
3845
+ # sh launcher convention (verify commands run shell=True): 126 = command found
3846
+ # but not executable, 127 = command not found. Both are environment faults —
3847
+ # deterministic for a given tree, unfixable by a repair session (issue #126:
3848
+ # seeded worktrees that lost +x burned dev attempts on no-op repairs).
3849
+ ENV_FAULT_RCS = frozenset({126, 127})
3850
+
3851
+ # cmd has no such convention (issue #302): `cmd /c
3852
+ # <missing tool>` exits 1 — the same code an ordinary test failure uses — and
3853
+ # 9009 surfaces only as %ERRORLEVEL% *inside* a batch file, so it reaches us
3854
+ # only when the verify command is itself a .cmd/.bat propagating it. Worse,
3855
+ # handing cmd a file it cannot execute (extension not in PATHEXT: a .sh, a
3856
+ # .txt) exits 0 without running it, so the check silently "passes". The win32
3857
+ # arm therefore classifies on three independent signals instead of the rc.
3858
+ _CMD_ENV_FAULT_RC = 9009
3859
+
3860
+ # Matched against the tail's last two non-empty lines only, because cmd writes its
3861
+ # message to stderr and ``run_verify_commands`` builds the tail as stdout + stderr —
3862
+ # so the message lands at the end, and it wraps ("… is not recognized as an internal
3863
+ # or external command,\noperable program or batch file."). Note what that ordering
3864
+ # does *not* buy: stderr is appended wholesale, not interleaved, so a command whose
3865
+ # own stderr ends with the phrase is read as a fault too. That is the accepted edge —
3866
+ # a verify command whose last stderr line is "X is not recognized" has a missing X
3867
+ # either way. Localized Windows prints neither phrase; the token probe covers those.
3868
+ _CMD_NOT_RECOGNIZED = "is not recognized as an internal or external command"
3869
+ _CMD_ACCESS_DENIED = "access is denied"
3870
+ _CMD_MESSAGE_LINES = 2
3871
+
3872
+ # cmd's *internal* commands: shutil.which cannot resolve them, so without this
3873
+ # allowlist every failing `if exist …` / `exit 1` would classify as an env
3874
+ # fault. External tools (findstr, robocopy, …) resolve through which as usual.
3875
+ _CMD_BUILTINS = frozenset(
3876
+ "assoc break call cd chdir cls color copy date del dir echo endlocal erase exit for"
3877
+ " ftype goto if md mkdir mklink move path pause popd prompt pushd rd rem ren rename"
3878
+ " rmdir set setlocal shift start time title type ver verify vol".split()
3879
+ )
3880
+
3881
+
3882
+ _CMD_METACHARS = ("%", "!", "<", ">", "&", "|", ";", "^")
3883
+
3884
+
3885
+ def _leading_token(command: str) -> str | None:
3886
+ """The executable part of a shell command string, or None when nothing about
3887
+ it can be probed. ``posix=False`` keeps Windows backslashes intact (posix mode
3888
+ eats them), at the cost of leaving quotes on the token — hence the strip.
3889
+
3890
+ Returning None is the safe answer: it drops the probe, which can only ever
3891
+ *add* an env fault. A token cmd would expand before running (``%VAR%``,
3892
+ delayed ``!VAR!``) is unprobeable for that reason — resolving the literal
3893
+ would report "not found" for a tool that is right there."""
3894
+ try:
3895
+ parts = shlex.split(command, posix=False)
3896
+ except ValueError: # unbalanced quotes — no reliable token to probe
3897
+ return None
3898
+ if not parts:
3899
+ return None
3900
+ # `(pytest -q)` tokenizes to `(pytest`, `(pytest)` to `(pytest)` — cmd's
3901
+ # grouping parens and echo-suppressing `@` are shell syntax, not the name.
3902
+ token = parts[0].strip('"').strip("()@")
3903
+ # Anything the shell would still act on is not a name to probe: expansion
3904
+ # (%VAR%, delayed !VAR!), redirection, or an operator shlex left attached
3905
+ # (`pytest|findstr x` splits to one token). Probing those reports "not found"
3906
+ # for a tool that is right there, so drop the probe instead.
3907
+ if not token or any(char in token for char in _CMD_METACHARS):
3908
+ return None
3909
+ return token
3910
+
3911
+
3912
+ def _cmd_executable(path: Path) -> bool:
3913
+ """Whether cmd resolves this path directly or by appending PATHEXT."""
3914
+ pathext = os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD")
3915
+ extensions = tuple(ext.strip().lower() for ext in pathext.split(";") if ext.strip())
3916
+ return (path.is_file() and path.suffix.lower() in extensions) or any(
3917
+ Path(f"{path}{ext}").is_file() for ext in extensions
3918
+ )
3919
+
3920
+
3921
+ def _win32_env_fault_reason(result: CommandResult, cwd: Path) -> str | None:
3922
+ """Windows env-fault evidence, cheapest signal first, or None. Each signal is
3923
+ independently sufficient; see the _CMD_* constants for why the rc alone isn't."""
3924
+ if result.returncode < 0:
3925
+ # the timeout sentinel: the command ran and hung, so it was found and it
3926
+ # was runnable — none of the signals below can apply to it.
3927
+ return None
3928
+ if result.returncode == _CMD_ENV_FAULT_RC:
3929
+ return f"rc={_CMD_ENV_FAULT_RC} — cmd reported the command as not found"
3930
+ lines = [line.strip() for line in result.output_tail.splitlines() if line.strip()]
3931
+ if result.returncode != 0 and lines:
3932
+ closing = " ".join(lines[-_CMD_MESSAGE_LINES:])
3933
+ if _CMD_NOT_RECOGNIZED in closing.lower():
3934
+ return f"cmd: {closing}"
3935
+ if _CMD_ACCESS_DENIED in lines[-1].lower():
3936
+ return f"cmd: {lines[-1]}"
3937
+ token = _leading_token(result.command)
3938
+ if token is None:
3939
+ return None
3940
+ token_path = cwd / token
3941
+ try:
3942
+ names_a_file = token_path.is_file()
3943
+ local_executable = _cmd_executable(token_path)
3944
+ except OSError: # a token no filesystem call can even ask about
3945
+ return None
3946
+ if names_a_file and not local_executable and token.lower() not in _CMD_BUILTINS:
3947
+ # The builtin guard is the same one the PATH branch carries, for the other
3948
+ # half of cmd's resolution order: an *unqualified* internal name is answered
3949
+ # internally, before any directory is searched, so a file that happens to be
3950
+ # named `echo` / `set` / `start` is never what runs and must not be read as a
3951
+ # broken one. Only the bare name is exempt — `.\echo`, `C:\…\echo` are file
3952
+ # references cmd does try to run, and they still classify below.
3953
+ #
3954
+ # PATHEXT is cmd's contract for what it *runs*; anything else it hands to
3955
+ # the file association. For the extensions this is about (.sh, .txt) that
3956
+ # association executes nothing and returns 0, so a green rc means nothing
3957
+ # was verified. An extension with a console association outside PATHEXT
3958
+ # (.rb, .pl on a host that registered them) does run — and is escalated
3959
+ # here anyway, deliberately: what an association returns is the *app's*
3960
+ # convention, not the script's, so it is not an exit code to gate on.
3961
+ # The message names the token, so the fix ("ruby check.rb") is obvious.
3962
+ return f"{token} is not executable by cmd (extension not in PATHEXT)"
3963
+ if (
3964
+ result.returncode != 0
3965
+ and not local_executable # cmd searches the run's own directory before PATH
3966
+ and token.lower() not in _CMD_BUILTINS
3967
+ and shutil.which(token) is None
3968
+ ):
3969
+ return f"{token} not found on PATH"
3970
+ return None
3971
+
3972
+
3973
+ def env_fault_reason(result: CommandResult, cwd: Path) -> str | None:
3974
+ """Why this verify command is an environment fault rather than a story
3975
+ failure, or None if it is not one. Per-shell: verify commands run through
3976
+ the host shell, and sh and cmd signal a broken environment differently."""
3977
+ if result.returncode in ENV_FAULT_RCS:
3978
+ return f"rc={result.returncode}"
3979
+ if sys.platform != "win32":
3980
+ return None
3981
+ return _win32_env_fault_reason(result, cwd)
3982
+
3983
+
3984
+ def _timeout_stream(value: str | bytes | None) -> str:
3985
+ """Normalize optional timeout output into what the completed path would give.
3986
+
3987
+ ``subprocess.run``'s timeout leg is not uniform, so three shapes arrive:
3988
+
3989
+ * ``bytes`` — POSIX. ``Popen._communicate`` raises ``TimeoutExpired`` from
3990
+ ``_check_timeout`` with the raw chunks joined, *before* the text-mode
3991
+ decode that ends the loop, so ``text=True`` never touched them.
3992
+ * ``str`` — Windows, where ``run`` calls ``communicate()`` after ``kill()``
3993
+ and the text wrapper has already decoded. Load-bearing: on that platform
3994
+ this branch is the only way the output arrives at all.
3995
+ * ``None`` — POSIX again, when nothing had been buffered on that stream.
3996
+
3997
+ So the bytes branch has to reproduce what text mode would have done to them,
3998
+ which is exactly ``Popen._translate_newlines``: decode, then collapse ``\\r\\n``
3999
+ and lone ``\\r`` to ``\\n``. Doing neither made the same bytes read back
4000
+ differently depending on which path produced them — under an ASCII locale
4001
+ ``b"caf\\xc3\\xa9\\r\\n"`` completed as ``"caf\\ufffd\\ufffd\\n"`` but timed out
4002
+ as ``"café\\r\\n"``. The codec half also contradicted
4003
+ :func:`run_verify_commands`' own rule (#378) that host-tool output stays on
4004
+ the locale codec: ``locale.getpreferredencoding(False)`` is what ``text=True``
4005
+ resolves for an unset ``encoding`` — deliberately not ``locale.getencoding()``,
4006
+ which disagrees with it under UTF-8 mode (PEP 540), a mode the C/POSIX locale
4007
+ enables by itself. ``errors="replace"`` for the reason the completed path uses
4008
+ it: one undecodable byte must not raise and lose every result.
4009
+
4010
+ The str branch is left alone: its newlines were translated by the text
4011
+ wrapper the reader thread read through, so there is nothing left to collapse."""
4012
+ if value is None:
4013
+ return ""
4014
+ if isinstance(value, bytes):
4015
+ decoded = value.decode(locale.getpreferredencoding(False), errors="replace")
4016
+ return decoded.replace("\r\n", "\n").replace("\r", "\n")
4017
+ return value
4018
+
4019
+
4020
+ def run_verify_commands(policy: Policy, cwd: Path) -> list[CommandResult]:
4021
+ """Run each of the policy's verify commands, one CommandResult apiece.
4022
+
4023
+ Output decodes with ``errors="replace"`` (#378): the children are arbitrary
4024
+ operator tools whose bytes are not ours to constrain, the captured tail is
4025
+ display-only feedback for a human or a repair session (already lossy at
4026
+ ``[-2000:]``), and one undecodable byte must not raise mid-loop and lose
4027
+ *every* command's result. Decoding stays on the locale codec (``text=True``)
4028
+ precisely because these are host tools — contrast tui/launch.py, which pins
4029
+ ``encoding="utf-8"`` because its child is our own UTF-8 CLI."""
4030
+ results = []
4031
+ for command in policy.verify.commands:
4032
+ try:
4033
+ # Verify commands are operator-authored shell strings from the project's
4034
+ # policy (e.g. "pytest -q && ruff check"); shell=True is intentional here.
4035
+ proc = subprocess.run( # nosec B602
4036
+ command,
4037
+ shell=True, # portability: operator-authored verify command — sanctioned shell-out (see plan out-of-scope)
4038
+ cwd=cwd,
4039
+ capture_output=True,
4040
+ text=True,
4041
+ errors="replace",
4042
+ timeout=COMMAND_TIMEOUT_S,
4043
+ )
4044
+ stdout, stdout_full = byte_tail(proc.stdout, MAX_STREAM_MEMORY_BYTES)
4045
+ stderr, stderr_full = byte_tail(proc.stderr, MAX_STREAM_MEMORY_BYTES)
4046
+ # merged from the ceilinged streams, not the raw pair: 2000 chars sits
4047
+ # far below the ceiling, so the tail is identical while the full
4048
+ # concatenation — a transient copy of both whole streams — is not built.
4049
+ output = (stdout + stderr)[-2000:]
4050
+ results.append(
4051
+ CommandResult(
4052
+ command, proc.returncode, output, stdout, stderr, stdout_full, stderr_full
4053
+ )
4054
+ )
4055
+ except subprocess.TimeoutExpired as exc:
4056
+ # the timeout leg is bounded too: a command killed at COMMAND_TIMEOUT_S
4057
+ # is exactly the one that may have been spewing output when it died.
4058
+ t_out, t_out_full = byte_tail(_timeout_stream(exc.stdout), MAX_STREAM_MEMORY_BYTES)
4059
+ t_err, t_err_full = byte_tail(_timeout_stream(exc.stderr), MAX_STREAM_MEMORY_BYTES)
4060
+ results.append(
4061
+ CommandResult(command, -1, "timed out", t_out, t_err, t_out_full, t_err_full)
4062
+ )
4063
+ return results
4064
+
4065
+
4066
+ def verify_command_results_outcome(results: list[CommandResult], cwd: Path) -> VerifyOutcome:
4067
+ """Classify already-observed verifier results without discarding them.
4068
+
4069
+ Kept separate from :func:`verify_commands_outcome` so the engine can retain
4070
+ and expose exactly the same results it asks core to classify. Failures are fixable:
4071
+ the captured output is concrete feedback a repair session can act on —
4072
+ except environment faults (see env_fault_reason), which escalate so the run
4073
+ pauses for an environment fix instead of burning story budgets. An env
4074
+ fault anywhere in the run wins over earlier ordinary failures: a repair
4075
+ session dispatched for the ordinary failure would still run in the
4076
+ broken environment. Note the first loop inspects rc=0 results too — on
4077
+ Windows an unrunnable command is a silent pass, not a failure (#302)."""
4078
+ for result in results:
4079
+ reason = env_fault_reason(result, cwd)
4080
+ if reason is not None:
4081
+ return VerifyOutcome.escalate(
4082
+ f"verify environment fault ({reason}): {result.command}\n"
4083
+ "command not found / not executable — this is the run environment, "
4084
+ "not the story; fix the environment, then re-arm the escalation "
4085
+ "(the attempt budget resets on re-arm)\n"
4086
+ f"{result.output_tail}",
4087
+ env_fault=True,
4088
+ )
4089
+ for result in results:
4090
+ if result.returncode != 0:
4091
+ return VerifyOutcome.retry(
4092
+ f"verify command failed (rc={result.returncode}): {result.command}\n"
4093
+ f"{result.output_tail}",
4094
+ fixable=True,
4095
+ )
4096
+ return VerifyOutcome.passed()
4097
+
4098
+
4099
+ def verify_commands_outcome(policy: Policy, cwd: Path) -> VerifyOutcome:
4100
+ """Run the policy's deterministic verify commands and classify the results."""
4101
+ return verify_command_results_outcome(run_verify_commands(policy, cwd), cwd)
4102
+
4103
+
4104
+ def _verify_review_commands(policy: Policy, paths: ProjectPaths) -> VerifyOutcome:
4105
+ """Run a review gate's ``[verify] commands`` in ``paths.repo_root``.
4106
+
4107
+ The two roots split by what is being addressed, and the split is deliberate:
4108
+ the artifacts these gates read — the claimed spec, ``paths.sprint_status``,
4109
+ ``paths.deferred_work`` — are FROID output and stay project-rooted, while
4110
+ ``[verify] commands`` are the operator's build/test verbs and belong in the
4111
+ git root the code lives in. Every other caller of these commands already
4112
+ resolves them that way: the dev side runs them in ``Workspace.root``
4113
+ (``Engine._verify_commands_with_results``), which ``Workspace.default`` sets
4114
+ from ``paths.repo_root``, and ``cli._reverify`` is handed ``paths.repo_root``
4115
+ at both of its call sites. The three review gates were the sole outlier
4116
+ (#695).
4117
+
4118
+ The two roots are the same path in the default layout and under worktree
4119
+ isolation (``ProjectPaths.rebased`` sets both); they diverge only under an
4120
+ explicit ``repo_root:`` with ``isolation = "none"``. One helper rather than
4121
+ three edited lines so the three gates cannot drift apart on the split.
4122
+
4123
+ On win32 the cwd carries one more thing with it, so the split is not purely a
4124
+ subprocess concern: ``verify_commands_outcome`` forwards ``cwd`` a second time
4125
+ into ``env_fault_reason`` -> ``_win32_env_fault_reason``, which resolves a
4126
+ command's leading token as ``cwd / token`` to tell "tool missing" from "command
4127
+ failed" — and an env fault escalates where a plain failure retries. So a
4128
+ RELATIVE verify command is now classified against ``repo_root`` on these legs
4129
+ too. That is the correct direction (classification should follow execution, and
4130
+ the dev side already classifies against the same root), but it is a second
4131
+ consequence of the move rather than a restatement of the first.
4132
+
4133
+ ``paths.repo_root`` is the ONLY member of ``paths`` this reads — it takes the
4134
+ whole dataclass to keep the three call sites uniform, not because it consults
4135
+ anything else. A future caller must not infer that artifact paths reach here.
4136
+ """
4137
+ return verify_commands_outcome(policy, paths.repo_root)
4138
+
4139
+
4140
+ def verify_review(
4141
+ task: StoryTask,
4142
+ paths: ProjectPaths,
4143
+ policy: Policy,
4144
+ *,
4145
+ sprint_reached_done: bool = False,
4146
+ operator_park: bool = False,
4147
+ ) -> VerifyOutcome:
4148
+ """Gate a completed review pass: spec at ``done``, sprint-status at ``done``,
4149
+ deterministic verify commands green.
4150
+
4151
+ ``sprint_reached_done`` tells the gate that the orchestrator had already
4152
+ advanced this story's sprint-status to ``done`` before the review ran (it is
4153
+ the sole ``sprint_advance`` caller, ``verify_dev`` asserted the write landed,
4154
+ and ``advance`` never regresses). A board now sitting *earlier* than ``done``
4155
+ is therefore not a stage the story never reached — it is a review session
4156
+ deliberately revoking the sign-off. Nothing in the review loop re-advances
4157
+ the board, so retrying only replays the same failure until the budget runs
4158
+ out and the work is rolled back; under
4159
+ ``review.on_status_contradiction = "escalate"`` (the default) the gate
4160
+ escalates instead, naming both sides. See #334.
4161
+
4162
+ ``(awaiting-operator, awaiting-operator)`` is the second accepted pair, on
4163
+ the same observed-spec-status selection ``verify_dev`` uses: this is the gate
4164
+ the park path runs before committing (``Engine._park_awaiting_operator``), so
4165
+ parked work clears exactly the deterministic checks every other commit path
4166
+ clears *at this gate* — the pair, a non-empty action list, and the verify
4167
+ commands. The scope is load-bearing: a ``done`` story additionally clears
4168
+ proof-of-work at the dev gate, which a park no longer does (#676), so this
4169
+ gate is not evidence that a park faced every check a ``done`` story faced. The
4170
+ sign-off-regression arm stays scoped to the ``done`` pair: a board short of
4171
+ ``awaiting-operator`` is a stage never reached, not a revoked sign-off.
4172
+
4173
+ ``operator_park`` is the SAME engine-supplied flag ``verify_dev`` takes, not a
4174
+ second reading of ``policy.operator.enabled``, so the two gates cannot
4175
+ disagree about whether this run parks. They would: the engine's
4176
+ ``_operator_park_enabled`` is an override seam, and a mode that opts out of
4177
+ parking while still reaching this gate would otherwise find it accepting a
4178
+ park the engine itself refuses to take."""
4179
+ if not task.spec_file:
4180
+ return VerifyOutcome.retry("no spec file recorded for task")
4181
+ fm = _gate_frontmatter(Path(task.spec_file))
4182
+ if isinstance(fm, VerifyOutcome):
4183
+ return fm
4184
+ status = status_of(fm)
4185
+ expected = AWAITING_OPERATOR if (operator_park and status == AWAITING_OPERATOR) else "done"
4186
+ if status != expected:
4187
+ return VerifyOutcome.retry(f"spec status is {status!r}, expected {expected!r}")
4188
+ if expected == AWAITING_OPERATOR:
4189
+ actions = _operator_actions_gate(fm, task.story_key)
4190
+ if actions is not None:
4191
+ return actions
4192
+
4193
+ sprint = story_status(paths.sprint_status, task.story_key)
4194
+ if sprint != expected:
4195
+ if expected == "done" and _is_signoff_regression(sprint, sprint_reached_done, policy):
4196
+ return VerifyOutcome.escalate(
4197
+ f"review revoked the sprint sign-off for {task.story_key}: the "
4198
+ f"orchestrator advanced the board to 'done' after dev verified, "
4199
+ f"and the review session wrote it back to {sprint!r} while leaving "
4200
+ f"the spec frontmatter at 'done'. The two sides disagree about "
4201
+ f"whether the story is finished, and no further review cycle can "
4202
+ f"reconcile them — the review loop never re-advances the board, so "
4203
+ f"the remaining cycles would burn down onto a defer that rolls the "
4204
+ f"work back. Resolve by either completing the outstanding work and "
4205
+ f"re-arming the escalation (the attempt budget resets on re-arm), "
4206
+ f"or accepting the story and advancing the board yourself; set "
4207
+ f'review.on_status_contradiction = "retry" to restore the legacy '
4208
+ f"retry-until-budget behavior.",
4209
+ contradiction=True,
4210
+ )
4211
+ return VerifyOutcome.retry(
4212
+ f"sprint-status for {task.story_key} is {sprint!r}, expected {expected!r}"
4213
+ )
4214
+
4215
+ return _verify_review_commands(policy, paths)
4216
+
4217
+
4218
+ def _is_signoff_regression(sprint: str | None, sprint_reached_done: bool, policy: Policy) -> bool:
4219
+ """Whether a non-``done`` sprint status is a review deliberately walking the
4220
+ board backward, as opposed to a stage the story simply never reached.
4221
+
4222
+ Conservative on every uncertainty: without the launch-time guarantee, with
4223
+ the knob set to ``retry``, or when the fresh read yields no status at all
4224
+ (missing story entry) or a token outside the known lifecycle (a hand-edited
4225
+ or future board), the caller falls through to the ordinary retry — a wrong
4226
+ escalation halts an otherwise healthy run."""
4227
+ if not sprint_reached_done or policy.review.on_status_contradiction != "escalate":
4228
+ return False
4229
+ if sprint is None or sprint not in STATUS_ORDER:
4230
+ return False
4231
+ return STATUS_ORDER.index(sprint) < STATUS_ORDER.index("done")
4232
+
4233
+
4234
+ def verify_review_stories(task: StoryTask, paths: ProjectPaths, policy: Policy) -> VerifyOutcome:
4235
+ """verify_review for stories mode: same spec-done + verify-commands gates,
4236
+ minus the sprint-status gate (stories mode has no sprint board — the story
4237
+ spec's own frontmatter status is authoritative). ``task.spec_file`` is the
4238
+ id-keyed story spec ``verify_dev_stories`` recorded on the dev pass."""
4239
+ if not task.spec_file:
4240
+ return VerifyOutcome.retry("no spec file recorded for task")
4241
+ fm = _gate_frontmatter(Path(task.spec_file))
4242
+ if isinstance(fm, VerifyOutcome):
4243
+ return fm
4244
+ status = status_of(fm)
4245
+ if status != "done":
4246
+ return VerifyOutcome.retry(f"spec status is {status!r}, expected 'done'")
4247
+ return _verify_review_commands(policy, paths)
4248
+
4249
+
4250
+ def verify_review_bundle(task: StoryTask, paths: ProjectPaths, policy: Policy) -> VerifyOutcome:
4251
+ """verify_review for a deferred-work bundle: no sprint-status check, but
4252
+ every dw id the bundle owns must be marked done in the ledger on disk. The
4253
+ legacy --dw-bundle skill flips them; on the generic froid-build-auto path the
4254
+ orchestrator flips them after dev and, if review rewrites the ledger diff,
4255
+ again immediately before this review gate. Either way this gate is why we
4256
+ can trust it happened."""
4257
+ if not task.spec_file:
4258
+ return VerifyOutcome.retry("no spec file recorded for task")
4259
+ fm = _gate_frontmatter(Path(task.spec_file))
4260
+ if isinstance(fm, VerifyOutcome):
4261
+ return fm
4262
+ status = status_of(fm)
4263
+ if status != "done":
4264
+ return VerifyOutcome.retry(f"spec status is {status!r}, expected 'done'")
4265
+
4266
+ ledger = paths.deferred_work
4267
+ # Same TOCTOU class as the spec read above: the ledger is rewritten by the
4268
+ # orchestrator's own mark_done between the dev and review gates.
4269
+ try:
4270
+ text = ledger.read_text(encoding="utf-8") if ledger.is_file() else ""
4271
+ except OSError as exc:
4272
+ return VerifyOutcome.retry(
4273
+ f"deferred-work ledger unreadable ({exc.__class__.__name__}: {exc}): {ledger}"
4274
+ )
4275
+ entries = {e.id: e for e in deferredwork.parse_ledger(text)}
4276
+ not_done = sorted(
4277
+ i for i in task.dw_ids if i not in entries or not entries[i].status.startswith("done")
4278
+ )
4279
+ if not_done:
4280
+ return VerifyOutcome.retry(
4281
+ "deferred-work entries not marked done in "
4282
+ f"{ledger}: {', '.join(not_done)} — set each to `status: done <date>` "
4283
+ "with a `resolution:` line",
4284
+ fixable=True,
4285
+ )
4286
+
4287
+ return _verify_review_commands(policy, paths)
4288
+
4289
+
4290
+ def commit_story(repo: Path, message: str) -> str:
4291
+ rc, out = _git(repo, "add", "-A")
4292
+ if rc != 0:
4293
+ raise GitError(f"git add failed: {out}")
4294
+ rc, out = _git(repo, "commit", "-m", message)
4295
+ if rc != 0:
4296
+ raise GitError(f"git commit failed: {out}")
4297
+ return rev_parse_head(repo)
4298
+
4299
+
4300
+ def finalize_commit(repo: Path, baseline: str | None, message: str) -> str | None:
4301
+ """Collapse everything since `baseline` into ONE commit with `message`.
4302
+
4303
+ froid-build-auto now commits its own work at the end of each iteration (one
4304
+ commit for the dev pass, one for each follow-up review pass), while the
4305
+ orchestrator still writes its own bookkeeping (sprint-status.yaml for
4306
+ stories, the deferred-work ledger for sweep bundles) into the working tree
4307
+ uncommitted. This squashes that whole chain — the skill's per-iteration
4308
+ commits PLUS the orchestrator's uncommitted writes — back onto `baseline`
4309
+ as a single commit carrying the orchestrator's message, so the one-commit-
4310
+ per-story invariant and the message template / pre_commit hook stay
4311
+ authoritative regardless of how many times the skill committed.
4312
+
4313
+ Mechanics: stage the working tree (`add -A`), move HEAD back to `baseline`
4314
+ keeping the index (`reset --soft`), then commit the accumulated index. The
4315
+ working tree is never touched, so a failure leaves the chain intact.
4316
+
4317
+ Residual-artifacts note (Froid Plane #2563): the skill now commits every file
4318
+ of the reviewed diff and deliberately leaves unrelated `git status` residue
4319
+ uncommitted (files outside the change's scope). The `add -A` here sweeps that
4320
+ residue into the story commit too — an intentional divergence from the skill's
4321
+ scoped commit. The loop must end each story on a clean tree because story
4322
+ N+1's step-01 HALTs on a dirty tree, so the orchestrator squashes EVERYTHING
4323
+ since baseline (skill commits + its own bookkeeping + any residue) into the one
4324
+ story commit rather than leaving the tree dirty for the next story to trip on.
4325
+
4326
+ Returns the new HEAD sha, or None when there is nothing to finalize: no
4327
+ version control (`baseline` falsy or NO_VCS) or the tree already equals
4328
+ `baseline` (no skill commits and no bookkeeping delta)."""
4329
+ if not baseline or baseline == "NO_VCS":
4330
+ return None
4331
+ original_head = rev_parse_head(repo)
4332
+ rc, out = _git(repo, "add", "-A")
4333
+ if rc != 0:
4334
+ raise GitError(f"git add failed: {out}")
4335
+ rc, out = _git(repo, "reset", "--soft", baseline)
4336
+ if rc != 0:
4337
+ raise GitError(f"git reset --soft {baseline} failed: {out}")
4338
+ # index now holds the cumulative diff vs baseline; nothing staged → no-op
4339
+ rc, _ = _git(repo, "diff", "--cached", "--quiet")
4340
+ if rc == 0:
4341
+ return None
4342
+ rc, out = _git(repo, "commit", "-m", message)
4343
+ if rc != 0:
4344
+ # The soft reset already rewound HEAD to baseline; a failed commit would
4345
+ # otherwise leave the branch pointer there, dropping the skill commit chain
4346
+ # from HEAD. Restore HEAD (the working tree is untouched) before raising.
4347
+ restore_rc, restore_out = _git(repo, "reset", "--soft", original_head)
4348
+ if restore_rc != 0:
4349
+ raise GitError(
4350
+ f"git commit failed: {out}; additionally failed to restore HEAD "
4351
+ f"to {original_head[:12]}: {restore_out}"
4352
+ )
4353
+ raise GitError(f"git commit failed: {out}")
4354
+ return rev_parse_head(repo)
4355
+
4356
+
4357
+ def resolve_restore_path(raw: str, root: Path) -> Path:
4358
+ """The latched intent-gap patch (`StoryTask.restore_patch`) as a concrete path:
4359
+ absolute values pass through, relative ones are anchored on `root`.
4360
+
4361
+ `model.StoryTask.restore_patch` documents the field as repo-relative-or-absolute,
4362
+ and every consumer must resolve it against the base it actually reads the tree
4363
+ from — the engine's live workspace root (the unit worktree under isolation),
4364
+ `paths.repo_root` for the proof-of-work exclude (which is where
4365
+ `has_changes_since` runs, so the latch has to name a path in that tree; #716),
4366
+ the CLI's `--project`. Hence the caller-supplied `root` rather than one
4367
+ baked-in base.
4368
+
4369
+ In practice `cli._resolve_restore_patch` always latches an already-`.resolve()`d
4370
+ absolute path, so the relative branch is exercised only by a hand-written state
4371
+ file or a future non-CLI latcher; it is kept because the field's contract
4372
+ promises it. Deliberately does NOT `.resolve()` the result — callers that need
4373
+ symlink/`..` normalization (path containment checks) do it themselves, and the
4374
+ apply/exclude paths match the pre-existing behavior byte-for-byte without it.
4375
+ """
4376
+ p = Path(raw)
4377
+ return p if p.is_absolute() else root / p
4378
+
4379
+
4380
+ def apply_patch(repo: Path, patch_path: Path) -> None:
4381
+ """Apply a saved patch to `repo`'s working tree (`git apply`), raising on failure.
4382
+
4383
+ The intent-gap patch-restore re-drive (Froid Plane #2564) uses this to re-lay
4384
+ the attempted change froid-build-auto saved before reverting. New files in the
4385
+ patch are created (they land untracked, matching how the original attempt sat
4386
+ before its revert).
4387
+
4388
+ A clean apply is likely but NOT guaranteed: the patch was diffed from the
4389
+ story's ORIGINAL baseline, while re-arm advances the re-drive's baseline to the
4390
+ project's post-resolve HEAD (runs.rearm_escalation) — so the apply holds only
4391
+ while the resolve session left the patched files untouched. A resolve session
4392
+ that committed changes to those files makes `git apply` fail, deliberately
4393
+ loudly: silently merging the human's resolution with the stale attempt could
4394
+ reproduce the very gap being resolved. A non-zero `git apply` — that overlap, a
4395
+ missing/corrupt patch, any other drift — raises `GitError` with git's output;
4396
+ the caller escalates rather than dispatch a session onto a half-applied tree,
4397
+ and the human re-resolves (typically re-arming without a restore, since the
4398
+ resolution commits already carry the overlapping work).
4399
+ """
4400
+ if not patch_path.is_file():
4401
+ raise GitError(f"restore patch not found: {patch_path}")
4402
+ rc, out = _git(repo, "apply", str(patch_path))
4403
+ if rc != 0:
4404
+ raise GitError(f"git apply {patch_path} failed: {out}")
4405
+
4406
+
4407
+ def patch_new_files(patch_path: Path) -> set[str]:
4408
+ """Repo-relative posix paths the saved patch *creates* — the untracked residue
4409
+ an `apply_patch` leaves behind (see `runs.rearm_escalation`).
4410
+
4411
+ Text-parse, not `git apply --numstat`: the caller runs after the tree has moved
4412
+ on, so the patch may no longer apply, and a creation list must still come back.
4413
+ Within each `diff --git` block, an old-side `---` header naming `_DIFF_ABSENT`
4414
+ marks a creation, and the `+++ <prefix>/<path>` after it names the file. The
4415
+ prefix is stripped by mirroring what `apply_patch`'s plain `git apply` (default
4416
+ -p1) did when it laid the residue down: drop the first path component whatever
4417
+ it is — `b/` standard, `w/`/`i/`/`c/` under diff.mnemonicPrefix, `2/` from
4418
+ --no-index. A target -p1 cannot strip (no `/`, e.g. --no-prefix output) is
4419
+ skipped: that apply failed outright, so no residue exists. Deletions (the
4420
+ absent token on the *new* side) are never returned — the caller feeds this to an
4421
+ *exclusion* set, and excluding a path the human later re-created would make the
4422
+ next rollback delete their file. For the same reason every ambiguous entry is
4423
+ skipped rather than guessed: quoted paths (`+++ "b/wéird"`, core.quotePath),
4424
+ renames, and non-`git diff` unified diffs with no `diff --git` header yield fewer
4425
+ results, never wrong ones. Under-reporting degrades to the pre-#90 behavior;
4426
+ over-reporting deletes user data.
4427
+
4428
+ Raises OSError / UnicodeDecodeError when the patch cannot be read; the caller
4429
+ decides (rearm treats it as best-effort and journals `stale-restore-unparseable`).
4430
+ """
4431
+ new_files: set[str] = set()
4432
+ in_hunk = False # past the first `@@`, a `--- x` line is content, not a header
4433
+ creating = False
4434
+ for line in patch_path.read_text(encoding="utf-8").splitlines():
4435
+ if line.startswith("diff --git "):
4436
+ in_hunk = creating = False
4437
+ elif line.startswith("@@"):
4438
+ in_hunk = True
4439
+ elif in_hunk:
4440
+ continue
4441
+ elif line.startswith("--- "):
4442
+ creating = line[4:].strip() == _DIFF_ABSENT
4443
+ elif line.startswith("+++ ") and creating:
4444
+ creating = False
4445
+ target = line[4:].split("\t", 1)[0].strip()
4446
+ if target == _DIFF_ABSENT or target.startswith('"') or "/" not in target:
4447
+ continue # delete-then-create pair, quoted path, or un-strippable target
4448
+ rel = target.split("/", 1)[1] # mirror `git apply`'s default -p1
4449
+ if rel:
4450
+ new_files.add(rel)
4451
+ return new_files
4452
+
4453
+
4454
+ def commit_paths(repo: Path, message: str, paths: list[Path]) -> str | None:
4455
+ """Commit exactly `paths` (and nothing else), leaving any unrelated working
4456
+ or staged changes untouched. Unlike commit_story's `add -A`, this is safe to
4457
+ call out of band (e.g. `froid-loop decisions`) when the tree may hold the
4458
+ user's own uncommitted work. Returns the new HEAD sha, or None when the
4459
+ given paths had no changes to commit. Paths outside the repo are ignored —
4460
+ and so is a path git has never seen (absent from both the working tree and
4461
+ the index): `git add` hard-fails on a pathspec matching nothing, and one
4462
+ optional path would otherwise sink the whole commit (a swallowed `GitError`
4463
+ in `confirm` silently losing the spec+board commit over a park record that
4464
+ was never committed). A missing-but-TRACKED path stays in: that is a
4465
+ deletion to stage. An uncertain repo root raises before staging; uncertainty
4466
+ in one candidate omits only that candidate, preserving the partial-path
4467
+ contract for healthy siblings. If no usable operand survives that uncertainty,
4468
+ the call raises instead of reporting a successful no-op."""
4469
+ rels: list[str] = []
4470
+ resolution_fault: tuple[Path, OSError | RuntimeError] | None = None
4471
+ try:
4472
+ repo_root = repo.resolve()
4473
+ except (OSError, RuntimeError) as e:
4474
+ raise GitError(
4475
+ f"cannot resolve repository root for exact commit safely ({repo}): {e}"
4476
+ ) from e
4477
+ for p in paths:
4478
+ try:
4479
+ # `.as_posix()`, not `str()`: every rel here becomes a git pathspec and is
4480
+ # compared against git-derived output, and git speaks posix separators on
4481
+ # every platform. `str()` yields backslashes on Windows, which git reads as
4482
+ # wildmatch ESCAPES rather than separators.
4483
+ rels.append(Path(p).resolve().relative_to(repo_root).as_posix())
4484
+ except (OSError, RuntimeError) as e:
4485
+ if resolution_fault is None:
4486
+ resolution_fault = (Path(p), e)
4487
+ continue
4488
+ except ValueError:
4489
+ continue
4490
+ missing = [r for r in rels if not ((repo_root / r).exists() or (repo_root / r).is_symlink())]
4491
+ if missing:
4492
+ rc, out = _git_raw(repo, "ls-files", "-z", "--", *_literal_specs(missing))
4493
+ if rc != 0:
4494
+ raise GitError(f"git ls-files failed: {out}")
4495
+ tracked = {t for t in out.split("\0") if t}
4496
+ rels = [r for r in rels if r not in missing or r in tracked]
4497
+ if not rels:
4498
+ if resolution_fault is not None:
4499
+ failed_path, error = resolution_fault
4500
+ raise GitError(
4501
+ "no exact commit operand remains after path resolution failed "
4502
+ f"for {failed_path}: {error}"
4503
+ ) from error
4504
+ return None
4505
+ # Every operand is forced LITERAL: git reads a positional operand as a PATHSPEC,
4506
+ # and `implementation_artifacts` reaches here verbatim out of the operator's
4507
+ # `_froid/bmm/config.yaml` (`froidconfig._resolve` substitutes and resolves it, and
4508
+ # nothing sanitizes it), so a `[`, `]`, `*` or `?` in a configured path is a
4509
+ # wildmatch metacharacter. Unescaped, this function breaks its own first promise —
4510
+ # "commit exactly `paths` (and nothing else)": `add -- docs[a]/f.md` also stages
4511
+ # `docsa/f.md`, and the operator's unrelated edit is committed under a story's
4512
+ # name. The operand is a SUPERSET (git compares literally before falling through
4513
+ # to fnmatch), which is why the over-match direction is the only one possible.
4514
+ #
4515
+ # A second, quieter harm with the ledger GITIGNORED — the shape
4516
+ # `_carry_harvested_deferrals` is built to hit: `git add` refuses an explicitly
4517
+ # named ignored path (rc 1) but SKIPS a globbed one, so the plain form could exit
4518
+ # rc 0 having staged nothing, `status` find no change, and the carry report success
4519
+ # having committed no ledger and journalled no `harvest-carry-uncommitted` — a
4520
+ # silent loss where the literal form leaves a record. Note the `ls-files` operands
4521
+ # above are literalised too: that leg reads membership rather than acting, so its
4522
+ # failure direction is to DROP a rel (the glob returns the neighbour's name, which
4523
+ # never equals `r`), but leaving one bare operand beside three fixed ones is the
4524
+ # next reader's trap. It is also why the `r.replace("\\", "/")` that used to guard
4525
+ # this comparison is gone: `.as_posix()` above fixes the separator at the source,
4526
+ # and a dead normalizer beside a glob operand reads as protection that isn't there.
4527
+ specs = _literal_specs(rels)
4528
+ rc, out = _git(repo, "add", "--", *specs)
4529
+ if rc != 0:
4530
+ raise GitError(f"git add failed: {out}")
4531
+ # Stdout ALONE (`_git_out`, #442): this is an EMPTINESS test, and `status` exits 0
4532
+ # while still warning on stderr — against the merge an unchanged path set reads
4533
+ # non-empty, the early-out below is skipped, and `git commit` runs with nothing
4534
+ # staged and raises where the contract says return None. The `add` above and the
4535
+ # `commit` below stay on `_git`: both are rc-only, and stderr is their diagnostic.
4536
+ rc, out, detail = _git_out(repo, "status", "--porcelain", "--", *specs)
4537
+ if rc != 0:
4538
+ raise GitError(f"git status failed: {detail}")
4539
+ if not out:
4540
+ return None # nothing changed in these paths
4541
+ # pathspec form commits only `rels`, ignoring any other staged changes
4542
+ rc, out = _git(repo, "commit", "-m", message, "--", *specs)
4543
+ if rc != 0:
4544
+ raise GitError(f"git commit failed: {out}")
4545
+ return rev_parse_head(repo)