prizmkit 1.0.35 → 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 (35) hide show
  1. package/bundled/VERSION.json +3 -3
  2. package/bundled/agents/prizm-dev-team-dev.md +11 -11
  3. package/bundled/agents/prizm-dev-team-reviewer.md +10 -10
  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
@@ -83,7 +83,7 @@ If MISSING — build it now:
83
83
  - **Section 1 — Feature Brief**: feature description + acceptance criteria (copy from above)
84
84
  - **Section 2 — Project Structure**: relevant `ls src/` output
85
85
  - **Section 3 — Prizm Context**: full content of root.prizm and relevant L1/L2 docs
86
- - **Section 4 — Existing Source Files**: full content of each related file as code block
86
+ - **Section 4 — Existing Source Files**: **full verbatim content** of each related file in fenced code blocks (with `### path/to/file` heading and line count). Include ALL files needed for implementation and review — downstream subagents read this section instead of re-reading individual source files
87
87
  - **Section 5 — Existing Tests**: full content of related test files as code blocks
88
88
 
89
89
  ### Phase 2: Plan & Tasks (you, the orchestrator)
@@ -156,15 +156,9 @@ Stage any `.prizm-docs/` changes produced: `git add .prizm-docs/`
156
156
  ### Phase 5: Commit
157
157
 
158
158
  - Run `/prizmkit-summarize` → archive to REGISTRY.md
159
- - Mark feature complete:
160
- ```bash
161
- python3 {{VALIDATOR_SCRIPTS_DIR}}/update-feature-status.py \
162
- --feature-list "{{FEATURE_LIST_PATH}}" \
163
- --state-dir "{{PROJECT_ROOT}}/dev-pipeline/state" \
164
- --feature-id "{{FEATURE_ID}}" --session-id "{{SESSION_ID}}" --action complete
165
- ```
166
159
  - Run `/prizmkit-committer` → `feat({{FEATURE_ID}}): {{FEATURE_TITLE}}`, do NOT push
167
160
  - MANDATORY: commit must be done via `/prizmkit-committer` skill. Do NOT run manual `git add`/`git commit` as a substitute.
161
+ - Do NOT run `update-feature-status.py` here — the pipeline runner handles feature-list.json updates automatically after session exit.
168
162
 
169
163
  ---
170
164
 
@@ -13,7 +13,7 @@ You are the **session orchestrator**. Implement Feature {{FEATURE_ID}}: "{{FEATU
13
13
 
14
14
  **CRITICAL**: You MUST NOT exit until ALL work is complete and session-status.json is written. When you spawn subagents, wait for each to finish (run_in_background=false). Do NOT spawn agents in background and exit — that kills the session.
15
15
 
16
- **Tier 3 — Full Team**: PM + Dev + Reviewer agents spawned directly via Task tool. Full 7-phase pipeline.
16
+ **Tier 3 — Full Team**: Dev + Reviewer agents spawned directly via Task tool. Full 7-phase pipeline.
17
17
 
18
18
  ### Feature Description
19
19
 
@@ -68,16 +68,15 @@ LLM context is frozen at prompt time. Modifying a skill source file during this
68
68
  ## PrizmKit Directory Convention
69
69
 
70
70
  ```
71
- .prizmkit/specs/{{FEATURE_SLUG}}/context-snapshot.md ← PM writes, all agents read
71
+ .prizmkit/specs/{{FEATURE_SLUG}}/context-snapshot.md ← orchestrator writes, all subagents read
72
72
  .prizmkit/specs/{{FEATURE_SLUG}}/spec.md
73
73
  .prizmkit/specs/{{FEATURE_SLUG}}/plan.md ← includes Tasks section
74
74
  .prizmkit/specs/REGISTRY.md
75
75
  ```
76
76
 
77
- **`context-snapshot.md`** is the shared knowledge base. PM writes it once; Dev and Reviewer read it instead of re-scanning source files. This eliminates redundant I/O across all agents.
77
+ **`context-snapshot.md`** is the shared knowledge base. Orchestrator writes it once; Dev and Reviewer read it instead of re-scanning source files. This eliminates redundant I/O across all agents.
78
78
 
79
79
  ### Agent Files
80
- - PM Agent: `{{PM_SUBAGENT_PATH}}`
81
80
  - Dev Agent: `{{DEV_SUBAGENT_PATH}}`
82
81
  - Reviewer Agent: `{{REVIEWER_SUBAGENT_PATH}}`
83
82
 
