gsd-pi 2.12.0 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/dist/cli.js +18 -1
  2. package/dist/resource-loader.d.ts +2 -0
  3. package/dist/resource-loader.js +36 -1
  4. package/dist/resources/extensions/gsd/auto-worktree.ts +509 -0
  5. package/dist/resources/extensions/gsd/auto.ts +222 -11
  6. package/dist/resources/extensions/gsd/doctor.ts +195 -1
  7. package/dist/resources/extensions/gsd/git-self-heal.ts +198 -0
  8. package/dist/resources/extensions/gsd/git-service.ts +11 -0
  9. package/dist/resources/extensions/gsd/preferences.ts +17 -1
  10. package/dist/resources/extensions/gsd/prompts/system.md +32 -29
  11. package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
  12. package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
  13. package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
  14. package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
  15. package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
  16. package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
  17. package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
  18. package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
  19. package/dist/resources/extensions/gsd/worktree-manager.ts +6 -4
  20. package/dist/resources/extensions/search-the-web/native-search.ts +15 -10
  21. package/package.json +1 -1
  22. package/packages/pi-coding-agent/dist/cli/args.js +1 -1
  23. package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
  24. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +4 -1
  25. package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
  26. package/packages/pi-coding-agent/dist/core/extensions/runner.js +2 -1
  27. package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
  28. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +5 -0
  29. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  30. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  31. package/packages/pi-coding-agent/dist/core/sdk.js +3 -3
  32. package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
  33. package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
  34. package/packages/pi-coding-agent/dist/core/system-prompt.js +6 -0
  35. package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
  36. package/packages/pi-coding-agent/src/cli/args.ts +1 -1
  37. package/packages/pi-coding-agent/src/core/extensions/runner.ts +2 -1
  38. package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
  39. package/packages/pi-coding-agent/src/core/sdk.ts +3 -3
  40. package/packages/pi-coding-agent/src/core/system-prompt.ts +9 -0
  41. package/packages/pi-tui/dist/components/editor.d.ts +11 -0
  42. package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
  43. package/packages/pi-tui/dist/components/editor.js +64 -6
  44. package/packages/pi-tui/dist/components/editor.js.map +1 -1
  45. package/packages/pi-tui/src/components/editor.ts +71 -6
  46. package/src/resources/extensions/gsd/auto-worktree.ts +509 -0
  47. package/src/resources/extensions/gsd/auto.ts +222 -11
  48. package/src/resources/extensions/gsd/doctor.ts +195 -1
  49. package/src/resources/extensions/gsd/git-self-heal.ts +198 -0
  50. package/src/resources/extensions/gsd/git-service.ts +11 -0
  51. package/src/resources/extensions/gsd/preferences.ts +17 -1
  52. package/src/resources/extensions/gsd/prompts/system.md +32 -29
  53. package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
  54. package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
  55. package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
  56. package/src/resources/extensions/gsd/tests/doctor-git.test.ts +246 -0
  57. package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +234 -0
  58. package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
  59. package/src/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
  60. package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +315 -0
  61. package/src/resources/extensions/gsd/worktree-manager.ts +6 -4
  62. package/src/resources/extensions/search-the-web/native-search.ts +15 -10
