@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.
Files changed (57) hide show
  1. package/README.md +1 -1
  2. package/package.json +2 -1
  3. package/plugins/ca-pi/CHANGELOG.md +159 -0
  4. package/plugins/ca-pi/SKILLS.md +1 -1
  5. package/plugins/ca-pi/SPRINT.md +40 -10
  6. package/plugins/ca-pi/agents/backend-author.md +1 -1
  7. package/plugins/ca-pi/agents/frontend-author.md +1 -1
  8. package/plugins/ca-pi/generated/command-catalog.json +1 -1
  9. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-amd64 +0 -0
  10. package/plugins/ca-pi/helpers/artifacts/ca-artifact-darwin-arm64 +0 -0
  11. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-amd64 +0 -0
  12. package/plugins/ca-pi/helpers/artifacts/ca-artifact-linux-arm64 +0 -0
  13. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-amd64.exe +0 -0
  14. package/plugins/ca-pi/helpers/artifacts/ca-artifact-windows-arm64.exe +0 -0
  15. package/plugins/ca-pi/helpers/artifacts/release.json +38 -0
  16. package/plugins/ca-pi/hooks/_approvallib.py +355 -0
  17. package/plugins/ca-pi/hooks/_artifactlib.py +855 -0
  18. package/plugins/ca-pi/hooks/_cleanuplib.py +1553 -0
  19. package/plugins/ca-pi/hooks/_hooklib.py +37 -13
  20. package/plugins/ca-pi/hooks/_host.py +1 -1
  21. package/plugins/ca-pi/hooks/_intentlib.py +31 -0
  22. package/plugins/ca-pi/hooks/_planfilelib.py +34 -1
  23. package/plugins/ca-pi/hooks/_protectedstatelib.py +11 -1
  24. package/plugins/ca-pi/hooks/_readinjectlib.py +120 -0
  25. package/plugins/ca-pi/hooks/_releaselib.py +933 -85
  26. package/plugins/ca-pi/hooks/_standuplib.py +38 -10
  27. package/plugins/ca-pi/hooks/doctor.py +103 -0
  28. package/plugins/ca-pi/hooks/hostapi.py +1 -1
  29. package/plugins/ca-pi/hooks/pi-bridge.py +5 -1
  30. package/plugins/ca-pi/hooks/prompt-submit.py +33 -10
  31. package/plugins/ca-pi/hooks/releasehash.py +46 -32
  32. package/plugins/ca-pi/includes/artifacts.md +166 -0
  33. package/plugins/ca-pi/includes/author-tdd-workflow.md +2 -1
  34. package/plugins/ca-pi/includes/farm.md +27 -4
  35. package/plugins/ca-pi/includes/routing-table.md +2 -2
  36. package/plugins/ca-pi/includes/verification-boundary.md +27 -0
  37. package/plugins/ca-pi/package.json +18 -0
  38. package/plugins/ca-pi/routines/INDEX.md +4 -4
  39. package/plugins/ca-pi/routines/brainstorming/SKILL.md +23 -4
  40. package/plugins/ca-pi/routines/commit-gate/SKILL.md +30 -5
  41. package/plugins/ca-pi/routines/executing-plans/SKILL.md +52 -7
  42. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +25 -3
  43. package/plugins/ca-pi/routines/refactor/SKILL.md +3 -2
  44. package/plugins/ca-pi/routines/release/SKILL.md +150 -44
  45. package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +53 -9
  46. package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +20 -3
  47. package/plugins/ca-pi/routines/tdd/SKILL.md +27 -5
  48. package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +11 -2
  49. package/plugins/ca-pi/routines/writing-plans/SKILL.md +39 -10
  50. package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +34 -13
  51. package/plugins/ca-pi/skills/ca-chore/SKILL.md +7 -3
  52. package/plugins/ca-pi/skills/ca-commit/SKILL.md +3 -2
  53. package/plugins/ca-pi/skills/ca-doctor/SKILL.md +14 -0
  54. package/plugins/ca-pi/skills/ca-feature/SKILL.md +42 -8
  55. package/plugins/ca-pi/skills/ca-refactor/SKILL.md +4 -2
  56. package/plugins/ca-pi/skills/ca-release/SKILL.md +7 -6
  57. 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")