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,3 @@
1
+ """Worktree lifecycle controller."""
2
+
3
+ __version__ = "1.30.4"
@@ -0,0 +1,483 @@
1
+ """Reversible, explicit layout transition used only by governed activation.
2
+
3
+ The old layout is caller supplied. Core operations use only the current layout.
4
+ Repository locks and temporary refusal at the old entry points fence the switch;
5
+ allocation ownership and historical receipts are never rewritten.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import fcntl
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import stat
15
+ from contextlib import ExitStack
16
+ from pathlib import Path, PurePosixPath
17
+
18
+ from . import cleanup_evidence as evidence
19
+ from . import metadata_relocation as relocation
20
+ from . import preservation as p
21
+ from .models import ControllerError
22
+ from .store import MetadataStore, writer_lease_from_dict
23
+
24
+ REFUSAL = b"#!/bin/sh\necho 'Controller layout transition in progress; retry after activation.' >&2\nexit 75\n"
25
+
26
+
27
+ class LayoutError(Exception):
28
+ pass
29
+
30
+
31
+ def digest(raw: bytes) -> str:
32
+ return hashlib.sha256(raw).hexdigest()
33
+
34
+
35
+ def owned(path: Path, *, directory: bool = False) -> os.stat_result:
36
+ info = path.lstat()
37
+ kind = stat.S_ISDIR if directory else stat.S_ISREG
38
+ if info.st_uid != os.geteuid() or not kind(info.st_mode):
39
+ raise LayoutError(
40
+ f"layout target is not an owned {'directory' if directory else 'file'}: {path}"
41
+ )
42
+ return info
43
+
44
+
45
+ def relative(value: str) -> Path:
46
+ path = PurePosixPath(value)
47
+ if (
48
+ not path.parts
49
+ or path.is_absolute()
50
+ or str(path) != value
51
+ or any(x in {".", "..", ".git"} for x in path.parts)
52
+ or any(x in value for x in "\\*?[]\n\r\x00")
53
+ ):
54
+ raise LayoutError("layout paths must be exact repository-relative paths")
55
+ return Path(value)
56
+
57
+
58
+ def replace_file(path: Path, raw: bytes, mode: int = 0o600) -> None:
59
+ pending = path.with_name(f".{path.name}.layout-pending")
60
+ fd = os.open(pending, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, mode)
61
+ try:
62
+ with os.fdopen(fd, "wb") as stream:
63
+ stream.write(raw)
64
+ stream.flush()
65
+ os.fchmod(stream.fileno(), mode)
66
+ os.fsync(stream.fileno())
67
+ os.replace(pending, path)
68
+ sync_directory(path.parent)
69
+ finally:
70
+ if pending.exists():
71
+ pending.unlink()
72
+
73
+
74
+ def sync_directory(path: Path) -> None:
75
+ fd = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
76
+ try:
77
+ os.fsync(fd)
78
+ finally:
79
+ os.close(fd)
80
+
81
+
82
+ def _lease_bytes(raw: bytes, *, reverse: bool = False) -> bytes:
83
+ value = json.loads(raw)
84
+ if reverse:
85
+ writer_lease_from_dict(value)
86
+ value["schema_version"] = 1
87
+ value["holder"]["provider"] = value["holder"].pop("client_id")
88
+ else:
89
+ if (
90
+ type(value.get("schema_version")) is not int
91
+ or value["schema_version"] != 1
92
+ or "client_id" in value.get("holder", {})
93
+ ):
94
+ raise LayoutError("previous writer lease does not match schema 1")
95
+ value["schema_version"] = 2
96
+ value["holder"]["client_id"] = value["holder"].pop("provider")
97
+ writer_lease_from_dict(value)
98
+ return (json.dumps(value, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode()
99
+
100
+
101
+ def lease_bytes(raw: bytes, *, reverse: bool = False) -> bytes:
102
+ try:
103
+ return _lease_bytes(raw, reverse=reverse)
104
+ except (ControllerError, ValueError, TypeError, KeyError, AttributeError) as exc:
105
+ raise LayoutError("writer lease does not match the exact migration schema") from exc
106
+
107
+
108
+ def quiet(root: Path, repository: Path) -> list[dict]:
109
+ if (root / "publication-lease.json").exists():
110
+ raise LayoutError(f"publication lease blocks layout migration: {root}")
111
+ compatible = []
112
+ try:
113
+ from .source_recovery import plan_reconciliation
114
+ from .transaction_scope import is_atomic_temp, pending, terminal_state
115
+
116
+ # The activation preflight already serializes on this namespace's
117
+ # lock; classifying through a fresh instance must inherit that
118
+ # serialization instead of taking a second, self-deadlocking flock.
119
+ store = MetadataStore(root.parent.parent, _migration_root=root)
120
+ journals = (
121
+ [
122
+ path
123
+ for path in (root / "transactions").iterdir()
124
+ if not is_atomic_temp(path.name)
125
+ ]
126
+ if (root / "transactions").exists()
127
+ else []
128
+ )
129
+ if journals:
130
+ pending(store, assume_serialized=True) # Validate every identity, including retained terminal journals.
131
+ for path in sorted(journals):
132
+ state = terminal_state(
133
+ store, path.stem, verify_generations=True, assume_serialized=True
134
+ )
135
+ if state is not None:
136
+ compatible.append({"transaction_id": path.stem, "state": state})
137
+ continue
138
+ _, receipt_sha = evidence.read(store.completion_receipts_dir / path.name)
139
+ plan = plan_reconciliation(repository, path.stem, receipt_sha, _migration_store=store)
140
+ compatible.append(
141
+ {
142
+ "transaction_id": path.stem,
143
+ "state": plan["state"],
144
+ "plan_id": plan["plan_id"],
145
+ "completion_receipt_sha256": receipt_sha,
146
+ }
147
+ )
148
+ except (ControllerError, OSError, ValueError, TypeError, KeyError, IndexError) as exc:
149
+ raise LayoutError(f"unfinished transaction blocks layout migration: {root}") from exc
150
+ live = set()
151
+ for path in (root / "records").glob("*.json"):
152
+ owned(path)
153
+ if json.loads(path.read_bytes()).get("lifecycle") != "RELEASED":
154
+ live.add(path.stem)
155
+ for path in (root / "completion-receipts").glob("*.json"):
156
+ owned(path)
157
+ raw = path.read_bytes()
158
+ value = json.loads(raw)
159
+ if value.get("allocation_id") in live and str(root).encode() in raw:
160
+ if any(row["transaction_id"] == path.stem for row in compatible):
161
+ continue # Exact supported evidence was verified above, not inferred from age.
162
+ raise LayoutError("live completion evidence still binds the previous layout")
163
+ return compatible
164
+
165
+
166
+ def lease_record(path: Path) -> dict[str, str]:
167
+ owned(path)
168
+ raw = path.read_bytes()
169
+ converted = lease_bytes(raw)
170
+ if json.loads(converted)["allocation_id"] != path.stem:
171
+ raise LayoutError("writer lease filename does not match its allocation")
172
+ return {"before": raw.hex(), "after": converted.hex()}
173
+
174
+
175
+ def refuse_running_entries(names: set[str]) -> None:
176
+ for process in Path("/proc").iterdir():
177
+ if not process.name.isdecimal():
178
+ continue
179
+ try:
180
+ if (
181
+ process.stat().st_uid == os.geteuid()
182
+ and (process / "comm").read_text().strip() in names
183
+ ):
184
+ raise LayoutError("Controller invocation is still running")
185
+ except (FileNotFoundError, ProcessLookupError):
186
+ continue
187
+
188
+
189
+ class LayoutMigration:
190
+ """One deployment receipt, not a new runtime authority or adapter."""
191
+
192
+ def __init__(self, receipt: Path, document: dict):
193
+ self.receipt = receipt
194
+ self.document = document
195
+
196
+ def save(self) -> None:
197
+ replace_file(
198
+ self.receipt, (json.dumps(self.document, indent=2, sort_keys=True) + "\n").encode()
199
+ )
200
+
201
+ @classmethod
202
+ def prepare(
203
+ cls,
204
+ receipt: Path,
205
+ repositories: dict[Path, Path],
206
+ *,
207
+ previous_namespace: str,
208
+ previous_policy: str,
209
+ previous_entries: dict[Path, str],
210
+ entries: dict[Path, bytes],
211
+ runtimes: dict[str, str],
212
+ ) -> LayoutMigration:
213
+ namespace, policy = relative(previous_namespace), relative(previous_policy)
214
+ if len(namespace.parts) != 1 or not previous_entries:
215
+ raise LayoutError("one previous namespace and exact previous entry points are required")
216
+ if receipt.exists() or receipt.parent.exists():
217
+ raise LayoutError("layout recovery directory already exists")
218
+ rows = []
219
+ for canonical, common in repositories.items():
220
+ old = common / namespace / "v1"
221
+ new = MetadataStore(common).root
222
+ source_policy = canonical / policy
223
+ target_policy = canonical / ".agents/worktree-policy.toml"
224
+ identity = owned(old, directory=True)
225
+ owned(old.parent, directory=True)
226
+ if os.path.lexists(old / relocation.RECORD):
227
+ raise LayoutError("previous layout already has relocation evidence")
228
+ owned(source_policy)
229
+ owned(source_policy.parent, directory=True)
230
+ if (
231
+ new.exists()
232
+ or new.is_symlink()
233
+ or target_policy.exists()
234
+ or target_policy.is_symlink()
235
+ ):
236
+ raise LayoutError("current layout already exists; refusing overlapping authorities")
237
+ with MetadataStore(common, _migration_root=old).lock(exclusive=False):
238
+ compatibility = quiet(old, canonical)
239
+ references = relocation.prepare(old, new)
240
+ for path in (old / "writer-leases").glob("*.json"):
241
+ lease_record(path)
242
+ rows.append(
243
+ {
244
+ "canonical": str(canonical),
245
+ "old": str(old),
246
+ "new": str(new),
247
+ "identity": [identity.st_dev, identity.st_ino],
248
+ "old_policy": str(source_policy),
249
+ "new_policy": str(target_policy),
250
+ "policy_sha256": digest(source_policy.read_bytes()),
251
+ "leases": {},
252
+ "transaction_compatibility": compatibility,
253
+ "references": references,
254
+ }
255
+ )
256
+ files = []
257
+ for path in sorted(set(previous_entries) | set(entries)):
258
+ before = None
259
+ mode = 0o755
260
+ if path.exists() or path.is_symlink():
261
+ info = owned(path)
262
+ before = path.read_bytes()
263
+ mode = stat.S_IMODE(info.st_mode)
264
+ if path in previous_entries:
265
+ if before is None or digest(before) != previous_entries[path]:
266
+ raise LayoutError(f"previous entry point drifted: {path}")
267
+ elif before is not None and before != entries[path]:
268
+ raise LayoutError(f"new entry point already has other content: {path}")
269
+ files.append(
270
+ {
271
+ "path": str(path),
272
+ "before": None if before is None else before.hex(),
273
+ "mode": mode,
274
+ "previous": path in previous_entries,
275
+ "after": entries[path].hex() if path in entries else REFUSAL.hex(),
276
+ }
277
+ )
278
+ receipt.parent.mkdir(mode=0o700)
279
+ migration = cls(
280
+ receipt,
281
+ {
282
+ "schema_version": 1,
283
+ "phase": "prepared",
284
+ "repositories": rows,
285
+ "entrypoints": files,
286
+ "runtimes": runtimes,
287
+ },
288
+ )
289
+ migration.save()
290
+ return migration
291
+
292
+ @classmethod
293
+ def load(cls, receipt: Path, expected_sha256: str) -> LayoutMigration:
294
+ owned(receipt)
295
+ raw = receipt.read_bytes()
296
+ if digest(raw) != expected_sha256:
297
+ raise LayoutError("layout receipt digest changed")
298
+ value = json.loads(raw)
299
+ if (
300
+ set(value) != {"schema_version", "phase", "repositories", "entrypoints", "runtimes"}
301
+ or value["schema_version"] != 1
302
+ ):
303
+ raise LayoutError("unsupported layout receipt")
304
+ return cls(receipt, value)
305
+
306
+ def apply(self) -> None:
307
+ if self.document["phase"] != "prepared":
308
+ raise LayoutError("layout receipt is not prepared")
309
+ with ExitStack() as stack:
310
+ for row in self.document["repositories"]:
311
+ old = Path(row["old"])
312
+ info = owned(old, directory=True)
313
+ if [info.st_dev, info.st_ino] != row["identity"]:
314
+ raise LayoutError("previous namespace identity changed")
315
+ owned(old / "repo.lock")
316
+ handle = stack.enter_context((old / "repo.lock").open("rb"))
317
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
318
+ if quiet(old, Path(row["canonical"])) != row["transaction_compatibility"]:
319
+ raise LayoutError("transaction compatibility changed before layout move")
320
+ if relocation.prepare(old, Path(row["new"])) != row["references"]:
321
+ raise LayoutError("immutable evidence changed before layout move")
322
+ owned(Path(row["old_policy"]))
323
+ if digest(Path(row["old_policy"]).read_bytes()) != row["policy_sha256"]:
324
+ raise LayoutError("previous policy changed")
325
+ row["leases"] = {
326
+ p.name: lease_record(p) for p in (old / "writer-leases").glob("*.json")
327
+ }
328
+ self.document["phase"] = "applying"
329
+ self.save()
330
+ for entry in self.document["entrypoints"]:
331
+ path = Path(entry["path"])
332
+ observed = path.read_bytes().hex() if path.exists() else None
333
+ if observed != entry["before"]:
334
+ raise LayoutError("entry point changed after layout preflight")
335
+ if entry["previous"]:
336
+ replace_file(path, REFUSAL, entry["mode"])
337
+ previous_names = {
338
+ Path(x["path"]).name[:15] for x in self.document["entrypoints"] if x["previous"]
339
+ }
340
+ refuse_running_entries(previous_names)
341
+ for row in self.document["repositories"]:
342
+ old, new = Path(row["old"]), Path(row["new"])
343
+ source_policy, target_policy = Path(row["old_policy"]), Path(row["new_policy"])
344
+ new.parent.mkdir(mode=0o700, exist_ok=True)
345
+ target_policy.parent.mkdir(mode=0o755, exist_ok=True)
346
+ owned(new.parent, directory=True)
347
+ owned(target_policy.parent, directory=True)
348
+ if (
349
+ new.exists()
350
+ or new.is_symlink()
351
+ or target_policy.exists()
352
+ or target_policy.is_symlink()
353
+ ):
354
+ raise LayoutError("current layout appeared after preflight")
355
+ os.rename(source_policy, target_policy)
356
+ sync_directory(source_policy.parent)
357
+ sync_directory(target_policy.parent)
358
+ # Travel with the directory rename: there is never a moved tree
359
+ # whose old immutable references lack their relocation evidence.
360
+ with p._absolute_directory(old) as chain:
361
+ p._write_once(chain[-1], relocation.RECORD, row["references"])
362
+ os.rename(old, new)
363
+ sync_directory(old.parent)
364
+ sync_directory(new.parent)
365
+ for name, content in row["leases"].items():
366
+ replace_file(new / "writer-leases" / name, bytes.fromhex(content["after"]))
367
+ for entry in self.document["entrypoints"]:
368
+ if entry["after"] != REFUSAL.hex():
369
+ replace_file(Path(entry["path"]), bytes.fromhex(entry["after"]), 0o755)
370
+ self.document["phase"] = "migrated"
371
+ self.save()
372
+
373
+ def rollback(self) -> None:
374
+ if self.document["phase"] not in {"prepared", "applying", "migrated", "activated"}:
375
+ raise LayoutError("layout receipt cannot be rolled back")
376
+ with ExitStack() as stack:
377
+ replacements = {}
378
+ for row in self.document["repositories"]:
379
+ old, new = Path(row["old"]), Path(row["new"])
380
+ if (old.exists() or old.is_symlink()) == (new.exists() or new.is_symlink()):
381
+ raise LayoutError("layout recovery has overlapping or missing namespaces")
382
+ current = new if new.exists() else old
383
+ info = owned(current, directory=True)
384
+ if [info.st_dev, info.st_ino] != row["identity"]:
385
+ raise LayoutError("namespace identity changed before rollback")
386
+ owned(current / "repo.lock")
387
+ handle = stack.enter_context((current / "repo.lock").open("rb"))
388
+ fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
389
+ quiet(current, Path(row["canonical"]))
390
+ if relocation.inventory(current) != row["references"]["files"]:
391
+ raise LayoutError("new or changed cleanup evidence blocks rollback")
392
+ if (current / relocation.RECORD).exists():
393
+ value, _ = evidence.read(current / relocation.RECORD)
394
+ if value != row["references"]:
395
+ raise LayoutError("relocation evidence changed before rollback")
396
+ old_policy, new_policy = Path(row["old_policy"]), Path(row["new_policy"])
397
+ if (old_policy.exists() or old_policy.is_symlink()) == (
398
+ new_policy.exists() or new_policy.is_symlink()
399
+ ):
400
+ raise LayoutError("layout recovery has overlapping or missing policies")
401
+ policy = new_policy if new_policy.exists() else old_policy
402
+ owned(policy)
403
+ if digest(policy.read_bytes()) != row["policy_sha256"]:
404
+ raise LayoutError("policy drift blocks rollback")
405
+ if current == new:
406
+ for path in (new / "writer-leases").glob("*.json"):
407
+ owned(path)
408
+ raw = path.read_bytes()
409
+ original = row["leases"].get(path.name)
410
+ if original and raw.hex() in {original["before"], original["after"]}:
411
+ replacements[path] = bytes.fromhex(original["before"])
412
+ else:
413
+ replacements[path] = lease_bytes(raw, reverse=True)
414
+ for entry in self.document["entrypoints"]:
415
+ path = Path(entry["path"])
416
+ if path.exists() or path.is_symlink():
417
+ owned(path)
418
+ raw = path.read_bytes().hex() if path.exists() else None
419
+ if raw not in {entry["before"], entry["after"], REFUSAL.hex()}:
420
+ raise LayoutError("entry point drift blocks rollback")
421
+ # New invocations must stop before their namespace moves back, too.
422
+ # A running command leaves the entries fenced for a later recovery retry.
423
+ current_entries = [
424
+ entry for entry in self.document["entrypoints"] if entry["after"] != REFUSAL.hex()
425
+ ]
426
+ if any(Path(row["new"]).exists() for row in self.document["repositories"]):
427
+ for entry in current_entries:
428
+ replace_file(Path(entry["path"]), REFUSAL, 0o755)
429
+ refuse_running_entries({Path(entry["path"]).name[:15] for entry in current_entries})
430
+ for path, raw in replacements.items():
431
+ replace_file(path, raw)
432
+ for row in reversed(self.document["repositories"]):
433
+ old, new = Path(row["old"]), Path(row["new"])
434
+ # Keep the reference map with the namespace through the inverse
435
+ # rename too. A failed move must remain readable for a retry.
436
+ if new.exists():
437
+ os.rename(new, old)
438
+ sync_directory(new.parent)
439
+ sync_directory(old.parent)
440
+ # Only this activation's additive record is removed on rollback.
441
+ # Original journals/receipts are moved back byte for byte.
442
+ with p._absolute_directory(old) as chain:
443
+ if relocation.RECORD in os.listdir(chain[-1].fd):
444
+ value, _ = relocation._document(chain[-1], relocation.RECORD)
445
+ if value != row["references"]:
446
+ raise LayoutError("relocation evidence changed before rollback")
447
+ os.unlink(relocation.RECORD, dir_fd=chain[-1].fd)
448
+ os.fsync(chain[-1].fd)
449
+ if Path(row["new_policy"]).exists():
450
+ os.rename(row["new_policy"], row["old_policy"])
451
+ sync_directory(Path(row["old_policy"]).parent)
452
+ sync_directory(Path(row["new_policy"]).parent)
453
+ for entry in self.document["entrypoints"]:
454
+ path = Path(entry["path"])
455
+ if entry["before"] is None:
456
+ if path.exists():
457
+ path.unlink()
458
+ sync_directory(path.parent)
459
+ else:
460
+ replace_file(path, bytes.fromhex(entry["before"]), entry["mode"])
461
+ self.document["phase"] = "rolled-back"
462
+ self.save()
463
+
464
+ def finish(self) -> None:
465
+ if self.document["phase"] != "activated":
466
+ raise LayoutError("only a verified activation can retire previous entries")
467
+ for entry in self.document["entrypoints"]:
468
+ path = Path(entry["path"])
469
+ owned(path)
470
+ if not path.exists() or digest(path.read_bytes()) != digest(
471
+ bytes.fromhex(entry["after"])
472
+ ):
473
+ raise LayoutError("entry point changed before retirement")
474
+ for entry in self.document["entrypoints"]:
475
+ if entry["previous"] and entry["after"] == REFUSAL.hex():
476
+ Path(entry["path"]).unlink()
477
+ sync_directory(Path(entry["path"]).parent)
478
+ for row in self.document["repositories"]:
479
+ for parent in (Path(row["old"]).parent, Path(row["old_policy"]).parent):
480
+ if parent.is_dir() and not any(parent.iterdir()):
481
+ parent.rmdir()
482
+ self.document["phase"] = "retired"
483
+ self.save()