prizmkit 1.0.34 → 1.0.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/agents/prizm-dev-team-dev.md +11 -20
  3. package/bundled/agents/prizm-dev-team-reviewer.md +10 -19
  4. package/bundled/dev-pipeline/README.md +14 -17
  5. package/bundled/dev-pipeline/assets/prizm-dev-team-integration.md +16 -22
  6. package/bundled/dev-pipeline/launch-bugfix-daemon.sh +8 -0
  7. package/bundled/dev-pipeline/launch-daemon.sh +2 -0
  8. package/bundled/dev-pipeline/lib/worktree.sh +164 -0
  9. package/bundled/dev-pipeline/retry-bug.sh +5 -2
  10. package/bundled/dev-pipeline/retry-feature.sh +5 -2
  11. package/bundled/dev-pipeline/run-bugfix.sh +167 -2
  12. package/bundled/dev-pipeline/run.sh +169 -2
  13. package/bundled/dev-pipeline/scripts/check-session-status.py +3 -1
  14. package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +0 -8
  15. package/bundled/dev-pipeline/scripts/update-bug-status.py +24 -1
  16. package/bundled/dev-pipeline/scripts/update-feature-status.py +3 -2
  17. package/bundled/dev-pipeline/templates/bootstrap-tier1.md +3 -9
  18. package/bundled/dev-pipeline/templates/bootstrap-tier2.md +2 -8
  19. package/bundled/dev-pipeline/templates/bootstrap-tier3.md +36 -43
  20. package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +1 -1
  21. package/bundled/dev-pipeline/templates/session-status-schema.json +1 -1
  22. package/bundled/dev-pipeline/tests/test_check_session.py +4 -0
  23. package/bundled/dev-pipeline/tests/test_update_feature_status.py +70 -0
  24. package/bundled/dev-pipeline/tests/test_worktree.py +236 -0
  25. package/bundled/dev-pipeline/tests/test_worktree_integration.py +796 -0
  26. package/bundled/skills/_metadata.json +1 -1
  27. package/bundled/skills/prizmkit-implement/SKILL.md +4 -2
  28. package/bundled/team/prizm-dev-team.json +3 -17
  29. package/package.json +1 -1
  30. package/src/clean.js +0 -2
  31. package/src/manifest.js +8 -4
  32. package/src/scaffold.js +69 -3
  33. package/src/upgrade.js +32 -5
  34. package/bundled/agents/prizm-dev-team-coordinator.md +0 -141
  35. package/bundled/agents/prizm-dev-team-pm.md +0 -126
  36. package/bundled/dev-pipeline/scripts/validate-framework.sh +0 -87