@@ -0,0 +1,259 @@
1
+ /**
2
+ * auto-worktree-milestone-merge.test.ts — Integration tests for mergeMilestoneToMain.
3
+ *
4
+ * Covers: squash-merge topology (one commit on main), rich commit message with
5
+ * slice titles, worktree cleanup, nothing-to-commit edge case, auto-push with
6
+ * bare remote. All tests use real git operations in temp repos.
7
+ */
8
+
9
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync, readFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+ import { execSync } from "node:child_process";
13
+
14
+ import {
15
+ createAutoWorktree,
16
+ mergeMilestoneToMain,
17
+ mergeSliceToMilestone,
18
+ getAutoWorktreeOriginalBase,
19
+ } from "../auto-worktree.ts";
20
+ import { getSliceBranchName } from "../worktree.ts";
21
+
22
+ import { createTestContext } from "./test-helpers.ts";
23
+
24
+ const { assertEq, assertTrue, assertMatch, report } = createTestContext();
25
+
26
+ function run(cmd: string, cwd: string): string {
27
+ return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
28
+ }
29
+
30
+ function createTempRepo(): string {
31
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "wt-ms-merge-test-")));
32
+ run("git init", dir);
33
+ run("git config user.email test@test.com", dir);
34
+ run("git config user.name Test", dir);
35
+ writeFileSync(join(dir, "README.md"), "# test\n");
36
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
37
+ writeFileSync(join(dir, ".gsd", "STATE.md"), "# State\n");
38
+ run("git add .", dir);
39
+ run("git commit -m init", dir);
40
+ run("git branch -M main", dir);
41
+ return dir;
42
+ }
43
+
44
+ /** Minimal roadmap content for mergeMilestoneToMain. */
45
+ function makeRoadmap(milestoneId: string, title: string, slices: Array<{ id: string; title: string }>): string {
46
+ const sliceLines = slices.map(s => `- [x] **${s.id}: ${s.title}**`).join("\n");
47
+ return `# ${milestoneId}: ${title}\n\n## Slices\n${sliceLines}\n`;
48
+ }
49
+
50
+ /** Set up a slice branch on the worktree, add commits, merge it --no-ff to milestone. */
51
+ function addSliceToMilestone(
52
+ repo: string,
53
+ wtPath: string,
54
+ milestoneId: string,
55
+ sliceId: string,
56
+ sliceTitle: string,
57
+ commits: Array<{ file: string; content: string; message: string }>,
58
+ ): void {
59
+ // Detect worktree name for branch naming
60
+ const normalizedPath = wtPath.replaceAll("\\", "/");
61
+ const marker = "/.gsd/worktrees/";
62
+ const idx = normalizedPath.indexOf(marker);
63
+ const worktreeName = idx !== -1 ? normalizedPath.slice(idx + marker.length).split("/")[0] : null;
64
+
65
+ const sliceBranch = getSliceBranchName(milestoneId, sliceId, worktreeName);
66
+
67
+ run(`git checkout -b ${sliceBranch}`, wtPath);
68
+ for (const c of commits) {
69
+ writeFileSync(join(wtPath, c.file), c.content);
70
+ run("git add .", wtPath);
71
+ run(`git commit -m "${c.message}"`, wtPath);
72
+ }
73
+ run(`git checkout milestone/${milestoneId}`, wtPath);
74
+ mergeSliceToMilestone(repo, milestoneId, sliceId, sliceTitle);
75
+ }
76
+
77
+ async function main(): Promise<void> {
78
+ const savedCwd = process.cwd();
79
+ const tempDirs: string[] = [];
80
+
81
+ function freshRepo(): string {
82
+ const d = createTempRepo();
83
+ tempDirs.push(d);
84
+ return d;
85
+ }
86
+
87
+ try {
88
+ // ─── Test 1: Basic squash merge — one commit on main ───────────────
89
+ console.log("\n=== basic squash merge — one commit on main ===");
90
+ {
91
+ const repo = freshRepo();
92
+ const wtPath = createAutoWorktree(repo, "M010");
93
+
94
+ // Add two slices with multiple commits each
95
+ addSliceToMilestone(repo, wtPath, "M010", "S01", "Auth module", [
96
+ { file: "auth.ts", content: "export const auth = true;\n", message: "add auth" },
97
+ { file: "auth-utils.ts", content: "export const hash = () => {};\n", message: "add auth utils" },
98
+ ]);
99
+ addSliceToMilestone(repo, wtPath, "M010", "S02", "User dashboard", [
100
+ { file: "dashboard.ts", content: "export const dash = true;\n", message: "add dashboard" },
101
+ { file: "widgets.ts", content: "export const widgets = [];\n", message: "add widgets" },
102
+ ]);
103
+
104
+ const roadmap = makeRoadmap("M010", "User management", [
105
+ { id: "S01", title: "Auth module" },
106
+ { id: "S02", title: "User dashboard" },
107
+ ]);
108
+
109
+ const mainLogBefore = run("git log --oneline main", repo);
110
+ const mainCommitCountBefore = mainLogBefore.split("\n").length;
111
+
112
+ const result = mergeMilestoneToMain(repo, "M010", roadmap);
113
+
114
+ // Exactly one new commit on main
115
+ const mainLog = run("git log --oneline main", repo);
116
+ const mainCommitCountAfter = mainLog.split("\n").length;
117
+ assertEq(mainCommitCountAfter, mainCommitCountBefore + 1, "exactly one new commit on main");
118
+
119
+ // Milestone branch deleted
120
+ const branches = run("git branch", repo);
121
+ assertTrue(!branches.includes("milestone/M010"), "milestone branch deleted");
122
+
123
+ // Worktree directory removed
124
+ const worktreeDir = join(repo, ".gsd", "worktrees", "M010");
125
+ assertTrue(!existsSync(worktreeDir), "worktree directory removed");
126
+
127
+ // Module state cleared
128
+ assertEq(getAutoWorktreeOriginalBase(), null, "originalBase cleared after merge");
129
+
130
+ // Files from both slices present on main
131
+ assertTrue(existsSync(join(repo, "auth.ts")), "auth.ts on main");
132
+ assertTrue(existsSync(join(repo, "dashboard.ts")), "dashboard.ts on main");
133
+ assertTrue(existsSync(join(repo, "widgets.ts")), "widgets.ts on main");
134
+
135
+ // Result shape
136
+ assertTrue(result.commitMessage.length > 0, "commitMessage returned");
137
+ assertTrue(typeof result.pushed === "boolean", "pushed is boolean");
138
+ }
139
+
140
+ // ─── Test 2: Rich commit message format ────────────────────────────
141
+ console.log("\n=== rich commit message format ===");
142
+ {
143
+ const repo = freshRepo();
144
+ const wtPath = createAutoWorktree(repo, "M020");
145
+
146
+ addSliceToMilestone(repo, wtPath, "M020", "S01", "Core API", [
147
+ { file: "api.ts", content: "export const api = true;\n", message: "add api" },
148
+ ]);
149
+ addSliceToMilestone(repo, wtPath, "M020", "S02", "Error handling", [
150
+ { file: "errors.ts", content: "export class AppError {}\n", message: "add errors" },
151
+ ]);
152
+ addSliceToMilestone(repo, wtPath, "M020", "S03", "Logging infra", [
153
+ { file: "logger.ts", content: "export const log = () => {};\n", message: "add logger" },
154
+ ]);
155
+
156
+ const roadmap = makeRoadmap("M020", "Backend foundation", [
157
+ { id: "S01", title: "Core API" },
158
+ { id: "S02", title: "Error handling" },
159
+ { id: "S03", title: "Logging infra" },
160
+ ]);
161
+
162
+ const result = mergeMilestoneToMain(repo, "M020", roadmap);
163
+
164
+ // Subject line: conventional commit format
165
+ assertMatch(result.commitMessage, /^feat\(M020\):/, "subject has conventional commit prefix");
166
+ assertTrue(result.commitMessage.includes("Backend foundation"), "subject includes milestone title");
167
+
168
+ // Body: slice listing
169
+ assertTrue(result.commitMessage.includes("- S01: Core API"), "body lists S01");
170
+ assertTrue(result.commitMessage.includes("- S02: Error handling"), "body lists S02");
171
+ assertTrue(result.commitMessage.includes("- S03: Logging infra"), "body lists S03");
172
+
173
+ // Branch metadata
174
+ assertTrue(result.commitMessage.includes("Branch: milestone/M020"), "body has branch metadata");
175
+
176
+ // Verify the actual git commit message matches
177
+ const gitMsg = run("git log -1 --format=%B main", repo).trim();
178
+ assertMatch(gitMsg, /^feat\(M020\):/, "git commit message starts with feat(M020):");
179
+ assertTrue(gitMsg.includes("- S01: Core API"), "git commit body has S01");
180
+ }
181
+
182
+ // ─── Test 3: Nothing to commit — no changes ────────────────────────
183
+ console.log("\n=== nothing to commit — no changes ===");
184
+ {
185
+ const repo = freshRepo();
186
+ const wtPath = createAutoWorktree(repo, "M030");
187
+
188
+ // Don't add any slices/changes — milestone branch is identical to main
189
+ const roadmap = makeRoadmap("M030", "Empty milestone", []);
190
+
191
+ // Should complete without throwing
192
+ let threw = false;
193
+ try {
194
+ const result = mergeMilestoneToMain(repo, "M030", roadmap);
195
+ assertTrue(typeof result.pushed === "boolean", "returns result even with nothing to commit");
196
+ } catch {
197
+ threw = true;
198
+ }
199
+ assertTrue(!threw, "does not throw on nothing-to-commit");
200
+
201
+ // Main log unchanged (only init commit)
202
+ const mainLog = run("git log --oneline main", repo);
203
+ assertEq(mainLog.split("\n").length, 1, "main still has only init commit");
204
+ }
205
+
206
+ // ─── Test 4: Auto-push — verify push mechanics work ──────────────
207
+ // Note: loadEffectiveGSDPreferences uses a module-level const for project
208
+ // prefs path (process.cwd() at import time), so temp repo prefs aren't
209
+ // discoverable. We verify the push mechanics work by testing that
210
+ // mergeMilestoneToMain successfully completes with a remote configured,
211
+ // then manually push to verify the remote is set up correctly.
212
+ console.log("\n=== auto-push with bare remote ===");
213
+ {
214
+ const repo = freshRepo();
215
+
216
+ // Set up bare remote
217
+ const bareDir = realpathSync(mkdtempSync(join(tmpdir(), "wt-ms-bare-")));
218
+ tempDirs.push(bareDir);
219
+ run("git init --bare", bareDir);
220
+ run(`git remote add origin ${bareDir}`, repo);
221
+ run("git push -u origin main", repo);
222
+
223
+ const wtPath = createAutoWorktree(repo, "M040");
224
+
225
+ addSliceToMilestone(repo, wtPath, "M040", "S01", "Push test", [
226
+ { file: "pushed.ts", content: "export const pushed = true;\n", message: "add pushed file" },
227
+ ]);
228
+
229
+ const roadmap = makeRoadmap("M040", "Push verification", [
230
+ { id: "S01", title: "Push test" },
231
+ ]);
232
+
233
+ const result = mergeMilestoneToMain(repo, "M040", roadmap);
234
+
235
+ // Verify merge succeeded (commit on main)
236
+ const mainLog = run("git log --oneline main", repo);
237
+ assertTrue(mainLog.includes("feat(M040)"), "milestone commit on main");
238
+
239
+ // Manually push to verify remote works
240
+ run("git push origin main", repo);
241
+ const remoteLog = run("git log --oneline main", bareDir);
242
+ assertTrue(remoteLog.includes("feat(M040)"), "milestone commit reachable on remote after manual push");
243
+
244
+ // result.pushed will be false since prefs aren't loadable in temp repos
245
+ // (module-level const limitation) — that's expected
246
+ assertEq(result.pushed, false, "pushed is false without discoverable prefs");
247
+ }
248
+
249
+ } finally {
250
+ process.chdir(savedCwd);
251
+ for (const d of tempDirs) {
252
+ if (existsSync(d)) rmSync(d, { recursive: true, force: true });
253
+ }
254
+ }
255
+
256
+ report();
257
+ }
258
+
259
+ main();
@@ -0,0 +1,147 @@
1
+ /**
2
+ * auto-worktree.test.ts — Tests for auto-worktree lifecycle.
3
+ *
4
+ * Covers: create → detect → teardown, re-entry, path helpers.
5
+ * Runs in a real temp git repo.
6
+ */
7
+
8
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { tmpdir } from "node:os";
11
+ import { execSync } from "node:child_process";
12
+
13
+ import {
14
+ createAutoWorktree,
15
+ teardownAutoWorktree,
16
+ isInAutoWorktree,
17
+ getAutoWorktreePath,
18
+ enterAutoWorktree,
19
+ getAutoWorktreeOriginalBase,
20
+ } from "../auto-worktree.ts";
21
+
22
+ import { createTestContext } from "./test-helpers.ts";
23
+
24
+ const { assertEq, assertTrue, report } = createTestContext();
25
+
26
+ function run(command: string, cwd: string): string {
27
+ return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
28
+ }
29
+
30
+ function createTempRepo(): string {
31
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "auto-wt-test-")));
32
+ run("git init", dir);
33
+ run("git config user.email test@test.com", dir);
34
+ run("git config user.name Test", dir);
35
+ // Create initial commit on main
36
+ writeFileSync(join(dir, "README.md"), "# test\n");
37
+ run("git add .", dir);
38
+ run("git commit -m init", dir);
39
+ // Ensure branch is called main
40
+ run("git branch -M main", dir);
41
+ return dir;
42
+ }
43
+
44
+ async function main(): Promise<void> {
45
+ const savedCwd = process.cwd();
46
+ let tempDir = "";
47
+
48
+ try {
49
+ tempDir = createTempRepo();
50
+
51
+ // Create .gsd/milestones/M003 with a dummy file (simulates planning artifacts)
52
+ const msDir = join(tempDir, ".gsd", "milestones", "M003");
53
+ mkdirSync(msDir, { recursive: true });
54
+ writeFileSync(join(msDir, "CONTEXT.md"), "# M003 Context\n");
55
+ run("git add .", tempDir);
56
+ run("git commit -m 'add milestone'", tempDir);
57
+
58
+ console.log("\n=== auto-worktree lifecycle ===");
59
+
60
+ // ─── createAutoWorktree ──────────────────────────────────────────
61
+ const wtPath = createAutoWorktree(tempDir, "M003");
62
+
63
+ assertTrue(existsSync(wtPath), "worktree directory exists after create");
64
+ assertEq(process.cwd(), wtPath, "process.cwd() is worktree path after create");
65
+
66
+ const branch = run("git branch --show-current", wtPath);
67
+ assertEq(branch, "milestone/M003", "git branch is milestone/M003");
68
+
69
+ assertTrue(
70
+ existsSync(join(wtPath, ".gsd", "milestones", "M003", "CONTEXT.md")),
71
+ "planning files inherited in worktree",
72
+ );
73
+
74
+ // ─── isInAutoWorktree ────────────────────────────────────────────
75
+ assertTrue(isInAutoWorktree(tempDir), "isInAutoWorktree returns true when inside");
76
+
77
+ // ─── getAutoWorktreeOriginalBase ─────────────────────────────────
78
+ assertEq(getAutoWorktreeOriginalBase(), tempDir, "originalBase returns temp dir");
79
+
80
+ // ─── getAutoWorktreePath ─────────────────────────────────────────
81
+ assertEq(getAutoWorktreePath(tempDir, "M003"), wtPath, "getAutoWorktreePath returns correct path");
82
+ assertEq(getAutoWorktreePath(tempDir, "M999"), null, "getAutoWorktreePath returns null for nonexistent");
83
+
84
+ // ─── teardownAutoWorktree ────────────────────────────────────────
85
+ teardownAutoWorktree(tempDir, "M003");
86
+
87
+ assertEq(process.cwd(), tempDir, "process.cwd() back to original after teardown");
88
+ assertTrue(!existsSync(wtPath), "worktree directory removed after teardown");
89
+ assertTrue(!isInAutoWorktree(tempDir), "isInAutoWorktree returns false after teardown");
90
+ assertEq(getAutoWorktreeOriginalBase(), null, "originalBase is null after teardown");
91
+
92
+ // ─── Re-entry: create again, exit without teardown, re-enter ─────
93
+ console.log("\n=== re-entry ===");
94
+
95
+ const wtPath2 = createAutoWorktree(tempDir, "M003");
96
+ assertTrue(existsSync(wtPath2), "worktree re-created");
97
+
98
+ // Manually chdir out (simulates pause/crash)
99
+ process.chdir(tempDir);
100
+
101
+ // enterAutoWorktree should re-enter
102
+ const entered = enterAutoWorktree(tempDir, "M003");
103
+ assertEq(process.cwd(), entered, "re-entered worktree via enterAutoWorktree");
104
+ assertEq(getAutoWorktreeOriginalBase(), tempDir, "originalBase restored on re-entry");
105
+ assertTrue(isInAutoWorktree(tempDir), "isInAutoWorktree true after re-entry");
106
+
107
+ // Cleanup
108
+ teardownAutoWorktree(tempDir, "M003");
109
+
110
+ // ─── Coexistence with manual worktree ─────────────────────────────
111
+ console.log("\n=== coexistence ===");
112
+
113
+ // Import createWorktree directly for manual worktree
114
+ const { createWorktree } = await import("../worktree-manager.ts");
115
+
116
+ // Create manual worktree (uses worktree/<name> branch)
117
+ const manualWt = createWorktree(tempDir, "feature-x");
118
+ assertTrue(existsSync(manualWt.path), "manual worktree exists");
119
+ assertEq(manualWt.branch, "worktree/feature-x", "manual worktree uses worktree/ prefix");
120
+
121
+ // Create auto-worktree alongside
122
+ const autoWtPath = createAutoWorktree(tempDir, "M003");
123
+ assertTrue(existsSync(autoWtPath), "auto-worktree coexists with manual");
124
+ assertTrue(existsSync(manualWt.path), "manual worktree still exists");
125
+
126
+ // Cleanup both
127
+ teardownAutoWorktree(tempDir, "M003");
128
+ const { removeWorktree } = await import("../worktree-manager.ts");
129
+ removeWorktree(tempDir, "feature-x");
130
+
131
+ // ─── Failure: split-brain prevention ──────────────────────────────
132
+ console.log("\n=== split-brain prevention ===");
133
+ // After teardown, originalBase should be null
134
+ assertEq(getAutoWorktreeOriginalBase(), null, "no split-brain: originalBase cleared");
135
+
136
+ } finally {
137
+ // Always restore cwd and clean up
138
+ process.chdir(savedCwd);
139
+ if (tempDir && existsSync(tempDir)) {
140
+ rmSync(tempDir, { recursive: true, force: true });
141
+ }
142
+ }
143
+
144
+ report("auto-worktree");
145
+ }
146
+
147
+ main();
@@ -0,0 +1,246 @@
1
+ /**
2
+ * doctor-git.test.ts — Integration tests for doctor git health checks.
3
+ *
4
+ * Creates real temp git repos with deliberate broken state, runs runGSDDoctor,
5
+ * and asserts correct detection and fixing of all 4 git issue codes:
6
+ * orphaned_auto_worktree, stale_milestone_branch,
7
+ * corrupt_merge_state, tracked_runtime_files
8
+ */
9
+
10
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+ import { execSync } from "node:child_process";
14
+
15
+ import { runGSDDoctor } from "../doctor.ts";
16
+ import { createTestContext } from "./test-helpers.ts";
17
+
18
+ const { assertEq, assertTrue, report } = createTestContext();
19
+
20
+ function run(cmd: string, cwd: string): string {
21
+ return execSync(cmd, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
22
+ }
23
+
24
+ /** Create a temp git repo with a completed milestone M001 in roadmap. */
25
+ function createRepoWithCompletedMilestone(): string {
26
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-git-test-")));
27
+ run("git init", dir);
28
+ run("git config user.email test@test.com", dir);
29
+ run("git config user.name Test", dir);
30
+
31
+ // Initial commit
32
+ writeFileSync(join(dir, "README.md"), "# test\n");
33
+ run("git add .", dir);
34
+ run("git commit -m init", dir);
35
+ run("git branch -M main", dir);
36
+
37
+ // Create .gsd structure with milestone M001 — all slices done → complete
38
+ const msDir = join(dir, ".gsd", "milestones", "M001");
39
+ mkdirSync(msDir, { recursive: true });
40
+ writeFileSync(join(msDir, "ROADMAP.md"), `---
41
+ id: M001
42
+ title: "Test Milestone"
43
+ ---
44
+
45
+ # M001: Test Milestone
46
+
47
+ ## Vision
48
+ Test
49
+
50
+ ## Success Criteria
51
+ - Done
52
+
53
+ ## Slices
54
+ - [x] **S01: Test slice** \`risk:low\` \`depends:[]\`
55
+ > After this: done
56
+
57
+ ## Boundary Map
58
+ _None_
59
+ `);
60
+
61
+ // Commit .gsd files
62
+ run("git add -A", dir);
63
+ run("git commit -m 'add milestone'", dir);
64
+
65
+ return dir;
66
+ }
67
+
68
+ /** Create a repo with an in-progress milestone. */
69
+ function createRepoWithActiveMilestone(): string {
70
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-git-test-")));
71
+ run("git init", dir);
72
+ run("git config user.email test@test.com", dir);
73
+ run("git config user.name Test", dir);
74
+
75
+ writeFileSync(join(dir, "README.md"), "# test\n");
76
+ run("git add .", dir);
77
+ run("git commit -m init", dir);
78
+ run("git branch -M main", dir);
79
+
80
+ const msDir = join(dir, ".gsd", "milestones", "M001");
81
+ mkdirSync(msDir, { recursive: true });
82
+ writeFileSync(join(msDir, "ROADMAP.md"), `---
83
+ id: M001
84
+ title: "Active Milestone"
85
+ ---
86
+
87
+ # M001: Active Milestone
88
+
89
+ ## Vision
90
+ Test
91
+
92
+ ## Success Criteria
93
+ - Done
94
+
95
+ ## Slices
96
+ - [ ] **S01: Test slice** \`risk:low\` \`depends:[]\`
97
+ > After this: done
98
+
99
+ ## Boundary Map
100
+ _None_
101
+ `);
102
+
103
+ run("git add -A", dir);
104
+ run("git commit -m 'add milestone'", dir);
105
+
106
+ return dir;
107
+ }
108
+
109
+ async function main(): Promise<void> {
110
+ const cleanups: string[] = [];
111
+
112
+ try {
113
+ // ─── Test 1: Orphaned worktree detection & fix ─────────────────────
114
+ console.log("\n=== orphaned_auto_worktree ===");
115
+ {
116
+ const dir = createRepoWithCompletedMilestone();
117
+ cleanups.push(dir);
118
+
119
+ // Create worktree with milestone/M001 branch under .gsd/worktrees/
120
+ mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true });
121
+ run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir);
122
+
123
+ const detect = await runGSDDoctor(dir);
124
+ const orphanIssues = detect.issues.filter(i => i.code === "orphaned_auto_worktree");
125
+ assertTrue(orphanIssues.length > 0, "detects orphaned worktree");
126
+ assertEq(orphanIssues[0]?.unitId, "M001", "orphaned worktree unitId is M001");
127
+
128
+ const fixed = await runGSDDoctor(dir, { fix: true });
129
+ assertTrue(fixed.fixesApplied.some(f => f.includes("removed orphaned worktree")), "fix removes orphaned worktree");
130
+
131
+ // Verify worktree is gone
132
+ const wtList = run("git worktree list", dir);
133
+ assertTrue(!wtList.includes("milestone/M001"), "worktree no longer listed after fix");
134
+ }
135
+
136
+ // ─── Test 2: Stale milestone branch detection & fix ────────────────
137
+ console.log("\n=== stale_milestone_branch ===");
138
+ {
139
+ const dir = createRepoWithCompletedMilestone();
140
+ cleanups.push(dir);
141
+
142
+ // Create a milestone/M001 branch (no worktree)
143
+ run("git branch milestone/M001", dir);
144
+
145
+ const detect = await runGSDDoctor(dir);
146
+ const staleIssues = detect.issues.filter(i => i.code === "stale_milestone_branch");
147
+ assertTrue(staleIssues.length > 0, "detects stale milestone branch");
148
+ assertEq(staleIssues[0]?.unitId, "M001", "stale branch unitId is M001");
149
+
150
+ const fixed = await runGSDDoctor(dir, { fix: true });
151
+ assertTrue(fixed.fixesApplied.some(f => f.includes("deleted stale branch")), "fix deletes stale branch");
152
+
153
+ // Verify branch is gone
154
+ const branches = run("git branch --list 'milestone/*'", dir);
155
+ assertTrue(!branches.includes("milestone/M001"), "branch gone after fix");
156
+ }
157
+
158
+ // ─── Test 3: Corrupt merge state detection & fix ───────────────────
159
+ console.log("\n=== corrupt_merge_state ===");
160
+ {
161
+ const dir = createRepoWithCompletedMilestone();
162
+ cleanups.push(dir);
163
+
164
+ // Inject MERGE_HEAD into .git
165
+ const headHash = run("git rev-parse HEAD", dir);
166
+ writeFileSync(join(dir, ".git", "MERGE_HEAD"), headHash + "\n");
167
+
168
+ const detect = await runGSDDoctor(dir);
169
+ const mergeIssues = detect.issues.filter(i => i.code === "corrupt_merge_state");
170
+ assertTrue(mergeIssues.length > 0, "detects corrupt merge state");
171
+
172
+ const fixed = await runGSDDoctor(dir, { fix: true });
173
+ assertTrue(fixed.fixesApplied.some(f => f.includes("cleaned merge state")), "fix cleans merge state");
174
+
175
+ // Verify MERGE_HEAD is gone
176
+ assertTrue(!existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD removed after fix");
177
+ }
178
+
179
+ // ─── Test 4: Tracked runtime files detection & fix ─────────────────
180
+ console.log("\n=== tracked_runtime_files ===");
181
+ {
182
+ const dir = createRepoWithCompletedMilestone();
183
+ cleanups.push(dir);
184
+
185
+ // Force-add a runtime file
186
+ const activityDir = join(dir, ".gsd", "activity");
187
+ mkdirSync(activityDir, { recursive: true });
188
+ writeFileSync(join(activityDir, "test.log"), "log data\n");
189
+ run("git add -f .gsd/activity/test.log", dir);
190
+ run("git commit -m 'track runtime file'", dir);
191
+
192
+ const detect = await runGSDDoctor(dir);
193
+ const trackedIssues = detect.issues.filter(i => i.code === "tracked_runtime_files");
194
+ assertTrue(trackedIssues.length > 0, "detects tracked runtime files");
195
+
196
+ const fixed = await runGSDDoctor(dir, { fix: true });
197
+ assertTrue(fixed.fixesApplied.some(f => f.includes("untracked")), "fix untracks runtime files");
198
+
199
+ // Verify file is no longer tracked
200
+ const tracked = run("git ls-files .gsd/activity/", dir);
201
+ assertEq(tracked, "", "runtime file untracked after fix");
202
+ }
203
+
204
+ // ─── Test 5: Non-git directory — graceful degradation ──────────────
205
+ console.log("\n=== non-git directory ===");
206
+ {
207
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "doc-git-test-")));
208
+ cleanups.push(dir);
209
+
210
+ // Create minimal .gsd structure (no git)
211
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
212
+
213
+ const result = await runGSDDoctor(dir);
214
+ const gitIssues = result.issues.filter(i =>
215
+ ["orphaned_auto_worktree", "stale_milestone_branch", "corrupt_merge_state", "tracked_runtime_files"].includes(i.code)
216
+ );
217
+ assertEq(gitIssues.length, 0, "no git issues in non-git directory");
218
+ // Should not throw — reaching here means no crash
219
+ assertTrue(true, "non-git directory does not crash");
220
+ }
221
+
222
+ // ─── Test 6: Active worktree NOT flagged (false positive prevention) ─
223
+ console.log("\n=== active worktree safety ===");
224
+ {
225
+ const dir = createRepoWithActiveMilestone();
226
+ cleanups.push(dir);
227
+
228
+ // Create worktree for in-progress milestone under .gsd/worktrees/
229
+ mkdirSync(join(dir, ".gsd", "worktrees"), { recursive: true });
230
+ run("git worktree add -b milestone/M001 .gsd/worktrees/M001", dir);
231
+
232
+ const detect = await runGSDDoctor(dir);
233
+ const orphanIssues = detect.issues.filter(i => i.code === "orphaned_auto_worktree");
234
+ assertEq(orphanIssues.length, 0, "active worktree NOT flagged as orphaned");
235
+ }
236
+
237
+ } finally {
238
+ for (const dir of cleanups) {
239
+ try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
240
+ }
241
+ }
242
+
243
+ report();
244
+ }
245
+
246
+ main();