* test: red — bounded git subprocess + structured worktree warnings (#3281) Regression tests for #3281: worktree-related git subprocess calls have no timeout bound, and timeout/error outcomes are not surfaced as structured signals. Failing assertions: - planWorktreePrune / listLinkedWorktreePaths / snapshotWorktreeInventory must return reason=git_timed_out (not generic git_list_failed) when execGit returns timedOut:true — enables callers to distinguish timeout from auth failure - executeWorktreePrunePlan must include timedOut:true in result when the git prune call itself times out Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(worktree): bounded git subprocess + structured warning surfacing (#3281) Root cause (PRED.k014): execGit / execGitDefault called spawnSync with no timeout, so `git worktree list --porcelain` against a hung/locked repo blocked the parent process indefinitely. Downstream callers in core.cjs and verify.cjs then swallowed any resulting failure silently via catch { /* intentionally empty */ } (PRED.k302). Fix: - worktree-safety.cjs: execGitDefault now passes timeout:10000 to spawnSync. Detects SIGTERM+ETIMEDOUT and returns { timedOut:true } in the result shape. readWorktreeList maps timedOut:true -> reason:'git_timed_out' (distinct from generic git_list_failed) so callers can emit a structured warning. executeWorktreePrunePlan propagates timedOut:true as a first-class result field. - core.cjs: execGit receives the same timeout+timedOut treatment (PRED.k014 uniform-fix discipline). pruneOrphanedWorktrees now emits a [gsd-tools] WARNING to stderr when the git prune call times out instead of silent-catch. - verify.cjs: Check 11 branches on worktreeHealth.ok to surface W018 warning when the worktree list times out, instead of silent-catch on ok:false. Backward-compatible: exitCode/stdout/stderr continue to work for all existing callers; timedOut and error are additive new fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * changeset: pr=3283 for #3281 * fix(verify): rename W020 for worktree-timeout warning to avoid W018 collision W018 is already used for milestone archive drift (Check 12). The new worktree-health-degraded timeout warning was assigned W018, causing warning-code ambiguity in triage. Rename to W020 (next available code). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
339 lines
9.0 KiB
JavaScript
339 lines
9.0 KiB
JavaScript
/**
|
|
* Worktree Safety Policy Module
|
|
*
|
|
* Owns worktree-root resolution and non-destructive prune policy decisions.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const { spawnSync } = require('child_process');
|
|
|
|
// Default timeout for worktree-related git subprocess calls.
|
|
// 10 s is generous enough for normal git operations on large repos while still
|
|
// providing a deterministic failure path when git stalls (locked index, hung
|
|
// remote, stalled NFS mount, etc.). Callers can override via deps.timeout.
|
|
const DEFAULT_GIT_TIMEOUT_MS = 10000;
|
|
|
|
/**
|
|
* Execute a git command with a bounded timeout.
|
|
*
|
|
* Return shape: { exitCode, stdout, stderr, timedOut, error }
|
|
* - exitCode: process exit status (null when killed by signal)
|
|
* - timedOut: true when spawnSync reports SIGTERM + ETIMEDOUT — callers must
|
|
* branch on this to surface a structured warning instead of
|
|
* silently treating the empty output as success (PRED.k302)
|
|
* - error: the Error object from spawnSync when the process could not start
|
|
* or was killed; null otherwise
|
|
*
|
|
* Backward-compatible: existing callers that only read exitCode/stdout/stderr
|
|
* continue to work unchanged.
|
|
*/
|
|
function execGitDefault(cwd, args, options = {}) {
|
|
const timeout = options.timeout ?? DEFAULT_GIT_TIMEOUT_MS;
|
|
const result = spawnSync('git', args, {
|
|
cwd,
|
|
stdio: 'pipe',
|
|
encoding: 'utf-8',
|
|
timeout,
|
|
});
|
|
// spawnSync sets signal='SIGTERM' and error.code='ETIMEDOUT' when the timeout
|
|
// fires and the subprocess is killed.
|
|
const timedOut = result.signal === 'SIGTERM' && result.error?.code === 'ETIMEDOUT';
|
|
return {
|
|
exitCode: result.status ?? 1,
|
|
stdout: (result.stdout ?? '').toString().trim(),
|
|
stderr: (result.stderr ?? '').toString().trim(),
|
|
timedOut,
|
|
error: result.error ?? null,
|
|
};
|
|
}
|
|
|
|
function parseWorktreePorcelain(porcelain) {
|
|
return parseWorktreeEntries(porcelain).filter((entry) => entry.branch).map((entry) => ({
|
|
path: entry.path,
|
|
branch: entry.branch,
|
|
}));
|
|
}
|
|
|
|
function parseWorktreeEntries(porcelain) {
|
|
const entries = [];
|
|
const blocks = String(porcelain || '').split('\n\n').filter(Boolean);
|
|
for (const block of blocks) {
|
|
const lines = block.split('\n');
|
|
const worktreeLine = lines.find((l) => l.startsWith('worktree '));
|
|
if (!worktreeLine) continue;
|
|
const worktreePath = worktreeLine.slice('worktree '.length).trim();
|
|
if (!worktreePath) continue;
|
|
const branchLine = lines.find((l) => l.startsWith('branch refs/heads/'));
|
|
const branch = branchLine ? branchLine.slice('branch refs/heads/'.length).trim() : null;
|
|
entries.push({ path: worktreePath, branch });
|
|
}
|
|
return entries;
|
|
}
|
|
|
|
function parseWorktreeListPaths(porcelain) {
|
|
return parseWorktreeEntries(porcelain).map((entry) => entry.path);
|
|
}
|
|
|
|
function readWorktreeList(repoRoot, deps = {}) {
|
|
const execGit = deps.execGit || execGitDefault;
|
|
const listResult = execGit(repoRoot, ['worktree', 'list', '--porcelain']);
|
|
if (listResult.timedOut) {
|
|
// AC2 / AC4: surface timeout as a distinct reason so callers can emit a
|
|
// structured warning rather than silently treating the failure as a generic
|
|
// list error (PRED.k302 — error-swallowing-empty-sentinel).
|
|
return {
|
|
ok: false,
|
|
reason: 'git_timed_out',
|
|
porcelain: '',
|
|
entries: [],
|
|
};
|
|
}
|
|
if (listResult.exitCode !== 0) {
|
|
return {
|
|
ok: false,
|
|
reason: 'git_list_failed',
|
|
porcelain: '',
|
|
entries: [],
|
|
};
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
reason: 'ok',
|
|
porcelain: listResult.stdout,
|
|
entries: parseWorktreeEntries(listResult.stdout),
|
|
};
|
|
}
|
|
|
|
function resolveWorktreeContext(cwd, deps = {}) {
|
|
const execGit = deps.execGit || execGitDefault;
|
|
const existsSync = deps.existsSync || fs.existsSync;
|
|
|
|
// Local .planning takes precedence over linked-worktree remapping.
|
|
if (existsSync(path.join(cwd, '.planning'))) {
|
|
return {
|
|
effectiveRoot: cwd,
|
|
mode: 'current_directory',
|
|
reason: 'has_local_planning',
|
|
};
|
|
}
|
|
|
|
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 {
|
|
effectiveRoot: cwd,
|
|
mode: 'current_directory',
|
|
reason: 'not_git_repo',
|
|
};
|
|
}
|
|
|
|
const gitDirResolved = path.resolve(cwd, gitDir.stdout);
|
|
const commonDirResolved = path.resolve(cwd, commonDir.stdout);
|
|
if (gitDirResolved !== commonDirResolved) {
|
|
return {
|
|
effectiveRoot: path.dirname(commonDirResolved),
|
|
mode: 'linked_worktree_root',
|
|
reason: 'linked_worktree',
|
|
};
|
|
}
|
|
|
|
return {
|
|
effectiveRoot: cwd,
|
|
mode: 'current_directory',
|
|
reason: 'main_worktree',
|
|
};
|
|
}
|
|
|
|
function planWorktreePrune(repoRoot, options = {}, deps = {}) {
|
|
const parsePorcelain = deps.parseWorktreePorcelain || parseWorktreePorcelain;
|
|
const destructiveModeRequested = Boolean(options.allowDestructive);
|
|
const listed = readWorktreeList(repoRoot, deps);
|
|
if (!listed.ok) {
|
|
return {
|
|
repoRoot,
|
|
action: 'skip',
|
|
reason: listed.reason,
|
|
destructiveModeRequested,
|
|
};
|
|
}
|
|
|
|
let worktrees = [];
|
|
try {
|
|
worktrees = parsePorcelain(listed.porcelain);
|
|
} catch {
|
|
// Keep historical behavior: still run metadata prune when parsing fails.
|
|
worktrees = [];
|
|
}
|
|
|
|
return {
|
|
repoRoot,
|
|
action: 'metadata_prune_only',
|
|
reason: worktrees.length === 0 ? 'no_worktrees' : 'worktrees_present',
|
|
destructiveModeRequested,
|
|
};
|
|
}
|
|
|
|
function executeWorktreePrunePlan(plan, deps = {}) {
|
|
const execGit = deps.execGit || execGitDefault;
|
|
if (!plan || plan.action === 'skip') {
|
|
return {
|
|
ok: false,
|
|
action: plan ? plan.action : 'skip',
|
|
reason: plan ? plan.reason : 'missing_plan',
|
|
pruned: [],
|
|
};
|
|
}
|
|
|
|
if (plan.action !== 'metadata_prune_only') {
|
|
return {
|
|
ok: false,
|
|
action: plan.action,
|
|
reason: 'unsupported_action',
|
|
pruned: [],
|
|
};
|
|
}
|
|
|
|
const result = execGit(plan.repoRoot, ['worktree', 'prune']);
|
|
if (result.timedOut) {
|
|
// AC4: surface timedOut as a first-class field so callers (e.g.
|
|
// pruneOrphanedWorktrees in core.cjs) can log a structured WARNING rather
|
|
// than silently ignoring it (PRED.k302 — error-swallowing-empty-sentinel).
|
|
return {
|
|
ok: false,
|
|
action: plan.action,
|
|
reason: 'git_timed_out',
|
|
timedOut: true,
|
|
pruned: [],
|
|
};
|
|
}
|
|
return {
|
|
ok: result.exitCode === 0,
|
|
action: plan.action,
|
|
reason: plan.reason,
|
|
timedOut: false,
|
|
pruned: [],
|
|
};
|
|
}
|
|
|
|
function listLinkedWorktreePaths(repoRoot, deps = {}) {
|
|
const listed = readWorktreeList(repoRoot, deps);
|
|
if (!listed.ok) {
|
|
return {
|
|
ok: false,
|
|
reason: listed.reason,
|
|
paths: [],
|
|
};
|
|
}
|
|
|
|
const allPaths = listed.entries.map((entry) => entry.path);
|
|
// git worktree list always includes the current/main worktree first.
|
|
return {
|
|
ok: true,
|
|
reason: 'ok',
|
|
paths: allPaths.slice(1),
|
|
};
|
|
}
|
|
|
|
function inspectWorktreeHealth(repoRoot, options = {}, deps = {}) {
|
|
const inventory = snapshotWorktreeInventory(repoRoot, options, deps);
|
|
if (!inventory.ok) {
|
|
return {
|
|
ok: false,
|
|
reason: inventory.reason,
|
|
findings: [],
|
|
};
|
|
}
|
|
|
|
const findings = [];
|
|
for (const entry of inventory.entries) {
|
|
if (!entry.exists) {
|
|
findings.push({
|
|
kind: 'orphan',
|
|
path: entry.path,
|
|
});
|
|
continue;
|
|
}
|
|
if (entry.isStale) {
|
|
findings.push({
|
|
kind: 'stale',
|
|
path: entry.path,
|
|
ageMinutes: entry.ageMinutes,
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
reason: 'ok',
|
|
findings,
|
|
};
|
|
}
|
|
|
|
function snapshotWorktreeInventory(repoRoot, options = {}, deps = {}) {
|
|
const existsSync = deps.existsSync || fs.existsSync;
|
|
const statSync = deps.statSync || fs.statSync;
|
|
const staleAfterMs = options.staleAfterMs ?? (60 * 60 * 1000);
|
|
const nowMs = options.nowMs ?? Date.now();
|
|
const listed = listLinkedWorktreePaths(repoRoot, { execGit: deps.execGit || execGitDefault });
|
|
if (!listed.ok) {
|
|
return {
|
|
ok: false,
|
|
reason: listed.reason,
|
|
entries: [],
|
|
};
|
|
}
|
|
|
|
const entries = [];
|
|
for (const worktreePath of listed.paths) {
|
|
let exists = false;
|
|
let isStale = false;
|
|
let ageMinutes = null;
|
|
|
|
if (!existsSync(worktreePath)) {
|
|
entries.push({
|
|
path: worktreePath,
|
|
exists,
|
|
isStale,
|
|
ageMinutes,
|
|
});
|
|
continue;
|
|
}
|
|
|
|
exists = true;
|
|
try {
|
|
const stat = statSync(worktreePath);
|
|
const ageMs = nowMs - stat.mtimeMs;
|
|
ageMinutes = Math.round(ageMs / 60000);
|
|
if (ageMs > staleAfterMs) {
|
|
isStale = true;
|
|
}
|
|
} catch {
|
|
// Keep historical behavior: stat failures are ignored.
|
|
}
|
|
entries.push({
|
|
path: worktreePath,
|
|
exists,
|
|
isStale,
|
|
ageMinutes,
|
|
});
|
|
}
|
|
|
|
return {
|
|
ok: true,
|
|
reason: 'ok',
|
|
entries,
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
resolveWorktreeContext,
|
|
parseWorktreePorcelain,
|
|
planWorktreePrune,
|
|
executeWorktreePrunePlan,
|
|
listLinkedWorktreePaths,
|
|
inspectWorktreeHealth,
|
|
snapshotWorktreeInventory,
|
|
};
|