From 5eb3c04bce6eb06c12fe71816d2dcb1a1d0e4387 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 22 Mar 2026 10:07:42 -0400 Subject: [PATCH] fix: respect .planning/ in linked worktrees before resolving to main repo resolveWorktreeRoot() unconditionally resolved linked worktrees to the main repo root. When a linked worktree has its own independent .planning/ directory (e.g., Conductor workspaces), all GSD commands read/wrote the wrong planning state. Add an early return that checks for a local .planning/ before falling through to main repo resolution. The caller in gsd-tools.cjs already had this guard (added in #1283), but the function itself should be correct regardless of call site. This is defense-in- depth for any future callers. Co-Authored-By: Claude Opus 4.6 (1M context) --- get-shit-done/bin/lib/core.cjs | 6 +++ tests/core.test.cjs | 73 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 179d21166..9785cfc74 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -451,6 +451,12 @@ function execGit(cwd, args) { * Returns the main worktree path, or cwd if not in a worktree. */ function resolveWorktreeRoot(cwd) { + // If the current directory already has its own .planning/, respect it. + // This handles linked worktrees with independent planning state (e.g., Conductor workspaces). + if (fs.existsSync(path.join(cwd, '.planning'))) { + return cwd; + } + // Check if we're in a linked worktree const gitDir = execGit(cwd, ['rev-parse', '--git-dir']); const commonDir = execGit(cwd, ['rev-parse', '--git-common-dir']); diff --git a/tests/core.test.cjs b/tests/core.test.cjs index b6daaf8fb..a94e67e7d 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -1039,6 +1039,79 @@ describe('resolveWorktreeRoot', () => { }); }); +// ─── resolveWorktreeRoot — linked worktree with .planning/ (#1315) ─────────── + +describe('resolveWorktreeRoot with linked worktree .planning/', () => { + const { resolveWorktreeRoot } = require('../get-shit-done/bin/lib/core.cjs'); + const { execSync } = require('child_process'); + + test('returns linked worktree cwd when it has its own .planning/', () => { + const mainDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wt-main-'))); + let worktreeDir; + try { + // Set up main repo with a commit + execSync('git init', { cwd: mainDir, stdio: 'pipe' }); + execSync('git config user.email "test@test.com"', { cwd: mainDir, stdio: 'pipe' }); + execSync('git config user.name "Test"', { cwd: mainDir, stdio: 'pipe' }); + execSync('git config commit.gpgsign false', { cwd: mainDir, stdio: 'pipe' }); + fs.mkdirSync(path.join(mainDir, '.planning'), { recursive: true }); + fs.writeFileSync(path.join(mainDir, 'README.md'), '# Main'); + execSync('git add -A', { cwd: mainDir, stdio: 'pipe' }); + execSync('git commit -m "initial"', { cwd: mainDir, stdio: 'pipe' }); + + // Create a linked worktree + worktreeDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wt-linked-'))); + fs.rmSync(worktreeDir, { recursive: true, force: true }); + execSync(`git worktree add "${worktreeDir}" -b test-linked`, { cwd: mainDir, stdio: 'pipe' }); + + // Give the linked worktree its own .planning/ + fs.mkdirSync(path.join(worktreeDir, '.planning'), { recursive: true }); + + // resolveWorktreeRoot should return the linked worktree dir, not the main repo + const result = resolveWorktreeRoot(worktreeDir); + assert.strictEqual(result, worktreeDir, + 'linked worktree with .planning/ should resolve to itself, not the main repo'); + } finally { + if (worktreeDir) { + try { execSync(`git worktree remove "${worktreeDir}" --force`, { cwd: mainDir, stdio: 'pipe' }); } catch { /* ok */ } + try { fs.rmSync(worktreeDir, { recursive: true, force: true }); } catch { /* ok */ } + } + fs.rmSync(mainDir, { recursive: true, force: true }); + } + }); + + test('returns main repo root when linked worktree has no .planning/', () => { + const mainDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wt-main-'))); + let worktreeDir; + try { + // Set up main repo with a commit + execSync('git init', { cwd: mainDir, stdio: 'pipe' }); + execSync('git config user.email "test@test.com"', { cwd: mainDir, stdio: 'pipe' }); + execSync('git config user.name "Test"', { cwd: mainDir, stdio: 'pipe' }); + execSync('git config commit.gpgsign false', { cwd: mainDir, stdio: 'pipe' }); + fs.writeFileSync(path.join(mainDir, 'README.md'), '# Main'); + execSync('git add -A', { cwd: mainDir, stdio: 'pipe' }); + execSync('git commit -m "initial"', { cwd: mainDir, stdio: 'pipe' }); + + // Create a linked worktree (no .planning/) + worktreeDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wt-linked-'))); + fs.rmSync(worktreeDir, { recursive: true, force: true }); + execSync(`git worktree add "${worktreeDir}" -b test-linked-no-plan`, { cwd: mainDir, stdio: 'pipe' }); + + // resolveWorktreeRoot should return the main repo root + const result = resolveWorktreeRoot(worktreeDir); + assert.strictEqual(result, mainDir, + 'linked worktree without .planning/ should resolve to main repo root'); + } finally { + if (worktreeDir) { + try { execSync(`git worktree remove "${worktreeDir}" --force`, { cwd: mainDir, stdio: 'pipe' }); } catch { /* ok */ } + try { fs.rmSync(worktreeDir, { recursive: true, force: true }); } catch { /* ok */ } + } + fs.rmSync(mainDir, { recursive: true, force: true }); + } + }); +}); + // ─── monorepo worktree CWD preservation (#1283) ───────────────────────────── describe('monorepo worktree CWD preservation', () => {