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) <noreply@anthropic.com>
This commit is contained in:
@@ -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']);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user