@ikon85/agent-workflow-kit 0.36.5 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.agents/skills/orchestrate-wave/SKILL.md +20 -12
- package/.agents/skills/setup-workflow/SKILL.md +24 -3
- package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +29 -1
- package/.agents/skills/wrapup/SKILL.md +42 -9
- package/.claude/hooks/migration-snapshot-reminder.py +1 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +11 -11
- package/.claude/skills/setup-workflow/SKILL.md +24 -3
- package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +3 -2
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +29 -1
- package/.claude/skills/skill-manifest.json +1 -1
- package/.claude/skills/wrapup/SKILL.md +33 -10
- package/README.md +88 -1
- package/agent-workflow-kit.package.json +36 -28
- package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +88 -0
- package/docs/agents/workflow-capabilities.json +11 -0
- package/package.json +1 -1
- package/scripts/anchor_table.py +14 -8
- package/scripts/project-skill-extension.mjs +21 -2
- package/scripts/readiness.mjs +32 -4
- package/scripts/release-state.mjs +19 -9
- package/scripts/release-state.test.mjs +71 -8
- package/scripts/test_anchor_table.py +69 -0
- package/scripts/test_board_sync_create_idempotency.py +15 -2
- package/scripts/test_board_sync_wave_title.py +14 -2
- package/scripts/test_census_backstop.py +33 -0
- package/scripts/test_orchestrate_wave_contract.py +44 -0
- package/scripts/test_retro_wrapup_contract.py +19 -2
- package/scripts/test_worktree_wrapup_contract.py +1004 -3
- package/scripts/test_wrapup_land.py +428 -0
- package/scripts/workflow-advisories/core.py +44 -2
- package/scripts/worktree-lifecycle/README.md +126 -4
- package/scripts/worktree-lifecycle/capabilities.json +9 -1
- package/scripts/worktree-lifecycle/cleanup.py +143 -5
- package/scripts/worktree-lifecycle/core.py +1383 -20
- package/scripts/worktree-lifecycle/profile.py +40 -3
- package/scripts/worktree-lifecycle/session.py +1857 -0
- package/scripts/worktree-lifecycle/setup.py +15 -0
- package/scripts/wrapup-land.py +650 -27
- package/src/cli.mjs +60 -27
- package/src/lib/bundle.mjs +1 -0
- package/src/lib/manifest.mjs +173 -3
- package/src/lib/projectSkillExtension.mjs +78 -1
- package/src/lib/updateCandidate.mjs +3 -1
- package/src/lib/updateDecisions.mjs +2 -2
- package/src/lib/verifyUpdateCandidateProtocol.mjs +15 -0
|
@@ -0,0 +1,1857 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Claim-bound creation and teardown of one orchestration run's exact worktrees."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
import fcntl
|
|
10
|
+
from hashlib import sha256
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import stat
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from cleanup import pr_state
|
|
20
|
+
from core import (
|
|
21
|
+
LifecycleError,
|
|
22
|
+
capture_artifact_baseline,
|
|
23
|
+
bind_cleanup_scratch_evidence,
|
|
24
|
+
classify_cleanup,
|
|
25
|
+
collect_cleanup_facts,
|
|
26
|
+
contained_untracked_identity,
|
|
27
|
+
durable_atomic_json,
|
|
28
|
+
load_profile,
|
|
29
|
+
load_artifact_baseline,
|
|
30
|
+
main_worktree,
|
|
31
|
+
registered_worktrees,
|
|
32
|
+
remove_authorized_scratch,
|
|
33
|
+
remove_contained_untracked,
|
|
34
|
+
run,
|
|
35
|
+
verified_landing_scratch_evidence,
|
|
36
|
+
verified_worktree_root,
|
|
37
|
+
)
|
|
38
|
+
from setup import execute_step
|
|
39
|
+
|
|
40
|
+
SCHEMA_VERSION = 1
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def resolve_commit(repo: Path, rev: str) -> str | None:
|
|
44
|
+
result = run(
|
|
45
|
+
["git", "rev-parse", "--verify", f"{rev}^{{commit}}"],
|
|
46
|
+
cwd=repo,
|
|
47
|
+
check=False,
|
|
48
|
+
)
|
|
49
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_ref(repo: Path, ref: str) -> str | None:
|
|
53
|
+
result = run(["git", "rev-parse", "--verify", ref], cwd=repo, check=False)
|
|
54
|
+
return result.stdout.strip() if result.returncode == 0 else None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def active_claim(main: Path, anchor: str, owner: str) -> tuple[str, dict[str, Any]]:
|
|
58
|
+
if not anchor.isdigit() or int(anchor) <= 0:
|
|
59
|
+
raise LifecycleError("anchor must be a positive issue number")
|
|
60
|
+
if not owner.strip():
|
|
61
|
+
raise LifecycleError("owner must be non-empty")
|
|
62
|
+
ref = f"refs/tags/wave-active/{anchor}"
|
|
63
|
+
tag_oid = resolve_ref(main, ref)
|
|
64
|
+
if tag_oid is None:
|
|
65
|
+
raise LifecycleError(f"active wave claim is missing: {ref}")
|
|
66
|
+
kind = run(["git", "cat-file", "-t", tag_oid], cwd=main).stdout.strip()
|
|
67
|
+
if kind != "tag":
|
|
68
|
+
raise LifecycleError(f"active wave claim is not annotated: {ref}")
|
|
69
|
+
raw = run(["git", "cat-file", "-p", tag_oid], cwd=main).stdout
|
|
70
|
+
separator = raw.find("\n\n")
|
|
71
|
+
try:
|
|
72
|
+
claim = json.loads(raw[separator + 2:].strip()) if separator >= 0 else None
|
|
73
|
+
except json.JSONDecodeError as error:
|
|
74
|
+
raise LifecycleError("active wave claim has invalid ownership evidence") from error
|
|
75
|
+
if (
|
|
76
|
+
not isinstance(claim, dict)
|
|
77
|
+
or claim.get("contractVersion") != 1
|
|
78
|
+
or str(claim.get("anchor")) != anchor
|
|
79
|
+
or claim.get("owner") != owner
|
|
80
|
+
):
|
|
81
|
+
raise LifecycleError("active wave claim belongs to another or incoherent run")
|
|
82
|
+
return tag_oid, claim
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def common_git_dir(main: Path) -> Path:
|
|
86
|
+
value = run(
|
|
87
|
+
["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
88
|
+
cwd=main,
|
|
89
|
+
).stdout.strip()
|
|
90
|
+
return Path(value).resolve()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def receipt_path(main: Path, anchor: str, claim_oid: str) -> Path:
|
|
94
|
+
return common_git_dir(main) / "agent-workflow-kit" / "session-teardown" / (
|
|
95
|
+
f"{anchor}-{claim_oid}.json"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def ownership_proof_ref(claim_oid: str, branch: str) -> str:
|
|
100
|
+
branch_digest = sha256(branch.encode("utf-8")).hexdigest()
|
|
101
|
+
return (
|
|
102
|
+
"refs/agent-workflow-kit/session-owned/"
|
|
103
|
+
f"{claim_oid}/{branch_digest}"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def validated_proof_ref(receipt: dict[str, Any], entry: dict[str, Any]) -> str:
|
|
108
|
+
expected = ownership_proof_ref(receipt["claimOid"], entry["branch"])
|
|
109
|
+
if entry.get("proofRef") != expected:
|
|
110
|
+
raise LifecycleError("session ownership proof identity is incoherent")
|
|
111
|
+
return expected
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def acquire_owned_branch(
|
|
115
|
+
main: Path,
|
|
116
|
+
branch: str,
|
|
117
|
+
proof_ref: str,
|
|
118
|
+
target_oid: str,
|
|
119
|
+
claim_ref: str,
|
|
120
|
+
claim_oid: str,
|
|
121
|
+
) -> bool:
|
|
122
|
+
transaction = (
|
|
123
|
+
"start\n"
|
|
124
|
+
f"verify {claim_ref} {claim_oid}\n"
|
|
125
|
+
f"create refs/heads/{branch} {target_oid}\n"
|
|
126
|
+
f"create {proof_ref} {target_oid}\n"
|
|
127
|
+
"prepare\n"
|
|
128
|
+
"commit\n"
|
|
129
|
+
)
|
|
130
|
+
result = subprocess.run(
|
|
131
|
+
["git", "update-ref", "--stdin"],
|
|
132
|
+
cwd=main,
|
|
133
|
+
input=transaction,
|
|
134
|
+
capture_output=True,
|
|
135
|
+
text=True,
|
|
136
|
+
check=False,
|
|
137
|
+
)
|
|
138
|
+
return result.returncode == 0
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def delete_owned_refs_prepared(
|
|
142
|
+
main: Path,
|
|
143
|
+
branch: str,
|
|
144
|
+
proof_ref: str,
|
|
145
|
+
expected_oid: str,
|
|
146
|
+
proof_oid: str,
|
|
147
|
+
claim_ref: str,
|
|
148
|
+
claim_oid: str,
|
|
149
|
+
*,
|
|
150
|
+
remove_worktree: Path | None = None,
|
|
151
|
+
main_ref: str | None = None,
|
|
152
|
+
main_oid: str | None = None,
|
|
153
|
+
root_identity: tuple[int, int] | None = None,
|
|
154
|
+
) -> bool:
|
|
155
|
+
process = subprocess.Popen(
|
|
156
|
+
["git", "update-ref", "--stdin"],
|
|
157
|
+
cwd=main,
|
|
158
|
+
stdin=subprocess.PIPE,
|
|
159
|
+
stdout=subprocess.PIPE,
|
|
160
|
+
stderr=subprocess.PIPE,
|
|
161
|
+
text=True,
|
|
162
|
+
)
|
|
163
|
+
assert process.stdin is not None
|
|
164
|
+
assert process.stdout is not None
|
|
165
|
+
commands = [
|
|
166
|
+
"start",
|
|
167
|
+
f"verify {claim_ref} {claim_oid}",
|
|
168
|
+
]
|
|
169
|
+
if main_ref is not None and main_oid is not None:
|
|
170
|
+
commands.append(f"verify {main_ref} {main_oid}")
|
|
171
|
+
commands.extend([
|
|
172
|
+
f"delete refs/heads/{branch} {expected_oid}",
|
|
173
|
+
f"delete {proof_ref} {proof_oid}",
|
|
174
|
+
"prepare",
|
|
175
|
+
])
|
|
176
|
+
try:
|
|
177
|
+
process.stdin.write("\n".join(commands) + "\n")
|
|
178
|
+
process.stdin.flush()
|
|
179
|
+
responses = [process.stdout.readline().strip(), process.stdout.readline().strip()]
|
|
180
|
+
if responses != ["start: ok", "prepare: ok"]:
|
|
181
|
+
process.stdin.close()
|
|
182
|
+
process.wait()
|
|
183
|
+
return False
|
|
184
|
+
linked = worktree_branches(main)
|
|
185
|
+
if (
|
|
186
|
+
(remove_worktree is None and branch in linked)
|
|
187
|
+
or (
|
|
188
|
+
remove_worktree is not None
|
|
189
|
+
and linked.get(branch) != remove_worktree.resolve()
|
|
190
|
+
)
|
|
191
|
+
):
|
|
192
|
+
process.stdin.write("abort\n")
|
|
193
|
+
process.stdin.flush()
|
|
194
|
+
process.stdin.close()
|
|
195
|
+
process.wait()
|
|
196
|
+
return False
|
|
197
|
+
if remove_worktree is not None:
|
|
198
|
+
try:
|
|
199
|
+
metadata = remove_worktree.lstat()
|
|
200
|
+
except OSError:
|
|
201
|
+
metadata = None
|
|
202
|
+
if (
|
|
203
|
+
metadata is None
|
|
204
|
+
or not stat.S_ISDIR(metadata.st_mode)
|
|
205
|
+
or root_identity is None
|
|
206
|
+
or (metadata.st_dev, metadata.st_ino) != root_identity
|
|
207
|
+
):
|
|
208
|
+
process.stdin.write("abort\n")
|
|
209
|
+
process.stdin.flush()
|
|
210
|
+
process.stdin.close()
|
|
211
|
+
process.wait()
|
|
212
|
+
return False
|
|
213
|
+
removed = run(
|
|
214
|
+
["git", "worktree", "remove", str(remove_worktree)],
|
|
215
|
+
cwd=main,
|
|
216
|
+
check=False,
|
|
217
|
+
)
|
|
218
|
+
if removed.returncode != 0:
|
|
219
|
+
process.stdin.write("abort\n")
|
|
220
|
+
process.stdin.flush()
|
|
221
|
+
process.stdin.close()
|
|
222
|
+
process.wait()
|
|
223
|
+
return False
|
|
224
|
+
process.stdin.write("commit\n")
|
|
225
|
+
process.stdin.flush()
|
|
226
|
+
process.stdin.close()
|
|
227
|
+
return process.wait() == 0
|
|
228
|
+
except (BrokenPipeError, OSError):
|
|
229
|
+
if process.poll() is None:
|
|
230
|
+
process.kill()
|
|
231
|
+
process.wait()
|
|
232
|
+
return False
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
@contextmanager
|
|
236
|
+
def receipt_lock(main: Path, anchor: str):
|
|
237
|
+
directory = common_git_dir(main) / "agent-workflow-kit" / "session-teardown"
|
|
238
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
lock_path = directory / f"{anchor}.lock"
|
|
240
|
+
descriptor = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
|
|
241
|
+
try:
|
|
242
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
|
243
|
+
yield
|
|
244
|
+
finally:
|
|
245
|
+
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
|
246
|
+
os.close(descriptor)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def write_receipt(path: Path, receipt: dict[str, Any]) -> None:
|
|
250
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
durable_atomic_json(
|
|
252
|
+
path,
|
|
253
|
+
receipt,
|
|
254
|
+
label="write session teardown receipt",
|
|
255
|
+
mode=0o600,
|
|
256
|
+
sort_keys=True,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def read_receipt(
|
|
261
|
+
main: Path,
|
|
262
|
+
anchor: str,
|
|
263
|
+
owner: str,
|
|
264
|
+
) -> tuple[Path, dict[str, Any]]:
|
|
265
|
+
claim_oid, _ = active_claim(main, anchor, owner)
|
|
266
|
+
path = receipt_path(main, anchor, claim_oid)
|
|
267
|
+
try:
|
|
268
|
+
receipt = json.loads(path.read_text(encoding="utf-8"))
|
|
269
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
270
|
+
raise LifecycleError(f"cannot read this run's teardown receipt: {error}") from error
|
|
271
|
+
if (
|
|
272
|
+
not isinstance(receipt, dict)
|
|
273
|
+
or receipt.get("schemaVersion") != SCHEMA_VERSION
|
|
274
|
+
or receipt.get("anchor") != anchor
|
|
275
|
+
or receipt.get("owner") != owner
|
|
276
|
+
or receipt.get("claimOid") != claim_oid
|
|
277
|
+
or Path(str(receipt.get("repoRoot", ""))).resolve() != main.resolve()
|
|
278
|
+
or not isinstance(receipt.get("targets"), list)
|
|
279
|
+
):
|
|
280
|
+
raise LifecycleError("teardown receipt belongs to another or incoherent run")
|
|
281
|
+
return path, receipt
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def begin(main: Path, args: argparse.Namespace) -> dict[str, Any]:
|
|
285
|
+
claim_oid, _ = active_claim(main, args.anchor, args.owner)
|
|
286
|
+
base_oid = resolve_commit(main, args.base)
|
|
287
|
+
if base_oid is None:
|
|
288
|
+
raise LifecycleError(f"base is not resolvable: {args.base}")
|
|
289
|
+
path = receipt_path(main, args.anchor, claim_oid)
|
|
290
|
+
with receipt_lock(main, args.anchor):
|
|
291
|
+
if path.exists():
|
|
292
|
+
_, receipt = read_receipt(main, args.anchor, args.owner)
|
|
293
|
+
if receipt.get("baseOid") != base_oid:
|
|
294
|
+
raise LifecycleError("existing receipt has a different base OID")
|
|
295
|
+
return receipt_report(path, receipt)
|
|
296
|
+
receipt = {
|
|
297
|
+
"schemaVersion": SCHEMA_VERSION,
|
|
298
|
+
"anchor": args.anchor,
|
|
299
|
+
"owner": args.owner,
|
|
300
|
+
"claimOid": claim_oid,
|
|
301
|
+
"repoRoot": str(main.resolve()),
|
|
302
|
+
"baseRef": args.base,
|
|
303
|
+
"baseOid": base_oid,
|
|
304
|
+
"state": "open",
|
|
305
|
+
"targets": [],
|
|
306
|
+
}
|
|
307
|
+
write_receipt(path, receipt)
|
|
308
|
+
return receipt_report(path, receipt)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def profile_path(main: Path, value: str) -> Path:
|
|
312
|
+
path = Path(value)
|
|
313
|
+
return path.resolve() if path.is_absolute() else (main / path).resolve()
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def contained_target(main: Path, relative: Path) -> Path:
|
|
317
|
+
if relative.is_absolute() or not relative.parts or any(
|
|
318
|
+
part in {"", ".", ".."} for part in relative.parts
|
|
319
|
+
):
|
|
320
|
+
raise LifecycleError(f"worktree path escapes repository root: {relative}")
|
|
321
|
+
return main.resolve().joinpath(*relative.parts)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def create_target(main: Path, args: argparse.Namespace) -> dict[str, Any]:
|
|
325
|
+
with receipt_lock(main, args.anchor):
|
|
326
|
+
path, receipt = read_receipt(main, args.anchor, args.owner)
|
|
327
|
+
if receipt.get("state") != "open":
|
|
328
|
+
raise LifecycleError("teardown receipt is already sealed")
|
|
329
|
+
configured_path = profile_path(main, args.profile)
|
|
330
|
+
profile = load_profile(configured_path)
|
|
331
|
+
branch = profile.branch_name(args.issue, args.slug, args.branch_type)
|
|
332
|
+
if run(
|
|
333
|
+
["git", "check-ref-format", "--branch", branch],
|
|
334
|
+
cwd=main,
|
|
335
|
+
check=False,
|
|
336
|
+
).returncode != 0:
|
|
337
|
+
raise LifecycleError("generated session branch is not a valid branch name")
|
|
338
|
+
target = contained_target(
|
|
339
|
+
main,
|
|
340
|
+
profile.relative_path(args.issue, args.slug, args.branch_type),
|
|
341
|
+
)
|
|
342
|
+
if branch in profile.protected_branches:
|
|
343
|
+
raise LifecycleError(f"refusing protected session branch: {branch}")
|
|
344
|
+
if any(entry.get("branch") == branch or entry.get("worktree") == str(target)
|
|
345
|
+
for entry in receipt["targets"]):
|
|
346
|
+
raise LifecycleError("target is already present in this receipt")
|
|
347
|
+
if resolve_ref(main, f"refs/heads/{branch}") is not None:
|
|
348
|
+
raise LifecycleError(f"branch pre-existed this run: {branch}")
|
|
349
|
+
if os.path.lexists(target) or target in registered_worktrees(main):
|
|
350
|
+
raise LifecycleError(f"worktree path pre-existed this run: {target}")
|
|
351
|
+
target_base = (
|
|
352
|
+
resolve_commit(main, args.base) if args.base else receipt["baseOid"]
|
|
353
|
+
)
|
|
354
|
+
if target_base is None or not is_ancestor(main, receipt["baseOid"], target_base):
|
|
355
|
+
raise LifecycleError("target base is unresolved or incoherent with the receipt base")
|
|
356
|
+
proof_ref = ownership_proof_ref(receipt["claimOid"], branch)
|
|
357
|
+
if resolve_ref(main, proof_ref) is not None:
|
|
358
|
+
raise LifecycleError("session ownership proof pre-existed this run")
|
|
359
|
+
|
|
360
|
+
entry = {
|
|
361
|
+
"state": "provisional",
|
|
362
|
+
"acquisitionState": "pending",
|
|
363
|
+
"branch": branch,
|
|
364
|
+
"worktree": str(target),
|
|
365
|
+
"profile": str(configured_path),
|
|
366
|
+
"proofRef": proof_ref,
|
|
367
|
+
"createdOid": target_base,
|
|
368
|
+
"expectedOid": target_base,
|
|
369
|
+
"rootDevice": None,
|
|
370
|
+
"rootInode": None,
|
|
371
|
+
"artifactBaselineDigest": None,
|
|
372
|
+
"removed": False,
|
|
373
|
+
}
|
|
374
|
+
# Pre-journal the deterministic proof identity, then acquire the branch
|
|
375
|
+
# and proof ref in one Git ref transaction. The proof, rather than this
|
|
376
|
+
# provisional row alone, is the durable ownership authority.
|
|
377
|
+
receipt["targets"].append(entry)
|
|
378
|
+
write_receipt(path, receipt)
|
|
379
|
+
failure_class = "ref-acquisition"
|
|
380
|
+
try:
|
|
381
|
+
if not acquire_owned_branch(
|
|
382
|
+
main,
|
|
383
|
+
branch,
|
|
384
|
+
proof_ref,
|
|
385
|
+
target_base,
|
|
386
|
+
f"refs/tags/wave-active/{args.anchor}",
|
|
387
|
+
receipt["claimOid"],
|
|
388
|
+
):
|
|
389
|
+
entry["acquisitionState"] = "failed"
|
|
390
|
+
write_receipt(path, receipt)
|
|
391
|
+
raise LifecycleError("session branch ownership acquisition failed")
|
|
392
|
+
entry["acquisitionState"] = "acquired"
|
|
393
|
+
write_receipt(path, receipt)
|
|
394
|
+
failure_class = "root-preparation"
|
|
395
|
+
parent, prepared = prepare_target_root_nofollow(main, target)
|
|
396
|
+
entry.update({
|
|
397
|
+
"state": "worktree-pending",
|
|
398
|
+
"parentDevice": parent.st_dev,
|
|
399
|
+
"parentInode": parent.st_ino,
|
|
400
|
+
"rootDevice": prepared.st_dev,
|
|
401
|
+
"rootInode": prepared.st_ino,
|
|
402
|
+
})
|
|
403
|
+
write_receipt(path, receipt)
|
|
404
|
+
failure_class = "worktree-add"
|
|
405
|
+
verify_target_parent_nofollow(
|
|
406
|
+
main,
|
|
407
|
+
target,
|
|
408
|
+
(entry["parentDevice"], entry["parentInode"]),
|
|
409
|
+
)
|
|
410
|
+
run(
|
|
411
|
+
["git", "worktree", "add", str(target), branch],
|
|
412
|
+
cwd=main,
|
|
413
|
+
)
|
|
414
|
+
failure_class = "root-journal"
|
|
415
|
+
created_oid = resolve_ref(main, f"refs/heads/{branch}")
|
|
416
|
+
if created_oid != target_base:
|
|
417
|
+
raise LifecycleError("new session branch did not retain the recorded base OID")
|
|
418
|
+
metadata = verify_created_worktree_root(
|
|
419
|
+
main,
|
|
420
|
+
target,
|
|
421
|
+
branch,
|
|
422
|
+
expected_parent_identity=(
|
|
423
|
+
entry["parentDevice"], entry["parentInode"]
|
|
424
|
+
),
|
|
425
|
+
)
|
|
426
|
+
if (metadata.st_dev, metadata.st_ino) != (
|
|
427
|
+
entry["rootDevice"], entry["rootInode"]
|
|
428
|
+
):
|
|
429
|
+
raise LifecycleError("new session worktree root identity changed")
|
|
430
|
+
entry.update({
|
|
431
|
+
"state": "baseline-pending",
|
|
432
|
+
"rootDevice": metadata.st_dev,
|
|
433
|
+
"rootInode": metadata.st_ino,
|
|
434
|
+
})
|
|
435
|
+
write_receipt(path, receipt)
|
|
436
|
+
|
|
437
|
+
# This baseline must precede every project setup step: only its
|
|
438
|
+
# exact untracked delta can later be attributed to failed setup.
|
|
439
|
+
failure_class = "baseline-capture"
|
|
440
|
+
verify_created_worktree_root(
|
|
441
|
+
main,
|
|
442
|
+
target,
|
|
443
|
+
branch,
|
|
444
|
+
expected_identity=(entry["rootDevice"], entry["rootInode"]),
|
|
445
|
+
expected_parent_identity=(
|
|
446
|
+
entry["parentDevice"], entry["parentInode"]
|
|
447
|
+
),
|
|
448
|
+
)
|
|
449
|
+
artifact_baseline = capture_artifact_baseline(target)
|
|
450
|
+
if artifact_baseline.setup_head != created_oid:
|
|
451
|
+
raise LifecycleError(
|
|
452
|
+
"artifact provenance baseline does not match the created OID"
|
|
453
|
+
)
|
|
454
|
+
entry.update({
|
|
455
|
+
"state": "setting-up",
|
|
456
|
+
"artifactBaselineDigest": artifact_baseline.digest,
|
|
457
|
+
})
|
|
458
|
+
write_receipt(path, receipt)
|
|
459
|
+
failure_class = "setup-step"
|
|
460
|
+
for step in profile.setup_steps:
|
|
461
|
+
verify_created_worktree_root(
|
|
462
|
+
main,
|
|
463
|
+
target,
|
|
464
|
+
branch,
|
|
465
|
+
expected_identity=(entry["rootDevice"], entry["rootInode"]),
|
|
466
|
+
expected_parent_identity=(
|
|
467
|
+
entry["parentDevice"], entry["parentInode"]
|
|
468
|
+
),
|
|
469
|
+
)
|
|
470
|
+
execute_step(
|
|
471
|
+
step,
|
|
472
|
+
main=main,
|
|
473
|
+
worktree=target,
|
|
474
|
+
issue=args.issue,
|
|
475
|
+
branch=branch,
|
|
476
|
+
)
|
|
477
|
+
failure_class = "promotion"
|
|
478
|
+
verify_created_worktree_root(
|
|
479
|
+
main,
|
|
480
|
+
target,
|
|
481
|
+
branch,
|
|
482
|
+
expected_identity=(entry["rootDevice"], entry["rootInode"]),
|
|
483
|
+
expected_parent_identity=(
|
|
484
|
+
entry["parentDevice"], entry["parentInode"]
|
|
485
|
+
),
|
|
486
|
+
)
|
|
487
|
+
created_oid = resolve_ref(main, f"refs/heads/{branch}")
|
|
488
|
+
if created_oid != target_base:
|
|
489
|
+
raise LifecycleError("new session branch did not retain the recorded base OID")
|
|
490
|
+
metadata = target.lstat()
|
|
491
|
+
if (metadata.st_dev, metadata.st_ino) != (
|
|
492
|
+
entry["rootDevice"], entry["rootInode"]
|
|
493
|
+
):
|
|
494
|
+
raise LifecycleError("session worktree root changed during setup")
|
|
495
|
+
entry.update({
|
|
496
|
+
"state": "active",
|
|
497
|
+
"rootDevice": metadata.st_dev,
|
|
498
|
+
"rootInode": metadata.st_ino,
|
|
499
|
+
"expectedOid": None,
|
|
500
|
+
})
|
|
501
|
+
write_receipt(path, receipt)
|
|
502
|
+
except Exception as error:
|
|
503
|
+
entry["state"] = "recovery-pending"
|
|
504
|
+
entry.pop("failure", None)
|
|
505
|
+
entry["failureClass"] = failure_class
|
|
506
|
+
write_receipt(path, receipt)
|
|
507
|
+
try:
|
|
508
|
+
_capture_setup_created_evidence(main, target, entry)
|
|
509
|
+
except Exception:
|
|
510
|
+
entry["evidenceState"] = "pending"
|
|
511
|
+
entry["evidenceFailureClass"] = "identity-capture"
|
|
512
|
+
write_receipt(path, receipt)
|
|
513
|
+
try:
|
|
514
|
+
_recover_creation_entry(
|
|
515
|
+
main,
|
|
516
|
+
args,
|
|
517
|
+
path,
|
|
518
|
+
receipt,
|
|
519
|
+
entry,
|
|
520
|
+
archive_reason="automatic rollback after failed setup",
|
|
521
|
+
)
|
|
522
|
+
except Exception:
|
|
523
|
+
# Ownership and the bounded failure class are already durable.
|
|
524
|
+
# Never surface a setup command's exception/stdout/stderr.
|
|
525
|
+
pass
|
|
526
|
+
raise LifecycleError(
|
|
527
|
+
f"session creation failed ({failure_class}); recovery receipt retained"
|
|
528
|
+
) from error
|
|
529
|
+
return {"branch": branch, "worktree": str(target), "createdOid": created_oid}
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _capture_setup_created_evidence(
|
|
533
|
+
main: Path,
|
|
534
|
+
target: Path,
|
|
535
|
+
entry: dict[str, Any],
|
|
536
|
+
) -> None:
|
|
537
|
+
entry.pop("evidenceFailureClass", None)
|
|
538
|
+
if entry.get("rootDevice") is not None:
|
|
539
|
+
verify_created_worktree_root(
|
|
540
|
+
main,
|
|
541
|
+
target,
|
|
542
|
+
entry["branch"],
|
|
543
|
+
expected_identity=(entry["rootDevice"], entry["rootInode"]),
|
|
544
|
+
expected_parent_identity=(
|
|
545
|
+
entry["parentDevice"], entry["parentInode"]
|
|
546
|
+
),
|
|
547
|
+
)
|
|
548
|
+
if not target.exists() or not entry.get("artifactBaselineDigest"):
|
|
549
|
+
entry["setupCreatedFiles"] = []
|
|
550
|
+
entry["setupTrackedEvidence"] = _tracked_evidence(target) if target.exists() else None
|
|
551
|
+
entry["evidenceState"] = "complete"
|
|
552
|
+
return
|
|
553
|
+
baseline = load_artifact_baseline(target)
|
|
554
|
+
if baseline.digest != entry["artifactBaselineDigest"]:
|
|
555
|
+
raise LifecycleError("creation baseline changed before recovery journaling")
|
|
556
|
+
facts = collect_cleanup_facts(main, target, pr_state="none")
|
|
557
|
+
created = tuple(sorted(
|
|
558
|
+
set(facts.untracked_files).difference(baseline.initial_untracked_files)
|
|
559
|
+
))
|
|
560
|
+
tracked = _tracked_evidence(target)
|
|
561
|
+
entry["setupTrackedEvidence"] = tracked
|
|
562
|
+
entry["setupCreatedPaths"] = list(created)
|
|
563
|
+
entry["setupInventoryDigest"] = _inventory_digest(tracked, created)
|
|
564
|
+
entry["evidenceState"] = "pending"
|
|
565
|
+
with verified_worktree_root(
|
|
566
|
+
target,
|
|
567
|
+
entry["rootDevice"],
|
|
568
|
+
entry["rootInode"],
|
|
569
|
+
) as descriptor:
|
|
570
|
+
entry["setupCreatedFiles"] = [
|
|
571
|
+
contained_untracked_identity(descriptor, relative)
|
|
572
|
+
for relative in created
|
|
573
|
+
]
|
|
574
|
+
entry["evidenceState"] = "complete"
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def _tracked_evidence(target: Path) -> dict[str, Any]:
|
|
578
|
+
paths: set[str] = set()
|
|
579
|
+
digests: dict[str, str] = {}
|
|
580
|
+
for name, cached in (("worktree", False), ("index", True)):
|
|
581
|
+
diff_command = [
|
|
582
|
+
"git", "diff", "--binary", "--full-index", "--no-renames",
|
|
583
|
+
]
|
|
584
|
+
names_command = ["git", "diff", "--name-only", "-z", "--no-renames"]
|
|
585
|
+
if cached:
|
|
586
|
+
diff_command.insert(2, "--cached")
|
|
587
|
+
names_command.insert(2, "--cached")
|
|
588
|
+
diff = run(diff_command, cwd=target).stdout
|
|
589
|
+
names = run(names_command, cwd=target).stdout
|
|
590
|
+
values = [value for value in names.split("\0") if value]
|
|
591
|
+
if any(
|
|
592
|
+
Path(value).is_absolute() or ".." in Path(value).parts
|
|
593
|
+
for value in values
|
|
594
|
+
):
|
|
595
|
+
raise LifecycleError("tracked recovery path is unsafe")
|
|
596
|
+
paths.update(values)
|
|
597
|
+
digests[f"{name}DiffSha256"] = sha256(
|
|
598
|
+
diff.encode("utf-8", errors="surrogateescape")
|
|
599
|
+
).hexdigest()
|
|
600
|
+
return {"paths": sorted(paths), **digests}
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _inventory_digest(
|
|
604
|
+
tracked: dict[str, Any],
|
|
605
|
+
untracked_paths: tuple[str, ...] | list[str],
|
|
606
|
+
) -> str:
|
|
607
|
+
payload = json.dumps(
|
|
608
|
+
{"tracked": tracked, "untrackedPaths": list(untracked_paths)},
|
|
609
|
+
ensure_ascii=False,
|
|
610
|
+
separators=(",", ":"),
|
|
611
|
+
sort_keys=True,
|
|
612
|
+
).encode("utf-8")
|
|
613
|
+
return sha256(payload).hexdigest()
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
def _tracked_evidence_at_created_oid() -> dict[str, Any]:
|
|
617
|
+
empty = sha256(b"").hexdigest()
|
|
618
|
+
return {
|
|
619
|
+
"paths": [],
|
|
620
|
+
"worktreeDiffSha256": empty,
|
|
621
|
+
"indexDiffSha256": empty,
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _worktree_backlink_matches(main: Path, target: Path, branch: str) -> bool:
|
|
626
|
+
top = run(
|
|
627
|
+
["git", "rev-parse", "--show-toplevel"],
|
|
628
|
+
cwd=target,
|
|
629
|
+
check=False,
|
|
630
|
+
)
|
|
631
|
+
common = run(
|
|
632
|
+
["git", "rev-parse", "--path-format=absolute", "--git-common-dir"],
|
|
633
|
+
cwd=target,
|
|
634
|
+
check=False,
|
|
635
|
+
)
|
|
636
|
+
current = run(
|
|
637
|
+
["git", "branch", "--show-current"],
|
|
638
|
+
cwd=target,
|
|
639
|
+
check=False,
|
|
640
|
+
)
|
|
641
|
+
return (
|
|
642
|
+
top.returncode == 0
|
|
643
|
+
and Path(top.stdout.strip()).resolve() == target.resolve()
|
|
644
|
+
and common.returncode == 0
|
|
645
|
+
and Path(common.stdout.strip()).resolve() == common_git_dir(main)
|
|
646
|
+
and current.returncode == 0
|
|
647
|
+
and current.stdout.strip() == branch
|
|
648
|
+
)
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _archive_recovered_target(
|
|
652
|
+
path: Path,
|
|
653
|
+
receipt: dict[str, Any],
|
|
654
|
+
entry: dict[str, Any],
|
|
655
|
+
reason: str,
|
|
656
|
+
) -> None:
|
|
657
|
+
entry.pop("failure", None)
|
|
658
|
+
archived = {key: value for key, value in entry.items() if key != "failure"}
|
|
659
|
+
receipt.setdefault("recoveredTargets", []).append({
|
|
660
|
+
**archived,
|
|
661
|
+
"state": "recovered",
|
|
662
|
+
"recoveryReason": reason,
|
|
663
|
+
})
|
|
664
|
+
receipt["targets"].remove(entry)
|
|
665
|
+
write_receipt(path, receipt)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _revalidate_creation_safety(
|
|
669
|
+
main: Path,
|
|
670
|
+
args: argparse.Namespace,
|
|
671
|
+
path: Path,
|
|
672
|
+
receipt: dict[str, Any],
|
|
673
|
+
entry: dict[str, Any],
|
|
674
|
+
) -> None:
|
|
675
|
+
branch = entry["branch"]
|
|
676
|
+
expected_oid = entry["createdOid"]
|
|
677
|
+
proof_ref = validated_proof_ref(receipt, entry)
|
|
678
|
+
# The PR query is deliberately before the final receipt/claim read: a
|
|
679
|
+
# command that races either ownership input is detected before mutation.
|
|
680
|
+
if pr_state(args.gh_command, main, branch) == "open":
|
|
681
|
+
raise LifecycleError(f"open PR during creation recovery: {branch}")
|
|
682
|
+
current_path, current_receipt = read_receipt(main, args.anchor, args.owner)
|
|
683
|
+
if current_path != path or current_receipt != receipt:
|
|
684
|
+
raise LifecycleError("active claim or recovery receipt changed")
|
|
685
|
+
profile = load_profile(Path(entry["profile"]))
|
|
686
|
+
if branch in profile.protected_branches:
|
|
687
|
+
raise LifecycleError(f"protected branch during creation recovery: {branch}")
|
|
688
|
+
if resolve_ref(main, f"refs/heads/{branch}") != expected_oid:
|
|
689
|
+
raise LifecycleError(f"unexpected OID during creation recovery: {branch}")
|
|
690
|
+
if resolve_ref(main, proof_ref) != expected_oid:
|
|
691
|
+
raise LifecycleError(f"session ownership proof changed: {branch}")
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def _recover_creation_entry(
|
|
695
|
+
main: Path,
|
|
696
|
+
args: argparse.Namespace,
|
|
697
|
+
path: Path,
|
|
698
|
+
receipt: dict[str, Any],
|
|
699
|
+
entry: dict[str, Any],
|
|
700
|
+
*,
|
|
701
|
+
archive_reason: str,
|
|
702
|
+
) -> None:
|
|
703
|
+
current_path, current_receipt = read_receipt(main, args.anchor, args.owner)
|
|
704
|
+
if current_path != path or current_receipt != receipt:
|
|
705
|
+
raise LifecycleError("active claim or recovery receipt changed")
|
|
706
|
+
if entry.get("state") != "recovery-pending":
|
|
707
|
+
raise LifecycleError("target is not pending creation recovery")
|
|
708
|
+
|
|
709
|
+
branch = entry["branch"]
|
|
710
|
+
target = Path(entry["worktree"])
|
|
711
|
+
proof_ref = validated_proof_ref(receipt, entry)
|
|
712
|
+
|
|
713
|
+
current_oid = resolve_ref(main, f"refs/heads/{branch}")
|
|
714
|
+
expected_oid = entry["createdOid"]
|
|
715
|
+
proof_oid = resolve_ref(main, proof_ref)
|
|
716
|
+
linked = worktree_branches(main)
|
|
717
|
+
linked_path = linked.get(branch)
|
|
718
|
+
target_present = os.path.lexists(target)
|
|
719
|
+
if entry.get("acquisitionState") == "failed":
|
|
720
|
+
if proof_oid is not None:
|
|
721
|
+
raise LifecycleError(
|
|
722
|
+
f"failed acquisition has ambiguous ownership proof: {branch}"
|
|
723
|
+
)
|
|
724
|
+
# The atomic ref transaction is the sole ownership grant. Without its
|
|
725
|
+
# proof ref, every same-name branch/path/registration is foreign and
|
|
726
|
+
# must be ignored rather than turning the receipt into a liveness trap.
|
|
727
|
+
_archive_recovered_target(path, receipt, entry, archive_reason)
|
|
728
|
+
return
|
|
729
|
+
if proof_oid is None:
|
|
730
|
+
if target_present or linked_path is not None:
|
|
731
|
+
raise LifecycleError(
|
|
732
|
+
f"session ownership proof is missing while a worktree remains: {branch}"
|
|
733
|
+
)
|
|
734
|
+
# A failed transaction may leave a foreign branch at the intended
|
|
735
|
+
# name. It is never adopted or deleted without the proof ref.
|
|
736
|
+
_archive_recovered_target(path, receipt, entry, archive_reason)
|
|
737
|
+
return
|
|
738
|
+
if proof_oid != expected_oid:
|
|
739
|
+
raise LifecycleError(f"session ownership proof changed: {branch}")
|
|
740
|
+
if current_oid is not None and current_oid != expected_oid:
|
|
741
|
+
raise LifecycleError(
|
|
742
|
+
f"unexpected OID during creation recovery: {branch}: "
|
|
743
|
+
f"expected {expected_oid}, found {current_oid}"
|
|
744
|
+
)
|
|
745
|
+
if current_oid is None and (target_present or linked_path is not None):
|
|
746
|
+
raise LifecycleError(
|
|
747
|
+
f"session ref disappeared while its worktree remains: {branch}"
|
|
748
|
+
)
|
|
749
|
+
if current_oid is None and not target_present and linked_path is None:
|
|
750
|
+
raise LifecycleError(
|
|
751
|
+
f"session ref disappeared while its ownership proof remains: {branch}"
|
|
752
|
+
)
|
|
753
|
+
if current_oid == expected_oid and not target_present and linked_path is None:
|
|
754
|
+
_revalidate_creation_safety(main, args, path, receipt, entry)
|
|
755
|
+
if not delete_owned_refs_prepared(
|
|
756
|
+
main,
|
|
757
|
+
branch,
|
|
758
|
+
proof_ref,
|
|
759
|
+
expected_oid,
|
|
760
|
+
entry["createdOid"],
|
|
761
|
+
f"refs/tags/wave-active/{args.anchor}",
|
|
762
|
+
receipt["claimOid"],
|
|
763
|
+
):
|
|
764
|
+
raise LifecycleError(
|
|
765
|
+
f"compare-delete failed during branch-only creation recovery: {branch}"
|
|
766
|
+
)
|
|
767
|
+
_archive_recovered_target(path, receipt, entry, archive_reason)
|
|
768
|
+
return
|
|
769
|
+
if current_oid == expected_oid and target_present and linked_path is None:
|
|
770
|
+
metadata = target.lstat()
|
|
771
|
+
recorded_identity = (entry.get("rootDevice"), entry.get("rootInode"))
|
|
772
|
+
if (
|
|
773
|
+
not stat.S_ISDIR(metadata.st_mode)
|
|
774
|
+
or None in recorded_identity
|
|
775
|
+
or recorded_identity != (metadata.st_dev, metadata.st_ino)
|
|
776
|
+
):
|
|
777
|
+
raise LifecycleError(
|
|
778
|
+
f"unregistered prepared root identity changed: {branch}"
|
|
779
|
+
)
|
|
780
|
+
with verified_worktree_root(
|
|
781
|
+
target, entry["rootDevice"], entry["rootInode"]
|
|
782
|
+
) as descriptor:
|
|
783
|
+
if os.listdir(descriptor):
|
|
784
|
+
raise LifecycleError(
|
|
785
|
+
f"unregistered prepared root is not empty: {branch}"
|
|
786
|
+
)
|
|
787
|
+
_revalidate_creation_safety(main, args, path, receipt, entry)
|
|
788
|
+
try:
|
|
789
|
+
os.rmdir(target)
|
|
790
|
+
except OSError as error:
|
|
791
|
+
raise LifecycleError(
|
|
792
|
+
f"unregistered prepared root changed before removal: {branch}"
|
|
793
|
+
) from error
|
|
794
|
+
if not delete_owned_refs_prepared(
|
|
795
|
+
main,
|
|
796
|
+
branch,
|
|
797
|
+
proof_ref,
|
|
798
|
+
expected_oid,
|
|
799
|
+
entry["createdOid"],
|
|
800
|
+
f"refs/tags/wave-active/{args.anchor}",
|
|
801
|
+
receipt["claimOid"],
|
|
802
|
+
):
|
|
803
|
+
raise LifecycleError(
|
|
804
|
+
f"compare-delete failed after prepared-root recovery: {branch}"
|
|
805
|
+
)
|
|
806
|
+
_archive_recovered_target(path, receipt, entry, archive_reason)
|
|
807
|
+
return
|
|
808
|
+
if linked_path != target.resolve():
|
|
809
|
+
raise LifecycleError(
|
|
810
|
+
f"worktree registration changed during creation recovery: {branch}"
|
|
811
|
+
)
|
|
812
|
+
if not target_present:
|
|
813
|
+
raise LifecycleError(
|
|
814
|
+
f"worktree directory is missing while Git registration remains: {branch}"
|
|
815
|
+
)
|
|
816
|
+
if target.is_symlink() or not target.is_dir():
|
|
817
|
+
raise LifecycleError(
|
|
818
|
+
f"worktree root type changed during creation recovery: {branch}"
|
|
819
|
+
)
|
|
820
|
+
metadata = target.stat()
|
|
821
|
+
recorded_identity = (entry.get("rootDevice"), entry.get("rootInode"))
|
|
822
|
+
if None not in recorded_identity and recorded_identity != (
|
|
823
|
+
metadata.st_dev, metadata.st_ino
|
|
824
|
+
):
|
|
825
|
+
raise LifecycleError(
|
|
826
|
+
f"worktree root identity changed during creation recovery: {branch}"
|
|
827
|
+
)
|
|
828
|
+
if None in recorded_identity:
|
|
829
|
+
if (
|
|
830
|
+
not _worktree_backlink_matches(main, target, branch)
|
|
831
|
+
or collect_cleanup_facts(main, target, pr_state="none").tracked_files
|
|
832
|
+
or collect_cleanup_facts(main, target, pr_state="none").untracked_files
|
|
833
|
+
):
|
|
834
|
+
raise LifecycleError(
|
|
835
|
+
f"worktree creation identity was not journaled before recovery: {branch}"
|
|
836
|
+
)
|
|
837
|
+
entry["rootDevice"] = metadata.st_dev
|
|
838
|
+
entry["rootInode"] = metadata.st_ino
|
|
839
|
+
recorded_identity = (metadata.st_dev, metadata.st_ino)
|
|
840
|
+
write_receipt(path, receipt)
|
|
841
|
+
|
|
842
|
+
facts = collect_cleanup_facts(main, target, pr_state="none")
|
|
843
|
+
baseline_digest = entry.get("artifactBaselineDigest")
|
|
844
|
+
if not baseline_digest:
|
|
845
|
+
if facts.tracked_files or facts.untracked_files:
|
|
846
|
+
raise LifecycleError(
|
|
847
|
+
"creation baseline is missing while worktree changes remain"
|
|
848
|
+
)
|
|
849
|
+
setup_created: tuple[str, ...] = ()
|
|
850
|
+
tracked_evidence = _tracked_evidence(target)
|
|
851
|
+
else:
|
|
852
|
+
baseline = load_artifact_baseline(target)
|
|
853
|
+
if (
|
|
854
|
+
baseline.digest != baseline_digest
|
|
855
|
+
or baseline.setup_head != expected_oid
|
|
856
|
+
or (baseline.root_device, baseline.root_inode) != recorded_identity
|
|
857
|
+
):
|
|
858
|
+
raise LifecycleError("creation baseline changed or is incoherent")
|
|
859
|
+
initial = set(baseline.initial_untracked_files)
|
|
860
|
+
current = set(facts.untracked_files)
|
|
861
|
+
if not initial.issubset(current):
|
|
862
|
+
raise LifecycleError(
|
|
863
|
+
"creation baseline inventory changed before recovery"
|
|
864
|
+
)
|
|
865
|
+
evidence = entry.get("setupCreatedFiles")
|
|
866
|
+
tracked_evidence = entry.get("setupTrackedEvidence")
|
|
867
|
+
evidence_state = entry.get("evidenceState")
|
|
868
|
+
if evidence_state == "pending":
|
|
869
|
+
current_tracked = _tracked_evidence(target)
|
|
870
|
+
current_created = tuple(sorted(current.difference(initial)))
|
|
871
|
+
current_digest = _inventory_digest(current_tracked, current_created)
|
|
872
|
+
if not facts.tracked_files and not current_created:
|
|
873
|
+
entry["setupTrackedEvidence"] = current_tracked
|
|
874
|
+
entry["setupCreatedFiles"] = []
|
|
875
|
+
entry["setupCreatedPaths"] = []
|
|
876
|
+
entry["setupInventoryDigest"] = current_digest
|
|
877
|
+
entry["evidenceState"] = "complete"
|
|
878
|
+
entry.pop("evidenceFailureClass", None)
|
|
879
|
+
write_receipt(path, receipt)
|
|
880
|
+
evidence = []
|
|
881
|
+
tracked_evidence = current_tracked
|
|
882
|
+
elif current_digest == entry.get("setupInventoryDigest"):
|
|
883
|
+
_capture_setup_created_evidence(main, target, entry)
|
|
884
|
+
write_receipt(path, receipt)
|
|
885
|
+
evidence = entry.get("setupCreatedFiles")
|
|
886
|
+
tracked_evidence = entry.get("setupTrackedEvidence")
|
|
887
|
+
else:
|
|
888
|
+
raise LifecycleError(
|
|
889
|
+
"creation recovery evidence is pending and inventory changed"
|
|
890
|
+
)
|
|
891
|
+
if not isinstance(evidence, list) or not all(
|
|
892
|
+
isinstance(item, dict)
|
|
893
|
+
and isinstance(item.get("path"), str)
|
|
894
|
+
and item.get("kind") in {"regular", "symlink"}
|
|
895
|
+
for item in evidence
|
|
896
|
+
):
|
|
897
|
+
raise LifecycleError("setup-created file evidence is missing or incoherent")
|
|
898
|
+
if (
|
|
899
|
+
not isinstance(tracked_evidence, dict)
|
|
900
|
+
or not isinstance(tracked_evidence.get("paths"), list)
|
|
901
|
+
):
|
|
902
|
+
raise LifecycleError("setup-created tracked evidence is missing or incoherent")
|
|
903
|
+
current_tracked = _tracked_evidence(target)
|
|
904
|
+
clean_tracked = _tracked_evidence_at_created_oid()
|
|
905
|
+
if current_tracked != clean_tracked and current_tracked != tracked_evidence:
|
|
906
|
+
raise LifecycleError("tracked setup changes changed after failed setup")
|
|
907
|
+
evidenced_paths = {item["path"] for item in evidence}
|
|
908
|
+
unexpected = current.difference(initial).difference(evidenced_paths)
|
|
909
|
+
if unexpected:
|
|
910
|
+
raise LifecycleError(
|
|
911
|
+
"foreign untracked files appeared after failed setup: "
|
|
912
|
+
+ ", ".join(sorted(unexpected))
|
|
913
|
+
)
|
|
914
|
+
setup_created = tuple(sorted(current.intersection(evidenced_paths)))
|
|
915
|
+
if initial:
|
|
916
|
+
raise LifecycleError(
|
|
917
|
+
"pre-setup untracked files remain protected: "
|
|
918
|
+
+ ", ".join(sorted(initial))
|
|
919
|
+
)
|
|
920
|
+
|
|
921
|
+
if tracked_evidence != _tracked_evidence_at_created_oid():
|
|
922
|
+
_revalidate_creation_safety(main, args, path, receipt, entry)
|
|
923
|
+
current_tracked = _tracked_evidence(target)
|
|
924
|
+
if current_tracked == tracked_evidence:
|
|
925
|
+
paths = tracked_evidence["paths"]
|
|
926
|
+
if paths:
|
|
927
|
+
_revalidate_creation_safety(main, args, path, receipt, entry)
|
|
928
|
+
restored = run(
|
|
929
|
+
[
|
|
930
|
+
"git", "restore", f"--source={expected_oid}",
|
|
931
|
+
"--staged", "--worktree", "--", *paths,
|
|
932
|
+
],
|
|
933
|
+
cwd=target,
|
|
934
|
+
check=False,
|
|
935
|
+
)
|
|
936
|
+
if restored.returncode != 0:
|
|
937
|
+
raise LifecycleError(
|
|
938
|
+
"bounded tracked restoration failed during creation recovery"
|
|
939
|
+
)
|
|
940
|
+
elif current_tracked != _tracked_evidence_at_created_oid():
|
|
941
|
+
raise LifecycleError("tracked setup changes changed after failed setup")
|
|
942
|
+
_revalidate_creation_safety(main, args, path, receipt, entry)
|
|
943
|
+
with verified_worktree_root(
|
|
944
|
+
target,
|
|
945
|
+
entry["rootDevice"],
|
|
946
|
+
entry["rootInode"],
|
|
947
|
+
) as descriptor:
|
|
948
|
+
for relative in setup_created:
|
|
949
|
+
_revalidate_creation_safety(main, args, path, receipt, entry)
|
|
950
|
+
expected = next(
|
|
951
|
+
item for item in entry["setupCreatedFiles"]
|
|
952
|
+
if item["path"] == relative
|
|
953
|
+
)
|
|
954
|
+
remove_contained_untracked(descriptor, expected)
|
|
955
|
+
latest = collect_cleanup_facts(main, target, pr_state="none")
|
|
956
|
+
if latest.tracked_files or latest.untracked_files:
|
|
957
|
+
raise LifecycleError("creation recovery inventory changed before removal")
|
|
958
|
+
_revalidate_creation_safety(main, args, path, receipt, entry)
|
|
959
|
+
if not delete_owned_refs_prepared(
|
|
960
|
+
main,
|
|
961
|
+
branch,
|
|
962
|
+
proof_ref,
|
|
963
|
+
expected_oid,
|
|
964
|
+
entry["createdOid"],
|
|
965
|
+
f"refs/tags/wave-active/{args.anchor}",
|
|
966
|
+
receipt["claimOid"],
|
|
967
|
+
remove_worktree=target,
|
|
968
|
+
root_identity=(entry["rootDevice"], entry["rootInode"]),
|
|
969
|
+
):
|
|
970
|
+
raise LifecycleError(
|
|
971
|
+
f"locked worktree/ref removal failed during creation recovery: {branch}"
|
|
972
|
+
)
|
|
973
|
+
if os.path.lexists(target) or branch in worktree_branches(main):
|
|
974
|
+
raise LifecycleError("worktree still exists after creation recovery")
|
|
975
|
+
_archive_recovered_target(path, receipt, entry, archive_reason)
|
|
976
|
+
|
|
977
|
+
|
|
978
|
+
def recover_creation(main: Path, args: argparse.Namespace) -> dict[str, Any]:
|
|
979
|
+
with receipt_lock(main, args.anchor):
|
|
980
|
+
path, receipt = read_receipt(main, args.anchor, args.owner)
|
|
981
|
+
entry = next(
|
|
982
|
+
(
|
|
983
|
+
candidate for candidate in receipt["targets"]
|
|
984
|
+
if candidate.get("branch") == args.branch
|
|
985
|
+
),
|
|
986
|
+
None,
|
|
987
|
+
)
|
|
988
|
+
if entry is None:
|
|
989
|
+
raise LifecycleError(f"no receipt target for recovery: {args.branch}")
|
|
990
|
+
if entry.get("state") not in {
|
|
991
|
+
"provisional",
|
|
992
|
+
"worktree-pending",
|
|
993
|
+
"baseline-pending",
|
|
994
|
+
"setting-up",
|
|
995
|
+
"recovery-pending",
|
|
996
|
+
}:
|
|
997
|
+
raise LifecycleError(
|
|
998
|
+
f"target is not a recoverable creation attempt: {args.branch}: "
|
|
999
|
+
f"{entry.get('state', '<missing>')}"
|
|
1000
|
+
)
|
|
1001
|
+
entry["state"] = "recovery-pending"
|
|
1002
|
+
entry.pop("failure", None)
|
|
1003
|
+
write_receipt(path, receipt)
|
|
1004
|
+
_recover_creation_entry(
|
|
1005
|
+
main,
|
|
1006
|
+
args,
|
|
1007
|
+
path,
|
|
1008
|
+
receipt,
|
|
1009
|
+
entry,
|
|
1010
|
+
archive_reason="explicit creation recovery",
|
|
1011
|
+
)
|
|
1012
|
+
return {
|
|
1013
|
+
"recovered": True,
|
|
1014
|
+
"branch": args.branch,
|
|
1015
|
+
"receipt": str(path),
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def worktree_branches(main: Path) -> dict[str, Path]:
|
|
1020
|
+
output = run(["git", "worktree", "list", "--porcelain"], cwd=main).stdout
|
|
1021
|
+
result: dict[str, Path] = {}
|
|
1022
|
+
current: Path | None = None
|
|
1023
|
+
for line in output.splitlines():
|
|
1024
|
+
if line.startswith("worktree "):
|
|
1025
|
+
current = Path(line.split(" ", 1)[1]).resolve()
|
|
1026
|
+
elif line.startswith("branch refs/heads/") and current is not None:
|
|
1027
|
+
result[line.removeprefix("branch refs/heads/")] = current
|
|
1028
|
+
return result
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def lexical_worktree_branches(main: Path) -> dict[str, Path]:
|
|
1032
|
+
"""Return Git's registered paths without resolving a substituted symlink."""
|
|
1033
|
+
output = run(["git", "worktree", "list", "--porcelain"], cwd=main).stdout
|
|
1034
|
+
result: dict[str, Path] = {}
|
|
1035
|
+
current: Path | None = None
|
|
1036
|
+
for line in output.splitlines():
|
|
1037
|
+
if line.startswith("worktree "):
|
|
1038
|
+
current = Path(line.split(" ", 1)[1]).absolute()
|
|
1039
|
+
elif line.startswith("branch refs/heads/") and current is not None:
|
|
1040
|
+
result[line.removeprefix("branch refs/heads/")] = current
|
|
1041
|
+
return result
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
def prepare_target_root_nofollow(
|
|
1045
|
+
main: Path,
|
|
1046
|
+
target: Path,
|
|
1047
|
+
) -> tuple[os.stat_result, os.stat_result]:
|
|
1048
|
+
"""Create the lexical target through directory descriptors only."""
|
|
1049
|
+
relative = target.relative_to(main.resolve())
|
|
1050
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
1051
|
+
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
1052
|
+
descriptors: list[int] = []
|
|
1053
|
+
current = os.open(main, directory_flags | no_follow)
|
|
1054
|
+
try:
|
|
1055
|
+
descriptors.append(current)
|
|
1056
|
+
for component in relative.parts[:-1]:
|
|
1057
|
+
try:
|
|
1058
|
+
child = os.open(
|
|
1059
|
+
component, directory_flags | no_follow, dir_fd=current
|
|
1060
|
+
)
|
|
1061
|
+
except FileNotFoundError:
|
|
1062
|
+
os.mkdir(component, mode=0o700, dir_fd=current)
|
|
1063
|
+
child = os.open(
|
|
1064
|
+
component, directory_flags | no_follow, dir_fd=current
|
|
1065
|
+
)
|
|
1066
|
+
current = child
|
|
1067
|
+
descriptors.append(current)
|
|
1068
|
+
parent_metadata = os.fstat(current)
|
|
1069
|
+
os.mkdir(relative.name, mode=0o700, dir_fd=current)
|
|
1070
|
+
root_metadata = os.stat(
|
|
1071
|
+
relative.name, dir_fd=current, follow_symlinks=False
|
|
1072
|
+
)
|
|
1073
|
+
if not stat.S_ISDIR(root_metadata.st_mode):
|
|
1074
|
+
raise LifecycleError("new session worktree root is not a directory")
|
|
1075
|
+
return parent_metadata, root_metadata
|
|
1076
|
+
except OSError as error:
|
|
1077
|
+
raise LifecycleError(
|
|
1078
|
+
"new session worktree parent/root creation is unsafe"
|
|
1079
|
+
) from error
|
|
1080
|
+
finally:
|
|
1081
|
+
for descriptor in reversed(descriptors):
|
|
1082
|
+
os.close(descriptor)
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
def verify_target_parent_nofollow(
|
|
1086
|
+
main: Path,
|
|
1087
|
+
target: Path,
|
|
1088
|
+
expected_identity: tuple[int, int],
|
|
1089
|
+
) -> None:
|
|
1090
|
+
relative = target.relative_to(main.resolve())
|
|
1091
|
+
directory_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0)
|
|
1092
|
+
no_follow = getattr(os, "O_NOFOLLOW", 0)
|
|
1093
|
+
descriptors: list[int] = []
|
|
1094
|
+
current = os.open(main, directory_flags | no_follow)
|
|
1095
|
+
try:
|
|
1096
|
+
descriptors.append(current)
|
|
1097
|
+
for component in relative.parts[:-1]:
|
|
1098
|
+
current = os.open(
|
|
1099
|
+
component, directory_flags | no_follow, dir_fd=current
|
|
1100
|
+
)
|
|
1101
|
+
descriptors.append(current)
|
|
1102
|
+
metadata = os.fstat(current)
|
|
1103
|
+
if (metadata.st_dev, metadata.st_ino) != expected_identity:
|
|
1104
|
+
raise LifecycleError("new session worktree parent identity changed")
|
|
1105
|
+
except OSError as error:
|
|
1106
|
+
raise LifecycleError(
|
|
1107
|
+
"new session worktree parent identity changed"
|
|
1108
|
+
) from error
|
|
1109
|
+
finally:
|
|
1110
|
+
for descriptor in reversed(descriptors):
|
|
1111
|
+
os.close(descriptor)
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def verify_created_worktree_root(
|
|
1115
|
+
main: Path,
|
|
1116
|
+
target: Path,
|
|
1117
|
+
branch: str,
|
|
1118
|
+
*,
|
|
1119
|
+
expected_identity: tuple[int, int] | None = None,
|
|
1120
|
+
expected_parent_identity: tuple[int, int] | None = None,
|
|
1121
|
+
) -> os.stat_result:
|
|
1122
|
+
"""Bind setup to the lexical linked-worktree directory without symlink following."""
|
|
1123
|
+
lexical_target = target.absolute()
|
|
1124
|
+
if expected_parent_identity is not None:
|
|
1125
|
+
verify_target_parent_nofollow(main, target, expected_parent_identity)
|
|
1126
|
+
if lexical_worktree_branches(main).get(branch) != lexical_target:
|
|
1127
|
+
raise LifecycleError("new session worktree registration is incoherent")
|
|
1128
|
+
try:
|
|
1129
|
+
metadata = target.lstat()
|
|
1130
|
+
if not stat.S_ISDIR(metadata.st_mode):
|
|
1131
|
+
raise LifecycleError("new session worktree root is not a directory")
|
|
1132
|
+
with verified_worktree_root(
|
|
1133
|
+
target,
|
|
1134
|
+
metadata.st_dev,
|
|
1135
|
+
metadata.st_ino,
|
|
1136
|
+
) as descriptor:
|
|
1137
|
+
git_file = os.open(
|
|
1138
|
+
".git",
|
|
1139
|
+
os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0),
|
|
1140
|
+
dir_fd=descriptor,
|
|
1141
|
+
)
|
|
1142
|
+
try:
|
|
1143
|
+
opened = os.fstat(git_file)
|
|
1144
|
+
if not stat.S_ISREG(opened.st_mode):
|
|
1145
|
+
raise LifecycleError("new session worktree backlink is incoherent")
|
|
1146
|
+
backlink = os.read(git_file, 16 * 1024).decode("utf-8").strip()
|
|
1147
|
+
finally:
|
|
1148
|
+
os.close(git_file)
|
|
1149
|
+
except OSError as error:
|
|
1150
|
+
raise LifecycleError("new session worktree root identity is incoherent") from error
|
|
1151
|
+
if not backlink.startswith("gitdir: "):
|
|
1152
|
+
raise LifecycleError("new session worktree backlink is incoherent")
|
|
1153
|
+
git_dir = Path(backlink.removeprefix("gitdir: "))
|
|
1154
|
+
common = common_git_dir(main)
|
|
1155
|
+
try:
|
|
1156
|
+
git_dir.relative_to(common / "worktrees")
|
|
1157
|
+
git_dir_metadata = git_dir.lstat()
|
|
1158
|
+
if not stat.S_ISDIR(git_dir_metadata.st_mode):
|
|
1159
|
+
raise ValueError
|
|
1160
|
+
recorded_backlink = (git_dir / "gitdir").read_text(encoding="utf-8").strip()
|
|
1161
|
+
except (OSError, ValueError) as error:
|
|
1162
|
+
raise LifecycleError("new session worktree backlink is incoherent") from error
|
|
1163
|
+
if Path(recorded_backlink).absolute() != lexical_target / ".git":
|
|
1164
|
+
raise LifecycleError("new session worktree backlink is incoherent")
|
|
1165
|
+
identity = (metadata.st_dev, metadata.st_ino)
|
|
1166
|
+
if expected_identity is not None and identity != expected_identity:
|
|
1167
|
+
raise LifecycleError("new session worktree root identity changed")
|
|
1168
|
+
return metadata
|
|
1169
|
+
|
|
1170
|
+
|
|
1171
|
+
def seal(main: Path, args: argparse.Namespace) -> dict[str, Any]:
|
|
1172
|
+
with receipt_lock(main, args.anchor):
|
|
1173
|
+
path, receipt = read_receipt(main, args.anchor, args.owner)
|
|
1174
|
+
if receipt.get("state") == "sealed":
|
|
1175
|
+
return receipt_report(path, receipt)
|
|
1176
|
+
if receipt.get("state") != "open" or (
|
|
1177
|
+
not receipt["targets"] and not receipt.get("recoveredTargets")
|
|
1178
|
+
):
|
|
1179
|
+
raise LifecycleError(
|
|
1180
|
+
"only an open receipt with active or recovered targets can be sealed"
|
|
1181
|
+
)
|
|
1182
|
+
linked = worktree_branches(main)
|
|
1183
|
+
for entry in receipt["targets"]:
|
|
1184
|
+
branch = entry["branch"]
|
|
1185
|
+
if entry.get("state") != "active":
|
|
1186
|
+
raise LifecycleError(
|
|
1187
|
+
f"session target is not ready to seal: {branch}: "
|
|
1188
|
+
f"{entry.get('state', '<missing>')}"
|
|
1189
|
+
)
|
|
1190
|
+
target = Path(entry["worktree"])
|
|
1191
|
+
if linked.get(branch) != target.resolve():
|
|
1192
|
+
raise LifecycleError(f"session worktree identity changed before seal: {branch}")
|
|
1193
|
+
metadata = target.stat()
|
|
1194
|
+
if (metadata.st_dev, metadata.st_ino) != (
|
|
1195
|
+
entry["rootDevice"], entry["rootInode"]
|
|
1196
|
+
):
|
|
1197
|
+
raise LifecycleError(f"session worktree root changed before seal: {branch}")
|
|
1198
|
+
if run(["git", "status", "--porcelain"], cwd=target).stdout.strip():
|
|
1199
|
+
raise LifecycleError(f"session worktree is dirty before seal: {branch}")
|
|
1200
|
+
expected = resolve_ref(main, f"refs/heads/{branch}")
|
|
1201
|
+
if expected is None or run(
|
|
1202
|
+
["git", "merge-base", "--is-ancestor", entry["createdOid"], expected],
|
|
1203
|
+
cwd=main,
|
|
1204
|
+
check=False,
|
|
1205
|
+
).returncode != 0:
|
|
1206
|
+
raise LifecycleError(f"session branch moved outside its created history: {branch}")
|
|
1207
|
+
proof_ref = validated_proof_ref(receipt, entry)
|
|
1208
|
+
if resolve_ref(main, proof_ref) != entry["createdOid"]:
|
|
1209
|
+
raise LifecycleError(f"session ownership proof changed before seal: {branch}")
|
|
1210
|
+
entry["expectedOid"] = expected
|
|
1211
|
+
entry["state"] = "sealed"
|
|
1212
|
+
receipt["state"] = "sealed"
|
|
1213
|
+
write_receipt(path, receipt)
|
|
1214
|
+
return receipt_report(path, receipt)
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def commit_parents(repo: Path, commit: str) -> list[str]:
|
|
1218
|
+
line = run(["git", "rev-list", "--parents", "-n", "1", commit], cwd=repo).stdout
|
|
1219
|
+
return line.strip().split()[1:]
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def content_empty(repo: Path, commit: str, first_parent: str) -> bool:
|
|
1223
|
+
return run(
|
|
1224
|
+
["git", "diff-tree", "--quiet", first_parent, commit],
|
|
1225
|
+
cwd=repo,
|
|
1226
|
+
check=False,
|
|
1227
|
+
).returncode == 0
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def patch_id(repo: Path, commit: str, first_parent: str) -> str | None:
|
|
1231
|
+
diff = run(
|
|
1232
|
+
[
|
|
1233
|
+
"git", "diff", "--binary", "--full-index", "--no-renames",
|
|
1234
|
+
first_parent, commit,
|
|
1235
|
+
],
|
|
1236
|
+
cwd=repo,
|
|
1237
|
+
).stdout
|
|
1238
|
+
if not diff:
|
|
1239
|
+
return None
|
|
1240
|
+
result = subprocess.run(
|
|
1241
|
+
["git", "patch-id", "--stable"],
|
|
1242
|
+
cwd=repo,
|
|
1243
|
+
input=diff,
|
|
1244
|
+
capture_output=True,
|
|
1245
|
+
text=True,
|
|
1246
|
+
check=False,
|
|
1247
|
+
)
|
|
1248
|
+
if result.returncode != 0 or not result.stdout.strip():
|
|
1249
|
+
return None
|
|
1250
|
+
return result.stdout.split()[0]
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
def is_ancestor(repo: Path, commit: str, target: str) -> bool:
|
|
1254
|
+
return run(
|
|
1255
|
+
["git", "merge-base", "--is-ancestor", commit, target],
|
|
1256
|
+
cwd=repo,
|
|
1257
|
+
check=False,
|
|
1258
|
+
).returncode == 0
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def main_patch_index(repo: Path, base: str, main_oid: str) -> Counter[str]:
|
|
1262
|
+
commits = run(
|
|
1263
|
+
["git", "rev-list", "--reverse", "--topo-order", f"{base}..{main_oid}"],
|
|
1264
|
+
cwd=repo,
|
|
1265
|
+
).stdout.splitlines()
|
|
1266
|
+
patches: Counter[str] = Counter()
|
|
1267
|
+
for commit in commits:
|
|
1268
|
+
parents = commit_parents(repo, commit)
|
|
1269
|
+
if len(parents) != 1 or content_empty(repo, commit, parents[0]):
|
|
1270
|
+
continue
|
|
1271
|
+
value = patch_id(repo, commit, parents[0])
|
|
1272
|
+
if value:
|
|
1273
|
+
patches[value] += 1
|
|
1274
|
+
return patches
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
def integration_report(
|
|
1278
|
+
repo: Path,
|
|
1279
|
+
created_oid: str,
|
|
1280
|
+
expected_oid: str,
|
|
1281
|
+
main_oid: str,
|
|
1282
|
+
) -> dict[str, Any]:
|
|
1283
|
+
if not is_ancestor(repo, created_oid, main_oid):
|
|
1284
|
+
return {
|
|
1285
|
+
"integration": "ambiguous",
|
|
1286
|
+
"commits": [],
|
|
1287
|
+
"reason": "recorded base is no longer an ancestor of canonical main",
|
|
1288
|
+
}
|
|
1289
|
+
commits = run(
|
|
1290
|
+
[
|
|
1291
|
+
"git", "rev-list", "--reverse", "--topo-order",
|
|
1292
|
+
f"{created_oid}..{expected_oid}",
|
|
1293
|
+
],
|
|
1294
|
+
cwd=repo,
|
|
1295
|
+
).stdout.splitlines()
|
|
1296
|
+
index = main_patch_index(repo, created_oid, main_oid)
|
|
1297
|
+
provisional: list[dict[str, Any]] = []
|
|
1298
|
+
owned_patch_counts: Counter[str] = Counter()
|
|
1299
|
+
for commit in commits:
|
|
1300
|
+
parents = commit_parents(repo, commit)
|
|
1301
|
+
if not parents:
|
|
1302
|
+
provisional.append({"oid": commit, "status": "ambiguous", "reason": "root commit"})
|
|
1303
|
+
continue
|
|
1304
|
+
if content_empty(repo, commit, parents[0]):
|
|
1305
|
+
provisional.append({
|
|
1306
|
+
"oid": commit,
|
|
1307
|
+
"status": "ambiguous",
|
|
1308
|
+
"reason": "empty commit has no patch identity",
|
|
1309
|
+
})
|
|
1310
|
+
continue
|
|
1311
|
+
if len(parents) != 1:
|
|
1312
|
+
provisional.append({
|
|
1313
|
+
"oid": commit,
|
|
1314
|
+
"status": "ambiguous",
|
|
1315
|
+
"reason": "merge commit has no unambiguous patch identity",
|
|
1316
|
+
})
|
|
1317
|
+
continue
|
|
1318
|
+
if is_ancestor(repo, commit, main_oid):
|
|
1319
|
+
provisional.append({"oid": commit, "status": "ancestry-merged"})
|
|
1320
|
+
continue
|
|
1321
|
+
value = patch_id(repo, commit, parents[0])
|
|
1322
|
+
if value is None:
|
|
1323
|
+
provisional.append({"oid": commit, "status": "ambiguous", "reason": "missing patch-id"})
|
|
1324
|
+
continue
|
|
1325
|
+
owned_patch_counts[value] += 1
|
|
1326
|
+
provisional.append({"oid": commit, "status": "pending", "patchId": value})
|
|
1327
|
+
|
|
1328
|
+
for row in provisional:
|
|
1329
|
+
value = row.get("patchId")
|
|
1330
|
+
if row["status"] != "pending" or value is None:
|
|
1331
|
+
continue
|
|
1332
|
+
if owned_patch_counts[value] != 1 or index[value] > 1:
|
|
1333
|
+
row["status"] = "ambiguous"
|
|
1334
|
+
row["reason"] = "patch-id is not one-to-one"
|
|
1335
|
+
elif index[value] == 1:
|
|
1336
|
+
row["status"] = "patch-equivalent"
|
|
1337
|
+
else:
|
|
1338
|
+
row["status"] = "unique-patch"
|
|
1339
|
+
|
|
1340
|
+
statuses = {row["status"] for row in provisional}
|
|
1341
|
+
if "ambiguous" in statuses:
|
|
1342
|
+
integration = "ambiguous"
|
|
1343
|
+
elif "unique-patch" in statuses:
|
|
1344
|
+
integration = "unique-patch"
|
|
1345
|
+
elif statuses and statuses <= {"ancestry-merged"}:
|
|
1346
|
+
integration = "ancestry-merged"
|
|
1347
|
+
else:
|
|
1348
|
+
integration = "patch-equivalent"
|
|
1349
|
+
return {"integration": integration, "commits": provisional}
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def assess(main: Path, args: argparse.Namespace) -> tuple[Path, dict[str, Any], dict[str, Any]]:
|
|
1353
|
+
path, receipt = read_receipt(main, args.anchor, args.owner)
|
|
1354
|
+
if receipt.get("state") not in {"sealed", "tearing-down", "complete"}:
|
|
1355
|
+
raise LifecycleError("teardown receipt must be sealed first")
|
|
1356
|
+
main_oid = resolve_commit(main, args.main)
|
|
1357
|
+
if main_oid is None:
|
|
1358
|
+
raise LifecycleError(f"canonical main is not resolvable: {args.main}")
|
|
1359
|
+
linked = worktree_branches(main)
|
|
1360
|
+
rows = []
|
|
1361
|
+
for entry in receipt["targets"]:
|
|
1362
|
+
branch = entry["branch"]
|
|
1363
|
+
target = Path(entry["worktree"])
|
|
1364
|
+
reasons: list[str] = []
|
|
1365
|
+
current_oid = resolve_ref(main, f"refs/heads/{branch}")
|
|
1366
|
+
proof_ref = validated_proof_ref(receipt, entry)
|
|
1367
|
+
proof_oid = resolve_ref(main, proof_ref)
|
|
1368
|
+
target_exists = os.path.lexists(target)
|
|
1369
|
+
linked_path = linked.get(branch)
|
|
1370
|
+
fully_absent = (
|
|
1371
|
+
current_oid is None
|
|
1372
|
+
and proof_oid is None
|
|
1373
|
+
and not target_exists
|
|
1374
|
+
and linked_path is None
|
|
1375
|
+
)
|
|
1376
|
+
if entry.get("removed"):
|
|
1377
|
+
if fully_absent:
|
|
1378
|
+
rows.append({
|
|
1379
|
+
"branch": branch,
|
|
1380
|
+
"worktree": str(target),
|
|
1381
|
+
"expectedOid": entry.get("expectedOid"),
|
|
1382
|
+
"currentOid": current_oid,
|
|
1383
|
+
"integration": "already-removed",
|
|
1384
|
+
"commits": [],
|
|
1385
|
+
"reasons": [],
|
|
1386
|
+
"removable": True,
|
|
1387
|
+
"scratchFiles": [],
|
|
1388
|
+
"scratchEvidence": [],
|
|
1389
|
+
})
|
|
1390
|
+
else:
|
|
1391
|
+
rows.append({
|
|
1392
|
+
"branch": branch,
|
|
1393
|
+
"worktree": str(target),
|
|
1394
|
+
"expectedOid": entry.get("expectedOid"),
|
|
1395
|
+
"currentOid": current_oid,
|
|
1396
|
+
"integration": "ambiguous",
|
|
1397
|
+
"commits": [],
|
|
1398
|
+
"reasons": ["removed target was recreated"],
|
|
1399
|
+
"removable": False,
|
|
1400
|
+
"scratchFiles": [],
|
|
1401
|
+
"scratchEvidence": [],
|
|
1402
|
+
})
|
|
1403
|
+
continue
|
|
1404
|
+
if (
|
|
1405
|
+
fully_absent
|
|
1406
|
+
and entry.get("teardownPhase") == "ref-deletion-pending"
|
|
1407
|
+
):
|
|
1408
|
+
rows.append({
|
|
1409
|
+
"branch": branch,
|
|
1410
|
+
"worktree": str(target),
|
|
1411
|
+
"expectedOid": entry.get("expectedOid"),
|
|
1412
|
+
"currentOid": current_oid,
|
|
1413
|
+
"integration": "already-removed",
|
|
1414
|
+
"commits": [],
|
|
1415
|
+
"reasons": [],
|
|
1416
|
+
"removable": True,
|
|
1417
|
+
"scratchFiles": [],
|
|
1418
|
+
"scratchEvidence": [],
|
|
1419
|
+
})
|
|
1420
|
+
continue
|
|
1421
|
+
if fully_absent:
|
|
1422
|
+
rows.append({
|
|
1423
|
+
"branch": branch,
|
|
1424
|
+
"worktree": str(target),
|
|
1425
|
+
"expectedOid": entry.get("expectedOid"),
|
|
1426
|
+
"currentOid": current_oid,
|
|
1427
|
+
"integration": "ambiguous",
|
|
1428
|
+
"commits": [],
|
|
1429
|
+
"reasons": ["owned target disappeared outside session teardown"],
|
|
1430
|
+
"removable": False,
|
|
1431
|
+
"scratchFiles": [],
|
|
1432
|
+
"scratchEvidence": [],
|
|
1433
|
+
})
|
|
1434
|
+
continue
|
|
1435
|
+
expected_oid = entry.get("expectedOid")
|
|
1436
|
+
if not expected_oid or current_oid != expected_oid:
|
|
1437
|
+
reasons.append(
|
|
1438
|
+
f"unexpected OID: expected {expected_oid or '<unsealed>'}, "
|
|
1439
|
+
f"found {current_oid or '<missing>'}"
|
|
1440
|
+
)
|
|
1441
|
+
if proof_oid != entry.get("createdOid"):
|
|
1442
|
+
reasons.append("session ownership proof is missing or changed")
|
|
1443
|
+
profile = load_profile(Path(entry["profile"]))
|
|
1444
|
+
if branch in profile.protected_branches:
|
|
1445
|
+
reasons.append(f"protected branch: {branch}")
|
|
1446
|
+
state = pr_state(args.gh_command, main, branch)
|
|
1447
|
+
if state == "open":
|
|
1448
|
+
reasons.append("open PR")
|
|
1449
|
+
integration = (
|
|
1450
|
+
integration_report(main, entry["createdOid"], expected_oid, main_oid)
|
|
1451
|
+
if expected_oid and current_oid == expected_oid
|
|
1452
|
+
else {"integration": "ambiguous", "commits": []}
|
|
1453
|
+
)
|
|
1454
|
+
if integration["integration"] == "unique-patch":
|
|
1455
|
+
reasons.append("unique patch content is not represented on canonical main")
|
|
1456
|
+
elif integration["integration"] == "ambiguous":
|
|
1457
|
+
reasons.append("ambiguous patch identity")
|
|
1458
|
+
|
|
1459
|
+
scratch: list[str] = []
|
|
1460
|
+
scratch_evidence: list[dict[str, Any]] = []
|
|
1461
|
+
if target_exists or linked_path is not None:
|
|
1462
|
+
if not target_exists and linked_path is not None:
|
|
1463
|
+
reasons.append(
|
|
1464
|
+
"worktree directory is missing while Git registration remains"
|
|
1465
|
+
)
|
|
1466
|
+
elif target_exists and (target.is_symlink() or not target.is_dir()):
|
|
1467
|
+
reasons.append("worktree root type changed")
|
|
1468
|
+
elif linked_path != target.resolve():
|
|
1469
|
+
reasons.append("worktree registration no longer matches receipt")
|
|
1470
|
+
elif target_exists:
|
|
1471
|
+
metadata = target.stat()
|
|
1472
|
+
if (metadata.st_dev, metadata.st_ino) != (
|
|
1473
|
+
entry["rootDevice"], entry["rootInode"]
|
|
1474
|
+
):
|
|
1475
|
+
reasons.append("worktree root identity changed")
|
|
1476
|
+
verified_scratch: tuple[str, ...] = ()
|
|
1477
|
+
try:
|
|
1478
|
+
baseline = load_artifact_baseline(target)
|
|
1479
|
+
if (
|
|
1480
|
+
baseline.digest != entry.get("artifactBaselineDigest")
|
|
1481
|
+
or baseline.setup_head != entry["createdOid"]
|
|
1482
|
+
):
|
|
1483
|
+
raise LifecycleError(
|
|
1484
|
+
"artifact provenance baseline changed or is incoherent"
|
|
1485
|
+
)
|
|
1486
|
+
evidence = verified_landing_scratch_evidence(
|
|
1487
|
+
profile,
|
|
1488
|
+
target,
|
|
1489
|
+
expected_baseline_digest=entry["artifactBaselineDigest"],
|
|
1490
|
+
)
|
|
1491
|
+
scratch_evidence = list(evidence)
|
|
1492
|
+
verified_scratch = tuple(item["path"] for item in evidence)
|
|
1493
|
+
except (LifecycleError, KeyError) as error:
|
|
1494
|
+
reasons.append(f"artifact provenance baseline stop: {error}")
|
|
1495
|
+
facts = collect_cleanup_facts(
|
|
1496
|
+
main,
|
|
1497
|
+
target,
|
|
1498
|
+
merge_target=args.main,
|
|
1499
|
+
pr_state=state,
|
|
1500
|
+
)
|
|
1501
|
+
cleanup = classify_cleanup(
|
|
1502
|
+
profile,
|
|
1503
|
+
facts,
|
|
1504
|
+
verified_scratch_files=verified_scratch,
|
|
1505
|
+
)
|
|
1506
|
+
try:
|
|
1507
|
+
cleanup = bind_cleanup_scratch_evidence(
|
|
1508
|
+
profile,
|
|
1509
|
+
cleanup,
|
|
1510
|
+
scratch_evidence,
|
|
1511
|
+
require_generator_evidence=True,
|
|
1512
|
+
)
|
|
1513
|
+
scratch_evidence = list(cleanup.scratch_evidence)
|
|
1514
|
+
except LifecycleError as error:
|
|
1515
|
+
reasons.append(f"scratch evidence stop: {error}")
|
|
1516
|
+
reasons.extend(
|
|
1517
|
+
reason for reason in cleanup.reasons
|
|
1518
|
+
if not reason.startswith("unmerged branch:")
|
|
1519
|
+
)
|
|
1520
|
+
scratch = list(cleanup.scratch_files)
|
|
1521
|
+
rows.append({
|
|
1522
|
+
"branch": branch,
|
|
1523
|
+
"worktree": str(target),
|
|
1524
|
+
"expectedOid": expected_oid,
|
|
1525
|
+
"currentOid": current_oid,
|
|
1526
|
+
"integration": integration["integration"],
|
|
1527
|
+
"commits": integration["commits"],
|
|
1528
|
+
"reasons": list(dict.fromkeys(reasons)),
|
|
1529
|
+
"removable": not reasons,
|
|
1530
|
+
"scratchFiles": scratch,
|
|
1531
|
+
"scratchEvidence": scratch_evidence,
|
|
1532
|
+
})
|
|
1533
|
+
report = {
|
|
1534
|
+
"receipt": str(path),
|
|
1535
|
+
"anchor": args.anchor,
|
|
1536
|
+
"owner": args.owner,
|
|
1537
|
+
"main": args.main,
|
|
1538
|
+
"mainOid": main_oid,
|
|
1539
|
+
"targets": rows,
|
|
1540
|
+
"removable": all(row["removable"] for row in rows),
|
|
1541
|
+
}
|
|
1542
|
+
return path, receipt, report
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
def inspect(main: Path, args: argparse.Namespace) -> dict[str, Any]:
|
|
1546
|
+
return assess(main, args)[2]
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
def revalidate_target(
|
|
1550
|
+
main: Path,
|
|
1551
|
+
args: argparse.Namespace,
|
|
1552
|
+
receipt_path_value: Path,
|
|
1553
|
+
receipt: dict[str, Any],
|
|
1554
|
+
entry: dict[str, Any],
|
|
1555
|
+
main_oid: str,
|
|
1556
|
+
) -> dict[str, Any]:
|
|
1557
|
+
if resolve_commit(main, args.main) != main_oid:
|
|
1558
|
+
raise LifecycleError("canonical main moved before target mutation")
|
|
1559
|
+
current_path, current_receipt, report = assess(main, args)
|
|
1560
|
+
if current_path != receipt_path_value or current_receipt != receipt:
|
|
1561
|
+
raise LifecycleError("active claim or teardown receipt changed before mutation")
|
|
1562
|
+
row = next(
|
|
1563
|
+
(candidate for candidate in report["targets"]
|
|
1564
|
+
if candidate["branch"] == entry["branch"]),
|
|
1565
|
+
None,
|
|
1566
|
+
)
|
|
1567
|
+
if row is None:
|
|
1568
|
+
raise LifecycleError("exact receipt target disappeared before mutation")
|
|
1569
|
+
if not row["removable"] or row["integration"] == "already-removed":
|
|
1570
|
+
detail = ", ".join(row["reasons"]) or row["integration"]
|
|
1571
|
+
raise LifecycleError(
|
|
1572
|
+
f"target changed before mutation: {entry['branch']}: {detail}"
|
|
1573
|
+
)
|
|
1574
|
+
# assess queries PR state after reading the receipt. Close that race with a
|
|
1575
|
+
# final claim/receipt, profile, PR and ref/proof pass.
|
|
1576
|
+
revalidate_owned_safety(
|
|
1577
|
+
main, args, receipt_path_value, receipt, entry,
|
|
1578
|
+
expected_oid=entry["expectedOid"],
|
|
1579
|
+
error_suffix="target mutation",
|
|
1580
|
+
)
|
|
1581
|
+
return row
|
|
1582
|
+
|
|
1583
|
+
|
|
1584
|
+
def revalidate_owned_safety(
|
|
1585
|
+
main: Path,
|
|
1586
|
+
args: argparse.Namespace,
|
|
1587
|
+
receipt_path_value: Path,
|
|
1588
|
+
receipt: dict[str, Any],
|
|
1589
|
+
entry: dict[str, Any],
|
|
1590
|
+
*,
|
|
1591
|
+
expected_oid: str,
|
|
1592
|
+
error_suffix: str,
|
|
1593
|
+
) -> None:
|
|
1594
|
+
branch = entry["branch"]
|
|
1595
|
+
if pr_state(args.gh_command, main, branch) == "open":
|
|
1596
|
+
raise LifecycleError(f"open PR before {error_suffix}: {branch}")
|
|
1597
|
+
current_path, current_receipt = read_receipt(main, args.anchor, args.owner)
|
|
1598
|
+
if current_path != receipt_path_value or current_receipt != receipt:
|
|
1599
|
+
raise LifecycleError(
|
|
1600
|
+
f"active claim or teardown receipt changed before {error_suffix}"
|
|
1601
|
+
)
|
|
1602
|
+
profile = load_profile(Path(entry["profile"]))
|
|
1603
|
+
if branch in profile.protected_branches:
|
|
1604
|
+
raise LifecycleError(f"protected branch before {error_suffix}: {branch}")
|
|
1605
|
+
if resolve_ref(main, f"refs/heads/{branch}") != expected_oid:
|
|
1606
|
+
raise LifecycleError(f"unexpected OID before {error_suffix}: {branch}")
|
|
1607
|
+
proof_ref = validated_proof_ref(receipt, entry)
|
|
1608
|
+
if resolve_ref(main, proof_ref) != entry["createdOid"]:
|
|
1609
|
+
raise LifecycleError(f"session ownership proof changed before {error_suffix}: {branch}")
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def revalidate_ref_cleanup(
|
|
1613
|
+
main: Path,
|
|
1614
|
+
args: argparse.Namespace,
|
|
1615
|
+
receipt_path_value: Path,
|
|
1616
|
+
receipt: dict[str, Any],
|
|
1617
|
+
entry: dict[str, Any],
|
|
1618
|
+
main_oid: str,
|
|
1619
|
+
) -> None:
|
|
1620
|
+
if resolve_commit(main, args.main) != main_oid:
|
|
1621
|
+
raise LifecycleError("canonical main moved before branch cleanup")
|
|
1622
|
+
branch = entry["branch"]
|
|
1623
|
+
revalidate_owned_safety(
|
|
1624
|
+
main, args, receipt_path_value, receipt, entry,
|
|
1625
|
+
expected_oid=entry["expectedOid"],
|
|
1626
|
+
error_suffix="branch cleanup",
|
|
1627
|
+
)
|
|
1628
|
+
target = Path(entry["worktree"])
|
|
1629
|
+
linked = worktree_branches(main)
|
|
1630
|
+
target_present = os.path.lexists(target)
|
|
1631
|
+
if target_present:
|
|
1632
|
+
metadata = target.lstat()
|
|
1633
|
+
if (
|
|
1634
|
+
not stat.S_ISDIR(metadata.st_mode)
|
|
1635
|
+
or
|
|
1636
|
+
linked.get(branch) != target.resolve()
|
|
1637
|
+
or (metadata.st_dev, metadata.st_ino)
|
|
1638
|
+
!= (entry["rootDevice"], entry["rootInode"])
|
|
1639
|
+
):
|
|
1640
|
+
raise LifecycleError(
|
|
1641
|
+
f"worktree identity changed before branch cleanup: {branch}"
|
|
1642
|
+
)
|
|
1643
|
+
elif branch in linked:
|
|
1644
|
+
raise LifecycleError(
|
|
1645
|
+
f"worktree registration changed before branch cleanup: {branch}"
|
|
1646
|
+
)
|
|
1647
|
+
elif entry.get("teardownPhase") != "ref-deletion-pending":
|
|
1648
|
+
raise LifecycleError(f"owned worktree disappeared before branch cleanup: {branch}")
|
|
1649
|
+
|
|
1650
|
+
|
|
1651
|
+
def canonical_ref(repo: Path, rev: str) -> str | None:
|
|
1652
|
+
result = run(
|
|
1653
|
+
["git", "rev-parse", "--symbolic-full-name", rev],
|
|
1654
|
+
cwd=repo,
|
|
1655
|
+
check=False,
|
|
1656
|
+
)
|
|
1657
|
+
value = result.stdout.strip()
|
|
1658
|
+
return value if result.returncode == 0 and value.startswith("refs/") else None
|
|
1659
|
+
|
|
1660
|
+
|
|
1661
|
+
def immutable_oid(value: str) -> bool:
|
|
1662
|
+
return len(value) == 40 and all(character in "0123456789abcdef" for character in value)
|
|
1663
|
+
|
|
1664
|
+
|
|
1665
|
+
def teardown(main: Path, args: argparse.Namespace) -> dict[str, Any]:
|
|
1666
|
+
with receipt_lock(main, args.anchor):
|
|
1667
|
+
path, receipt, preview = assess(main, args)
|
|
1668
|
+
main_ref = canonical_ref(main, args.main)
|
|
1669
|
+
if main_ref is None and not immutable_oid(args.main):
|
|
1670
|
+
raise LifecycleError(
|
|
1671
|
+
"canonical main must be a ref or immutable full commit OID"
|
|
1672
|
+
)
|
|
1673
|
+
if not preview["removable"]:
|
|
1674
|
+
blockers = [
|
|
1675
|
+
f"{row['branch']}: {', '.join(row['reasons'])}"
|
|
1676
|
+
for row in preview["targets"] if row["reasons"]
|
|
1677
|
+
]
|
|
1678
|
+
raise LifecycleError("; ".join(blockers))
|
|
1679
|
+
_, latest_receipt, latest = assess(main, args)
|
|
1680
|
+
if latest != preview or latest_receipt != receipt:
|
|
1681
|
+
raise LifecycleError("teardown inventory changed before removal")
|
|
1682
|
+
if resolve_commit(main, args.main) != preview["mainOid"]:
|
|
1683
|
+
raise LifecycleError("canonical main moved before teardown")
|
|
1684
|
+
|
|
1685
|
+
by_branch = {row["branch"]: row for row in latest["targets"]}
|
|
1686
|
+
# Persist every recovery OID before the first mutation. The receipt is
|
|
1687
|
+
# an audit/recovery archive even if the process stops between targets.
|
|
1688
|
+
for entry in receipt["targets"]:
|
|
1689
|
+
if not entry.get("removed"):
|
|
1690
|
+
entry["recoveryOid"] = entry["expectedOid"]
|
|
1691
|
+
receipt["state"] = "tearing-down"
|
|
1692
|
+
write_receipt(path, receipt)
|
|
1693
|
+
|
|
1694
|
+
for entry in receipt["targets"]:
|
|
1695
|
+
row = by_branch[entry["branch"]]
|
|
1696
|
+
if row["integration"] == "already-removed":
|
|
1697
|
+
entry["removed"] = True
|
|
1698
|
+
continue
|
|
1699
|
+
row = revalidate_target(
|
|
1700
|
+
main,
|
|
1701
|
+
args,
|
|
1702
|
+
path,
|
|
1703
|
+
receipt,
|
|
1704
|
+
entry,
|
|
1705
|
+
preview["mainOid"],
|
|
1706
|
+
)
|
|
1707
|
+
if (
|
|
1708
|
+
row["scratchFiles"] != by_branch[entry["branch"]]["scratchFiles"]
|
|
1709
|
+
or row["scratchEvidence"]
|
|
1710
|
+
!= by_branch[entry["branch"]]["scratchEvidence"]
|
|
1711
|
+
):
|
|
1712
|
+
raise LifecycleError(
|
|
1713
|
+
f"scratch evidence changed before target mutation: "
|
|
1714
|
+
f"{entry['branch']}"
|
|
1715
|
+
)
|
|
1716
|
+
target = Path(entry["worktree"])
|
|
1717
|
+
if os.path.lexists(target):
|
|
1718
|
+
with verified_worktree_root(
|
|
1719
|
+
target,
|
|
1720
|
+
entry["rootDevice"],
|
|
1721
|
+
entry["rootInode"],
|
|
1722
|
+
) as descriptor:
|
|
1723
|
+
profile = load_profile(Path(entry["profile"]))
|
|
1724
|
+
remove_authorized_scratch(
|
|
1725
|
+
profile,
|
|
1726
|
+
descriptor,
|
|
1727
|
+
row["scratchFiles"],
|
|
1728
|
+
row["scratchEvidence"],
|
|
1729
|
+
)
|
|
1730
|
+
after_scratch = revalidate_target(
|
|
1731
|
+
main,
|
|
1732
|
+
args,
|
|
1733
|
+
path,
|
|
1734
|
+
receipt,
|
|
1735
|
+
entry,
|
|
1736
|
+
preview["mainOid"],
|
|
1737
|
+
)
|
|
1738
|
+
if after_scratch["scratchFiles"]:
|
|
1739
|
+
raise LifecycleError(
|
|
1740
|
+
f"scratch inventory changed before worktree removal: "
|
|
1741
|
+
f"{entry['branch']}"
|
|
1742
|
+
)
|
|
1743
|
+
revalidate_ref_cleanup(
|
|
1744
|
+
main,
|
|
1745
|
+
args,
|
|
1746
|
+
path,
|
|
1747
|
+
receipt,
|
|
1748
|
+
entry,
|
|
1749
|
+
preview["mainOid"],
|
|
1750
|
+
)
|
|
1751
|
+
entry["teardownPhase"] = "ref-deletion-pending"
|
|
1752
|
+
write_receipt(path, receipt)
|
|
1753
|
+
revalidate_ref_cleanup(
|
|
1754
|
+
main,
|
|
1755
|
+
args,
|
|
1756
|
+
path,
|
|
1757
|
+
receipt,
|
|
1758
|
+
entry,
|
|
1759
|
+
preview["mainOid"],
|
|
1760
|
+
)
|
|
1761
|
+
if not delete_owned_refs_prepared(
|
|
1762
|
+
main,
|
|
1763
|
+
entry["branch"],
|
|
1764
|
+
entry["proofRef"],
|
|
1765
|
+
entry["expectedOid"],
|
|
1766
|
+
entry["createdOid"],
|
|
1767
|
+
f"refs/tags/wave-active/{args.anchor}",
|
|
1768
|
+
receipt["claimOid"],
|
|
1769
|
+
remove_worktree=target if os.path.lexists(target) else None,
|
|
1770
|
+
main_ref=main_ref,
|
|
1771
|
+
main_oid=preview["mainOid"],
|
|
1772
|
+
root_identity=(
|
|
1773
|
+
(entry["rootDevice"], entry["rootInode"])
|
|
1774
|
+
if os.path.lexists(target) else None
|
|
1775
|
+
),
|
|
1776
|
+
):
|
|
1777
|
+
raise LifecycleError(
|
|
1778
|
+
f"locked worktree/ref cleanup stopped: {entry['branch']}"
|
|
1779
|
+
)
|
|
1780
|
+
entry["removed"] = True
|
|
1781
|
+
entry["teardownPhase"] = "removed"
|
|
1782
|
+
write_receipt(path, receipt)
|
|
1783
|
+
receipt["state"] = "complete"
|
|
1784
|
+
write_receipt(path, receipt)
|
|
1785
|
+
result = receipt_report(path, receipt)
|
|
1786
|
+
result["removed"] = True
|
|
1787
|
+
return result
|
|
1788
|
+
|
|
1789
|
+
|
|
1790
|
+
def receipt_report(path: Path, receipt: dict[str, Any]) -> dict[str, Any]:
|
|
1791
|
+
return {
|
|
1792
|
+
"receipt": str(path),
|
|
1793
|
+
"anchor": receipt["anchor"],
|
|
1794
|
+
"owner": receipt["owner"],
|
|
1795
|
+
"baseOid": receipt["baseOid"],
|
|
1796
|
+
"state": receipt["state"],
|
|
1797
|
+
"targets": receipt["targets"],
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
|
|
1801
|
+
def parser() -> argparse.ArgumentParser:
|
|
1802
|
+
root = argparse.ArgumentParser()
|
|
1803
|
+
commands = root.add_subparsers(dest="action", required=True)
|
|
1804
|
+
|
|
1805
|
+
def common(name: str) -> argparse.ArgumentParser:
|
|
1806
|
+
command = commands.add_parser(name)
|
|
1807
|
+
command.add_argument("--anchor", required=True)
|
|
1808
|
+
command.add_argument("--owner", required=True)
|
|
1809
|
+
return command
|
|
1810
|
+
|
|
1811
|
+
begin_parser = common("begin")
|
|
1812
|
+
begin_parser.add_argument("--base", default="origin/main")
|
|
1813
|
+
|
|
1814
|
+
create_parser = common("create")
|
|
1815
|
+
create_parser.add_argument(
|
|
1816
|
+
"--profile", default="docs/agents/workflow-capabilities.json"
|
|
1817
|
+
)
|
|
1818
|
+
create_parser.add_argument("--base")
|
|
1819
|
+
create_parser.add_argument("--gh-command", default="gh")
|
|
1820
|
+
create_parser.add_argument("issue")
|
|
1821
|
+
create_parser.add_argument("slug")
|
|
1822
|
+
create_parser.add_argument("branch_type", nargs="?", default="feat")
|
|
1823
|
+
|
|
1824
|
+
recover_parser = common("recover")
|
|
1825
|
+
recover_parser.add_argument("--branch", required=True)
|
|
1826
|
+
recover_parser.add_argument("--gh-command", default="gh")
|
|
1827
|
+
|
|
1828
|
+
common("seal")
|
|
1829
|
+
for name in ("inspect", "teardown"):
|
|
1830
|
+
command = common(name)
|
|
1831
|
+
command.add_argument("--main", default="origin/main")
|
|
1832
|
+
command.add_argument("--gh-command", default="gh")
|
|
1833
|
+
return root
|
|
1834
|
+
|
|
1835
|
+
|
|
1836
|
+
def main() -> int:
|
|
1837
|
+
try:
|
|
1838
|
+
args = parser().parse_args()
|
|
1839
|
+
repo = main_worktree(Path.cwd())
|
|
1840
|
+
actions = {
|
|
1841
|
+
"begin": begin,
|
|
1842
|
+
"create": create_target,
|
|
1843
|
+
"recover": recover_creation,
|
|
1844
|
+
"seal": seal,
|
|
1845
|
+
"inspect": inspect,
|
|
1846
|
+
"teardown": teardown,
|
|
1847
|
+
}
|
|
1848
|
+
result = actions[args.action](repo, args)
|
|
1849
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
1850
|
+
return 0
|
|
1851
|
+
except LifecycleError as error:
|
|
1852
|
+
print(f"STOP: {error}", file=sys.stderr)
|
|
1853
|
+
return 1
|
|
1854
|
+
|
|
1855
|
+
|
|
1856
|
+
if __name__ == "__main__":
|
|
1857
|
+
raise SystemExit(main())
|