diff --git a/get-shit-done/bin/gsd-tools.cjs b/get-shit-done/bin/gsd-tools.cjs index b05a98995..7842fb6d8 100755 --- a/get-shit-done/bin/gsd-tools.cjs +++ b/get-shit-done/bin/gsd-tools.cjs @@ -173,6 +173,14 @@ async function main() { error(`Invalid --cwd: ${cwd}`); } + // Resolve worktree root: in a linked worktree, .planning/ lives in the main worktree + const { resolveWorktreeRoot } = require('./lib/core.cjs'); + const worktreeRoot = resolveWorktreeRoot(cwd); + if (worktreeRoot !== cwd) { + // Only override cwd for planning-related commands — keep original cwd for git operations + cwd = worktreeRoot; + } + const rawIndex = args.indexOf('--raw'); const raw = rawIndex !== -1; if (rawIndex !== -1) args.splice(rawIndex, 1); diff --git a/get-shit-done/bin/lib/core.cjs b/get-shit-done/bin/lib/core.cjs index 807f883d7..43b81bc20 100644 --- a/get-shit-done/bin/lib/core.cjs +++ b/get-shit-done/bin/lib/core.cjs @@ -260,6 +260,84 @@ function execGit(cwd, args) { // ─── Common path helpers ────────────────────────────────────────────────────── +/** + * Resolve the main worktree root when running inside a git worktree. + * In a linked worktree, .planning/ lives in the main worktree, not in the linked one. + * Returns the main worktree path, or cwd if not in a worktree. + */ +function resolveWorktreeRoot(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']); + + if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) return cwd; + + // In a linked worktree, .git is a file pointing to .git/worktrees/ + // and git-common-dir points to the main repo's .git directory + const gitDirResolved = path.resolve(cwd, gitDir.stdout); + const commonDirResolved = path.resolve(cwd, commonDir.stdout); + + if (gitDirResolved !== commonDirResolved) { + // We're in a linked worktree — resolve main worktree root + // The common dir is the main repo's .git, so its parent is the main worktree root + return path.dirname(commonDirResolved); + } + + return cwd; +} + +/** + * Acquire a file-based lock for .planning/ writes. + * Prevents concurrent worktrees from corrupting shared planning files. + * Lock is auto-released after the callback completes. + */ +function withPlanningLock(cwd, fn) { + const lockPath = path.join(planningDir(cwd), '.lock'); + const lockTimeout = 10000; // 10 seconds + const retryDelay = 100; + const start = Date.now(); + + // Ensure .planning/ exists + try { fs.mkdirSync(planningDir(cwd), { recursive: true }); } catch { /* ok */ } + + while (Date.now() - start < lockTimeout) { + try { + // Atomic create — fails if file exists + fs.writeFileSync(lockPath, JSON.stringify({ + pid: process.pid, + cwd, + acquired: new Date().toISOString(), + }), { flag: 'wx' }); + + // Lock acquired — run the function + try { + return fn(); + } finally { + try { fs.unlinkSync(lockPath); } catch { /* already released */ } + } + } catch (err) { + if (err.code === 'EEXIST') { + // Lock exists — check if stale (>30s old) + try { + const stat = fs.statSync(lockPath); + if (Date.now() - stat.mtimeMs > 30000) { + fs.unlinkSync(lockPath); + continue; // retry + } + } catch { continue; } + + // Wait and retry + spawnSync('sleep', ['0.1'], { stdio: 'ignore' }); + continue; + } + throw err; + } + } + // Timeout — force acquire (stale lock recovery) + try { fs.unlinkSync(lockPath); } catch { /* ok */ } + return fn(); +} + /** Get the .planning directory path */ function planningDir(cwd) { return path.join(cwd, '.planning'); @@ -778,6 +856,8 @@ module.exports = { replaceInCurrentMilestone, toPosixPath, extractOneLinerFromBody, + resolveWorktreeRoot, + withPlanningLock, MODEL_ALIAS_MAP, planningDir, planningPaths, diff --git a/tests/core.test.cjs b/tests/core.test.cjs index 3a3861ea1..3ed18ef01 100644 --- a/tests/core.test.cjs +++ b/tests/core.test.cjs @@ -898,3 +898,80 @@ describe('stale hook filter', () => { assert.ok(!filtered.includes('my-custom-hook.js'), 'must not include non-gsd hooks'); }); }); + +// ─── resolveWorktreeRoot ───────────────────────────────────────────────────── + +describe('resolveWorktreeRoot', () => { + const { resolveWorktreeRoot } = require('../get-shit-done/bin/lib/core.cjs'); + + test('returns cwd when not in a git repo', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wt-test-')); + try { + assert.strictEqual(resolveWorktreeRoot(tmpDir), tmpDir); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + test('returns cwd in a normal git repo (not a worktree)', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wt-test-')); + try { + const { execSync } = require('child_process'); + execSync('git init', { cwd: tmpDir, stdio: 'pipe' }); + assert.strictEqual(resolveWorktreeRoot(tmpDir), tmpDir); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +}); + +// ─── withPlanningLock ──────────────────────────────────────────────────────── + +describe('withPlanningLock', () => { + const { withPlanningLock, planningDir } = require('../get-shit-done/bin/lib/core.cjs'); + + test('executes function and returns result', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lock-test-')); + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + try { + const result = withPlanningLock(tmpDir, () => 42); + assert.strictEqual(result, 42); + // Lock file should be cleaned up + assert.ok(!fs.existsSync(path.join(planningDir(tmpDir), '.lock'))); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + test('cleans up lock file even on error', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lock-test-')); + fs.mkdirSync(path.join(tmpDir, '.planning'), { recursive: true }); + try { + assert.throws(() => { + withPlanningLock(tmpDir, () => { throw new Error('test'); }); + }, /test/); + assert.ok(!fs.existsSync(path.join(planningDir(tmpDir), '.lock'))); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + test('recovers from stale lock (>30s old)', () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-lock-test-')); + const planDir = path.join(tmpDir, '.planning'); + fs.mkdirSync(planDir, { recursive: true }); + const lockPath = path.join(planDir, '.lock'); + try { + // Create a stale lock + fs.writeFileSync(lockPath, '{"pid":99999}'); + // Backdate the lock file by 31 seconds + const staleTime = new Date(Date.now() - 31000); + fs.utimesSync(lockPath, staleTime, staleTime); + + const result = withPlanningLock(tmpDir, () => 'recovered'); + assert.strictEqual(result, 'recovered'); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); +});