worktree-controller 1.30.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. worktree_controller/__init__.py +3 -0
  2. worktree_controller/activation_layout.py +483 -0
  3. worktree_controller/branch_retirement.py +1865 -0
  4. worktree_controller/canonical_refresh.py +150 -0
  5. worktree_controller/canonical_transition.py +324 -0
  6. worktree_controller/cleanup_attempts.py +442 -0
  7. worktree_controller/cleanup_evidence.py +75 -0
  8. worktree_controller/cleanup_recovery.py +912 -0
  9. worktree_controller/cli.py +1907 -0
  10. worktree_controller/controller.py +1974 -0
  11. worktree_controller/convergence.py +893 -0
  12. worktree_controller/delivered_closeout.py +928 -0
  13. worktree_controller/disposable.py +677 -0
  14. worktree_controller/environment.py +1038 -0
  15. worktree_controller/frozen_recovery.py +291 -0
  16. worktree_controller/git_backend.py +682 -0
  17. worktree_controller/governance.py +365 -0
  18. worktree_controller/hardlinks.py +294 -0
  19. worktree_controller/integration_cleanup.py +92 -0
  20. worktree_controller/integration_conflict.py +117 -0
  21. worktree_controller/integration_retry.py +526 -0
  22. worktree_controller/keyed_document_scope.py +162 -0
  23. worktree_controller/lifecycle.py +8992 -0
  24. worktree_controller/lifecycle_history.py +358 -0
  25. worktree_controller/lifecycle_observation.py +234 -0
  26. worktree_controller/metadata_codec.py +1694 -0
  27. worktree_controller/metadata_relocation.py +131 -0
  28. worktree_controller/models.py +546 -0
  29. worktree_controller/mount_diagnostics.py +324 -0
  30. worktree_controller/multiple_cleanup_recovery.py +730 -0
  31. worktree_controller/namespace_migration.py +1412 -0
  32. worktree_controller/native_session_recovery.py +171 -0
  33. worktree_controller/owner_actions.py +34 -0
  34. worktree_controller/policy.py +333 -0
  35. worktree_controller/preservation.py +983 -0
  36. worktree_controller/preserved_cleanup.py +849 -0
  37. worktree_controller/publication_execution.py +361 -0
  38. worktree_controller/reconciler.py +430 -0
  39. worktree_controller/recovery_guidance.py +205 -0
  40. worktree_controller/registration_backfill.py +166 -0
  41. worktree_controller/registration_prune.py +391 -0
  42. worktree_controller/rendering.py +179 -0
  43. worktree_controller/resource_admission.py +1736 -0
  44. worktree_controller/retirement_adapters.py +130 -0
  45. worktree_controller/retirement_execution.py +234 -0
  46. worktree_controller/semantics.py +459 -0
  47. worktree_controller/session.py +505 -0
  48. worktree_controller/session_admission.py +265 -0
  49. worktree_controller/source_cleanup.py +515 -0
  50. worktree_controller/source_preservation.py +692 -0
  51. worktree_controller/source_recovery.py +592 -0
  52. worktree_controller/store.py +1228 -0
  53. worktree_controller/tool_cli_contract.json +1791 -0
  54. worktree_controller/transaction_scope.py +876 -0
  55. worktree_controller/writer_lease.py +662 -0
  56. worktree_controller/writer_session.py +887 -0
  57. worktree_controller-1.30.4.dist-info/METADATA +3149 -0
  58. worktree_controller-1.30.4.dist-info/RECORD +61 -0
  59. worktree_controller-1.30.4.dist-info/WHEEL +4 -0
  60. worktree_controller-1.30.4.dist-info/entry_points.txt +5 -0
  61. worktree_controller-1.30.4.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,166 @@
