diff --git a/src/resources/extensions/gsd/auto-worktree.ts b/src/resources/extensions/gsd/auto-worktree.ts index a0275619a..d2420257f 100644 --- a/src/resources/extensions/gsd/auto-worktree.ts +++ b/src/resources/extensions/gsd/auto-worktree.ts @@ -1453,8 +1453,13 @@ export function mergeMilestoneToMain( originalBasePath_, milestoneId, ); + // Validate prefs.main_branch exists before using it — a stale preference + // (e.g. "master" when repo uses "main") causes merge failure (#3589). + const validatedPrefBranch = prefs.main_branch && nativeBranchExists(originalBasePath_, prefs.main_branch) + ? prefs.main_branch + : undefined; const mainBranch = - integrationBranch ?? prefs.main_branch ?? nativeDetectMainBranch(originalBasePath_); + integrationBranch ?? validatedPrefBranch ?? nativeDetectMainBranch(originalBasePath_); // Remove transient project-root state files before any branch or merge // operation. Untracked milestone metadata can otherwise block squash merges. diff --git a/src/resources/extensions/gsd/commands/context.ts b/src/resources/extensions/gsd/commands/context.ts index 7bbaa5790..f4a5aa423 100644 --- a/src/resources/extensions/gsd/commands/context.ts +++ b/src/resources/extensions/gsd/commands/context.ts @@ -13,7 +13,13 @@ export interface GsdDispatchContext { } export function projectRoot(): string { - const cwd = process.cwd(); + let cwd: string; + try { + cwd = process.cwd(); + } catch { + // cwd directory was deleted (e.g. worktree teardown) — fall back to HOME (#3598) + cwd = process.env.HOME ?? "/"; + } const root = resolveProjectRoot(cwd); if (root !== cwd) { assertSafeDirectory(cwd); diff --git a/src/resources/extensions/gsd/tests/project-root-cwd-crash.test.ts b/src/resources/extensions/gsd/tests/project-root-cwd-crash.test.ts new file mode 100644 index 000000000..a75d3f13f --- /dev/null +++ b/src/resources/extensions/gsd/tests/project-root-cwd-crash.test.ts @@ -0,0 +1,53 @@ +/** + * Regression test for #3598 — projectRoot ENOENT crash on deleted cwd + * + * When the working directory is deleted (e.g. worktree teardown), process.cwd() + * throws ENOENT. The fix wraps process.cwd() in a try/catch and falls back to + * process.env.HOME. + * + * Also verifies #3589 — nativeBranchExists validation for prefs.main_branch + * in auto-worktree.ts to prevent merge failures with stale preferences. + * + * Structural verification test — reads source to confirm the guards exist. + */ + +import { describe, test } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +const contextSource = readFileSync(join(__dirname, '..', 'commands', 'context.ts'), 'utf-8'); +const worktreeSource = readFileSync(join(__dirname, '..', 'auto-worktree.ts'), 'utf-8'); + +describe('projectRoot cwd crash guard (#3598)', () => { + test('projectRoot wraps process.cwd() in try/catch', () => { + assert.match(contextSource, /try\s*\{[\s\S]*?process\.cwd\(\)/, + 'process.cwd() should be inside a try block'); + }); + + test('catch block falls back to process.env.HOME', () => { + assert.match(contextSource, /catch[\s\S]*?process\.env\.HOME/, + 'catch block should fall back to process.env.HOME'); + }); + + test('projectRoot function is exported', () => { + assert.match(contextSource, /export function projectRoot\(\)/, + 'projectRoot should be an exported function'); + }); +}); + +describe('main_branch nativeBranchExists validation (#3589)', () => { + test('prefs.main_branch is validated with nativeBranchExists', () => { + assert.match(worktreeSource, /nativeBranchExists\(.*prefs\.main_branch\)/, + 'nativeBranchExists should validate prefs.main_branch'); + }); + + test('validatedPrefBranch falls back to undefined when branch missing', () => { + assert.match(worktreeSource, /validatedPrefBranch[\s\S]*?:\s*undefined/, + 'validatedPrefBranch should fall back to undefined'); + }); +});