@@ -129,7 +128,6 @@ No TeamCreate required. Agents are spawned directly via the `Task` tool using `s
129
128
  `ls .prizmkit/specs/{{FEATURE_SLUG}}/ 2>/dev/null`
130
129
 
131
130
  Agent files are at:
132
- - PM: `{{PM_SUBAGENT_PATH}}`
133
131
  - Dev: `{{DEV_SUBAGENT_PATH}}`
134
132
  - Reviewer: `{{REVIEWER_SUBAGENT_PATH}}`
135
133
 
@@ -144,7 +142,7 @@ python3 {{INIT_SCRIPT_PATH}} --project-root {{PROJECT_ROOT}} --feature-id {{FEAT
144
142
  After team setup: check `.prizmkit/specs/{{FEATURE_SLUG}}/context-snapshot.md` — if exists, all agents MUST use it. Read existing artifacts and resume from Phase {{RESUME_PHASE}}.
145
143
  {{END_IF_RESUME}}
146
144
 
147
- ### Phase 1-2: Specify + Plan — PM Agent
145
+ ### Phase 1-2: Specify + Plan — Orchestrator (you)
148
146
 
149
147
  Check existing artifacts first:
150
148
  ```bash
@@ -152,40 +150,44 @@ ls .prizmkit/specs/{{FEATURE_SLUG}}/ 2>/dev/null
152
150
  ```
153
151
 
154
152
  - Both (spec.md, plan.md) exist → **SKIP to CP-1**
155
- - `context-snapshot.md` exists → PM reads it instead of re-scanning source files
156
- - Some missing → PM generates only missing files
153
+ - `context-snapshot.md` exists → use it directly, skip Phase 1
154
+ - Some missing → generate only missing files
157
155
 
158
- Before spawning PM, check whether feature code already exists in the project:
156
+ Before planning, check whether feature code already exists in the project:
159
157
  ```bash
160
158
  grep -r "{{FEATURE_SLUG}}" src/ --include="*.js" --include="*.ts" -l 2>/dev/null | head -20
161
159
  ```
162
160
 
163
- Record result as `EXISTING_CODE` (list of files, or empty). Pass this to PM prompt below.
161
+ Record result as `EXISTING_CODE` (list of files, or empty).
164
162
 
165
- Spawn PM agent (Task tool, subagent_type="prizm-dev-team-pm", run_in_background=false).
163
+ If `EXISTING_CODE` is non-empty: your spec/plan/tasks must reflect this existing implementation — document what exists, identify gaps, do NOT re-implement what is already done.
166
164
 
167
- **Construct prompt dynamically** always prefix with:
168
- > "Read {{PM_SUBAGENT_PATH}}. For feature {{FEATURE_ID}} (slug: {{FEATURE_SLUG}}), complete the following IN THIS SINGLE SESSION — do NOT exit until ALL listed steps are done and files are written to disk:"
165
+ **Step A — Build Context Snapshot** (skip if `context-snapshot.md` already exists):
169
166
 
170
- If `EXISTING_CODE` is non-empty, append to prefix:
171
- > "NOTE: The following files related to this feature already exist in the codebase: `<EXISTING_CODE list>`. Your spec/plan/tasks must reflect this existing implementation — document what exists, identify gaps, do NOT re-implement what is already done."
167
+ 1. Read `.prizm-docs/root.prizm` and relevant L1/L2 prizm docs
168
+ 2. Scan `src/` for files related to this feature; read each one
169
+ 3. Write `.prizmkit/specs/{{FEATURE_SLUG}}/context-snapshot.md`:
170
+ - **Section 1 — Feature Brief**: feature description + acceptance criteria (copy from above)
171
+ - **Section 2 — Project Structure**: relevant `ls src/` output
172
+ - **Section 3 — Prizm Context**: full content of root.prizm and relevant L1/L2 docs
173
+ - **Section 4 — Existing Source Files**: **full verbatim content** of each related file in fenced code blocks (with `### path/to/file` heading and line count). Include ALL files needed for implementation and review — downstream subagents read this section instead of re-reading individual source files
174
+ - **Section 5 — Existing Tests**: full content of related test files as code blocks
175
+ 4. Confirm: `ls .prizmkit/specs/{{FEATURE_SLUG}}/context-snapshot.md`
172
176
 
173
- **Step A Build Context Snapshot** (include only if `context-snapshot.md` does NOT exist):
174
- > "Step A: Write `.prizmkit/specs/{{FEATURE_SLUG}}/context-snapshot.md`. This is the team knowledge base — complete it before anything else. Include:
175
- > - Section 1 'Feature Brief': feature description and acceptance criteria
176
- > - Section 2 'Project Structure': output of `ls src/` and relevant subdirectories
177
- > - Section 3 'Prizm Context': full content of `.prizm-docs/root.prizm` and relevant L1/L2 docs
178
- > - Section 4 'Existing Source Files': full content of every related source file as a code block
179
- > - Section 5 'Existing Tests': full content of related test files as code blocks
180
- > Confirm with `ls .prizmkit/specs/{{FEATURE_SLUG}}/context-snapshot.md`."
177
+ **After Step A, do NOT re-read any original source files** use context-snapshot.md for all subsequent work.
181
178
 
