@arbiterforge/ca-pi 0.10.13 → 0.14.6
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.
- package/README.md +1 -1
- package/package.json +2 -1
- package/plugins/ca-pi/CHANGELOG.md +159 -0
- package/plugins/ca-pi/SKILLS.md +1 -1
- package/plugins/ca-pi/SPRINT.md +40 -10
- package/plugins/ca-pi/agents/backend-author.md +1 -1
- package/plugins/ca-pi/agents/frontend-author.md +1 -1
- package/plugins/ca-pi/generated/command-catalog.json +1 -1
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
- package/plugins/ca-pi/helpers/artifacts/release.json +38 -0
- package/plugins/ca-pi/hooks/_approvallib.py +355 -0
- package/plugins/ca-pi/hooks/_artifactlib.py +855 -0
- package/plugins/ca-pi/hooks/_cleanuplib.py +1553 -0
- package/plugins/ca-pi/hooks/_hooklib.py +37 -13
- package/plugins/ca-pi/hooks/_host.py +1 -1
- package/plugins/ca-pi/hooks/_intentlib.py +31 -0
- package/plugins/ca-pi/hooks/_planfilelib.py +34 -1
- package/plugins/ca-pi/hooks/_protectedstatelib.py +11 -1
- package/plugins/ca-pi/hooks/_readinjectlib.py +120 -0
- package/plugins/ca-pi/hooks/_releaselib.py +933 -85
- package/plugins/ca-pi/hooks/_standuplib.py +38 -10
- package/plugins/ca-pi/hooks/doctor.py +103 -0
- package/plugins/ca-pi/hooks/hostapi.py +1 -1
- package/plugins/ca-pi/hooks/pi-bridge.py +5 -1
- package/plugins/ca-pi/hooks/prompt-submit.py +33 -10
- package/plugins/ca-pi/hooks/releasehash.py +46 -32
- package/plugins/ca-pi/includes/artifacts.md +166 -0
- package/plugins/ca-pi/includes/author-tdd-workflow.md +2 -1
- package/plugins/ca-pi/includes/farm.md +27 -4
- package/plugins/ca-pi/includes/routing-table.md +2 -2
- package/plugins/ca-pi/includes/verification-boundary.md +27 -0
- package/plugins/ca-pi/package.json +18 -0
- package/plugins/ca-pi/routines/INDEX.md +4 -4
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +23 -4
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +30 -5
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +52 -7
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +25 -3
- package/plugins/ca-pi/routines/refactor/SKILL.md +3 -2
- package/plugins/ca-pi/routines/release/SKILL.md +150 -44
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +53 -9
- package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +20 -3
- package/plugins/ca-pi/routines/tdd/SKILL.md +27 -5
- package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +11 -2
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +39 -10
- package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +34 -13
- package/plugins/ca-pi/skills/ca-chore/SKILL.md +7 -3
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +3 -2
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +14 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +42 -8
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +4 -2
- package/plugins/ca-pi/skills/ca-release/SKILL.md +7 -6
- package/plugins/ca-pi/skills/ca-standup/SKILL.md +36 -8
|
@@ -0,0 +1,1553 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""codeArbiter: repository-qualified integration-proof evaluator (T-04).
|
|
3
|
+
|
|
4
|
+
Decides whether a candidate branch tip's content has genuinely landed on an
|
|
5
|
+
authorized integration target -- the one fact routine branch/worktree cleanup
|
|
6
|
+
may act on. Deliberately narrow: this module answers ONE question (is this
|
|
7
|
+
candidate proven-delivered?) and takes already-resolved git/PR facts as input
|
|
8
|
+
rather than querying Git or GitHub itself, so it stays a pure, unit-testable
|
|
9
|
+
decision function. Discovery (which branches exist, what their upstream state
|
|
10
|
+
is, resolving a PR record by number) is the caller's job and never substitutes
|
|
11
|
+
for the proof performed here:
|
|
12
|
+
|
|
13
|
+
- AC-07: ancestry is checked against the caller-supplied authorized
|
|
14
|
+
integration target only -- never inferred from upstream state (gone,
|
|
15
|
+
absent, still-existing), and it needs no PR/gh input to succeed.
|
|
16
|
+
- AC-08: a non-ancestry (typically squash) landing is proven only from an
|
|
17
|
+
already-resolved, repository-qualified PR record naming an exact head SHA,
|
|
18
|
+
base repository and a landing commit shown to be retained in the fetched
|
|
19
|
+
target. Tree-equality is accepted only as corroboration alongside a real
|
|
20
|
+
proof; it can never establish eligibility by itself.
|
|
21
|
+
- AC-10: the result records whether the original branch commits' identity
|
|
22
|
+
survives (true only for an ancestry landing) so a squash/PR-delivery proof
|
|
23
|
+
is never represented as preserving them.
|
|
24
|
+
|
|
25
|
+
Public API:
|
|
26
|
+
- evaluate_merge_proof(...) -> ProofResult (T-04, the integration proof)
|
|
27
|
+
- The T-05 slice below: a repository-bound operation journal
|
|
28
|
+
(load_journal / append_pending_operation / update_operation_outcome /
|
|
29
|
+
reconcile_pending_operations) and a guarded, revalidating branch-deletion
|
|
30
|
+
executor (guard_branch_deletion / execute_branch_deletion). Scope: branch
|
|
31
|
+
deletion only -- worktree-removal guarding is a distinct, not-yet-built
|
|
32
|
+
increment sharing this same journal/executor pattern.
|
|
33
|
+
`execute_branch_deletion` REQUIRES a `ProofResult`: an unproven target is
|
|
34
|
+
refused outright, and the force/atomic-delete path is unlocked only by
|
|
35
|
+
`proof.method == "pr_delivery"` -- never a caller-supplied flag.
|
|
36
|
+
- The T-02 slice below: the shared decision and interaction vocabulary
|
|
37
|
+
(Scope, Target, Decision, make_decision, scope_covers,
|
|
38
|
+
required_confirmation_count) that T-06's live routing will consume.
|
|
39
|
+
ProofResult above already serves as the "proof outcome" type this layer
|
|
40
|
+
wraps; T-02 adds no second proof representation.
|
|
41
|
+
- The T-03 slice below: an authoritative, bounded branch/worktree inventory
|
|
42
|
+
(parse_branch_ref_inventory / parse_worktree_inventory /
|
|
43
|
+
parse_worktree_inventory_nul / bind_branch_occupancy /
|
|
44
|
+
classify_worktree_loss_surface / validate_resource_path). Pure parsers and
|
|
45
|
+
classifiers only, same discipline as the rest of this module -- discovery
|
|
46
|
+
facts in, structured records out, no eligibility or authorization claim
|
|
47
|
+
made anywhere in this slice (AC-07: a gone upstream is a raw fact here,
|
|
48
|
+
never proof of merged eligibility). `validate_resource_path` takes the
|
|
49
|
+
caller's own exact allowlist of authorized targets, not a "nested under
|
|
50
|
+
a root" rule -- worktrees are conventionally siblings of the repo root,
|
|
51
|
+
not descendants of it.
|
|
52
|
+
|
|
53
|
+
Independent-review status (2026-09-17): an adversarial review of T-04/T-05
|
|
54
|
+
as merged (T-03, the authoritative inventory, did not exist yet at review
|
|
55
|
+
time -- it was skipped over in the original delivery order and is built
|
|
56
|
+
separately, below) found 27 concrete gaps (6 blocking) against ADR-0036's
|
|
57
|
+
gate for superseding safety-core.md #6, and returned NO-GO. The blocking
|
|
58
|
+
findings are fixed here: force eligibility now derives from proof.method
|
|
59
|
+
rather than a caller flag; the executor requires and journals a ProofResult
|
|
60
|
+
instead of trusting a bare oid; the ancestry proof path is repository-
|
|
61
|
+
qualified (it previously was not -- only the PR-delivery path was);
|
|
62
|
+
worktree occupancy is revalidated fresh at every mutation attempt via a
|
|
63
|
+
callable, not a static snapshot; and a claimed-successful delete is
|
|
64
|
+
reverified before being recorded "applied". Still true, unchanged by this
|
|
65
|
+
pass, and NOT claimed otherwise: nothing in this repository calls this
|
|
66
|
+
module in production -- `delete_fn` is entirely caller-injected with no
|
|
67
|
+
real git-calling implementation anywhere, the three vendored plugin copies
|
|
68
|
+
are unused, and worktree-removal guarding and filesystem-operation safety
|
|
69
|
+
do not exist. Wiring any of this into a live route is T-06's job, not this
|
|
70
|
+
module's, and this module being correct is a precondition for that work,
|
|
71
|
+
not a substitute for the review T-06 will still need before it lands.
|
|
72
|
+
|
|
73
|
+
T-03's own independent review (2026-09-17, against this module as first
|
|
74
|
+
merged) returned NO-GO: 3 blocking, 5 high. Both blocking defects it shares
|
|
75
|
+
a class with the T-04/T-05 review above -- a read failure folded into
|
|
76
|
+
"confirmed absent" -- are fixed here (B-3: unknown worktree-list readability
|
|
77
|
+
no longer defaults branch occupancy to unoccupied; H-1: a for-each-ref line
|
|
78
|
+
without exactly the documented 4 tab-separated fields is now a fully
|
|
79
|
+
unreadable record, never a partially-trusted one). `is_main` no longer
|
|
80
|
+
string-compares against a caller-supplied repo root -- a linked worktree's
|
|
81
|
+
`show-toplevel` almost never equals it, silently flagging every worktree
|
|
82
|
+
`is_main=False` including no ambiguity signal (B-2) -- it now trusts Git's
|
|
83
|
+
own documented ordering guarantee (the main worktree is always first in
|
|
84
|
+
`git worktree list`) instead of guessing from a path string. `validate_
|
|
85
|
+
resource_path` no longer treats "nested under the repo root" as the safe
|
|
86
|
+
shape: git worktrees are conventionally SIBLINGS of the repo root, not
|
|
87
|
+
descendants, so that rule refused every real worktree this module exists to
|
|
88
|
+
let a caller remove (H-2). It is now an exact-match allowlist guard --
|
|
89
|
+
`(True, resolved_path)` only when the resolved path equals one of the
|
|
90
|
+
caller's own authorized targets exactly -- which also fixes H-3 (the
|
|
91
|
+
resolved path is now returned so a caller can act on what was actually
|
|
92
|
+
validated, not the original unresolved string) and H-4 (no more prefix/
|
|
93
|
+
separator heuristics to get wrong on a volume or UNC root).
|
|
94
|
+
`classify_worktree_loss_surface` now accepts a `record_read_error` flag and
|
|
95
|
+
folds any UNREADABLE input into retain=True (H-5, M-3) rather than silently
|
|
96
|
+
being unreachable from the inventory's own error channel.
|
|
97
|
+
|
|
98
|
+
A CodeRabbit review of that remediation (2026-09-17) found and fixed one
|
|
99
|
+
more repository-identity gap (proof.target_repo not bound to repository_id
|
|
100
|
+
at the mutation seam), a `_c_unquote` mojibake bug (an octal-escaped
|
|
101
|
+
multi-byte UTF-8 lock reason was decoded one byte at a time instead of as
|
|
102
|
+
one accumulated buffer), and a `bind_branch_occupancy` gap where an
|
|
103
|
+
unreadable record elsewhere in the list could leave an unmatched branch's
|
|
104
|
+
occupancy as `None` instead of `UNREADABLE`.
|
|
105
|
+
|
|
106
|
+
A second, fresh independent review (2026-09-17, against this module
|
|
107
|
+
including the above CodeRabbit fixes) returned NO-GO: 2 high, 3 medium.
|
|
108
|
+
Both HIGH findings are fixed here: `%(refname:short)` was an unsafe join
|
|
109
|
+
key against the worktree porcelain list's fixed-prefix-stripped branch name
|
|
110
|
+
-- it shortens differently whenever another ref (e.g. a same-named tag)
|
|
111
|
+
shadows the branch -- so this module now reads the FULL `%(refname)`/
|
|
112
|
+
`%(upstream)` and strips the fixed `refs/heads/`/`refs/remotes/` prefix
|
|
113
|
+
itself (HIGH-1); and `parse_worktree_inventory` used `str.splitlines()`,
|
|
114
|
+
which also splits on eight Unicode line-boundary characters Git treats as
|
|
115
|
+
ordinary raw path bytes in this format, letting a worktree path containing
|
|
116
|
+
one fabricate a phantom second record (HIGH-2). The three MEDIUM
|
|
117
|
+
ride-along fixes: `classify_worktree_loss_surface` now treats `None` the
|
|
118
|
+
same as `UNREADABLE` for `is_locked`/`is_current_worktree`/`has_nested_repo`
|
|
119
|
+
(a caller's only two ways to spell "not yet determined"), and two
|
|
120
|
+
`TestValidateResourcePath` cases were strengthened after the reviewer
|
|
121
|
+
showed their original identity `real_path_fn` could not actually
|
|
122
|
+
distinguish the behavior they claimed to pin. Still true and not claimed
|
|
123
|
+
otherwise: this module still has no live caller -- wiring it into a route
|
|
124
|
+
is T-06's job -- and this second remediation pass has not itself been
|
|
125
|
+
re-reviewed yet.
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
import json
|
|
129
|
+
import ntpath
|
|
130
|
+
import os
|
|
131
|
+
import re
|
|
132
|
+
import uuid
|
|
133
|
+
from collections import namedtuple
|
|
134
|
+
from datetime import datetime, timezone
|
|
135
|
+
|
|
136
|
+
from _hooklib import acquire_lock, release_lock, write_text_atomic
|
|
137
|
+
|
|
138
|
+
ProofResult = namedtuple(
|
|
139
|
+
"ProofResult",
|
|
140
|
+
[
|
|
141
|
+
"proven",
|
|
142
|
+
"method", # "ancestry" | "pr_delivery" | None
|
|
143
|
+
"target_ref",
|
|
144
|
+
"target_sha",
|
|
145
|
+
"target_repo", # the AUTHORIZED integration_repo this proof was
|
|
146
|
+
# evaluated against (never the caller's unverified target_repo
|
|
147
|
+
# assertion) -- CodeRabbit review, 2026-09-17: lets a downstream
|
|
148
|
+
# consumer (execute_branch_deletion) bind a proof to the exact
|
|
149
|
+
# repository_id it is operating on, rather than re-trusting that
|
|
150
|
+
# the caller derived the same repository correctly a second time.
|
|
151
|
+
"candidate_sha",
|
|
152
|
+
"pr_number",
|
|
153
|
+
"pr_merge_commit",
|
|
154
|
+
"preserves_original_identity",
|
|
155
|
+
"corroboration", # tree-equality note, or None; never load-bearing
|
|
156
|
+
"reason", # always populated when proven is False
|
|
157
|
+
],
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
_PR_REQUIRED_FIELDS = ("number", "state", "head_sha", "base_repo", "merge_commit_sha")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _require_sha(value, label):
|
|
164
|
+
if not isinstance(value, str) or not value.strip():
|
|
165
|
+
raise ValueError("%s must be a non-empty sha string" % label)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
_PrDeliveryOutcome = namedtuple("_PrDeliveryOutcome", ["landing_sha", "pr_number", "reason"])
|
|
169
|
+
# M-3: replaces an earlier (landing_or_none, detail) return where the SAME
|
|
170
|
+
# slot meant "the landing sha" on success and "the failure reason" on
|
|
171
|
+
# failure -- correct only because merge_commit_sha is proven truthy before
|
|
172
|
+
# that slot is ever populated with a real value; a named, three-field
|
|
173
|
+
# outcome makes success/failure unambiguous regardless of what future
|
|
174
|
+
# fields carry.
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _evaluate_pr_delivery(pr_record, candidate_sha, integration_repo, is_ancestor, target_sha):
|
|
178
|
+
"""Return a _PrDeliveryOutcome. `.reason` is set whenever `.landing_sha`
|
|
179
|
+
is None (failure); exactly one of the two is ever populated."""
|
|
180
|
+
if pr_record is None:
|
|
181
|
+
return _PrDeliveryOutcome(None, None, "no PR delivery record supplied")
|
|
182
|
+
if not isinstance(pr_record, dict):
|
|
183
|
+
return _PrDeliveryOutcome(
|
|
184
|
+
None, None, "PR delivery record must be a resolved record, not a name search result",
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
missing = [field for field in _PR_REQUIRED_FIELDS if not pr_record.get(field)]
|
|
188
|
+
if missing:
|
|
189
|
+
return _PrDeliveryOutcome(
|
|
190
|
+
None, None, "PR record missing required field(s): %s" % ", ".join(missing),
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
number = pr_record["number"]
|
|
194
|
+
|
|
195
|
+
if pr_record["state"] != "MERGED":
|
|
196
|
+
return _PrDeliveryOutcome(
|
|
197
|
+
None, None, "PR #%s is not MERGED (state=%s)" % (number, pr_record["state"]),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
if pr_record["head_sha"] != candidate_sha:
|
|
201
|
+
return _PrDeliveryOutcome(None, None, (
|
|
202
|
+
"PR #%s head %s does not match candidate tip %s (stale or wrong PR)"
|
|
203
|
+
% (number, pr_record["head_sha"], candidate_sha)
|
|
204
|
+
))
|
|
205
|
+
|
|
206
|
+
if pr_record["base_repo"] != integration_repo:
|
|
207
|
+
return _PrDeliveryOutcome(None, None, (
|
|
208
|
+
"PR #%s base repository %s is not the authorized integration repository %s"
|
|
209
|
+
% (number, pr_record["base_repo"], integration_repo)
|
|
210
|
+
))
|
|
211
|
+
|
|
212
|
+
landing = pr_record["merge_commit_sha"]
|
|
213
|
+
if not is_ancestor(landing, target_sha):
|
|
214
|
+
return _PrDeliveryOutcome(None, None, (
|
|
215
|
+
"PR #%s landing commit %s is not retained in the fetched integration target"
|
|
216
|
+
% (number, landing)
|
|
217
|
+
))
|
|
218
|
+
|
|
219
|
+
return _PrDeliveryOutcome(landing, number, None)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _evaluate_tree_corroboration(candidate_sha, target_sha, tree_equal_fn):
|
|
223
|
+
if tree_equal_fn is None:
|
|
224
|
+
return None
|
|
225
|
+
if tree_equal_fn(candidate_sha, target_sha):
|
|
226
|
+
return "tree contents are byte-identical to the fetched integration target"
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def evaluate_merge_proof(
|
|
231
|
+
candidate_sha,
|
|
232
|
+
target_ref,
|
|
233
|
+
target_sha,
|
|
234
|
+
target_repo,
|
|
235
|
+
integration_repo,
|
|
236
|
+
is_ancestor,
|
|
237
|
+
pr_record=None,
|
|
238
|
+
tree_equal_fn=None,
|
|
239
|
+
):
|
|
240
|
+
"""Evaluate whether candidate_sha's content is proven delivered to target_ref/target_sha.
|
|
241
|
+
|
|
242
|
+
Args:
|
|
243
|
+
candidate_sha: full SHA of the branch tip under consideration.
|
|
244
|
+
target_ref: display name of the authorized, fetched integration target
|
|
245
|
+
(e.g. "origin/main"), carried through into the result only.
|
|
246
|
+
target_sha: full SHA the authorized integration target was fetched at.
|
|
247
|
+
target_repo: the "owner/repo" the caller asserts target_ref/target_sha
|
|
248
|
+
were actually fetched from. Checked against `integration_repo`
|
|
249
|
+
unconditionally, before EITHER proof path runs (independent
|
|
250
|
+
review, 2026-09-17): without this, is_ancestor(candidate_sha,
|
|
251
|
+
target_sha) returning True "proved" ancestry no matter which
|
|
252
|
+
repository target_sha actually came from -- a caller bug, a
|
|
253
|
+
stale remote, or a fork could all forge a passing ancestry
|
|
254
|
+
check. Ancestry is not exempt from repository qualification
|
|
255
|
+
just because it needs no PR record.
|
|
256
|
+
integration_repo: the "owner/repo" the target must actually belong
|
|
257
|
+
to. Also used to reject a PR whose base repository does not
|
|
258
|
+
match (fork-name collision / ambiguous fork identity).
|
|
259
|
+
is_ancestor: callable(a, b) -> bool, true when commit a is an
|
|
260
|
+
ancestor of commit b. No git process is spawned here.
|
|
261
|
+
pr_record: an already-resolved (by exact PR number/ID, never by a
|
|
262
|
+
branch-name search) dict describing one specific PR, or None.
|
|
263
|
+
Required keys when supplied: number, state, head_sha, base_repo,
|
|
264
|
+
merge_commit_sha.
|
|
265
|
+
tree_equal_fn: optional callable(a, b) -> bool for exact-tree
|
|
266
|
+
comparison. Recorded as corroboration only; never sufficient
|
|
267
|
+
alone (AC-08).
|
|
268
|
+
|
|
269
|
+
Returns:
|
|
270
|
+
ProofResult. `.proven` is True only when ancestry or a qualified PR
|
|
271
|
+
delivery proof held; otherwise False with a specific, non-generic
|
|
272
|
+
`.reason` -- never a bare boolean.
|
|
273
|
+
"""
|
|
274
|
+
_require_sha(candidate_sha, "candidate_sha")
|
|
275
|
+
_require_sha(target_sha, "target_sha")
|
|
276
|
+
if not isinstance(target_repo, str) or not target_repo.strip():
|
|
277
|
+
raise ValueError("target_repo must be a non-empty repository identity string")
|
|
278
|
+
|
|
279
|
+
corroboration = _evaluate_tree_corroboration(candidate_sha, target_sha, tree_equal_fn)
|
|
280
|
+
|
|
281
|
+
if target_repo != integration_repo:
|
|
282
|
+
return ProofResult(
|
|
283
|
+
proven=False,
|
|
284
|
+
method=None,
|
|
285
|
+
target_ref=target_ref,
|
|
286
|
+
target_sha=target_sha,
|
|
287
|
+
target_repo=integration_repo,
|
|
288
|
+
candidate_sha=candidate_sha,
|
|
289
|
+
pr_number=None,
|
|
290
|
+
pr_merge_commit=None,
|
|
291
|
+
preserves_original_identity=False,
|
|
292
|
+
corroboration=corroboration,
|
|
293
|
+
reason=(
|
|
294
|
+
"fetched target repository %s is not the authorized integration repository %s"
|
|
295
|
+
% (target_repo, integration_repo)
|
|
296
|
+
),
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
if is_ancestor(candidate_sha, target_sha):
|
|
300
|
+
return ProofResult(
|
|
301
|
+
proven=True,
|
|
302
|
+
method="ancestry",
|
|
303
|
+
target_ref=target_ref,
|
|
304
|
+
target_sha=target_sha,
|
|
305
|
+
target_repo=integration_repo,
|
|
306
|
+
candidate_sha=candidate_sha,
|
|
307
|
+
pr_number=None,
|
|
308
|
+
pr_merge_commit=None,
|
|
309
|
+
preserves_original_identity=True,
|
|
310
|
+
corroboration=corroboration,
|
|
311
|
+
reason=None,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
pr_outcome = _evaluate_pr_delivery(
|
|
315
|
+
pr_record, candidate_sha, integration_repo, is_ancestor, target_sha,
|
|
316
|
+
)
|
|
317
|
+
if pr_outcome.landing_sha is not None:
|
|
318
|
+
return ProofResult(
|
|
319
|
+
proven=True,
|
|
320
|
+
method="pr_delivery",
|
|
321
|
+
target_ref=target_ref,
|
|
322
|
+
target_sha=target_sha,
|
|
323
|
+
target_repo=integration_repo,
|
|
324
|
+
candidate_sha=candidate_sha,
|
|
325
|
+
pr_number=pr_outcome.pr_number,
|
|
326
|
+
pr_merge_commit=pr_outcome.landing_sha,
|
|
327
|
+
preserves_original_identity=False,
|
|
328
|
+
corroboration=corroboration,
|
|
329
|
+
reason=None,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
reason = "not an ancestor of %s, and %s" % (target_ref, pr_outcome.reason)
|
|
333
|
+
if corroboration:
|
|
334
|
+
reason += "; %s, but that is corroboration only, not proof" % corroboration
|
|
335
|
+
|
|
336
|
+
return ProofResult(
|
|
337
|
+
proven=False,
|
|
338
|
+
method=None,
|
|
339
|
+
target_ref=target_ref,
|
|
340
|
+
target_sha=target_sha,
|
|
341
|
+
target_repo=integration_repo,
|
|
342
|
+
candidate_sha=candidate_sha,
|
|
343
|
+
pr_number=None,
|
|
344
|
+
pr_merge_commit=None,
|
|
345
|
+
preserves_original_identity=False,
|
|
346
|
+
corroboration=corroboration,
|
|
347
|
+
reason=reason,
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
# T-05: operation journal + guarded, revalidating branch-deletion executor.
|
|
353
|
+
#
|
|
354
|
+
# Scope: branch deletion only. Worktree-removal guarding is a distinct,
|
|
355
|
+
# not-yet-built increment sharing this same journal/executor pattern -- it is
|
|
356
|
+
# not implemented here and nothing below claims otherwise.
|
|
357
|
+
# ---------------------------------------------------------------------------
|
|
358
|
+
|
|
359
|
+
_JOURNAL_SCHEMA = "cleanup-journal/v1"
|
|
360
|
+
_JOURNAL_RELPATH = os.path.join(".codearbiter", ".cleanup-journal.json")
|
|
361
|
+
_COMPLETED_STATUSES = frozenset({"applied", "skipped", "failed"})
|
|
362
|
+
# H-2: the proposal's own named example is a 40-branch bulk delete. The bound
|
|
363
|
+
# must comfortably exceed that so the audit trail for the exact authorized
|
|
364
|
+
# batch is never truncated mid-operation.
|
|
365
|
+
_MAX_COMPLETED_RECORDS = 50
|
|
366
|
+
|
|
367
|
+
_FULL_SHA_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _require_full_oid(value, label):
|
|
371
|
+
"""M-4: an abbreviated or malformed OID is rejected up front with a
|
|
372
|
+
specific error, rather than silently causing every downstream
|
|
373
|
+
comparison to "skip" as stale with no diagnosis."""
|
|
374
|
+
if not isinstance(value, str) or not _FULL_SHA_RE.match(value):
|
|
375
|
+
raise ValueError("%s must be a full 40-character hex SHA, got %r" % (label, value))
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _require_branch_collection(value, label):
|
|
379
|
+
"""M-2: a bare string is iterable and would silently match by
|
|
380
|
+
SUBSTRING (`"ai" in "main"` is True) rather than by membership in a
|
|
381
|
+
branch-name collection. Reject it outright instead of guessing intent."""
|
|
382
|
+
if isinstance(value, str):
|
|
383
|
+
raise TypeError(
|
|
384
|
+
"%s must be a collection of branch names, not a bare string %r" % (label, value)
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _require_repository_id(value, label):
|
|
389
|
+
"""CodeRabbit review (2026-09-17): a missing or empty repository_id can
|
|
390
|
+
never be cross-checked against anything, which is exactly the "unbound
|
|
391
|
+
journal record" gap the review named. Reject it up front rather than
|
|
392
|
+
silently accepting an identity that binds to nothing."""
|
|
393
|
+
if not isinstance(value, str) or not value.strip():
|
|
394
|
+
raise ValueError("%s must be a non-empty repository identity string" % label)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
_OID_UNREADABLE = object()
|
|
398
|
+
|
|
399
|
+
UNREADABLE = _OID_UNREADABLE
|
|
400
|
+
# Public alias: the T-03 inventory below (branch/worktree records, worktree
|
|
401
|
+
# loss-surface classification) reuses this exact sentinel for "this could
|
|
402
|
+
# not be determined," rather than minting a second "unreadable" concept.
|
|
403
|
+
# One canonical marker for "read failed" across the whole module means a
|
|
404
|
+
# caller can never accidentally treat T-03's UNREADABLE and T-05's internal
|
|
405
|
+
# _OID_UNREADABLE as somehow different states -- they are the same object.
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _read_oid(current_oid_fn, branch):
|
|
409
|
+
"""Distinguish confirmed-absent (None, a positive answer from a
|
|
410
|
+
successful read) from unreadable (the read itself failed) -- H-1's
|
|
411
|
+
fix. Never fold the two together: `current_oid_fn` raising means "I
|
|
412
|
+
could not tell," which is a materially different fact from "I checked
|
|
413
|
+
and it is gone." Callers must never treat _OID_UNREADABLE as None."""
|
|
414
|
+
try:
|
|
415
|
+
return current_oid_fn(branch)
|
|
416
|
+
except Exception as exc: # noqa: BLE001 -- any read failure is "unreadable"
|
|
417
|
+
return _OID_UNREADABLE
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class JournalCorruptError(Exception):
|
|
421
|
+
"""Raised when the operation journal exists but cannot be trusted --
|
|
422
|
+
unreadable, unparseable, or the wrong shape. Never silently treated as an
|
|
423
|
+
empty journal: a corrupt journal is a stop condition (spec: "Stop the
|
|
424
|
+
whole operation on ... corrupt/unwritable operation journal"), not a
|
|
425
|
+
reason to proceed as if no prior operation was ever recorded."""
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _journal_path(root):
|
|
429
|
+
return os.path.join(root, _JOURNAL_RELPATH)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _now_iso():
|
|
433
|
+
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def load_journal(root):
|
|
437
|
+
"""The operation journal at `root`, or a fresh empty envelope when no
|
|
438
|
+
journal file exists yet. Raises JournalCorruptError -- never returns a
|
|
439
|
+
silently-reset empty envelope -- when the file exists but is unreadable,
|
|
440
|
+
unparseable, or not shaped like a journal."""
|
|
441
|
+
path = _journal_path(root)
|
|
442
|
+
if not os.path.exists(path):
|
|
443
|
+
return {"schema": _JOURNAL_SCHEMA, "operations": []}
|
|
444
|
+
try:
|
|
445
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
446
|
+
data = json.load(f)
|
|
447
|
+
except (OSError, ValueError) as exc:
|
|
448
|
+
raise JournalCorruptError(
|
|
449
|
+
"cleanup operation journal at %s is unreadable: %s" % (path, exc)
|
|
450
|
+
) from exc
|
|
451
|
+
if (
|
|
452
|
+
not isinstance(data, dict)
|
|
453
|
+
or data.get("schema") != _JOURNAL_SCHEMA
|
|
454
|
+
or not isinstance(data.get("operations"), list)
|
|
455
|
+
):
|
|
456
|
+
raise JournalCorruptError(
|
|
457
|
+
"cleanup operation journal at %s has an unrecognized shape" % path
|
|
458
|
+
)
|
|
459
|
+
return data
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _compact(journal):
|
|
463
|
+
"""Bound completed (applied/skipped/failed) records to the most recent
|
|
464
|
+
_MAX_COMPLETED_RECORDS; every unresolved record (pending, or unknown from
|
|
465
|
+
a reconciliation that could not be resolved) is retained regardless of
|
|
466
|
+
count -- compaction never drops something still needing attention.
|
|
467
|
+
|
|
468
|
+
Preserves the list's existing relative order and drops only the OLDEST
|
|
469
|
+
excess completed entries (by their position in the current list) rather
|
|
470
|
+
than partitioning into "unresolved first, then completed": a partition-
|
|
471
|
+
and-concatenate approach reorders every record each time a record
|
|
472
|
+
transitions from pending to completed, which destroys the very
|
|
473
|
+
chronological ordering "most recent" depends on -- a record's position
|
|
474
|
+
would then reflect when it was LAST re-sorted, not when it was created."""
|
|
475
|
+
operations = journal.get("operations", [])
|
|
476
|
+
completed_indices = [i for i, op in enumerate(operations) if op.get("status") in _COMPLETED_STATUSES]
|
|
477
|
+
if len(completed_indices) > _MAX_COMPLETED_RECORDS:
|
|
478
|
+
drop = set(completed_indices[: len(completed_indices) - _MAX_COMPLETED_RECORDS])
|
|
479
|
+
operations = [op for i, op in enumerate(operations) if i not in drop]
|
|
480
|
+
journal["operations"] = operations
|
|
481
|
+
return journal
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _with_locked_journal(root, mutate_fn):
|
|
485
|
+
"""Acquire the cross-process journal lock, load-mutate-compact-save,
|
|
486
|
+
release. `mutate_fn(journal) -> journal`.
|
|
487
|
+
|
|
488
|
+
A failed lock acquisition is a HARD error here, not the fail-soft None
|
|
489
|
+
`_hooklib.acquire_lock` itself returns for disposable callers (a
|
|
490
|
+
statusline render) -- this journal is load-bearing operational state for
|
|
491
|
+
a mutation about to happen, the same treatment `taskwrite.py` gives its
|
|
492
|
+
board lock."""
|
|
493
|
+
path = _journal_path(root)
|
|
494
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
495
|
+
handle = acquire_lock(path)
|
|
496
|
+
if handle is None:
|
|
497
|
+
raise RuntimeError(
|
|
498
|
+
"could not acquire the cleanup operation journal lock at %s" % path
|
|
499
|
+
)
|
|
500
|
+
try:
|
|
501
|
+
journal = load_journal(root)
|
|
502
|
+
journal = mutate_fn(journal)
|
|
503
|
+
journal = _compact(journal)
|
|
504
|
+
write_text_atomic(path, json.dumps(journal, indent=2, sort_keys=True) + "\n", newline="\n")
|
|
505
|
+
return journal
|
|
506
|
+
finally:
|
|
507
|
+
release_lock(handle)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def _check_repository_identity(journal, repository_id):
|
|
511
|
+
"""H-3 (partial): AC-05's "invalid repository identity blocks the whole
|
|
512
|
+
operation" applies to the journal itself, not just Scope (T-02). If the
|
|
513
|
+
journal already holds records for a DIFFERENT repository_id than the one
|
|
514
|
+
supplied now, something is wrong -- the wrong root was passed, or one
|
|
515
|
+
journal path is serving two different repositories -- and we refuse
|
|
516
|
+
rather than silently mixing records from two repositories together.
|
|
517
|
+
|
|
518
|
+
`repository_id` is required non-empty by every caller (enforced in
|
|
519
|
+
`_append_operation_record` via `_require_repository_id`) before this
|
|
520
|
+
runs, so every record this ever compares against is itself non-empty --
|
|
521
|
+
there is no "either side is None" leniency left to exploit (CodeRabbit
|
|
522
|
+
review, 2026-09-17: the old skip-on-None behavior permitted unbound
|
|
523
|
+
records to coexist silently with bound ones).
|
|
524
|
+
|
|
525
|
+
This does not solve cross-worktree mutual exclusion for one shared
|
|
526
|
+
repository (a deeper structural question -- the journal path is still
|
|
527
|
+
resolved relative to the caller's `root`, so two worktrees of the SAME
|
|
528
|
+
repository currently get separate journals and separate locks). That
|
|
529
|
+
remains an explicitly open gap, not silently claimed as fixed here."""
|
|
530
|
+
for op in journal.get("operations", []):
|
|
531
|
+
existing = op.get("repository_id")
|
|
532
|
+
if existing != repository_id:
|
|
533
|
+
raise ValueError(
|
|
534
|
+
"cleanup journal at this path already holds records for repository %r, "
|
|
535
|
+
"not %r -- refusing to mix operations across repositories"
|
|
536
|
+
% (existing, repository_id)
|
|
537
|
+
)
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _append_operation_record(root, record):
|
|
541
|
+
_require_repository_id(record.get("repository_id"), "repository_id")
|
|
542
|
+
|
|
543
|
+
def _mutate(journal):
|
|
544
|
+
_check_repository_identity(journal, record.get("repository_id"))
|
|
545
|
+
journal["operations"].append(record)
|
|
546
|
+
return journal
|
|
547
|
+
|
|
548
|
+
_with_locked_journal(root, _mutate)
|
|
549
|
+
return record["operation_id"]
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def append_pending_operation(root, kind, target, repository_id):
|
|
553
|
+
"""Write-ahead intent (AC-19): record a pending operation BEFORE any
|
|
554
|
+
mutation is attempted. Returns the new operation_id. `repository_id`
|
|
555
|
+
identifies the repository this operation belongs to (AC-05); it must be
|
|
556
|
+
a non-empty string (CodeRabbit review, 2026-09-17 -- an absent or empty
|
|
557
|
+
identity can never be cross-checked against anything) and is validated
|
|
558
|
+
for consistency against any existing records in this journal -- see
|
|
559
|
+
_check_repository_identity."""
|
|
560
|
+
record = {
|
|
561
|
+
"operation_id": uuid.uuid4().hex,
|
|
562
|
+
"kind": kind,
|
|
563
|
+
"target": target,
|
|
564
|
+
"repository_id": repository_id,
|
|
565
|
+
"status": "pending",
|
|
566
|
+
"created_at": _now_iso(),
|
|
567
|
+
}
|
|
568
|
+
return _append_operation_record(root, record)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def update_operation_outcome(root, operation_id, status, result):
|
|
572
|
+
"""Record the final outcome of a journaled operation. Raises KeyError if
|
|
573
|
+
`operation_id` is not present -- an outcome for an operation that was
|
|
574
|
+
never journaled indicates a caller bug, not a state to paper over.
|
|
575
|
+
|
|
576
|
+
H-5: the membership check happens INSIDE the locked mutation, and
|
|
577
|
+
raising there means _with_locked_journal never reaches its write --
|
|
578
|
+
an outcome update for an unknown operation_id never rewrites the
|
|
579
|
+
journal file at all, rather than writing an unchanged copy first and
|
|
580
|
+
only then discovering the id was never found."""
|
|
581
|
+
|
|
582
|
+
def _mutate(journal):
|
|
583
|
+
for op in journal["operations"]:
|
|
584
|
+
if op["operation_id"] == operation_id:
|
|
585
|
+
op["status"] = status
|
|
586
|
+
op["result"] = result
|
|
587
|
+
op["completed_at"] = _now_iso()
|
|
588
|
+
return journal
|
|
589
|
+
raise KeyError("no operation %r in the cleanup journal" % operation_id)
|
|
590
|
+
|
|
591
|
+
_with_locked_journal(root, _mutate)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
def reconcile_pending_operations(root, current_oid_fn, repository_id):
|
|
595
|
+
"""Crash-time reconciliation (AC-19): resolve every still-pending
|
|
596
|
+
branch_delete record from ACTUAL current state, never by blind retry.
|
|
597
|
+
|
|
598
|
+
- branch no longer exists (a successful read confirms absence) -> the
|
|
599
|
+
deletion evidently completed: "applied".
|
|
600
|
+
- the read itself fails (H-1) -> "unknown", NEVER "applied". A read
|
|
601
|
+
failure is not evidence of absence; folding the two together would
|
|
602
|
+
let a transient git error masquerade as a confirmed deletion.
|
|
603
|
+
- branch exists with the SAME expected tip -> still genuinely pending;
|
|
604
|
+
left untouched for a fresh attempt.
|
|
605
|
+
- branch exists with a DIFFERENT tip -> ambiguous (a fresh commit, or a
|
|
606
|
+
partial/racing mutation); marked "unknown" for explicit review, never
|
|
607
|
+
auto-resolved to "applied" and never silently retried.
|
|
608
|
+
|
|
609
|
+
Only "branch_delete" records are touched (H-6) -- a pending record of
|
|
610
|
+
any other kind (e.g. a future worktree_remove) is left completely
|
|
611
|
+
alone; this function has no basis for judging a different kind's state.
|
|
612
|
+
|
|
613
|
+
`repository_id` (CodeRabbit review, 2026-09-17) identifies the
|
|
614
|
+
repository actually being reconciled RIGHT NOW; it must be a non-empty
|
|
615
|
+
string. A pending record belonging to a DIFFERENT repository_id is left
|
|
616
|
+
completely untouched -- reconciliation must never resolve another
|
|
617
|
+
repository's operation from this repository's git state, even though
|
|
618
|
+
both currently share one journal path per `root`.
|
|
619
|
+
|
|
620
|
+
Already-resolved (applied/skipped/failed) records are never touched."""
|
|
621
|
+
_require_repository_id(repository_id, "repository_id")
|
|
622
|
+
|
|
623
|
+
def _mutate(journal):
|
|
624
|
+
for op in journal["operations"]:
|
|
625
|
+
if op.get("status") != "pending" or op.get("kind") != "branch_delete":
|
|
626
|
+
continue
|
|
627
|
+
if op.get("repository_id") != repository_id:
|
|
628
|
+
continue
|
|
629
|
+
target = op.get("target") or {}
|
|
630
|
+
branch = target.get("branch")
|
|
631
|
+
expected_oid = target.get("expected_oid")
|
|
632
|
+
actual = _read_oid(current_oid_fn, branch)
|
|
633
|
+
if actual is _OID_UNREADABLE:
|
|
634
|
+
op["status"] = "unknown"
|
|
635
|
+
op["result"] = {"detail": "reconciled: could not determine current state (read failed)"}
|
|
636
|
+
op["completed_at"] = _now_iso()
|
|
637
|
+
elif actual is None:
|
|
638
|
+
op["status"] = "applied"
|
|
639
|
+
op["result"] = {"detail": "reconciled: branch no longer exists"}
|
|
640
|
+
op["completed_at"] = _now_iso()
|
|
641
|
+
elif actual != expected_oid:
|
|
642
|
+
op["status"] = "unknown"
|
|
643
|
+
op["result"] = {
|
|
644
|
+
"detail": (
|
|
645
|
+
"reconciled: tip is %s, neither deleted nor the expected %s"
|
|
646
|
+
% (actual, expected_oid)
|
|
647
|
+
),
|
|
648
|
+
}
|
|
649
|
+
op["completed_at"] = _now_iso()
|
|
650
|
+
# else: still genuinely pending -- left untouched, not retried here.
|
|
651
|
+
return journal
|
|
652
|
+
|
|
653
|
+
_with_locked_journal(root, _mutate)
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
class GuardRefusal(Exception):
|
|
657
|
+
"""Raised by guard_branch_deletion when a branch must not be deleted.
|
|
658
|
+
`.reason` is a specific, human-readable explanation -- callers never see
|
|
659
|
+
a bare boolean refusal."""
|
|
660
|
+
|
|
661
|
+
def __init__(self, reason):
|
|
662
|
+
super().__init__(reason)
|
|
663
|
+
self.reason = reason
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def guard_branch_deletion(branch, expected_oid, current_branch, default_branch,
|
|
667
|
+
protected_branches, worktree_branches_fn, current_oid_fn):
|
|
668
|
+
"""Pure guard (AC-09, AC-06): raises GuardRefusal if `branch` must not be
|
|
669
|
+
deleted right now. Performs no mutation itself -- see
|
|
670
|
+
execute_branch_deletion for the full journaled apply path.
|
|
671
|
+
|
|
672
|
+
`worktree_branches_fn` is a zero-argument callable invoked HERE, fresh,
|
|
673
|
+
every time the guard runs (B-6) -- never a snapshot bound earlier. A
|
|
674
|
+
worktree created after inspection but before this call must still be
|
|
675
|
+
caught.
|
|
676
|
+
|
|
677
|
+
Refuses, in order: the current branch, the default branch, an explicitly
|
|
678
|
+
protected branch, a branch occupied by any worktree (occupancy read
|
|
679
|
+
fresh via `worktree_branches_fn`), a branch that no longer exists, and
|
|
680
|
+
-- the AC-06 case -- a branch whose ACTUAL current tip (read here,
|
|
681
|
+
immediately before mutation, via `current_oid_fn`) no longer equals
|
|
682
|
+
`expected_oid` bound at inspection time. A candidate that passed
|
|
683
|
+
inspection and then moved is never swept in on a stale proof.
|
|
684
|
+
|
|
685
|
+
A read failure (either callable raising) is fail-safe: refuse, never
|
|
686
|
+
crash, and never treat "could not tell" as "safe to proceed" (H-1)."""
|
|
687
|
+
_require_full_oid(expected_oid, "expected_oid")
|
|
688
|
+
_require_branch_collection(protected_branches, "protected_branches")
|
|
689
|
+
if branch == current_branch:
|
|
690
|
+
raise GuardRefusal("%r is the current branch" % branch)
|
|
691
|
+
if branch == default_branch:
|
|
692
|
+
raise GuardRefusal("%r is the protected default branch" % branch)
|
|
693
|
+
if branch in (protected_branches or ()):
|
|
694
|
+
raise GuardRefusal("%r is an explicitly protected branch" % branch)
|
|
695
|
+
try:
|
|
696
|
+
worktree_branches = worktree_branches_fn()
|
|
697
|
+
except Exception as exc: # noqa: BLE001 -- any read failure is fail-safe refusal
|
|
698
|
+
raise GuardRefusal(
|
|
699
|
+
"could not determine worktree occupancy for %r: %s" % (branch, exc)
|
|
700
|
+
) from exc
|
|
701
|
+
_require_branch_collection(worktree_branches, "worktree_branches_fn() result")
|
|
702
|
+
if branch in (worktree_branches or ()):
|
|
703
|
+
raise GuardRefusal("%r is checked out in a worktree" % branch)
|
|
704
|
+
actual_oid = _read_oid(current_oid_fn, branch)
|
|
705
|
+
if actual_oid is _OID_UNREADABLE:
|
|
706
|
+
raise GuardRefusal("could not determine %r's current state" % branch)
|
|
707
|
+
if actual_oid is None:
|
|
708
|
+
raise GuardRefusal("%r no longer exists (already deleted or renamed)" % branch)
|
|
709
|
+
if actual_oid != expected_oid:
|
|
710
|
+
raise GuardRefusal(
|
|
711
|
+
"%r tip moved from expected %s to %s since inspection -- stale target"
|
|
712
|
+
% (branch, expected_oid, actual_oid)
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
OperationResult = namedtuple("OperationResult", ["operation_id", "status", "detail"])
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
def execute_branch_deletion(root, branch, proof, current_branch, default_branch,
|
|
720
|
+
protected_branches, worktree_branches_fn, current_oid_fn,
|
|
721
|
+
delete_fn, repository_id):
|
|
722
|
+
"""Journal-then-mutate, per-item outcome (AC-14, AC-19). This is the ONE
|
|
723
|
+
guarded path branch deletion may go through; it verifies evidence itself
|
|
724
|
+
(via `proof` and `guard_branch_deletion`) rather than trusting a
|
|
725
|
+
caller-supplied safe/owned flag.
|
|
726
|
+
|
|
727
|
+
`proof` is a `ProofResult` (from `evaluate_merge_proof`) for `branch`,
|
|
728
|
+
whose `candidate_sha` is used as the branch's expected tip. "Proven-
|
|
729
|
+
merged" is never caller honor (B-3): if `proof.proven` is not True, the
|
|
730
|
+
operation is skipped outright, before the guard even runs.
|
|
731
|
+
|
|
732
|
+
Force/atomic-delete (the `-D`-equivalent path) is permitted ONLY when
|
|
733
|
+
`proof.method == "pr_delivery"` -- NEVER a caller-supplied boolean
|
|
734
|
+
(B-1). An ancestry-proven branch has no legitimate reason to need a
|
|
735
|
+
forced delete; if its plain attempt fails, that is itself suspicious
|
|
736
|
+
and returns "failed" for investigation rather than escalating.
|
|
737
|
+
|
|
738
|
+
`delete_fn(branch, force, expected_oid) -> (ok: bool, detail: str)`
|
|
739
|
+
performs the actual deletion. `expected_oid` is passed through so a real
|
|
740
|
+
implementation can make the git-level operation ITSELF atomic with
|
|
741
|
+
respect to the tip -- e.g. `git update-ref -d refs/heads/<branch>
|
|
742
|
+
<expected_oid>`, which the project's own Git-behavior probes (GIT-07)
|
|
743
|
+
confirm refuses a stale value -- rather than a plain `git branch -d`/`-D`,
|
|
744
|
+
which performs its own unrelated lookup and could still delete a branch
|
|
745
|
+
whose tip moved between our guard's check and git's own call.
|
|
746
|
+
|
|
747
|
+
If the plain attempt fails and a forced retry is permitted, BOTH the
|
|
748
|
+
branch's actual tip AND its worktree occupancy are revalidated AGAIN,
|
|
749
|
+
fresh, immediately before the retry (B-6) -- a plain-attempt refusal is
|
|
750
|
+
not itself proof either fact is still what was expected, and a ref move
|
|
751
|
+
or new worktree injected exactly at this seam must still refuse (AC-06),
|
|
752
|
+
never fall through to a forced delete of a now-different branch.
|
|
753
|
+
|
|
754
|
+
After ANY claimed-successful delete, the branch's state is reverified
|
|
755
|
+
(H-4) -- `delete_fn` returning `ok=True` is never trusted absolutely.
|
|
756
|
+
If the branch still resolves, that is a contradiction and is recorded
|
|
757
|
+
"failed"; if the post-delete state cannot even be read, that is
|
|
758
|
+
honestly "unknown," never silently folded into "applied."
|
|
759
|
+
"""
|
|
760
|
+
expected_oid = proof.candidate_sha
|
|
761
|
+
_require_full_oid(expected_oid, "proof.candidate_sha")
|
|
762
|
+
_require_repository_id(repository_id, "repository_id")
|
|
763
|
+
operation_id = uuid.uuid4().hex
|
|
764
|
+
_append_operation_record(root, {
|
|
765
|
+
"operation_id": operation_id,
|
|
766
|
+
"kind": "branch_delete",
|
|
767
|
+
"target": {"branch": branch, "expected_oid": expected_oid},
|
|
768
|
+
"proof": {
|
|
769
|
+
"method": proof.method,
|
|
770
|
+
"target_ref": proof.target_ref,
|
|
771
|
+
"target_sha": proof.target_sha,
|
|
772
|
+
"target_repo": proof.target_repo,
|
|
773
|
+
"pr_number": proof.pr_number,
|
|
774
|
+
"pr_merge_commit": proof.pr_merge_commit,
|
|
775
|
+
},
|
|
776
|
+
"repository_id": repository_id,
|
|
777
|
+
"status": "pending",
|
|
778
|
+
"created_at": _now_iso(),
|
|
779
|
+
})
|
|
780
|
+
|
|
781
|
+
def _finish(status, detail):
|
|
782
|
+
update_operation_outcome(root, operation_id, status, {"detail": detail})
|
|
783
|
+
return OperationResult(operation_id, status, detail)
|
|
784
|
+
|
|
785
|
+
if not proof.proven:
|
|
786
|
+
return _finish("skipped", "not proven delivered: %s" % proof.reason)
|
|
787
|
+
|
|
788
|
+
if proof.target_repo != repository_id:
|
|
789
|
+
# CodeRabbit review (2026-09-17): a proof validated for one
|
|
790
|
+
# repository must never authorize a deletion journaled/executed
|
|
791
|
+
# under a different repository_id -- ProofResult.target_repo and
|
|
792
|
+
# the operation's repository_id are two independently-derived
|
|
793
|
+
# facts and must agree before anything is deleted.
|
|
794
|
+
return _finish(
|
|
795
|
+
"skipped",
|
|
796
|
+
"proof was validated for repository %r, not this operation's repository %r"
|
|
797
|
+
% (proof.target_repo, repository_id),
|
|
798
|
+
)
|
|
799
|
+
|
|
800
|
+
allow_force = proof.method == "pr_delivery"
|
|
801
|
+
|
|
802
|
+
try:
|
|
803
|
+
guard_branch_deletion(
|
|
804
|
+
branch, expected_oid, current_branch, default_branch,
|
|
805
|
+
protected_branches, worktree_branches_fn, current_oid_fn,
|
|
806
|
+
)
|
|
807
|
+
except GuardRefusal as exc:
|
|
808
|
+
return _finish("skipped", exc.reason)
|
|
809
|
+
|
|
810
|
+
ok, detail = delete_fn(branch, False, expected_oid)
|
|
811
|
+
if not ok and allow_force:
|
|
812
|
+
actual_oid = _read_oid(current_oid_fn, branch)
|
|
813
|
+
if actual_oid is _OID_UNREADABLE or actual_oid != expected_oid:
|
|
814
|
+
return _finish(
|
|
815
|
+
"skipped",
|
|
816
|
+
"tip changed (or became unreadable) before the forced retry -- refusing",
|
|
817
|
+
)
|
|
818
|
+
try:
|
|
819
|
+
worktree_branches = worktree_branches_fn()
|
|
820
|
+
except Exception as exc: # noqa: BLE001
|
|
821
|
+
return _finish(
|
|
822
|
+
"skipped",
|
|
823
|
+
"could not confirm worktree occupancy before the forced retry -- refusing: %s" % exc,
|
|
824
|
+
)
|
|
825
|
+
_require_branch_collection(worktree_branches, "worktree_branches_fn() result")
|
|
826
|
+
if branch in (worktree_branches or ()):
|
|
827
|
+
return _finish(
|
|
828
|
+
"skipped",
|
|
829
|
+
"%r became worktree-occupied before the forced retry -- refusing" % branch,
|
|
830
|
+
)
|
|
831
|
+
ok, detail = delete_fn(branch, True, expected_oid)
|
|
832
|
+
|
|
833
|
+
if ok:
|
|
834
|
+
verify_oid = _read_oid(current_oid_fn, branch)
|
|
835
|
+
if verify_oid is _OID_UNREADABLE:
|
|
836
|
+
return _finish(
|
|
837
|
+
"unknown",
|
|
838
|
+
"delete_fn reported success but %r's post-delete state could not be reverified" % branch,
|
|
839
|
+
)
|
|
840
|
+
if verify_oid is not None:
|
|
841
|
+
return _finish(
|
|
842
|
+
"failed",
|
|
843
|
+
"delete_fn reported success but %r still resolves to %s" % (branch, verify_oid),
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
return _finish("applied" if ok else "failed", detail)
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
# ---------------------------------------------------------------------------
|
|
850
|
+
# T-02: shared decision and interaction typed objects.
|
|
851
|
+
#
|
|
852
|
+
# Pure policy-decision vocabulary: how many confirmations an operation needs
|
|
853
|
+
# (authorization), whether a scope covers a given target (eligibility-of-
|
|
854
|
+
# scope, not eligibility-of-content -- that's ProofResult/guard_branch_
|
|
855
|
+
# deletion's job), and one typed outcome per target. Consumed by T-06's live
|
|
856
|
+
# routing and by shared/host orchestration tests alike; this module makes no
|
|
857
|
+
# orchestration decisions and calls no git/gh itself.
|
|
858
|
+
# ---------------------------------------------------------------------------
|
|
859
|
+
|
|
860
|
+
AUTHORIZATION_SOURCES = frozenset({
|
|
861
|
+
# AC-01: an explicit instruction that already names a bounded operation.
|
|
862
|
+
"direct_instruction",
|
|
863
|
+
# The interaction model's snapshot-bind row: a reply that accepts (all
|
|
864
|
+
# or a subset of) an already-displayed enumerated proposal.
|
|
865
|
+
"snapshot_reply",
|
|
866
|
+
# AC-04: an accepted work lifecycle's own disposable housekeeping.
|
|
867
|
+
"accepted_lifecycle",
|
|
868
|
+
})
|
|
869
|
+
|
|
870
|
+
RESOURCE_KINDS = frozenset({"branch", "worktree", "file", "directory"})
|
|
871
|
+
|
|
872
|
+
DECISION_OUTCOMES = frozenset({
|
|
873
|
+
"eligible", # proven and unblocked; may proceed
|
|
874
|
+
"excluded", # protected, occupied, or outside the scope's kinds -- never a loss
|
|
875
|
+
"unique_loss", # eligible for removal, but discards unique work (AC-12)
|
|
876
|
+
"unknown", # cannot be determined; blocks only itself and dependants (AC-05)
|
|
877
|
+
"error", # invalid identity or corrupt state; blocks the whole operation (AC-05)
|
|
878
|
+
})
|
|
879
|
+
|
|
880
|
+
Scope = namedtuple("Scope", ["source", "resource_kinds", "members", "repository_id"])
|
|
881
|
+
# source: one of AUTHORIZATION_SOURCES, or None for a not-yet-authorized
|
|
882
|
+
# proposal (nothing is covered until the user responds).
|
|
883
|
+
# resource_kinds: frozenset naming EVERY resource kind this scope covers
|
|
884
|
+
# (AC-03: composite scopes name their resource kinds and members --
|
|
885
|
+
# there is no implicit "and anything else that looks related").
|
|
886
|
+
# members: frozenset of exact target locators bound at authorization time,
|
|
887
|
+
# or None when offered but not yet enumerated/accepted. A later-
|
|
888
|
+
# discovered target is never silently added to an existing operation.
|
|
889
|
+
# repository_id: the exact repository identity this authorization applies
|
|
890
|
+
# to (AC-05: an invalid repository identity blocks the whole operation).
|
|
891
|
+
|
|
892
|
+
Target = namedtuple("Target", ["kind", "locator", "expected_identity"])
|
|
893
|
+
# kind: a RESOURCE_KINDS member, or an out-of-band kind (e.g. "task_archive")
|
|
894
|
+
# that a scope may legitimately never cover.
|
|
895
|
+
# locator: the exact name/path identifying this target.
|
|
896
|
+
# expected_identity: an oid/fingerprint bound at inspection time, or None
|
|
897
|
+
# when identity is not yet resolved.
|
|
898
|
+
|
|
899
|
+
Decision = namedtuple("Decision", ["target", "outcome", "reason", "blocks_operation"])
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
def make_decision(target, outcome, reason):
|
|
903
|
+
"""One typed outcome for one target (AC-05, AC-12). `reason` is always a
|
|
904
|
+
specific, human-readable string -- callers never see a bare outcome
|
|
905
|
+
without why. `blocks_operation` is True only for "error": an unknown,
|
|
906
|
+
excluded, or unique-loss decision blocks only its own target and
|
|
907
|
+
dependants, never the whole operation."""
|
|
908
|
+
if outcome not in DECISION_OUTCOMES:
|
|
909
|
+
raise ValueError("unrecognized decision outcome %r" % outcome)
|
|
910
|
+
return Decision(
|
|
911
|
+
target=target, outcome=outcome, reason=reason,
|
|
912
|
+
blocks_operation=(outcome == "error"),
|
|
913
|
+
)
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def scope_covers(scope, target, repository_id):
|
|
917
|
+
"""AC-03 (keep the scope closed): a target is covered only when its
|
|
918
|
+
resource kind is one the scope explicitly names AND its locator is in
|
|
919
|
+
the scope's exact bound member set. Kind is checked before membership,
|
|
920
|
+
so a same-named resource of the wrong kind (a task-archive candidate
|
|
921
|
+
sharing a branch's name) is never covered by a branch-only scope. A
|
|
922
|
+
scope with no bound members yet (an unaccepted proposal) covers
|
|
923
|
+
nothing, and neither does no scope at all. A Scope with an unknown
|
|
924
|
+
authorization source covers nothing either, even if members happens
|
|
925
|
+
to already be populated (e.g. a displayed-but-not-yet-accepted
|
|
926
|
+
snapshot). The caller also supplies the active repository identity;
|
|
927
|
+
authorization bound in one repository never transfers to another."""
|
|
928
|
+
if (
|
|
929
|
+
scope is None
|
|
930
|
+
or scope.source not in AUTHORIZATION_SOURCES
|
|
931
|
+
or scope.members is None
|
|
932
|
+
):
|
|
933
|
+
return False
|
|
934
|
+
if not repository_id or scope.repository_id != repository_id:
|
|
935
|
+
return False
|
|
936
|
+
if target.kind not in scope.resource_kinds:
|
|
937
|
+
return False
|
|
938
|
+
return target.locator in scope.members
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def required_confirmation_count(scope, has_unresolved_unique_loss=False):
|
|
942
|
+
"""Pure prompt-budget classifier (AC-01, AC-02, AC-04, AC-12, AC-26):
|
|
943
|
+
how many NEW confirmation questions this scope's operation needs, based
|
|
944
|
+
only on its authorization source -- never per-target, never per-branch.
|
|
945
|
+
|
|
946
|
+
- scope=None (a proposal not yet authorized, e.g. a generic standup
|
|
947
|
+
sweep) needs exactly one batch offer (AC-02): "at most 1 per
|
|
948
|
+
proposed scope," not zero and not one per candidate.
|
|
949
|
+
- Each AUTHORIZATION_SOURCES member is already self-authorizing for
|
|
950
|
+
the members it binds (AC-01's direct instruction, the snapshot-bind
|
|
951
|
+
reply, AC-04's accepted lifecycle): zero further questions.
|
|
952
|
+
- A declined proposal is represented the same way as one never made
|
|
953
|
+
(scope stays None) -- there is no per-item prompt primitive here for
|
|
954
|
+
a decline to fall into; re-offering the same declined proposal is an
|
|
955
|
+
orchestration decision, not this function's job to inflate.
|
|
956
|
+
- An unresolved unique loss (AC-12) always adds exactly one further
|
|
957
|
+
question on top of any authorization source, because routine
|
|
958
|
+
cleanup authorization never covers a unique loss by itself. The
|
|
959
|
+
flag is boolean, not a count: multiple named losses still share one
|
|
960
|
+
explicit decision, never one prompt each.
|
|
961
|
+
"""
|
|
962
|
+
if scope is None or scope.source is None:
|
|
963
|
+
base = 1
|
|
964
|
+
elif scope.source in AUTHORIZATION_SOURCES:
|
|
965
|
+
base = 0
|
|
966
|
+
else:
|
|
967
|
+
raise ValueError("unrecognized authorization source %r" % scope.source)
|
|
968
|
+
return base + (1 if has_unresolved_unique_loss else 0)
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
# ---------------------------------------------------------------------------
|
|
972
|
+
# T-03: authoritative, bounded branch/worktree inventory.
|
|
973
|
+
#
|
|
974
|
+
# Pure parsers only (same discipline as T-02/T-04/T-05 above): every function
|
|
975
|
+
# here takes an already-captured Git command output string (or an
|
|
976
|
+
# already-resolved filesystem fact via an injectable callable) and returns a
|
|
977
|
+
# structured record -- no subprocess call, no os.environ read, no network
|
|
978
|
+
# call happens in this module. Discovery output is reported AS-IS; nothing
|
|
979
|
+
# here decides eligibility (AC-07: gone/absent/still-existing upstream state
|
|
980
|
+
# never implies merged -- that is evaluate_merge_proof's job) or authorizes a
|
|
981
|
+
# mutation (that is Scope/guard_branch_deletion's job). A read failure or a
|
|
982
|
+
# malformed line is always its own explicit state, never silently folded
|
|
983
|
+
# into "absent" or "clean" (AC-05, AC-09, AC-11): an inventory that hides a
|
|
984
|
+
# read failure behind a default value is worse than no inventory at all.
|
|
985
|
+
#
|
|
986
|
+
# Branch OIDs come from `git for-each-ref refs/heads --format='%(refname)
|
|
987
|
+
# %09%(objectname)%09%(upstream)%09%(upstream:track)'` -- full hex SHAs (40
|
|
988
|
+
# characters for SHA-1, 64 for a SHA-256 repository), never the abbreviated
|
|
989
|
+
# form `git branch -vv` prints by default. That format ALWAYS emits exactly
|
|
990
|
+
# 4 tab-separated fields (empty ones for an unset upstream); anything else
|
|
991
|
+
# means a truncated read, a format-string drift, or a different git
|
|
992
|
+
# version, so a line with any other field count is rejected as a whole, not
|
|
993
|
+
# partially trusted.
|
|
994
|
+
#
|
|
995
|
+
# The `refname`/`upstream` fields are deliberately the FULL ref
|
|
996
|
+
# (`refs/heads/x`, `refs/remotes/origin/x`), never Git's own `:short` form:
|
|
997
|
+
# `%(refname:short)` is context-dependent -- it lengthens to `heads/x`
|
|
998
|
+
# whenever another ref (e.g. a same-named tag) shadows the branch, while
|
|
999
|
+
# `git worktree list --porcelain`'s `branch refs/heads/x` line is always the
|
|
1000
|
+
# full, unshortened ref. Joining `:short` output against the porcelain
|
|
1001
|
+
# side's fixed-prefix strip silently missed a live occupancy match on any
|
|
1002
|
+
# such collision (independent review, 2026-09-17) -- not a hypothetical:
|
|
1003
|
+
# a release tag sharing a branch's name is common. Stripping the FIXED,
|
|
1004
|
+
# unambiguous `refs/heads/`/`refs/remotes/` prefix ourselves, from the full
|
|
1005
|
+
# ref, gives the same short name both sides already expected, deterministically.
|
|
1006
|
+
#
|
|
1007
|
+
# Branch occupancy is bound by cross-referencing the REAL worktree list
|
|
1008
|
+
# (`git worktree list --porcelain[|-z]`), never guessed from a display-only
|
|
1009
|
+
# "+"/"*" prefix: a marker alone would say a branch is occupied without ever
|
|
1010
|
+
# saying by which worktree, and the actual path is exactly what a caller
|
|
1011
|
+
# needs to make a real decision. The MAIN worktree is identified by Git's
|
|
1012
|
+
# own documented ordering guarantee -- it is always the first record `git
|
|
1013
|
+
# worktree list` prints -- never by string-comparing a path against a
|
|
1014
|
+
# caller-supplied repo root: a linked worktree's own `show-toplevel` is
|
|
1015
|
+
# almost never equal to that root, and Windows path separators never equal
|
|
1016
|
+
# Git's forward slashes, so a string-equality rule flags EVERY worktree
|
|
1017
|
+
# "not main" with no ambiguity signal at all.
|
|
1018
|
+
# ---------------------------------------------------------------------------
|
|
1019
|
+
|
|
1020
|
+
BranchRefRecord = namedtuple("BranchRefRecord", [
|
|
1021
|
+
"name", "oid", "upstream", "upstream_gone", "occupied_by", "read_error",
|
|
1022
|
+
])
|
|
1023
|
+
# oid: full-length hex SHA (40 or 64 characters), or UNREADABLE if the line
|
|
1024
|
+
# did not carry exactly 4 tab-separated fields or the OID field was
|
|
1025
|
+
# anything short of a full-length hex object name (an abbreviated SHA is
|
|
1026
|
+
# refused, not silently accepted, since T-03's whole point is a FULL,
|
|
1027
|
+
# authoritative ref ID).
|
|
1028
|
+
# upstream: the upstream ref's short name, or None for a real, confirmed
|
|
1029
|
+
# "no upstream configured" -- or UNREADABLE if the line itself could not
|
|
1030
|
+
# be parsed at all (never conflated with a confirmed-absent upstream).
|
|
1031
|
+
# upstream_gone: True/False only when Git's own %(upstream:track) explicitly
|
|
1032
|
+
# said so from a fully-parsed line; UNREADABLE if the line could not be
|
|
1033
|
+
# parsed. AC-07: this is a raw discovery FACT, never proof of merged
|
|
1034
|
+
# eligibility -- a gone-but-unmerged branch stays exactly that, unproven,
|
|
1035
|
+
# through this entire module.
|
|
1036
|
+
# occupied_by: the worktree path currently holding this branch checked out,
|
|
1037
|
+
# UNREADABLE if occupancy could not be determined, or None if it is
|
|
1038
|
+
# affirmatively known to be unoccupied. Populated ONLY by
|
|
1039
|
+
# bind_branch_occupancy from a real list of WorktreeInventoryRecord --
|
|
1040
|
+
# never inferred here.
|
|
1041
|
+
# read_error: True if the source line did not carry exactly the 4
|
|
1042
|
+
# tab-separated fields the --format string always emits, or carried an
|
|
1043
|
+
# unparseable OID. A malformed line still produces its own record (AC-05:
|
|
1044
|
+
# an unknown item blocks only itself) with whatever name text could be
|
|
1045
|
+
# salvaged, rather than vanishing from the inventory -- a silently
|
|
1046
|
+
# shrunken inventory is a worse failure than a visibly broken record.
|
|
1047
|
+
|
|
1048
|
+
_FULL_HEX_RE = re.compile(r"^(?:[0-9a-fA-F]{40}|[0-9a-fA-F]{64})$")
|
|
1049
|
+
|
|
1050
|
+
_REFS_HEADS_PREFIX = "refs/heads/"
|
|
1051
|
+
_REFS_REMOTES_PREFIX = "refs/remotes/"
|
|
1052
|
+
|
|
1053
|
+
|
|
1054
|
+
def _strip_fixed_ref_prefix(full_ref, prefixes):
|
|
1055
|
+
"""Strip a FIXED, unambiguous ref-namespace prefix ourselves -- never
|
|
1056
|
+
Git's own `:short` form, which shortens differently depending on what
|
|
1057
|
+
else happens to exist in the repo (see module docstring). `prefixes` is
|
|
1058
|
+
a single prefix string, or a tuple of them tried in order -- %(upstream)
|
|
1059
|
+
is USUALLY `refs/remotes/...` but can legally be `refs/heads/...` when a
|
|
1060
|
+
branch tracks another local branch (independent review follow-up,
|
|
1061
|
+
2026-09-17): trying only one fixed prefix left that shape unstripped."""
|
|
1062
|
+
if isinstance(prefixes, str):
|
|
1063
|
+
prefixes = (prefixes,)
|
|
1064
|
+
for prefix in prefixes:
|
|
1065
|
+
if full_ref.startswith(prefix):
|
|
1066
|
+
return full_ref[len(prefix):]
|
|
1067
|
+
return full_ref
|
|
1068
|
+
|
|
1069
|
+
|
|
1070
|
+
def parse_branch_ref_inventory(for_each_ref_text):
|
|
1071
|
+
"""Parse tab-delimited `git for-each-ref refs/heads` output (see module
|
|
1072
|
+
docstring for the exact --format) into a list of BranchRefRecord, one
|
|
1073
|
+
per non-blank line, in the order Git printed them. The format string
|
|
1074
|
+
always emits exactly 4 tab-separated fields; any other count is a fully
|
|
1075
|
+
unreadable record (H-1) -- not a partially-trusted one with a
|
|
1076
|
+
confirmed-absent upstream fabricated from a truncated read.
|
|
1077
|
+
|
|
1078
|
+
Splits on a literal "\\n" ONLY -- never `str.splitlines()` (see
|
|
1079
|
+
parse_worktree_inventory's docstring for the full hazard: Git accepts
|
|
1080
|
+
a branch name containing a Unicode line-boundary character such as
|
|
1081
|
+
U+2028 that `str.splitlines()` treats as a record separator but this
|
|
1082
|
+
format does not). Here a `splitlines()` split would truncate such a
|
|
1083
|
+
branch's for-each-ref line mid-name and parse the remainder as a
|
|
1084
|
+
fully-plausible SECOND record, carrying the real branch's own OID and
|
|
1085
|
+
a fabricated confirmed-absent upstream (independent review, second
|
|
1086
|
+
round follow-up, 2026-09-17) -- reopening H-1's exact hazard through a
|
|
1087
|
+
different door. Every field is `.strip()`-ed individually before use,
|
|
1088
|
+
so this parser is otherwise CRLF-tolerant without further changes."""
|
|
1089
|
+
out = []
|
|
1090
|
+
for raw in (for_each_ref_text or "").split("\n"):
|
|
1091
|
+
if not raw.strip():
|
|
1092
|
+
continue
|
|
1093
|
+
fields = raw.split("\t")
|
|
1094
|
+
name = (
|
|
1095
|
+
_strip_fixed_ref_prefix(fields[0], _REFS_HEADS_PREFIX)
|
|
1096
|
+
if fields and fields[0] else raw.strip()
|
|
1097
|
+
)
|
|
1098
|
+
if len(fields) != 4:
|
|
1099
|
+
out.append(BranchRefRecord(
|
|
1100
|
+
name=name, oid=UNREADABLE, upstream=UNREADABLE,
|
|
1101
|
+
upstream_gone=UNREADABLE, occupied_by=None, read_error=True,
|
|
1102
|
+
))
|
|
1103
|
+
continue
|
|
1104
|
+
_, oid_field, upstream_field, track_field = fields
|
|
1105
|
+
oid_field = oid_field.strip()
|
|
1106
|
+
oid = oid_field if _FULL_HEX_RE.match(oid_field) else UNREADABLE
|
|
1107
|
+
upstream_field = upstream_field.strip()
|
|
1108
|
+
upstream = (
|
|
1109
|
+
_strip_fixed_ref_prefix(upstream_field, (_REFS_REMOTES_PREFIX, _REFS_HEADS_PREFIX))
|
|
1110
|
+
if upstream_field else None
|
|
1111
|
+
)
|
|
1112
|
+
upstream_gone = "gone" in track_field.strip()
|
|
1113
|
+
out.append(BranchRefRecord(
|
|
1114
|
+
name=name, oid=oid, upstream=upstream, upstream_gone=upstream_gone,
|
|
1115
|
+
occupied_by=None, read_error=(oid is UNREADABLE),
|
|
1116
|
+
))
|
|
1117
|
+
return out
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
WorktreeInventoryRecord = namedtuple("WorktreeInventoryRecord", [
|
|
1121
|
+
"path", "oid", "branch", "is_main", "detached", "locked", "locked_reason",
|
|
1122
|
+
"bare", "prunable", "prunable_reason", "path_exists", "read_error",
|
|
1123
|
+
])
|
|
1124
|
+
# oid: full-length hex from the record's HEAD line, or UNREADABLE if that
|
|
1125
|
+
# line was missing or malformed -- a worktree whose HEAD cannot be read is
|
|
1126
|
+
# never silently treated as "no branch" or otherwise safe (AC-09, AC-11).
|
|
1127
|
+
# Always UNREADABLE for a `bare` record (a bare worktree has no single
|
|
1128
|
+
# checked-out commit and carries no HEAD line at all -- that absence is
|
|
1129
|
+
# expected, not a read error).
|
|
1130
|
+
# is_main: True for exactly the first record a parse call produces -- Git's
|
|
1131
|
+
# `worktree list` always lists the main worktree first (B-2) -- never
|
|
1132
|
+
# derived by comparing `path` against a caller-supplied root.
|
|
1133
|
+
# bare: True for a bare-repository worktree record (a `bare` porcelain
|
|
1134
|
+
# line, no HEAD/branch of its own). Never flagged as a read error merely
|
|
1135
|
+
# for lacking a HEAD line.
|
|
1136
|
+
# prunable / prunable_reason: True and Git's stated reason when Git itself
|
|
1137
|
+
# marked this record `prunable <reason>` -- an inventory that silently
|
|
1138
|
+
# drops this fact would hide exactly the "this worktree's gitdir points
|
|
1139
|
+
# nowhere real" signal an authoritative inventory exists to surface.
|
|
1140
|
+
# path_exists: computed via the injected path_exists_fn (production default
|
|
1141
|
+
# os.path.exists). AC-11: a missing path is surfaced as its own fact,
|
|
1142
|
+
# never auto-abandoned -- the caller decides what a missing path means.
|
|
1143
|
+
# read_error: True only when the HEAD line itself was missing/malformed for
|
|
1144
|
+
# a non-bare record; a missing path is NOT a read error (the record
|
|
1145
|
+
# parsed fine; the path just isn't there right now).
|
|
1146
|
+
|
|
1147
|
+
|
|
1148
|
+
def _strip_trailing_sep(path):
|
|
1149
|
+
"""Strip ALL trailing separators (M-9: a single-strip left a
|
|
1150
|
+
double-separated path like "/repo//" not fully normalized, which could
|
|
1151
|
+
under- or over-match in a caller's own comparison), while never
|
|
1152
|
+
stripping a bare root down to nothing -- "/" stays "/"."""
|
|
1153
|
+
if not path:
|
|
1154
|
+
return path
|
|
1155
|
+
stripped = path.rstrip("/\\")
|
|
1156
|
+
return stripped or path[0]
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _c_unquote(s):
|
|
1160
|
+
"""Undo Git's C-style quoting of a value that contains a raw newline or
|
|
1161
|
+
other special byte -- Git wraps such a value in double quotes with
|
|
1162
|
+
backslash/octal escapes when NOT using a NUL-delimited (`-z`) format
|
|
1163
|
+
(M-6). Only ever applied on the newline-delimited porcelain parser; the
|
|
1164
|
+
NUL-delimited parser already returns the raw bytes verbatim and must
|
|
1165
|
+
never go through this. A value not shaped like a quoted string is
|
|
1166
|
+
returned unchanged.
|
|
1167
|
+
|
|
1168
|
+
Builds a raw BYTE buffer and decodes it as UTF-8 once at the end,
|
|
1169
|
+
rather than converting each octal escape to a Unicode code point one at
|
|
1170
|
+
a time: a multi-byte UTF-8 character is written as one octal escape per
|
|
1171
|
+
RAW BYTE (e.g. "\\302\\265" is the two bytes 0xC2 0xB5, together
|
|
1172
|
+
spelling "µ"), and `chr()`-per-triplet would decode each byte as
|
|
1173
|
+
its own Latin-1-shaped code point instead, corrupting it into two
|
|
1174
|
+
mojibake characters."""
|
|
1175
|
+
if s is None or len(s) < 2 or not (s.startswith('"') and s.endswith('"')):
|
|
1176
|
+
return s
|
|
1177
|
+
inner = s[1:-1]
|
|
1178
|
+
out = bytearray()
|
|
1179
|
+
i = 0
|
|
1180
|
+
simple = {"n": b"\n", "t": b"\t", "r": b"\r", "\\": b"\\", '"': b'"'}
|
|
1181
|
+
while i < len(inner):
|
|
1182
|
+
ch = inner[i]
|
|
1183
|
+
if ch == "\\" and i + 1 < len(inner):
|
|
1184
|
+
nxt = inner[i + 1]
|
|
1185
|
+
octal = inner[i + 1:i + 4]
|
|
1186
|
+
if len(octal) == 3 and all(c in "01234567" for c in octal):
|
|
1187
|
+
out.append(int(octal, 8))
|
|
1188
|
+
i += 4
|
|
1189
|
+
continue
|
|
1190
|
+
if nxt in simple:
|
|
1191
|
+
out.extend(simple[nxt])
|
|
1192
|
+
i += 2
|
|
1193
|
+
continue
|
|
1194
|
+
out.extend(ch.encode("utf-8"))
|
|
1195
|
+
i += 1
|
|
1196
|
+
return out.decode("utf-8", errors="replace")
|
|
1197
|
+
|
|
1198
|
+
|
|
1199
|
+
def _new_worktree_record(path):
|
|
1200
|
+
return {
|
|
1201
|
+
"path": path, "head": None, "branch": None, "detached": False,
|
|
1202
|
+
"locked": False, "locked_reason": None, "head_seen": False,
|
|
1203
|
+
"bare": False, "prunable": False, "prunable_reason": None,
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
|
|
1207
|
+
def _apply_worktree_line(cur, line, unquote=False):
|
|
1208
|
+
"""Mutate `cur` (the dict form of an in-progress record) with one
|
|
1209
|
+
porcelain line's worth of fact. Shared between the newline-delimited
|
|
1210
|
+
and NUL-delimited parsers so the two formats can never silently drift
|
|
1211
|
+
apart in what they recognize -- `unquote` is the one deliberate,
|
|
1212
|
+
format-level difference (M-6): the newline-delimited format C-quotes a
|
|
1213
|
+
lock reason containing special bytes onto one line; the NUL-delimited
|
|
1214
|
+
format never does."""
|
|
1215
|
+
if line.startswith("HEAD "):
|
|
1216
|
+
cur["head"] = line[len("HEAD "):].strip() or None
|
|
1217
|
+
cur["head_seen"] = True
|
|
1218
|
+
elif line == "detached":
|
|
1219
|
+
cur["detached"] = True
|
|
1220
|
+
elif line.startswith("branch "):
|
|
1221
|
+
ref = line[len("branch "):].strip()
|
|
1222
|
+
cur["branch"] = ref[len("refs/heads/"):] if ref.startswith("refs/heads/") else ref
|
|
1223
|
+
elif line == "locked":
|
|
1224
|
+
cur["locked"] = True
|
|
1225
|
+
elif line.startswith("locked "):
|
|
1226
|
+
cur["locked"] = True
|
|
1227
|
+
reason = line[len("locked "):].strip() or None
|
|
1228
|
+
cur["locked_reason"] = _c_unquote(reason) if (unquote and reason) else reason
|
|
1229
|
+
elif line == "bare":
|
|
1230
|
+
cur["bare"] = True
|
|
1231
|
+
elif line == "prunable" or line.startswith("prunable "):
|
|
1232
|
+
cur["prunable"] = True
|
|
1233
|
+
cur["prunable_reason"] = line[len("prunable"):].strip() or None
|
|
1234
|
+
|
|
1235
|
+
|
|
1236
|
+
def _finish_worktree_record(cur, path_exists_fn):
|
|
1237
|
+
head = cur["head"]
|
|
1238
|
+
bare = cur["bare"]
|
|
1239
|
+
if bare:
|
|
1240
|
+
# A bare worktree record carries no HEAD line at all -- that is
|
|
1241
|
+
# Git's normal shape for it, never a read error (M-4).
|
|
1242
|
+
read_error = False
|
|
1243
|
+
oid = UNREADABLE
|
|
1244
|
+
else:
|
|
1245
|
+
# A genuine, complete Git record for a non-bare worktree always
|
|
1246
|
+
# states EITHER `branch <ref>` OR `detached` -- Git never omits
|
|
1247
|
+
# both. Neither present means the read was truncated or the format
|
|
1248
|
+
# drifted mid-record, not that this worktree legitimately has no
|
|
1249
|
+
# checkout state; treating it as fully readable would let
|
|
1250
|
+
# bind_branch_occupancy silently confirm every OTHER branch as
|
|
1251
|
+
# unoccupied from a list that might have concealed one of them
|
|
1252
|
+
# right here (CodeRabbit, 2026-09-17).
|
|
1253
|
+
incomplete_checkout_state = not cur["branch"] and not cur["detached"]
|
|
1254
|
+
read_error = (
|
|
1255
|
+
not cur["head_seen"] or head is None or not _FULL_HEX_RE.match(head or "")
|
|
1256
|
+
or incomplete_checkout_state
|
|
1257
|
+
)
|
|
1258
|
+
oid = head if (head and _FULL_HEX_RE.match(head)) else UNREADABLE
|
|
1259
|
+
return WorktreeInventoryRecord(
|
|
1260
|
+
path=cur["path"], oid=oid, branch=cur["branch"], is_main=False,
|
|
1261
|
+
detached=cur["detached"], locked=cur["locked"], locked_reason=cur["locked_reason"],
|
|
1262
|
+
bare=bare, prunable=cur["prunable"], prunable_reason=cur["prunable_reason"],
|
|
1263
|
+
path_exists=path_exists_fn(cur["path"]), read_error=read_error,
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
|
|
1267
|
+
def _flush_worktree_record(out, cur, path_exists_fn):
|
|
1268
|
+
"""Append the finished record for `cur`, marking it `is_main=True` iff
|
|
1269
|
+
it is the FIRST record either porcelain parser has produced so far
|
|
1270
|
+
(B-2) -- Git's own documented guarantee, shared here so the two parsers
|
|
1271
|
+
can never disagree about which record is main."""
|
|
1272
|
+
record = _finish_worktree_record(cur, path_exists_fn)
|
|
1273
|
+
if not out:
|
|
1274
|
+
record = record._replace(is_main=True)
|
|
1275
|
+
out.append(record)
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def parse_worktree_inventory(porcelain_text, path_exists_fn=None):
|
|
1279
|
+
"""Parse newline/blank-line-delimited `git worktree list --porcelain`
|
|
1280
|
+
output into a list of WorktreeInventoryRecord. See
|
|
1281
|
+
parse_worktree_inventory_nul for the `-z` (NUL-terminated) variant
|
|
1282
|
+
needed for a path that could itself contain a newline.
|
|
1283
|
+
|
|
1284
|
+
Splits on a literal "\\n" ONLY -- never `str.splitlines()`, which also
|
|
1285
|
+
splits on eight other Unicode line-boundary characters (vertical tab,
|
|
1286
|
+
form feed, FS/GS/RS, NEL, U+2028, U+2029) that Git treats as ordinary
|
|
1287
|
+
raw path bytes, never as record separators, in this porcelain format.
|
|
1288
|
+
A worktree path containing one of those bytes would otherwise be
|
|
1289
|
+
truncated mid-path and the remainder parsed as a second, PHANTOM
|
|
1290
|
+
worktree record with an attacker- or accident-controlled path and
|
|
1291
|
+
`read_error=False` (independent review, 2026-09-17) -- exactly the
|
|
1292
|
+
kind of confidently-wrong record `validate_resource_path`'s caller
|
|
1293
|
+
would trust as "one of the exact worktree paths a fresh inventory just
|
|
1294
|
+
reported".
|
|
1295
|
+
|
|
1296
|
+
Each line still has a trailing "\\r" stripped explicitly (a literal
|
|
1297
|
+
"\\n"-only split leaves one behind on CRLF input): without it, the
|
|
1298
|
+
bare-keyword lines `_apply_worktree_line` matches by exact equality
|
|
1299
|
+
(`detached`, `locked`, `bare`) would silently fail to match at all,
|
|
1300
|
+
which for `locked` is fail-UNSAFE -- a genuinely locked worktree would
|
|
1301
|
+
come back `locked=False, read_error=False` instead of retained
|
|
1302
|
+
(independent review follow-up, 2026-09-17)."""
|
|
1303
|
+
path_exists_fn = path_exists_fn or os.path.exists
|
|
1304
|
+
out = []
|
|
1305
|
+
cur = None
|
|
1306
|
+
for raw in (porcelain_text or "").split("\n"):
|
|
1307
|
+
line = raw.rstrip("\r\n")
|
|
1308
|
+
if not line.strip():
|
|
1309
|
+
if cur is not None:
|
|
1310
|
+
_flush_worktree_record(out, cur, path_exists_fn)
|
|
1311
|
+
cur = None
|
|
1312
|
+
continue
|
|
1313
|
+
if line.startswith("worktree "):
|
|
1314
|
+
if cur is not None:
|
|
1315
|
+
_flush_worktree_record(out, cur, path_exists_fn)
|
|
1316
|
+
cur = _new_worktree_record(line[len("worktree "):].strip())
|
|
1317
|
+
continue
|
|
1318
|
+
if cur is None:
|
|
1319
|
+
continue
|
|
1320
|
+
_apply_worktree_line(cur, line, unquote=True)
|
|
1321
|
+
if cur is not None:
|
|
1322
|
+
_flush_worktree_record(out, cur, path_exists_fn)
|
|
1323
|
+
return out
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def parse_worktree_inventory_nul(porcelain_z_text, path_exists_fn=None):
|
|
1327
|
+
"""Parse `git worktree list --porcelain -z` output: each LINE is
|
|
1328
|
+
NUL-terminated instead of newline-terminated, and a record ends at an
|
|
1329
|
+
EMPTY field (i.e. two consecutive NULs) instead of a blank line. This is
|
|
1330
|
+
the format that survives a worktree path containing a raw newline or
|
|
1331
|
+
unusual/non-ASCII bytes -- the ordinary newline-delimited porcelain
|
|
1332
|
+
format cannot represent that path unambiguously at all."""
|
|
1333
|
+
path_exists_fn = path_exists_fn or os.path.exists
|
|
1334
|
+
out = []
|
|
1335
|
+
cur = None
|
|
1336
|
+
if not porcelain_z_text:
|
|
1337
|
+
return out
|
|
1338
|
+
for field in porcelain_z_text.split("\0"):
|
|
1339
|
+
if field == "":
|
|
1340
|
+
if cur is not None:
|
|
1341
|
+
_flush_worktree_record(out, cur, path_exists_fn)
|
|
1342
|
+
cur = None
|
|
1343
|
+
continue
|
|
1344
|
+
if field.startswith("worktree "):
|
|
1345
|
+
if cur is not None:
|
|
1346
|
+
_flush_worktree_record(out, cur, path_exists_fn)
|
|
1347
|
+
cur = _new_worktree_record(field[len("worktree "):])
|
|
1348
|
+
continue
|
|
1349
|
+
if cur is None:
|
|
1350
|
+
continue
|
|
1351
|
+
_apply_worktree_line(cur, field, unquote=False)
|
|
1352
|
+
if cur is not None:
|
|
1353
|
+
_flush_worktree_record(out, cur, path_exists_fn)
|
|
1354
|
+
return out
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
def bind_branch_occupancy(branch_records, worktree_records):
|
|
1358
|
+
"""AC-09: return a new list of BranchRefRecord with `occupied_by` filled
|
|
1359
|
+
in from the REAL worktree list -- a branch is occupied iff some
|
|
1360
|
+
non-detached, readable worktree record's `branch` field names it
|
|
1361
|
+
exactly. Never mutates upstream_gone or any other field (AC-07:
|
|
1362
|
+
occupancy is a separate fact from delivery-proof eligibility).
|
|
1363
|
+
|
|
1364
|
+
`worktree_records` being `None` or `UNREADABLE` means the worktree list
|
|
1365
|
+
itself could not be obtained -- every branch's occupancy is then
|
|
1366
|
+
UNREADABLE (unknown), never silently defaulted to unoccupied (B-3): a
|
|
1367
|
+
guard keyed on "confirmed not checked out anywhere" must never receive
|
|
1368
|
+
that answer from a read that never actually happened. An explicit empty
|
|
1369
|
+
list is trusted as a genuinely complete (if unusual) worktree list.
|
|
1370
|
+
|
|
1371
|
+
A branch bound here as occupied by a worktree whose OWN `path_exists`
|
|
1372
|
+
is False is still reported occupied -- the fact is true (some record
|
|
1373
|
+
still names this branch) even though the path is currently missing; a
|
|
1374
|
+
caller that wants to treat "worktree gone from disk" differently from
|
|
1375
|
+
"worktree present and checked out" can cross-reference `path_exists` on
|
|
1376
|
+
the same worktree record this occupancy came from.
|
|
1377
|
+
|
|
1378
|
+
If ANY record in an otherwise-obtained list carries `read_error=True`,
|
|
1379
|
+
every branch that no CLEAN record actually claims is bound to
|
|
1380
|
+
UNREADABLE rather than None: a corrupted or truncated record's own
|
|
1381
|
+
branch identity is exactly the unknown quantity, so we cannot rule out
|
|
1382
|
+
that it was the very branch left unmatched. A branch a clean record DID
|
|
1383
|
+
claim stays confirmed occupied regardless -- a positive match from
|
|
1384
|
+
good data is trustworthy even alongside one bad record elsewhere in
|
|
1385
|
+
the same list (CodeRabbit, 2026-09-17)."""
|
|
1386
|
+
if worktree_records is None or worktree_records is UNREADABLE:
|
|
1387
|
+
return [b._replace(occupied_by=UNREADABLE) for b in (branch_records or [])]
|
|
1388
|
+
occupants = {}
|
|
1389
|
+
any_unreadable = False
|
|
1390
|
+
for wt in worktree_records:
|
|
1391
|
+
if wt.read_error:
|
|
1392
|
+
any_unreadable = True
|
|
1393
|
+
continue
|
|
1394
|
+
if wt.branch and not wt.detached:
|
|
1395
|
+
occupants.setdefault(wt.branch, wt.path)
|
|
1396
|
+
out = []
|
|
1397
|
+
for b in branch_records or []:
|
|
1398
|
+
if b.name in occupants:
|
|
1399
|
+
occupied = occupants[b.name]
|
|
1400
|
+
elif any_unreadable:
|
|
1401
|
+
occupied = UNREADABLE
|
|
1402
|
+
else:
|
|
1403
|
+
occupied = None
|
|
1404
|
+
out.append(b._replace(occupied_by=occupied))
|
|
1405
|
+
return out
|
|
1406
|
+
|
|
1407
|
+
|
|
1408
|
+
WorktreeLossSurface = namedtuple("WorktreeLossSurface", [
|
|
1409
|
+
"dirty", "untracked", "ignored_only", "locked", "is_current",
|
|
1410
|
+
"nested_repo", "read_error", "retain",
|
|
1411
|
+
])
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
def classify_worktree_loss_surface(status_text, is_locked, is_current_worktree,
|
|
1415
|
+
has_nested_repo, record_read_error=False):
|
|
1416
|
+
"""AC-11/AC-12: classify what a worktree actually holds BEFORE any
|
|
1417
|
+
directory removal is even proposed.
|
|
1418
|
+
|
|
1419
|
+
`status_text` is `git status --porcelain=v1 --ignored --untracked-
|
|
1420
|
+
files=all` run INSIDE the worktree, or `None`/`UNREADABLE` if that read
|
|
1421
|
+
failed. A failed read is NEVER folded into "no output means clean"
|
|
1422
|
+
(AC-05, AC-11) -- it retains the worktree and sets `read_error=True`.
|
|
1423
|
+
The same applies if `is_locked`, `is_current_worktree`, or
|
|
1424
|
+
`has_nested_repo` is itself `UNREADABLE` OR `None` (M-3: "unknown" must
|
|
1425
|
+
never silently coerce to "confirmed absent" in a boolean `or` chain --
|
|
1426
|
+
`None` is treated exactly like `UNREADABLE` here, since a caller that
|
|
1427
|
+
has not yet determined one of these signals has no principled way to
|
|
1428
|
+
spell "unknown" other than one of those two, and letting only one of
|
|
1429
|
+
them count would just move the coercion bug one call frame out), or if
|
|
1430
|
+
the caller passes `record_read_error=True` -- the inventory record this
|
|
1431
|
+
worktree came from was itself unreadable (H-5: a caller MUST be able to
|
|
1432
|
+
fold that fact in here, since this function is the only one that
|
|
1433
|
+
produces the retention verdict).
|
|
1434
|
+
|
|
1435
|
+
`retain` is True -- i.e. this worktree is NOT a disposal candidate
|
|
1436
|
+
without a separate, explicit named-loss decision (AC-12) -- if it is
|
|
1437
|
+
dirty, has ANY untracked content (ignored or not), is locked, is the
|
|
1438
|
+
currently executing worktree, contains a nested repository/unsupported
|
|
1439
|
+
submodule boundary, or any input above could not be determined at all.
|
|
1440
|
+
`retain` is False only when every one of those signals is affirmatively
|
|
1441
|
+
absent, and is always a real bool (M-3), never a sentinel."""
|
|
1442
|
+
unknown_input = record_read_error or any(
|
|
1443
|
+
v is UNREADABLE or v is None
|
|
1444
|
+
for v in (is_locked, is_current_worktree, has_nested_repo)
|
|
1445
|
+
)
|
|
1446
|
+
if status_text is None or status_text is UNREADABLE or unknown_input:
|
|
1447
|
+
return WorktreeLossSurface(
|
|
1448
|
+
dirty=False, untracked=False, ignored_only=False, locked=is_locked,
|
|
1449
|
+
is_current=is_current_worktree, nested_repo=has_nested_repo,
|
|
1450
|
+
read_error=True, retain=True,
|
|
1451
|
+
)
|
|
1452
|
+
dirty = False
|
|
1453
|
+
untracked = False
|
|
1454
|
+
has_ignored = False
|
|
1455
|
+
for raw in status_text.splitlines():
|
|
1456
|
+
if not raw.strip():
|
|
1457
|
+
continue
|
|
1458
|
+
code = raw[:2]
|
|
1459
|
+
if code == "!!":
|
|
1460
|
+
has_ignored = True
|
|
1461
|
+
elif code == "??":
|
|
1462
|
+
untracked = True
|
|
1463
|
+
else:
|
|
1464
|
+
dirty = True
|
|
1465
|
+
# M-1: "ignored_only" means what it says -- ignored content coexisting
|
|
1466
|
+
# with dirty/untracked content is not "only" ignored content.
|
|
1467
|
+
ignored_only = has_ignored and not dirty and not untracked
|
|
1468
|
+
# The unknown_input check above already refused every UNREADABLE-or-None
|
|
1469
|
+
# input via an early return, but it does NOT refuse an arbitrary other
|
|
1470
|
+
# non-bool truthy/falsy value a careless caller might pass for is_locked/
|
|
1471
|
+
# is_current_worktree/has_nested_repo (e.g. 0/1) -- wrap explicitly so
|
|
1472
|
+
# `retain` is always a real bool (M-3), never whatever type happened to
|
|
1473
|
+
# be the last truthy/falsy operand in the `or` chain (independent
|
|
1474
|
+
# review follow-up, 2026-09-17).
|
|
1475
|
+
retain = bool(dirty or untracked or has_ignored or is_locked or is_current_worktree or has_nested_repo)
|
|
1476
|
+
return WorktreeLossSurface(
|
|
1477
|
+
dirty=dirty, untracked=untracked, ignored_only=ignored_only, locked=is_locked,
|
|
1478
|
+
is_current=is_current_worktree, nested_repo=has_nested_repo,
|
|
1479
|
+
read_error=False, retain=retain,
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
|
|
1483
|
+
def _is_filesystem_root(path):
|
|
1484
|
+
"""True iff `path` (already realpath-resolved and separator-stripped)
|
|
1485
|
+
IS a bare volume, filesystem, or UNC-share root -- "/", "C:",
|
|
1486
|
+
"\\\\server\\share" -- with no further path component. Guards
|
|
1487
|
+
validate_resource_path's allowlist against ever admitting "everything
|
|
1488
|
+
on this volume/share" as an authorized target (H-4).
|
|
1489
|
+
|
|
1490
|
+
Uses `ntpath` explicitly rather than `os.path`: a Windows drive letter
|
|
1491
|
+
or UNC prefix must be recognized the same way on every platform this
|
|
1492
|
+
module's tests run on (Linux, macOS, Windows) -- `os.path.splitdrive`
|
|
1493
|
+
is platform-native and never recognizes a Windows-shaped root at all on
|
|
1494
|
+
POSIX, which silently admitted "C:\\" and "\\\\server\\share" as valid
|
|
1495
|
+
(non-root) allowlist entries whenever this ran on a POSIX CI runner."""
|
|
1496
|
+
if not path:
|
|
1497
|
+
return False
|
|
1498
|
+
if path in ("/", "\\"):
|
|
1499
|
+
return True
|
|
1500
|
+
drive, tail = ntpath.splitdrive(path)
|
|
1501
|
+
return bool(drive) and tail.rstrip("/\\") == ""
|
|
1502
|
+
|
|
1503
|
+
|
|
1504
|
+
def validate_resource_path(path, allowed_paths, real_path_fn=None):
|
|
1505
|
+
"""AC-13: pure guard against an unsafe filesystem target -- BEFORE any
|
|
1506
|
+
directory removal is even proposed. `allowed_paths` is the CALLER'S own
|
|
1507
|
+
authorized target set -- e.g. the exact worktree paths a fresh
|
|
1508
|
+
inventory just reported -- never a "nested under some root" rule (H-2):
|
|
1509
|
+
Git worktrees are conventionally SIBLINGS of the repository root, not
|
|
1510
|
+
descendants of it, so a nesting rule refuses every real worktree this
|
|
1511
|
+
guard exists to let a caller remove.
|
|
1512
|
+
|
|
1513
|
+
Returns `(True, resolved_path)` only when `path` resolves (via
|
|
1514
|
+
`real_path_fn`, production default os.path.realpath) to EXACTLY one
|
|
1515
|
+
entry of `allowed_paths` (also resolved, and compared with
|
|
1516
|
+
OS-appropriate case-folding via os.path.normcase) -- and that entry is
|
|
1517
|
+
not itself a bare volume/filesystem/UNC-share root (H-4). Returns
|
|
1518
|
+
`(False, reason)` for an empty/blank path, a resolution that raises, or
|
|
1519
|
+
a resolution matching no authorized target -- a resolution failure is
|
|
1520
|
+
refused, never defaulted to safe.
|
|
1521
|
+
|
|
1522
|
+
The RESOLVED path is returned specifically so a caller acts on what
|
|
1523
|
+
this guard actually validated (H-3) -- but this function proves nothing
|
|
1524
|
+
about what a symlink or junction resolves to a moment later. A caller
|
|
1525
|
+
MUST revalidate at the actual mutation seam (the same discipline
|
|
1526
|
+
execute_branch_deletion already applies to worktree occupancy), not
|
|
1527
|
+
cache this result across a delay."""
|
|
1528
|
+
real_path_fn = real_path_fn or os.path.realpath
|
|
1529
|
+
if not path or not path.strip():
|
|
1530
|
+
return (False, "path is empty")
|
|
1531
|
+
try:
|
|
1532
|
+
resolved = real_path_fn(path)
|
|
1533
|
+
except Exception as exc: # noqa: BLE001 -- any resolution failure is fail-safe refusal
|
|
1534
|
+
return (False, "path could not be resolved: %s" % exc)
|
|
1535
|
+
if not resolved:
|
|
1536
|
+
return (False, "path could not be resolved")
|
|
1537
|
+
resolved = _strip_trailing_sep(resolved)
|
|
1538
|
+
resolved_key = os.path.normcase(resolved)
|
|
1539
|
+
for allowed in (allowed_paths or []):
|
|
1540
|
+
if not allowed or not allowed.strip():
|
|
1541
|
+
continue
|
|
1542
|
+
try:
|
|
1543
|
+
resolved_allowed = real_path_fn(allowed)
|
|
1544
|
+
except Exception: # noqa: BLE001 -- an unresolvable allowlist entry is skipped, not fatal
|
|
1545
|
+
continue
|
|
1546
|
+
if not resolved_allowed:
|
|
1547
|
+
continue
|
|
1548
|
+
resolved_allowed = _strip_trailing_sep(resolved_allowed)
|
|
1549
|
+
if _is_filesystem_root(resolved_allowed):
|
|
1550
|
+
continue
|
|
1551
|
+
if os.path.normcase(resolved_allowed) == resolved_key:
|
|
1552
|
+
return (True, resolved)
|
|
1553
|
+
return (False, "path does not exactly match an authorized target")
|