prizmkit 1.0.45 → 1.0.66
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/adapters/claude/agent-adapter.js +2 -1
- package/bundled/adapters/claude/command-adapter.js +3 -3
- package/bundled/agents/prizm-dev-team-dev.md +1 -1
- package/bundled/dev-pipeline/README.md +6 -8
- package/bundled/dev-pipeline/assets/prizm-dev-team-integration.md +24 -19
- package/bundled/dev-pipeline/launch-bugfix-daemon.sh +2 -2
- package/bundled/dev-pipeline/launch-daemon.sh +2 -2
- package/bundled/dev-pipeline/lib/branch.sh +76 -0
- package/bundled/dev-pipeline/run-bugfix.sh +58 -149
- package/bundled/dev-pipeline/run.sh +60 -153
- package/bundled/dev-pipeline/scripts/generate-bootstrap-prompt.py +17 -4
- package/bundled/dev-pipeline/scripts/parse-stream-progress.py +2 -2
- package/bundled/dev-pipeline/templates/bootstrap-tier1.md +16 -27
- package/bundled/dev-pipeline/templates/bootstrap-tier2.md +20 -32
- package/bundled/dev-pipeline/templates/bootstrap-tier3.md +32 -53
- package/bundled/dev-pipeline/templates/bugfix-bootstrap-prompt.md +29 -41
- package/bundled/dev-pipeline/templates/session-status-schema.json +1 -1
- package/bundled/dev-pipeline/tests/conftest.py +19 -126
- package/bundled/dev-pipeline/tests/test_generate_bootstrap_prompt.py +207 -0
- package/bundled/dev-pipeline/tests/test_generate_bugfix_prompt.py +128 -141
- package/bundled/dev-pipeline/tests/test_utils.py +51 -110
- package/bundled/rules/prizm/prizm-commit-workflow.md +3 -3
- package/bundled/skills/_metadata.json +15 -16
- package/bundled/skills/app-planner/SKILL.md +8 -7
- package/bundled/skills/bug-fix-workflow/SKILL.md +171 -0
- package/bundled/skills/bug-planner/SKILL.md +25 -33
- package/bundled/skills/bug-planner/scripts/validate-bug-list.py +156 -0
- package/bundled/skills/bugfix-pipeline-launcher/SKILL.md +5 -7
- package/bundled/skills/dev-pipeline-launcher/SKILL.md +4 -6
- package/bundled/skills/feature-workflow/SKILL.md +25 -42
- package/bundled/skills/prizm-kit/SKILL.md +61 -23
- package/bundled/skills/prizm-kit/assets/{claude-md-template.md → project-memory-template.md} +3 -3
- package/bundled/skills/prizmkit-analyze/SKILL.md +44 -33
- package/bundled/skills/prizmkit-clarify/SKILL.md +40 -30
- package/bundled/skills/prizmkit-code-review/SKILL.md +58 -45
- package/bundled/skills/prizmkit-committer/SKILL.md +30 -68
- package/bundled/skills/prizmkit-implement/SKILL.md +60 -28
- package/bundled/skills/prizmkit-init/SKILL.md +57 -66
- package/bundled/skills/prizmkit-plan/SKILL.md +60 -23
- package/bundled/skills/prizmkit-prizm-docs/SKILL.md +74 -19
- package/bundled/skills/prizmkit-prizm-docs/assets/PRIZM-SPEC.md +23 -23
- package/bundled/skills/prizmkit-retrospective/SKILL.md +142 -65
- package/bundled/skills/prizmkit-retrospective/assets/retrospective-template.md +13 -0
- package/bundled/skills/prizmkit-specify/SKILL.md +69 -15
- package/bundled/skills/refactor-workflow/SKILL.md +116 -52
- package/bundled/team/prizm-dev-team.json +2 -2
- package/package.json +1 -1
- package/src/scaffold.js +4 -4
- package/bundled/dev-pipeline/lib/worktree.sh +0 -164
- package/bundled/dev-pipeline/tests/__init__.py +0 -0
- package/bundled/dev-pipeline/tests/test_check_session.py +0 -131
- package/bundled/dev-pipeline/tests/test_cleanup_logs.py +0 -119
- package/bundled/dev-pipeline/tests/test_detect_stuck.py +0 -207
- package/bundled/dev-pipeline/tests/test_generate_prompt.py +0 -190
- package/bundled/dev-pipeline/tests/test_init_bugfix_pipeline.py +0 -153
- package/bundled/dev-pipeline/tests/test_init_pipeline.py +0 -241
- package/bundled/dev-pipeline/tests/test_update_bug_status.py +0 -142
- package/bundled/dev-pipeline/tests/test_update_feature_status.py +0 -338
- package/bundled/dev-pipeline/tests/test_worktree.py +0 -236
- package/bundled/dev-pipeline/tests/test_worktree_integration.py +0 -796
- package/bundled/skills/prizm-kit/assets/codebuddy-md-template.md +0 -35
- package/bundled/skills/prizm-kit/assets/hooks/prizm-commit-hook.json +0 -15
- package/bundled/skills/prizmkit-summarize/SKILL.md +0 -51
- package/bundled/skills/prizmkit-summarize/assets/registry-template.md +0 -18
- package/bundled/templates/hooks/commit-intent-claude.json +0 -26
- /package/bundled/templates/hooks/{commit-intent-codebuddy.json → commit-intent.json} +0 -0
|
@@ -1,338 +0,0 @@
|
|
|
1
|
-
"""Tests for update-feature-status.py."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import os
|
|
5
|
-
import re
|
|
6
|
-
import sys
|
|
7
|
-
import pytest
|
|
8
|
-
from types import SimpleNamespace
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def _import_update_feature_status():
|
|
12
|
-
import importlib.util
|
|
13
|
-
path = os.path.join(
|
|
14
|
-
os.path.dirname(__file__), "..", "scripts", "update-feature-status.py"
|
|
15
|
-
)
|
|
16
|
-
spec = importlib.util.spec_from_file_location("update_feature_status", path)
|
|
17
|
-
mod = importlib.util.module_from_spec(spec)
|
|
18
|
-
sys.modules["update_feature_status"] = mod
|
|
19
|
-
spec.loader.exec_module(mod)
|
|
20
|
-
return mod
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
ufs = _import_update_feature_status()
|
|
24
|
-
now_iso = ufs.now_iso
|
|
25
|
-
load_feature_status = ufs.load_feature_status
|
|
26
|
-
save_feature_status = ufs.save_feature_status
|
|
27
|
-
_build_feature_slug = ufs._build_feature_slug
|
|
28
|
-
_format_duration = ufs._format_duration
|
|
29
|
-
_calc_feature_duration = ufs._calc_feature_duration
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
class TestSessionStatusValues:
|
|
33
|
-
def test_contains_commit_missing(self):
|
|
34
|
-
assert "commit_missing" in ufs.SESSION_STATUS_VALUES
|
|
35
|
-
|
|
36
|
-
def test_contains_docs_missing(self):
|
|
37
|
-
assert "docs_missing" in ufs.SESSION_STATUS_VALUES
|
|
38
|
-
|
|
39
|
-
def test_contains_merge_conflict(self):
|
|
40
|
-
assert "merge_conflict" in ufs.SESSION_STATUS_VALUES
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
class TestNowIso:
|
|
44
|
-
def test_returns_valid_iso_format(self):
|
|
45
|
-
result = now_iso()
|
|
46
|
-
# Should match YYYY-MM-DDTHH:MM:SSZ
|
|
47
|
-
pattern = r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$"
|
|
48
|
-
assert re.match(pattern, result) is not None
|
|
49
|
-
|
|
50
|
-
def test_returns_string(self):
|
|
51
|
-
assert isinstance(now_iso(), str)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
class TestLoadAndSaveFeatureStatus:
|
|
55
|
-
def test_round_trip(self, state_dir):
|
|
56
|
-
fid = "F-001"
|
|
57
|
-
# Create the feature directory
|
|
58
|
-
fdir = os.path.join(state_dir, "features", fid)
|
|
59
|
-
os.makedirs(fdir, exist_ok=True)
|
|
60
|
-
|
|
61
|
-
status_data = {
|
|
62
|
-
"feature_id": fid,
|
|
63
|
-
"status": "in_progress",
|
|
64
|
-
"retry_count": 1,
|
|
65
|
-
"max_retries": 3,
|
|
66
|
-
"sessions": ["s-001"],
|
|
67
|
-
"last_session_id": "s-001",
|
|
68
|
-
"resume_from_phase": "3",
|
|
69
|
-
"created_at": "2024-01-01T00:00:00Z",
|
|
70
|
-
"updated_at": "2024-01-01T01:00:00Z",
|
|
71
|
-
}
|
|
72
|
-
err = save_feature_status(state_dir, fid, status_data)
|
|
73
|
-
assert err is None
|
|
74
|
-
|
|
75
|
-
loaded = load_feature_status(state_dir, fid)
|
|
76
|
-
assert loaded["feature_id"] == fid
|
|
77
|
-
assert loaded["status"] == "in_progress"
|
|
78
|
-
assert loaded["retry_count"] == 1
|
|
79
|
-
|
|
80
|
-
def test_load_missing_returns_default(self, state_dir):
|
|
81
|
-
result = load_feature_status(state_dir, "F-999")
|
|
82
|
-
assert result["feature_id"] == "F-999"
|
|
83
|
-
assert result["status"] == "pending"
|
|
84
|
-
assert result["retry_count"] == 0
|
|
85
|
-
|
|
86
|
-
def test_load_invalid_json_returns_default(self, state_dir):
|
|
87
|
-
fid = "F-BAD"
|
|
88
|
-
fdir = os.path.join(state_dir, "features", fid)
|
|
89
|
-
os.makedirs(fdir, exist_ok=True)
|
|
90
|
-
with open(os.path.join(fdir, "status.json"), "w") as f:
|
|
91
|
-
f.write("{invalid json")
|
|
92
|
-
result = load_feature_status(state_dir, fid)
|
|
93
|
-
assert result["status"] == "pending"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
class TestBuildFeatureSlug:
|
|
97
|
-
def test_basic(self):
|
|
98
|
-
assert _build_feature_slug("F-001", "Project Setup") == "001-project-setup"
|
|
99
|
-
|
|
100
|
-
def test_special_chars(self):
|
|
101
|
-
result = _build_feature_slug("F-002", "Auth (OAuth2.0)")
|
|
102
|
-
assert result == "002-auth-oauth20"
|
|
103
|
-
|
|
104
|
-
def test_empty_title(self):
|
|
105
|
-
result = _build_feature_slug("F-003", "")
|
|
106
|
-
assert result == "003-feature"
|
|
107
|
-
|
|
108
|
-
def test_none_title(self):
|
|
109
|
-
result = _build_feature_slug("F-003", None)
|
|
110
|
-
assert result == "003-feature"
|
|
111
|
-
|
|
112
|
-
def test_lowercase_f(self):
|
|
113
|
-
result = _build_feature_slug("f-5", "Test")
|
|
114
|
-
assert result == "005-test"
|
|
115
|
-
|
|
116
|
-
def test_numeric_padding(self):
|
|
117
|
-
result = _build_feature_slug("F-1", "A")
|
|
118
|
-
assert result.startswith("001-")
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
class TestFormatDuration:
|
|
122
|
-
def test_none(self):
|
|
123
|
-
assert _format_duration(None) == "N/A"
|
|
124
|
-
|
|
125
|
-
def test_seconds(self):
|
|
126
|
-
assert _format_duration(45) == "45s"
|
|
127
|
-
|
|
128
|
-
def test_minutes(self):
|
|
129
|
-
assert _format_duration(125) == "2m5s"
|
|
130
|
-
|
|
131
|
-
def test_hours(self):
|
|
132
|
-
assert _format_duration(3661) == "1h1m"
|
|
133
|
-
|
|
134
|
-
def test_zero(self):
|
|
135
|
-
assert _format_duration(0) == "0s"
|
|
136
|
-
|
|
137
|
-
def test_exact_minute(self):
|
|
138
|
-
assert _format_duration(60) == "1m0s"
|
|
139
|
-
|
|
140
|
-
def test_exact_hour(self):
|
|
141
|
-
assert _format_duration(3600) == "1h0m"
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
class TestCalcFeatureDuration:
|
|
145
|
-
def test_valid_duration(self, state_dir):
|
|
146
|
-
fid = "F-001"
|
|
147
|
-
fdir = os.path.join(state_dir, "features", fid)
|
|
148
|
-
os.makedirs(fdir, exist_ok=True)
|
|
149
|
-
status = {
|
|
150
|
-
"created_at": "2024-01-01T00:00:00Z",
|
|
151
|
-
"updated_at": "2024-01-01T00:05:00Z", # 300 seconds
|
|
152
|
-
}
|
|
153
|
-
with open(os.path.join(fdir, "status.json"), "w") as f:
|
|
154
|
-
json.dump(status, f)
|
|
155
|
-
result = _calc_feature_duration(state_dir, fid)
|
|
156
|
-
assert result == 300.0
|
|
157
|
-
|
|
158
|
-
def test_too_short_duration(self, state_dir):
|
|
159
|
-
fid = "F-002"
|
|
160
|
-
fdir = os.path.join(state_dir, "features", fid)
|
|
161
|
-
os.makedirs(fdir, exist_ok=True)
|
|
162
|
-
status = {
|
|
163
|
-
"created_at": "2024-01-01T00:00:00Z",
|
|
164
|
-
"updated_at": "2024-01-01T00:00:05Z", # 5 seconds - below 10s threshold
|
|
165
|
-
}
|
|
166
|
-
with open(os.path.join(fdir, "status.json"), "w") as f:
|
|
167
|
-
json.dump(status, f)
|
|
168
|
-
result = _calc_feature_duration(state_dir, fid)
|
|
169
|
-
assert result is None
|
|
170
|
-
|
|
171
|
-
def test_missing_file(self, state_dir):
|
|
172
|
-
result = _calc_feature_duration(state_dir, "F-MISSING")
|
|
173
|
-
assert result is None
|
|
174
|
-
|
|
175
|
-
def test_missing_timestamps(self, state_dir):
|
|
176
|
-
fid = "F-003"
|
|
177
|
-
fdir = os.path.join(state_dir, "features", fid)
|
|
178
|
-
os.makedirs(fdir, exist_ok=True)
|
|
179
|
-
with open(os.path.join(fdir, "status.json"), "w") as f:
|
|
180
|
-
json.dump({"status": "pending"}, f)
|
|
181
|
-
result = _calc_feature_duration(state_dir, fid)
|
|
182
|
-
assert result is None
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
class TestActionUpdateDegradedStatuses:
|
|
186
|
-
def test_commit_missing_updates_feature_state_without_cleanup(
|
|
187
|
-
self, feature_list_file, state_dir, monkeypatch, capsys
|
|
188
|
-
):
|
|
189
|
-
called = {"cleanup": 0}
|
|
190
|
-
|
|
191
|
-
def _fake_cleanup(**_kwargs):
|
|
192
|
-
called["cleanup"] += 1
|
|
193
|
-
return []
|
|
194
|
-
|
|
195
|
-
monkeypatch.setattr(ufs, "cleanup_feature_artifacts", _fake_cleanup)
|
|
196
|
-
|
|
197
|
-
args = SimpleNamespace(
|
|
198
|
-
feature_id="F-001",
|
|
199
|
-
session_status="commit_missing",
|
|
200
|
-
session_id="s-commit-missing",
|
|
201
|
-
max_retries=3,
|
|
202
|
-
project_root=None,
|
|
203
|
-
)
|
|
204
|
-
|
|
205
|
-
ufs.action_update(args, feature_list_file, state_dir)
|
|
206
|
-
out = capsys.readouterr().out
|
|
207
|
-
summary = json.loads(out)
|
|
208
|
-
|
|
209
|
-
fs = ufs.load_feature_status(state_dir, "F-001")
|
|
210
|
-
assert fs["status"] == "commit_missing"
|
|
211
|
-
assert fs["retry_count"] == 1
|
|
212
|
-
assert summary["new_status"] == "commit_missing"
|
|
213
|
-
assert summary["degraded_reason"] == "commit_missing"
|
|
214
|
-
assert summary["restart_policy"] == "finalization_retry"
|
|
215
|
-
assert called["cleanup"] == 0
|
|
216
|
-
|
|
217
|
-
with open(feature_list_file, "r", encoding="utf-8") as f:
|
|
218
|
-
data = json.load(f)
|
|
219
|
-
f1 = next(x for x in data["features"] if x["id"] == "F-001")
|
|
220
|
-
assert f1["status"] == "commit_missing"
|
|
221
|
-
|
|
222
|
-
def test_docs_missing_reaches_failed_when_retry_exhausted(
|
|
223
|
-
self, feature_list_file, state_dir, monkeypatch, capsys
|
|
224
|
-
):
|
|
225
|
-
called = {"cleanup": 0}
|
|
226
|
-
|
|
227
|
-
def _fake_cleanup(**_kwargs):
|
|
228
|
-
called["cleanup"] += 1
|
|
229
|
-
return []
|
|
230
|
-
|
|
231
|
-
monkeypatch.setattr(ufs, "cleanup_feature_artifacts", _fake_cleanup)
|
|
232
|
-
|
|
233
|
-
args = SimpleNamespace(
|
|
234
|
-
feature_id="F-001",
|
|
235
|
-
session_status="docs_missing",
|
|
236
|
-
session_id="s-docs-missing",
|
|
237
|
-
max_retries=1,
|
|
238
|
-
project_root=None,
|
|
239
|
-
)
|
|
240
|
-
|
|
241
|
-
ufs.action_update(args, feature_list_file, state_dir)
|
|
242
|
-
out = capsys.readouterr().out
|
|
243
|
-
summary = json.loads(out)
|
|
244
|
-
|
|
245
|
-
fs = ufs.load_feature_status(state_dir, "F-001")
|
|
246
|
-
assert fs["status"] == "failed"
|
|
247
|
-
assert fs["retry_count"] == 1
|
|
248
|
-
assert summary["new_status"] == "failed"
|
|
249
|
-
assert summary["degraded_reason"] == "docs_missing"
|
|
250
|
-
assert summary["restart_policy"] == "finalization_retry"
|
|
251
|
-
assert called["cleanup"] == 0
|
|
252
|
-
|
|
253
|
-
with open(feature_list_file, "r", encoding="utf-8") as f:
|
|
254
|
-
data = json.load(f)
|
|
255
|
-
f1 = next(x for x in data["features"] if x["id"] == "F-001")
|
|
256
|
-
assert f1["status"] == "failed"
|
|
257
|
-
|
|
258
|
-
def test_merge_conflict_updates_feature_state_without_cleanup(
|
|
259
|
-
self, feature_list_file, state_dir, monkeypatch, capsys
|
|
260
|
-
):
|
|
261
|
-
"""merge_conflict is treated like commit_missing: retryable, no artifact cleanup."""
|
|
262
|
-
called = {"cleanup": 0}
|
|
263
|
-
|
|
264
|
-
def _fake_cleanup(**_kwargs):
|
|
265
|
-
called["cleanup"] += 1
|
|
266
|
-
return []
|
|
267
|
-
|
|
268
|
-
monkeypatch.setattr(ufs, "cleanup_feature_artifacts", _fake_cleanup)
|
|
269
|
-
|
|
270
|
-
args = SimpleNamespace(
|
|
271
|
-
feature_id="F-001",
|
|
272
|
-
session_status="merge_conflict",
|
|
273
|
-
session_id="s-merge-conflict",
|
|
274
|
-
max_retries=3,
|
|
275
|
-
project_root=None,
|
|
276
|
-
)
|
|
277
|
-
|
|
278
|
-
ufs.action_update(args, feature_list_file, state_dir)
|
|
279
|
-
out = capsys.readouterr().out
|
|
280
|
-
summary = json.loads(out)
|
|
281
|
-
|
|
282
|
-
fs = ufs.load_feature_status(state_dir, "F-001")
|
|
283
|
-
assert fs["status"] == "merge_conflict"
|
|
284
|
-
assert fs["retry_count"] == 1
|
|
285
|
-
assert summary["new_status"] == "merge_conflict"
|
|
286
|
-
assert summary["degraded_reason"] == "merge_conflict"
|
|
287
|
-
assert summary["restart_policy"] == "finalization_retry"
|
|
288
|
-
assert called["cleanup"] == 0
|
|
289
|
-
|
|
290
|
-
with open(feature_list_file, "r", encoding="utf-8") as f:
|
|
291
|
-
data = json.load(f)
|
|
292
|
-
f1 = next(x for x in data["features"] if x["id"] == "F-001")
|
|
293
|
-
assert f1["status"] == "merge_conflict"
|
|
294
|
-
|
|
295
|
-
def test_merge_conflict_reaches_failed_when_retry_exhausted(
|
|
296
|
-
self, feature_list_file, state_dir, monkeypatch, capsys
|
|
297
|
-
):
|
|
298
|
-
"""merge_conflict with max_retries=1 should fail the feature."""
|
|
299
|
-
called = {"cleanup": 0}
|
|
300
|
-
|
|
301
|
-
def _fake_cleanup(**_kwargs):
|
|
302
|
-
called["cleanup"] += 1
|
|
303
|
-
return []
|
|
304
|
-
|
|
305
|
-
monkeypatch.setattr(ufs, "cleanup_feature_artifacts", _fake_cleanup)
|
|
306
|
-
|
|
307
|
-
args = SimpleNamespace(
|
|
308
|
-
feature_id="F-001",
|
|
309
|
-
session_status="merge_conflict",
|
|
310
|
-
session_id="s-merge-conflict-fail",
|
|
311
|
-
max_retries=1,
|
|
312
|
-
project_root=None,
|
|
313
|
-
)
|
|
314
|
-
|
|
315
|
-
ufs.action_update(args, feature_list_file, state_dir)
|
|
316
|
-
out = capsys.readouterr().out
|
|
317
|
-
summary = json.loads(out)
|
|
318
|
-
|
|
319
|
-
fs = ufs.load_feature_status(state_dir, "F-001")
|
|
320
|
-
assert fs["status"] == "failed"
|
|
321
|
-
assert fs["retry_count"] == 1
|
|
322
|
-
assert summary["new_status"] == "failed"
|
|
323
|
-
assert called["cleanup"] == 0
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
class TestActionStatusForDegradedStates:
|
|
327
|
-
def test_status_output_contains_degraded_counters(self, feature_list_file, state_dir, capsys):
|
|
328
|
-
# Update feature-list.json to set degraded statuses (single source of truth)
|
|
329
|
-
with open(feature_list_file, "r", encoding="utf-8") as f:
|
|
330
|
-
feature_list_data = json.load(f)
|
|
331
|
-
|
|
332
|
-
feature_list_data["features"][0]["status"] = "commit_missing"
|
|
333
|
-
feature_list_data["features"][1]["status"] = "docs_missing"
|
|
334
|
-
|
|
335
|
-
ufs.action_status(feature_list_data, state_dir)
|
|
336
|
-
out = capsys.readouterr().out
|
|
337
|
-
|
|
338
|
-
assert "Commit Missing: 1 | Docs Missing: 1" in out
|
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
"""Tests for dev-pipeline/lib/worktree.sh.
|
|
2
|
-
|
|
3
|
-
Uses subprocess to invoke the worktree functions in a real (temporary) git repo
|
|
4
|
-
to verify create/merge/cleanup/prune lifecycle.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import json
|
|
8
|
-
import os
|
|
9
|
-
import subprocess
|
|
10
|
-
import pytest
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
SCRIPT_DIR = os.path.join(os.path.dirname(__file__), "..")
|
|
14
|
-
LIB_DIR = os.path.join(SCRIPT_DIR, "lib")
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
def _run_worktree_script(script_body, env=None):
|
|
18
|
-
"""Run a bash script that sources worktree.sh (with stub log functions), then executes body."""
|
|
19
|
-
# We define stub log functions instead of sourcing common.sh to avoid
|
|
20
|
-
# CLI/platform detection which requires cbc/claude in PATH.
|
|
21
|
-
worktree_sh_path = os.path.join(os.path.abspath(LIB_DIR), "worktree.sh")
|
|
22
|
-
full_script = "\n".join([
|
|
23
|
-
"#!/usr/bin/env bash",
|
|
24
|
-
"set -euo pipefail",
|
|
25
|
-
# Stub log functions (worktree.sh uses log_info, log_warn, log_error, log_success)
|
|
26
|
-
'log_info() { echo "[INFO] $*"; }',
|
|
27
|
-
'log_warn() { echo "[WARN] $*"; }',
|
|
28
|
-
'log_error() { echo "[ERROR] $*"; }',
|
|
29
|
-
'log_success() { echo "[SUCCESS] $*"; }',
|
|
30
|
-
'source "%s"' % worktree_sh_path,
|
|
31
|
-
script_body,
|
|
32
|
-
])
|
|
33
|
-
merged_env = dict(os.environ)
|
|
34
|
-
if env:
|
|
35
|
-
merged_env.update(env)
|
|
36
|
-
result = subprocess.run(
|
|
37
|
-
["bash", "-c", full_script],
|
|
38
|
-
capture_output=True,
|
|
39
|
-
text=True,
|
|
40
|
-
env=merged_env,
|
|
41
|
-
)
|
|
42
|
-
return result
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def _init_git_repo(path):
|
|
46
|
-
"""Initialize a bare-bones git repo with an initial commit on 'main' branch."""
|
|
47
|
-
subprocess.run(["git", "init", str(path)], capture_output=True, check=True)
|
|
48
|
-
subprocess.run(
|
|
49
|
-
["git", "-C", str(path), "config", "user.email", "test@test.com"],
|
|
50
|
-
capture_output=True,
|
|
51
|
-
check=True,
|
|
52
|
-
)
|
|
53
|
-
subprocess.run(
|
|
54
|
-
["git", "-C", str(path), "config", "user.name", "Test"],
|
|
55
|
-
capture_output=True,
|
|
56
|
-
check=True,
|
|
57
|
-
)
|
|
58
|
-
# Create an initial commit so we have a branch
|
|
59
|
-
readme = os.path.join(str(path), "README.md")
|
|
60
|
-
with open(readme, "w") as f:
|
|
61
|
-
f.write("# Test Repo\n")
|
|
62
|
-
subprocess.run(
|
|
63
|
-
["git", "-C", str(path), "add", "."], capture_output=True, check=True
|
|
64
|
-
)
|
|
65
|
-
subprocess.run(
|
|
66
|
-
["git", "-C", str(path), "commit", "-m", "Initial commit"],
|
|
67
|
-
capture_output=True,
|
|
68
|
-
check=True,
|
|
69
|
-
)
|
|
70
|
-
# Ensure the branch is named 'main' regardless of git defaults
|
|
71
|
-
subprocess.run(
|
|
72
|
-
["git", "-C", str(path), "branch", "-M", "main"],
|
|
73
|
-
capture_output=True,
|
|
74
|
-
check=True,
|
|
75
|
-
)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
class TestWorktreeCreate:
|
|
79
|
-
def test_creates_worktree_and_branch(self, tmp_path):
|
|
80
|
-
repo = tmp_path / "repo"
|
|
81
|
-
repo.mkdir()
|
|
82
|
-
_init_git_repo(repo)
|
|
83
|
-
wt_base = tmp_path / "worktrees"
|
|
84
|
-
|
|
85
|
-
script = (
|
|
86
|
-
'worktree_create "{repo}" "{wt_base}" "test-session-001" "main"\n'
|
|
87
|
-
'echo "PATH=$_WORKTREE_PATH"\n'
|
|
88
|
-
'echo "BRANCH=$_WORKTREE_BRANCH"\n'
|
|
89
|
-
).format(repo=repo, wt_base=wt_base)
|
|
90
|
-
|
|
91
|
-
result = _run_worktree_script(script)
|
|
92
|
-
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
93
|
-
|
|
94
|
-
lines = result.stdout.strip().split("\n")
|
|
95
|
-
path_line = [l for l in lines if l.startswith("PATH=")]
|
|
96
|
-
branch_line = [l for l in lines if l.startswith("BRANCH=")]
|
|
97
|
-
|
|
98
|
-
assert len(path_line) == 1
|
|
99
|
-
assert len(branch_line) == 1
|
|
100
|
-
|
|
101
|
-
wt_path = path_line[0].split("=", 1)[1]
|
|
102
|
-
wt_branch = branch_line[0].split("=", 1)[1]
|
|
103
|
-
|
|
104
|
-
assert os.path.isdir(wt_path)
|
|
105
|
-
assert wt_branch == "worktree/test-session-001"
|
|
106
|
-
|
|
107
|
-
# Verify git branch exists
|
|
108
|
-
branch_check = subprocess.run(
|
|
109
|
-
["git", "-C", str(repo), "rev-parse", "--verify", wt_branch],
|
|
110
|
-
capture_output=True,
|
|
111
|
-
)
|
|
112
|
-
assert branch_check.returncode == 0
|
|
113
|
-
|
|
114
|
-
def test_fails_when_worktree_already_exists(self, tmp_path):
|
|
115
|
-
repo = tmp_path / "repo"
|
|
116
|
-
repo.mkdir()
|
|
117
|
-
_init_git_repo(repo)
|
|
118
|
-
wt_base = tmp_path / "worktrees"
|
|
119
|
-
|
|
120
|
-
# Create the worktree directory manually to trigger failure
|
|
121
|
-
wt_path = wt_base / "dup-session"
|
|
122
|
-
wt_path.mkdir(parents=True)
|
|
123
|
-
|
|
124
|
-
script = (
|
|
125
|
-
'worktree_create "{repo}" "{wt_base}" "dup-session" "main"\n'
|
|
126
|
-
'echo "RC=$?"\n'
|
|
127
|
-
).format(repo=repo, wt_base=wt_base)
|
|
128
|
-
|
|
129
|
-
# Since set -e is on and worktree_create returns 1, the script exits with 1
|
|
130
|
-
result = _run_worktree_script(script)
|
|
131
|
-
assert result.returncode != 0 or "RC=1" in result.stdout
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
class TestWorktreeMerge:
|
|
135
|
-
def test_merge_success(self, tmp_path):
|
|
136
|
-
repo = tmp_path / "repo"
|
|
137
|
-
repo.mkdir()
|
|
138
|
-
_init_git_repo(repo)
|
|
139
|
-
wt_base = tmp_path / "worktrees"
|
|
140
|
-
|
|
141
|
-
# Create worktree, add a file, commit, then merge
|
|
142
|
-
script = (
|
|
143
|
-
'worktree_create "{repo}" "{wt_base}" "merge-test" "main"\n'
|
|
144
|
-
'echo "hello" > "$_WORKTREE_PATH/newfile.txt"\n'
|
|
145
|
-
'git -C "$_WORKTREE_PATH" add newfile.txt\n'
|
|
146
|
-
'git -C "$_WORKTREE_PATH" commit -m "Add newfile"\n'
|
|
147
|
-
'worktree_merge "{repo}" "$_WORKTREE_BRANCH" "main" "F-001" "merge-test"\n'
|
|
148
|
-
'echo "MERGE=$_MERGE_RESULT"\n'
|
|
149
|
-
).format(repo=repo, wt_base=wt_base)
|
|
150
|
-
|
|
151
|
-
result = _run_worktree_script(script)
|
|
152
|
-
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
153
|
-
assert "MERGE=success" in result.stdout
|
|
154
|
-
|
|
155
|
-
# Verify the file exists on main
|
|
156
|
-
assert os.path.isfile(str(repo / "newfile.txt"))
|
|
157
|
-
|
|
158
|
-
def test_merge_conflict(self, tmp_path):
|
|
159
|
-
repo = tmp_path / "repo"
|
|
160
|
-
repo.mkdir()
|
|
161
|
-
_init_git_repo(repo)
|
|
162
|
-
wt_base = tmp_path / "worktrees"
|
|
163
|
-
|
|
164
|
-
# Create worktree, modify README on both main and worktree to cause conflict
|
|
165
|
-
script = (
|
|
166
|
-
# Create worktree
|
|
167
|
-
'worktree_create "{repo}" "{wt_base}" "conflict-test" "main"\n'
|
|
168
|
-
# Modify file in worktree and commit
|
|
169
|
-
'echo "worktree change" > "$_WORKTREE_PATH/README.md"\n'
|
|
170
|
-
'git -C "$_WORKTREE_PATH" add README.md\n'
|
|
171
|
-
'git -C "$_WORKTREE_PATH" commit -m "Worktree change"\n'
|
|
172
|
-
# Modify same file in main and commit
|
|
173
|
-
'echo "main change" > "{repo}/README.md"\n'
|
|
174
|
-
'git -C "{repo}" add README.md\n'
|
|
175
|
-
'git -C "{repo}" commit -m "Main change"\n'
|
|
176
|
-
# Attempt merge (should conflict)
|
|
177
|
-
'worktree_merge "{repo}" "worktree/conflict-test" "main" "F-001" "conflict-test" || true\n'
|
|
178
|
-
'echo "MERGE=$_MERGE_RESULT"\n'
|
|
179
|
-
).format(repo=repo, wt_base=wt_base)
|
|
180
|
-
|
|
181
|
-
result = _run_worktree_script(script)
|
|
182
|
-
# Script may exit non-zero due to merge conflict detection
|
|
183
|
-
assert "MERGE=conflict" in result.stdout
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
class TestWorktreeCleanup:
|
|
187
|
-
def test_cleanup_removes_worktree_and_branch(self, tmp_path):
|
|
188
|
-
repo = tmp_path / "repo"
|
|
189
|
-
repo.mkdir()
|
|
190
|
-
_init_git_repo(repo)
|
|
191
|
-
wt_base = tmp_path / "worktrees"
|
|
192
|
-
|
|
193
|
-
script = (
|
|
194
|
-
'worktree_create "{repo}" "{wt_base}" "cleanup-test" "main"\n'
|
|
195
|
-
'local_path="$_WORKTREE_PATH"\n'
|
|
196
|
-
'local_branch="$_WORKTREE_BRANCH"\n'
|
|
197
|
-
'worktree_cleanup "{repo}" "$local_path" "$local_branch"\n'
|
|
198
|
-
'[ -d "$local_path" ] && echo "DIR_EXISTS" || echo "DIR_GONE"\n'
|
|
199
|
-
'git -C "{repo}" rev-parse --verify "$local_branch" 2>/dev/null && echo "BRANCH_EXISTS" || echo "BRANCH_GONE"\n'
|
|
200
|
-
).format(repo=repo, wt_base=wt_base)
|
|
201
|
-
|
|
202
|
-
result = _run_worktree_script(script)
|
|
203
|
-
assert result.returncode == 0, f"stderr: {result.stderr}"
|
|
204
|
-
assert "DIR_GONE" in result.stdout
|
|
205
|
-
assert "BRANCH_GONE" in result.stdout
|
|
206
|
-
|
|
207
|
-
def test_cleanup_idempotent(self, tmp_path):
|
|
208
|
-
repo = tmp_path / "repo"
|
|
209
|
-
repo.mkdir()
|
|
210
|
-
_init_git_repo(repo)
|
|
211
|
-
|
|
212
|
-
# Call cleanup on non-existent worktree/branch
|
|
213
|
-
script = (
|
|
214
|
-
'worktree_cleanup "{repo}" "/nonexistent/path" "nonexistent-branch"\n'
|
|
215
|
-
'echo "OK"\n'
|
|
216
|
-
).format(repo=repo)
|
|
217
|
-
|
|
218
|
-
result = _run_worktree_script(script)
|
|
219
|
-
assert result.returncode == 0
|
|
220
|
-
assert "OK" in result.stdout
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
class TestWorktreePruneStale:
|
|
224
|
-
def test_prune_stale(self, tmp_path):
|
|
225
|
-
repo = tmp_path / "repo"
|
|
226
|
-
repo.mkdir()
|
|
227
|
-
_init_git_repo(repo)
|
|
228
|
-
|
|
229
|
-
script = (
|
|
230
|
-
'worktree_prune_stale "{repo}"\n'
|
|
231
|
-
'echo "OK"\n'
|
|
232
|
-
).format(repo=repo)
|
|
233
|
-
|
|
234
|
-
result = _run_worktree_script(script)
|
|
235
|
-
assert result.returncode == 0
|
|
236
|
-
assert "OK" in result.stdout
|