182
- **Step B — Planning Artifacts** (include only missing files):
183
- - spec.md missing: "Run prizmkit-specify → generate spec.md. Resolve any `[NEEDS CLARIFICATION]` markers using the feature description — do NOT pause for interactive input."
184
- - plan.md missing: "Run prizmkit-plan → generate plan.md (architecture, components, interface design, data model, testing strategy, risk assessment, and Tasks section with `[ ]` checkboxes)"
179
+ **Step B — Planning Artifacts** (generate only missing files):
185
180
 
186
- > "All files go under `.prizmkit/specs/{{FEATURE_SLUG}}/`. Confirm each with `ls` after writing."
181
+ ```bash
182
+ ls .prizmkit/specs/{{FEATURE_SLUG}}/spec.md .prizmkit/specs/{{FEATURE_SLUG}}/plan.md 2>/dev/null
183
+ ```
184
+
185
+ - spec.md missing: Run `/prizmkit-specify` → generate spec.md. Resolve any `[NEEDS CLARIFICATION]` markers using the feature description — do NOT pause for interactive input.
186
+ - plan.md missing: Run `/prizmkit-plan` → generate plan.md (architecture, components, interface design, data model, testing strategy, risk assessment, and Tasks section with `[ ]` checkboxes)
187
187
 
188
- Wait for PM to return. **CP-1**: Both files exist. If missing, diagnose from PM output do NOT spawn another PM blindly.
188
+ > All files go under `.prizmkit/specs/{{FEATURE_SLUG}}/`. Confirm each with `ls` after writing.
189
+
190
+ **CP-1**: Both spec.md and plan.md exist.
189
191
 
190
192
  ### Phase 4: Analyze — Reviewer Agent
191
193
 
@@ -199,9 +201,7 @@ Prompt:
199
201
  > Report: CRITICAL, HIGH, MEDIUM issues found (or 'No issues found')."
200
202
 
201
203
  Wait for Reviewer to return.
202
- - If CRITICAL issues found: spawn PM to fix use this prompt:
203
- > "Read {{PM_SUBAGENT_PATH}}. Read `.prizmkit/specs/{{FEATURE_SLUG}}/context-snapshot.md` FIRST for full project context. Do NOT re-read individual source files. Fix ONLY the following CRITICAL issues in spec.md/plan.md/tasks.md: `<list issues>`. Do NOT exit until all files are updated."
204
- Then re-run analyze (max 1 round).
204
+ - If CRITICAL issues found: fix them yourselfread `.prizmkit/specs/{{FEATURE_SLUG}}/context-snapshot.md` for full project context. Fix ONLY the listed CRITICAL issues in spec.md/plan.md/tasks.md. Then re-run analyze (max 1 round).
205
205
 
206
206
  **CP-2**: No CRITICAL issues.
207
207
 
@@ -335,17 +335,11 @@ git log --oneline | grep "{{FEATURE_ID}}" | head -3
335
335
  - Stage any `.prizm-docs/` changes: `git add .prizm-docs/`
336
336
  - **Skip if this session is bug-fix-only**
337
337
 
338
- **7c.** Mark feature complete:
339
- ```bash
340
- python3 {{VALIDATOR_SCRIPTS_DIR}}/update-feature-status.py \
341
- --feature-list "{{FEATURE_LIST_PATH}}" \
342
- --state-dir "{{PROJECT_ROOT}}/dev-pipeline/state" \
343
- --feature-id "{{FEATURE_ID}}" --session-id "{{SESSION_ID}}" --action complete
344
- ```
338
+ **7c.** Run `/prizmkit-committer` → `feat({{FEATURE_ID}}): {{FEATURE_TITLE}}`, do NOT push
345
339
 
346
- **7d.** Run `/prizmkit-committer` `feat({{FEATURE_ID}}): {{FEATURE_TITLE}}`, do NOT push
340
+ **7d.** MANDATORY: commit must be done via `/prizmkit-committer` skill. Do NOT run manual `git add`/`git commit` as a substitute.
347
341
 
348
- **7e.** MANDATORY: commit must be done via `/prizmkit-committer` skill. Do NOT run manual `git add`/`git commit` as a substitute.
342
+ **7e.** Do NOT run `update-feature-status.py` here the pipeline runner handles feature-list.json updates automatically after session exit.
349
343
 
350
344
  ---
351
345
 