@@ -0,0 +1,796 @@
1
+ """Integration tests for F-001 Git Worktree Automation.
2
+
3
+ Covers all 6 user stories from spec.md:
4
+ US-1: Isolated Session Execution via Worktree
5
+ US-2: Auto-Merge on Success
6
+ US-3: Conflict Detection and Recording
7
+ US-4: Optional Auto-Push
8
+ US-5: Worktree Cleanup
9
+ US-6: Backward Compatibility Toggle
10
+
11
+ These tests verify cross-module interactions between:
12
+ - lib/worktree.sh (worktree lifecycle functions)
13
+ - run.sh / run-bugfix.sh (integration hooks)
14
+ - scripts/check-session-status.py (merge_conflict status recognition)
15
+ - scripts/update-feature-status.py (merge_conflict state handling)
16
+ - scripts/update-bug-status.py (merge_conflict state handling)
17
+ - templates/session-status-schema.json (schema validity)
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import subprocess
23
+ import sys
24
+ import pytest
25
+
26
+
27
+ SCRIPT_DIR = os.path.join(os.path.dirname(__file__), "..")
28
+ LIB_DIR = os.path.join(SCRIPT_DIR, "lib")
29
+ SCRIPTS_DIR = os.path.join(SCRIPT_DIR, "scripts")
30
+ TEMPLATES_DIR = os.path.join(SCRIPT_DIR, "templates")
31
+
32
+
33
+ # ======================================================================
34
+ # Helpers
35
+ # ======================================================================
36
+
37
+
38
+ def _init_git_repo(path):
39
+ """Initialize a git repo with an initial commit on 'main'."""
40
+ subprocess.run(["git", "init", str(path)], capture_output=True, check=True)
41
+ subprocess.run(
42
+ ["git", "-C", str(path), "config", "user.email", "test@test.com"],
43
+ capture_output=True, check=True,
44
+ )
45
+ subprocess.run(
46
+ ["git", "-C", str(path), "config", "user.name", "Test"],
47
+ capture_output=True, check=True,
48
+ )
49
+ readme = os.path.join(str(path), "README.md")
50
+ with open(readme, "w") as f:
51
+ f.write("# Test Repo\n")
52
+ subprocess.run(["git", "-C", str(path), "add", "."], capture_output=True, check=True)
53
+ subprocess.run(
54
+ ["git", "-C", str(path), "commit", "-m", "Initial commit"],
55
+ capture_output=True, check=True,
56
+ )
57
+ subprocess.run(
58
+ ["git", "-C", str(path), "branch", "-M", "main"],
59
+ capture_output=True, check=True,
60
+ )
61
+
62
+
63
+ def _run_worktree_script(script_body, env=None):
64
+ """Run a bash script with worktree.sh sourced."""
65
+ worktree_sh_path = os.path.join(os.path.abspath(LIB_DIR), "worktree.sh")
66
+ full_script = "\n".join([
67
+ "#!/usr/bin/env bash",
68
+ "set -euo pipefail",
69
+ 'log_info() { echo "[INFO] $*"; }',
70
+ 'log_warn() { echo "[WARN] $*"; }',
71
+ 'log_error() { echo "[ERROR] $*"; }',
72
+ 'log_success() { echo "[SUCCESS] $*"; }',
73
+ 'source "%s"' % worktree_sh_path,
74
+ script_body,
75
+ ])
76
+ merged_env = dict(os.environ)
77
+ if env:
78
+ merged_env.update(env)
79
+ return subprocess.run(
80
+ ["bash", "-c", full_script],
81
+ capture_output=True, text=True, env=merged_env,
82
+ )
83
+
84
+
85
+ def _import_module(name, filename):
86
+ """Import a Python script as a module."""
87
+ import importlib.util
88
+ path = os.path.join(SCRIPTS_DIR, filename)
89
+ spec = importlib.util.spec_from_file_location(name, path)
90
+ mod = importlib.util.module_from_spec(spec)
91
+ sys.modules[name] = mod
92
+ spec.loader.exec_module(mod)
93
+ return mod
94
+
95
+
96
+ # Lazy imports of the Python modules under test
97
+ _css = None
98
+ _ufs = None
99
+ _ubs = None
100
+
101
+
102
+ def _get_check_session_module():
103
+ global _css
104
+ if _css is None:
105
+ _css = _import_module("check_session_status_integ", "check-session-status.py")
106
+ return _css
107
+
108
+
109
+ def _get_update_feature_module():
110
+ global _ufs
111
+ if _ufs is None:
112
+ _ufs = _import_module("update_feature_status_integ", "update-feature-status.py")
113
+ return _ufs
114
+
115
+
116
+ def _get_update_bug_module():
117
+ global _ubs
118
+ if _ubs is None:
119
+ _ubs = _import_module("update_bug_status_integ", "update-bug-status.py")
120
+ return _ubs
121
+
122
+
123
+ # ======================================================================
124
+ # US-1: Isolated Session Execution via Worktree
125
+ # ======================================================================
126
+
127
+
128
+ class TestUS1_IsolatedSessionExecution:
129
+ """US-1: Each AI session runs in a dedicated git worktree branch."""
130
+
131
+ def test_ac_1_1_worktree_created_at_deterministic_path(self, tmp_path):
132
+ """AC-1.1: Worktree created at <state-dir>/worktrees/<session-id>/."""
133
+ repo = tmp_path / "repo"
134
+ repo.mkdir()
135
+ _init_git_repo(repo)
136
+ wt_base = tmp_path / "state" / "worktrees"
137
+
138
+ script = (
139
+ 'worktree_create "{repo}" "{wt_base}" "F-001-20260318120000" "main"\n'
140
+ 'echo "PATH=$_WORKTREE_PATH"\n'
141
+ ).format(repo=repo, wt_base=wt_base)
142
+
143
+ result = _run_worktree_script(script)
144
+ assert result.returncode == 0
145
+
146
+ path_line = [l for l in result.stdout.strip().split("\n") if l.startswith("PATH=")]
147
+ assert len(path_line) == 1
148
+ wt_path = path_line[0].split("=", 1)[1]
149
+
150
+ # Deterministic path check
151
+ expected_path = str(wt_base / "F-001-20260318120000")
152
+ assert wt_path == expected_path
153
+ assert os.path.isdir(wt_path)
154
+
155
+ def test_ac_1_2_branch_naming_pattern(self, tmp_path):
156
+ """AC-1.2: Branch name follows 'worktree/<feature-id>-<timestamp>'."""
157
+ repo = tmp_path / "repo"
158
+ repo.mkdir()
159
+ _init_git_repo(repo)
160
+ wt_base = tmp_path / "worktrees"
161
+
162
+ script = (
163
+ 'worktree_create "{repo}" "{wt_base}" "F-001-20260318120000" "main"\n'
164
+ 'echo "BRANCH=$_WORKTREE_BRANCH"\n'
165
+ ).format(repo=repo, wt_base=wt_base)
166
+
167
+ result = _run_worktree_script(script)
168
+ assert result.returncode == 0
169
+
170
+ branch_line = [l for l in result.stdout.strip().split("\n") if l.startswith("BRANCH=")]
171
+ assert len(branch_line) == 1
172
+ branch = branch_line[0].split("=", 1)[1]
173
+ assert branch == "worktree/F-001-20260318120000"
174
+
175
+ def test_ac_1_3_cli_session_runs_in_worktree_directory(self, tmp_path):
176
+ """AC-1.3: AI CLI session's working directory is the worktree root.
177
+
178
+ Verify that spawn_and_wait_session changes directory to worktree path.
179
+ (We test the cd logic in worktree.sh rather than spawning an actual CLI.)
180
+ """
181
+ repo = tmp_path / "repo"
182
+ repo.mkdir()
183
+ _init_git_repo(repo)
184
+ wt_base = tmp_path / "worktrees"
185
+
186
+ # Create worktree, then verify pwd inside it
187
+ script = (
188
+ 'worktree_create "{repo}" "{wt_base}" "cd-test" "main"\n'
189
+ 'cd "$_WORKTREE_PATH"\n'
190
+ 'pwd\n'
191
+ ).format(repo=repo, wt_base=wt_base)
192
+
193
+ result = _run_worktree_script(script)
194
+ assert result.returncode == 0
195
+ expected = str(wt_base / "cd-test")
196
+ # Resolve symlinks for macOS /private/tmp
197
+ actual_pwd = result.stdout.strip().split("\n")[-1]
198
+ assert os.path.realpath(actual_pwd) == os.path.realpath(expected)
199
+
200
+ def test_ac_1_4_worktree_created_from_current_head(self, tmp_path):
201
+ """AC-1.4: Worktree created from the current HEAD of main."""
202
+ repo = tmp_path / "repo"
203
+ repo.mkdir()
204
+ _init_git_repo(repo)
205
+
206
+ # Get main HEAD commit
207
+ main_head = subprocess.run(
208
+ ["git", "-C", str(repo), "rev-parse", "HEAD"],
209
+ capture_output=True, text=True, check=True,
210
+ ).stdout.strip()
211
+
212
+ wt_base = tmp_path / "worktrees"
213
+ script = (
214
+ 'worktree_create "{repo}" "{wt_base}" "head-test" "main"\n'
215
+ 'wt_head=$(git -C "$_WORKTREE_PATH" rev-parse HEAD)\n'
216
+ 'echo "WT_HEAD=$wt_head"\n'
217
+ ).format(repo=repo, wt_base=wt_base)
218
+
219
+ result = _run_worktree_script(script)
220
+ assert result.returncode == 0
221
+ wt_head_line = [l for l in result.stdout.strip().split("\n") if l.startswith("WT_HEAD=")]
222
+ wt_head = wt_head_line[0].split("=", 1)[1]
223
+ assert wt_head == main_head
224
+
225
+
226
+ # ======================================================================
227
+ # US-2: Auto-Merge on Success
228
+ # ======================================================================
229
+
230
+
231
+ class TestUS2_AutoMergeOnSuccess:
232
+ """US-2: Successful sessions are auto-merged back into main."""
233
+
234
+ def test_ac_2_1_merge_no_ff_on_success(self, tmp_path):
235
+ """AC-2.1: On success, pipeline performs git merge --no-ff."""
236
+ repo = tmp_path / "repo"
237
+ repo.mkdir()
238
+ _init_git_repo(repo)
239
+ wt_base = tmp_path / "worktrees"
240
+
241
+ script = (
242
+ 'worktree_create "{repo}" "{wt_base}" "merge-noff" "main"\n'
243
+ 'echo "new content" > "$_WORKTREE_PATH/feature.txt"\n'
244
+ 'git -C "$_WORKTREE_PATH" add feature.txt\n'
245
+ 'git -C "$_WORKTREE_PATH" commit -m "Add feature"\n'
246
+ 'worktree_merge "{repo}" "$_WORKTREE_BRANCH" "main" "F-001" "merge-noff"\n'
247
+ 'echo "MERGE=$_MERGE_RESULT"\n'
248
+ # Verify it's a merge commit (has 2 parents)
249
+ 'parents=$(git -C "{repo}" log -1 --format="%P" HEAD)\n'
250
+ 'parent_count=$(echo "$parents" | wc -w | tr -d " ")\n'
251
+ 'echo "PARENTS=$parent_count"\n'
252
+ ).format(repo=repo, wt_base=wt_base)
253
+
254
+ result = _run_worktree_script(script)
255
+ assert result.returncode == 0
256
+ assert "MERGE=success" in result.stdout
257
+ assert "PARENTS=2" in result.stdout
258
+
259
+ def test_ac_2_2_merge_commit_message_includes_ids(self, tmp_path):
260
+ """AC-2.2: Merge commit message includes feature/bug ID and session ID."""
261
+ repo = tmp_path / "repo"
262
+ repo.mkdir()
263
+ _init_git_repo(repo)
264
+ wt_base = tmp_path / "worktrees"
265
+
266
+ script = (
267
+ 'worktree_create "{repo}" "{wt_base}" "msg-test" "main"\n'
268
+ 'echo "x" > "$_WORKTREE_PATH/x.txt"\n'
269
+ 'git -C "$_WORKTREE_PATH" add x.txt\n'
270
+ 'git -C "$_WORKTREE_PATH" commit -m "Work"\n'
271
+ 'worktree_merge "{repo}" "$_WORKTREE_BRANCH" "main" "F-001" "msg-test"\n'
272
+ 'git -C "{repo}" log -1 --format="%s" HEAD\n'
273
+ ).format(repo=repo, wt_base=wt_base)
274
+
275
+ result = _run_worktree_script(script)
276
+ assert result.returncode == 0
277
+ lines = result.stdout.strip().split("\n")
278
+ # Last line should be the commit message
279
+ commit_msg = lines[-1]
280
+ assert "F-001" in commit_msg
281
+ assert "msg-test" in commit_msg
282
+
283
+ def test_ac_2_3_worktree_branch_deleted_after_successful_merge(self, tmp_path):
284
+ """AC-2.3: After merge, worktree branch and directory are deleted."""
285
+ repo = tmp_path / "repo"
286
+ repo.mkdir()
287
+ _init_git_repo(repo)
288
+ wt_base = tmp_path / "worktrees"
289
+
290
+ script = (
291
+ 'worktree_create "{repo}" "{wt_base}" "del-test" "main"\n'
292
+ 'local_path="$_WORKTREE_PATH"\n'
293
+ 'local_branch="$_WORKTREE_BRANCH"\n'
294
+ 'echo "y" > "$_WORKTREE_PATH/y.txt"\n'
295
+ 'git -C "$_WORKTREE_PATH" add y.txt\n'
296
+ 'git -C "$_WORKTREE_PATH" commit -m "Work"\n'
297
+ 'worktree_merge "{repo}" "$local_branch" "main" "F-001" "del-test"\n'
298
+ 'worktree_cleanup "{repo}" "$local_path" "$local_branch"\n'
299
+ '[ -d "$local_path" ] && echo "DIR_EXISTS" || echo "DIR_GONE"\n'
300
+ 'git -C "{repo}" rev-parse --verify "$local_branch" 2>/dev/null && echo "BRANCH_EXISTS" || echo "BRANCH_GONE"\n'
301
+ ).format(repo=repo, wt_base=wt_base)
302
+
303
+ result = _run_worktree_script(script)
304
+ assert result.returncode == 0
305
+ assert "DIR_GONE" in result.stdout
306
+ assert "BRANCH_GONE" in result.stdout
307
+
308
+
309
+ # ======================================================================
310
+ # US-3: Conflict Detection and Recording
311
+ # ======================================================================
312
+
313
+
314
+ class TestUS3_ConflictDetectionAndRecording:
315
+ """US-3: Merge conflicts detected and recorded as distinct status."""
316
+
317
+ def test_ac_3_1_merge_abort_on_conflict(self, tmp_path):
318
+ """AC-3.1: On conflict, merge is aborted (working tree left clean)."""
319
+ repo = tmp_path / "repo"
320
+ repo.mkdir()
321
+ _init_git_repo(repo)
322
+ wt_base = tmp_path / "worktrees"
323
+
324
+ script = (
325
+ 'worktree_create "{repo}" "{wt_base}" "conflict-abort" "main"\n'
326
+ 'echo "wt change" > "$_WORKTREE_PATH/README.md"\n'
327
+ 'git -C "$_WORKTREE_PATH" add README.md\n'
328
+ 'git -C "$_WORKTREE_PATH" commit -m "WT change"\n'
329
+ 'echo "main change" > "{repo}/README.md"\n'
330
+ 'git -C "{repo}" add README.md\n'
331
+ 'git -C "{repo}" commit -m "Main change"\n'
332
+ 'worktree_merge "{repo}" "worktree/conflict-abort" "main" "F-001" "conflict-abort" || true\n'
333
+ 'echo "MERGE=$_MERGE_RESULT"\n'
334
+ # Verify working tree is clean after abort
335
+ 'dirty=$(git -C "{repo}" status --porcelain)\n'
336
+ '[ -z "$dirty" ] && echo "CLEAN" || echo "DIRTY"\n'
337
+ ).format(repo=repo, wt_base=wt_base)
338
+
339
+ result = _run_worktree_script(script)
340
+ assert "MERGE=conflict" in result.stdout
341
+ assert "CLEAN" in result.stdout
342
+
343
+ def test_ac_3_2_session_status_set_to_merge_conflict(self):
344
+ """AC-3.2: merge_conflict recognized by check-session-status.py."""
345
+ css = _get_check_session_module()
346
+ data = {"status": "merge_conflict"}
347
+ assert css.determine_status(data) == "merge_conflict"
348
+
349
+ def test_ac_3_3_schema_includes_merge_conflict(self):
350
+ """AC-3.3: merge_conflict is in session-status-schema.json."""
351
+ schema_path = os.path.join(TEMPLATES_DIR, "session-status-schema.json")
352
+ with open(schema_path, "r", encoding="utf-8") as f:
353
+ schema = json.load(f)
354
+ status_enum = schema["properties"]["status"]["enum"]
355
+ assert "merge_conflict" in status_enum
356
+
357
+ def test_ac_3_4_feature_status_recognizes_merge_conflict(self, tmp_path):
358
+ """AC-3.4: update-feature-status.py treats merge_conflict as retryable."""
359
+ ufs = _get_update_feature_module()
360
+ assert "merge_conflict" in ufs.SESSION_STATUS_VALUES
361
+
362
+ def test_ac_3_4_bug_status_recognizes_merge_conflict(self):
363
+ """AC-3.4: update-bug-status.py treats merge_conflict as retryable."""
364
+ ubs = _get_update_bug_module()
365
+ assert "merge_conflict" in ubs.SESSION_STATUS_VALUES
366
+
367
+ def test_ac_3_5_worktree_preserved_on_conflict(self, tmp_path):
368
+ """AC-3.5: Worktree branch is preserved (not deleted) on merge conflict."""
369
+ repo = tmp_path / "repo"
370
+ repo.mkdir()
371
+ _init_git_repo(repo)
372
+ wt_base = tmp_path / "worktrees"
373
+
374
+ script = (
375
+ 'worktree_create "{repo}" "{wt_base}" "preserve-test" "main"\n'
376
+ 'echo "wt" > "$_WORKTREE_PATH/README.md"\n'
377
+ 'git -C "$_WORKTREE_PATH" add README.md\n'
378
+ 'git -C "$_WORKTREE_PATH" commit -m "WT"\n'
379
+ 'echo "main" > "{repo}/README.md"\n'
380
+ 'git -C "{repo}" add README.md\n'
381
+ 'git -C "{repo}" commit -m "Main"\n'
382
+ 'worktree_merge "{repo}" "worktree/preserve-test" "main" "F-001" "preserve-test" || true\n'
383
+ 'echo "MERGE=$_MERGE_RESULT"\n'
384
+ # Do NOT call worktree_cleanup (simulating merge_conflict path)
385
+ '[ -d "$_WORKTREE_PATH" ] && echo "DIR_EXISTS" || echo "DIR_GONE"\n'
386
+ 'git -C "{repo}" rev-parse --verify "worktree/preserve-test" 2>/dev/null && echo "BRANCH_EXISTS" || echo "BRANCH_GONE"\n'
387
+ ).format(repo=repo, wt_base=wt_base)
388
+
389
+ result = _run_worktree_script(script)
390
+ assert "MERGE=conflict" in result.stdout
391
+ assert "DIR_EXISTS" in result.stdout
392
+ assert "BRANCH_EXISTS" in result.stdout
393
+
394
+
395
+ # ======================================================================
396
+ # US-4: Optional Auto-Push
397
+ # ======================================================================
398
+
399
+
400
+ class TestUS4_OptionalAutoPush:
401
+ """US-4: Conflict-free merges optionally auto-push to remote."""
402
+
403
+ def test_ac_4_1_env_var_documentation_in_run_sh(self):
404
+ """AC-4.1: AUTO_PUSH env var is documented in run.sh header."""
405
+ run_sh_path = os.path.join(SCRIPT_DIR, "run.sh")
406
+ with open(run_sh_path, "r") as f:
407
+ header = f.read(3000) # Read header section
408
+ assert "AUTO_PUSH" in header
409
+
410
+ def test_ac_4_2_auto_push_documented_in_run_bugfix_sh(self):
411
+ """AC-4.1 (bugfix): AUTO_PUSH documented in run-bugfix.sh header."""
412
+ run_bugfix_path = os.path.join(SCRIPT_DIR, "run-bugfix.sh")
413
+ with open(run_bugfix_path, "r") as f:
414
+ header = f.read(2000)
415
+ assert "AUTO_PUSH" in header
416
+
417
+ def test_ac_4_3_auto_push_defaults_to_0(self):
418
+ """AC-4.3: AUTO_PUSH defaults to 0 (disabled)."""
419
+ run_sh_path = os.path.join(SCRIPT_DIR, "run.sh")
420
+ with open(run_sh_path, "r") as f:
421
+ content = f.read()
422
+ assert "AUTO_PUSH=${AUTO_PUSH:-0}" in content
423
+
424
+ def test_ac_4_3_auto_push_defaults_to_0_bugfix(self):
425
+ """AC-4.3 (bugfix): AUTO_PUSH defaults to 0 in run-bugfix.sh."""
426
+ run_bugfix_path = os.path.join(SCRIPT_DIR, "run-bugfix.sh")
427
+ with open(run_bugfix_path, "r") as f:
428
+ content = f.read()
429
+ assert "AUTO_PUSH=${AUTO_PUSH:-0}" in content
430
+
431
+
432
+ # ======================================================================
433
+ # US-5: Worktree Cleanup
434
+ # ======================================================================
435
+
436
+
437
+ class TestUS5_WorktreeCleanup:
438
+ """US-5: Worktrees cleaned up after merge or failure."""
439
+
440
+ def test_ac_5_1_cleanup_after_successful_merge(self, tmp_path):
441
+ """AC-5.1: After successful merge, worktree removed via git worktree remove --force."""
442
+ repo = tmp_path / "repo"
443
+ repo.mkdir()
444
+ _init_git_repo(repo)
445
+ wt_base = tmp_path / "worktrees"
446
+
447
+ script = (
448
+ 'worktree_create "{repo}" "{wt_base}" "cleanup-merge" "main"\n'
449
+ 'echo "z" > "$_WORKTREE_PATH/z.txt"\n'
450
+ 'git -C "$_WORKTREE_PATH" add z.txt\n'
451
+ 'git -C "$_WORKTREE_PATH" commit -m "Add z"\n'
452
+ 'local_path="$_WORKTREE_PATH"\n'
453
+ 'local_branch="$_WORKTREE_BRANCH"\n'
454
+ 'worktree_merge "{repo}" "$local_branch" "main" "F-001" "cleanup-merge"\n'
455
+ 'worktree_cleanup "{repo}" "$local_path" "$local_branch"\n'
456
+ '[ -d "$local_path" ] && echo "DIR_EXISTS" || echo "DIR_GONE"\n'
457
+ # Verify no worktree entries remain
458
+ 'wt_count=$(git -C "{repo}" worktree list | wc -l | tr -d " ")\n'
459
+ 'echo "WT_COUNT=$wt_count"\n'
460
+ ).format(repo=repo, wt_base=wt_base)
461
+
462
+ result = _run_worktree_script(script)
463
+ assert result.returncode == 0
464
+ assert "DIR_GONE" in result.stdout
465
+ # Only the main worktree should remain
466
+ wt_line = [l for l in result.stdout.strip().split("\n") if l.startswith("WT_COUNT=")]
467
+ assert wt_line[0] == "WT_COUNT=1"
468
+
469
+ def test_ac_5_2_cleanup_after_failed_session(self, tmp_path):
470
+ """AC-5.2: After failed session, worktree is removed."""
471
+ repo = tmp_path / "repo"
472
+ repo.mkdir()
473
+ _init_git_repo(repo)
474
+ wt_base = tmp_path / "worktrees"
475
+
476
+ script = (
477
+ 'worktree_create "{repo}" "{wt_base}" "fail-cleanup" "main"\n'
478
+ 'local_path="$_WORKTREE_PATH"\n'
479
+ 'local_branch="$_WORKTREE_BRANCH"\n'
480
+ # Simulate failure: no commit, just cleanup
481
+ 'worktree_cleanup "{repo}" "$local_path" "$local_branch"\n'
482
+ '[ -d "$local_path" ] && echo "DIR_EXISTS" || echo "DIR_GONE"\n'
483
+ ).format(repo=repo, wt_base=wt_base)
484
+
485
+ result = _run_worktree_script(script)
486
+ assert result.returncode == 0
487
+ assert "DIR_GONE" in result.stdout
488
+
489
+ def test_ac_5_3_cleanup_trap_references_active_worktree_vars(self):
490
+ """AC-5.3: Cleanup trap in run.sh references _ACTIVE_WORKTREE_PATH."""
491
+ run_sh_path = os.path.join(SCRIPT_DIR, "run.sh")
492
+ with open(run_sh_path, "r") as f:
493
+ content = f.read()
494
+ # The cleanup function should reference active worktree
495
+ assert "_ACTIVE_WORKTREE_PATH" in content
496
+ assert "worktree_cleanup" in content
497
+
498
+ def test_ac_5_3_cleanup_trap_in_run_bugfix(self):
499
+ """AC-5.3 (bugfix): Cleanup trap in run-bugfix.sh references active worktree."""
500
+ path = os.path.join(SCRIPT_DIR, "run-bugfix.sh")
501
+ with open(path, "r") as f:
502
+ content = f.read()
503
+ assert "_ACTIVE_WORKTREE_PATH" in content
504
+ assert "worktree_cleanup" in content
505
+
506
+ def test_ac_5_4_conflict_preserves_worktree(self, tmp_path):
507
+ """AC-5.4: On merge_conflict, worktree is preserved."""
508
+ # Already covered by TestUS3 test_ac_3_5_worktree_preserved_on_conflict
509
+ # Additional: verify the logic in run.sh
510
+ run_sh_path = os.path.join(SCRIPT_DIR, "run.sh")
511
+ with open(run_sh_path, "r") as f:
512
+ content = f.read()
513
+ # After conflict, code should NOT call worktree_cleanup
514
+ # Instead it logs a message about preservation
515
+ assert "Worktree branch preserved for manual conflict resolution" in content
516
+
517
+
518
+ # ======================================================================
519
+ # US-6: Backward Compatibility Toggle
520
+ # ======================================================================
521
+
522
+
523
+ class TestUS6_BackwardCompatibilityToggle:
524
+ """US-6: USE_WORKTREE=0 disables worktree isolation."""
525
+
526
+ def test_ac_6_1_use_worktree_defaults_to_1_run_sh(self):
527
+ """AC-6.1: USE_WORKTREE defaults to 1 in run.sh."""
528
+ path = os.path.join(SCRIPT_DIR, "run.sh")
529
+ with open(path, "r") as f:
530
+ content = f.read()
531
+ assert "USE_WORKTREE=${USE_WORKTREE:-1}" in content
532
+
533
+ def test_ac_6_1_use_worktree_defaults_to_1_run_bugfix(self):
534
+ """AC-6.1: USE_WORKTREE defaults to 1 in run-bugfix.sh."""
535
+ path = os.path.join(SCRIPT_DIR, "run-bugfix.sh")
536
+ with open(path, "r") as f:
537
+ content = f.read()
538
+ assert "USE_WORKTREE=${USE_WORKTREE:-1}" in content
539
+
540
+ def test_ac_6_2_use_worktree_0_skips_worktree_creation(self):
541
+ """AC-6.2: USE_WORKTREE=0 makes pipeline behavior identical to no-worktree.
542
+
543
+ Verify the conditional guard exists in run.sh: the worktree code
544
+ is gated behind 'if [[ "$USE_WORKTREE" == "1" ]]'.
545
+ """
546
+ path = os.path.join(SCRIPT_DIR, "run.sh")
547
+ with open(path, "r") as f:
548
+ content = f.read()
549
+ assert '"$USE_WORKTREE" == "1"' in content
550
+
551
+ def test_ac_6_3_env_var_documented_in_header_run_sh(self):
552
+ """AC-6.3: USE_WORKTREE documented in run.sh header."""
553
+ path = os.path.join(SCRIPT_DIR, "run.sh")
554
+ with open(path, "r") as f:
555
+ header = f.read(3000)
556
+ assert "USE_WORKTREE" in header
557
+
558
+ def test_ac_6_3_env_var_documented_in_header_run_bugfix(self):
559
+ """AC-6.3: USE_WORKTREE documented in run-bugfix.sh header."""
560
+ path = os.path.join(SCRIPT_DIR, "run-bugfix.sh")
561
+ with open(path, "r") as f:
562
+ header = f.read(2000)
563
+ assert "USE_WORKTREE" in header
564
+
565
+ def test_ac_6_4_launch_daemon_documents_worktree_env(self):
566
+ """AC-6.4: launch-daemon.sh documents USE_WORKTREE via --env."""
567
+ path = os.path.join(SCRIPT_DIR, "launch-daemon.sh")
568
+ with open(path, "r") as f:
569
+ content = f.read()
570
+ assert "USE_WORKTREE" in content
571
+ assert "AUTO_PUSH" in content
572
+
573
+ def test_ac_6_4_launch_bugfix_daemon_documents_worktree_env(self):
574
+ """AC-6.4: launch-bugfix-daemon.sh documents USE_WORKTREE via --env."""
575
+ path = os.path.join(SCRIPT_DIR, "launch-bugfix-daemon.sh")
576
+ with open(path, "r") as f:
577
+ content = f.read()
578
+ assert "USE_WORKTREE" in content
579
+ assert "AUTO_PUSH" in content
580
+
581
+
582
+ # ======================================================================
583
+ # Cross-Module Data Flow Tests
584
+ # ======================================================================
585
+
586
+
587
+ class TestCrossModuleDataFlow:
588
+ """Verify end-to-end data flow across worktree.sh, status scripts, and schema."""
589
+
590
+ def test_full_lifecycle_success_path(self, tmp_path):
591
+ """E2E: create worktree → work → commit → merge → cleanup."""
592
+ repo = tmp_path / "repo"
593
+ repo.mkdir()
594
+ _init_git_repo(repo)
595
+ wt_base = tmp_path / "worktrees"
596
+
597
+ script = (
598
+ 'worktree_create "{repo}" "{wt_base}" "e2e-success" "main"\n'
599
+ 'echo "feature code" > "$_WORKTREE_PATH/feature.py"\n'
600
+ 'git -C "$_WORKTREE_PATH" add feature.py\n'
601
+ 'git -C "$_WORKTREE_PATH" commit -m "Implement feature"\n'
602
+ 'local_path="$_WORKTREE_PATH"\n'
603
+ 'local_branch="$_WORKTREE_BRANCH"\n'
604
+ 'worktree_merge "{repo}" "$local_branch" "main" "F-001" "e2e-success"\n'
605
+ 'worktree_cleanup "{repo}" "$local_path" "$local_branch"\n'
606
+ # Verify feature.py is on main
607
+ '[ -f "{repo}/feature.py" ] && echo "FILE_ON_MAIN" || echo "FILE_MISSING"\n'
608
+ # Verify worktree is gone
609
+ '[ -d "$local_path" ] && echo "WT_EXISTS" || echo "WT_GONE"\n'
610
+ # Verify branch is gone
611
+ 'git -C "{repo}" rev-parse --verify "$local_branch" 2>/dev/null && echo "BR_EXISTS" || echo "BR_GONE"\n'
612
+ ).format(repo=repo, wt_base=wt_base)
613
+
614
+ result = _run_worktree_script(script)
615
+ assert result.returncode == 0
616
+ assert "FILE_ON_MAIN" in result.stdout
617
+ assert "WT_GONE" in result.stdout
618
+ assert "BR_GONE" in result.stdout
619
+
620
+ def test_full_lifecycle_conflict_path(self, tmp_path):
621
+ """E2E: create worktree → conflicting changes → merge fails → worktree preserved."""
622
+ repo = tmp_path / "repo"
623
+ repo.mkdir()
624
+ _init_git_repo(repo)
625
+ wt_base = tmp_path / "worktrees"
626
+
627
+ script = (
628
+ 'worktree_create "{repo}" "{wt_base}" "e2e-conflict" "main"\n'
629
+ 'echo "wt content" > "$_WORKTREE_PATH/README.md"\n'
630
+ 'git -C "$_WORKTREE_PATH" add README.md\n'
631
+ 'git -C "$_WORKTREE_PATH" commit -m "WT edit"\n'
632
+ 'echo "main content" > "{repo}/README.md"\n'
633
+ 'git -C "{repo}" add README.md\n'
634
+ 'git -C "{repo}" commit -m "Main edit"\n'
635
+ 'worktree_merge "{repo}" "worktree/e2e-conflict" "main" "F-001" "e2e-conflict" || true\n'
636
+ 'echo "MERGE=$_MERGE_RESULT"\n'
637
+ # Worktree should be preserved
638
+ '[ -d "$_WORKTREE_PATH" ] && echo "WT_PRESERVED" || echo "WT_GONE"\n'
639
+ ).format(repo=repo, wt_base=wt_base)
640
+
641
+ result = _run_worktree_script(script)
642
+ assert "MERGE=conflict" in result.stdout
643
+ assert "WT_PRESERVED" in result.stdout
644
+
645
+ def test_merge_conflict_propagates_through_status_pipeline(
646
+ self, feature_list_file, state_dir, monkeypatch, capsys
647
+ ):
648
+ """E2E: merge_conflict flows from worktree → update-feature-status → state.
649
+
650
+ Verifies the cross-module integration:
651
+ 1. merge_conflict is in SESSION_STATUS_VALUES
652
+ 2. action_update handles it as retryable without cleanup
653
+ 3. Status persists correctly in state
654
+ """
655
+ from types import SimpleNamespace
656
+ ufs = _get_update_feature_module()
657
+
658
+ called = {"cleanup": 0}
659
+ def _fake_cleanup(**_kw):
660
+ called["cleanup"] += 1
661
+ return []
662
+ monkeypatch.setattr(ufs, "cleanup_feature_artifacts", _fake_cleanup)
663
+
664
+ args = SimpleNamespace(
665
+ feature_id="F-001",
666
+ session_status="merge_conflict",
667
+ session_id="s-merge-conflict-e2e",
668
+ max_retries=3,
669
+ project_root=None,
670
+ )
671
+ ufs.action_update(args, feature_list_file, state_dir)
672
+ out = capsys.readouterr().out
673
+ summary = json.loads(out)
674
+
675
+ assert summary["new_status"] == "merge_conflict"
676
+ assert summary["degraded_reason"] == "merge_conflict"
677
+ assert summary["restart_policy"] == "finalization_retry"
678
+ assert called["cleanup"] == 0
679
+
680
+ def test_merge_conflict_in_bug_pipeline(
681
+ self, bug_list_file, bugfix_state_dir, capsys
682
+ ):
683
+ """E2E: merge_conflict flows through update-bug-status.py."""
684
+ from types import SimpleNamespace
685
+ ubs = _get_update_bug_module()
686
+
687
+ args = SimpleNamespace(
688
+ bug_id="B-001",
689
+ session_status="merge_conflict",
690
+ session_id="s-bug-merge-conflict",
691
+ max_retries=3,
692
+ project_root=None,
693
+ )
694
+ ubs.action_update(args, bug_list_file, bugfix_state_dir)
695
+ out = capsys.readouterr().out
696
+ summary = json.loads(out)
697
+
698
+ assert summary["new_status"] == "merge_conflict"
699
+ assert summary["degraded_reason"] == "merge_conflict"
700
+ assert summary["restart_policy"] == "finalization_retry"
701
+
702
+
703
+ # ======================================================================
704
+ # Edge Cases and Boundary Conditions
705
+ # ======================================================================
706
+
707
+
708
+ class TestEdgeCases:
709
+ """Boundary conditions and error paths."""
710
+
711
+ def test_worktree_create_fails_on_duplicate_branch(self, tmp_path):
712
+ """Edge: Creating worktree with existing branch name fails gracefully."""
713
+ repo = tmp_path / "repo"
714
+ repo.mkdir()
715
+ _init_git_repo(repo)
716
+ wt_base = tmp_path / "worktrees"
717
+
718
+ # Create a branch manually
719
+ subprocess.run(
720
+ ["git", "-C", str(repo), "branch", "worktree/dup-branch"],
721
+ capture_output=True, check=True,
722
+ )
723
+
724
+ script = (
725
+ 'worktree_create "{repo}" "{wt_base}" "dup-branch" "main" || echo "CREATE_FAILED"\n'
726
+ ).format(repo=repo, wt_base=wt_base)
727
+
728
+ result = _run_worktree_script(script)
729
+ assert "CREATE_FAILED" in result.stdout or result.returncode != 0
730
+
731
+ def test_worktree_cleanup_is_idempotent(self, tmp_path):
732
+ """Edge: Double cleanup doesn't error."""
733
+ repo = tmp_path / "repo"
734
+ repo.mkdir()
735
+ _init_git_repo(repo)
736
+
737
+ script = (
738
+ 'worktree_cleanup "{repo}" "/nonexistent/path" "nonexistent-branch"\n'
739
+ 'worktree_cleanup "{repo}" "/nonexistent/path" "nonexistent-branch"\n'
740
+ 'echo "OK"\n'
741
+ ).format(repo=repo)
742
+
743
+ result = _run_worktree_script(script)
744
+ assert result.returncode == 0
745
+ assert "OK" in result.stdout
746
+
747
+ def test_prune_stale_at_startup(self):
748
+ """Edge: Verify worktree_prune_stale is called at startup in main()."""
749
+ path = os.path.join(SCRIPT_DIR, "run.sh")
750
+ with open(path, "r") as f:
751
+ content = f.read()
752
+ # In main() there should be a prune call
753
+ assert "worktree_prune_stale" in content
754
+
755
+ def test_merge_conflict_retry_exhaustion_leads_to_failed(
756
+ self, feature_list_file, state_dir, monkeypatch, capsys
757
+ ):
758
+ """Edge: merge_conflict with max_retries=1 should fail the feature."""
759
+ from types import SimpleNamespace
760
+ ufs = _get_update_feature_module()
761
+
762
+ monkeypatch.setattr(ufs, "cleanup_feature_artifacts", lambda **_: [])
763
+
764
+ args = SimpleNamespace(
765
+ feature_id="F-001",
766
+ session_status="merge_conflict",
767
+ session_id="s-exhaust",
768
+ max_retries=1,
769
+ project_root=None,
770
+ )
771
+ ufs.action_update(args, feature_list_file, state_dir)
772
+ out = capsys.readouterr().out
773
+ summary = json.loads(out)
774
+
775
+ assert summary["new_status"] == "failed"
776
+ assert summary["retry_count"] == 1
777
+
778
+ def test_schema_validates_with_merge_conflict_status(self, tmp_path):
779
+ """Edge: A session-status.json with merge_conflict passes schema validation."""
780
+ schema_path = os.path.join(TEMPLATES_DIR, "session-status-schema.json")
781
+ with open(schema_path, "r") as f:
782
+ schema = json.load(f)
783
+
784
+ # Basic validation: merge_conflict is in the enum
785
+ valid_statuses = schema["properties"]["status"]["enum"]
786
+ test_doc = {
787
+ "session_id": "test-session",
788
+ "feature_id": "F-001",
789
+ "status": "merge_conflict",
790
+ "timestamp": "2026-03-18T12:00:00Z",
791
+ }
792
+ assert test_doc["status"] in valid_statuses
793
+
794
+ # Verify all required fields are present
795
+ for field in schema["required"]:
796
+ assert field in test_doc