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.
- package/bundled/VERSION.json +3 -3
- package/bundled/agents/prizm-dev-team-dev.md +11 -20
- package/bundled/agents/prizm-dev-team-reviewer.md +10 -19
- package/bundled/dev-pipeline/README.md +14 -17
- package/bundled/dev-pipeline/assets/prizm-dev-team-integration.md +16 -22
- package/bundled/dev-pipeline/launch-bugfix-daemon.sh +8 -0
- package/bundled/dev-pipeline/launch-daemon.sh +2 -0
- package/bundled/dev-pipeline/lib/worktree.sh +164 -0
- package/bundled/dev-pipeline/retry-bug.sh +5 -2
- package/bundled/dev-pipeline/retry-feature.sh +5 -2
- package/bundled/dev-pipeline/run-bugfix.sh +167 -2
- package/bundled/dev-pipeline/run.sh +169 -2
- package/bundled/dev-pipeline/scripts/check-session-status.py +3 -1
- package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +0 -8
- package/bundled/dev-pipeline/scripts/update-bug-status.py +24 -1
- package/bundled/dev-pipeline/scripts/update-feature-status.py +3 -2
- package/bundled/dev-pipeline/templates/bootstrap-tier1.md +3 -9
- package/bundled/dev-pipeline/templates/bootstrap-tier2.md +2 -8
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +36 -43
- package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +1 -1
- package/bundled/dev-pipeline/templates/session-status-schema.json +1 -1
- package/bundled/dev-pipeline/tests/test_check_session.py +4 -0
- package/bundled/dev-pipeline/tests/test_update_feature_status.py +70 -0
- package/bundled/dev-pipeline/tests/test_worktree.py +236 -0
- package/bundled/dev-pipeline/tests/test_worktree_integration.py +796 -0
- package/bundled/skills/_metadata.json +1 -1
- package/bundled/skills/prizmkit-implement/SKILL.md +4 -2
- package/bundled/team/prizm-dev-team.json +3 -17
- package/package.json +1 -1
- package/src/clean.js +0 -2
- package/src/manifest.js +8 -4
- package/src/scaffold.js +69 -3
- package/src/upgrade.js +32 -5
- package/bundled/agents/prizm-dev-team-coordinator.md +0 -141
- package/bundled/agents/prizm-dev-team-pm.md +0 -126
- 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
|