@@ -409,7 +403,6 @@ No team cleanup needed — agents were spawned directly without TeamCreate.
409
403
  | Feature Artifacts Dir | `.prizmkit/specs/{{FEATURE_SLUG}}/` |
410
404
  | Context Snapshot | `.prizmkit/specs/{{FEATURE_SLUG}}/context-snapshot.md` |
411
405
  | Team Config | `{{TEAM_CONFIG_PATH}}` |
412
- | PM Agent Def | {{PM_SUBAGENT_PATH}} |
413
406
  | Dev Agent Def | {{DEV_SUBAGENT_PATH}} |
414
407
  | Reviewer Agent Def | {{REVIEWER_SUBAGENT_PATH}} |
415
408
  | Session Status Output | {{SESSION_STATUS_PATH}} |
@@ -418,8 +411,8 @@ No team cleanup needed — agents were spawned directly without TeamCreate.
418
411
 
419
412
  ## Reminders
420
413
 
421
- - Tier 3: full team — PM (planning) → Dev (implementation) → Reviewer (review) — agents spawned directly via Task tool (no TeamCreate needed)
422
- - context-snapshot.md is the team knowledge base: PM writes it once, all agents read it
414
+ - Tier 3: full team — Dev (implementation) → Reviewer (review) — agents spawned directly via Task tool (no TeamCreate needed)
415
+ - context-snapshot.md is the team knowledge base: orchestrator writes it once, all agents read it
423
416
  - Do NOT use `run_in_background=true` when spawning agents
424
417
  - ALWAYS write session-status.json before exiting
425
418
  - Commit phase must use `/prizmkit-committer`; do NOT replace with manual git commit commands
@@ -18,7 +18,7 @@ You are the **bug fix session orchestrator**. Fix Bug {{BUG_ID}}: "{{BUG_TITLE}}
18
18
 
19
19
  **CRITICAL SESSION LIFECYCLE RULE**: You MUST NOT exit until ALL work is complete and session-status.json is written. When you spawn subagents, you MUST **wait for each to finish** (run_in_background=false) before proceeding. Do NOT spawn an agent in the background and exit — that kills the session.
20
20
 
21
- **MANDATORY TEAM REQUIREMENT**: You MUST use the `prizm-dev-team` multi-agent team. This is NON-NEGOTIABLE. All implementation and review work MUST be performed by the appropriate team agents (Dev, Reviewer).
21
+ **MANDATORY TEAM REQUIREMENT**: You MUST use the `prizm-dev-team` multi-agent team. This is NON-NEGOTIABLE. All implementation and review work MUST be performed by the appropriate team agents (Dev, Reviewer). You are the orchestrator — handle coordination, planning, and commit phases directly.
22
22
 
23
23
  **BUG FIX DOCUMENTATION POLICY**: Bug fixes MUST NOT be recorded as new documentation entries:
24
24
  - Do NOT run `/prizmkit-summarize` (no REGISTRY.md entries)
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "status": {
17
17
  "type": "string",
18
- "enum": ["success", "partial", "failed", "commit_missing", "docs_missing"]
18
+ "enum": ["success", "partial", "failed", "commit_missing", "docs_missing", "merge_conflict"]
19
19
  },
20
20
  "completed_phases": {
21
21
  "type": "array",
@@ -114,6 +114,10 @@ class TestDetermineStatus:
114
114
  data = {"status": "docs_missing"}
115
115
  assert determine_status(data) == "docs_missing"
116
116
 
117
+ def test_merge_conflict(self):
118
+ data = {"status": "merge_conflict"}
119
+ assert determine_status(data) == "merge_conflict"
120
+
117
121
  def test_unknown_status(self):
118
122
  data = {"status": "something_weird"}
119
123
  assert determine_status(data) == "crashed"
@@ -36,6 +36,9 @@ class TestSessionStatusValues:
36
36
  def test_contains_docs_missing(self):
37
37
  assert "docs_missing" in ufs.SESSION_STATUS_VALUES
38
38
 
39
+ def test_contains_merge_conflict(self):
40
+ assert "merge_conflict" in ufs.SESSION_STATUS_VALUES
41
+
39
42
 
40
43
  class TestNowIso:
41
44
  def test_returns_valid_iso_format(self):
@@ -252,6 +255,73 @@ class TestActionUpdateDegradedStatuses:
252
255
  f1 = next(x for x in data["features"] if x["id"] == "F-001")
253
256
  assert f1["status"] == "failed"
254
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
+
255
325
 
256
326
  class TestActionStatusForDegradedStates:
257
327
  def test_status_output_contains_degraded_counters(self, feature_list_file, state_dir, capsys):
@@ -0,0 +1,236 @@
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