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.
- froid_loop/__init__.py +11 -0
- froid_loop/__main__.py +12 -0
- froid_loop/adapters/__init__.py +3 -0
- froid_loop/adapters/base.py +254 -0
- froid_loop/adapters/entrypoints.py +63 -0
- froid_loop/adapters/env_fault.py +290 -0
- froid_loop/adapters/generic.py +2013 -0
- froid_loop/adapters/mock.py +49 -0
- froid_loop/adapters/multiplexer.py +914 -0
- froid_loop/adapters/opencode_http.py +1687 -0
- froid_loop/adapters/profile.py +650 -0
- froid_loop/adapters/psmux_backend.py +1428 -0
- froid_loop/adapters/registry.py +322 -0
- froid_loop/adapters/tmux_backend.py +35 -0
- froid_loop/adapters/tmux_base.py +630 -0
- froid_loop/checks.py +187 -0
- froid_loop/cli.py +5041 -0
- froid_loop/data/__init__.py +0 -0
- froid_loop/data/froid_loop_hook.py +228 -0
- froid_loop/data/froid_loop_probe_hook.py +88 -0
- froid_loop/data/plugins/example/plugin.toml +21 -0
- froid_loop/data/plugins/tea/plugin.toml +184 -0
- froid_loop/data/plugins/tea/tea_plugin.py +258 -0
- froid_loop/data/plugins/unity/plugin.toml +140 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
- froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
- froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
- froid_loop/data/plugins/unity/unity_facts.md +17 -0
- froid_loop/data/plugins/unity/unity_plugin.py +415 -0
- froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
- froid_loop/data/plugins/unity/unity_ready.py +230 -0
- froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
- froid_loop/data/plugins/unity/unity_setup.py +551 -0
- froid_loop/data/plugins/unity/unity_teardown.py +362 -0
- froid_loop/data/profiles/antigravity.toml +52 -0
- froid_loop/data/profiles/claude.toml +85 -0
- froid_loop/data/profiles/codex.toml +22 -0
- froid_loop/data/profiles/copilot.toml +52 -0
- froid_loop/data/profiles/gemini.toml +26 -0
- froid_loop/data/profiles/opencode.toml +54 -0
- froid_loop/data/settings/core.toml +458 -0
- froid_loop/data/skills/README.md +93 -0
- froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
- froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
- froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
- froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
- froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
- froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
- froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
- froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
- froid_loop/decisions.py +202 -0
- froid_loop/deferredwork.py +2282 -0
- froid_loop/devcontract.py +892 -0
- froid_loop/diagnostics.py +1104 -0
- froid_loop/documents.py +532 -0
- froid_loop/engine.py +7732 -0
- froid_loop/envvars.py +111 -0
- froid_loop/escalation.py +225 -0
- froid_loop/events.py +266 -0
- froid_loop/fences.py +103 -0
- froid_loop/froidconfig.py +226 -0
- froid_loop/frontmatter.py +526 -0
- froid_loop/gates.py +133 -0
- froid_loop/install.py +2936 -0
- froid_loop/journal.py +178 -0
- froid_loop/machine.py +148 -0
- froid_loop/model.py +898 -0
- froid_loop/operatoractions.py +474 -0
- froid_loop/platform_util.py +1490 -0
- froid_loop/plugins/__init__.py +64 -0
- froid_loop/plugins/bus.py +259 -0
- froid_loop/plugins/context.py +319 -0
- froid_loop/plugins/loader.py +145 -0
- froid_loop/plugins/manifest.py +279 -0
- froid_loop/plugins/model.py +296 -0
- froid_loop/plugins/registry.py +245 -0
- froid_loop/plugins/trust.py +75 -0
- froid_loop/policy.py +1569 -0
- froid_loop/probe.py +1044 -0
- froid_loop/process_host.py +408 -0
- froid_loop/recovery_flow.py +1561 -0
- froid_loop/resolve.py +283 -0
- froid_loop/runs.py +4715 -0
- froid_loop/runsetup.py +1293 -0
- froid_loop/sanitize.py +593 -0
- froid_loop/settings_schema.py +276 -0
- froid_loop/signals.py +160 -0
- froid_loop/sprintstatus.py +609 -0
- froid_loop/statemachine.py +57 -0
- froid_loop/stories.py +615 -0
- froid_loop/stories_engine.py +796 -0
- froid_loop/sweep.py +1892 -0
- froid_loop/tokens.py +196 -0
- froid_loop/tui/__init__.py +11 -0
- froid_loop/tui/app.py +1584 -0
- froid_loop/tui/data.py +840 -0
- froid_loop/tui/launch.py +1003 -0
- froid_loop/tui/screens/__init__.py +1 -0
- froid_loop/tui/screens/dashboard.py +1071 -0
- froid_loop/tui/screens/modals.py +943 -0
- froid_loop/tui/screens/settings_screen.py +477 -0
- froid_loop/tui/settings.py +135 -0
- froid_loop/tui/widgets.py +981 -0
- froid_loop/verify.py +4545 -0
- froid_loop/workspace.py +320 -0
- froid_loop/worktree_flow.py +2301 -0
- froid_loop-0.11.1.dist-info/METADATA +728 -0
- froid_loop-0.11.1.dist-info/RECORD +116 -0
- froid_loop-0.11.1.dist-info/WHEEL +4 -0
- froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
- 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)
|