@ikon85/agent-workflow-kit 0.37.0 → 0.39.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/kit-update/SKILL.md +33 -1
- package/.agents/skills/orchestrate-wave/SKILL.md +5 -5
- package/.agents/skills/setup-workflow/SKILL.md +42 -4
- package/.agents/skills/setup-workflow/board-sync.md +6 -2
- package/.agents/skills/setup-workflow/workflow-advisories.md +34 -0
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +47 -1
- package/.agents/skills/wrapup/SKILL.md +46 -0
- package/.claude/hooks/drift-guard.py +212 -21
- package/.claude/skills/kit-update/SKILL.md +33 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +5 -5
- package/.claude/skills/setup-workflow/SKILL.md +42 -4
- package/.claude/skills/setup-workflow/board-sync.md +6 -2
- package/.claude/skills/setup-workflow/workflow-advisories.md +34 -0
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +47 -1
- package/.claude/skills/wrapup/SKILL.md +46 -0
- package/README.md +73 -0
- package/agent-workflow-kit.package.json +49 -25
- package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +88 -0
- package/docs/agents/workflow-capabilities.json +11 -1
- package/package.json +1 -1
- package/scripts/board_bootstrap.py +367 -0
- package/scripts/kit-update-pr.mjs +20 -7
- package/scripts/kit-update-pr.test.mjs +29 -0
- package/scripts/profile_globs.py +347 -0
- package/scripts/test_board_bootstrap.py +348 -0
- package/scripts/test_drift_guard_diagnostics.py +295 -0
- package/scripts/test_orchestrate_wave_contract.py +9 -0
- package/scripts/test_profile_globs.py +280 -0
- package/scripts/test_skill_setup_workflow_seeds.py +87 -0
- package/scripts/test_worktree_wrapup_contract.py +1592 -3
- package/scripts/workflow-advisories/core.py +29 -4
- package/scripts/worktree-lifecycle/README.md +139 -0
- package/scripts/worktree-lifecycle/capabilities.json +9 -1
- package/scripts/worktree-lifecycle/cleanup.py +22 -51
- package/scripts/worktree-lifecycle/core.py +1206 -5
- package/scripts/worktree-lifecycle/profile.py +38 -3
- package/scripts/worktree-lifecycle/session.py +1857 -0
- package/scripts/worktree-lifecycle/setup.py +15 -0
- package/scripts/wrapup-land.py +461 -14
- package/src/cli.mjs +32 -1
- package/src/commands/update.mjs +30 -21
- package/src/consumer-migrations.json +19 -0
- package/src/lib/bundle.mjs +11 -0
- package/src/lib/consumerMigrations.mjs +161 -0
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
"""Wrapup must reuse the shipped Worktree Lifecycle cleanup assessment."""
|
|
3
3
|
|
|
4
4
|
import importlib.util
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
5
8
|
import tempfile
|
|
6
9
|
import unittest
|
|
7
10
|
from pathlib import Path
|
|
@@ -10,6 +13,7 @@ from unittest.mock import patch
|
|
|
10
13
|
|
|
11
14
|
REPO = Path(__file__).resolve().parent.parent
|
|
12
15
|
WRAPUP = REPO / "scripts/wrapup-land.py"
|
|
16
|
+
SETUP = REPO / "scripts/worktree-lifecycle/setup.py"
|
|
13
17
|
|
|
14
18
|
|
|
15
19
|
def load_wrapup():
|
|
@@ -19,16 +23,634 @@ def load_wrapup():
|
|
|
19
23
|
return module
|
|
20
24
|
|
|
21
25
|
|
|
26
|
+
def command(args, cwd):
|
|
27
|
+
result = subprocess.run(
|
|
28
|
+
args,
|
|
29
|
+
cwd=cwd,
|
|
30
|
+
capture_output=True,
|
|
31
|
+
text=True,
|
|
32
|
+
)
|
|
33
|
+
if result.returncode != 0:
|
|
34
|
+
raise AssertionError(
|
|
35
|
+
f"{' '.join(args)} failed ({result.returncode}): "
|
|
36
|
+
f"{(result.stderr or result.stdout).strip()}"
|
|
37
|
+
)
|
|
38
|
+
return result
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def create_merged_worktree(
|
|
42
|
+
root: Path,
|
|
43
|
+
*,
|
|
44
|
+
setup_steps: list[dict] | None = None,
|
|
45
|
+
scratch_patterns: list[str] | None = None,
|
|
46
|
+
) -> tuple[Path, Path]:
|
|
47
|
+
remote = root / "remote.git"
|
|
48
|
+
main = root / "main"
|
|
49
|
+
command(["git", "init", "--bare", str(remote)], root)
|
|
50
|
+
command(["git", "init", "-b", "main", str(main)], root)
|
|
51
|
+
command(["git", "config", "user.name", "Test"], main)
|
|
52
|
+
command(["git", "config", "user.email", "test@example.invalid"], main)
|
|
53
|
+
(main / ".gitignore").write_text(
|
|
54
|
+
".worktrees/\ndist-kit/\n__pycache__/\n.claude/logs/\nconsumer/\n",
|
|
55
|
+
encoding="utf-8",
|
|
56
|
+
)
|
|
57
|
+
profile = main / "docs/agents/workflow-capabilities.json"
|
|
58
|
+
profile.parent.mkdir(parents=True)
|
|
59
|
+
profile.write_text(json.dumps({
|
|
60
|
+
"worktreeLifecycle": {
|
|
61
|
+
"enabled": True,
|
|
62
|
+
"worktreeRoot": ".worktrees",
|
|
63
|
+
"branchTemplate": "{type}/{issue}-{slug}",
|
|
64
|
+
"pathTemplate": "{issue}-{slug}",
|
|
65
|
+
"mainBranches": ["main"],
|
|
66
|
+
"protectedBranches": ["main"],
|
|
67
|
+
"scratchPatterns": scratch_patterns or [],
|
|
68
|
+
"setupSteps": setup_steps or [],
|
|
69
|
+
},
|
|
70
|
+
"wrapup": {
|
|
71
|
+
"landingGeneratedArtifactPatterns": [
|
|
72
|
+
"dist-kit/**",
|
|
73
|
+
"**/__pycache__/**",
|
|
74
|
+
],
|
|
75
|
+
},
|
|
76
|
+
}), encoding="utf-8")
|
|
77
|
+
command(["git", "add", "."], main)
|
|
78
|
+
command(["git", "commit", "-m", "seed"], main)
|
|
79
|
+
command(["git", "remote", "add", "origin", str(remote)], main)
|
|
80
|
+
command(["git", "push", "-u", "origin", "main"], main)
|
|
81
|
+
command([
|
|
82
|
+
os.sys.executable,
|
|
83
|
+
str(SETUP),
|
|
84
|
+
"--profile",
|
|
85
|
+
str(profile),
|
|
86
|
+
"--base",
|
|
87
|
+
"origin/main",
|
|
88
|
+
"268",
|
|
89
|
+
"cleanup",
|
|
90
|
+
"fix",
|
|
91
|
+
], main)
|
|
92
|
+
worktree = main / ".worktrees/268-cleanup"
|
|
93
|
+
command(["git", "rev-parse", "--verify", "HEAD"], worktree)
|
|
94
|
+
(worktree / "change.txt").write_text("landed\n", encoding="utf-8")
|
|
95
|
+
command(["git", "add", "change.txt"], worktree)
|
|
96
|
+
command(["git", "commit", "-m", "change"], worktree)
|
|
97
|
+
command(["git", "merge", "--ff-only", "fix/268-cleanup"], main)
|
|
98
|
+
command(["git", "push", "origin", "main"], main)
|
|
99
|
+
return main, worktree
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def create_real_kit_merged_worktree(root: Path) -> tuple[Path, Path]:
|
|
103
|
+
main = root / "main"
|
|
104
|
+
remote = root / "remote.git"
|
|
105
|
+
command(["git", "clone", "--no-local", str(REPO), str(main)], root)
|
|
106
|
+
command(["git", "config", "user.name", "Test"], main)
|
|
107
|
+
command(["git", "config", "user.email", "test@example.invalid"], main)
|
|
108
|
+
command(["git", "checkout", "-B", "main"], main)
|
|
109
|
+
command(["git", "remote", "remove", "origin"], main)
|
|
110
|
+
command(["git", "init", "--bare", str(remote)], root)
|
|
111
|
+
command(["git", "remote", "add", "origin", str(remote)], main)
|
|
112
|
+
|
|
113
|
+
ignore = main / ".gitignore"
|
|
114
|
+
ignored = ignore.read_text(encoding="utf-8")
|
|
115
|
+
required_ignores = [
|
|
116
|
+
".worktrees/",
|
|
117
|
+
"dist-kit/",
|
|
118
|
+
"**/__pycache__/",
|
|
119
|
+
".claude/logs/",
|
|
120
|
+
"PLAN.md",
|
|
121
|
+
]
|
|
122
|
+
missing = [pattern for pattern in required_ignores if pattern not in ignored.splitlines()]
|
|
123
|
+
if missing:
|
|
124
|
+
ignore.write_text(
|
|
125
|
+
ignored.rstrip("\n") + "\n" + "\n".join(missing) + "\n",
|
|
126
|
+
encoding="utf-8",
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
profile_path = main / "docs/agents/workflow-capabilities.json"
|
|
130
|
+
profile = json.loads(profile_path.read_text(encoding="utf-8"))
|
|
131
|
+
lifecycle = profile.setdefault("worktreeLifecycle", {})
|
|
132
|
+
lifecycle.update({
|
|
133
|
+
"enabled": True,
|
|
134
|
+
"worktreeRoot": ".worktrees",
|
|
135
|
+
"branchTemplate": "{type}/{issue}-{slug}",
|
|
136
|
+
"pathTemplate": "{issue}-{slug}",
|
|
137
|
+
"mainBranches": ["main"],
|
|
138
|
+
"protectedBranches": ["main"],
|
|
139
|
+
"scratchPatterns": ["PLAN.md", ".claude/logs/**"],
|
|
140
|
+
"setupSteps": [],
|
|
141
|
+
})
|
|
142
|
+
profile["wrapup"] = {
|
|
143
|
+
"landingGeneratedArtifactPatterns": [
|
|
144
|
+
"dist-kit/**",
|
|
145
|
+
"**/__pycache__/**",
|
|
146
|
+
],
|
|
147
|
+
}
|
|
148
|
+
profile_path.write_text(
|
|
149
|
+
json.dumps(profile, indent=2) + "\n",
|
|
150
|
+
encoding="utf-8",
|
|
151
|
+
)
|
|
152
|
+
command(["git", "add", ".gitignore", str(profile_path.relative_to(main))], main)
|
|
153
|
+
command(["git", "commit", "-m", "configure lifecycle fixture"], main)
|
|
154
|
+
command(["git", "push", "-u", "origin", "main"], main)
|
|
155
|
+
|
|
156
|
+
command([
|
|
157
|
+
os.sys.executable,
|
|
158
|
+
str(SETUP),
|
|
159
|
+
"--profile",
|
|
160
|
+
str(profile_path),
|
|
161
|
+
"--base",
|
|
162
|
+
"origin/main",
|
|
163
|
+
"268",
|
|
164
|
+
"real-generator",
|
|
165
|
+
"fix",
|
|
166
|
+
], main)
|
|
167
|
+
worktree = main / ".worktrees/268-real-generator"
|
|
168
|
+
(worktree / "change.txt").write_text("landed\n", encoding="utf-8")
|
|
169
|
+
command(["git", "add", "change.txt"], worktree)
|
|
170
|
+
command(["git", "commit", "-m", "change"], worktree)
|
|
171
|
+
command(["git", "merge", "--ff-only", "fix/268-real-generator"], main)
|
|
172
|
+
command(["git", "push", "origin", "main"], main)
|
|
173
|
+
return main, worktree
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def merged_landing_runner(wrapup):
|
|
177
|
+
"""Stub the external gh/board calls of an already-MERGED land run."""
|
|
178
|
+
real_run = wrapup.run
|
|
179
|
+
|
|
180
|
+
def landing_run(args, cwd=None, check=False):
|
|
181
|
+
if args[:3] == ["gh", "pr", "view"]:
|
|
182
|
+
fields = args[-1]
|
|
183
|
+
payload = (
|
|
184
|
+
{"number": 42, "state": "MERGED", "body": "**Retro:** n/a"}
|
|
185
|
+
if "number,state,body" in fields
|
|
186
|
+
else {"state": "MERGED"}
|
|
187
|
+
)
|
|
188
|
+
return subprocess.CompletedProcess(args, 0, json.dumps(payload), "")
|
|
189
|
+
if args[:3] == ["gh", "issue", "view"]:
|
|
190
|
+
return subprocess.CompletedProcess(
|
|
191
|
+
args, 0, json.dumps({"state": "CLOSED"}), ""
|
|
192
|
+
)
|
|
193
|
+
if args[:3] == ["gh", "pr", "list"]:
|
|
194
|
+
return subprocess.CompletedProcess(args, 0, "", "")
|
|
195
|
+
if args and args[0] == os.sys.executable:
|
|
196
|
+
return subprocess.CompletedProcess(args, 0, "", "")
|
|
197
|
+
return real_run(args, cwd=cwd, check=check)
|
|
198
|
+
|
|
199
|
+
return landing_run
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def land_args(branch="fix/268-cleanup", **overrides):
|
|
203
|
+
values = {
|
|
204
|
+
"branch": branch,
|
|
205
|
+
"body_file": None,
|
|
206
|
+
"title": None,
|
|
207
|
+
"anchor": None,
|
|
208
|
+
"skip_malformed_drift": False,
|
|
209
|
+
"abandon_unfinished_attempt": False,
|
|
210
|
+
"recover_canonical_cleanup": False,
|
|
211
|
+
}
|
|
212
|
+
values.update(overrides)
|
|
213
|
+
return SimpleNamespace(**values)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def run_land(wrapup, main: Path, args) -> dict:
|
|
217
|
+
"""Run `land` from the main tree against an already-merged PR."""
|
|
218
|
+
previous = Path.cwd()
|
|
219
|
+
try:
|
|
220
|
+
os.chdir(main)
|
|
221
|
+
with (
|
|
222
|
+
patch.object(wrapup, "run", side_effect=merged_landing_runner(wrapup)),
|
|
223
|
+
patch.object(wrapup, "wait_for_merge_gate", return_value=True),
|
|
224
|
+
patch.object(wrapup, "kill_worktree_processes", return_value=[]),
|
|
225
|
+
):
|
|
226
|
+
return wrapup.cmd_land(args)
|
|
227
|
+
finally:
|
|
228
|
+
os.chdir(previous)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def rewrite_landing_attempt(core, worktree: Path, mutate) -> Path:
|
|
232
|
+
"""Rewrite the attempt journal with a coherent digest for a forged payload."""
|
|
233
|
+
path = core.artifact_baseline_path(worktree).with_name(core.LANDING_ATTEMPT_FILE)
|
|
234
|
+
document = json.loads(path.read_text(encoding="utf-8"))
|
|
235
|
+
payload = {key: value for key, value in document.items() if key != "sha256"}
|
|
236
|
+
mutate(payload)
|
|
237
|
+
path.write_text(
|
|
238
|
+
json.dumps({**payload, "sha256": core._baseline_digest(payload)}),
|
|
239
|
+
encoding="utf-8",
|
|
240
|
+
)
|
|
241
|
+
return path
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def downgrade_landing_attempt_to_v1(core, worktree: Path) -> Path:
|
|
245
|
+
"""Produce the coherent v1 journal shape a pre-upgrade landing left behind."""
|
|
246
|
+
def mutate(payload):
|
|
247
|
+
payload.pop("policyDigest", None)
|
|
248
|
+
payload["contractVersion"] = 1
|
|
249
|
+
|
|
250
|
+
return rewrite_landing_attempt(core, worktree, mutate)
|
|
251
|
+
|
|
252
|
+
|
|
22
253
|
class WorktreeCleanupContract(unittest.TestCase):
|
|
254
|
+
def test_profile_scratch_and_generated_evidence_share_safe_removal_contract(self):
|
|
255
|
+
wrapup = load_wrapup()
|
|
256
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
257
|
+
main, worktree = create_merged_worktree(
|
|
258
|
+
Path(tmp), scratch_patterns=["PLAN.md"]
|
|
259
|
+
)
|
|
260
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
261
|
+
(worktree / "PLAN.md").write_text("plan\n", encoding="utf-8")
|
|
262
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
263
|
+
generated.parent.mkdir(parents=True)
|
|
264
|
+
generated.write_text("generated\n", encoding="utf-8")
|
|
265
|
+
evidence = wrapup.landing_verified_scratch_evidence(
|
|
266
|
+
str(worktree), str(main)
|
|
267
|
+
)
|
|
268
|
+
assessment = wrapup.ensure_worktree_removable(
|
|
269
|
+
str(worktree),
|
|
270
|
+
str(main),
|
|
271
|
+
verified_scratch_files=tuple(item["path"] for item in evidence),
|
|
272
|
+
verified_scratch_evidence=evidence,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
final = wrapup.remove_verified_worktree_scratch(
|
|
276
|
+
str(worktree),
|
|
277
|
+
str(main),
|
|
278
|
+
assessment,
|
|
279
|
+
verified_scratch_evidence=evidence,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
self.assertFalse(final.reasons)
|
|
283
|
+
self.assertFalse((worktree / "PLAN.md").exists())
|
|
284
|
+
self.assertFalse(generated.exists())
|
|
285
|
+
|
|
286
|
+
def test_profile_scratch_same_path_replacement_is_preserved(self):
|
|
287
|
+
wrapup = load_wrapup()
|
|
288
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
289
|
+
main, worktree = create_merged_worktree(
|
|
290
|
+
Path(tmp), scratch_patterns=["PLAN.md"]
|
|
291
|
+
)
|
|
292
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
293
|
+
plan = worktree / "PLAN.md"
|
|
294
|
+
plan.write_text("assessed\n", encoding="utf-8")
|
|
295
|
+
assessment = wrapup.ensure_worktree_removable(
|
|
296
|
+
str(worktree), str(main)
|
|
297
|
+
)
|
|
298
|
+
plan.unlink()
|
|
299
|
+
plan.write_text("replacement\n", encoding="utf-8")
|
|
300
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
301
|
+
wrapup.remove_verified_worktree_scratch(
|
|
302
|
+
str(worktree), str(main), assessment
|
|
303
|
+
)
|
|
304
|
+
self.assertIn("inventory no longer matches preview", stopped.exception.reason)
|
|
305
|
+
self.assertEqual(plan.read_text(encoding="utf-8"), "replacement\n")
|
|
306
|
+
|
|
307
|
+
def test_missing_generator_identity_is_not_downgraded_to_profile_scratch(self):
|
|
308
|
+
wrapup = load_wrapup()
|
|
309
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
310
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
311
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
312
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
313
|
+
generated.parent.mkdir(parents=True)
|
|
314
|
+
generated.write_text("generated\n", encoding="utf-8")
|
|
315
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
316
|
+
wrapup.ensure_worktree_removable(
|
|
317
|
+
str(worktree),
|
|
318
|
+
str(main),
|
|
319
|
+
verified_scratch_files=("dist-kit/package.tgz",),
|
|
320
|
+
verified_scratch_evidence=(),
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
self.assertIn("evidence", stopped.exception.detail)
|
|
324
|
+
self.assertTrue(generated.exists())
|
|
325
|
+
|
|
326
|
+
def test_generator_pattern_overlap_still_requires_exact_evidence(self):
|
|
327
|
+
wrapup = load_wrapup()
|
|
328
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
329
|
+
main, worktree = create_merged_worktree(
|
|
330
|
+
Path(tmp), scratch_patterns=["dist-kit/**"]
|
|
331
|
+
)
|
|
332
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
333
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
334
|
+
generated.parent.mkdir(parents=True)
|
|
335
|
+
generated.write_text("generated\n", encoding="utf-8")
|
|
336
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
337
|
+
wrapup.ensure_worktree_removable(
|
|
338
|
+
str(worktree), str(main), verified_scratch_files=()
|
|
339
|
+
)
|
|
340
|
+
self.assertIn(
|
|
341
|
+
"landing-generated scratch evidence is missing",
|
|
342
|
+
stopped.exception.detail,
|
|
343
|
+
)
|
|
344
|
+
self.assertTrue(generated.exists())
|
|
345
|
+
|
|
346
|
+
def test_legacy_worktree_gets_conservative_baseline_without_claiming_existing_files(self):
|
|
347
|
+
wrapup = load_wrapup()
|
|
348
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
349
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
350
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
351
|
+
core.artifact_baseline_path(worktree).unlink()
|
|
352
|
+
existing = worktree / "dist-kit/existing.tgz"
|
|
353
|
+
existing.parent.mkdir(parents=True)
|
|
354
|
+
existing.write_text("consumer\n", encoding="utf-8")
|
|
355
|
+
preserved = worktree / "consumer/keep.txt"
|
|
356
|
+
preserved.parent.mkdir(parents=True)
|
|
357
|
+
preserved.write_text("preserve\n", encoding="utf-8")
|
|
358
|
+
|
|
359
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
360
|
+
wrapup.landing_start_artifact_inventory(
|
|
361
|
+
str(worktree), str(main)
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
self.assertFalse(core.artifact_baseline_path(worktree).exists())
|
|
365
|
+
self.assertIn("consumer-owned", stopped.exception.reason)
|
|
366
|
+
self.assertTrue(existing.exists())
|
|
367
|
+
self.assertTrue(preserved.exists())
|
|
368
|
+
|
|
369
|
+
existing.unlink()
|
|
370
|
+
attempt = wrapup.landing_start_artifact_inventory(
|
|
371
|
+
str(worktree), str(main)
|
|
372
|
+
)
|
|
373
|
+
baseline = core.load_artifact_baseline(worktree)
|
|
374
|
+
self.assertNotIn("dist-kit/existing.tgz", baseline.initial_ignored_files)
|
|
375
|
+
self.assertIn("consumer/keep.txt", baseline.initial_ignored_files)
|
|
376
|
+
existing.write_text("landing-generated\n", encoding="utf-8")
|
|
377
|
+
evidence = wrapup.landing_verified_scratch_evidence(
|
|
378
|
+
str(worktree),
|
|
379
|
+
str(main),
|
|
380
|
+
expected_baseline_digest=attempt["baselineDigest"],
|
|
381
|
+
landing_start_files=tuple(attempt["generatedFiles"]),
|
|
382
|
+
)
|
|
383
|
+
self.assertEqual(
|
|
384
|
+
[item["path"] for item in evidence],
|
|
385
|
+
["dist-kit/existing.tgz"],
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
def test_preexisting_generated_blocker_does_not_poison_next_attempt(self):
|
|
389
|
+
wrapup = load_wrapup()
|
|
390
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
391
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
392
|
+
blocker = worktree / "dist-kit/preexisting.tgz"
|
|
393
|
+
blocker.parent.mkdir(parents=True)
|
|
394
|
+
blocker.write_text("consumer\n", encoding="utf-8")
|
|
395
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
396
|
+
attempt_path = core.artifact_baseline_path(worktree).with_name(
|
|
397
|
+
core.LANDING_ATTEMPT_FILE
|
|
398
|
+
)
|
|
399
|
+
|
|
400
|
+
with self.assertRaises(wrapup.Stop):
|
|
401
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
402
|
+
self.assertFalse(attempt_path.exists())
|
|
403
|
+
blocker.unlink()
|
|
404
|
+
attempt = wrapup.landing_start_artifact_inventory(
|
|
405
|
+
str(worktree), str(main)
|
|
406
|
+
)
|
|
407
|
+
self.assertTrue(attempt["newAttempt"])
|
|
408
|
+
|
|
409
|
+
def test_candidate_policy_blocks_generated_path_before_canonical_bootstrap(self):
|
|
410
|
+
wrapup = load_wrapup()
|
|
411
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
412
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
413
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
414
|
+
baseline_path = core.artifact_baseline_path(worktree)
|
|
415
|
+
baseline_path.unlink()
|
|
416
|
+
canonical_profile = main / "docs/agents/workflow-capabilities.json"
|
|
417
|
+
canonical = json.loads(canonical_profile.read_text(encoding="utf-8"))
|
|
418
|
+
del canonical["wrapup"]
|
|
419
|
+
canonical_profile.write_text(json.dumps(canonical), encoding="utf-8")
|
|
420
|
+
blocker = worktree / "dist-kit/bootstrap.tgz"
|
|
421
|
+
blocker.parent.mkdir(parents=True)
|
|
422
|
+
blocker.write_text("preserve\n", encoding="utf-8")
|
|
423
|
+
|
|
424
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
425
|
+
wrapup.landing_start_artifact_inventory(
|
|
426
|
+
str(worktree), str(main)
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
self.assertIn(
|
|
430
|
+
"landing-start generated paths are consumer-owned",
|
|
431
|
+
stopped.exception.reason,
|
|
432
|
+
)
|
|
433
|
+
self.assertFalse(baseline_path.exists())
|
|
434
|
+
self.assertFalse(
|
|
435
|
+
baseline_path.with_name(core.LANDING_ATTEMPT_FILE).exists()
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
def test_candidate_policy_cannot_widen_post_merge_deletion_authority(self):
|
|
439
|
+
wrapup = load_wrapup()
|
|
440
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
441
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
442
|
+
candidate_path = worktree / "docs/agents/workflow-capabilities.json"
|
|
443
|
+
candidate = json.loads(candidate_path.read_text(encoding="utf-8"))
|
|
444
|
+
candidate["wrapup"]["landingGeneratedArtifactPatterns"].append("**")
|
|
445
|
+
candidate_path.write_text(json.dumps(candidate), encoding="utf-8")
|
|
446
|
+
command(["git", "add", "docs/agents/workflow-capabilities.json"], worktree)
|
|
447
|
+
command(["git", "commit", "-m", "widen candidate policy"], worktree)
|
|
448
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
449
|
+
attempt_path = core.artifact_baseline_path(worktree).with_name(
|
|
450
|
+
core.LANDING_ATTEMPT_FILE
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
454
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
455
|
+
wrapup.ensure_worktree_removable(str(worktree), str(main))
|
|
456
|
+
|
|
457
|
+
self.assertIn(
|
|
458
|
+
"worktree cleanup policy differs from merged canonical origin/main",
|
|
459
|
+
stopped.exception.reason,
|
|
460
|
+
)
|
|
461
|
+
self.assertTrue(attempt_path.exists())
|
|
462
|
+
|
|
463
|
+
def test_policy_drift_during_attempt_cannot_claim_preexisting_consumer_file(self):
|
|
464
|
+
wrapup = load_wrapup()
|
|
465
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
466
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
467
|
+
victim = worktree / "consumer/victim.txt"
|
|
468
|
+
victim.parent.mkdir(parents=True)
|
|
469
|
+
victim.write_text("preserve\n", encoding="utf-8")
|
|
470
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
471
|
+
candidate_path = worktree / "docs/agents/workflow-capabilities.json"
|
|
472
|
+
candidate = json.loads(candidate_path.read_text(encoding="utf-8"))
|
|
473
|
+
candidate["wrapup"]["landingGeneratedArtifactPatterns"].append(
|
|
474
|
+
"consumer/**"
|
|
475
|
+
)
|
|
476
|
+
candidate_path.write_text(json.dumps(candidate), encoding="utf-8")
|
|
477
|
+
command(["git", "add", "docs/agents/workflow-capabilities.json"], worktree)
|
|
478
|
+
command(["git", "commit", "-m", "widen active attempt policy"], worktree)
|
|
479
|
+
|
|
480
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
481
|
+
wrapup.freeze_landing_artifact_evidence(
|
|
482
|
+
str(worktree), str(main), push_succeeded=True
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
self.assertIn(
|
|
486
|
+
"landing cleanup policy changed after attempt start",
|
|
487
|
+
stopped.exception.reason,
|
|
488
|
+
)
|
|
489
|
+
self.assertEqual(victim.read_text(encoding="utf-8"), "preserve\n")
|
|
490
|
+
|
|
491
|
+
def test_canonical_cleanup_rejects_generator_evidence_outside_policy(self):
|
|
492
|
+
wrapup = load_wrapup()
|
|
493
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
494
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
495
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
496
|
+
victim = worktree / "consumer/victim.txt"
|
|
497
|
+
victim.parent.mkdir(parents=True)
|
|
498
|
+
victim.write_text("preserve\n", encoding="utf-8")
|
|
499
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
500
|
+
baseline = core.load_artifact_baseline(worktree)
|
|
501
|
+
with core.verified_worktree_root(
|
|
502
|
+
worktree, baseline.root_device, baseline.root_inode
|
|
503
|
+
) as descriptor:
|
|
504
|
+
evidence = (
|
|
505
|
+
core.contained_regular_identity(
|
|
506
|
+
descriptor, "consumer/victim.txt"
|
|
507
|
+
),
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
511
|
+
wrapup.ensure_worktree_removable(
|
|
512
|
+
str(worktree),
|
|
513
|
+
str(main),
|
|
514
|
+
verified_scratch_evidence=evidence,
|
|
515
|
+
)
|
|
516
|
+
|
|
517
|
+
self.assertIn(
|
|
518
|
+
"generator evidence is outside canonical landing policy",
|
|
519
|
+
stopped.exception.detail,
|
|
520
|
+
)
|
|
521
|
+
self.assertEqual(victim.read_text(encoding="utf-8"), "preserve\n")
|
|
522
|
+
|
|
523
|
+
def test_missing_local_main_profile_cannot_disable_canonical_guard(self):
|
|
524
|
+
wrapup = load_wrapup()
|
|
525
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
526
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
527
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
528
|
+
wrapup.freeze_landing_artifact_evidence(
|
|
529
|
+
str(worktree), str(main), push_succeeded=True
|
|
530
|
+
)
|
|
531
|
+
(main / "docs/agents/workflow-capabilities.json").unlink()
|
|
532
|
+
victim = worktree / "consumer/ignored.txt"
|
|
533
|
+
victim.parent.mkdir(parents=True)
|
|
534
|
+
victim.write_text("preserve\n", encoding="utf-8")
|
|
535
|
+
|
|
536
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
537
|
+
wrapup.ensure_worktree_removable(str(worktree), str(main))
|
|
538
|
+
|
|
539
|
+
self.assertIn(
|
|
540
|
+
"dirty worktree: untracked non-scratch: consumer/ignored.txt",
|
|
541
|
+
stopped.exception.detail,
|
|
542
|
+
)
|
|
543
|
+
self.assertEqual(victim.read_text(encoding="utf-8"), "preserve\n")
|
|
544
|
+
self.assertTrue(worktree.is_dir())
|
|
545
|
+
|
|
546
|
+
def test_explicit_empty_landing_policy_is_distinct_from_missing(self):
|
|
547
|
+
wrapup = load_wrapup()
|
|
548
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
549
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
550
|
+
profile_path = Path(tmp) / "workflow-capabilities.json"
|
|
551
|
+
document = {
|
|
552
|
+
"worktreeLifecycle": {"enabled": True},
|
|
553
|
+
"wrapup": {"landingGeneratedArtifactPatterns": []},
|
|
554
|
+
}
|
|
555
|
+
profile_path.write_text(json.dumps(document), encoding="utf-8")
|
|
556
|
+
configured = core.load_profile(profile_path)
|
|
557
|
+
del document["wrapup"]
|
|
558
|
+
profile_path.write_text(json.dumps(document), encoding="utf-8")
|
|
559
|
+
missing = core.load_profile(profile_path)
|
|
560
|
+
|
|
561
|
+
self.assertTrue(
|
|
562
|
+
configured.landing_generated_artifact_policy_configured
|
|
563
|
+
)
|
|
564
|
+
self.assertEqual(configured.landing_generated_artifact_patterns, ())
|
|
565
|
+
self.assertFalse(missing.landing_generated_artifact_policy_configured)
|
|
566
|
+
|
|
567
|
+
def test_abandon_archives_drifted_frozen_attempt_without_touching_files(self):
|
|
568
|
+
wrapup = load_wrapup()
|
|
569
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
570
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
571
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
572
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
573
|
+
generated.parent.mkdir(parents=True)
|
|
574
|
+
generated.write_text("one\n", encoding="utf-8")
|
|
575
|
+
wrapup.freeze_landing_artifact_evidence(
|
|
576
|
+
str(worktree), str(main), push_succeeded=True
|
|
577
|
+
)
|
|
578
|
+
generated.write_text("two\n", encoding="utf-8")
|
|
579
|
+
|
|
580
|
+
args = SimpleNamespace(
|
|
581
|
+
branch="fix/268-cleanup",
|
|
582
|
+
body_file=None,
|
|
583
|
+
title=None,
|
|
584
|
+
anchor=None,
|
|
585
|
+
skip_malformed_drift=False,
|
|
586
|
+
abandon_unfinished_attempt=True,
|
|
587
|
+
)
|
|
588
|
+
previous = Path.cwd()
|
|
589
|
+
try:
|
|
590
|
+
os.chdir(main)
|
|
591
|
+
result = wrapup.cmd_land(args)
|
|
592
|
+
finally:
|
|
593
|
+
os.chdir(previous)
|
|
594
|
+
archive = Path(result["landing_attempt_abandoned"])
|
|
595
|
+
|
|
596
|
+
self.assertTrue(archive.is_file())
|
|
597
|
+
self.assertEqual(generated.read_text(encoding="utf-8"), "two\n")
|
|
598
|
+
with self.assertRaises(wrapup.Stop):
|
|
599
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
600
|
+
|
|
601
|
+
def test_cli_abandon_archives_attempt_even_when_baseline_is_missing(self):
|
|
602
|
+
wrapup = load_wrapup()
|
|
603
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
604
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
605
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
606
|
+
generated = worktree / "dist-kit/ambiguous.tgz"
|
|
607
|
+
generated.parent.mkdir(parents=True)
|
|
608
|
+
generated.write_text("keep\n", encoding="utf-8")
|
|
609
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
610
|
+
core.artifact_baseline_path(worktree).unlink()
|
|
611
|
+
args = SimpleNamespace(
|
|
612
|
+
branch="fix/268-cleanup",
|
|
613
|
+
body_file=None,
|
|
614
|
+
title=None,
|
|
615
|
+
anchor=None,
|
|
616
|
+
skip_malformed_drift=False,
|
|
617
|
+
abandon_unfinished_attempt=True,
|
|
618
|
+
)
|
|
619
|
+
previous = Path.cwd()
|
|
620
|
+
try:
|
|
621
|
+
os.chdir(main)
|
|
622
|
+
result = wrapup.cmd_land(args)
|
|
623
|
+
finally:
|
|
624
|
+
os.chdir(previous)
|
|
625
|
+
self.assertTrue(Path(result["landing_attempt_abandoned"]).is_file())
|
|
626
|
+
self.assertEqual(generated.read_text(encoding="utf-8"), "keep\n")
|
|
627
|
+
|
|
628
|
+
def test_profile_globs_match_root_and_nested_without_star_crossing_slash(self):
|
|
629
|
+
wrapup = load_wrapup()
|
|
630
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
631
|
+
self.assertTrue(core.path_glob_matches("__pycache__/a.pyc", "**/__pycache__/**"))
|
|
632
|
+
self.assertTrue(core.path_glob_matches("src/__pycache__/a.pyc", "**/__pycache__/**"))
|
|
633
|
+
self.assertTrue(core.path_glob_matches("dist-kit/a", "dist-kit/**"))
|
|
634
|
+
self.assertTrue(core.path_glob_matches("dist-kit/a/b", "dist-kit/**"))
|
|
635
|
+
self.assertFalse(core.path_glob_matches("dist-kit/a/b", "dist-kit/*"))
|
|
636
|
+
self.assertTrue(core.path_glob_matches("cache/7.tmp", "cache/[0-9].tmp"))
|
|
637
|
+
self.assertFalse(core.path_glob_matches("cache/x.tmp", "cache/[0-9].tmp"))
|
|
638
|
+
|
|
23
639
|
def test_active_profile_delegates_removal_safety_to_shared_assessment(self):
|
|
24
640
|
wrapup = load_wrapup()
|
|
25
641
|
calls = []
|
|
26
642
|
|
|
27
643
|
class FakeCore:
|
|
644
|
+
LifecycleError = RuntimeError
|
|
645
|
+
|
|
28
646
|
@staticmethod
|
|
29
647
|
def load_profile(path):
|
|
30
648
|
calls.append(("profile", path))
|
|
31
|
-
return
|
|
649
|
+
return SimpleNamespace(
|
|
650
|
+
landing_generated_artifact_policy_configured=True,
|
|
651
|
+
landing_generated_artifact_patterns=(),
|
|
652
|
+
scratch_patterns=(),
|
|
653
|
+
)
|
|
32
654
|
|
|
33
655
|
@staticmethod
|
|
34
656
|
def cleanup_assessment(profile, main, target, merge_target=None):
|
|
@@ -40,12 +662,979 @@ class WorktreeCleanupContract(unittest.TestCase):
|
|
|
40
662
|
profile = main / "docs/agents/workflow-capabilities.json"
|
|
41
663
|
profile.parent.mkdir(parents=True)
|
|
42
664
|
profile.write_text('{"worktreeLifecycle":{"enabled":true}}\n')
|
|
43
|
-
with
|
|
665
|
+
with (
|
|
666
|
+
patch.object(wrapup, "load_worktree_cleanup_core", return_value=FakeCore),
|
|
667
|
+
patch.object(
|
|
668
|
+
wrapup,
|
|
669
|
+
"load_canonical_landing_profile",
|
|
670
|
+
return_value=SimpleNamespace(),
|
|
671
|
+
),
|
|
672
|
+
):
|
|
44
673
|
with self.assertRaises(wrapup.Stop) as stopped:
|
|
45
674
|
wrapup.ensure_worktree_removable(str(main / "wt"), str(main))
|
|
46
675
|
|
|
47
676
|
self.assertIn("shared cleanup guard", stopped.exception.reason)
|
|
48
|
-
self.assertEqual(calls[1][-1], "origin/main")
|
|
677
|
+
self.assertEqual(calls[-1][-1], "origin/main")
|
|
678
|
+
|
|
679
|
+
def test_real_build_and_python_check_after_baseline_are_cleaned_by_land(self):
|
|
680
|
+
wrapup = load_wrapup()
|
|
681
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
682
|
+
root = Path(tmp)
|
|
683
|
+
main, worktree = create_real_kit_merged_worktree(root)
|
|
684
|
+
appendable = worktree / ".claude/logs/session.log"
|
|
685
|
+
appendable.parent.mkdir(parents=True, exist_ok=True)
|
|
686
|
+
appendable.write_text("appendable\n", encoding="utf-8")
|
|
687
|
+
(worktree / "PLAN.md").write_text("plan\n", encoding="utf-8")
|
|
688
|
+
# The first landing attempt journals its start before the real
|
|
689
|
+
# pre-push generators run. A resumed already-MERGED land must
|
|
690
|
+
# reuse that attempt instead of treating its outputs as user files.
|
|
691
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
692
|
+
command(["npm", "run", "kit:build"], worktree)
|
|
693
|
+
command([
|
|
694
|
+
os.sys.executable,
|
|
695
|
+
"-m",
|
|
696
|
+
"py_compile",
|
|
697
|
+
"scripts/worktree-lifecycle/core.py",
|
|
698
|
+
], worktree)
|
|
699
|
+
wrapup.freeze_landing_artifact_evidence(
|
|
700
|
+
str(worktree),
|
|
701
|
+
str(main),
|
|
702
|
+
push_succeeded=True,
|
|
703
|
+
)
|
|
704
|
+
self.assertTrue((worktree / "dist-kit/package.json").is_file())
|
|
705
|
+
self.assertTrue(
|
|
706
|
+
any(
|
|
707
|
+
(worktree / "scripts/worktree-lifecycle/__pycache__").glob(
|
|
708
|
+
"core.*.pyc"
|
|
709
|
+
)
|
|
710
|
+
)
|
|
711
|
+
)
|
|
712
|
+
|
|
713
|
+
real_run = wrapup.run
|
|
714
|
+
|
|
715
|
+
def landing_run(args, cwd=None, check=False):
|
|
716
|
+
if args[:3] == ["gh", "pr", "view"]:
|
|
717
|
+
fields = args[-1]
|
|
718
|
+
payload = (
|
|
719
|
+
{"number": 42, "state": "MERGED", "body": "**Retro:** n/a"}
|
|
720
|
+
if "number,state,body" in fields
|
|
721
|
+
else {"state": "MERGED"}
|
|
722
|
+
)
|
|
723
|
+
return subprocess.CompletedProcess(args, 0, json.dumps(payload), "")
|
|
724
|
+
if args[:3] == ["gh", "issue", "view"]:
|
|
725
|
+
return subprocess.CompletedProcess(
|
|
726
|
+
args, 0, json.dumps({"state": "CLOSED"}), ""
|
|
727
|
+
)
|
|
728
|
+
if args[:3] == ["gh", "pr", "list"]:
|
|
729
|
+
return subprocess.CompletedProcess(args, 0, "", "")
|
|
730
|
+
if args and args[0] == os.sys.executable:
|
|
731
|
+
return subprocess.CompletedProcess(args, 0, "", "")
|
|
732
|
+
return real_run(args, cwd=cwd, check=check)
|
|
733
|
+
|
|
734
|
+
args = SimpleNamespace(
|
|
735
|
+
branch="fix/268-real-generator",
|
|
736
|
+
body_file=None,
|
|
737
|
+
title=None,
|
|
738
|
+
anchor=None,
|
|
739
|
+
skip_malformed_drift=False,
|
|
740
|
+
)
|
|
741
|
+
previous = Path.cwd()
|
|
742
|
+
try:
|
|
743
|
+
os.chdir(main)
|
|
744
|
+
with (
|
|
745
|
+
patch.object(wrapup, "run", side_effect=landing_run),
|
|
746
|
+
patch.object(wrapup, "wait_for_merge_gate", return_value=True),
|
|
747
|
+
patch.object(wrapup, "kill_worktree_processes", return_value=[]),
|
|
748
|
+
):
|
|
749
|
+
first = wrapup.cmd_land(args)
|
|
750
|
+
second = wrapup.cmd_land(args)
|
|
751
|
+
finally:
|
|
752
|
+
os.chdir(previous)
|
|
753
|
+
|
|
754
|
+
generated = first["cleanup_guard"]["landing_generated_files"]
|
|
755
|
+
self.assertIn("dist-kit/package.json", generated)
|
|
756
|
+
self.assertTrue(
|
|
757
|
+
any(
|
|
758
|
+
path.startswith(
|
|
759
|
+
"scripts/worktree-lifecycle/__pycache__/core."
|
|
760
|
+
)
|
|
761
|
+
and path.endswith(".pyc")
|
|
762
|
+
for path in generated
|
|
763
|
+
),
|
|
764
|
+
)
|
|
765
|
+
self.assertEqual(first["worktree_removed"], str(worktree))
|
|
766
|
+
self.assertFalse(worktree.exists())
|
|
767
|
+
self.assertTrue(second["merged"])
|
|
768
|
+
|
|
769
|
+
def test_append_after_profile_log_assessment_is_preserved_and_stops(self):
|
|
770
|
+
wrapup = load_wrapup()
|
|
771
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
772
|
+
main, worktree = create_merged_worktree(
|
|
773
|
+
Path(tmp), scratch_patterns=[".claude/logs/**"]
|
|
774
|
+
)
|
|
775
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
776
|
+
log = worktree / ".claude/logs/session.log"
|
|
777
|
+
log.parent.mkdir(parents=True)
|
|
778
|
+
log.write_text("assessed\n", encoding="utf-8")
|
|
779
|
+
assessment = wrapup.ensure_worktree_removable(
|
|
780
|
+
str(worktree), str(main)
|
|
781
|
+
)
|
|
782
|
+
with log.open("a", encoding="utf-8") as handle:
|
|
783
|
+
handle.write("late\n")
|
|
784
|
+
with self.assertRaises(wrapup.Stop):
|
|
785
|
+
wrapup.remove_verified_worktree_scratch(
|
|
786
|
+
str(worktree), str(main), assessment
|
|
787
|
+
)
|
|
788
|
+
self.assertEqual(log.read_text(encoding="utf-8"), "assessed\nlate\n")
|
|
789
|
+
|
|
790
|
+
def test_profile_generated_path_present_in_creation_baseline_is_not_scratch(self):
|
|
791
|
+
wrapup = load_wrapup()
|
|
792
|
+
setup_step = {
|
|
793
|
+
"kind": "command",
|
|
794
|
+
"command": [
|
|
795
|
+
os.sys.executable,
|
|
796
|
+
"-c",
|
|
797
|
+
(
|
|
798
|
+
"from pathlib import Path; "
|
|
799
|
+
"p=Path('dist-kit/setup-owned.txt'); "
|
|
800
|
+
"p.parent.mkdir(parents=True); p.write_text('initial')"
|
|
801
|
+
),
|
|
802
|
+
],
|
|
803
|
+
}
|
|
804
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
805
|
+
main, worktree = create_merged_worktree(
|
|
806
|
+
Path(tmp),
|
|
807
|
+
setup_steps=[setup_step],
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
811
|
+
wrapup.landing_verified_scratch_files(
|
|
812
|
+
str(worktree),
|
|
813
|
+
str(main),
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
self.assertIn("dist-kit/setup-owned.txt", stopped.exception.reason)
|
|
817
|
+
|
|
818
|
+
def test_missing_creation_baseline_fails_safe(self):
|
|
819
|
+
wrapup = load_wrapup()
|
|
820
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
821
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
822
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
823
|
+
core.artifact_baseline_path(worktree).unlink()
|
|
824
|
+
|
|
825
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
826
|
+
wrapup.landing_verified_scratch_files(
|
|
827
|
+
str(worktree),
|
|
828
|
+
str(main),
|
|
829
|
+
)
|
|
830
|
+
|
|
831
|
+
self.assertIn("artifact provenance baseline", stopped.exception.reason)
|
|
832
|
+
|
|
833
|
+
def test_incoherent_creation_baseline_fails_safe(self):
|
|
834
|
+
wrapup = load_wrapup()
|
|
835
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
836
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
837
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
838
|
+
baseline_path = core.artifact_baseline_path(worktree)
|
|
839
|
+
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
|
|
840
|
+
baseline["branch"] = "fix/999-foreign"
|
|
841
|
+
baseline_path.write_text(json.dumps(baseline), encoding="utf-8")
|
|
842
|
+
|
|
843
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
844
|
+
wrapup.landing_verified_scratch_files(
|
|
845
|
+
str(worktree),
|
|
846
|
+
str(main),
|
|
847
|
+
)
|
|
848
|
+
|
|
849
|
+
self.assertIn("artifact provenance baseline", stopped.exception.reason)
|
|
850
|
+
|
|
851
|
+
def test_arbitrary_ignored_file_stops_exact_landing_cleanup(self):
|
|
852
|
+
wrapup = load_wrapup()
|
|
853
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
854
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
855
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
856
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
857
|
+
generated.parent.mkdir(parents=True)
|
|
858
|
+
generated.write_text("package", encoding="utf-8")
|
|
859
|
+
consumer = worktree / "consumer/private.cache"
|
|
860
|
+
consumer.parent.mkdir(parents=True)
|
|
861
|
+
consumer.write_text("mine", encoding="utf-8")
|
|
862
|
+
|
|
863
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
864
|
+
wrapup.ensure_worktree_removable(
|
|
865
|
+
str(worktree),
|
|
866
|
+
str(main),
|
|
867
|
+
verified_scratch_files=("dist-kit/package.tgz",),
|
|
868
|
+
)
|
|
869
|
+
|
|
870
|
+
self.assertIn("consumer/private.cache", stopped.exception.detail)
|
|
871
|
+
self.assertTrue(generated.exists())
|
|
872
|
+
self.assertTrue(consumer.exists())
|
|
873
|
+
|
|
874
|
+
def test_symlink_in_generated_evidence_stops_before_foreign_target_is_touched(self):
|
|
875
|
+
wrapup = load_wrapup()
|
|
876
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
877
|
+
root = Path(tmp)
|
|
878
|
+
main, worktree = create_merged_worktree(root)
|
|
879
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
880
|
+
foreign = root / "foreign.txt"
|
|
881
|
+
foreign.write_text("keep", encoding="utf-8")
|
|
882
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
883
|
+
generated.parent.mkdir(parents=True)
|
|
884
|
+
generated.symlink_to(foreign)
|
|
885
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
886
|
+
wrapup.ensure_worktree_removable(
|
|
887
|
+
str(worktree),
|
|
888
|
+
str(main),
|
|
889
|
+
verified_scratch_files=("dist-kit/package.tgz",),
|
|
890
|
+
)
|
|
891
|
+
|
|
892
|
+
self.assertIn("evidence is missing", stopped.exception.detail)
|
|
893
|
+
self.assertEqual(foreign.read_text(encoding="utf-8"), "keep")
|
|
894
|
+
self.assertTrue(generated.is_symlink())
|
|
895
|
+
|
|
896
|
+
def test_late_generated_pattern_write_is_not_added_to_verified_evidence(self):
|
|
897
|
+
wrapup = load_wrapup()
|
|
898
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
899
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
900
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
901
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
902
|
+
generated.parent.mkdir(parents=True)
|
|
903
|
+
generated.write_text("package", encoding="utf-8")
|
|
904
|
+
evidence = wrapup.landing_verified_scratch_evidence(
|
|
905
|
+
str(worktree),
|
|
906
|
+
str(main),
|
|
907
|
+
landing_start_files=(),
|
|
908
|
+
)
|
|
909
|
+
assessment = wrapup.ensure_worktree_removable(
|
|
910
|
+
str(worktree),
|
|
911
|
+
str(main),
|
|
912
|
+
verified_scratch_evidence=evidence,
|
|
913
|
+
)
|
|
914
|
+
late = worktree / "dist-kit/late.tgz"
|
|
915
|
+
late.parent.mkdir(parents=True, exist_ok=True)
|
|
916
|
+
late.write_text("late", encoding="utf-8")
|
|
917
|
+
|
|
918
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
919
|
+
wrapup.remove_verified_worktree_scratch(
|
|
920
|
+
str(worktree),
|
|
921
|
+
str(main),
|
|
922
|
+
assessment,
|
|
923
|
+
verified_scratch_evidence=evidence,
|
|
924
|
+
)
|
|
925
|
+
|
|
926
|
+
self.assertIn("dist-kit/late.tgz", stopped.exception.reason)
|
|
927
|
+
self.assertTrue(generated.exists())
|
|
928
|
+
self.assertTrue(late.exists())
|
|
929
|
+
|
|
930
|
+
def test_same_path_generated_replacement_is_preserved_by_frozen_evidence(self):
|
|
931
|
+
wrapup = load_wrapup()
|
|
932
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
933
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
934
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
935
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
936
|
+
generated.parent.mkdir(parents=True)
|
|
937
|
+
generated.write_text("generator-owned", encoding="utf-8")
|
|
938
|
+
evidence = wrapup.landing_verified_scratch_evidence(
|
|
939
|
+
str(worktree),
|
|
940
|
+
str(main),
|
|
941
|
+
landing_start_files=(),
|
|
942
|
+
)
|
|
943
|
+
assessment = wrapup.ensure_worktree_removable(
|
|
944
|
+
str(worktree),
|
|
945
|
+
str(main),
|
|
946
|
+
verified_scratch_evidence=evidence,
|
|
947
|
+
)
|
|
948
|
+
generated.unlink()
|
|
949
|
+
generated.write_text("user replacement", encoding="utf-8")
|
|
950
|
+
|
|
951
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
952
|
+
wrapup.remove_verified_worktree_scratch(
|
|
953
|
+
str(worktree),
|
|
954
|
+
str(main),
|
|
955
|
+
assessment,
|
|
956
|
+
verified_scratch_evidence=evidence,
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
self.assertIn("identity changed", stopped.exception.reason)
|
|
960
|
+
self.assertEqual(generated.read_text(encoding="utf-8"), "user replacement")
|
|
961
|
+
|
|
962
|
+
def test_landing_start_generated_file_is_rejected_before_attempt_is_written(self):
|
|
963
|
+
wrapup = load_wrapup()
|
|
964
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
965
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
966
|
+
user_file = worktree / "dist-kit/user-note.txt"
|
|
967
|
+
user_file.parent.mkdir(parents=True)
|
|
968
|
+
user_file.write_text("keep", encoding="utf-8")
|
|
969
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
970
|
+
wrapup.landing_start_artifact_inventory(
|
|
971
|
+
str(worktree), str(main)
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
self.assertIn("landing-start generated paths", stopped.exception.reason)
|
|
975
|
+
self.assertTrue(user_file.exists())
|
|
976
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
977
|
+
self.assertFalse(
|
|
978
|
+
core.artifact_baseline_path(worktree)
|
|
979
|
+
.with_name(core.LANDING_ATTEMPT_FILE)
|
|
980
|
+
.exists()
|
|
981
|
+
)
|
|
982
|
+
|
|
983
|
+
def test_frozen_landing_output_does_not_claim_a_later_private_path(self):
|
|
984
|
+
wrapup = load_wrapup()
|
|
985
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
986
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
987
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
988
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
989
|
+
generated.parent.mkdir(parents=True)
|
|
990
|
+
generated.write_text("generator", encoding="utf-8")
|
|
991
|
+
wrapup.freeze_landing_artifact_evidence(
|
|
992
|
+
str(worktree), str(main), push_succeeded=True
|
|
993
|
+
)
|
|
994
|
+
private = worktree / "dist-kit/private.log"
|
|
995
|
+
private.parent.mkdir(parents=True, exist_ok=True)
|
|
996
|
+
private.write_text("keep", encoding="utf-8")
|
|
997
|
+
|
|
998
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
999
|
+
wrapup.freeze_landing_artifact_evidence(
|
|
1000
|
+
str(worktree), str(main), push_succeeded=True
|
|
1001
|
+
)
|
|
1002
|
+
|
|
1003
|
+
self.assertIn("evidence changed", stopped.exception.reason)
|
|
1004
|
+
self.assertTrue(generated.exists())
|
|
1005
|
+
self.assertTrue(private.exists())
|
|
1006
|
+
|
|
1007
|
+
def test_failed_push_retry_validates_replacement_before_invoking_push(self):
|
|
1008
|
+
wrapup = load_wrapup()
|
|
1009
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1010
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1011
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1012
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
1013
|
+
generated.parent.mkdir(parents=True)
|
|
1014
|
+
generated.write_text("generator", encoding="utf-8")
|
|
1015
|
+
wrapup.freeze_landing_artifact_evidence(
|
|
1016
|
+
str(worktree), str(main), push_succeeded=False
|
|
1017
|
+
)
|
|
1018
|
+
generated.unlink()
|
|
1019
|
+
generated.write_text("user replacement", encoding="utf-8")
|
|
1020
|
+
pushes = []
|
|
1021
|
+
real_git = wrapup.git
|
|
1022
|
+
|
|
1023
|
+
def observed_git(args, **kwargs):
|
|
1024
|
+
if args and args[0] == "push":
|
|
1025
|
+
pushes.append(args)
|
|
1026
|
+
return real_git(args, **kwargs)
|
|
1027
|
+
|
|
1028
|
+
args = SimpleNamespace(
|
|
1029
|
+
branch="fix/268-cleanup",
|
|
1030
|
+
body_file=None,
|
|
1031
|
+
title=None,
|
|
1032
|
+
anchor=None,
|
|
1033
|
+
skip_malformed_drift=False,
|
|
1034
|
+
abandon_unfinished_attempt=False,
|
|
1035
|
+
)
|
|
1036
|
+
previous = Path.cwd()
|
|
1037
|
+
try:
|
|
1038
|
+
os.chdir(main)
|
|
1039
|
+
with (
|
|
1040
|
+
patch.object(wrapup, "git", side_effect=observed_git),
|
|
1041
|
+
self.assertRaises(wrapup.Stop) as stopped,
|
|
1042
|
+
):
|
|
1043
|
+
wrapup.cmd_land(args)
|
|
1044
|
+
finally:
|
|
1045
|
+
os.chdir(previous)
|
|
1046
|
+
|
|
1047
|
+
self.assertIn("evidence changed", stopped.exception.reason)
|
|
1048
|
+
self.assertEqual(pushes, [])
|
|
1049
|
+
self.assertEqual(
|
|
1050
|
+
generated.read_text(encoding="utf-8"), "user replacement"
|
|
1051
|
+
)
|
|
1052
|
+
|
|
1053
|
+
def test_first_attempt_protects_preexisting_generated_path_before_push(self):
|
|
1054
|
+
wrapup = load_wrapup()
|
|
1055
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1056
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1057
|
+
user_file = worktree / "dist-kit/package.json"
|
|
1058
|
+
user_file.parent.mkdir(parents=True)
|
|
1059
|
+
user_file.write_text("user bytes", encoding="utf-8")
|
|
1060
|
+
pushes = []
|
|
1061
|
+
real_git = wrapup.git
|
|
1062
|
+
|
|
1063
|
+
def observed_git(args, **kwargs):
|
|
1064
|
+
if args and args[0] == "push":
|
|
1065
|
+
pushes.append(args)
|
|
1066
|
+
return real_git(args, **kwargs)
|
|
1067
|
+
|
|
1068
|
+
args = SimpleNamespace(
|
|
1069
|
+
branch="fix/268-cleanup",
|
|
1070
|
+
body_file=None,
|
|
1071
|
+
title=None,
|
|
1072
|
+
anchor=None,
|
|
1073
|
+
skip_malformed_drift=False,
|
|
1074
|
+
abandon_unfinished_attempt=False,
|
|
1075
|
+
)
|
|
1076
|
+
previous = Path.cwd()
|
|
1077
|
+
try:
|
|
1078
|
+
os.chdir(main)
|
|
1079
|
+
with (
|
|
1080
|
+
patch.object(wrapup, "git", side_effect=observed_git),
|
|
1081
|
+
self.assertRaises(wrapup.Stop) as stopped,
|
|
1082
|
+
):
|
|
1083
|
+
wrapup.cmd_land(args)
|
|
1084
|
+
finally:
|
|
1085
|
+
os.chdir(previous)
|
|
1086
|
+
|
|
1087
|
+
self.assertIn("consumer-owned", stopped.exception.reason)
|
|
1088
|
+
self.assertEqual(pushes, [])
|
|
1089
|
+
self.assertEqual(user_file.read_text(encoding="utf-8"), "user bytes")
|
|
1090
|
+
|
|
1091
|
+
def test_unfinished_attempt_can_be_archived_without_claiming_ambiguous_files(self):
|
|
1092
|
+
wrapup = load_wrapup()
|
|
1093
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1094
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1095
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1096
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
1097
|
+
baseline_path = core.artifact_baseline_path(worktree)
|
|
1098
|
+
attempt_path = baseline_path.with_name(core.LANDING_ATTEMPT_FILE)
|
|
1099
|
+
attempt = json.loads(attempt_path.read_text(encoding="utf-8"))
|
|
1100
|
+
payload = {
|
|
1101
|
+
key: value
|
|
1102
|
+
for key, value in attempt.items()
|
|
1103
|
+
if key not in {"policyDigest", "sha256"}
|
|
1104
|
+
}
|
|
1105
|
+
payload["contractVersion"] = 1
|
|
1106
|
+
attempt_path.write_text(
|
|
1107
|
+
json.dumps({**payload, "sha256": core._baseline_digest(payload)}),
|
|
1108
|
+
encoding="utf-8",
|
|
1109
|
+
)
|
|
1110
|
+
baseline_path.unlink()
|
|
1111
|
+
ambiguous = worktree / "dist-kit/ambiguous.tgz"
|
|
1112
|
+
ambiguous.parent.mkdir(parents=True)
|
|
1113
|
+
ambiguous.write_text("unknown owner", encoding="utf-8")
|
|
1114
|
+
(main / "docs/agents/workflow-capabilities.json").unlink()
|
|
1115
|
+
|
|
1116
|
+
archive = Path(wrapup.abandon_unfinished_landing_attempt(
|
|
1117
|
+
str(worktree), str(main)
|
|
1118
|
+
))
|
|
1119
|
+
|
|
1120
|
+
self.assertTrue(archive.is_file())
|
|
1121
|
+
self.assertEqual(ambiguous.read_text(encoding="utf-8"), "unknown owner")
|
|
1122
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
1123
|
+
wrapup.landing_start_artifact_inventory(
|
|
1124
|
+
str(worktree), str(main)
|
|
1125
|
+
)
|
|
1126
|
+
self.assertIn("consumer-owned", stopped.exception.reason)
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
class LandingAttemptJournalContract(unittest.TestCase):
|
|
1130
|
+
"""#274 — one nofollow-safe journal classification and no dead v1 surface."""
|
|
1131
|
+
|
|
1132
|
+
def test_symlinked_attempt_journal_is_refused_without_following_it(self):
|
|
1133
|
+
wrapup = load_wrapup()
|
|
1134
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1135
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1136
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1137
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
1138
|
+
attempt_path = core.landing_attempt_path(worktree)
|
|
1139
|
+
moved = attempt_path.with_name("relocated-attempt.json")
|
|
1140
|
+
os.replace(attempt_path, moved)
|
|
1141
|
+
attempt_path.symlink_to(moved)
|
|
1142
|
+
frozen_bytes = moved.read_text(encoding="utf-8")
|
|
1143
|
+
|
|
1144
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
1145
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1146
|
+
|
|
1147
|
+
self.assertIn("not a regular file", stopped.exception.reason)
|
|
1148
|
+
self.assertTrue(attempt_path.is_symlink())
|
|
1149
|
+
self.assertEqual(moved.read_text(encoding="utf-8"), frozen_bytes)
|
|
1150
|
+
|
|
1151
|
+
def test_dangling_attempt_symlink_is_never_silently_replaced(self):
|
|
1152
|
+
wrapup = load_wrapup()
|
|
1153
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1154
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1155
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1156
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
1157
|
+
attempt_path = core.landing_attempt_path(worktree)
|
|
1158
|
+
absent = attempt_path.with_name("absent-attempt.json")
|
|
1159
|
+
attempt_path.unlink()
|
|
1160
|
+
attempt_path.symlink_to(absent)
|
|
1161
|
+
|
|
1162
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
1163
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1164
|
+
|
|
1165
|
+
self.assertIn("not a regular file", stopped.exception.reason)
|
|
1166
|
+
self.assertTrue(attempt_path.is_symlink())
|
|
1167
|
+
self.assertFalse(os.path.lexists(absent))
|
|
1168
|
+
|
|
1169
|
+
def test_attempt_presence_uses_one_nofollow_classification(self):
|
|
1170
|
+
wrapup = load_wrapup()
|
|
1171
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1172
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1173
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
1174
|
+
attempt_path = core.landing_attempt_path(worktree)
|
|
1175
|
+
|
|
1176
|
+
self.assertFalse(core.landing_attempt_exists(attempt_path))
|
|
1177
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1178
|
+
self.assertTrue(core.landing_attempt_exists(attempt_path))
|
|
1179
|
+
attempt_path.unlink()
|
|
1180
|
+
attempt_path.symlink_to(attempt_path.with_name("absent-attempt.json"))
|
|
1181
|
+
self.assertTrue(core.landing_attempt_exists(attempt_path))
|
|
1182
|
+
|
|
1183
|
+
def test_archived_v2_receipt_name_is_not_stamped_v1(self):
|
|
1184
|
+
wrapup = load_wrapup()
|
|
1185
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1186
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1187
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1188
|
+
|
|
1189
|
+
archive = Path(
|
|
1190
|
+
wrapup.abandon_unfinished_landing_attempt(str(worktree), str(main))
|
|
1191
|
+
)
|
|
1192
|
+
|
|
1193
|
+
self.assertTrue(archive.is_file())
|
|
1194
|
+
self.assertNotIn("-v1", archive.name)
|
|
1195
|
+
self.assertIn(".v2.abandoned-", archive.name)
|
|
1196
|
+
self.assertTrue(
|
|
1197
|
+
archive.name.startswith(core_archive_stem(wrapup)),
|
|
1198
|
+
archive.name,
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
def test_archived_v1_receipt_name_reports_its_own_contract_version(self):
|
|
1202
|
+
wrapup = load_wrapup()
|
|
1203
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1204
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1205
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1206
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
1207
|
+
downgrade_landing_attempt_to_v1(core, worktree)
|
|
1208
|
+
|
|
1209
|
+
archive = Path(
|
|
1210
|
+
wrapup.abandon_unfinished_landing_attempt(str(worktree), str(main))
|
|
1211
|
+
)
|
|
1212
|
+
|
|
1213
|
+
self.assertTrue(archive.is_file())
|
|
1214
|
+
self.assertIn(".v1.abandoned-", archive.name)
|
|
1215
|
+
|
|
1216
|
+
def test_cleanup_authorization_always_returns_an_assessment_or_stops(self):
|
|
1217
|
+
"""Coverage for the removed `cleanup is not None` dead branches."""
|
|
1218
|
+
wrapup = load_wrapup()
|
|
1219
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1220
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1221
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1222
|
+
|
|
1223
|
+
assessment = wrapup.ensure_worktree_removable(str(worktree), str(main))
|
|
1224
|
+
self.assertIsNotNone(assessment)
|
|
1225
|
+
self.assertEqual(assessment.reasons, ())
|
|
1226
|
+
|
|
1227
|
+
blocker = worktree / "consumer/blocker.txt"
|
|
1228
|
+
blocker.parent.mkdir(parents=True)
|
|
1229
|
+
blocker.write_text("keep", encoding="utf-8")
|
|
1230
|
+
with self.assertRaises(wrapup.Stop):
|
|
1231
|
+
wrapup.ensure_worktree_removable(str(worktree), str(main))
|
|
1232
|
+
self.assertEqual(blocker.read_text(encoding="utf-8"), "keep")
|
|
1233
|
+
|
|
1234
|
+
def test_land_reports_generated_files_without_a_dead_guard_flag(self):
|
|
1235
|
+
wrapup = load_wrapup()
|
|
1236
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1237
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1238
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1239
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
1240
|
+
generated.parent.mkdir(parents=True)
|
|
1241
|
+
generated.write_text("generated\n", encoding="utf-8")
|
|
1242
|
+
wrapup.freeze_landing_artifact_evidence(
|
|
1243
|
+
str(worktree), str(main), push_succeeded=True
|
|
1244
|
+
)
|
|
1245
|
+
|
|
1246
|
+
report = run_land(wrapup, main, land_args())
|
|
1247
|
+
|
|
1248
|
+
self.assertEqual(
|
|
1249
|
+
report["cleanup_guard"],
|
|
1250
|
+
{
|
|
1251
|
+
"assumptions_read": False,
|
|
1252
|
+
"landing_generated_files": ["dist-kit/package.tgz"],
|
|
1253
|
+
},
|
|
1254
|
+
)
|
|
1255
|
+
self.assertEqual(report["worktree_removed"], str(worktree))
|
|
1256
|
+
self.assertFalse(worktree.exists())
|
|
1257
|
+
|
|
1258
|
+
def test_tampered_v2_journal_claiming_generated_files_still_stops_land(self):
|
|
1259
|
+
wrapup = load_wrapup()
|
|
1260
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1261
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1262
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1263
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
1264
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
1265
|
+
generated.parent.mkdir(parents=True)
|
|
1266
|
+
generated.write_text("generated\n", encoding="utf-8")
|
|
1267
|
+
wrapup.freeze_landing_artifact_evidence(
|
|
1268
|
+
str(worktree), str(main), push_succeeded=True
|
|
1269
|
+
)
|
|
1270
|
+
victim = worktree / "dist-kit/claimed.tgz"
|
|
1271
|
+
victim.write_text("consumer bytes", encoding="utf-8")
|
|
1272
|
+
rewrite_landing_attempt(
|
|
1273
|
+
core,
|
|
1274
|
+
worktree,
|
|
1275
|
+
lambda payload: payload.__setitem__(
|
|
1276
|
+
"generatedFiles", ["dist-kit/claimed.tgz"]
|
|
1277
|
+
),
|
|
1278
|
+
)
|
|
1279
|
+
|
|
1280
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
1281
|
+
run_land(wrapup, main, land_args())
|
|
1282
|
+
|
|
1283
|
+
self.assertIn("consumer-owned", stopped.exception.reason)
|
|
1284
|
+
self.assertIn("dist-kit/claimed.tgz", stopped.exception.reason)
|
|
1285
|
+
self.assertEqual(victim.read_text(encoding="utf-8"), "consumer bytes")
|
|
1286
|
+
self.assertTrue(worktree.is_dir())
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
class LegacyLandingAttemptContract(unittest.TestCase):
|
|
1290
|
+
"""#275 — a coherent v1 journal is legacy, not corruption, and has a route out."""
|
|
1291
|
+
|
|
1292
|
+
def assert_legacy_stop(self, stop):
|
|
1293
|
+
self.assertIn("superseded v1 journal contract", stop.reason)
|
|
1294
|
+
self.assertIn("--abandon-unfinished-attempt", stop.reason)
|
|
1295
|
+
self.assertNotIn("incoherent", stop.reason)
|
|
1296
|
+
|
|
1297
|
+
def test_coherent_v1_started_journal_is_classified_as_legacy(self):
|
|
1298
|
+
wrapup = load_wrapup()
|
|
1299
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1300
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1301
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1302
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
1303
|
+
downgrade_landing_attempt_to_v1(core, worktree)
|
|
1304
|
+
|
|
1305
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
1306
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1307
|
+
|
|
1308
|
+
self.assert_legacy_stop(stopped.exception)
|
|
1309
|
+
|
|
1310
|
+
def test_coherent_v1_frozen_journal_is_classified_as_legacy(self):
|
|
1311
|
+
wrapup = load_wrapup()
|
|
1312
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1313
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1314
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1315
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
1316
|
+
generated.parent.mkdir(parents=True)
|
|
1317
|
+
generated.write_text("generated\n", encoding="utf-8")
|
|
1318
|
+
wrapup.freeze_landing_artifact_evidence(
|
|
1319
|
+
str(worktree), str(main), push_succeeded=True
|
|
1320
|
+
)
|
|
1321
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
1322
|
+
downgrade_landing_attempt_to_v1(core, worktree)
|
|
1323
|
+
|
|
1324
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
1325
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1326
|
+
|
|
1327
|
+
self.assert_legacy_stop(stopped.exception)
|
|
1328
|
+
self.assertEqual(generated.read_text(encoding="utf-8"), "generated\n")
|
|
1329
|
+
|
|
1330
|
+
def test_incoherent_v1_journal_stays_classified_as_corruption(self):
|
|
1331
|
+
wrapup = load_wrapup()
|
|
1332
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1333
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1334
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1335
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
1336
|
+
|
|
1337
|
+
def mutate(payload):
|
|
1338
|
+
payload.pop("policyDigest", None)
|
|
1339
|
+
payload["contractVersion"] = 1
|
|
1340
|
+
payload["branch"] = "fix/999-foreign"
|
|
1341
|
+
|
|
1342
|
+
rewrite_landing_attempt(core, worktree, mutate)
|
|
1343
|
+
|
|
1344
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
1345
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1346
|
+
|
|
1347
|
+
self.assertIn("incoherent", stopped.exception.reason)
|
|
1348
|
+
|
|
1349
|
+
def test_land_names_the_safe_abandon_command_for_a_legacy_attempt(self):
|
|
1350
|
+
wrapup = load_wrapup()
|
|
1351
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1352
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1353
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1354
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
1355
|
+
downgrade_landing_attempt_to_v1(core, worktree)
|
|
1356
|
+
consumer = worktree / "consumer/keep.txt"
|
|
1357
|
+
consumer.parent.mkdir(parents=True)
|
|
1358
|
+
consumer.write_text("mine", encoding="utf-8")
|
|
1359
|
+
|
|
1360
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
1361
|
+
run_land(wrapup, main, land_args())
|
|
1362
|
+
|
|
1363
|
+
self.assert_legacy_stop(stopped.exception)
|
|
1364
|
+
self.assertTrue(worktree.is_dir())
|
|
1365
|
+
self.assertEqual(consumer.read_text(encoding="utf-8"), "mine")
|
|
1366
|
+
|
|
1367
|
+
def test_v2_archival_stays_valid_without_baseline_or_local_main_profile(self):
|
|
1368
|
+
wrapup = load_wrapup()
|
|
1369
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1370
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1371
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1372
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
1373
|
+
core.artifact_baseline_path(worktree).unlink()
|
|
1374
|
+
(main / "docs/agents/workflow-capabilities.json").unlink()
|
|
1375
|
+
ambiguous = worktree / "dist-kit/ambiguous.tgz"
|
|
1376
|
+
ambiguous.parent.mkdir(parents=True)
|
|
1377
|
+
ambiguous.write_text("unknown owner", encoding="utf-8")
|
|
1378
|
+
|
|
1379
|
+
archive = Path(
|
|
1380
|
+
wrapup.abandon_unfinished_landing_attempt(str(worktree), str(main))
|
|
1381
|
+
)
|
|
1382
|
+
|
|
1383
|
+
self.assertTrue(archive.is_file())
|
|
1384
|
+
self.assertIn(".v2.abandoned-", archive.name)
|
|
1385
|
+
self.assertEqual(ambiguous.read_text(encoding="utf-8"), "unknown owner")
|
|
1386
|
+
|
|
1387
|
+
def test_legacy_abandon_claims_no_generated_or_consumer_file(self):
|
|
1388
|
+
wrapup = load_wrapup()
|
|
1389
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1390
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1391
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1392
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
1393
|
+
generated.parent.mkdir(parents=True)
|
|
1394
|
+
generated.write_text("generated\n", encoding="utf-8")
|
|
1395
|
+
wrapup.freeze_landing_artifact_evidence(
|
|
1396
|
+
str(worktree), str(main), push_succeeded=True
|
|
1397
|
+
)
|
|
1398
|
+
consumer = worktree / "consumer/keep.txt"
|
|
1399
|
+
consumer.parent.mkdir(parents=True)
|
|
1400
|
+
consumer.write_text("mine", encoding="utf-8")
|
|
1401
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
1402
|
+
downgrade_landing_attempt_to_v1(core, worktree)
|
|
1403
|
+
|
|
1404
|
+
archive = Path(
|
|
1405
|
+
wrapup.abandon_unfinished_landing_attempt(str(worktree), str(main))
|
|
1406
|
+
)
|
|
1407
|
+
|
|
1408
|
+
self.assertTrue(archive.is_file())
|
|
1409
|
+
self.assertIn(".v1.abandoned-", archive.name)
|
|
1410
|
+
self.assertEqual(generated.read_text(encoding="utf-8"), "generated\n")
|
|
1411
|
+
self.assertEqual(consumer.read_text(encoding="utf-8"), "mine")
|
|
1412
|
+
self.assertTrue(worktree.is_dir())
|
|
1413
|
+
|
|
1414
|
+
|
|
1415
|
+
class CanonicalCleanupDriftRecovery(unittest.TestCase):
|
|
1416
|
+
"""#272 — canonical policy drift stays fail-closed but has a supported route out."""
|
|
1417
|
+
|
|
1418
|
+
def drifted_merged_worktree(self, root: Path, wrapup, mutate) -> tuple[Path, Path]:
|
|
1419
|
+
"""Freeze a landing attempt, then drift canonical policy after the merge."""
|
|
1420
|
+
main, worktree = create_merged_worktree(
|
|
1421
|
+
root, scratch_patterns=[".claude/logs/**"]
|
|
1422
|
+
)
|
|
1423
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1424
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
1425
|
+
generated.parent.mkdir(parents=True)
|
|
1426
|
+
generated.write_text("generated\n", encoding="utf-8")
|
|
1427
|
+
wrapup.freeze_landing_artifact_evidence(
|
|
1428
|
+
str(worktree), str(main), push_succeeded=True
|
|
1429
|
+
)
|
|
1430
|
+
profile_path = main / "docs/agents/workflow-capabilities.json"
|
|
1431
|
+
document = json.loads(profile_path.read_text(encoding="utf-8"))
|
|
1432
|
+
mutate(document)
|
|
1433
|
+
profile_path.write_text(json.dumps(document), encoding="utf-8")
|
|
1434
|
+
command(["git", "add", "docs/agents/workflow-capabilities.json"], main)
|
|
1435
|
+
command(["git", "commit", "-m", "drift canonical cleanup policy"], main)
|
|
1436
|
+
command(["git", "push", "origin", "main"], main)
|
|
1437
|
+
return main, worktree
|
|
1438
|
+
|
|
1439
|
+
@staticmethod
|
|
1440
|
+
def widen_scratch(document):
|
|
1441
|
+
document["worktreeLifecycle"]["scratchPatterns"] = [
|
|
1442
|
+
".claude/logs/**", "**/.cache/**",
|
|
1443
|
+
]
|
|
1444
|
+
|
|
1445
|
+
@staticmethod
|
|
1446
|
+
def drop_generator_pattern(document):
|
|
1447
|
+
document["wrapup"]["landingGeneratedArtifactPatterns"] = [
|
|
1448
|
+
"**/__pycache__/**",
|
|
1449
|
+
]
|
|
1450
|
+
|
|
1451
|
+
def test_first_cleanup_stops_before_every_mutation_and_names_recovery(self):
|
|
1452
|
+
wrapup = load_wrapup()
|
|
1453
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1454
|
+
main, worktree = self.drifted_merged_worktree(
|
|
1455
|
+
Path(tmp), wrapup, self.widen_scratch
|
|
1456
|
+
)
|
|
1457
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
1458
|
+
|
|
1459
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
1460
|
+
run_land(wrapup, main, land_args())
|
|
1461
|
+
|
|
1462
|
+
self.assertIn(
|
|
1463
|
+
"worktree cleanup policy differs from merged canonical origin/main",
|
|
1464
|
+
stopped.exception.reason,
|
|
1465
|
+
)
|
|
1466
|
+
self.assertIn("--recover-canonical-cleanup", stopped.exception.reason)
|
|
1467
|
+
self.assertTrue(worktree.is_dir())
|
|
1468
|
+
self.assertEqual(generated.read_text(encoding="utf-8"), "generated\n")
|
|
1469
|
+
self.assertIn(
|
|
1470
|
+
"fix/268-cleanup",
|
|
1471
|
+
command(["git", "branch", "--list", "fix/268-cleanup"], main).stdout,
|
|
1472
|
+
)
|
|
1473
|
+
|
|
1474
|
+
def test_recovery_retires_the_merged_worktree_and_branch_idempotently(self):
|
|
1475
|
+
wrapup = load_wrapup()
|
|
1476
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1477
|
+
main, worktree = self.drifted_merged_worktree(
|
|
1478
|
+
Path(tmp), wrapup, self.widen_scratch
|
|
1479
|
+
)
|
|
1480
|
+
log = worktree / ".claude/logs/session.log"
|
|
1481
|
+
log.parent.mkdir(parents=True)
|
|
1482
|
+
log.write_text("session\n", encoding="utf-8")
|
|
1483
|
+
|
|
1484
|
+
first = run_land(
|
|
1485
|
+
wrapup, main, land_args(recover_canonical_cleanup=True)
|
|
1486
|
+
)
|
|
1487
|
+
second = run_land(
|
|
1488
|
+
wrapup, main, land_args(recover_canonical_cleanup=True)
|
|
1489
|
+
)
|
|
1490
|
+
|
|
1491
|
+
self.assertEqual(first["worktree_removed"], str(worktree))
|
|
1492
|
+
self.assertEqual(
|
|
1493
|
+
first["cleanup_guard"]["landing_generated_files"],
|
|
1494
|
+
["dist-kit/package.tgz"],
|
|
1495
|
+
)
|
|
1496
|
+
self.assertFalse(worktree.exists())
|
|
1497
|
+
self.assertEqual(first["branch_retired"], True)
|
|
1498
|
+
self.assertEqual(second["worktree_removed"], None)
|
|
1499
|
+
self.assertEqual(second["branch_retired"], "already absent")
|
|
1500
|
+
self.assertEqual(
|
|
1501
|
+
command(["git", "branch", "--list", "fix/268-cleanup"], main).stdout,
|
|
1502
|
+
"",
|
|
1503
|
+
)
|
|
1504
|
+
|
|
1505
|
+
def test_recovery_refuses_evidence_outside_canonical_policy(self):
|
|
1506
|
+
wrapup = load_wrapup()
|
|
1507
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1508
|
+
main, worktree = self.drifted_merged_worktree(
|
|
1509
|
+
Path(tmp), wrapup, self.drop_generator_pattern
|
|
1510
|
+
)
|
|
1511
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
1512
|
+
|
|
1513
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
1514
|
+
run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
|
|
1515
|
+
|
|
1516
|
+
self.assertIn(
|
|
1517
|
+
"outside canonical cleanup policy", stopped.exception.reason
|
|
1518
|
+
)
|
|
1519
|
+
self.assertIn("dist-kit/package.tgz", stopped.exception.reason)
|
|
1520
|
+
self.assertEqual(generated.read_text(encoding="utf-8"), "generated\n")
|
|
1521
|
+
self.assertTrue(worktree.is_dir())
|
|
1522
|
+
|
|
1523
|
+
def test_recovery_refuses_a_changed_frozen_identity(self):
|
|
1524
|
+
wrapup = load_wrapup()
|
|
1525
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1526
|
+
main, worktree = self.drifted_merged_worktree(
|
|
1527
|
+
Path(tmp), wrapup, self.widen_scratch
|
|
1528
|
+
)
|
|
1529
|
+
generated = worktree / "dist-kit/package.tgz"
|
|
1530
|
+
generated.write_text("replaced by a user\n", encoding="utf-8")
|
|
1531
|
+
|
|
1532
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
1533
|
+
run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
|
|
1534
|
+
|
|
1535
|
+
self.assertIn("evidence changed", stopped.exception.reason)
|
|
1536
|
+
self.assertEqual(
|
|
1537
|
+
generated.read_text(encoding="utf-8"), "replaced by a user\n"
|
|
1538
|
+
)
|
|
1539
|
+
self.assertTrue(worktree.is_dir())
|
|
1540
|
+
|
|
1541
|
+
def test_recovery_preserves_pre_existing_and_foreign_files(self):
|
|
1542
|
+
wrapup = load_wrapup()
|
|
1543
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1544
|
+
root = Path(tmp)
|
|
1545
|
+
main, worktree = self.drifted_merged_worktree(
|
|
1546
|
+
root, wrapup, self.widen_scratch
|
|
1547
|
+
)
|
|
1548
|
+
consumer = worktree / "consumer/private.cache"
|
|
1549
|
+
consumer.parent.mkdir(parents=True)
|
|
1550
|
+
consumer.write_text("mine", encoding="utf-8")
|
|
1551
|
+
foreign = root / "foreign.txt"
|
|
1552
|
+
foreign.write_text("keep", encoding="utf-8")
|
|
1553
|
+
|
|
1554
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
1555
|
+
run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
|
|
1556
|
+
|
|
1557
|
+
self.assertIn("consumer/private.cache", stopped.exception.detail)
|
|
1558
|
+
self.assertEqual(consumer.read_text(encoding="utf-8"), "mine")
|
|
1559
|
+
self.assertEqual(foreign.read_text(encoding="utf-8"), "keep")
|
|
1560
|
+
self.assertTrue((worktree / "dist-kit/package.tgz").exists())
|
|
1561
|
+
self.assertTrue(worktree.is_dir())
|
|
1562
|
+
|
|
1563
|
+
def test_recovery_refuses_an_unmerged_branch(self):
|
|
1564
|
+
wrapup = load_wrapup()
|
|
1565
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1566
|
+
main, worktree = create_merged_worktree(Path(tmp))
|
|
1567
|
+
wrapup.landing_start_artifact_inventory(str(worktree), str(main))
|
|
1568
|
+
wrapup.freeze_landing_artifact_evidence(
|
|
1569
|
+
str(worktree), str(main), push_succeeded=True
|
|
1570
|
+
)
|
|
1571
|
+
(worktree / "later.txt").write_text("unmerged\n", encoding="utf-8")
|
|
1572
|
+
command(["git", "add", "later.txt"], worktree)
|
|
1573
|
+
command(["git", "commit", "-m", "unmerged work"], worktree)
|
|
1574
|
+
|
|
1575
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
1576
|
+
run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
|
|
1577
|
+
|
|
1578
|
+
self.assertIn("not merged into canonical origin/main",
|
|
1579
|
+
stopped.exception.reason)
|
|
1580
|
+
self.assertTrue(worktree.is_dir())
|
|
1581
|
+
|
|
1582
|
+
def test_recovery_refuses_an_unfrozen_attempt(self):
|
|
1583
|
+
wrapup = load_wrapup()
|
|
1584
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1585
|
+
main, worktree = self.drifted_merged_worktree(
|
|
1586
|
+
Path(tmp), wrapup, self.widen_scratch
|
|
1587
|
+
)
|
|
1588
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
1589
|
+
rewrite_landing_attempt(
|
|
1590
|
+
core,
|
|
1591
|
+
worktree,
|
|
1592
|
+
lambda payload: payload.update(
|
|
1593
|
+
{"state": "started", "authorizedEvidence": [],
|
|
1594
|
+
"pushSucceeded": False},
|
|
1595
|
+
),
|
|
1596
|
+
)
|
|
1597
|
+
|
|
1598
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
1599
|
+
run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
|
|
1600
|
+
|
|
1601
|
+
self.assertIn("frozen landing attempt", stopped.exception.reason)
|
|
1602
|
+
self.assertTrue((worktree / "dist-kit/package.tgz").exists())
|
|
1603
|
+
self.assertTrue(worktree.is_dir())
|
|
1604
|
+
|
|
1605
|
+
def test_recovery_never_adopts_a_legacy_journal(self):
|
|
1606
|
+
wrapup = load_wrapup()
|
|
1607
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
1608
|
+
main, worktree = self.drifted_merged_worktree(
|
|
1609
|
+
Path(tmp), wrapup, self.widen_scratch
|
|
1610
|
+
)
|
|
1611
|
+
core = wrapup.load_worktree_cleanup_core()
|
|
1612
|
+
downgrade_landing_attempt_to_v1(core, worktree)
|
|
1613
|
+
|
|
1614
|
+
with self.assertRaises(wrapup.Stop) as stopped:
|
|
1615
|
+
run_land(wrapup, main, land_args(recover_canonical_cleanup=True))
|
|
1616
|
+
|
|
1617
|
+
self.assertIn("incoherent", stopped.exception.reason)
|
|
1618
|
+
self.assertTrue((worktree / "dist-kit/package.tgz").exists())
|
|
1619
|
+
self.assertTrue(worktree.is_dir())
|
|
1620
|
+
|
|
1621
|
+
def test_abandon_and_recovery_flags_are_mutually_exclusive(self):
|
|
1622
|
+
result = subprocess.run(
|
|
1623
|
+
[
|
|
1624
|
+
os.sys.executable, str(WRAPUP), "land", "--branch", "fix/1-x",
|
|
1625
|
+
"--abandon-unfinished-attempt", "--recover-canonical-cleanup",
|
|
1626
|
+
],
|
|
1627
|
+
cwd=REPO,
|
|
1628
|
+
capture_output=True,
|
|
1629
|
+
text=True,
|
|
1630
|
+
)
|
|
1631
|
+
|
|
1632
|
+
self.assertEqual(result.returncode, 2)
|
|
1633
|
+
self.assertIn("not allowed with argument", result.stderr)
|
|
1634
|
+
|
|
1635
|
+
|
|
1636
|
+
def core_archive_stem(wrapup) -> str:
|
|
1637
|
+
return wrapup.load_worktree_cleanup_core().LANDING_ATTEMPT_ARCHIVE_STEM
|
|
49
1638
|
|
|
50
1639
|
|
|
51
1640
|
if __name__ == "__main__":
|