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,198 @@
1
+ /**
2
+ * git-self-heal.ts — Automated git state recovery utilities.
3
+ *
4
+ * Four synchronous functions for recovering from broken git state
5
+ * during auto-mode operations. Uses only `git reset --hard HEAD` —
6
+ * never `git clean` (which would delete untracked .gsd/ dirs).
7
+ *
8
+ * Observability: Each function returns structured results describing
9
+ * what actions were taken. `formatGitError` maps raw git errors to
10
+ * user-friendly messages suggesting `/gsd doctor`.
11
+ */
12
+
13
+ import { execSync } from "node:child_process";
14
+ import { existsSync, unlinkSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { MergeConflictError } from "./git-service.js";
17
+
18
+ // Re-export for consumers
19
+ export { MergeConflictError };
20
+
21
+ /** Result from abortAndReset describing what was cleaned up. */
22
+ export interface AbortAndResetResult {
23
+ /** List of actions taken, e.g. ["aborted merge", "removed SQUASH_MSG", "reset to HEAD"] */
24
+ cleaned: string[];
25
+ }
26
+
27
+ /**
28
+ * Detect and clean up leftover merge/rebase state, then hard-reset.
29
+ *
30
+ * Checks for: .git/MERGE_HEAD, .git/SQUASH_MSG, .git/rebase-apply.
31
+ * Aborts in-progress merge or rebase if detected. Always finishes
32
+ * with `git reset --hard HEAD`.
33
+ *
34
+ * @returns Structured result listing what was cleaned. Empty `cleaned`
35
+ * array means repo was already in a clean state.
36
+ */
37
+ export function abortAndReset(cwd: string): AbortAndResetResult {
38
+ const gitDir = join(cwd, ".git");
39
+ const cleaned: string[] = [];
40
+
41
+ // Abort in-progress merge
42
+ if (existsSync(join(gitDir, "MERGE_HEAD"))) {
43
+ try {
44
+ execSync("git merge --abort", { cwd, stdio: "pipe" });
45
+ cleaned.push("aborted merge");
46
+ } catch {
47
+ // merge --abort can fail if state is really broken; continue to reset
48
+ cleaned.push("merge abort attempted (may have failed)");
49
+ }
50
+ }
51
+
52
+ // Remove leftover SQUASH_MSG (squash-merge leaves this without MERGE_HEAD)
53
+ const squashMsgPath = join(gitDir, "SQUASH_MSG");
54
+ if (existsSync(squashMsgPath)) {
55
+ try {
56
+ unlinkSync(squashMsgPath);
57
+ cleaned.push("removed SQUASH_MSG");
58
+ } catch {
59
+ // Not critical
60
+ }
61
+ }
62
+
63
+ // Abort in-progress rebase
64
+ if (existsSync(join(gitDir, "rebase-apply")) || existsSync(join(gitDir, "rebase-merge"))) {
65
+ try {
66
+ execSync("git rebase --abort", { cwd, stdio: "pipe" });
67
+ cleaned.push("aborted rebase");
68
+ } catch {
69
+ cleaned.push("rebase abort attempted (may have failed)");
70
+ }
71
+ }
72
+
73
+ // Always hard-reset to HEAD
74
+ try {
75
+ execSync("git reset --hard HEAD", { cwd, stdio: "pipe" });
76
+ if (cleaned.length > 0) {
77
+ cleaned.push("reset to HEAD");
78
+ }
79
+ } catch {
80
+ cleaned.push("reset to HEAD failed");
81
+ }
82
+
83
+ return { cleaned };
84
+ }
85
+
86
+ /**
87
+ * Wrap a merge operation with self-healing retry logic.
88
+ *
89
+ * Calls `mergeFn()`. On failure:
90
+ * - If conflicted files exist (via `git diff --diff-filter=U`), re-throws
91
+ * as MergeConflictError immediately — no retry for real code conflicts.
92
+ * - Otherwise, runs `abortAndReset(cwd)`, retries `mergeFn()` once.
93
+ * - On second failure, throws the error.
94
+ *
95
+ * @param cwd - Working directory for git operations
96
+ * @param mergeFn - Synchronous function that performs the merge
97
+ * @returns The return value of `mergeFn()`
98
+ */
99
+ export function withMergeHeal<T>(cwd: string, mergeFn: () => T): T {
100
+ try {
101
+ return mergeFn();
102
+ } catch (firstError) {
103
+ // Check for real code conflicts — escalate immediately, no retry
104
+ try {
105
+ const conflictOutput = execSync("git diff --name-only --diff-filter=U", {
106
+ cwd,
107
+ encoding: "utf-8",
108
+ stdio: ["pipe", "pipe", "pipe"],
109
+ }).trim();
110
+
111
+ if (conflictOutput.length > 0) {
112
+ const conflictedFiles = conflictOutput.split("\n").filter(Boolean);
113
+ // If the original error is already a MergeConflictError, re-throw as-is
114
+ if (firstError instanceof MergeConflictError) {
115
+ throw firstError;
116
+ }
117
+ throw new MergeConflictError(
118
+ conflictedFiles,
119
+ "merge",
120
+ "unknown",
121
+ "unknown",
122
+ );
123
+ }
124
+ } catch (diffErr) {
125
+ // If diffErr is a MergeConflictError we just created/re-threw, propagate it
126
+ if (diffErr instanceof MergeConflictError) throw diffErr;
127
+ // Otherwise git diff itself failed — proceed with retry
128
+ }
129
+
130
+ // No real conflict detected — try abort+reset+retry once
131
+ abortAndReset(cwd);
132
+
133
+ // Retry
134
+ return mergeFn();
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Recover a failed checkout by resetting first, then checking out.
140
+ *
141
+ * Performs `git reset --hard HEAD` then `git checkout <targetBranch>`.
142
+ * If checkout still fails after reset, throws with context.
143
+ */
144
+ export function recoverCheckout(cwd: string, targetBranch: string): void {
145
+ execSync("git reset --hard HEAD", { cwd, stdio: "pipe" });
146
+
147
+ try {
148
+ execSync(`git checkout ${targetBranch}`, { cwd, stdio: "pipe" });
149
+ } catch (err) {
150
+ const msg = err instanceof Error ? err.message : String(err);
151
+ throw new Error(
152
+ `recoverCheckout failed: could not checkout '${targetBranch}' after reset. ${msg}`,
153
+ );
154
+ }
155
+ }
156
+
157
+ /** Known git error patterns mapped to user-friendly messages. */
158
+ const ERROR_PATTERNS: Array<{ pattern: RegExp; message: string }> = [
159
+ {
160
+ pattern: /conflict|CONFLICT|merge conflict/i,
161
+ message: "A merge conflict occurred. Code changes on different branches touched the same files. Run `/gsd doctor` to diagnose.",
162
+ },
163
+ {
164
+ pattern: /cannot checkout|did not match any|pathspec .* did not match/i,
165
+ message: "Git could not switch branches — the target branch may not exist or the working tree is dirty. Run `/gsd doctor` to diagnose.",
166
+ },
167
+ {
168
+ pattern: /HEAD detached|detached HEAD/i,
169
+ message: "Git is in a detached HEAD state — not on any branch. Run `/gsd doctor` to diagnose and reattach.",
170
+ },
171
+ {
172
+ pattern: /\.lock|Unable to create .* lock|lock file/i,
173
+ message: "A git lock file is blocking operations. Another git process may be running, or a previous one crashed. Run `/gsd doctor` to diagnose.",
174
+ },
175
+ {
176
+ pattern: /fatal: not a git repository/i,
177
+ message: "This directory is not a git repository. Run `/gsd doctor` to check your project setup.",
178
+ },
179
+ ];
180
+
181
+ /**
182
+ * Translate raw git error strings into user-friendly messages.
183
+ *
184
+ * Pattern-matches against common git error strings and returns
185
+ * a non-technical message suggesting `/gsd doctor`. Returns the
186
+ * original message if no pattern matches.
187
+ */
188
+ export function formatGitError(error: string | Error): string {
189
+ const errorStr = error instanceof Error ? error.message : error;
190
+
191
+ for (const { pattern, message } of ERROR_PATTERNS) {
192
+ if (pattern.test(errorStr)) {
193
+ return message;
194
+ }
195
+ }
196
+
197
+ return `A git error occurred: ${errorStr.slice(0, 200)}. Run \`/gsd doctor\` for help.`;
198
+ }
@@ -37,6 +37,8 @@ export interface GitPreferences {
37
37
  commit_type?: string;
38
38
  main_branch?: string;
39
39
  merge_strategy?: "squash" | "merge";
40
+ isolation?: "worktree" | "branch";
41
+ merge_to_main?: "milestone" | "slice";
40
42
  }
41
43
 
42
44
  export const VALID_BRANCH_NAME = /^[a-zA-Z0-9_\-\/.]+$/;
@@ -764,6 +766,15 @@ export class GitServiceImpl {
764
766
  this.git(mergeArgs);
765
767
  } catch (mergeError) {
766
768
  // Check if conflicts can be auto-resolved (#189, #218)
769
+ //
770
+ // ─── BRANCH-MODE ONLY (D038) ────────────────────────────────────────
771
+ // The conflict resolution logic below applies ONLY when git.isolation = "branch".
772
+ // In worktree isolation mode, each milestone works in its own worktree directory
773
+ // so merge conflicts between slice branches and main are handled differently
774
+ // (worktree teardown merges via worktree-manager). This block is never reached
775
+ // in worktree mode because mergeSliceToMain is only called from the branch-mode
776
+ // code path. If you're modifying this logic, verify the isolation mode first.
777
+ // ─────────────────────────────────────────────────────────────────────
767
778
  const conflicted = this.git(["diff", "--name-only", "--diff-filter=U"], { allowFailure: true });
768
779
  if (conflicted) {
769
780
  const conflictedFiles = conflicted.split("\n").filter(Boolean);
@@ -634,7 +634,7 @@ function mergePreferences(base: GSDPreferences, override: GSDPreferences): GSDPr
634
634
  };
635
635
  }
636
636
 
637
- function validatePreferences(preferences: GSDPreferences): {
637
+ export function validatePreferences(preferences: GSDPreferences): {
638
638
  preferences: GSDPreferences;
639
639
  errors: string[];
640
640
  } {
@@ -905,6 +905,22 @@ function validatePreferences(preferences: GSDPreferences): {
905
905
  errors.push("git.main_branch must be a valid branch name (alphanumeric, _, -, /, .)");
906
906
  }
907
907
  }
908
+ if (g.isolation !== undefined) {
909
+ const validIsolation = new Set(["worktree", "branch"]);
910
+ if (typeof g.isolation === "string" && validIsolation.has(g.isolation)) {
911
+ git.isolation = g.isolation as "worktree" | "branch";
912
+ } else {
913
+ errors.push("git.isolation must be one of: worktree, branch");
914
+ }
915
+ }
916
+ if (g.merge_to_main !== undefined) {
917
+ const validMergeToMain = new Set(["milestone", "slice"]);
918
+ if (typeof g.merge_to_main === "string" && validMergeToMain.has(g.merge_to_main)) {
919
+ git.merge_to_main = g.merge_to_main as "milestone" | "slice";
920
+ } else {
921
+ errors.push("git.merge_to_main must be one of: milestone, slice");
922
+ }
923
+ }
908
924
 
909
925
  if (Object.keys(git).length > 0) {
910
926
  validated.git = git as GitPreferences;
@@ -120,17 +120,37 @@ Templates showing the expected format for each artifact type are in:
120
120
 
121
121
  ## Execution Heuristics
122
122
 
123
- ### Tool-routing hierarchy
123
+ ### Tool rules
124
124
 
125
- Use the lightest sufficient tool first.
125
+ **File reading:** Use `read` for inspecting files. Never use `cat`, `head`, `tail`, or `sed -n` to view file contents. Use `read` with `offset`/`limit` for slicing. `bash` is for searching (`rg`, `grep`, `find`) and running commands — not for displaying file contents.
126
126
 
127
- - Broad unfamiliar subsystem mapping -> `subagent` with `scout`
128
- - Library, package, or framework truth -> `resolve_library` then `get_library_docs`
129
- - Current external facts -> `search-the-web` + `fetch_page`, or `search_and_read` for one-call extraction
130
- - Long-running processes (servers, watchers, persistent daemons) -> `bg_shell` with `start` + `wait_for_ready`
131
- - Background process status -> `bg_shell` with `digest` (not `output`). Token budget: `digest` (~30 tokens) < `highlights` (~100) < `output` (~2000).
132
- - One-shot commands where you want the result delivered back (builds, tests, installs) -> `async_bash`; result is pushed to you automatically when the command exits.
133
- - Secrets -> `secure_env_collect`
127
+ **File editing:** Always `read` a file before using `edit`. The `edit` tool requires exact text match — you need the real content, not a guess. Use `write` only for new files or complete rewrites.
128
+
129
+ **Code navigation:** Use `lsp` for go-to-definition, find-references, and type info. Falls back gracefully if no server is available. Never `grep` for a symbol definition when `lsp` can resolve it semantically.
130
+
131
+ **Codebase exploration:** Use `subagent` with `scout` for broad unfamiliar subsystem mapping. Use `rg` for text search across files. Use `lsp` for structural navigation. Never read files one-by-one to "explore" — search first, then read what's relevant.
132
+
133
+ **Documentation lookup:** Use `resolve_library` → `get_library_docs` for library/framework questions. Start with `tokens=5000`. Never guess at API signatures from memory when docs are available.
134
+
135
+ **External facts:** Use `search-the-web` + `fetch_page`, or `search_and_read` for one-call extraction. Use `freshness` for recency. Never state current facts from training data without verification.
136
+
137
+ **Background processes:** Use `bg_shell` with `start` + `wait_for_ready` for servers, watchers, and daemons. Never poll with `sleep`/retry loops — `wait_for_ready` exists for this. For status checks, use `digest` (~30 tokens), not `output` (~2000 tokens). Use `highlights` (~100 tokens) when you need significant lines only. Use `output` only when actively debugging.
138
+
139
+ **One-shot commands:** Use `async_bash` for builds, tests, and installs. The result is pushed to you when the command exits — no polling needed. Use `await_job` to block on a specific job.
140
+
141
+ **Secrets:** Use `secure_env_collect`. Never ask the user to edit `.env` files or paste secrets.
142
+
143
+ **Browser verification:** Verify frontend work against a running app. Discovery: `browser_find`/`browser_snapshot_refs`. Action: refs/selectors → `browser_batch` for obvious sequences. Verification: `browser_assert` for explicit pass/fail. Diagnostics: `browser_diff` for ambiguous outcomes → console/network logs when assertions fail → full page inspection as last resort. Debug in order: failing assertion → diff → diagnostics → element state → broader inspection. Retry only with a new hypothesis.
144
+
145
+ ### Anti-patterns — never do these
146
+
147
+ - Never use `cat` to read a file you might edit — `read` gives you the exact text `edit` needs.
148
+ - Never `grep` for a function definition when `lsp` go-to-definition is available.
149
+ - Never poll a server with `sleep 1 && curl` loops — use `bg_shell` `wait_for_ready`.
150
+ - Never use `bg_shell` `output` for a status check — use `digest`.
151
+ - Never read files one-by-one to understand a subsystem — use `rg` or `scout` first.
152
+ - Never guess at library APIs from training data — use `get_library_docs`.
153
+ - Never ask the user to run a command, set a variable, or check something you can check yourself.
134
154
 
135
155
  ### Ask vs infer
136
156
 
@@ -150,32 +170,15 @@ Verify according to task type: bug fix → rerun repro, script fix → rerun com
150
170
 
151
171
  For non-trivial work, verify both the feature and the failure/diagnostic surface. If a command fails, loop: inspect error, fix, rerun until it passes or a real blocker requires user input.
152
172
 
173
+ Work is not done when the code compiles. Work is done when the verification passes.
174
+
153
175
  ### Agent-First Observability
154
176
 
155
177
  For relevant work: add health/status surfaces, persist failure state (last error, phase, timestamp, retry count), verify both happy path and at least one diagnostic signal. Never log secrets. Remove noisy one-off instrumentation before finishing unless it provides durable diagnostic value.
156
178
 
157
179
  ### Root-cause-first debugging
158
180
 
159
- Fix the root cause, not symptoms. When applying a temporary mitigation, label it clearly and preserve the path to the real fix.
160
-
161
- ## Situational Playbooks
162
-
163
- ### Background processes
164
-
165
- Use `bg_shell` for persistent processes — servers, watchers, anything that keeps running. Set `type:'server'` + `ready_port` for dev servers, `group:'name'` for related processes. Use `wait_for_ready` instead of polling. Use `digest` for status checks, `highlights` for significant output, `output` only when debugging. Use `send_and_wait` for interactive CLIs. Kill processes when done.
166
-
167
- Use `async_bash` for one-shot commands (builds, tests, installs) where you want the output delivered back automatically. Result arrives as a follow-up message when the command exits — no polling needed. Use `await_job` to explicitly wait for a specific job, `cancel_job` to stop one, `/jobs` to see what's running.
168
-
169
- ### Web behavior
170
-
171
- Verify frontend work with browser tools against a running app. Operating order: `browser_find`/`browser_snapshot_refs` for discovery → refs/selectors for targeting → `browser_batch` for obvious sequences → `browser_assert` for verification → `browser_diff` for ambiguous outcomes → console/network logs when assertions fail → full page inspection as last resort.
172
-
173
- Debug browser failures in order: failing assertion → `browser_diff` → console/network diagnostics → element/accessibility state → broader inspection. Retry only with a new hypothesis.
174
-
175
- ### Libraries and current facts
176
-
177
- - Libraries: `resolve_library` → `get_library_docs` with specific topic query. Start with `tokens=5000`.
178
- - Current facts: `search-the-web` to evaluate the landscape and pick URLs, or `search_and_read` when you know what you're looking for. Use `freshness` for recency, `domain` to scope to a specific site.
181
+ Fix the root cause, not symptoms. When applying a temporary mitigation, label it clearly and preserve the path to the real fix. Never add a guard or try/catch to suppress an error you haven't diagnosed.
179
182
 
180
183
  ## Communication
181
184
 
@@ -0,0 +1,282 @@
1
+ /**
2
+ * auto-worktree-merge.test.ts — Integration tests for mergeSliceToMilestone.
3
+ *
4
+ * Covers: --no-ff merge topology, rich commit messages, slice branch deletion,
5
+ * zero-commit error, real code conflicts, .gsd/ non-conflict in worktree mode.
6
+ * All tests use real git operations in temp repos.
7
+ */
8
+
9
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } 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
+ teardownAutoWorktree,
17
+ mergeSliceToMilestone,
18
+ } from "../auto-worktree.ts";
19
+ import { MergeConflictError } from "../git-service.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-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
+ /** Create a slice branch in the worktree, add commits, return branch name. */
45
+ function setupSliceBranch(
46
+ wtPath: string,
47
+ milestoneId: string,
48
+ sliceId: string,
49
+ commits: Array<{ file: string; content: string; message: string }>,
50
+ ): string {
51
+ // Detect worktree name for branch naming
52
+ const normalizedPath = wtPath.replaceAll("\\", "/");
53
+ const marker = "/.gsd/worktrees/";
54
+ const idx = normalizedPath.indexOf(marker);
55
+ const worktreeName = idx !== -1 ? normalizedPath.slice(idx + marker.length).split("/")[0] : null;
56
+ const sliceBranch = getSliceBranchName(milestoneId, sliceId, worktreeName);
57
+
58
+ run(`git checkout -b ${sliceBranch}`, wtPath);
59
+ for (const c of commits) {
60
+ writeFileSync(join(wtPath, c.file), c.content);
61
+ run("git add .", wtPath);
62
+ run(`git commit -m "${c.message}"`, wtPath);
63
+ }
64
+ return sliceBranch;
65
+ }
66
+
67
+ async function main(): Promise<void> {
68
+ const savedCwd = process.cwd();
69
+ const tempDirs: string[] = [];
70
+
71
+ function freshRepo(): string {
72
+ const d = createTempRepo();
73
+ tempDirs.push(d);
74
+ return d;
75
+ }
76
+
77
+ try {
78
+ // ─── Test 1: Single slice --no-ff merge ────────────────────────────
79
+ console.log("\n=== single slice --no-ff merge ===");
80
+ {
81
+ const repo = freshRepo();
82
+ const wtPath = createAutoWorktree(repo, "M003");
83
+
84
+ const sliceBranch = setupSliceBranch(wtPath, "M003", "S01", [
85
+ { file: "a.ts", content: "const a = 1;\n", message: "add a.ts" },
86
+ { file: "b.ts", content: "const b = 2;\n", message: "add b.ts" },
87
+ { file: "c.ts", content: "const c = 3;\n", message: "add c.ts" },
88
+ ]);
89
+ run("git checkout milestone/M003", wtPath);
90
+
91
+ const result = mergeSliceToMilestone(repo, "M003", "S01", "Add core files");
92
+
93
+ // Verify we're back on milestone branch
94
+ const branch = run("git branch --show-current", wtPath);
95
+ assertEq(branch, "milestone/M003", "back on milestone branch after merge");
96
+
97
+ // Verify merge topology via git log --graph
98
+ const log = run("git log --oneline --graph", wtPath);
99
+ assertTrue(log.includes("* "), "merge commit visible in graph (asterisk with two parents)");
100
+ assertTrue(log.includes("add a.ts"), "slice commit 'add a.ts' visible");
101
+ assertTrue(log.includes("add b.ts"), "slice commit 'add b.ts' visible");
102
+ assertTrue(log.includes("add c.ts"), "slice commit 'add c.ts' visible");
103
+
104
+ // Verify commit message format
105
+ assertMatch(result.mergedCommitMessage, /feat\(M003\/S01\)/, "commit message has conventional format");
106
+ assertTrue(result.mergedCommitMessage.includes("Add core files"), "commit message includes slice title");
107
+
108
+ // Verify slice branch deleted
109
+ assertTrue(result.deletedBranch, "slice branch deleted");
110
+ const branches = run("git branch", wtPath);
111
+ assertTrue(!branches.includes(sliceBranch), "slice branch no longer in git branch list");
112
+
113
+ teardownAutoWorktree(repo, "M003");
114
+ }
115
+
116
+ // ─── Test 2: Two sequential slices ─────────────────────────────────
117
+ console.log("\n=== two sequential slices ===");
118
+ {
119
+ const repo = freshRepo();
120
+ const wtPath = createAutoWorktree(repo, "M003");
121
+
122
+ // Slice S01
123
+ setupSliceBranch(wtPath, "M003", "S01", [
124
+ { file: "s1.ts", content: "export const s1 = 1;\n", message: "s1 work" },
125
+ ]);
126
+ run("git checkout milestone/M003", wtPath);
127
+ mergeSliceToMilestone(repo, "M003", "S01", "First slice");
128
+
129
+ // Slice S02
130
+ setupSliceBranch(wtPath, "M003", "S02", [
131
+ { file: "s2.ts", content: "export const s2 = 2;\n", message: "s2 work" },
132
+ ]);
133
+ run("git checkout milestone/M003", wtPath);
134
+ mergeSliceToMilestone(repo, "M003", "S02", "Second slice");
135
+
136
+ // Verify two merge boundaries
137
+ const log = run("git log --oneline --graph", wtPath);
138
+ const mergeLines = log.split("\n").filter(l => l.includes("* "));
139
+ assertTrue(mergeLines.length >= 2, "two distinct merge commits in graph");
140
+ assertTrue(log.includes("s1 work"), "S01 commit visible");
141
+ assertTrue(log.includes("s2 work"), "S02 commit visible");
142
+
143
+ teardownAutoWorktree(repo, "M003");
144
+ }
145
+
146
+ // ─── Test 3: Zero commits throws ───────────────────────────────────
147
+ console.log("\n=== zero commits throws ===");
148
+ {
149
+ const repo = freshRepo();
150
+ const wtPath = createAutoWorktree(repo, "M003");
151
+
152
+ // Create slice branch with no commits ahead
153
+ const normalizedPath = wtPath.replaceAll("\\", "/");
154
+ const marker = "/.gsd/worktrees/";
155
+ const idx = normalizedPath.indexOf(marker);
156
+ const worktreeName = idx !== -1 ? normalizedPath.slice(idx + marker.length).split("/")[0] : null;
157
+ const sliceBranch = getSliceBranchName("M003", "S01", worktreeName);
158
+ run(`git checkout -b ${sliceBranch}`, wtPath);
159
+ // No commits — immediately try to merge
160
+ run(`git checkout milestone/M003`, wtPath);
161
+
162
+ let threw = false;
163
+ try {
164
+ mergeSliceToMilestone(repo, "M003", "S01", "Empty slice");
165
+ } catch (err) {
166
+ threw = true;
167
+ assertTrue(
168
+ err instanceof Error && err.message.includes("no commits ahead"),
169
+ "error message mentions no commits ahead",
170
+ );
171
+ }
172
+ assertTrue(threw, "mergeSliceToMilestone throws on zero commits");
173
+
174
+ teardownAutoWorktree(repo, "M003");
175
+ }
176
+
177
+ // ─── Test 4: Real code conflict throws MergeConflictError ──────────
178
+ console.log("\n=== real code conflict throws MergeConflictError ===");
179
+ {
180
+ const repo = freshRepo();
181
+ const wtPath = createAutoWorktree(repo, "M003");
182
+
183
+ // Add a file on milestone branch
184
+ writeFileSync(join(wtPath, "shared.ts"), "// version 1\n");
185
+ run("git add .", wtPath);
186
+ run('git commit -m "add shared.ts"', wtPath);
187
+
188
+ // Create slice branch, modify same file differently
189
+ const normalizedPath = wtPath.replaceAll("\\", "/");
190
+ const marker = "/.gsd/worktrees/";
191
+ const idx = normalizedPath.indexOf(marker);
192
+ const worktreeName = idx !== -1 ? normalizedPath.slice(idx + marker.length).split("/")[0] : null;
193
+ const sliceBranch = getSliceBranchName("M003", "S01", worktreeName);
194
+ run(`git checkout -b ${sliceBranch}`, wtPath);
195
+ writeFileSync(join(wtPath, "shared.ts"), "// slice version\nexport const x = 1;\n");
196
+ run("git add .", wtPath);
197
+ run('git commit -m "slice edit shared.ts"', wtPath);
198
+
199
+ // Modify same file on milestone branch
200
+ run("git checkout milestone/M003", wtPath);
201
+ writeFileSync(join(wtPath, "shared.ts"), "// milestone version\nexport const y = 2;\n");
202
+ run("git add .", wtPath);
203
+ run('git commit -m "milestone edit shared.ts"', wtPath);
204
+
205
+ // Go back to milestone branch for merge call
206
+ run("git checkout milestone/M003", wtPath);
207
+
208
+ let caught: MergeConflictError | null = null;
209
+ try {
210
+ mergeSliceToMilestone(repo, "M003", "S01", "Conflicting slice");
211
+ } catch (err) {
212
+ if (err instanceof MergeConflictError) {
213
+ caught = err;
214
+ } else {
215
+ throw err;
216
+ }
217
+ }
218
+
219
+ assertTrue(caught !== null, "MergeConflictError thrown on conflict");
220
+ if (caught) {
221
+ assertTrue(caught.conflictedFiles.includes("shared.ts"), "conflictedFiles includes shared.ts");
222
+ assertEq(caught.strategy, "merge", "strategy is merge");
223
+ assertTrue(caught.branch.includes("S01"), "branch includes S01");
224
+ }
225
+
226
+ // Clean up conflict state before teardown
227
+ run("git merge --abort || true", wtPath);
228
+ run("git checkout milestone/M003", wtPath);
229
+ teardownAutoWorktree(repo, "M003");
230
+ }
231
+
232
+ // ─── Test 5: .gsd/ changes don't conflict ─────────────────────────
233
+ console.log("\n=== .gsd/ changes don't conflict ===");
234
+ {
235
+ const repo = freshRepo();
236
+ const wtPath = createAutoWorktree(repo, "M003");
237
+
238
+ // The .gsd/ directory in worktrees is local — it's not shared via git
239
+ // between the main repo and the worktree. So modifications to .gsd/
240
+ // files in both branches shouldn't cause conflicts because .gsd/ is
241
+ // in the main repo's tree but the worktree has its own working copy.
242
+ //
243
+ // In the worktree, .gsd/ IS tracked (inherited from main). But since
244
+ // slice branches diverge from milestone branch, .gsd/ changes on both
245
+ // can conflict. The key insight: in real auto-mode, .gsd/ changes only
246
+ // happen on the milestone branch (planning artifacts), not on slice
247
+ // branches (which only have code changes). So we test that code-only
248
+ // slice commits merge cleanly even when milestone has .gsd/ changes.
249
+
250
+ // Add a .gsd/ change on milestone branch
251
+ writeFileSync(join(wtPath, ".gsd", "STATE.md"), "# Updated State\nactive: M003\n");
252
+ run("git add .", wtPath);
253
+ run('git commit -m "update .gsd/STATE.md on milestone"', wtPath);
254
+
255
+ // Create slice branch with code-only changes
256
+ setupSliceBranch(wtPath, "M003", "S01", [
257
+ { file: "feature.ts", content: "export const feature = true;\n", message: "add feature" },
258
+ ]);
259
+ run("git checkout milestone/M003", wtPath);
260
+
261
+ // Merge should succeed — no .gsd/ conflict since slice didn't touch .gsd/
262
+ const result = mergeSliceToMilestone(repo, "M003", "S01", "Feature slice");
263
+ assertTrue(result.branch.includes("S01"), ".gsd/ no-conflict merge succeeded");
264
+ assertTrue(result.deletedBranch, "slice branch deleted after .gsd/-safe merge");
265
+
266
+ // Verify feature file exists after merge
267
+ assertTrue(existsSync(join(wtPath, "feature.ts")), "feature.ts present after merge");
268
+
269
+ teardownAutoWorktree(repo, "M003");
270
+ }
271
+
272
+ } finally {
273
+ process.chdir(savedCwd);
274
+ for (const d of tempDirs) {
275
+ if (existsSync(d)) rmSync(d, { recursive: true, force: true });
276
+ }
277
+ }
278
+
279
+ report();
280
+ }
281
+
282
+ main();