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,234 @@
1
+ /**
2
+ * git-self-heal.test.ts — Integration tests for git self-healing utilities.
3
+ *
4
+ * Uses real temporary git repos with deliberately broken state.
5
+ * No mocks — exercises actual git operations.
6
+ */
7
+
8
+ import { execSync } from "node:child_process";
9
+ import { existsSync, mkdtempSync, writeFileSync, mkdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { tmpdir } from "node:os";
12
+ import { rmSync } from "node:fs";
13
+ import assert from "node:assert/strict";
14
+ import {
15
+ abortAndReset,
16
+ withMergeHeal,
17
+ recoverCheckout,
18
+ formatGitError,
19
+ MergeConflictError,
20
+ } from "../git-self-heal.js";
21
+
22
+ // ─── Helpers ─────────────────────────────────────────────────────────
23
+
24
+ function makeTempRepo(): string {
25
+ const dir = mkdtempSync(join(tmpdir(), "gsd-self-heal-"));
26
+ execSync("git init", { cwd: dir, stdio: "pipe" });
27
+ execSync("git config user.email 'test@test.com'", { cwd: dir, stdio: "pipe" });
28
+ execSync("git config user.name 'Test'", { cwd: dir, stdio: "pipe" });
29
+ writeFileSync(join(dir, "README.md"), "# init\n");
30
+ execSync("git add -A && git commit -m 'init'", { cwd: dir, stdio: "pipe" });
31
+ return dir;
32
+ }
33
+
34
+ function cleanup(dir: string) {
35
+ try {
36
+ rmSync(dir, { recursive: true, force: true });
37
+ } catch {
38
+ // ignore
39
+ }
40
+ }
41
+
42
+ // ─── abortAndReset ───────────────────────────────────────────────────
43
+
44
+ console.log("── abortAndReset ──");
45
+
46
+ // Test: leftover MERGE_HEAD
47
+ {
48
+ const dir = makeTempRepo();
49
+ try {
50
+ // Create a conflicting branch
51
+ execSync("git checkout -b feature", { cwd: dir, stdio: "pipe" });
52
+ writeFileSync(join(dir, "file.txt"), "feature content\n");
53
+ execSync("git add -A && git commit -m 'feature'", { cwd: dir, stdio: "pipe" });
54
+ execSync("git checkout master 2>/dev/null || git checkout main", { cwd: dir, stdio: "pipe" });
55
+ writeFileSync(join(dir, "file.txt"), "main content\n");
56
+ execSync("git add -A && git commit -m 'main change'", { cwd: dir, stdio: "pipe" });
57
+
58
+ // Create a merge conflict → MERGE_HEAD will exist
59
+ try {
60
+ execSync("git merge feature", { cwd: dir, stdio: "pipe" });
61
+ } catch {
62
+ // expected conflict
63
+ }
64
+
65
+ assert.ok(existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD should exist before abort");
66
+
67
+ const result = abortAndReset(dir);
68
+ assert.ok(result.cleaned.some((s) => s.includes("aborted merge")), "should report aborted merge");
69
+ assert.ok(!existsSync(join(dir, ".git", "MERGE_HEAD")), "MERGE_HEAD should be gone after abort");
70
+
71
+ console.log(" ✓ cleans up leftover MERGE_HEAD");
72
+ } finally {
73
+ cleanup(dir);
74
+ }
75
+ }
76
+
77
+ // Test: leftover SQUASH_MSG (no MERGE_HEAD)
78
+ {
79
+ const dir = makeTempRepo();
80
+ try {
81
+ // Manually create a SQUASH_MSG to simulate leftover state
82
+ writeFileSync(join(dir, ".git", "SQUASH_MSG"), "leftover squash message\n");
83
+
84
+ const result = abortAndReset(dir);
85
+ assert.ok(result.cleaned.some((s) => s.includes("SQUASH_MSG")), "should report SQUASH_MSG removal");
86
+ assert.ok(!existsSync(join(dir, ".git", "SQUASH_MSG")), "SQUASH_MSG should be gone");
87
+
88
+ console.log(" ✓ cleans up leftover SQUASH_MSG");
89
+ } finally {
90
+ cleanup(dir);
91
+ }
92
+ }
93
+
94
+ // Test: clean state (no-op)
95
+ {
96
+ const dir = makeTempRepo();
97
+ try {
98
+ const result = abortAndReset(dir);
99
+ assert.deepStrictEqual(result.cleaned, [], "clean repo should produce empty cleaned array");
100
+
101
+ console.log(" ✓ no-op on clean state");
102
+ } finally {
103
+ cleanup(dir);
104
+ }
105
+ }
106
+
107
+ // ─── withMergeHeal ───────────────────────────────────────────────────
108
+
109
+ console.log("── withMergeHeal ──");
110
+
111
+ // Test: transient failure succeeds on retry
112
+ {
113
+ const dir = makeTempRepo();
114
+ try {
115
+ let callCount = 0;
116
+ const result = withMergeHeal(dir, () => {
117
+ callCount++;
118
+ if (callCount === 1) throw new Error("transient git error");
119
+ return "success";
120
+ });
121
+
122
+ assert.strictEqual(result, "success", "should return mergeFn result on retry");
123
+ assert.strictEqual(callCount, 2, "should have called mergeFn twice");
124
+
125
+ console.log(" ✓ transient failure succeeds on retry");
126
+ } finally {
127
+ cleanup(dir);
128
+ }
129
+ }
130
+
131
+ // Test: real conflict escalates immediately (no retry)
132
+ {
133
+ const dir = makeTempRepo();
134
+ try {
135
+ // Set up a real merge conflict
136
+ execSync("git checkout -b conflict-branch", { cwd: dir, stdio: "pipe" });
137
+ writeFileSync(join(dir, "conflict.txt"), "branch A\n");
138
+ execSync("git add -A && git commit -m 'branch A'", { cwd: dir, stdio: "pipe" });
139
+ execSync("git checkout master 2>/dev/null || git checkout main", { cwd: dir, stdio: "pipe" });
140
+ writeFileSync(join(dir, "conflict.txt"), "branch B\n");
141
+ execSync("git add -A && git commit -m 'branch B'", { cwd: dir, stdio: "pipe" });
142
+
143
+ let callCount = 0;
144
+ try {
145
+ withMergeHeal(dir, () => {
146
+ callCount++;
147
+ // Actually perform the conflicting merge
148
+ execSync("git merge conflict-branch", { cwd: dir, stdio: "pipe" });
149
+ });
150
+ assert.fail("should have thrown MergeConflictError");
151
+ } catch (err) {
152
+ assert.ok(err instanceof MergeConflictError, `should throw MergeConflictError, got ${(err as Error).constructor.name}`);
153
+ assert.strictEqual(callCount, 1, "should NOT retry on real conflict");
154
+ }
155
+
156
+ console.log(" ✓ real conflict escalates immediately without retry");
157
+ } finally {
158
+ cleanup(dir);
159
+ }
160
+ }
161
+
162
+ // ─── recoverCheckout ─────────────────────────────────────────────────
163
+
164
+ console.log("── recoverCheckout ──");
165
+
166
+ // Test: dirty index recovery
167
+ {
168
+ const dir = makeTempRepo();
169
+ try {
170
+ // Create a branch to checkout to
171
+ execSync("git checkout -b target-branch", { cwd: dir, stdio: "pipe" });
172
+ execSync("git checkout master 2>/dev/null || git checkout main", { cwd: dir, stdio: "pipe" });
173
+
174
+ // Dirty the index
175
+ writeFileSync(join(dir, "README.md"), "dirty changes\n");
176
+ execSync("git add README.md", { cwd: dir, stdio: "pipe" });
177
+
178
+ // Normal checkout would complain about dirty index
179
+ recoverCheckout(dir, "target-branch");
180
+
181
+ const branch = execSync("git branch --show-current", { cwd: dir, encoding: "utf-8" }).trim();
182
+ assert.strictEqual(branch, "target-branch", "should be on target branch after recovery");
183
+
184
+ console.log(" ✓ recovers checkout with dirty index");
185
+ } finally {
186
+ cleanup(dir);
187
+ }
188
+ }
189
+
190
+ // Test: non-existent branch throws with context
191
+ {
192
+ const dir = makeTempRepo();
193
+ try {
194
+ try {
195
+ recoverCheckout(dir, "nonexistent-branch");
196
+ assert.fail("should have thrown");
197
+ } catch (err) {
198
+ assert.ok((err as Error).message.includes("recoverCheckout failed"), "should include context in error");
199
+ assert.ok((err as Error).message.includes("nonexistent-branch"), "should mention branch name");
200
+ }
201
+
202
+ console.log(" ✓ throws with context for non-existent branch");
203
+ } finally {
204
+ cleanup(dir);
205
+ }
206
+ }
207
+
208
+ // ─── formatGitError ──────────────────────────────────────────────────
209
+
210
+ console.log("── formatGitError ──");
211
+
212
+ {
213
+ const cases: Array<{ input: string; shouldContain: string; label: string }> = [
214
+ { input: "CONFLICT (content): Merge conflict in file.ts", shouldContain: "/gsd doctor", label: "merge conflict" },
215
+ { input: "error: pathspec 'foo' did not match any file(s)", shouldContain: "/gsd doctor", label: "checkout failure" },
216
+ { input: "HEAD detached at abc123", shouldContain: "/gsd doctor", label: "detached HEAD" },
217
+ { input: "Unable to create '/path/.git/index.lock': File exists", shouldContain: "/gsd doctor", label: "lock file" },
218
+ { input: "fatal: not a git repository", shouldContain: "/gsd doctor", label: "not a repo" },
219
+ { input: "some unknown error", shouldContain: "/gsd doctor", label: "unknown error" },
220
+ ];
221
+
222
+ for (const { input, shouldContain, label } of cases) {
223
+ const result = formatGitError(input);
224
+ assert.ok(result.includes(shouldContain), `${label}: should suggest /gsd doctor`);
225
+ console.log(` ✓ ${label} → suggests /gsd doctor`);
226
+ }
227
+
228
+ // Test with Error object
229
+ const result = formatGitError(new Error("CONFLICT in merge"));
230
+ assert.ok(result.includes("/gsd doctor"), "should handle Error objects");
231
+ console.log(" ✓ handles Error objects");
232
+ }
233
+
234
+ console.log("\n✅ All git-self-heal tests passed");
@@ -0,0 +1,107 @@
1
+ /**
2
+ * isolation-resolver.test.ts -- Tests for shouldUseWorktreeIsolation resolver.
3
+ *
4
+ * Tests three resolution paths:
5
+ * 1. Explicit git.isolation preference overrides everything
6
+ * 2. Legacy detection: existing gsd/*\/* branches = branch mode
7
+ * 3. Default: new project = worktree mode
8
+ */
9
+
10
+ import { mkdtempSync, writeFileSync, rmSync, 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 { shouldUseWorktreeIsolation } from "../auto-worktree.ts";
16
+ import { createTestContext } from "./test-helpers.ts";
17
+
18
+ const { assertEq, report } = createTestContext();
19
+
20
+ function run(command: string, cwd: string): string {
21
+ return execSync(command, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
22
+ }
23
+
24
+ function createTempRepo(): string {
25
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "iso-resolver-test-")));
26
+ run("git init", dir);
27
+ run("git config user.email test@test.com", dir);
28
+ run("git config user.name Test", dir);
29
+ writeFileSync(join(dir, "README.md"), "# test\n");
30
+ run("git add .", dir);
31
+ run("git commit -m init", dir);
32
+ run("git branch -M main", dir);
33
+ return dir;
34
+ }
35
+
36
+ async function main(): Promise<void> {
37
+ const savedCwd = process.cwd();
38
+
39
+ console.log("\n=== shouldUseWorktreeIsolation ===");
40
+
41
+ // Test 1: New project with no gsd branches → defaults to worktree (true)
42
+ {
43
+ const dir = createTempRepo();
44
+ try {
45
+ const result = shouldUseWorktreeIsolation(dir);
46
+ assertEq(result, true, "new project defaults to worktree isolation");
47
+ } finally {
48
+ process.chdir(savedCwd);
49
+ rmSync(dir, { recursive: true, force: true });
50
+ }
51
+ }
52
+
53
+ // Test 2: Legacy project with gsd/*/* branches → returns false (branch mode)
54
+ {
55
+ const dir = createTempRepo();
56
+ try {
57
+ // Create a legacy gsd/*/* branch
58
+ run("git checkout -b gsd/M001/S01", dir);
59
+ writeFileSync(join(dir, "slice.md"), "# S01\n");
60
+ run("git add .", dir);
61
+ run("git commit -m 'slice work'", dir);
62
+ run("git checkout main", dir);
63
+
64
+ const result = shouldUseWorktreeIsolation(dir);
65
+ assertEq(result, false, "legacy project with gsd branches → branch mode");
66
+ } finally {
67
+ process.chdir(savedCwd);
68
+ rmSync(dir, { recursive: true, force: true });
69
+ }
70
+ }
71
+
72
+ // Test 3: Explicit preference override -- isolation: "worktree"
73
+ {
74
+ const dir = createTempRepo();
75
+ try {
76
+ // Create legacy branches that would normally trigger branch mode
77
+ run("git checkout -b gsd/M001/S01", dir);
78
+ writeFileSync(join(dir, "slice.md"), "# S01\n");
79
+ run("git add .", dir);
80
+ run("git commit -m 'slice work'", dir);
81
+ run("git checkout main", dir);
82
+
83
+ const result = shouldUseWorktreeIsolation(dir, { isolation: "worktree" });
84
+ assertEq(result, true, "explicit isolation: worktree overrides legacy detection");
85
+ } finally {
86
+ process.chdir(savedCwd);
87
+ rmSync(dir, { recursive: true, force: true });
88
+ }
89
+ }
90
+
91
+ // Test 4: Explicit preference override -- isolation: "branch"
92
+ {
93
+ const dir = createTempRepo();
94
+ try {
95
+ // No legacy branches -- would normally default to worktree
96
+ const result = shouldUseWorktreeIsolation(dir, { isolation: "branch" });
97
+ assertEq(result, false, "explicit isolation: branch overrides default");
98
+ } finally {
99
+ process.chdir(savedCwd);
100
+ rmSync(dir, { recursive: true, force: true });
101
+ }
102
+ }
103
+
104
+ report();
105
+ }
106
+
107
+ main();
@@ -0,0 +1,88 @@
1
+ /**
2
+ * preferences-git.test.ts — Validates git.isolation and git.merge_to_main preference fields.
3
+ */
4
+
5
+ import { createTestContext } from "./test-helpers.ts";
6
+ import { validatePreferences } from "../preferences.ts";
7
+
8
+ const { assertEq, assertTrue, report } = createTestContext();
9
+
10
+ async function main(): Promise<void> {
11
+ console.log("\n=== git.isolation validation ===");
12
+
13
+ // Valid values
14
+ {
15
+ const { preferences, errors } = validatePreferences({ git: { isolation: "worktree" } });
16
+ assertEq(errors.length, 0, "isolation: worktree — no errors");
17
+ assertEq(preferences.git?.isolation, "worktree", "isolation: worktree — value preserved");
18
+ }
19
+ {
20
+ const { preferences, errors } = validatePreferences({ git: { isolation: "branch" } });
21
+ assertEq(errors.length, 0, "isolation: branch — no errors");
22
+ assertEq(preferences.git?.isolation, "branch", "isolation: branch — value preserved");
23
+ }
24
+
25
+ // Invalid values
26
+ {
27
+ const { errors } = validatePreferences({ git: { isolation: "invalid" } });
28
+ assertTrue(errors.length > 0, "isolation: invalid — produces error");
29
+ assertTrue(errors[0].includes("isolation"), "isolation: invalid — error mentions isolation");
30
+ }
31
+ {
32
+ const { errors } = validatePreferences({ git: { isolation: 42 } });
33
+ assertTrue(errors.length > 0, "isolation: number — produces error");
34
+ }
35
+
36
+ // Undefined passes through
37
+ {
38
+ const { preferences, errors } = validatePreferences({ git: { auto_push: true } });
39
+ assertEq(errors.length, 0, "isolation: undefined — no errors");
40
+ assertEq(preferences.git?.isolation, undefined, "isolation: undefined — not set");
41
+ }
42
+
43
+ console.log("\n=== git.merge_to_main validation ===");
44
+
45
+ // Valid values
46
+ {
47
+ const { preferences, errors } = validatePreferences({ git: { merge_to_main: "milestone" } });
48
+ assertEq(errors.length, 0, "merge_to_main: milestone — no errors");
49
+ assertEq(preferences.git?.merge_to_main, "milestone", "merge_to_main: milestone — value preserved");
50
+ }
51
+ {
52
+ const { preferences, errors } = validatePreferences({ git: { merge_to_main: "slice" } });
53
+ assertEq(errors.length, 0, "merge_to_main: slice — no errors");
54
+ assertEq(preferences.git?.merge_to_main, "slice", "merge_to_main: slice — value preserved");
55
+ }
56
+
57
+ // Invalid values
58
+ {
59
+ const { errors } = validatePreferences({ git: { merge_to_main: "invalid" } });
60
+ assertTrue(errors.length > 0, "merge_to_main: invalid — produces error");
61
+ assertTrue(errors[0].includes("merge_to_main"), "merge_to_main: invalid — error mentions merge_to_main");
62
+ }
63
+ {
64
+ const { errors } = validatePreferences({ git: { merge_to_main: false } });
65
+ assertTrue(errors.length > 0, "merge_to_main: boolean — produces error");
66
+ }
67
+
68
+ // Undefined passes through
69
+ {
70
+ const { preferences, errors } = validatePreferences({ git: { auto_push: true } });
71
+ assertEq(errors.length, 0, "merge_to_main: undefined — no errors");
72
+ assertEq(preferences.git?.merge_to_main, undefined, "merge_to_main: undefined — not set");
73
+ }
74
+
75
+ console.log("\n=== both fields together ===");
76
+ {
77
+ const { preferences, errors } = validatePreferences({
78
+ git: { isolation: "worktree", merge_to_main: "slice" },
79
+ });
80
+ assertEq(errors.length, 0, "both fields valid — no errors");
81
+ assertEq(preferences.git?.isolation, "worktree", "isolation preserved");
82
+ assertEq(preferences.git?.merge_to_main, "slice", "merge_to_main preserved");
83
+ }
84
+
85
+ report();
86
+ }
87
+
88
+ main();