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.
- worktree_controller/__init__.py +3 -0
- worktree_controller/activation_layout.py +483 -0
- worktree_controller/branch_retirement.py +1865 -0
- worktree_controller/canonical_refresh.py +150 -0
- worktree_controller/canonical_transition.py +324 -0
- worktree_controller/cleanup_attempts.py +442 -0
- worktree_controller/cleanup_evidence.py +75 -0
- worktree_controller/cleanup_recovery.py +912 -0
- worktree_controller/cli.py +1907 -0
- worktree_controller/controller.py +1974 -0
- worktree_controller/convergence.py +893 -0
- worktree_controller/delivered_closeout.py +928 -0
- worktree_controller/disposable.py +677 -0
- worktree_controller/environment.py +1038 -0
- worktree_controller/frozen_recovery.py +291 -0
- worktree_controller/git_backend.py +682 -0
- worktree_controller/governance.py +365 -0
- worktree_controller/hardlinks.py +294 -0
- worktree_controller/integration_cleanup.py +92 -0
- worktree_controller/integration_conflict.py +117 -0
- worktree_controller/integration_retry.py +526 -0
- worktree_controller/keyed_document_scope.py +162 -0
- worktree_controller/lifecycle.py +8992 -0
- worktree_controller/lifecycle_history.py +358 -0
- worktree_controller/lifecycle_observation.py +234 -0
- worktree_controller/metadata_codec.py +1694 -0
- worktree_controller/metadata_relocation.py +131 -0
- worktree_controller/models.py +546 -0
- worktree_controller/mount_diagnostics.py +324 -0
- worktree_controller/multiple_cleanup_recovery.py +730 -0
- worktree_controller/namespace_migration.py +1412 -0
- worktree_controller/native_session_recovery.py +171 -0
- worktree_controller/owner_actions.py +34 -0
- worktree_controller/policy.py +333 -0
- worktree_controller/preservation.py +983 -0
- worktree_controller/preserved_cleanup.py +849 -0
- worktree_controller/publication_execution.py +361 -0
- worktree_controller/reconciler.py +430 -0
- worktree_controller/recovery_guidance.py +205 -0
- worktree_controller/registration_backfill.py +166 -0
- worktree_controller/registration_prune.py +391 -0
- worktree_controller/rendering.py +179 -0
- worktree_controller/resource_admission.py +1736 -0
- worktree_controller/retirement_adapters.py +130 -0
- worktree_controller/retirement_execution.py +234 -0
- worktree_controller/semantics.py +459 -0
- worktree_controller/session.py +505 -0
- worktree_controller/session_admission.py +265 -0
- worktree_controller/source_cleanup.py +515 -0
- worktree_controller/source_preservation.py +692 -0
- worktree_controller/source_recovery.py +592 -0
- worktree_controller/store.py +1228 -0
- worktree_controller/tool_cli_contract.json +1791 -0
- worktree_controller/transaction_scope.py +876 -0
- worktree_controller/writer_lease.py +662 -0
- worktree_controller/writer_session.py +887 -0
- worktree_controller-1.30.4.dist-info/METADATA +3149 -0
- worktree_controller-1.30.4.dist-info/RECORD +61 -0
- worktree_controller-1.30.4.dist-info/WHEEL +4 -0
- worktree_controller-1.30.4.dist-info/entry_points.txt +5 -0
- 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
|