Merge pull request #1225 from trek-e/feat/worktree-support-1215

feat(core): worktree-aware .planning/ resolution and file locking
This commit is contained in:
Tom Boucher
2026-03-19 13:46:31 -04:00
committed by GitHub
3 changed files with 165 additions and 0 deletions

View File

@@ -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);

View File

@@ -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/<name>
// 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,

View File

@@ -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 });
}
});
});