gsd-pi 2.12.0 → 2.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +18 -1
- package/dist/resource-loader.d.ts +2 -0
- package/dist/resource-loader.js +36 -1
- package/dist/resources/extensions/gsd/auto-worktree.ts +512 -0
- package/dist/resources/extensions/gsd/auto.ts +222 -11
- package/dist/resources/extensions/gsd/doctor.ts +198 -2
- package/dist/resources/extensions/gsd/git-self-heal.ts +198 -0
- package/dist/resources/extensions/gsd/git-service.ts +11 -0
- package/dist/resources/extensions/gsd/preferences.ts +17 -1
- package/dist/resources/extensions/gsd/prompts/system.md +32 -29
- package/dist/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
- package/dist/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
- package/dist/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
- package/dist/resources/extensions/gsd/tests/doctor-git.test.ts +264 -0
- package/dist/resources/extensions/gsd/tests/git-self-heal.test.ts +235 -0
- package/dist/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
- package/dist/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
- package/dist/resources/extensions/gsd/tests/worktree-e2e.test.ts +321 -0
- package/dist/resources/extensions/gsd/worktree-manager.ts +13 -7
- package/dist/resources/extensions/search-the-web/native-search.ts +15 -10
- package/package.json +1 -1
- package/packages/pi-coding-agent/dist/cli/args.js +1 -1
- package/packages/pi-coding-agent/dist/cli/args.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts +4 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.js +2 -1
- package/packages/pi-coding-agent/dist/core/extensions/runner.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +5 -0
- package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/sdk.js +3 -3
- package/packages/pi-coding-agent/dist/core/sdk.js.map +1 -1
- package/packages/pi-coding-agent/dist/core/system-prompt.d.ts.map +1 -1
- package/packages/pi-coding-agent/dist/core/system-prompt.js +6 -0
- package/packages/pi-coding-agent/dist/core/system-prompt.js.map +1 -1
- package/packages/pi-coding-agent/src/cli/args.ts +1 -1
- package/packages/pi-coding-agent/src/core/extensions/runner.ts +2 -1
- package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
- package/packages/pi-coding-agent/src/core/sdk.ts +3 -3
- package/packages/pi-coding-agent/src/core/system-prompt.ts +9 -0
- package/packages/pi-tui/dist/components/editor.d.ts +11 -0
- package/packages/pi-tui/dist/components/editor.d.ts.map +1 -1
- package/packages/pi-tui/dist/components/editor.js +64 -6
- package/packages/pi-tui/dist/components/editor.js.map +1 -1
- package/packages/pi-tui/src/components/editor.ts +71 -6
- package/src/resources/extensions/gsd/auto-worktree.ts +512 -0
- package/src/resources/extensions/gsd/auto.ts +222 -11
- package/src/resources/extensions/gsd/doctor.ts +198 -2
- package/src/resources/extensions/gsd/git-self-heal.ts +198 -0
- package/src/resources/extensions/gsd/git-service.ts +11 -0
- package/src/resources/extensions/gsd/preferences.ts +17 -1
- package/src/resources/extensions/gsd/prompts/system.md +32 -29
- package/src/resources/extensions/gsd/tests/auto-worktree-merge.test.ts +282 -0
- package/src/resources/extensions/gsd/tests/auto-worktree-milestone-merge.test.ts +259 -0
- package/src/resources/extensions/gsd/tests/auto-worktree.test.ts +147 -0
- package/src/resources/extensions/gsd/tests/doctor-git.test.ts +264 -0
- package/src/resources/extensions/gsd/tests/git-self-heal.test.ts +235 -0
- package/src/resources/extensions/gsd/tests/isolation-resolver.test.ts +107 -0
- package/src/resources/extensions/gsd/tests/preferences-git.test.ts +88 -0
- package/src/resources/extensions/gsd/tests/worktree-e2e.test.ts +321 -0
- package/src/resources/extensions/gsd/worktree-manager.ts +13 -7
- package/src/resources/extensions/search-the-web/native-search.ts +15 -10
package/dist/cli.js
CHANGED
|
@@ -2,12 +2,27 @@ import { AuthStorage, DefaultResourceLoader, ModelRegistry, SettingsManager, Ses
|
|
|
2
2
|
import { existsSync, readdirSync, renameSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { agentDir, sessionsDir, authFilePath } from './app-paths.js';
|
|
5
|
-
import { initResources, buildResourceLoader } from './resource-loader.js';
|
|
5
|
+
import { initResources, buildResourceLoader, getNewerManagedResourceVersion } from './resource-loader.js';
|
|
6
6
|
import { ensureManagedTools } from './tool-bootstrap.js';
|
|
7
7
|
import { loadStoredEnvKeys } from './wizard.js';
|
|
8
8
|
import { getPiDefaultModelAndProvider, migratePiCredentials } from './pi-migration.js';
|
|
9
9
|
import { shouldRunOnboarding, runOnboarding } from './onboarding.js';
|
|
10
10
|
import { checkForUpdates } from './update-check.js';
|
|
11
|
+
function exitIfManagedResourcesAreNewer(currentAgentDir) {
|
|
12
|
+
const currentVersion = process.env.GSD_VERSION || '0.0.0';
|
|
13
|
+
const managedVersion = getNewerManagedResourceVersion(currentAgentDir, currentVersion);
|
|
14
|
+
if (!managedVersion) {
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
const yellow = '\x1b[33m';
|
|
18
|
+
const dim = '\x1b[2m';
|
|
19
|
+
const reset = '\x1b[0m';
|
|
20
|
+
const bold = '\x1b[1m';
|
|
21
|
+
process.stderr.write(`[gsd] ${yellow}Version mismatch detected${reset}\n` +
|
|
22
|
+
`[gsd] Synced resources are from ${bold}v${managedVersion}${reset}, but this \`gsd\` binary is ${dim}v${currentVersion}${reset}.\n` +
|
|
23
|
+
`[gsd] Run ${bold}npm install -g gsd-pi@latest${reset} or ${bold}gsd update${reset}, then try again.\n`);
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
11
26
|
function parseCliArgs(argv) {
|
|
12
27
|
const flags = { extensions: [], messages: [] };
|
|
13
28
|
const args = argv.slice(2); // skip node + script
|
|
@@ -199,6 +214,7 @@ if (isPrintMode) {
|
|
|
199
214
|
appendSystemPrompt = cliFlags.appendSystemPrompt;
|
|
200
215
|
}
|
|
201
216
|
}
|
|
217
|
+
exitIfManagedResourcesAreNewer(agentDir);
|
|
202
218
|
initResources(agentDir);
|
|
203
219
|
const resourceLoader = new DefaultResourceLoader({
|
|
204
220
|
agentDir,
|
|
@@ -272,6 +288,7 @@ if (existsSync(sessionsDir)) {
|
|
|
272
288
|
const sessionManager = cliFlags.continue
|
|
273
289
|
? SessionManager.continueRecent(cwd, projectSessionsDir)
|
|
274
290
|
: SessionManager.create(cwd, projectSessionsDir);
|
|
291
|
+
exitIfManagedResourcesAreNewer(agentDir);
|
|
275
292
|
initResources(agentDir);
|
|
276
293
|
const resourceLoader = buildResourceLoader(agentDir);
|
|
277
294
|
await resourceLoader.reload();
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { DefaultResourceLoader } from '@gsd/pi-coding-agent';
|
|
2
2
|
export declare function discoverExtensionEntryPaths(extensionsDir: string): string[];
|
|
3
|
+
export declare function readManagedResourceVersion(agentDir: string): string | null;
|
|
4
|
+
export declare function getNewerManagedResourceVersion(agentDir: string, currentVersion: string): string | null;
|
|
3
5
|
/**
|
|
4
6
|
* Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch.
|
|
5
7
|
*
|
package/dist/resource-loader.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { DefaultResourceLoader } from '@gsd/pi-coding-agent';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync } from 'node:fs';
|
|
3
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { dirname, join, relative, resolve } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { compareSemver } from './update-check.js';
|
|
6
7
|
// Resolve resources directory — prefer dist/resources/ (stable, set at build time)
|
|
7
8
|
// over src/resources/ (live working tree, changes with git branch).
|
|
8
9
|
//
|
|
@@ -16,6 +17,7 @@ const distResources = join(packageRoot, 'dist', 'resources');
|
|
|
16
17
|
const srcResources = join(packageRoot, 'src', 'resources');
|
|
17
18
|
const resourcesDir = existsSync(distResources) ? distResources : srcResources;
|
|
18
19
|
const bundledExtensionsDir = join(resourcesDir, 'extensions');
|
|
20
|
+
const resourceVersionManifestName = 'managed-resources.json';
|
|
19
21
|
function isExtensionFile(name) {
|
|
20
22
|
return name.endsWith('.ts') || name.endsWith('.js');
|
|
21
23
|
}
|
|
@@ -70,6 +72,38 @@ function getExtensionKey(entryPath, extensionsDir) {
|
|
|
70
72
|
const relPath = relative(extensionsDir, entryPath);
|
|
71
73
|
return relPath.split(/[\\/]/)[0];
|
|
72
74
|
}
|
|
75
|
+
function getManagedResourceManifestPath(agentDir) {
|
|
76
|
+
return join(agentDir, resourceVersionManifestName);
|
|
77
|
+
}
|
|
78
|
+
function getBundledGsdVersion() {
|
|
79
|
+
try {
|
|
80
|
+
const pkg = JSON.parse(readFileSync(join(packageRoot, 'package.json'), 'utf-8'));
|
|
81
|
+
return typeof pkg?.version === 'string' ? pkg.version : '0.0.0';
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return process.env.GSD_VERSION || '0.0.0';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function writeManagedResourceManifest(agentDir) {
|
|
88
|
+
const manifest = { gsdVersion: getBundledGsdVersion() };
|
|
89
|
+
writeFileSync(getManagedResourceManifestPath(agentDir), JSON.stringify(manifest));
|
|
90
|
+
}
|
|
91
|
+
export function readManagedResourceVersion(agentDir) {
|
|
92
|
+
try {
|
|
93
|
+
const manifest = JSON.parse(readFileSync(getManagedResourceManifestPath(agentDir), 'utf-8'));
|
|
94
|
+
return typeof manifest?.gsdVersion === 'string' ? manifest.gsdVersion : null;
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
export function getNewerManagedResourceVersion(agentDir, currentVersion) {
|
|
101
|
+
const managedVersion = readManagedResourceVersion(agentDir);
|
|
102
|
+
if (!managedVersion) {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
return compareSemver(managedVersion, currentVersion) > 0 ? managedVersion : null;
|
|
106
|
+
}
|
|
73
107
|
/**
|
|
74
108
|
* Syncs all bundled resources to agentDir (~/.gsd/agent/) on every launch.
|
|
75
109
|
*
|
|
@@ -101,6 +135,7 @@ export function initResources(agentDir) {
|
|
|
101
135
|
if (existsSync(srcSkills)) {
|
|
102
136
|
cpSync(srcSkills, destSkills, { recursive: true, force: true });
|
|
103
137
|
}
|
|
138
|
+
writeManagedResourceManifest(agentDir);
|
|
104
139
|
}
|
|
105
140
|
/**
|
|
106
141
|
* Constructs a DefaultResourceLoader that loads extensions from both
|
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GSD Auto-Worktree -- lifecycle management for auto-mode worktrees.
|
|
3
|
+
*
|
|
4
|
+
* Auto-mode creates worktrees with `milestone/<MID>` branches (distinct from
|
|
5
|
+
* manual `/worktree` which uses `worktree/<name>` branches). This module
|
|
6
|
+
* manages create, enter, detect, and teardown for auto-mode worktrees.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { existsSync, readFileSync, realpathSync, utimesSync } from "node:fs";
|
|
10
|
+
import { join, resolve } from "node:path";
|
|
11
|
+
import { execSync, execFileSync } from "node:child_process";
|
|
12
|
+
import {
|
|
13
|
+
createWorktree,
|
|
14
|
+
removeWorktree,
|
|
15
|
+
worktreePath,
|
|
16
|
+
} from "./worktree-manager.js";
|
|
17
|
+
import {
|
|
18
|
+
detectWorktreeName,
|
|
19
|
+
getSliceBranchName,
|
|
20
|
+
} from "./worktree.js";
|
|
21
|
+
import {
|
|
22
|
+
MergeConflictError,
|
|
23
|
+
inferCommitType,
|
|
24
|
+
} from "./git-service.js";
|
|
25
|
+
import type { MergeSliceResult } from "./git-service.js";
|
|
26
|
+
import { recoverCheckout, withMergeHeal } from "./git-self-heal.js";
|
|
27
|
+
import {
|
|
28
|
+
nativeBranchExists,
|
|
29
|
+
nativeCommitCountBetween,
|
|
30
|
+
} from "./native-git-bridge.js";
|
|
31
|
+
import { parseRoadmap } from "./files.js";
|
|
32
|
+
import { loadEffectiveGSDPreferences } from "./preferences.js";
|
|
33
|
+
|
|
34
|
+
// ─── Module State ──────────────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
/** Original project root before chdir into auto-worktree. */
|
|
37
|
+
let originalBase: string | null = null;
|
|
38
|
+
|
|
39
|
+
// ─── Isolation Resolver ────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Determine whether auto-mode should use worktree isolation.
|
|
43
|
+
*
|
|
44
|
+
* Resolution order:
|
|
45
|
+
* 1. Explicit git.isolation preference -> return (isolation === "worktree")
|
|
46
|
+
* 2. Legacy detection: if gsd branches exist -> return false (branch mode)
|
|
47
|
+
* 3. Default: return true (worktree mode for new projects)
|
|
48
|
+
*/
|
|
49
|
+
export function shouldUseWorktreeIsolation(basePath: string, overridePrefs?: { isolation?: string }): boolean {
|
|
50
|
+
const prefs = overridePrefs ?? loadEffectiveGSDPreferences()?.preferences?.git;
|
|
51
|
+
if (prefs?.isolation) {
|
|
52
|
+
return prefs.isolation === "worktree";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Legacy detection: check for existing gsd/*/* branches (branch-per-slice pattern)
|
|
56
|
+
try {
|
|
57
|
+
// Use unquoted glob pattern — single quotes are not interpreted by cmd.exe on Windows,
|
|
58
|
+
// causing the pattern to match literally instead of as a glob.
|
|
59
|
+
const output = execSync("git branch --list gsd/*/*", {
|
|
60
|
+
cwd: basePath,
|
|
61
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
62
|
+
encoding: "utf-8",
|
|
63
|
+
}).trim();
|
|
64
|
+
if (output) return false; // Legacy branch-per-slice project
|
|
65
|
+
} catch {
|
|
66
|
+
// If git command fails, default to worktree
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return true; // New project default
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Resolve the merge_to_main preference value.
|
|
74
|
+
* Returns "milestone" (default) or "slice".
|
|
75
|
+
*/
|
|
76
|
+
export function getMergeToMainMode(): "milestone" | "slice" {
|
|
77
|
+
const prefs = loadEffectiveGSDPreferences()?.preferences?.git;
|
|
78
|
+
return prefs?.merge_to_main ?? "milestone";
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ─── Git Helpers (local, mirrors worktree-command.ts pattern) ──────────────
|
|
82
|
+
|
|
83
|
+
function resolveGitHeadPath(dir: string): string | null {
|
|
84
|
+
const gitPath = join(dir, ".git");
|
|
85
|
+
if (!existsSync(gitPath)) return null;
|
|
86
|
+
try {
|
|
87
|
+
const content = readFileSync(gitPath, "utf8").trim();
|
|
88
|
+
if (content.startsWith("gitdir: ")) {
|
|
89
|
+
const gitDir = resolve(dir, content.slice(8));
|
|
90
|
+
const headPath = join(gitDir, "HEAD");
|
|
91
|
+
return existsSync(headPath) ? headPath : null;
|
|
92
|
+
}
|
|
93
|
+
const headPath = join(dir, ".git", "HEAD");
|
|
94
|
+
return existsSync(headPath) ? headPath : null;
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Nudge pi's FooterDataProvider to re-read the git branch after chdir.
|
|
102
|
+
* Touches HEAD in both old and new cwd to fire the fs watcher.
|
|
103
|
+
*/
|
|
104
|
+
function nudgeGitBranchCache(previousCwd: string): void {
|
|
105
|
+
const now = new Date();
|
|
106
|
+
for (const dir of [previousCwd, process.cwd()]) {
|
|
107
|
+
try {
|
|
108
|
+
const headPath = resolveGitHeadPath(dir);
|
|
109
|
+
if (headPath) utimesSync(headPath, now, now);
|
|
110
|
+
} catch {
|
|
111
|
+
// Best-effort
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function getCurrentBranch(cwd: string): string {
|
|
117
|
+
try {
|
|
118
|
+
return execSync("git branch --show-current", {
|
|
119
|
+
cwd,
|
|
120
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
121
|
+
encoding: "utf-8",
|
|
122
|
+
}).trim();
|
|
123
|
+
} catch {
|
|
124
|
+
return "";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── Auto-Worktree Branch Naming ───────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
export function autoWorktreeBranch(milestoneId: string): string {
|
|
131
|
+
return `milestone/${milestoneId}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ─── Public API ────────────────────────────────────────────────────────────
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Create a new auto-worktree for a milestone, chdir into it, and store
|
|
138
|
+
* the original base path for later teardown.
|
|
139
|
+
*
|
|
140
|
+
* Atomic: chdir + originalBase update happen in the same try block
|
|
141
|
+
* to prevent split-brain.
|
|
142
|
+
*/
|
|
143
|
+
export function createAutoWorktree(basePath: string, milestoneId: string): string {
|
|
144
|
+
const branch = autoWorktreeBranch(milestoneId);
|
|
145
|
+
const info = createWorktree(basePath, milestoneId, { branch });
|
|
146
|
+
const previousCwd = process.cwd();
|
|
147
|
+
|
|
148
|
+
try {
|
|
149
|
+
process.chdir(info.path);
|
|
150
|
+
originalBase = basePath;
|
|
151
|
+
} catch (err) {
|
|
152
|
+
// If chdir fails, the worktree was created but we couldn't enter it.
|
|
153
|
+
// Don't store originalBase -- caller can retry or clean up.
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Auto-worktree created at ${info.path} but chdir failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
nudgeGitBranchCache(previousCwd);
|
|
160
|
+
return info.path;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Teardown an auto-worktree: chdir back to original base, then remove
|
|
165
|
+
* the worktree and its branch.
|
|
166
|
+
*/
|
|
167
|
+
export function teardownAutoWorktree(originalBasePath: string, milestoneId: string): void {
|
|
168
|
+
const branch = autoWorktreeBranch(milestoneId);
|
|
169
|
+
const previousCwd = process.cwd();
|
|
170
|
+
|
|
171
|
+
try {
|
|
172
|
+
process.chdir(originalBasePath);
|
|
173
|
+
originalBase = null;
|
|
174
|
+
} catch (err) {
|
|
175
|
+
throw new Error(
|
|
176
|
+
`Failed to chdir back to ${originalBasePath} during teardown: ${err instanceof Error ? err.message : String(err)}`,
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
nudgeGitBranchCache(previousCwd);
|
|
181
|
+
removeWorktree(originalBasePath, milestoneId, { branch });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Detect if the process is currently inside an auto-worktree.
|
|
186
|
+
* Checks both module state and git branch prefix.
|
|
187
|
+
*/
|
|
188
|
+
export function isInAutoWorktree(basePath: string): boolean {
|
|
189
|
+
if (!originalBase) return false;
|
|
190
|
+
const cwd = process.cwd();
|
|
191
|
+
const resolvedBase = existsSync(basePath) ? realpathSync(basePath) : basePath;
|
|
192
|
+
const wtDir = join(resolvedBase, ".gsd", "worktrees");
|
|
193
|
+
if (!cwd.startsWith(wtDir)) return false;
|
|
194
|
+
const branch = getCurrentBranch(cwd);
|
|
195
|
+
return branch.startsWith("milestone/");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Get the filesystem path for an auto-worktree, or null if it doesn't exist.
|
|
200
|
+
*/
|
|
201
|
+
export function getAutoWorktreePath(basePath: string, milestoneId: string): string | null {
|
|
202
|
+
const p = worktreePath(basePath, milestoneId);
|
|
203
|
+
return existsSync(p) ? p : null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Enter an existing auto-worktree (chdir into it, store originalBase).
|
|
208
|
+
* Use for resume -- the worktree already exists from a prior create.
|
|
209
|
+
*
|
|
210
|
+
* Atomic: chdir + originalBase update in same try block.
|
|
211
|
+
*/
|
|
212
|
+
export function enterAutoWorktree(basePath: string, milestoneId: string): string {
|
|
213
|
+
const p = worktreePath(basePath, milestoneId);
|
|
214
|
+
if (!existsSync(p)) {
|
|
215
|
+
throw new Error(`Auto-worktree for ${milestoneId} does not exist at ${p}`);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const previousCwd = process.cwd();
|
|
219
|
+
|
|
220
|
+
try {
|
|
221
|
+
process.chdir(p);
|
|
222
|
+
originalBase = basePath;
|
|
223
|
+
} catch (err) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`Failed to enter auto-worktree at ${p}: ${err instanceof Error ? err.message : String(err)}`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
nudgeGitBranchCache(previousCwd);
|
|
230
|
+
return p;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Get the original project root stored when entering an auto-worktree.
|
|
235
|
+
* Returns null if not currently in an auto-worktree.
|
|
236
|
+
*/
|
|
237
|
+
export function getAutoWorktreeOriginalBase(): string | null {
|
|
238
|
+
return originalBase;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// ─── Merge Slice -> Milestone ───────────────────────────────────────────────
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Merge a completed slice branch into the milestone branch via `--no-ff`.
|
|
245
|
+
*
|
|
246
|
+
* Worktree-mode merge: `.gsd/` is local to the worktree (not tracked in
|
|
247
|
+
* git), so there are zero `.gsd/` conflict resolution concerns. No runtime
|
|
248
|
+
* exclusion untracking, no `--theirs` checkout, no snapshot creation.
|
|
249
|
+
*
|
|
250
|
+
* On conflict: throws MergeConflictError with conflicted file list.
|
|
251
|
+
* On success: deletes the slice branch and returns MergeSliceResult.
|
|
252
|
+
*/
|
|
253
|
+
export function mergeSliceToMilestone(
|
|
254
|
+
basePath: string,
|
|
255
|
+
milestoneId: string,
|
|
256
|
+
sliceId: string,
|
|
257
|
+
sliceTitle: string,
|
|
258
|
+
): MergeSliceResult {
|
|
259
|
+
if (!isInAutoWorktree(basePath)) {
|
|
260
|
+
throw new Error("mergeSliceToMilestone called outside auto-worktree");
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const cwd = process.cwd();
|
|
264
|
+
const milestoneBranch = autoWorktreeBranch(milestoneId);
|
|
265
|
+
const worktreeName = detectWorktreeName(cwd);
|
|
266
|
+
const sliceBranch = getSliceBranchName(milestoneId, sliceId, worktreeName);
|
|
267
|
+
|
|
268
|
+
// Verify slice branch exists
|
|
269
|
+
if (!nativeBranchExists(cwd, sliceBranch)) {
|
|
270
|
+
throw new Error(`Slice branch "${sliceBranch}" does not exist`);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Verify slice has commits ahead of milestone branch
|
|
274
|
+
const commitCount = nativeCommitCountBetween(cwd, milestoneBranch, sliceBranch);
|
|
275
|
+
if (commitCount === 0) {
|
|
276
|
+
throw new Error(
|
|
277
|
+
`Slice branch "${sliceBranch}" has no commits ahead of "${milestoneBranch}"`,
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Checkout milestone branch (with self-healing reset)
|
|
282
|
+
recoverCheckout(cwd, milestoneBranch);
|
|
283
|
+
|
|
284
|
+
// Build rich commit message (replicates GitServiceImpl.buildRichCommitMessage format)
|
|
285
|
+
const commitType = inferCommitType(sliceTitle);
|
|
286
|
+
const subject = `${commitType}(${milestoneId}/${sliceId}): ${sliceTitle}`;
|
|
287
|
+
|
|
288
|
+
let message = subject;
|
|
289
|
+
try {
|
|
290
|
+
const logOutput = execSync(
|
|
291
|
+
`git log --oneline --format=%s ${milestoneBranch}..${sliceBranch}`,
|
|
292
|
+
{ cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" },
|
|
293
|
+
).trim();
|
|
294
|
+
|
|
295
|
+
if (logOutput) {
|
|
296
|
+
const subjects = logOutput.split("\n").filter(Boolean);
|
|
297
|
+
const MAX_ENTRIES = 20;
|
|
298
|
+
const truncated = subjects.length > MAX_ENTRIES;
|
|
299
|
+
const displayed = truncated ? subjects.slice(0, MAX_ENTRIES) : subjects;
|
|
300
|
+
const taskLines = displayed.map(s => `- ${s}`).join("\n");
|
|
301
|
+
const truncationLine = truncated
|
|
302
|
+
? `\n- ... and ${subjects.length - MAX_ENTRIES} more`
|
|
303
|
+
: "";
|
|
304
|
+
message = `${subject}\n\nTasks:\n${taskLines}${truncationLine}\n\nBranch: ${sliceBranch}`;
|
|
305
|
+
}
|
|
306
|
+
} catch {
|
|
307
|
+
// Fall back to subject-only message
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Merge --no-ff (with self-healing retry for transient failures)
|
|
311
|
+
try {
|
|
312
|
+
withMergeHeal(cwd, () => {
|
|
313
|
+
execFileSync("git", ["merge", "--no-ff", "-m", message, sliceBranch], {
|
|
314
|
+
cwd,
|
|
315
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
316
|
+
encoding: "utf-8",
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
} catch (err) {
|
|
320
|
+
if (err instanceof MergeConflictError) {
|
|
321
|
+
// Re-throw with correct branch context
|
|
322
|
+
throw new MergeConflictError(
|
|
323
|
+
err.conflictedFiles,
|
|
324
|
+
err.strategy,
|
|
325
|
+
sliceBranch,
|
|
326
|
+
milestoneBranch,
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
throw err;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Delete slice branch
|
|
333
|
+
let deletedBranch = false;
|
|
334
|
+
try {
|
|
335
|
+
execSync(`git branch -d ${sliceBranch}`, {
|
|
336
|
+
cwd,
|
|
337
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
338
|
+
encoding: "utf-8",
|
|
339
|
+
});
|
|
340
|
+
deletedBranch = true;
|
|
341
|
+
} catch {
|
|
342
|
+
// Branch deletion is best-effort
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return {
|
|
346
|
+
branch: sliceBranch,
|
|
347
|
+
mergedCommitMessage: message,
|
|
348
|
+
deletedBranch,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ─── Merge Milestone -> Main ───────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Auto-commit any dirty (uncommitted) state in the given directory.
|
|
356
|
+
* Returns true if a commit was made, false if working tree was clean.
|
|
357
|
+
*/
|
|
358
|
+
function autoCommitDirtyState(cwd: string): boolean {
|
|
359
|
+
try {
|
|
360
|
+
const status = execSync("git status --porcelain", {
|
|
361
|
+
cwd,
|
|
362
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
363
|
+
encoding: "utf-8",
|
|
364
|
+
}).trim();
|
|
365
|
+
if (!status) return false;
|
|
366
|
+
execFileSync("git", ["add", "-A"], { cwd, stdio: "pipe" });
|
|
367
|
+
execFileSync("git", ["commit", "-m", "chore: auto-commit before milestone merge"], {
|
|
368
|
+
cwd,
|
|
369
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
370
|
+
encoding: "utf-8",
|
|
371
|
+
});
|
|
372
|
+
return true;
|
|
373
|
+
} catch {
|
|
374
|
+
return false;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Squash-merge the milestone branch into main with a rich commit message
|
|
380
|
+
* listing all completed slices, then tear down the worktree.
|
|
381
|
+
*
|
|
382
|
+
* Sequence:
|
|
383
|
+
* 1. Auto-commit dirty worktree state
|
|
384
|
+
* 2. chdir to originalBasePath
|
|
385
|
+
* 3. git checkout main
|
|
386
|
+
* 4. git merge --squash milestone/<MID>
|
|
387
|
+
* 5. git commit with rich message
|
|
388
|
+
* 6. Auto-push if enabled
|
|
389
|
+
* 7. Delete milestone branch
|
|
390
|
+
* 8. Remove worktree directory
|
|
391
|
+
* 9. Clear originalBase
|
|
392
|
+
*
|
|
393
|
+
* On merge conflict: throws MergeConflictError.
|
|
394
|
+
* On "nothing to commit" after squash: handles gracefully (no error).
|
|
395
|
+
*/
|
|
396
|
+
export function mergeMilestoneToMain(
|
|
397
|
+
originalBasePath_: string,
|
|
398
|
+
milestoneId: string,
|
|
399
|
+
roadmapContent: string,
|
|
400
|
+
): { commitMessage: string; pushed: boolean } {
|
|
401
|
+
const worktreeCwd = process.cwd();
|
|
402
|
+
const milestoneBranch = autoWorktreeBranch(milestoneId);
|
|
403
|
+
|
|
404
|
+
// 1. Auto-commit dirty state in worktree before leaving
|
|
405
|
+
autoCommitDirtyState(worktreeCwd);
|
|
406
|
+
|
|
407
|
+
// 2. Parse roadmap for slice listing
|
|
408
|
+
const roadmap = parseRoadmap(roadmapContent);
|
|
409
|
+
const completedSlices = roadmap.slices.filter(s => s.done);
|
|
410
|
+
|
|
411
|
+
// 3. chdir to original base
|
|
412
|
+
const previousCwd = process.cwd();
|
|
413
|
+
process.chdir(originalBasePath_);
|
|
414
|
+
|
|
415
|
+
// 4. Resolve main branch from preferences
|
|
416
|
+
const prefs = loadEffectiveGSDPreferences()?.preferences?.git ?? {};
|
|
417
|
+
const mainBranch = prefs.main_branch || "main";
|
|
418
|
+
|
|
419
|
+
// 5. Checkout main (with self-healing reset)
|
|
420
|
+
recoverCheckout(originalBasePath_, mainBranch);
|
|
421
|
+
|
|
422
|
+
// 6. Build rich commit message
|
|
423
|
+
const milestoneTitle = roadmap.title.replace(/^M\d+:\s*/, "").trim() || milestoneId;
|
|
424
|
+
const subject = `feat(${milestoneId}): ${milestoneTitle}`;
|
|
425
|
+
let body = "";
|
|
426
|
+
if (completedSlices.length > 0) {
|
|
427
|
+
const sliceLines = completedSlices.map(s => `- ${s.id}: ${s.title}`).join("\n");
|
|
428
|
+
body = `\n\nCompleted slices:\n${sliceLines}\n\nBranch: ${milestoneBranch}`;
|
|
429
|
+
}
|
|
430
|
+
const commitMessage = subject + body;
|
|
431
|
+
|
|
432
|
+
// 7. Squash merge (with self-healing retry for transient failures)
|
|
433
|
+
try {
|
|
434
|
+
withMergeHeal(originalBasePath_, () => {
|
|
435
|
+
execSync(`git merge --squash ${milestoneBranch}`, {
|
|
436
|
+
cwd: originalBasePath_,
|
|
437
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
438
|
+
encoding: "utf-8",
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
} catch (err) {
|
|
442
|
+
if (err instanceof MergeConflictError) {
|
|
443
|
+
// Re-throw with correct branch context
|
|
444
|
+
throw new MergeConflictError(
|
|
445
|
+
err.conflictedFiles,
|
|
446
|
+
err.strategy,
|
|
447
|
+
milestoneBranch,
|
|
448
|
+
mainBranch,
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
// Possibly "already up to date" -- fall through to commit which will handle nothing-to-commit
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
// 8. Commit (handle nothing-to-commit gracefully)
|
|
455
|
+
let nothingToCommit = false;
|
|
456
|
+
try {
|
|
457
|
+
execFileSync("git", ["commit", "-m", commitMessage], {
|
|
458
|
+
cwd: originalBasePath_,
|
|
459
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
460
|
+
encoding: "utf-8",
|
|
461
|
+
});
|
|
462
|
+
} catch (err: unknown) {
|
|
463
|
+
// execSync errors have stdout/stderr as properties -- check those for git's message
|
|
464
|
+
const errObj = err as { stdout?: string; stderr?: string; message?: string };
|
|
465
|
+
const combined = [errObj.stdout, errObj.stderr, errObj.message].filter(Boolean).join(" ");
|
|
466
|
+
if (combined.includes("nothing to commit") || combined.includes("nothing added to commit") || combined.includes("no changes added")) {
|
|
467
|
+
nothingToCommit = true;
|
|
468
|
+
} else {
|
|
469
|
+
throw err;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// 9. Auto-push if enabled
|
|
474
|
+
let pushed = false;
|
|
475
|
+
if (prefs.auto_push === true && !nothingToCommit) {
|
|
476
|
+
const remote = prefs.remote ?? "origin";
|
|
477
|
+
try {
|
|
478
|
+
execSync(`git push ${remote} ${mainBranch}`, {
|
|
479
|
+
cwd: originalBasePath_,
|
|
480
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
481
|
+
encoding: "utf-8",
|
|
482
|
+
});
|
|
483
|
+
pushed = true;
|
|
484
|
+
} catch {
|
|
485
|
+
// Push failure is non-fatal
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// 10. Remove worktree directory first (must happen before branch deletion)
|
|
490
|
+
try {
|
|
491
|
+
removeWorktree(originalBasePath_, milestoneId, { branch: null as unknown as string, deleteBranch: false });
|
|
492
|
+
} catch {
|
|
493
|
+
// Best-effort -- worktree dir may already be gone
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// 11. Delete milestone branch (after worktree removal so ref is unlocked)
|
|
497
|
+
try {
|
|
498
|
+
execSync(`git branch -D ${milestoneBranch}`, {
|
|
499
|
+
cwd: originalBasePath_,
|
|
500
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
501
|
+
encoding: "utf-8",
|
|
502
|
+
});
|
|
503
|
+
} catch {
|
|
504
|
+
// Best-effort
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// 12. Clear module state
|
|
508
|
+
originalBase = null;
|
|
509
|
+
nudgeGitBranchCache(previousCwd);
|
|
510
|
+
|
|
511
|
+
return { commitMessage, pushed };
|
|
512
|
+
}
|