1
+ """Backfill registration identity for linked bindings written before 1.26.0.
2
+
3
+ Bindings created by older runtimes carry no ``worktree-registration/v1`` document,
4
+ so a later Git registration that reuses their administrative name would inherit
5
+ them. This reconciliation ties such a binding to its current registration only
6
+ when the live evidence still points to the allocation that created it; anything
7
+ else is reported and keeps its previous (legacy) meaning.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import hashlib
13
+ import json
14
+ import os
15
+ import stat
16
+ from datetime import datetime
17
+ from pathlib import Path
18
+ from typing import Any
19
+
20
+ from .controller import envelope
21
+ from .git_backend import inventory, is_ancestor
22
+ from .models import ControllerError
23
+ from .store import REGISTRATION_MARKER, MetadataStore
24
+
25
+ BACKFILL_CONTRACT = "binding-registration-backfill/v1"
26
+ # Acquire stamps created_at before ``git worktree add`` runs; allow for that gap
27
+ # but not for a registration created long after the allocation.
28
+ REGISTRATION_WINDOW_SECONDS = 900
29
+
30
+
31
+ def _digest(value: dict[str, Any]) -> str:
32
+ return hashlib.sha256(
33
+ json.dumps(value, sort_keys=True, separators=(",", ":")).encode()
34
+ ).hexdigest()
35
+
36
+
37
+ def _registered_at_ns(admin: Path) -> int | None:
38
+ """When Git created this registration: ``commondir`` is written once by add."""
39
+ try:
40
+ observed = os.lstat(admin / "commondir")
41
+ except OSError:
42
+ return None
43
+ return observed.st_mtime_ns if stat.S_ISREG(observed.st_mode) else None
44
+
45
+
46
+ def _pointer(admin: Path) -> str | None:
47
+ try:
48
+ return (admin / "gitdir").read_text(encoding="utf-8").strip()
49
+ except (OSError, UnicodeError):
50
+ return None
51
+
52
+
53
+ def _evaluate(store: MetadataStore, item: Any, record: Any) -> tuple[list[str], dict[str, Any]]:
54
+ blockers: list[str] = []
55
+ admin = item.git_dir
56
+ relative = admin.relative_to(store.common_dir).as_posix()
57
+ store._registration_admin(item.key, relative)
58
+ if admin.is_symlink() or not admin.is_dir():
59
+ blockers.append("ADMIN_DIR_UNAVAILABLE")
60
+ if item.prunable or not item.path.is_dir():
61
+ blockers.append("CHECKOUT_MISSING")
62
+ if os.path.lexists(admin / REGISTRATION_MARKER):
63
+ blockers.append("STALE_REGISTRATION_MARKER")
64
+ if _pointer(admin) != os.fspath(item.path / ".git"):
65
+ blockers.append("GIT_POINTER_MISMATCH")
66
+ registered_ns = _registered_at_ns(admin)
67
+ created = datetime.fromisoformat(record.created_at)
68
+ limit_ns = int(created.timestamp() + REGISTRATION_WINDOW_SECONDS) * 1_000_000_000
69
+ if registered_ns is None:
70
+ blockers.append("REGISTRATION_TIME_UNKNOWN")
71
+ elif registered_ns > limit_ns:
72
+ blockers.append("REGISTRATION_NEWER_THAN_ALLOCATION")
73
+ if not item.head:
74
+ blockers.append("HEAD_UNRESOLVED")
75
+ elif record.base_commit and not blockers:
76
+ try:
77
+ if not is_ancestor(item.path, record.base_commit, item.head):
78
+ blockers.append("BASE_NOT_IN_HISTORY")
79
+ except ControllerError:
80
+ blockers.append("BASE_NOT_IN_HISTORY")
81
+ evidence = {
82
+ "worktree_key": item.key,
83
+ "allocation_id": record.allocation_id,
84
+ "record_version": record.record_version,
85
+ "created_at": record.created_at,
86
+ "admin_dir": relative,
87
+ "path": os.fspath(item.path),
88
+ "head": item.head,
89
+ "branch": item.branch,
90
+ "registered_at_ns": registered_ns,
91
+ }
92
+ return blockers, evidence
93
+
94
+
95
+ def reconcile_binding_registrations(
96
+ repository_path: Path, *, dry_run: bool, expected_plan_id: str | None = None
97
+ ) -> dict[str, Any]:
98
+ store = MetadataStore(inventory(repository_path).common_dir)
99
+ with store.lock(exclusive=not dry_run):
100
+ repository = inventory(repository_path)
101
+ snapshot = store._load_unlocked()
102
+ registered = store._registrations()
103
+ candidates: list[dict[str, Any]] = []
104
+ refused: list[dict[str, Any]] = []
105
+ already = 0
106
+ live = {item.key: item for item in repository.worktrees if not item.canonical}
107
+ for key, allocation_id in sorted(snapshot.bindings.items()):
108
+ item = live.get(key)
109
+ if item is None:
110
+ continue # Canonical or orphan bindings: not a linked registration.
111
+ if key in registered:
112
+ already += 1
113
+ continue
114
+ record = snapshot.records.get(allocation_id)
115
+ if record is None:
116
+ refused.append(
117
+ {"worktree_key": key, "allocation_id": allocation_id,
118
+ "blockers": ["RECORD_MISSING"]}
119
+ )
120
+ continue
121
+ blockers, evidence = _evaluate(store, item, record)
122
+ if blockers:
123
+ refused.append({**evidence, "blockers": blockers})
124
+ else:
125
+ candidates.append(evidence)
126
+ plan = {
127
+ "contract": BACKFILL_CONTRACT,
128
+ "repository_id": snapshot.repository_id,
129
+ "candidates": candidates,
130
+ }
131
+ plan_id = _digest(plan)
132
+ decision: dict[str, Any] = {
133
+ "plan_id": plan_id,
134
+ "candidates": candidates,
135
+ "refused": refused,
136
+ "already_registered": already,
137
+ }
138
+ if dry_run:
139
+ decision["code"] = (
140
+ "BINDING_REGISTRATION_BACKFILL_READY" if candidates
141
+ else "BINDING_REGISTRATION_BACKFILL_NOTHING_TO_DO"
142
+ )
143
+ decision["dry_run"] = True
144
+ else:
145
+ if expected_plan_id != plan_id:
146
+ raise ControllerError(
147
+ "RESOURCE_PLAN_MISMATCH",
148
+ "registration backfill requires the exact current dry-run plan",
149
+ )
150
+ for candidate in candidates:
151
+ store._write_registration(
152
+ candidate["worktree_key"],
153
+ candidate["allocation_id"],
154
+ store.common_dir / candidate["admin_dir"],
155
+ )
156
+ if candidates:
157
+ store.touch_repository()
158
+ decision["code"] = (
159
+ "BINDING_REGISTRATIONS_BACKFILLED" if candidates
160
+ else "BINDING_REGISTRATION_BACKFILL_NOTHING_TO_DO"
161
+ )
162
+ decision["dry_run"] = False
163
+ decision["written"] = len(candidates)
164
+ payload = envelope("reconcile")
165
+ payload["decision"] = decision
166
+ return payload
@@ -0,0 +1,391 @@
1
+ """Owner-reviewed removal of a missing, no-change audit registration.
2
+
3
+ The checkout is already absent. This path removes only its stale Git registration;
4
+ it never uses ``--force`` and never treats a clean retained checkout as disposable.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import uuid
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .git_backend import (
17
+ git_write,
18
+ inventory,
19
+ is_ancestor,
20
+ public_refs,
21
+ worktree_content_fingerprint,
22
+ )
23
+ from .lifecycle import _updated_record
24
+ from .models import (
25
+ ControllerError,
26
+ MetadataRecord,
27
+ MetadataSnapshot,
28
+ RepositoryInventory,
29
+ WorktreeGitState,
30
+ WorktreePolicy,
31
+ )
32
+ from .store import MetadataStore, now_iso
33
+
34
+ CONTRACT = "registration-prune/v1"
35
+
36
+
37
+ def _path_absent(path: Path) -> bool:
38
+ try:
39
+ os.lstat(path)
40
+ except FileNotFoundError:
41
+ return True
42
+ except OSError as exc:
43
+ raise ControllerError("PATH_STATE_UNKNOWN", f"cannot inspect GC target: {path}") from exc
44
+ return False
45
+
46
+
47
+ def _plan_id(plan: dict[str, Any]) -> str:
48
+ encoded = json.dumps(plan, sort_keys=True, separators=(",", ":"))
49
+ return hashlib.sha256(encoded.encode("utf-8")).hexdigest()
50
+
51
+
52
+ def build_prune_plan(
53
+ repository: RepositoryInventory,
54
+ snapshot: MetadataSnapshot,
55
+ policy: WorktreePolicy,
56
+ item: WorktreeGitState,
57
+ record: MetadataRecord,
58
+ owner_release: str | None,
59
+ ) -> dict[str, Any]:
60
+ """Require exact missing-path, owner, no-change, and recoverable-ref evidence."""
61
+ if item.canonical or not item.prunable or item.locked or not item.detached or item.branch:
62
+ raise ControllerError(
63
+ "PRUNABLE_STATE_INVALID", "only detached, prunable linked audits qualify"
64
+ )
65
+ if not _path_absent(item.path):
66
+ raise ControllerError("PATH_REAPPEARED", "GC target is present")
67
+ if record.role != "audit" or record.lifecycle != "RETAINED":
68
+ raise ControllerError("LIFECYCLE_NOT_RELEASABLE", "only retained no-change audits qualify")
69
+ if owner_release is None or owner_release.strip() != record.owner:
70
+ raise ControllerError("ACTIVE_OWNER", "recorded owner must authorize registration pruning")
71
+ if (
72
+ record.base_commit != item.head
73
+ or record.task_commit is not None
74
+ or record.handoff_commit is not None
75
+ or record.finalizer_commit is not None
76
+ or record.finalizer_record_id is not None
77
+ ):
78
+ raise ControllerError("TASK_EVIDENCE_PRESENT", "audit may contain task work")
79
+ if record.external_bindings or record.disposable_resources:
80
+ raise ControllerError("EXTERNAL_BINDING", "audit has managed resources or bindings")
81
+ if (
82
+ snapshot.bindings.get(item.key) != record.allocation_id
83
+ or sum(allocation == record.allocation_id for allocation in snapshot.bindings.values()) != 1
84
+ ):
85
+ raise ControllerError("BINDING_DRIFT", "audit allocation must have one exact binding")
86
+ if record.allocation_id in snapshot.writer_leases or (
87
+ snapshot.publication_lease is not None
88
+ and snapshot.publication_lease.allocation_id == record.allocation_id
89
+ ):
90
+ raise ControllerError("LEASE_PRESENT", "an active lease blocks registration pruning")
91
+ if any(
92
+ record.allocation_id in intent.source_allocations
93
+ or intent.allocation_id == record.allocation_id
94
+ for intent in snapshot.integration_intents.values()
95
+ ) or any(
96
+ intent.allocation_id == record.allocation_id for intent in snapshot.release_intents.values()
97
+ ):
98
+ raise ControllerError("TASK_REFERENCE_PRESENT", "an intent still references the audit")
99
+ if snapshot.repository_id is None:
100
+ raise ControllerError("BLOCK_DRIFT", "repository metadata is missing")
101
+ preserving_ref = f"refs/heads/{policy.publication_branch}"
102
+ preserving_oid = public_refs(repository.canonical_path).get(preserving_ref)
103
+ if preserving_oid is None or not is_ancestor(
104
+ repository.canonical_path, item.head, preserving_oid
105
+ ):
106
+ raise ControllerError("MISSING_RETAINED_REF", "audit HEAD is not retained by publication")
107
+ plan = {
108
+ "contract": CONTRACT,
109
+ "repository_id": snapshot.repository_id,
110
+ "allocation_id": record.allocation_id,
111
+ "target_path": str(item.path),
112
+ "worktree_key": item.key,
113
+ "admin_path": str(item.git_dir),
114
+ "expected_head": item.head,
115
+ "expected_record_version": record.record_version,
116
+ "owner": record.owner,
117
+ "task_key": record.task_key,
118
+ "authority_key": record.authority_key,
119
+ "preserving_ref": preserving_ref,
120
+ "preserving_oid": preserving_oid,
121
+ }
122
+ return {**plan, "gc_plan_id": _plan_id(plan)}
123
+
124
+
125
+ def _journal_for_target(store: MetadataStore, target: Path) -> dict[str, Any] | None:
126
+ matches: list[dict[str, Any]] = []
127
+ for path in sorted(store.transactions_dir.glob("*.json")):
128
+ value = dict(store._read_json(path))
129
+ if value.get("command") == "registration-prune" and value.get("target_path") == str(target):
130
+ if value.get("transaction_id") != path.stem:
131
+ raise ControllerError("INVALID_TRANSACTION", "prune journal filename differs")
132
+ matches.append(value)
133
+ if len(matches) > 1:
134
+ raise ControllerError("INTERRUPTED_TRANSACTION", "multiple prune attempts need review")
135
+ return matches[0] if matches else None
136
+
137
+
138
+ def _validate_recovery(
139
+ repository: RepositoryInventory,
140
+ snapshot: MetadataSnapshot,
141
+ target: Path,
142
+ journal: dict[str, Any],
143
+ expected_gc_plan_id: str,
144
+ owner_release: str | None,
145
+ ) -> MetadataRecord:
146
+ allocation = journal.get("allocation_id")
147
+ record = snapshot.records.get(allocation)
148
+ if (
149
+ record is None
150
+ or journal.get("repository_id") != snapshot.repository_id
151
+ or journal.get("gc_plan_id") != expected_gc_plan_id
152
+ or journal.get("target_path") != str(target)
153
+ or journal.get("owner") != owner_release
154
+ or (journal.get("owner") != record.owner and record.lifecycle != "RELEASED")
155
+ or journal.get("task_key") != record.task_key
156
+ or journal.get("authority_key") != record.authority_key
157
+ or journal.get("expected_head") != record.base_commit
158
+ or not isinstance(journal.get("preserving_oid"), str)
159
+ or journal.get("phase") not in {"PREPARED", "WORKTREE_REMOVED", "RELEASED_RECORDED"}
160
+ or not isinstance(journal.get("expected_record_version"), int)
161
+ ):
162
+ raise ControllerError("GC_PLAN_DRIFT", "prune recovery identity changed")
163
+ if not _path_absent(target):
164
+ raise ControllerError("PATH_REAPPEARED", "GC target reappeared during recovery")
165
+ if record.record_version not in {
166
+ journal["expected_record_version"],
167
+ journal["expected_record_version"] + 1,
168
+ }:
169
+ raise ControllerError("GC_PLAN_DRIFT", "prune record version changed")
170
+ if record.lifecycle == "RELEASED" and (
171
+ record.record_version != journal["expected_record_version"] + 1
172
+ or record.owner != "unassigned"
173
+ ):
174
+ raise ControllerError("GC_PLAN_DRIFT", "released audit metadata differs from prune plan")
175
+ if not is_ancestor(
176
+ repository.canonical_path, journal["expected_head"], journal["preserving_oid"]
177
+ ):
178
+ raise ControllerError("MISSING_RETAINED_REF", "reviewed publication OID lost the audit")
179
+ if record.lifecycle != "RELEASED" and (
180
+ record.role != "audit"
181
+ or record.lifecycle != "RETAINED"
182
+ or record.task_commit is not None
183
+ or record.handoff_commit is not None
184
+ or record.finalizer_commit is not None
185
+ or record.finalizer_record_id is not None
186
+ or record.external_bindings
187
+ or record.disposable_resources
188
+ ):
189
+ raise ControllerError("GC_PLAN_DRIFT", "audit acquired task evidence during recovery")
190
+ preserving_oid = public_refs(repository.canonical_path).get(journal.get("preserving_ref"))
191
+ if preserving_oid is None or not is_ancestor(
192
+ repository.canonical_path, journal["expected_head"], preserving_oid
193
+ ):
194
+ raise ControllerError("MISSING_RETAINED_REF", "audit HEAD lost its retaining ref")
195
+ if record.allocation_id in snapshot.writer_leases or (
196
+ snapshot.publication_lease is not None
197
+ and snapshot.publication_lease.allocation_id == record.allocation_id
198
+ ):
199
+ raise ControllerError("LEASE_PRESENT", "a lease appeared during prune recovery")
200
+ if any(
201
+ (key == journal.get("worktree_key") and allocation != record.allocation_id)
202
+ or (key != journal.get("worktree_key") and allocation == record.allocation_id)
203
+ for key, allocation in snapshot.bindings.items()
204
+ ):
205
+ raise ControllerError("BINDING_DRIFT", "prune binding changed owner")
206
+ return record
207
+
208
+
209
+ def apply_prune(
210
+ repository_path: Path,
211
+ *,
212
+ target: Path,
213
+ expected_gc_plan_id: str,
214
+ owner_release: str | None,
215
+ ) -> dict[str, Any] | None:
216
+ """Return None for ordinary GC targets; otherwise apply or resume exact prune."""
217
+ target = target.expanduser().resolve(strict=False)
218
+ initial = inventory(repository_path)
219
+ store = MetadataStore(initial.common_dir)
220
+ with store.lock(exclusive=True):
221
+ repository = inventory(repository_path)
222
+ snapshot = store._load_unlocked()
223
+ item = next((row for row in repository.worktrees if row.path == target), None)
224
+ journal = _journal_for_target(store, target)
225
+ if journal is None and (item is None or not item.prunable):
226
+ return None
227
+ if journal is None:
228
+ if item is None:
229
+ raise ControllerError("GC_PLAN_DRIFT", "prunable registration disappeared")
230
+ record = snapshot.records.get(snapshot.bindings.get(item.key, ""))
231
+ if record is None:
232
+ raise ControllerError("METADATA_MISSING", "prunable audit lacks owner record")
233
+ from .policy import load_policy
234
+ from .transaction_scope import conflicts
235
+
236
+ plan = build_prune_plan(
237
+ repository,
238
+ snapshot,
239
+ load_policy(repository.canonical_path),
240
+ item,
241
+ record,
242
+ owner_release,
243
+ )
244
+ if plan["gc_plan_id"] != expected_gc_plan_id:
245
+ raise ControllerError("GC_PLAN_DRIFT", "registration prune plan changed")
246
+ if conflicts(store, allocation=record.allocation_id, target=target):
247
+ raise ControllerError(
248
+ "INTERRUPTED_TRANSACTION", "another mutation fences the audit"
249
+ )
250
+ journal = {
251
+ "schema_version": 4,
252
+ "transaction_id": str(uuid.uuid4()),
253
+ "repository_id": plan["repository_id"],
254
+ "command": "registration-prune",
255
+ "phase": "PREPARED",
256
+ "target_path": str(target),
257
+ "allocation_id": record.allocation_id,
258
+ "branch": None,
259
+ "expected_head": item.head,
260
+ "expected_record_version": record.record_version,
261
+ "worktree_key": item.key,
262
+ "admin_path": str(item.git_dir),
263
+ "owner": record.owner,
264
+ "task_key": record.task_key,
265
+ "authority_key": record.authority_key,
266
+ "preserving_ref": plan["preserving_ref"],
267
+ "preserving_oid": plan["preserving_oid"],
268
+ "gc_plan_id": expected_gc_plan_id,
269
+ "created_at": now_iso(),
270
+ "updated_at": now_iso(),
271
+ }
272
+ store.write_transaction(journal["transaction_id"], journal)
273
+ else:
274
+ if item is None:
275
+ from .transaction_scope import registration_prune_terminal
276
+
277
+ if registration_prune_terminal(store, journal["transaction_id"]):
278
+ raise ControllerError(
279
+ "GC_ALREADY_APPLIED", "registration prune receipt already closes this plan"
280
+ )
281
+ record = _validate_recovery(
282
+ repository, snapshot, target, journal, expected_gc_plan_id, owner_release
283
+ )
284
+ from .transaction_scope import conflicts
285
+
286
+ if any(
287
+ row.get("transaction_id") != journal["transaction_id"]
288
+ for row in conflicts(store, allocation=record.allocation_id, target=target)
289
+ ):
290
+ raise ControllerError(
291
+ "INTERRUPTED_TRANSACTION", "another mutation fences the audit"
292
+ )
293
+ if item is not None:
294
+ if (
295
+ not item.prunable
296
+ or item.key != journal.get("worktree_key")
297
+ or (
298
+ item.head != journal.get("expected_head")
299
+ or str(item.git_dir) != journal.get("admin_path")
300
+ )
301
+ ):
302
+ raise ControllerError("GC_PLAN_DRIFT", "registered audit identity changed")
303
+ from .policy import load_policy
304
+
305
+ plan = build_prune_plan(
306
+ repository,
307
+ snapshot,
308
+ load_policy(repository.canonical_path),
309
+ item,
310
+ record,
311
+ owner_release,
312
+ )
313
+ if plan["gc_plan_id"] != expected_gc_plan_id:
314
+ raise ControllerError(
315
+ "GC_PLAN_DRIFT", "registered audit changed during recovery"
316
+ )
317
+ elif os.path.lexists(journal["admin_path"]):
318
+ raise ControllerError("GC_PLAN_DRIFT", "Git admin registration remains uncertain")
319
+ transaction_id = journal["transaction_id"]
320
+ if item is not None:
321
+ refs_before = public_refs(repository.canonical_path)
322
+ others_before = worktree_content_fingerprint(
323
+ repository, exclude_keys=frozenset({item.key})
324
+ )
325
+ if not _path_absent(target):
326
+ raise ControllerError("PATH_REAPPEARED", "GC target reappeared before Git removal")
327
+ git_write(repository.canonical_path, ("worktree", "remove", str(target)))
328
+ after = inventory(repository_path)
329
+ if any(row.path == target for row in after.worktrees) or (
330
+ public_refs(after.canonical_path) != refs_before
331
+ or worktree_content_fingerprint(after) != others_before
332
+ ):
333
+ raise ControllerError(
334
+ "BLOCK_DRIFT", "registration prune changed another Git identity"
335
+ )
336
+ journal["phase"] = "WORKTREE_REMOVED"
337
+ journal["updated_at"] = now_iso()
338
+ store.write_transaction(transaction_id, journal)
339
+ elif journal["phase"] == "PREPARED":
340
+ journal["phase"] = "WORKTREE_REMOVED"
341
+ journal["updated_at"] = now_iso()
342
+ store.write_transaction(transaction_id, journal)
343
+ snapshot = store._load_unlocked()
344
+ record = snapshot.records[journal["allocation_id"]]
345
+ if record.record_version == journal["expected_record_version"]:
346
+ updated = _updated_record(
347
+ record, "REGISTRATION_PRUNED", lifecycle="RELEASED", owner="unassigned"
348
+ )
349
+ store.replace_record(updated)
350
+ elif record.record_version != journal["expected_record_version"] + 1 or (
351
+ record.lifecycle != "RELEASED" or record.owner != "unassigned"
352
+ ):
353
+ raise ControllerError("GC_PLAN_DRIFT", "prune metadata changed during recovery")
354
+ snapshot = store._load_unlocked()
355
+ if snapshot.bindings.get(journal["worktree_key"]) == journal["allocation_id"]:
356
+ store.remove_binding(journal["worktree_key"])
357
+ elif journal["worktree_key"] in snapshot.bindings:
358
+ raise ControllerError("BINDING_DRIFT", "prune binding changed allocation")
359
+ receipt = {
360
+ "contract": CONTRACT,
361
+ "receipt_id": transaction_id,
362
+ "repository_id": journal["repository_id"],
363
+ "allocation_id": journal["allocation_id"],
364
+ "target_path": str(target),
365
+ "worktree_key": journal["worktree_key"],
366
+ "expected_head": journal["expected_head"],
367
+ "expected_record_version": journal["expected_record_version"],
368
+ "released_record_version": journal["expected_record_version"] + 1,
369
+ "preserving_ref": journal["preserving_ref"],
370
+ "preserving_oid": journal["preserving_oid"],
371
+ "gc_plan_id": expected_gc_plan_id,
372
+ "result": "REGISTRATION_PRUNED",
373
+ "completed_at": now_iso(),
374
+ }
375
+ store.write_registration_prune_receipt(transaction_id, receipt)
376
+ journal["phase"] = "RELEASED_RECORDED"
377
+ journal["updated_at"] = now_iso()
378
+ store.write_transaction(transaction_id, journal)
379
+ from .controller import status
380
+
381
+ result = status(repository_path)
382
+ result["command"] = "gc-apply"
383
+ result["decision"] = {
384
+ "code": "REGISTRATION_PRUNED",
385
+ "allocation_id": journal["allocation_id"],
386
+ "gc_plan_id": expected_gc_plan_id,
387
+ "receipt_id": transaction_id,
388
+ "preserving_ref": journal["preserving_ref"],
389
+ "reason": "missing audit registration removed; commit retained by publication ref",
390
+ }
391
+ return result