* test(#4081): failing-first regression for quotepath C-quoted paths in codebase-drift * fix(#4081): decode git C-quoted paths in codebase-drift --name-status parse * test(#4081): set drift_threshold 1 so decoded-path test triggers action_required * chore(#4081): add changeset fragment * chore(#4081): fix changeset fragment formatting * chore(#4081): backfill PR number in changeset --------- Co-authored-by: sim <sim@local>
2339 lines
97 KiB
TypeScript
2339 lines
97 KiB
TypeScript
/**
|
||
* Worktree Safety Policy Module
|
||
*
|
||
* Owns worktree-root resolution and non-destructive prune policy decisions.
|
||
*
|
||
* ADR-457 build-at-publish: the hand-written bin/lib/worktree-safety.cjs
|
||
* collapsed to a TypeScript source of truth. Behaviour is preserved
|
||
* byte-for-behaviour from the prior hand-written .cjs; only types are added.
|
||
*/
|
||
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { execGit as execGitSeam, posixNormalize, type SpawnResultOutput } from './shell-command-projection.cjs';
|
||
|
||
// 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;
|
||
|
||
// #3021: accept the Workflow tool's worktree-wf_<runid>-<n> naming convention
|
||
// (claude-orchestration's isolation:"worktree" emission) alongside the
|
||
// existing agent-<id> / worktree-agent-<id> shapes.
|
||
const WORKTREE_AGENT_BRANCH_RE = /^((worktree-)?agent-|worktree-wf_)[A-Za-z0-9._/-]+$/;
|
||
const WORKTREE_AGENT_BRANCH_PATTERN = WORKTREE_AGENT_BRANCH_RE.source;
|
||
|
||
// GitResult now aliases the canonical SpawnResultOutput (shell-command-projection.cts),
|
||
// which already carries `timedOut` — kept as a local name since it is referenced
|
||
// below (gitResultOk).
|
||
type GitResult = SpawnResultOutput;
|
||
|
||
type ExecGitFn = typeof execGitSeam;
|
||
|
||
/**
|
||
* Execute a git command via the shell-projection seam, applying the module's
|
||
* default timeout. `timedOut` is now derived by the seam itself
|
||
* (shell-command-projection.cts's `_spawnResult`), so this is a thin
|
||
* passthrough. Tests inject mocks via deps.execGit using the same
|
||
* (args, opts) shape — see worktree-safety-policy.test.cjs.
|
||
*/
|
||
function execGitDefault(args: string[], opts: { cwd?: string; env?: Record<string, string>; timeout?: number } = {}): GitResult {
|
||
return execGitSeam(args, { ...opts, timeout: opts.timeout ?? DEFAULT_GIT_TIMEOUT_MS });
|
||
}
|
||
|
||
interface WorktreeBranchEntry {
|
||
path: string;
|
||
branch: string;
|
||
}
|
||
|
||
interface WorktreeEntry {
|
||
path: string;
|
||
branch: string | null;
|
||
}
|
||
|
||
function parseWorktreePorcelain(porcelain: string): WorktreeBranchEntry[] {
|
||
return parseWorktreeEntries(porcelain).filter((entry) => entry.branch !== null).map((entry) => ({
|
||
path: entry.path,
|
||
branch: entry.branch!,
|
||
}));
|
||
}
|
||
|
||
function parseWorktreeEntries(porcelain: string): WorktreeEntry[] {
|
||
const entries: WorktreeEntry[] = [];
|
||
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: string): string[] {
|
||
return parseWorktreeEntries(porcelain).map((entry) => entry.path);
|
||
}
|
||
|
||
interface WorktreeListResult {
|
||
ok: boolean;
|
||
reason: string;
|
||
porcelain: string;
|
||
entries: WorktreeEntry[];
|
||
}
|
||
|
||
interface WorktreeDeps {
|
||
execGit?: ExecGitFn;
|
||
existsSync?: (p: string) => boolean;
|
||
statSync?: (p: string) => fs.Stats;
|
||
findSummaryFiles?: (worktreePath: string) => string[];
|
||
readFileSync?: (p: string) => string;
|
||
mkdirSync?: (d: string, o?: { recursive?: boolean }) => void;
|
||
copyFileSync?: (src: string, dest: string) => void;
|
||
isPidAlive?: (pid: number) => boolean;
|
||
readDirSafe?: (dir: string) => string[] | null;
|
||
readFileSafe?: (file: string) => string | null;
|
||
mtimeSafe?: (file: string) => Date | null;
|
||
reapMtimeGuardMs?: number;
|
||
/** Injected current time in ms since epoch for deterministic tests (#1191). */
|
||
nowMs?: number;
|
||
parseWorktreePorcelain?: (porcelain: string) => WorktreeBranchEntry[];
|
||
}
|
||
|
||
function readWorktreeList(repoRoot: string, deps: WorktreeDeps = {}): WorktreeListResult {
|
||
const execGit = deps.execGit || execGitDefault;
|
||
const listResult = execGit(['worktree', 'list', '--porcelain'], { cwd: repoRoot });
|
||
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) {
|
||
const stderr = String(listResult.stderr || '');
|
||
return {
|
||
ok: false,
|
||
reason: /not a git repository|not a git repo/i.test(stderr)
|
||
? 'not_a_git_repo'
|
||
: 'git_list_failed',
|
||
porcelain: '',
|
||
entries: [],
|
||
};
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
reason: 'ok',
|
||
porcelain: listResult.stdout,
|
||
entries: parseWorktreeEntries(listResult.stdout),
|
||
};
|
||
}
|
||
|
||
interface WorktreeContextResult {
|
||
effectiveRoot: string;
|
||
mode: string;
|
||
reason: string;
|
||
}
|
||
|
||
/**
|
||
* Shortcut-free git-dir-vs-git-common-dir comparison: the actual primitive
|
||
* that distinguishes a linked worktree from the main worktree.
|
||
*
|
||
* Deliberately factored out of `resolveWorktreeContext` (#3045). That
|
||
* function's `has_local_planning` shortcut answers a DIFFERENT question ("is
|
||
* there already a usable project root right here") and must NOT be consulted
|
||
* for isolation detection: a git worktree created specifically to isolate an
|
||
* executor is a full checkout, so it normally has its OWN checked-out
|
||
* `.planning/` too. A caller that ran the shortcut first would read that
|
||
* correctly-isolated worktree as `current_directory`/`has_local_planning` —
|
||
* i.e. "not isolated" — a false positive that defeats the very isolation
|
||
* guard that needs this check (see `hooks/gsd-cursor-subagent-start.js`,
|
||
* #3045). `resolveWorktreeLinkage` always performs the real git-dir
|
||
* comparison, independent of whether `.planning` exists locally.
|
||
*/
|
||
function resolveWorktreeLinkage(cwd: string, deps: WorktreeDeps = {}): WorktreeContextResult {
|
||
const execGit = deps.execGit || execGitDefault;
|
||
|
||
const gitDir = execGit(['rev-parse', '--git-dir'], { cwd });
|
||
const commonDir = execGit(['rev-parse', '--git-common-dir'], { cwd });
|
||
// A TIMEOUT means the command never completed — it is not evidence of "not a
|
||
// git repository" (which completes fast, with a clean non-zero exit). Surface
|
||
// it under a distinct reason so callers can tell "genuinely not a repo" apart
|
||
// from "could not determine" (#3050). effectiveRoot still degrades to cwd
|
||
// (there is no safer default without a resolved git-dir), but the reason is
|
||
// no longer indistinguishable from the benign case.
|
||
if (gitDir.timedOut || commonDir.timedOut) {
|
||
return {
|
||
effectiveRoot: cwd,
|
||
mode: 'current_directory',
|
||
reason: 'git_timed_out',
|
||
};
|
||
}
|
||
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 resolveWorktreeContext(cwd: string, deps: WorktreeDeps = {}): WorktreeContextResult {
|
||
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',
|
||
};
|
||
}
|
||
|
||
return resolveWorktreeLinkage(cwd, deps);
|
||
}
|
||
|
||
interface WorktreePrunePlan {
|
||
repoRoot: string;
|
||
action: string;
|
||
reason: string;
|
||
destructiveModeRequested: boolean;
|
||
}
|
||
|
||
function planWorktreePrune(repoRoot: string, options: { allowDestructive?: boolean } = {}, deps: WorktreeDeps = {}): WorktreePrunePlan {
|
||
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: WorktreeBranchEntry[] = [];
|
||
let parseFailed = false;
|
||
try {
|
||
worktrees = parsePorcelain(listed.porcelain);
|
||
} catch {
|
||
// Keep historical behavior: still run metadata prune when parsing fails.
|
||
// #3050/#3057 (B6): but the reason must NOT collide with the
|
||
// genuinely-empty-list case below — a parser that could not read the
|
||
// porcelain output is not the same fact as "there are no worktrees", and
|
||
// this plan drives a PRUNE, so conflating them means a prune decision made
|
||
// on unread data would be indistinguishable from one made on real data.
|
||
worktrees = [];
|
||
parseFailed = true;
|
||
}
|
||
|
||
return {
|
||
repoRoot,
|
||
action: 'metadata_prune_only',
|
||
reason: parseFailed
|
||
? 'parse_failed'
|
||
: (worktrees.length === 0 ? 'no_worktrees' : 'worktrees_present'),
|
||
destructiveModeRequested,
|
||
};
|
||
}
|
||
|
||
interface PruneExecuteResult {
|
||
ok: boolean;
|
||
action: string;
|
||
reason: string;
|
||
timedOut?: boolean;
|
||
pruned: unknown[];
|
||
}
|
||
|
||
function executeWorktreePrunePlan(plan: WorktreePrunePlan | null, deps: WorktreeDeps = {}): PruneExecuteResult {
|
||
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(['worktree', 'prune'], { cwd: plan.repoRoot });
|
||
if (result.timedOut) {
|
||
// AC4: surface timedOut as a first-class field so callers 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: [],
|
||
};
|
||
}
|
||
|
||
interface LinkedWorktreePathsResult {
|
||
ok: boolean;
|
||
reason: string;
|
||
paths: string[];
|
||
}
|
||
|
||
function listLinkedWorktreePaths(repoRoot: string, deps: WorktreeDeps = {}): LinkedWorktreePathsResult {
|
||
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),
|
||
};
|
||
}
|
||
|
||
interface WorktreeFinding {
|
||
kind: 'orphan' | 'stale' | 'unverified';
|
||
path: string;
|
||
ageMinutes?: number;
|
||
}
|
||
|
||
interface HealthResult {
|
||
ok: boolean;
|
||
reason: string;
|
||
findings: WorktreeFinding[];
|
||
}
|
||
|
||
function inspectWorktreeHealth(repoRoot: string, options: { staleAfterMs?: number; nowMs?: number } = {}, deps: WorktreeDeps = {}): HealthResult {
|
||
const inventory = snapshotWorktreeInventory(repoRoot, options, deps);
|
||
if (!inventory.ok) {
|
||
return {
|
||
ok: false,
|
||
reason: inventory.reason,
|
||
findings: [],
|
||
};
|
||
}
|
||
|
||
const findings: WorktreeFinding[] = [];
|
||
for (const entry of inventory.entries) {
|
||
if (entry.exists === 'absent') {
|
||
findings.push({
|
||
kind: 'orphan',
|
||
path: entry.path,
|
||
});
|
||
continue;
|
||
}
|
||
if (entry.exists === 'unverified') {
|
||
// #3050/#3057 (B5): existsSync confirmed the path is present but statSync
|
||
// threw, so age/staleness could not be determined. This is neither
|
||
// "orphan" (existsSync says it IS there) nor "healthy" (we never verified
|
||
// it) — surface it as its own finding so a caller can't silently treat an
|
||
// unverifiable worktree as confirmed present-and-not-stale.
|
||
findings.push({
|
||
kind: 'unverified',
|
||
path: entry.path,
|
||
});
|
||
continue;
|
||
}
|
||
if (entry.isStale) {
|
||
findings.push({
|
||
kind: 'stale',
|
||
path: entry.path,
|
||
ageMinutes: entry.ageMinutes ?? undefined,
|
||
});
|
||
}
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
reason: 'ok',
|
||
findings,
|
||
};
|
||
}
|
||
|
||
interface InventoryEntry {
|
||
path: string;
|
||
/**
|
||
* Tri-state (#3050/#3057, B5): `'present'` = existsSync AND statSync both
|
||
* succeeded (confirmed present, age known); `'absent'` = existsSync confirmed
|
||
* the path is genuinely absent; `'unverified'` = existsSync confirmed presence
|
||
* but statSync threw — presence could not be fully verified. `'unverified'`
|
||
* MUST NOT be treated as `'present'`: a caller that could not check must not
|
||
* report the worktree as confirmed present.
|
||
*/
|
||
exists: 'present' | 'absent' | 'unverified';
|
||
isStale: boolean;
|
||
ageMinutes: number | null;
|
||
}
|
||
|
||
interface InventoryResult {
|
||
ok: boolean;
|
||
reason: string;
|
||
entries: InventoryEntry[];
|
||
}
|
||
|
||
function snapshotWorktreeInventory(repoRoot: string, options: { staleAfterMs?: number; nowMs?: number } = {}, deps: WorktreeDeps = {}): InventoryResult {
|
||
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: InventoryEntry[] = [];
|
||
for (const worktreePath of listed.paths) {
|
||
let exists: 'present' | 'absent' | 'unverified' = 'absent';
|
||
let isStale = false;
|
||
let ageMinutes: number | null = null;
|
||
|
||
if (!existsSync(worktreePath)) {
|
||
entries.push({
|
||
path: worktreePath,
|
||
exists,
|
||
isStale,
|
||
ageMinutes,
|
||
});
|
||
continue;
|
||
}
|
||
|
||
try {
|
||
const stat = statSync(worktreePath);
|
||
exists = 'present';
|
||
const ageMs = nowMs - stat.mtimeMs;
|
||
ageMinutes = Math.round(ageMs / 60000);
|
||
if (ageMs > staleAfterMs) {
|
||
isStale = true;
|
||
}
|
||
} catch {
|
||
// #3050/#3057 (B5): a statSync throw means presence could not be
|
||
// verified — do NOT report exists:'present' (a guard that could not
|
||
// check must not claim the worktree is confirmed present). Distinguish
|
||
// from the genuinely-absent case above with a third state ('unverified')
|
||
// rather than silently falling through to the pre-existing 'present' default.
|
||
exists = 'unverified';
|
||
}
|
||
entries.push({
|
||
path: worktreePath,
|
||
exists,
|
||
isStale,
|
||
ageMinutes,
|
||
});
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
reason: 'ok',
|
||
entries,
|
||
};
|
||
}
|
||
|
||
interface CleanupManifestEntry {
|
||
agent_id: string | null;
|
||
worktree_path: string;
|
||
branch: string;
|
||
expected_base: string;
|
||
allowed_bases?: string[];
|
||
/** #2596: the plan's declared `files_modified`, when the recorder supplied it. Absent = unknown, never "declares nothing". */
|
||
files_modified?: string[];
|
||
/**
|
||
* #3003: paths the PLAN declared it would delete. The deletions guard blocks only
|
||
* deletions NOT in this list. Absent = declares nothing, which keeps the guard's
|
||
* original unconditional block — never "authorizes everything".
|
||
*
|
||
* A path LIST, not a boolean, deliberately: a boolean would disarm the guard for the
|
||
* whole entry, so an unexpected deletion riding along with a declared one would pass
|
||
* unnoticed. Matching is EXACT after `normalizeScopePath`, never a prefix and never a
|
||
* glob — a directory prefix authorizes a mass deletion, which is the precise accident
|
||
* this guard exists to catch.
|
||
*/
|
||
declared_deletions?: string[];
|
||
}
|
||
|
||
function normalizeCleanupManifestEntry(entry: unknown): CleanupManifestEntry | null {
|
||
if (!entry || typeof entry !== 'object') return null;
|
||
const e = entry as Record<string, unknown>;
|
||
const worktreePath = typeof e.worktree_path === 'string'
|
||
? e.worktree_path
|
||
: (typeof e.path === 'string' ? e.path : '');
|
||
const branch = typeof e.branch === 'string' ? e.branch : '';
|
||
const expectedBase = typeof e.expected_base === 'string' ? e.expected_base : '';
|
||
if (!worktreePath || !branch || !expectedBase) return null;
|
||
if (!WORKTREE_AGENT_BRANCH_RE.test(branch)) return null;
|
||
const rawAllowedBases = Array.isArray(e.allowed_bases) ? e.allowed_bases : [];
|
||
const allowedBases = Array.from(new Set(
|
||
[expectedBase, ...rawAllowedBases.filter((base): base is string => typeof base === 'string' && base.length > 0)]
|
||
));
|
||
// #2596: liberal in what we accept — non-array, or non-string / empty
|
||
// elements, are dropped rather than coerced. An EMPTY result omits the field
|
||
// entirely, so "declared nothing" is indistinguishable from "not recorded":
|
||
// that ambiguity is already resolved as *unknown* by the per-plan submodule
|
||
// gate's own `[ -z "$PLAN_FILES" ]` rule, and inventing a second rule here
|
||
// would make an unrecorded plan look 100% out of scope.
|
||
const filesModified = (Array.isArray(e.files_modified) ? e.files_modified : [])
|
||
.filter((f): f is string => typeof f === 'string' && f.trim().length > 0);
|
||
// #3003: same liberal-in-what-we-accept rule as `files_modified` above — non-array,
|
||
// or non-string / empty elements, are dropped rather than coerced, and an EMPTY
|
||
// result omits the field entirely so "declares nothing" stays indistinguishable
|
||
// from "not recorded" (the guard's own absence-check already treats both as unknown).
|
||
const declaredDeletions = (Array.isArray(e.declared_deletions) ? e.declared_deletions : [])
|
||
.filter((f): f is string => typeof f === 'string' && f.trim().length > 0);
|
||
const normalized: CleanupManifestEntry = {
|
||
agent_id: typeof e.agent_id === 'string' ? e.agent_id : null,
|
||
worktree_path: worktreePath,
|
||
branch,
|
||
expected_base: expectedBase,
|
||
allowed_bases: allowedBases,
|
||
};
|
||
if (filesModified.length > 0) normalized.files_modified = filesModified;
|
||
if (declaredDeletions.length > 0) normalized.declared_deletions = declaredDeletions;
|
||
return normalized;
|
||
}
|
||
|
||
interface NormalizedManifestResult {
|
||
ok: boolean;
|
||
reason: string;
|
||
entries: CleanupManifestEntry[];
|
||
}
|
||
|
||
function normalizeCleanupManifest(manifest: unknown): NormalizedManifestResult {
|
||
let parsed: unknown = manifest;
|
||
if (typeof manifest === 'string') {
|
||
try {
|
||
parsed = JSON.parse(manifest);
|
||
} catch {
|
||
return { ok: false, reason: 'invalid_manifest_json', entries: [] };
|
||
}
|
||
}
|
||
|
||
const p = parsed as Record<string, unknown> | unknown[] | null;
|
||
const rawEntries = Array.isArray(p)
|
||
? p
|
||
: (Array.isArray((p as Record<string, unknown>)?.worktrees) ? (p as Record<string, unknown>).worktrees as unknown[] : []);
|
||
const seen = new Set<string>();
|
||
const entries: CleanupManifestEntry[] = [];
|
||
for (const raw of rawEntries) {
|
||
const entry = normalizeCleanupManifestEntry(raw);
|
||
if (!entry) continue;
|
||
const key = `${entry.worktree_path}\0${entry.branch}`;
|
||
if (seen.has(key)) continue;
|
||
seen.add(key);
|
||
entries.push(entry);
|
||
}
|
||
|
||
if (entries.length === 0) {
|
||
return { ok: false, reason: 'empty_manifest', entries: [] };
|
||
}
|
||
|
||
return { ok: true, reason: 'ok', entries };
|
||
}
|
||
|
||
interface WaveCleanupPlan {
|
||
ok: boolean;
|
||
repoRoot: string;
|
||
action: string;
|
||
discovery: string;
|
||
reason: string;
|
||
entries: CleanupManifestEntry[];
|
||
}
|
||
|
||
function planWorktreeWaveCleanup(repoRoot: string, manifest: unknown): WaveCleanupPlan {
|
||
const normalized = normalizeCleanupManifest(manifest);
|
||
if (!normalized.ok) {
|
||
return {
|
||
ok: false,
|
||
repoRoot,
|
||
action: 'skip',
|
||
discovery: 'manifest',
|
||
reason: normalized.reason,
|
||
entries: [],
|
||
};
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
repoRoot,
|
||
action: 'cleanup_wave',
|
||
discovery: 'manifest',
|
||
reason: 'manifest_entries_present',
|
||
entries: normalized.entries,
|
||
};
|
||
}
|
||
|
||
function gitResultOk(result: GitResult | null | undefined): boolean {
|
||
return !!(result && result.exitCode === 0 && !result.timedOut);
|
||
}
|
||
|
||
/**
|
||
* #2852: after a failed `git merge` + a `git merge --abort` attempt, determine
|
||
* whether `repoRoot` is STILL mid-merge — the only condition that genuinely
|
||
* invalidates the rest of a cleanup wave.
|
||
*
|
||
* `git merge --abort`'s own exit code is NOT a reliable signal here: git refuses
|
||
* many merges (e.g. "your local changes to the following files would be
|
||
* overwritten by merge") WITHOUT ever creating a `MERGE_HEAD`, in which case
|
||
* `repoRoot`'s tree was never touched and `git merge --abort` correctly fails
|
||
* with "fatal: There is no merge to abort (MERGE_HEAD missing)?" — a SAFE
|
||
* outcome, not a broken one. Trusting that exit code alone would misclassify an
|
||
* ordinary per-entry merge failure as a repo-level one and strand the rest of
|
||
* the wave (caught in review).
|
||
*
|
||
* Checked directly via `git rev-parse --verify -q MERGE_HEAD` against the git
|
||
* ref itself rather than the filesystem: exit 0 means a merge is genuinely still
|
||
* in progress (unrecoverable — halt); exit 1 (the ref simply doesn't exist) means
|
||
* repoRoot is clean, whether because no merge state was ever entered or because
|
||
* abort successfully cleared it (safe — isolate and continue). Anything else
|
||
* (a timeout, or an unexpected git error) is treated conservatively as "still
|
||
* mid-merge" — degrade to the safe/halting answer rather than throw or guess.
|
||
*/
|
||
function repoRootStillMidMerge(execGit: ExecGitFn, repoRoot: string): boolean {
|
||
const check = execGit(['rev-parse', '--verify', '-q', 'MERGE_HEAD'], { cwd: repoRoot });
|
||
if (check.timedOut) return true; // fail closed — cannot confirm safety
|
||
if (check.exitCode === 0) return true; // MERGE_HEAD exists — genuinely still mid-merge
|
||
if (check.exitCode === 1) return false; // ref not found — repoRoot is not mid-merge
|
||
return true; // any other exit code (e.g. a fatal git error) — fail closed
|
||
}
|
||
|
||
// #2596: the single definition of "this file is an executor-written SUMMARY
|
||
// artifact". Shared by `defaultFindSummaryFiles` (which walks for them to
|
||
// rescue) and the scope advisory below (which must never flag them) — a plan's
|
||
// declared `files_modified` never lists a SUMMARY, because the executor writes
|
||
// it by orchestration contract, so a second copy of this rule would make the
|
||
// advisory fire on essentially every wave.
|
||
const SUMMARY_ARTIFACT_DIR = '.planning';
|
||
const SUMMARY_ARTIFACT_SUFFIX = 'SUMMARY.md';
|
||
|
||
/**
|
||
* Decode a git-quoted path. With `core.quotepath` left at its default (`true`)
|
||
* git wraps any path containing non-ASCII or special bytes in double quotes
|
||
* and C-escapes it — e.g. `tests/é.ts` is emitted as the literal
|
||
* `"tests/\303\251.ts"`. Both sides of the deletion/scope comparison
|
||
* (a declared path and a git-reported path) must agree on the same decoded
|
||
* shape, and decoding it here — rather than passing `-c core.quotepath=false`
|
||
* on the `execGit` call — keeps the git argv, and therefore every existing
|
||
* test fixture that asserts on exact argv, unchanged. It also works
|
||
* regardless of the user's own `core.quotepath` config.
|
||
*
|
||
* A value that is not wrapped in a leading and trailing `"` is returned
|
||
* completely untouched — this is the overwhelmingly common (plain ASCII)
|
||
* case and must not be altered in any way.
|
||
*
|
||
* Escapes decode to BYTES, collected into a Buffer and decoded as UTF-8 only
|
||
* at the end: `\303\251` is two bytes that together form one character (é),
|
||
* so decoding them one at a time would produce mojibake. Malformed input
|
||
* (a trailing lone backslash, or an octal escape with fewer than three
|
||
* digits) never throws — it degrades to treating the character literally, so
|
||
* a single bad path can never take down the whole cleanup wave.
|
||
*
|
||
* Exported (#4081) so the codebase-drift gate's `--name-status` parser can
|
||
* decode the identical C-quoted form before classifying paths — the gate's
|
||
* `execGit` call sets no `core.quotepath` config, so it receives the same
|
||
* quoting and must decode it with the same single owner of this seam.
|
||
*/
|
||
function decodeGitQuotedPath(raw: string): string {
|
||
if (raw.length < 2 || !raw.startsWith('"') || !raw.endsWith('"')) return raw;
|
||
const body = raw.slice(1, -1);
|
||
const bytes: number[] = [];
|
||
for (let i = 0; i < body.length; i++) {
|
||
const ch = body[i];
|
||
if (ch !== '\\' || i === body.length - 1) {
|
||
// UTF-8 bytes, not the code unit: a DECLARED path may be quoted while
|
||
// still holding a literal `é`, and pushing 0xE9 alone is invalid UTF-8.
|
||
// codePointAt keeps a surrogate pair together.
|
||
const codePoint = body.codePointAt(i);
|
||
const char = codePoint === undefined ? ch : String.fromCodePoint(codePoint);
|
||
bytes.push(...Buffer.from(char, 'utf8'));
|
||
i += char.length - 1;
|
||
continue;
|
||
}
|
||
const rest = body.slice(i + 1);
|
||
const octalMatch = /^([0-3][0-7][0-7])/.exec(rest);
|
||
if (octalMatch) {
|
||
bytes.push(parseInt(octalMatch[1], 8));
|
||
i += 3;
|
||
continue;
|
||
}
|
||
const next = body[i + 1];
|
||
const CONTROL_ESCAPES: Record<string, number> = {
|
||
a: 0x07, b: 0x08, f: 0x0c, n: 0x0a, r: 0x0d, t: 0x09, v: 0x0b,
|
||
'\\': 0x5c, '"': 0x22,
|
||
};
|
||
if (Object.prototype.hasOwnProperty.call(CONTROL_ESCAPES, next)) {
|
||
bytes.push(CONTROL_ESCAPES[next]);
|
||
} else {
|
||
bytes.push(next.charCodeAt(0));
|
||
}
|
||
i += 1;
|
||
}
|
||
return Buffer.from(bytes).toString('utf8');
|
||
}
|
||
|
||
/**
|
||
* Normalize one path for scope comparison. Applied to BOTH sides so a declared
|
||
* path and a git-reported path meet in the same shape: any git C-quoting is
|
||
* decoded first (order matters — the backslash-to-slash conversion below
|
||
* would destroy the escape sequences if it ran first), then backslashes
|
||
* become slashes unconditionally (a backslash path is not a Windows-only
|
||
* input), and a leading `./` and any trailing `/` are stripped. This is the
|
||
* single normalizer shared by the SUMMARY-artifact predicate and the scope
|
||
* advisory, so the two can never disagree about what `./a\b/` means.
|
||
*/
|
||
function normalizeScopePath(raw: string): string {
|
||
return decodeGitQuotedPath(String(raw || '').trim())
|
||
.replace(/\\/g, '/')
|
||
.trim()
|
||
.replace(/^\.\//, '')
|
||
.replace(/\/+$/, '');
|
||
}
|
||
|
||
/**
|
||
* True when a worktree-relative path is a SUMMARY artifact. Input may use
|
||
* either separator; normalization to POSIX is unconditional (backslash paths
|
||
* reach Linux too).
|
||
*/
|
||
function isSummaryArtifactRelPath(relPath: string): boolean {
|
||
const normalized = normalizeScopePath(relPath);
|
||
return normalized.startsWith(`${SUMMARY_ARTIFACT_DIR}/`)
|
||
&& normalized.endsWith(SUMMARY_ARTIFACT_SUFFIX);
|
||
}
|
||
|
||
/**
|
||
* Walk <worktreePath>/.planning/ recursively and collect absolute paths of
|
||
* all files whose names match *SUMMARY.md. Returns [] when the directory
|
||
* does not exist or cannot be read.
|
||
*
|
||
* Mirrors the shell fallback in quick.md (#2296, #2070, #2838):
|
||
* find "$WT/.planning" -name "*SUMMARY.md"
|
||
*/
|
||
function defaultFindSummaryFiles(worktreePath: string): string[] {
|
||
const planningDir = path.join(worktreePath, SUMMARY_ARTIFACT_DIR);
|
||
const results: string[] = [];
|
||
function walk(dir: string): void {
|
||
let entries: fs.Dirent[];
|
||
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
||
for (const entry of entries) {
|
||
const full = path.join(dir, entry.name);
|
||
if (entry.isDirectory()) {
|
||
walk(full);
|
||
} else if (entry.isFile() && entry.name.endsWith(SUMMARY_ARTIFACT_SUFFIX)) {
|
||
results.push(full);
|
||
}
|
||
}
|
||
}
|
||
walk(planningDir);
|
||
return results;
|
||
}
|
||
|
||
/**
|
||
* Rescue uncommitted SUMMARY.md artifacts from a worktree into the main repo
|
||
* tree before the dirty-state check. Mirrors the shell-fallback rescue block
|
||
* in quick.md (lines 878–891, #2296/#2070/#2838).
|
||
*
|
||
* For each *SUMMARY.md found under <worktreePath>/.planning/:
|
||
* - compute relative path from worktree root → .planning/<id>-SUMMARY.md
|
||
* - if the file is ALREADY COMMITTED on the worktree branch
|
||
* (`git cat-file -e HEAD:<relPath>` returns exit 0), skip the copy entirely:
|
||
* the merge will carry it naturally and copying it as an untracked file would
|
||
* cause a "untracked working tree files would be overwritten by merge" collision.
|
||
* On timeout or fatal exit (128) the rescue is also skipped (fail-closed).
|
||
* (#706 — execute-phase committed-SUMMARY contract)
|
||
* - destination = <repoRoot>/<relPath>
|
||
* - copy when dest is absent or content differs
|
||
*
|
||
* Returns `{ rescuedRelPaths, failures }`:
|
||
* - `rescuedRelPaths`: Set of worktree-relative paths that were successfully rescued
|
||
* (copy not needed because dest already matches, or copy succeeded). Only paths
|
||
* where the rescue genuinely succeeded are included so the dirty-block filter does
|
||
* not suppress paths that were silently lost.
|
||
* - `failures`: array of `{ relPath, error }` for any path where mkdirSync or
|
||
* copyFileSync threw. A read failure during content comparison is NOT a rescue
|
||
* failure — it sets needsCopy=true and the copy is attempted normally.
|
||
*/
|
||
function rescueSummaryArtifacts(
|
||
worktreePath: string,
|
||
repoRoot: string,
|
||
deps: WorktreeDeps,
|
||
): { rescuedRelPaths: Set<string>; failures: Array<{ relPath: string; error: string }> } {
|
||
const execGit = deps.execGit || execGitDefault;
|
||
const findSummaryFiles = deps.findSummaryFiles || defaultFindSummaryFiles;
|
||
const existsSync = deps.existsSync || fs.existsSync;
|
||
const readFileSync = deps.readFileSync || ((p: string) => fs.readFileSync(p, 'utf8'));
|
||
const mkdirSync = deps.mkdirSync || ((d: string, o?: { recursive?: boolean }) => fs.mkdirSync(d, o));
|
||
const copyFileSync = deps.copyFileSync || fs.copyFileSync;
|
||
|
||
const summaryPaths = findSummaryFiles(worktreePath);
|
||
const rescuedRelPaths = new Set<string>();
|
||
const failures: Array<{ relPath: string; error: string }> = [];
|
||
|
||
for (const absPath of summaryPaths) {
|
||
// relPath is the path relative to the worktree root (e.g. ".planning/q1-SUMMARY.md")
|
||
// Normalize to forward slashes so the Set comparison against `git status --porcelain`
|
||
// output works on Windows too (git always emits forward slashes in porcelain output).
|
||
const relPath = posixNormalize(absPath.slice(worktreePath.length).replace(/^[/\\]/, ''));
|
||
|
||
// #706: skip rescue when the SUMMARY is already committed on the branch.
|
||
// Use `git cat-file -e HEAD:<relPath>` (not `ls-files --error-unmatch`) so
|
||
// the check is against the committed tree, not the index. ls-files also
|
||
// matches staged-but-uncommitted files, which would skip rescue when the
|
||
// file is staged but not yet committed — the merge wouldn't carry it, and
|
||
// the executor's content could be lost. cat-file -e HEAD:<path> returns
|
||
// exit 0 only when the object exists in the committed HEAD tree.
|
||
//
|
||
// #2556: rescue whenever the object is NOT confirmed committed (any non-zero
|
||
// exit). `git cat-file -e` returns 128 — NOT 1 — for an absent path (the
|
||
// normal uncommitted-SUMMARY state), so the previous `!== 1` check never
|
||
// rescued and the untracked file was silently discarded by `worktree remove
|
||
// --force`. Data safety wins: an un-rescued untracked SUMMARY is lost, while
|
||
// a spurious rescue is usually a no-op — the destination check below skips the
|
||
// copy when the main tree already holds identical content (which is also what
|
||
// guards the #706 merge collision). A divergent dest is overwritten, but only
|
||
// uncommitted main-tree content could be lost (committed content is git-recoverable).
|
||
const catFileResult = execGit(['-C', worktreePath, 'cat-file', '-e', `HEAD:${relPath}`], { cwd: repoRoot });
|
||
if (catFileResult.exitCode === 0) {
|
||
// exit 0 → the SUMMARY is committed on HEAD; the merge will carry it, so skip rescue.
|
||
continue;
|
||
}
|
||
|
||
const dest = path.join(repoRoot, relPath);
|
||
let needsCopy = !existsSync(dest);
|
||
if (!needsCopy) {
|
||
try {
|
||
const srcContent = readFileSync(absPath);
|
||
const destContent = readFileSync(dest);
|
||
needsCopy = srcContent !== destContent;
|
||
} catch {
|
||
// Read failure during comparison is not a rescue failure — force a copy attempt.
|
||
needsCopy = true;
|
||
}
|
||
}
|
||
if (needsCopy) {
|
||
try {
|
||
mkdirSync(path.dirname(dest), { recursive: true });
|
||
copyFileSync(absPath, dest);
|
||
// Copy succeeded — the SUMMARY is now safe in the main tree.
|
||
rescuedRelPaths.add(relPath);
|
||
} catch (err) {
|
||
// Write failure: the SUMMARY was NOT rescued. Record it so the caller can
|
||
// block cleanup instead of silently losing data.
|
||
failures.push({ relPath, error: (err as Error).message });
|
||
}
|
||
} else {
|
||
// dest already exists with identical content — SUMMARY is already safe.
|
||
rescuedRelPaths.add(relPath);
|
||
}
|
||
}
|
||
|
||
return { rescuedRelPaths, failures };
|
||
}
|
||
|
||
/**
|
||
* #2596: advisory codes emitted by the wave-cleanup gauntlet. Frozen and
|
||
* exported so tests assert on a code rather than on rendered prose (this repo
|
||
* forbids raw-text matching on test output).
|
||
*/
|
||
const WAVE_CLEANUP_WARNING = Object.freeze({
|
||
/** A committed path fell outside the plan's declared `files_modified`. */
|
||
SCOPE_OUT_OF_DECLARED: 'scope_out_of_declared',
|
||
/** The scope diff could not be computed, so conformance is unknown. */
|
||
SCOPE_CHECK_UNAVAILABLE: 'scope_check_unavailable',
|
||
});
|
||
|
||
interface WaveCleanupWarning {
|
||
code: string;
|
||
branch: string;
|
||
/** The offending path; null when the check itself could not run. */
|
||
path: string | null;
|
||
}
|
||
|
||
/**
|
||
* The literal directory prefix a declared path covers, or `null` when the
|
||
* pattern begins with a glob metacharacter and therefore has no usable prefix.
|
||
*
|
||
* Deliberately literal-prefix only — NOT a glob engine. A hand-rolled
|
||
* `**`/`*`/`?` matcher inside a worktree-lifecycle module is an informal,
|
||
* undocumented pattern language living where no language belongs, and this
|
||
* repo forbids external deps in core. The submodule-intersection gate
|
||
* (`workflows/execute-phase/steps/per-plan-worktree-gate.md`) already ships
|
||
* exactly this glob-prefix rule; reusing it beats inventing a second one.
|
||
*
|
||
* `null` (no literal prefix, e.g. `*.md`) means "matches everything": for an
|
||
* ADVISORY, a false alarm costs more than a miss, so the ambiguous case
|
||
* suppresses rather than shouts.
|
||
*/
|
||
function declaredScopePrefix(declared: string): string | null {
|
||
const globAt = declared.search(/[*?[]/);
|
||
if (globAt < 0) return declared;
|
||
const literal = declared.slice(0, globAt).replace(/\/+$/, '');
|
||
return literal.length > 0 ? literal : null;
|
||
}
|
||
|
||
/**
|
||
* #2596: compare a branch's actual committed paths against the plan's declared
|
||
* scope. Pure — no git, no IO. Returns one warning per out-of-scope path, in
|
||
* the order the paths were given. Returns [] when nothing usable was declared:
|
||
* absence of data is not evidence of over-reach.
|
||
*/
|
||
function planWaveScopeConformance(
|
||
changedPaths: unknown,
|
||
declaredFiles: unknown,
|
||
branch: string,
|
||
): WaveCleanupWarning[] {
|
||
if (!Array.isArray(declaredFiles)) return [];
|
||
const prefixes: Array<string | null> = [];
|
||
for (const declared of declaredFiles) {
|
||
if (typeof declared !== 'string') continue;
|
||
const normalized = normalizeScopePath(declared);
|
||
if (!normalized) continue;
|
||
prefixes.push(declaredScopePrefix(normalized));
|
||
}
|
||
if (prefixes.length === 0) return [];
|
||
|
||
const warnings: WaveCleanupWarning[] = [];
|
||
const seen = new Set<string>();
|
||
for (const raw of Array.isArray(changedPaths) ? changedPaths : []) {
|
||
if (typeof raw !== 'string') continue;
|
||
const changed = normalizeScopePath(raw);
|
||
if (!changed || seen.has(changed)) continue;
|
||
seen.add(changed);
|
||
if (isSummaryArtifactRelPath(changed)) continue;
|
||
const covered = prefixes.some((prefix) => (
|
||
prefix === null || changed === prefix || changed.startsWith(`${prefix}/`)
|
||
));
|
||
if (covered) continue;
|
||
warnings.push({ code: WAVE_CLEANUP_WARNING.SCOPE_OUT_OF_DECLARED, branch, path: changed });
|
||
}
|
||
return warnings;
|
||
}
|
||
|
||
/**
|
||
* #3003: split git's reported deletions into declared and undeclared.
|
||
*
|
||
* EXACT match after `normalizeScopePath` — the same normalizer both other path
|
||
* comparisons in this file use, so a declared path and a git-reported path meet in the
|
||
* same shape (`a\b` and `./a/b/` both become `a/b`).
|
||
*
|
||
* Deliberately NOT `declaredScopePrefix`. That helper returns `null` for a glob-leading
|
||
* pattern meaning "matches everything", which is right for the ADVISORY it serves — a
|
||
* false alarm there costs more than a miss — and exactly wrong for a GATE, where it would
|
||
* let `["*.ts"]` disarm the guard. A glob here is simply a literal path that matches
|
||
* nothing. Prefix matching is likewise refused: `["tests"]` must not authorize deleting
|
||
* everything under tests/.
|
||
*
|
||
* Pure — no git, no IO. Returns the undeclared residue in the order git reported it.
|
||
*/
|
||
function partitionDeclaredDeletions(deletedPaths: unknown, declared: unknown): string[] {
|
||
const allowed = new Set(
|
||
(Array.isArray(declared) ? declared : [])
|
||
.filter((p): p is string => typeof p === 'string')
|
||
.map((p) => normalizeScopePath(p))
|
||
.filter((p) => p.length > 0),
|
||
);
|
||
const undeclared: string[] = [];
|
||
const seen = new Set<string>();
|
||
for (const raw of Array.isArray(deletedPaths) ? deletedPaths : []) {
|
||
if (typeof raw !== 'string') continue;
|
||
const normalized = normalizeScopePath(raw);
|
||
if (!normalized || seen.has(normalized)) continue;
|
||
seen.add(normalized);
|
||
if (allowed.has(normalized)) continue;
|
||
undeclared.push(normalized);
|
||
}
|
||
return undeclared;
|
||
}
|
||
|
||
interface WaveCleanupEntryResult extends CleanupManifestEntry {
|
||
status: string;
|
||
reason: string | null;
|
||
stderr: string;
|
||
warnings: WaveCleanupWarning[];
|
||
}
|
||
|
||
interface WaveCleanupResult {
|
||
ok: boolean;
|
||
action: string;
|
||
reason: string;
|
||
entries: WaveCleanupEntryResult[];
|
||
pending: CleanupManifestEntry[];
|
||
warnings: WaveCleanupWarning[];
|
||
}
|
||
|
||
function executeWorktreeWaveCleanupPlan(plan: WaveCleanupPlan | null, deps: WorktreeDeps = {}): WaveCleanupResult {
|
||
const execGit = deps.execGit || execGitDefault;
|
||
const entries = Array.isArray(plan?.entries) ? plan.entries : [];
|
||
if (!plan || plan.action !== 'cleanup_wave' || entries.length === 0) {
|
||
return {
|
||
ok: false,
|
||
action: plan ? plan.action : 'skip',
|
||
reason: plan ? (plan.reason || 'missing_entries') : 'missing_plan',
|
||
entries: [],
|
||
pending: entries,
|
||
warnings: [],
|
||
};
|
||
}
|
||
|
||
const results: WaveCleanupEntryResult[] = [];
|
||
const pending: CleanupManifestEntry[] = [];
|
||
const allWarnings: WaveCleanupWarning[] = [];
|
||
let ok = true;
|
||
|
||
// #2852: every per-entry failure site marks the SAME shape — status='blocked',
|
||
// a reason code, the captured stderr, push to results, flip the overall `ok`
|
||
// flag — and then either `continue` (isolate, the default) or, for the one
|
||
// repo-level-failure carve-out, `break`. Factored out so the 8 call sites below
|
||
// don't repeat the assembly; each site still owns its own control-flow decision.
|
||
function blockEntry(result: WaveCleanupEntryResult, reason: string, stderr: string): void {
|
||
result.status = 'blocked';
|
||
result.reason = reason;
|
||
result.stderr = stderr;
|
||
results.push(result);
|
||
ok = false;
|
||
}
|
||
|
||
for (let i = 0; i < entries.length; i += 1) {
|
||
const entry = entries[i];
|
||
const result: WaveCleanupEntryResult = {
|
||
...entry,
|
||
status: 'pending',
|
||
reason: null,
|
||
stderr: '',
|
||
warnings: [],
|
||
};
|
||
|
||
const branchCheck = execGit(['-C', entry.worktree_path, 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: plan.repoRoot });
|
||
if (!gitResultOk(branchCheck) || branchCheck.stdout.trim() !== entry.branch) {
|
||
blockEntry(result, 'branch_mismatch', branchCheck?.stderr || '');
|
||
// #2852: isolate — this entry's problem does not touch repoRoot's git state,
|
||
// so every remaining entry is still independently evaluated.
|
||
continue;
|
||
}
|
||
|
||
const mergeBase = execGit(['merge-base', 'HEAD', entry.branch], { cwd: plan.repoRoot });
|
||
const allowedBases = Array.isArray(entry.allowed_bases) && entry.allowed_bases.length > 0
|
||
? entry.allowed_bases
|
||
: [entry.expected_base];
|
||
if (!gitResultOk(mergeBase) || !allowedBases.includes(mergeBase.stdout.trim())) {
|
||
blockEntry(result, 'base_mismatch', mergeBase?.stderr || '');
|
||
continue; // #2852: isolate
|
||
}
|
||
|
||
const deletions = execGit(['diff', '--diff-filter=D', '--name-only', `HEAD...${entry.branch}`], { cwd: plan.repoRoot });
|
||
if (!gitResultOk(deletions)) {
|
||
blockEntry(result, 'deletion_check_failed', deletions?.stderr || '');
|
||
continue; // #2852: isolate
|
||
}
|
||
if (deletions.stdout) {
|
||
// #3003: a deletion the PLAN declared is authorized; anything else still blocks.
|
||
// Only a SUCCESSFUL check reaches here — a failed one blocked above on its own
|
||
// reason, so a broken check can never be filtered into a pass.
|
||
//
|
||
// The block detail carries ONLY the undeclared residue. Listing declared paths
|
||
// there would misdirect the operator toward paths that were fine.
|
||
const undeclaredDeletions = partitionDeclaredDeletions(
|
||
deletions.stdout.split('\n'),
|
||
entry.declared_deletions,
|
||
);
|
||
if (undeclaredDeletions.length > 0) {
|
||
blockEntry(result, 'branch_contains_deletions', undeclaredDeletions.join('\n'));
|
||
continue; // #2852: isolate
|
||
}
|
||
}
|
||
|
||
// #2596: advisory scope conformance — does the branch's ACTUAL committed
|
||
// diff stay inside the scope the plan declared? Gated on a declared scope
|
||
// being present: with nothing declared there is nothing to compare, so no
|
||
// git subprocess is spent at all (and every pre-#2596 fixture, none of
|
||
// which declares one, issues exactly the git calls it always did).
|
||
//
|
||
// ADVISORY ONLY. Unlike the deletions check above, a finding here does NOT
|
||
// call blockEntry and does NOT touch `ok` — the merge proceeds. Promotion
|
||
// to a hard gate is a separate, disclosed change.
|
||
// #3003: a declared deletion is in scope by construction — `git diff --name-only`
|
||
// includes deleted paths, so without accounting for the declaration, authorizing a
|
||
// deletion would immediately warn that the same path is out of declared scope.
|
||
//
|
||
// It is SUBTRACTED from the findings rather than UNIONED into the declared scope,
|
||
// and the difference is not cosmetic. `planWaveScopeConformance` reads its scope
|
||
// list with prefix-and-glob semantics, so unioning would silently hand
|
||
// `declared_deletions` a second, WIDER matching rule than the gate gives it:
|
||
// `["*.md"]` (inert at the gate) would yield a null prefix meaning "matches
|
||
// everything" and mute the advisory entirely, and `["src"]` would mute all of
|
||
// `src/`. One field with two matching rules is a trap. Subtracting keeps the
|
||
// field exact-match-only on every surface it touches.
|
||
//
|
||
// Gating stays on `files_modified` alone for the same reason: a plan that declares
|
||
// only deletions has still declared no modification scope, so the advisory stays
|
||
// exactly as silent as it was before this change instead of warning on every
|
||
// modified path.
|
||
const declaredFiles = Array.isArray(entry.files_modified) ? entry.files_modified : [];
|
||
if (declaredFiles.length > 0) {
|
||
const scopeDiff = execGit(['diff', '--name-only', `HEAD...${entry.branch}`], { cwd: plan.repoRoot });
|
||
const scopeWarnings: WaveCleanupWarning[] = !gitResultOk(scopeDiff)
|
||
// A broken advisory must never become a gate: record that conformance
|
||
// is unknown rather than blocking (or, worse, silently passing).
|
||
? [{ code: WAVE_CLEANUP_WARNING.SCOPE_CHECK_UNAVAILABLE, branch: entry.branch, path: null }]
|
||
: planWaveScopeConformance((scopeDiff.stdout || '').split('\n'), declaredFiles, entry.branch)
|
||
// A path-less warning (SCOPE_CHECK_UNAVAILABLE) is never subtracted.
|
||
.filter((w) => w.path === null
|
||
|| partitionDeclaredDeletions([w.path], entry.declared_deletions).length > 0);
|
||
result.warnings.push(...scopeWarnings);
|
||
allWarnings.push(...scopeWarnings);
|
||
}
|
||
|
||
// Safety net: rescue uncommitted SUMMARY.md artifacts before the dirty check.
|
||
// The executor leaves <quick_id>-SUMMARY.md uncommitted by contract — the
|
||
// orchestrator commits it. Mirrors quick.md shell fallback (#2296, #2070, #2838, #3804).
|
||
const { rescuedRelPaths, failures: rescueFailures } = rescueSummaryArtifacts(entry.worktree_path, plan.repoRoot, deps);
|
||
if (rescueFailures.length > 0) {
|
||
blockEntry(result, 'summary_rescue_failed', rescueFailures.map((f) => `${f.relPath}: ${f.error}`).join('; '));
|
||
continue; // #2852: isolate
|
||
}
|
||
|
||
const worktreeStatus = execGit(['-C', entry.worktree_path, 'status', '--porcelain', '--untracked-files=all'], { cwd: plan.repoRoot });
|
||
if (!gitResultOk(worktreeStatus)) {
|
||
blockEntry(result, 'worktree_dirty', worktreeStatus?.stderr || '');
|
||
continue; // #2852: isolate
|
||
}
|
||
// Filter rescued SUMMARY paths out of the porcelain output before deciding dirty.
|
||
// A line like "?? .planning/q1-SUMMARY.md" should not block when the SUMMARY
|
||
// has already been rescued into the main tree.
|
||
const dirtyLines = (worktreeStatus.stdout || '')
|
||
.split('\n')
|
||
.filter((line) => {
|
||
if (!line.trim()) return false;
|
||
// porcelain v1 format: "XY path" (3-char prefix + space + path)
|
||
const filePath = line.slice(3).trim();
|
||
return !rescuedRelPaths.has(filePath);
|
||
});
|
||
if (dirtyLines.length > 0) {
|
||
blockEntry(result, 'worktree_dirty', dirtyLines.join('\n'));
|
||
continue; // #2852: isolate
|
||
}
|
||
|
||
const merge = execGit(['merge', entry.branch, '--no-ff', '--no-edit', '-m', `chore: merge executor worktree (${entry.branch})`], { cwd: plan.repoRoot });
|
||
if (!gitResultOk(merge)) {
|
||
blockEntry(result, 'merge_failed', merge?.stderr || merge?.stdout || '');
|
||
// #2852: a failed --no-ff merge MIGHT leave repoRoot itself mid-merge
|
||
// (MERGE_HEAD set, conflict markers in the tree) — unlike every other block
|
||
// reason above, that specific state is NOT scoped to this one entry: a second
|
||
// `git merge` cannot even start while one is in progress, so every remaining
|
||
// entry would be corrupted by it. But git also refuses many merges WITHOUT ever
|
||
// entering a merge state (e.g. "your local changes would be overwritten by
|
||
// merge") — in that case repoRoot's tree was never touched and this failure is
|
||
// scoped to this entry, same as everything else. Attempt the abort as a
|
||
// best-effort cleanup, then check repoRoot's ACTUAL state directly — not
|
||
// `git merge --abort`'s own exit code, which fails "There is no merge to abort"
|
||
// in the safe case too and would misclassify it as unrecoverable (caught in
|
||
// review). Only a repo genuinely still mid-merge afterward legitimately halts
|
||
// the rest of the wave (the brief's "infrastructure-level failure" carve-out).
|
||
execGit(['merge', '--abort'], { cwd: plan.repoRoot });
|
||
if (repoRootStillMidMerge(execGit, plan.repoRoot)) {
|
||
pending.push(...entries.slice(i + 1));
|
||
break;
|
||
}
|
||
continue; // #2852: isolate — repoRoot is not (or no longer) mid-merge
|
||
}
|
||
|
||
let remove = execGit(['worktree', 'remove', entry.worktree_path, '--force'], { cwd: plan.repoRoot });
|
||
if (!gitResultOk(remove)) {
|
||
// Locked worktrees require unlock before remove (or --force --force).
|
||
// Attempt: git worktree unlock <path> (ignore failure — already unlocked is ok)
|
||
// then retry git worktree remove --force. (#3707)
|
||
execGit(['worktree', 'unlock', entry.worktree_path], { cwd: plan.repoRoot });
|
||
remove = execGit(['worktree', 'remove', entry.worktree_path, '--force'], { cwd: plan.repoRoot });
|
||
}
|
||
if (!gitResultOk(remove)) {
|
||
blockEntry(result, 'worktree_remove_failed', remove?.stderr || '');
|
||
// #2852: isolate — the merge already landed on repoRoot; only this entry's
|
||
// worktree/branch teardown is affected.
|
||
continue;
|
||
}
|
||
|
||
const branchDelete = execGit(['branch', '-D', entry.branch], { cwd: plan.repoRoot });
|
||
if (!gitResultOk(branchDelete)) {
|
||
result.status = 'warning';
|
||
result.reason = 'branch_delete_failed';
|
||
result.stderr = branchDelete?.stderr || '';
|
||
ok = false;
|
||
} else {
|
||
result.status = 'merged_removed';
|
||
result.reason = 'ok';
|
||
}
|
||
results.push(result);
|
||
}
|
||
|
||
return {
|
||
ok,
|
||
action: plan.action,
|
||
reason: ok ? 'ok' : 'cleanup_blocked',
|
||
entries: results,
|
||
pending,
|
||
warnings: allWarnings,
|
||
};
|
||
}
|
||
|
||
function cmdWorktreeCleanupWave(cwd: string, args: string[] = []): void {
|
||
const manifestFlagIndex = args.indexOf('--manifest');
|
||
const manifestPath = manifestFlagIndex >= 0 ? args[manifestFlagIndex + 1] : '';
|
||
if (!manifestPath) {
|
||
process.stderr.write('Usage: worktree cleanup-wave --manifest <path>\n');
|
||
process.exitCode = 2;
|
||
return;
|
||
}
|
||
|
||
let manifest: string;
|
||
try {
|
||
manifest = fs.readFileSync(path.resolve(cwd, manifestPath), 'utf8');
|
||
} catch (err) {
|
||
process.stdout.write(`${JSON.stringify({
|
||
ok: false,
|
||
reason: 'manifest_read_failed',
|
||
error: (err as Error).message,
|
||
}, null, 2)}\n`);
|
||
process.exitCode = 1;
|
||
return;
|
||
}
|
||
|
||
const plan = planWorktreeWaveCleanup(cwd, manifest);
|
||
const result = executeWorktreeWaveCleanupPlan(plan);
|
||
const response = {
|
||
ok: result.ok,
|
||
plan: {
|
||
action: plan.action,
|
||
discovery: plan.discovery,
|
||
reason: plan.reason,
|
||
entries: plan.entries.length,
|
||
},
|
||
result,
|
||
};
|
||
process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
|
||
if (!result.ok) {
|
||
process.exitCode = 1;
|
||
}
|
||
}
|
||
|
||
interface RecordAgentFields {
|
||
agentId: string;
|
||
worktreePath: string;
|
||
branch: string;
|
||
base: string;
|
||
files?: string;
|
||
deletions?: string;
|
||
}
|
||
|
||
/**
|
||
* #2596: split a `--files` value into declared paths. Whitespace-separated,
|
||
* matching the `PLAN_FILES` shape the per-plan worktree gate already builds
|
||
* with `jq -r '.files_modified // [] | join(" ")'`. Values are DATA compared
|
||
* against a diff — never opened, never passed to a shell.
|
||
*/
|
||
function parseDeclaredScopeFlag(raw?: string): string[] {
|
||
return String(raw || '').split(/\s+/).filter((token) => token.length > 0);
|
||
}
|
||
|
||
interface RecordAgentPlan {
|
||
ok: boolean;
|
||
reason: string;
|
||
hint?: string;
|
||
entry: CleanupManifestEntry | null;
|
||
/** Serialized manifest to write back (with trailing newline); null when ok === false. */
|
||
manifest: string | null;
|
||
}
|
||
|
||
/**
|
||
* Pure planner for the per-agent wave-manifest append.
|
||
*
|
||
* Validates the candidate entry at write time using the SAME rules the
|
||
* cleanup-wave reader enforces (via `normalizeCleanupManifestEntry`), so an
|
||
* entry that `record-agent` accepts is guaranteed to survive
|
||
* `normalizeCleanupManifest` on read — a field that would be silently dropped
|
||
* at cleanup time fails loudly here instead.
|
||
*
|
||
* `agent_id` is treated write-strict (required) even though the reader is
|
||
* lenient (nullable): the whole point of this verb is to catch an
|
||
* under-populated entry at write time, and an entry whose author cannot be
|
||
* identified defeats that. A duplicate `(worktree_path, branch)` is also
|
||
* rejected loudly — the reader dedups on that key, so a re-record would be
|
||
* silently dropped (the failure mode this verb exists to eliminate). The
|
||
* on-disk shape stays the existing 4-field entry (`agent_id`, `worktree_path`,
|
||
* `branch`, `expected_base`) unless `--files` declares a scope, in which case
|
||
* an optional `files_modified` is appended (#2596); the reader still
|
||
* re-derives `allowed_bases`.
|
||
*/
|
||
function planWorktreeRecordAgent(manifestRaw: string, fields: RecordAgentFields): RecordAgentPlan {
|
||
// 1. Write-strict required-field check (loud, with which flag is missing).
|
||
// Trim first so a whitespace-only value (" ") is rejected here rather
|
||
// than deferred to a guaranteed `git worktree remove` failure at cleanup.
|
||
const agentId = (fields.agentId || '').trim();
|
||
const worktreePath = (fields.worktreePath || '').trim();
|
||
const branch = (fields.branch || '').trim();
|
||
const base = (fields.base || '').trim();
|
||
const missing: string[] = [];
|
||
if (!agentId) missing.push('--agent-id');
|
||
if (!worktreePath) missing.push('--path');
|
||
if (!branch) missing.push('--branch');
|
||
if (!base) missing.push('--base');
|
||
if (missing.length > 0) {
|
||
return {
|
||
ok: false,
|
||
reason: 'missing_field',
|
||
hint: `record-agent requires ${missing.join(', ')}. Re-run with all of --agent-id, --path, --branch, --base set to non-empty (non-whitespace) values.`,
|
||
entry: null,
|
||
manifest: null,
|
||
};
|
||
}
|
||
|
||
// 2. Shared validation: run the candidate through the reader's normalizer.
|
||
// If it returns null the reader would drop this entry on read — reject now.
|
||
const declaredScope = parseDeclaredScopeFlag(fields.files);
|
||
// #3003: reuse parseDeclaredScopeFlag — a blank/absent --deletions leaves the
|
||
// entry shape untouched, mirroring the --files rule directly above.
|
||
const declaredDeletions = parseDeclaredScopeFlag(fields.deletions);
|
||
const candidate = {
|
||
agent_id: agentId,
|
||
worktree_path: worktreePath,
|
||
branch,
|
||
expected_base: base,
|
||
...(declaredScope.length > 0 ? { files_modified: declaredScope } : {}),
|
||
...(declaredDeletions.length > 0 ? { declared_deletions: declaredDeletions } : {}),
|
||
};
|
||
const entry = normalizeCleanupManifestEntry(candidate);
|
||
if (!entry) {
|
||
return {
|
||
ok: false,
|
||
reason: 'invalid_entry',
|
||
hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ${WORKTREE_AGENT_BRANCH_PATTERN} (accepts agent-<id>, worktree-agent-<id>, and worktree-wf_<runid> namespaces; got branch="${branch}"). Fix the field and re-run.`,
|
||
entry: null,
|
||
manifest: null,
|
||
};
|
||
}
|
||
|
||
// 3. Parse the existing manifest. The init shell ({orchestrator_root, worktrees: []})
|
||
// is written inline by the orchestrator before any agent spawns; a missing or
|
||
// malformed manifest is a loud failure here, not a silent under-populated write.
|
||
let parsed: unknown;
|
||
try {
|
||
parsed = JSON.parse(manifestRaw);
|
||
} catch {
|
||
return {
|
||
ok: false,
|
||
reason: 'invalid_manifest_json',
|
||
hint: 'Manifest is not valid JSON. The orchestrator must initialize it as {"orchestrator_root": "...", "worktrees": []} before recording agents.',
|
||
entry: null,
|
||
manifest: null,
|
||
};
|
||
}
|
||
|
||
// Accept the canonical {worktrees: []} shell or a bare top-level array (both
|
||
// are read by normalizeCleanupManifest); preserve any other top-level keys.
|
||
let worktrees: unknown[];
|
||
let writeBack: unknown;
|
||
if (Array.isArray(parsed)) {
|
||
worktrees = parsed;
|
||
writeBack = worktrees;
|
||
} else if (parsed && typeof parsed === 'object') {
|
||
const container = parsed as Record<string, unknown>;
|
||
if (container.worktrees === undefined) container.worktrees = [];
|
||
if (!Array.isArray(container.worktrees)) {
|
||
return {
|
||
ok: false,
|
||
reason: 'manifest_shape_invalid',
|
||
hint: 'Manifest "worktrees" must be an array. Re-initialize as {"orchestrator_root": "...", "worktrees": []}.',
|
||
entry: null,
|
||
manifest: null,
|
||
};
|
||
}
|
||
worktrees = container.worktrees;
|
||
writeBack = container;
|
||
} else {
|
||
return {
|
||
ok: false,
|
||
reason: 'manifest_shape_invalid',
|
||
hint: 'Manifest must be a JSON object {"worktrees": []} or a top-level array.',
|
||
entry: null,
|
||
manifest: null,
|
||
};
|
||
}
|
||
|
||
// 4. Reject a duplicate (worktree_path, branch). The reader dedups on this
|
||
// exact key, but only over entries that NORMALIZE successfully — so an
|
||
// existing malformed same-key entry (which the reader would drop) must NOT
|
||
// block recording a valid one. Run each existing entry through the reader's
|
||
// own normalizer and compare only the entries the reader would keep; this
|
||
// matches its dedup behavior exactly. A real duplicate signals an upstream
|
||
// double-spawn — surface it loudly instead of silently dropping it.
|
||
const dupKey = `${entry.worktree_path}\0${entry.branch}`;
|
||
const isDuplicate = worktrees.some((existing) => {
|
||
const normalized = normalizeCleanupManifestEntry(existing);
|
||
return normalized !== null && `${normalized.worktree_path}\0${normalized.branch}` === dupKey;
|
||
});
|
||
if (isDuplicate) {
|
||
return {
|
||
ok: false,
|
||
reason: 'duplicate_entry',
|
||
hint: `The manifest already records worktree_path="${entry.worktree_path}" branch="${entry.branch}". The cleanup reader dedups on (worktree_path, branch), so re-recording would be silently dropped — this usually signals an upstream double-spawn. Investigate rather than re-record.`,
|
||
entry: null,
|
||
manifest: null,
|
||
};
|
||
}
|
||
|
||
// 5. Append the minimal 4-field entry, matching the existing on-disk format.
|
||
const recorded: CleanupManifestEntry = {
|
||
agent_id: entry.agent_id,
|
||
worktree_path: entry.worktree_path,
|
||
branch: entry.branch,
|
||
expected_base: entry.expected_base,
|
||
};
|
||
// #2596: only written when a scope was actually declared — conservative in
|
||
// what we send, so a blank --files leaves the 4-field shape untouched.
|
||
if (entry.files_modified && entry.files_modified.length > 0) {
|
||
recorded.files_modified = entry.files_modified;
|
||
}
|
||
// #3003: same conservative rule as --files above — a blank --deletions
|
||
// leaves the entry shape untouched.
|
||
if (entry.declared_deletions && entry.declared_deletions.length > 0) {
|
||
recorded.declared_deletions = entry.declared_deletions;
|
||
}
|
||
worktrees.push(recorded);
|
||
|
||
return {
|
||
ok: true,
|
||
reason: 'ok',
|
||
entry: recorded,
|
||
manifest: `${JSON.stringify(writeBack, null, 2)}\n`,
|
||
};
|
||
}
|
||
|
||
interface RecordAgentCmdDeps {
|
||
readFile?: (p: string) => string;
|
||
writeFile?: (p: string, content: string) => void;
|
||
write?: (s: string) => void;
|
||
writeErr?: (s: string) => void;
|
||
}
|
||
|
||
interface RecordAgentCmdResult {
|
||
ok: boolean;
|
||
reason: string;
|
||
hint?: string;
|
||
entry: CleanupManifestEntry | null;
|
||
manifest_path?: string;
|
||
}
|
||
|
||
/**
|
||
* CLI command: append a validated per-agent entry to a wave cleanup manifest.
|
||
*
|
||
* Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> [--files "<space-separated paths>"] [--deletions "<space-separated paths>"]
|
||
*
|
||
* Fails loudly (non-zero exit + recovery hint on stderr) when a field is
|
||
* missing/garbled or the manifest is absent/malformed, rather than appending an
|
||
* under-populated entry that the cleanup reader would silently drop.
|
||
*/
|
||
function cmdWorktreeRecordAgent(cwd: string, args: string[] = [], deps: RecordAgentCmdDeps = {}): RecordAgentCmdResult {
|
||
const flag = (name: string): string => {
|
||
const i = args.indexOf(name);
|
||
if (i < 0 || i + 1 >= args.length) return '';
|
||
return args[i + 1];
|
||
};
|
||
const write = deps.write || ((s: string) => process.stdout.write(s));
|
||
const writeErr = deps.writeErr || ((s: string) => process.stderr.write(s));
|
||
|
||
const manifestPath = flag('--manifest');
|
||
if (!manifestPath) {
|
||
writeErr('Usage: worktree record-agent --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> [--files "<space-separated paths>"] [--deletions "<space-separated paths>"]\n');
|
||
process.exitCode = 2;
|
||
return { ok: false, reason: 'usage', entry: null };
|
||
}
|
||
|
||
const resolved = path.resolve(cwd, manifestPath);
|
||
const readFile = deps.readFile || ((p: string) => fs.readFileSync(p, 'utf8'));
|
||
let manifestRaw: string;
|
||
try {
|
||
manifestRaw = readFile(resolved);
|
||
} catch (err) {
|
||
const hint = `Manifest not found or unreadable at ${manifestPath}. The orchestrator must initialize it ({"orchestrator_root": "...", "worktrees": []}) before recording agents.`;
|
||
writeErr(`[gsd] worktree.record-agent: manifest_read_failed — ${hint}\n`);
|
||
write(`${JSON.stringify({ ok: false, reason: 'manifest_read_failed', hint, error: (err as Error).message }, null, 2)}\n`);
|
||
process.exitCode = 1;
|
||
return { ok: false, reason: 'manifest_read_failed', hint, entry: null };
|
||
}
|
||
|
||
const plan = planWorktreeRecordAgent(manifestRaw, {
|
||
agentId: flag('--agent-id'),
|
||
worktreePath: flag('--path'),
|
||
branch: flag('--branch'),
|
||
base: flag('--base'),
|
||
files: flag('--files'),
|
||
deletions: flag('--deletions'),
|
||
});
|
||
|
||
if (!plan.ok || plan.manifest === null) {
|
||
writeErr(`[gsd] worktree.record-agent: ${plan.reason} — ${plan.hint || ''}\n`);
|
||
write(`${JSON.stringify({ ok: false, reason: plan.reason, hint: plan.hint }, null, 2)}\n`);
|
||
process.exitCode = 1;
|
||
return { ok: false, reason: plan.reason, hint: plan.hint, entry: null };
|
||
}
|
||
|
||
const writeFile = deps.writeFile || ((p: string, content: string) => fs.writeFileSync(p, content, 'utf8'));
|
||
writeFile(resolved, plan.manifest);
|
||
write(`${JSON.stringify({ ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved }, null, 2)}\n`);
|
||
return { ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved };
|
||
}
|
||
|
||
// ─── worktree create (#2584 ADR-1239 Codex-binding amendment — Phase 2) ───────
|
||
//
|
||
// The `orchestrator-worktree` isolation ladder value (ADR-1239) requires GSD
|
||
// itself to create + bind the git worktree an executor runs in — this is that
|
||
// git-worktree-creation primitive. UNCONSUMED in Phase 2: no scheduler calls
|
||
// this yet (Phase 3 wires it). Mirrors the plan/execute/cmd split used by
|
||
// cleanup-wave and record-agent above so a created worktree is immediately
|
||
// manageable by cleanup-wave / reap-orphans without a second code path.
|
||
|
||
interface WorktreeCreateFields {
|
||
agentId: string;
|
||
worktreePath: string;
|
||
branch: string;
|
||
base: string;
|
||
files?: string;
|
||
deletions?: string;
|
||
}
|
||
|
||
interface WorktreeCreatePlan {
|
||
ok: boolean;
|
||
reason: string;
|
||
hint?: string;
|
||
entry: CleanupManifestEntry | null;
|
||
}
|
||
|
||
/**
|
||
* Pure planner for `worktree create`. Validates the four required fields
|
||
* (write-strict, same missing-field-hint style as `planWorktreeRecordAgent`),
|
||
* then runs the candidate entry through the SAME `normalizeCleanupManifestEntry`
|
||
* validation the cleanup-wave reader and record-agent use — so a worktree this
|
||
* verb creates is guaranteed manageable by cleanup-wave/reap-orphans, and an
|
||
* entry that would fail the reader's branch-namespace guard is rejected here,
|
||
* fail-closed, before any git command runs.
|
||
*/
|
||
function planWorktreeCreate(fields: WorktreeCreateFields): WorktreeCreatePlan {
|
||
const agentId = (fields.agentId || '').trim();
|
||
const worktreePath = (fields.worktreePath || '').trim();
|
||
const branch = (fields.branch || '').trim();
|
||
const base = (fields.base || '').trim();
|
||
const missing: string[] = [];
|
||
if (!agentId) missing.push('--agent-id');
|
||
if (!worktreePath) missing.push('--path');
|
||
if (!branch) missing.push('--branch');
|
||
if (!base) missing.push('--base');
|
||
if (missing.length > 0) {
|
||
return {
|
||
ok: false,
|
||
reason: 'missing_field',
|
||
hint: `worktree create requires ${missing.join(', ')}. Re-run with all of --agent-id, --path, --branch, --base set to non-empty (non-whitespace) values.`,
|
||
entry: null,
|
||
};
|
||
}
|
||
|
||
const declaredScope = parseDeclaredScopeFlag(fields.files);
|
||
// #3003: reuse parseDeclaredScopeFlag — a blank/absent --deletions leaves the
|
||
// entry shape untouched, mirroring the --files rule directly above.
|
||
const declaredDeletions = parseDeclaredScopeFlag(fields.deletions);
|
||
const candidate = {
|
||
agent_id: agentId,
|
||
worktree_path: worktreePath,
|
||
branch,
|
||
expected_base: base,
|
||
...(declaredScope.length > 0 ? { files_modified: declaredScope } : {}),
|
||
...(declaredDeletions.length > 0 ? { declared_deletions: declaredDeletions } : {}),
|
||
};
|
||
const entry = normalizeCleanupManifestEntry(candidate);
|
||
if (!entry) {
|
||
return {
|
||
ok: false,
|
||
reason: 'invalid_entry',
|
||
hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ${WORKTREE_AGENT_BRANCH_PATTERN} (accepts agent-<id>, worktree-agent-<id>, and worktree-wf_<runid> namespaces; got branch="${branch}"). Fix the field and re-run.`,
|
||
entry: null,
|
||
};
|
||
}
|
||
|
||
// #2584 FIX 4 — git argument-injection guard: a value starting with '-'
|
||
// could be parsed by git as a FLAG rather than a positional argument (e.g.
|
||
// base="--upload-pack=x", path="-f"). `git worktree add` / `git rev-parse`
|
||
// support for a `--` end-of-options separator is inconsistent across git
|
||
// versions, so rejecting a leading dash outright — not relying on `--` — is
|
||
// the portable fix.
|
||
if (branch.startsWith('-') || base.startsWith('-') || worktreePath.startsWith('-')) {
|
||
return {
|
||
ok: false,
|
||
reason: 'unsafe_leading_dash',
|
||
hint: `--branch/--base/--path must not start with "-" (a leading dash would be parsed by git as a flag, not a value). Got branch="${branch}" base="${base}" path="${worktreePath}".`,
|
||
entry: null,
|
||
};
|
||
}
|
||
|
||
// #2584 FIX 4 — path-traversal guard: reject a ".." path segment in --path.
|
||
// Absolute paths ARE allowed (the orchestrator legitimately uses them —
|
||
// Phase-3 root confinement is out of Phase-2 scope); only a literal ".."
|
||
// component is rejected. Split on BOTH separators so the guard is effective
|
||
// on a Windows-style path too.
|
||
if (worktreePath.split(/[/\\]/).includes('..')) {
|
||
return {
|
||
ok: false,
|
||
reason: 'unsafe_path_traversal',
|
||
hint: `--path must not contain a ".." path segment (got: "${worktreePath}").`,
|
||
entry: null,
|
||
};
|
||
}
|
||
|
||
return { ok: true, reason: 'ok', entry };
|
||
}
|
||
|
||
interface WorktreeCreateResult {
|
||
ok: boolean;
|
||
reason: string;
|
||
worktree_path?: string;
|
||
branch?: string;
|
||
base?: string;
|
||
cwd?: string;
|
||
stderr?: string;
|
||
}
|
||
|
||
/**
|
||
* Best-effort bounded rollback of a partial/orphaned worktree (#2584 FIX 3,
|
||
* scope narrowed by FIX 5). Invoked ONLY when a `git worktree add` TIMED OUT
|
||
* mid-operation — a SIGTERM'd `add` can leave a `.git/worktrees/<name>` admin
|
||
* entry / directory on disk that got past validation into the file checkout,
|
||
* so the partial is genuinely THIS call's own creation and is safe to
|
||
* best-effort remove immediately. It is deliberately NOT invoked on a clean
|
||
* non-zero `add` exit (see FIX 5) — the most common such failure is a
|
||
* COLLISION (the path/branch is already a registered worktree), git fails
|
||
* FAST there having created nothing, and the branch namespace this verb
|
||
* writes into (`worktree-agent-*`/`agent-*`) is exactly the concurrent-
|
||
* executor namespace, so a colliding path is very plausibly a LIVE PEER
|
||
* executor whose uncommitted work `--force` would destroy. Also invoked from
|
||
* `cmdWorktreeCreate` when a successful `add` is followed by a manifest-write
|
||
* failure (#2584 FIX 1) — that path proves THIS call created the worktree, so
|
||
* removing it is safe. This is immediate best-effort hygiene, not the only
|
||
* safety net: `reapOrphanWorktrees` scans the `.git/worktrees/` admin
|
||
* directory directly (a genuine directory-scan backstop, not manifest-only),
|
||
* so any partial this call cannot reach is still eventually discovered and
|
||
* reaped there. The result is intentionally ignored and a throw is
|
||
* swallowed: this is best-effort cleanup, never a new source of truth, and
|
||
* must never mask or block the caller's own degraded-but-honest return.
|
||
*/
|
||
function rollbackPartialWorktree(execGit: ExecGitFn, worktreePath: string, repoRoot: string): void {
|
||
try {
|
||
execGit(['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot });
|
||
} catch {
|
||
// best-effort only — a throwing rollback must never mask the original failure.
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Execute a `planWorktreeCreate` plan via bounded git. Fail-closed at every
|
||
* step — a timeout or a non-zero exit degrades to a structured result rather
|
||
* than throwing, and the base must resolve BEFORE any worktree is created (no
|
||
* partial/orphaned worktree on a bad base). Returns `cwd` — the working
|
||
* directory Phase 3's executor spawn will pass through.
|
||
*/
|
||
function executeWorktreeCreatePlan(plan: WorktreeCreatePlan, repoRoot: string, deps: WorktreeDeps = {}): WorktreeCreateResult {
|
||
const execGit = deps.execGit || execGitDefault;
|
||
if (!plan || !plan.ok || !plan.entry) {
|
||
return {
|
||
ok: false,
|
||
reason: plan ? plan.reason : 'missing_plan',
|
||
};
|
||
}
|
||
|
||
const { worktree_path: worktreePath, branch, expected_base: base } = plan.entry;
|
||
const normalizedPath = posixNormalize(worktreePath);
|
||
|
||
// 1. Verify the base resolves BEFORE creating anything (fail-closed). Nothing
|
||
// has been created on this path yet, so there is nothing to roll back.
|
||
const baseCheck = execGit(['rev-parse', '--verify', '--quiet', `${base}^{commit}`], { cwd: repoRoot });
|
||
if (baseCheck.timedOut) {
|
||
return { ok: false, reason: 'git_timeout', worktree_path: normalizedPath, branch, base };
|
||
}
|
||
if (!gitResultOk(baseCheck)) {
|
||
return { ok: false, reason: 'base_unresolved', worktree_path: normalizedPath, branch, base, stderr: baseCheck.stderr || '' };
|
||
}
|
||
|
||
// 2. Create the worktree + branch together.
|
||
const addResult = execGit(['worktree', 'add', '-b', branch, worktreePath, base], { cwd: repoRoot });
|
||
if (addResult.timedOut) {
|
||
// #2584 FIX 3: a SIGTERM'd `add` can leave a partial worktree on disk.
|
||
rollbackPartialWorktree(execGit, worktreePath, repoRoot);
|
||
return { ok: false, reason: 'git_timeout', worktree_path: normalizedPath, branch, base };
|
||
}
|
||
if (addResult.exitCode !== 0) {
|
||
// #2584 FIX 5: deliberately NO rollback here. A clean non-zero exit is
|
||
// most commonly a COLLISION (path/branch already a registered worktree),
|
||
// and git fails FAST on that — it creates nothing. A colliding path in
|
||
// this branch namespace is very plausibly a LIVE PEER executor;
|
||
// `git worktree remove --force` on it would destroy real, uncommitted
|
||
// work. The safe response to a clean failure is to fail loudly and leave
|
||
// whatever is already on disk untouched.
|
||
return { ok: false, reason: 'worktree_add_failed', worktree_path: normalizedPath, branch, base, stderr: addResult.stderr || '' };
|
||
}
|
||
|
||
return {
|
||
ok: true,
|
||
reason: 'created',
|
||
worktree_path: normalizedPath,
|
||
branch,
|
||
base,
|
||
cwd: normalizedPath,
|
||
};
|
||
}
|
||
|
||
interface WorktreeCreateCmdResult {
|
||
ok: boolean;
|
||
reason: string;
|
||
hint?: string;
|
||
entry?: CleanupManifestEntry | null;
|
||
cwd?: string;
|
||
manifest_path?: string;
|
||
stderr?: string;
|
||
}
|
||
|
||
/**
|
||
* CLI command: create a git worktree + branch off `--base`, then append the
|
||
* validated manifest entry so the worktree is immediately manageable by
|
||
* `worktree cleanup-wave` / `worktree reap-orphans`.
|
||
*
|
||
* Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> --root <dir> [--files "<space-separated paths>"] [--deletions "<space-separated paths>"]
|
||
*
|
||
* #2584 FIX 1 — ORDERING CONTRACT: every manifest read/parse/shape-validate/
|
||
* plan step runs BEFORE the git side effect (step 5). The ONLY manifest
|
||
* operation that can run AFTER `git worktree add` has succeeded is the final
|
||
* guarded write (step 6), and a failure there triggers a best-effort rollback
|
||
* of the just-created worktree — so a malformed/mis-shaped manifest, or a
|
||
* `writeFile` IO error, can never leave a REAL worktree on disk with no
|
||
* manifest entry (cleanup-wave/reap-orphans only discover worktrees via the
|
||
* manifest, never a directory scan) or an uncaught throw.
|
||
*/
|
||
function cmdWorktreeCreate(cwd: string, args: string[] = [], deps: RecordAgentCmdDeps & WorktreeDeps = {}): WorktreeCreateCmdResult {
|
||
const flag = (name: string): string => {
|
||
const i = args.indexOf(name);
|
||
if (i < 0 || i + 1 >= args.length) return '';
|
||
return args[i + 1];
|
||
};
|
||
const write = deps.write || ((s: string) => process.stdout.write(s));
|
||
const writeErr = deps.writeErr || ((s: string) => process.stderr.write(s));
|
||
|
||
const manifestPath = flag('--manifest');
|
||
if (!manifestPath) {
|
||
writeErr('Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> --root <dir> [--files "<space-separated paths>"] [--deletions "<space-separated paths>"]\n');
|
||
process.exitCode = 2;
|
||
return { ok: false, reason: 'usage' };
|
||
}
|
||
|
||
// 1. Read the manifest (no side effect yet).
|
||
const resolved = path.resolve(cwd, manifestPath);
|
||
const readFile = deps.readFile || ((p: string) => fs.readFileSync(p, 'utf8'));
|
||
let manifestRaw: string;
|
||
try {
|
||
manifestRaw = readFile(resolved);
|
||
} catch (err) {
|
||
const hint = `Manifest not found or unreadable at ${manifestPath}. The orchestrator must initialize it ({"orchestrator_root": "...", "worktrees": []}) before creating agent worktrees.`;
|
||
writeErr(`[gsd] worktree.create: manifest_read_failed — ${hint}\n`);
|
||
write(`${JSON.stringify({ ok: false, reason: 'manifest_read_failed', hint, error: (err as Error).message }, null, 2)}\n`);
|
||
process.exitCode = 1;
|
||
return { ok: false, reason: 'manifest_read_failed', hint };
|
||
}
|
||
|
||
// 2. Parse + shape-validate the manifest BEFORE any git command runs.
|
||
// Mirrors planWorktreeRecordAgent's shell-acceptance rules (canonical
|
||
// {worktrees:[]} object OR a bare top-level array).
|
||
let parsed: unknown;
|
||
try {
|
||
parsed = JSON.parse(manifestRaw);
|
||
} catch {
|
||
const hint = 'Manifest is not valid JSON. The orchestrator must initialize it as {"orchestrator_root": "...", "worktrees": []} before creating agent worktrees.';
|
||
writeErr(`[gsd] worktree.create: invalid_manifest_json — ${hint}\n`);
|
||
write(`${JSON.stringify({ ok: false, reason: 'invalid_manifest_json', hint }, null, 2)}\n`);
|
||
process.exitCode = 1;
|
||
return { ok: false, reason: 'invalid_manifest_json', hint };
|
||
}
|
||
|
||
let worktrees: unknown[];
|
||
let writeBack: unknown;
|
||
if (Array.isArray(parsed)) {
|
||
worktrees = parsed;
|
||
writeBack = worktrees;
|
||
} else if (parsed && typeof parsed === 'object') {
|
||
const container = parsed as Record<string, unknown>;
|
||
if (container.worktrees === undefined) container.worktrees = [];
|
||
if (!Array.isArray(container.worktrees)) {
|
||
const hint = 'Manifest "worktrees" must be an array. Re-initialize as {"orchestrator_root": "...", "worktrees": []}.';
|
||
writeErr(`[gsd] worktree.create: manifest_shape_invalid — ${hint}\n`);
|
||
write(`${JSON.stringify({ ok: false, reason: 'manifest_shape_invalid', hint }, null, 2)}\n`);
|
||
process.exitCode = 1;
|
||
return { ok: false, reason: 'manifest_shape_invalid', hint };
|
||
}
|
||
worktrees = container.worktrees;
|
||
writeBack = container;
|
||
} else {
|
||
const hint = 'Manifest must be a JSON object {"worktrees": []} or a top-level array.';
|
||
writeErr(`[gsd] worktree.create: manifest_shape_invalid — ${hint}\n`);
|
||
write(`${JSON.stringify({ ok: false, reason: 'manifest_shape_invalid', hint }, null, 2)}\n`);
|
||
process.exitCode = 1;
|
||
return { ok: false, reason: 'manifest_shape_invalid', hint };
|
||
}
|
||
|
||
// 3. Plan (pure, no I/O) — still before any git command.
|
||
const plan = planWorktreeCreate({
|
||
agentId: flag('--agent-id'),
|
||
worktreePath: flag('--path'),
|
||
branch: flag('--branch'),
|
||
base: flag('--base'),
|
||
files: flag('--files'),
|
||
deletions: flag('--deletions'),
|
||
});
|
||
|
||
if (!plan.ok || !plan.entry) {
|
||
writeErr(`[gsd] worktree.create: ${plan.reason} — ${plan.hint || ''}\n`);
|
||
write(`${JSON.stringify({ ok: false, reason: plan.reason, hint: plan.hint }, null, 2)}\n`);
|
||
process.exitCode = 1;
|
||
return { ok: false, reason: plan.reason, hint: plan.hint };
|
||
}
|
||
|
||
// 3b. Mandatory root confinement (#2627 Phase 3 introduced it; #3050 made it
|
||
// mandatory — the confinement Phase 2 deferred here from
|
||
// planWorktreeCreate's path-traversal guard). planWorktreeCreate rejects a
|
||
// literal ".." SEGMENT, but a plain absolute path outside the project
|
||
// contains no ".." and passes. The orchestrator SPAWNS executor processes
|
||
// into these paths, so an unconfined --path is a write primitive aimed
|
||
// anywhere on the filesystem.
|
||
//
|
||
// The root is DECLARED by the caller (`--root`) rather than inferred: agent
|
||
// worktrees legitimately live outside the orchestrator's own root (a lane
|
||
// orchestrator creates siblings under the repo's .claude/worktrees/), so
|
||
// there is no layout this module could derive without guessing.
|
||
//
|
||
// Lexical by design: the worktree does not exist yet, so there is nothing
|
||
// to realpath, and resolving only the root would not close a symlinked-leaf
|
||
// hole. Pairs with the leading-dash and ".."-segment guards above.
|
||
//
|
||
// #3050: confinement does not depend on the caller remembering to pass
|
||
// `--root` — it used to be silently skippable, so a caller that forgot the
|
||
// flag got an unconfined `--path` with no warning. Fail closed instead:
|
||
// absent `--root`, this verb refuses to create anything. The one current
|
||
// caller (execute-phase's orchestrator-worktree dispatch) always passes
|
||
// `--root`, so this closes the gap without breaking it.
|
||
const rootFlag = flag('--root');
|
||
if (!rootFlag) {
|
||
const hint = '--root is required (fail-closed root confinement, #3050). Pass --root <orchestrator-root-dir> so worktree.create can verify --path resolves inside it before creating anything.';
|
||
writeErr(`[gsd] worktree.create: root_required — ${hint}\n`);
|
||
write(`${JSON.stringify({ ok: false, reason: 'root_required', hint }, null, 2)}\n`);
|
||
process.exitCode = 1;
|
||
return { ok: false, reason: 'root_required', hint };
|
||
}
|
||
{
|
||
const absRoot = path.resolve(cwd, rootFlag);
|
||
const absWorktree = path.resolve(cwd, plan.entry.worktree_path);
|
||
const rel = path.relative(absRoot, absWorktree);
|
||
// rel === '' → the worktree IS the root (would clobber the checkout)
|
||
// rel === '..' / '../…' → escapes the root
|
||
// path.isAbsolute(rel) → a different Windows drive or UNC root
|
||
if (rel === '' || rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
||
const hint = `--path must resolve INSIDE --root (root="${absRoot}", path="${absWorktree}"). A worktree outside the declared root is unreachable by manifest-scoped cleanup and would let a spawned executor write outside the project.`;
|
||
writeErr(`[gsd] worktree.create: path_outside_root — ${hint}\n`);
|
||
write(`${JSON.stringify({ ok: false, reason: 'path_outside_root', hint }, null, 2)}\n`);
|
||
process.exitCode = 1;
|
||
return { ok: false, reason: 'path_outside_root', hint };
|
||
}
|
||
}
|
||
|
||
// 4. Compute the deduped final manifest STRING in memory now — the ONLY
|
||
// manifest work left is the guarded write in step 6, after git succeeds.
|
||
// #2584 FIX 2: the on-disk entry is the SAME minimal 4-field shape
|
||
// `cmdWorktreeRecordAgent` writes — never the full normalized entry with
|
||
// the derived `allowed_bases` — so the two verbs never write divergent
|
||
// shapes into the same manifest (the reader re-derives `allowed_bases`
|
||
// from `expected_base` on load, exactly as it does for record-agent).
|
||
const recorded: CleanupManifestEntry = {
|
||
agent_id: plan.entry.agent_id,
|
||
worktree_path: plan.entry.worktree_path,
|
||
branch: plan.entry.branch,
|
||
expected_base: plan.entry.expected_base,
|
||
};
|
||
// #2596: only written when a scope was actually declared — conservative in
|
||
// what we send, so a blank --files leaves the 4-field shape untouched.
|
||
if (Array.isArray(plan.entry.files_modified) && plan.entry.files_modified.length > 0) {
|
||
recorded.files_modified = plan.entry.files_modified;
|
||
}
|
||
// #3003: same conservative rule as --files above — a blank --deletions
|
||
// leaves the entry shape untouched.
|
||
if (Array.isArray(plan.entry.declared_deletions) && plan.entry.declared_deletions.length > 0) {
|
||
recorded.declared_deletions = plan.entry.declared_deletions;
|
||
}
|
||
const dedupeKey = `${recorded.worktree_path}\0${recorded.branch}`;
|
||
const alreadyPresent = worktrees.some((existing) => {
|
||
const normalized = normalizeCleanupManifestEntry(existing);
|
||
return normalized !== null && `${normalized.worktree_path}\0${normalized.branch}` === dedupeKey;
|
||
});
|
||
if (!alreadyPresent) {
|
||
worktrees.push(recorded);
|
||
}
|
||
const manifestToWrite = `${JSON.stringify(writeBack, null, 2)}\n`;
|
||
|
||
// 5. NOW run the git side effect. Every manifest problem above is caught
|
||
// before this point, so a malformed/mis-shaped manifest can never leave
|
||
// an unmanifested worktree on disk. `executeWorktreeCreatePlan` itself
|
||
// best-effort rolls back a partial worktree on its own add-timeout /
|
||
// add-failed paths (#2584 FIX 3).
|
||
const result = executeWorktreeCreatePlan(plan, cwd, deps);
|
||
if (!result.ok) {
|
||
writeErr(`[gsd] worktree.create: ${result.reason} — ${result.stderr || ''}\n`);
|
||
write(`${JSON.stringify({ ok: false, reason: result.reason, stderr: result.stderr }, null, 2)}\n`);
|
||
process.exitCode = 1;
|
||
return { ok: false, reason: result.reason, stderr: result.stderr };
|
||
}
|
||
|
||
// 6. Write the pre-computed manifest string, guarded. A write failure here
|
||
// means a REAL worktree now exists with NO manifest entry — roll it back
|
||
// (best-effort) rather than leaving an orphan cleanup-wave/reap-orphans
|
||
// can never reach (#2584 FIX 1). Never throws past this function.
|
||
const writeFile = deps.writeFile || ((p: string, content: string) => fs.writeFileSync(p, content, 'utf8'));
|
||
try {
|
||
writeFile(resolved, manifestToWrite);
|
||
} catch (err) {
|
||
const execGit = deps.execGit || execGitDefault;
|
||
rollbackPartialWorktree(execGit, plan.entry.worktree_path, cwd);
|
||
const hint = `The worktree was created but the manifest write failed (${(err as Error).message}); rolled back the worktree via a best-effort 'git worktree remove --force'.`;
|
||
writeErr(`[gsd] worktree.create: manifest_write_failed — ${hint}\n`);
|
||
write(`${JSON.stringify({ ok: false, reason: 'manifest_write_failed', hint, error: (err as Error).message }, null, 2)}\n`);
|
||
process.exitCode = 1;
|
||
return { ok: false, reason: 'manifest_write_failed', hint };
|
||
}
|
||
|
||
write(`${JSON.stringify({ ok: true, reason: 'created', entry: recorded, cwd: result.cwd, manifest_path: resolved }, null, 2)}\n`);
|
||
return { ok: true, reason: 'created', entry: recorded, cwd: result.cwd, manifest_path: resolved };
|
||
}
|
||
|
||
/**
|
||
* Reap orphaned linked worktrees whose lock owner process is dead, whose
|
||
* branch tip is fully merged into the default branch, and whose lock file
|
||
* mtime is older than REAP_MTIME_GUARD_MS (race guard).
|
||
*/
|
||
const REAP_MTIME_GUARD_MS = 5 * 60 * 1000; // 5 minutes
|
||
|
||
interface ReapResult {
|
||
path: string;
|
||
status: 'reaped' | 'skipped';
|
||
reason: string;
|
||
}
|
||
|
||
function reapOrphanWorktrees(repoRoot: string, deps: WorktreeDeps = {}): ReapResult[] {
|
||
const execGit = deps.execGit || execGitDefault;
|
||
const isPidAliveCheck = deps.isPidAlive || defaultIsPidAlive;
|
||
const readDirSafe = deps.readDirSafe || defaultReadDirSafe;
|
||
const readFileSafe = deps.readFileSafe || defaultReadFileSafe;
|
||
const mtimeSafe = deps.mtimeSafe || defaultMtimeSafe;
|
||
const reapMtimeGuardMs = deps.reapMtimeGuardMs !== undefined ? deps.reapMtimeGuardMs : REAP_MTIME_GUARD_MS;
|
||
const nowMs = deps.nowMs ?? Date.now();
|
||
|
||
const results: ReapResult[] = [];
|
||
|
||
// 1. Discover the .git/worktrees/ admin directory.
|
||
const gitDir = execGit(['rev-parse', '--git-dir'], { cwd: repoRoot });
|
||
if (!gitResultOk(gitDir)) return results;
|
||
const gitDirPath = path.resolve(repoRoot, gitDir.stdout.trim());
|
||
|
||
const worktreesAdminDir = path.join(gitDirPath, 'worktrees');
|
||
const entries = readDirSafe(worktreesAdminDir);
|
||
if (!entries) return results;
|
||
|
||
// 2. Discover the default branch (main/master/etc) tip.
|
||
const defaultBranchResult = execGit(
|
||
['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'],
|
||
{ cwd: repoRoot }
|
||
);
|
||
|
||
let mainTip: string | undefined;
|
||
if (gitResultOk(defaultBranchResult)) {
|
||
// Remote default branch is known — use it exclusively.
|
||
const branchName = defaultBranchResult.stdout.trim().replace(/^origin\//, '');
|
||
const r = execGit(['rev-parse', `refs/remotes/origin/${branchName}`], { cwd: repoRoot });
|
||
if (!gitResultOk(r)) return results; // remote ref unresolvable — fail closed
|
||
mainTip = r.stdout.trim();
|
||
} else {
|
||
// No remote configured (local-only repo, e.g. test fixtures).
|
||
const hasRemote = execGit(['remote'], { cwd: repoRoot });
|
||
if (gitResultOk(hasRemote) && hasRemote.stdout.trim()) {
|
||
// Remote exists but origin/HEAD not set — ambiguous; fail closed.
|
||
return results;
|
||
}
|
||
// Build candidate list: init.defaultBranch config, HEAD symref, then main, master.
|
||
const candidateBranches: string[] = [];
|
||
const configResult = execGit(['config', '--get', 'init.defaultBranch'], { cwd: repoRoot });
|
||
if (gitResultOk(configResult) && configResult.stdout.trim()) {
|
||
candidateBranches.push(configResult.stdout.trim());
|
||
}
|
||
const headSymref = execGit(['symbolic-ref', '--quiet', '--short', 'HEAD'], { cwd: repoRoot });
|
||
if (gitResultOk(headSymref) && headSymref.stdout.trim()) {
|
||
const headBranch = headSymref.stdout.trim();
|
||
if (!candidateBranches.includes(headBranch)) {
|
||
candidateBranches.push(headBranch);
|
||
}
|
||
}
|
||
for (const b of ['main', 'master']) {
|
||
if (!candidateBranches.includes(b)) candidateBranches.push(b);
|
||
}
|
||
for (const candidate of candidateBranches) {
|
||
const r = execGit(['rev-parse', candidate], { cwd: repoRoot });
|
||
if (gitResultOk(r)) {
|
||
mainTip = r.stdout.trim();
|
||
break;
|
||
}
|
||
}
|
||
if (!mainTip) return results;
|
||
}
|
||
|
||
// 3. Build a canonical-path → listed-path index from git worktree list.
|
||
const listedResult = execGit(['worktree', 'list', '--porcelain'], { cwd: repoRoot });
|
||
const canonicalToListed = new Map<string, string>();
|
||
if (gitResultOk(listedResult)) {
|
||
const normalizedListed = listedResult.stdout.replace(/\r\n/g, '\n');
|
||
for (const block of normalizedListed.split('\n\n').filter(Boolean)) {
|
||
const wtLine = block.split('\n').find((l) => l.startsWith('worktree '));
|
||
if (!wtLine) continue;
|
||
const listed = wtLine.slice('worktree '.length).trim();
|
||
try {
|
||
const canonical = fs.realpathSync.native(listed);
|
||
canonicalToListed.set(canonical, listed);
|
||
} catch {
|
||
// If the path doesn't exist (already removed), skip silently.
|
||
}
|
||
}
|
||
}
|
||
|
||
// 4. Process each worktree admin entry that has a 'locked' file.
|
||
for (const entryName of entries) {
|
||
const adminDir = path.join(worktreesAdminDir, entryName);
|
||
const lockedFile = path.join(adminDir, 'locked');
|
||
const lockedContent = readFileSafe(lockedFile);
|
||
if (lockedContent === null) continue; // no lock file — not our concern
|
||
|
||
// Resolve the actual worktree path from the gitdir pointer.
|
||
const gitdirFile = path.join(adminDir, 'gitdir');
|
||
const gitdirContent = readFileSafe(gitdirFile);
|
||
if (!gitdirContent) continue;
|
||
const resolvedGitFile = path.resolve(adminDir, gitdirContent.trim());
|
||
const worktreePath = path.basename(resolvedGitFile) === '.git'
|
||
? path.dirname(resolvedGitFile)
|
||
: resolvedGitFile;
|
||
|
||
// Look up the git-list path (the path git knows about) for use in
|
||
// git worktree unlock/remove commands.
|
||
let gitKnownPath = worktreePath;
|
||
try {
|
||
const canonical = fs.realpathSync.native(worktreePath);
|
||
gitKnownPath = canonicalToListed.get(canonical) || worktreePath;
|
||
} catch {
|
||
// worktreePath may not exist yet (already removed); use as-is.
|
||
}
|
||
|
||
// 4a. Stale-lock guard: skip if lock is too fresh (PID recycling / race).
|
||
//
|
||
// The two causes are reported SEPARATELY (#3057). A lock whose mtime could
|
||
// not be read is not "fresh" in any sense: `lock_too_fresh` tells an
|
||
// operator that waiting will resolve the skip, and waiting never resolves a
|
||
// stat failure — the lock could be seconds or months old and the sweep has
|
||
// no way to tell. Conflating them is the same defect this module already
|
||
// fixed for `parse_failed` vs `no_worktrees` in planWorktreePrune: a
|
||
// decision made on unread data must not be indistinguishable from one made
|
||
// on real data.
|
||
const lockMtime = mtimeSafe(lockedFile);
|
||
if (!lockMtime) {
|
||
results.push({ path: worktreePath, status: 'skipped', reason: 'lock_age_unknown' });
|
||
continue;
|
||
}
|
||
if (nowMs - lockMtime.getTime() < reapMtimeGuardMs) {
|
||
results.push({ path: worktreePath, status: 'skipped', reason: 'lock_too_fresh' });
|
||
continue;
|
||
}
|
||
|
||
// 4b. PID liveness check.
|
||
const pidStr = lockedContent.trim().match(/^\d+/)?.[0];
|
||
if (!pidStr) {
|
||
results.push({ path: worktreePath, status: 'skipped', reason: 'lock_owner_unknown' });
|
||
continue;
|
||
}
|
||
const pid = parseInt(pidStr, 10);
|
||
// Number.isFinite, not Number.isNaN: pidStr is captured by /^\d+/ above, so
|
||
// pid can never be NaN. A 309-or-more-digit string parses to Infinity.
|
||
//
|
||
// NOT LOAD-BEARING FOR SAFETY — do not delete it as redundant. Fail-closed
|
||
// liveness now lives in defaultIsPidAlive, which treats every non-ESRCH
|
||
// outcome (including the TypeError process.kill throws for Infinity) as
|
||
// ALIVE. This guard survives because it produces a more ACCURATE verdict
|
||
// for garbage input: `lock_owner_unknown` says "the lock names a PID this
|
||
// parse could not represent", whereas falling through would report
|
||
// `pid_alive` — an assertion about an owner that was never probed.
|
||
// Note this is an EARLIER, DIFFERENT gate than the process.kill range
|
||
// limit: process.kill accepts up to 2147483647 and rejects 2147483648
|
||
// (measured), far below the parse cliff this guard catches.
|
||
if (!Number.isFinite(pid)) {
|
||
results.push({ path: worktreePath, status: 'skipped', reason: 'lock_owner_unknown' });
|
||
continue;
|
||
}
|
||
let pidIsAlive: boolean;
|
||
try {
|
||
pidIsAlive = isPidAliveCheck(pid);
|
||
} catch {
|
||
pidIsAlive = true; // Cannot determine liveness — treat as alive, do not reap.
|
||
}
|
||
if (pidIsAlive) {
|
||
results.push({ path: worktreePath, status: 'skipped', reason: 'pid_alive' });
|
||
continue;
|
||
}
|
||
|
||
// 4c. Ancestry guard: branch-tip must be reachable from main (fail closed).
|
||
let branchTip: string | undefined;
|
||
{
|
||
const headContent = readFileSafe(path.join(adminDir, 'HEAD'));
|
||
if (!headContent) {
|
||
results.push({ path: worktreePath, status: 'skipped', reason: 'cannot_resolve_branch_tip' });
|
||
continue;
|
||
}
|
||
const trimmed = headContent.trim();
|
||
if (trimmed.startsWith('ref: refs/heads/')) {
|
||
// Symbolic ref — resolve to commit SHA via git
|
||
const branchName = trimmed.slice('ref: refs/heads/'.length);
|
||
const resolveResult = execGit(['rev-parse', `refs/heads/${branchName}`], { cwd: repoRoot });
|
||
if (!gitResultOk(resolveResult)) {
|
||
results.push({ path: worktreePath, status: 'skipped', reason: 'cannot_resolve_branch_tip' });
|
||
continue;
|
||
}
|
||
branchTip = resolveResult.stdout.trim();
|
||
} else if (/^[0-9a-f]{40}$/i.test(trimmed)) {
|
||
// Detached HEAD — bare SHA
|
||
branchTip = trimmed;
|
||
} else {
|
||
results.push({ path: worktreePath, status: 'skipped', reason: 'cannot_resolve_branch_tip' });
|
||
continue;
|
||
}
|
||
}
|
||
|
||
const ancestorCheck = execGit(
|
||
['merge-base', '--is-ancestor', branchTip, mainTip],
|
||
{ cwd: repoRoot }
|
||
);
|
||
if (!gitResultOk(ancestorCheck)) {
|
||
results.push({ path: worktreePath, status: 'skipped', reason: 'branch_not_merged' });
|
||
continue;
|
||
}
|
||
|
||
// 4d. Reap: unlock → remove --force.
|
||
execGit(['worktree', 'unlock', gitKnownPath], { cwd: repoRoot }); // ignore failure (already unlocked)
|
||
const removeResult = execGit(['worktree', 'remove', gitKnownPath, '--force'], { cwd: repoRoot });
|
||
if (!gitResultOk(removeResult)) {
|
||
results.push({ path: worktreePath, status: 'skipped', reason: 'remove_failed' });
|
||
continue;
|
||
}
|
||
|
||
results.push({ path: gitKnownPath, status: 'reaped', reason: 'pid_dead_and_merged' });
|
||
}
|
||
|
||
// 5. Always prune stale metadata (handles missing-on-disk entries).
|
||
execGit(['worktree', 'prune'], { cwd: repoRoot });
|
||
|
||
return results;
|
||
}
|
||
|
||
// ─── reapOrphanWorktrees deps helpers ─────────────────────────────────────────
|
||
|
||
/**
|
||
* Liveness probe for a lock-owner PID — FAILS CLOSED (#3057).
|
||
*
|
||
* `ESRCH` ("no such process") is the ONLY outcome that proves the owner is
|
||
* gone. Every other failure means the probe could not determine liveness:
|
||
* - `EPERM` — the process exists, we just may not signal it;
|
||
* - `TypeError` / `ERR_INVALID_ARG_TYPE` — `process.kill` accepts a pid up
|
||
* to 2147483647 and REJECTS 2147483648 and above (measured), so a finite
|
||
* but out-of-range pid never reaches the OS at all;
|
||
* - anything else — an outcome this helper does not recognise.
|
||
*
|
||
* The return value feeds a DESTRUCTIVE decision (`git worktree remove
|
||
* --force`), so an unrecognised failure must never read as "dead". Hence the
|
||
* inversion: only ESRCH returns false; everything else returns true (alive,
|
||
* do not reap).
|
||
*/
|
||
function defaultIsPidAlive(pid: number): boolean {
|
||
try {
|
||
process.kill(pid, 0);
|
||
return true;
|
||
} catch (err) {
|
||
return (err as NodeJS.ErrnoException | null | undefined)?.code !== 'ESRCH';
|
||
}
|
||
}
|
||
|
||
function defaultReadDirSafe(dir: string): string[] | null {
|
||
try { return fs.readdirSync(dir); } catch { return null; }
|
||
}
|
||
|
||
function defaultReadFileSafe(file: string): string | null {
|
||
try { return fs.readFileSync(file, 'utf8'); } catch { return null; }
|
||
}
|
||
|
||
function defaultMtimeSafe(file: string): Date | null {
|
||
try { return fs.statSync(file).mtime; } catch { return null; }
|
||
}
|
||
|
||
function cmdWorktreeReapOrphans(cwd: string, deps: RecordAgentCmdDeps & WorktreeDeps = {}): void {
|
||
const write = deps.write || ((s: string) => process.stdout.write(s));
|
||
const writeErr = deps.writeErr || ((s: string) => process.stderr.write(s));
|
||
let result: ReapResult[];
|
||
try {
|
||
result = reapOrphanWorktrees(cwd, deps);
|
||
} catch (err) {
|
||
// Surface failure as a one-line warning; keep exit-zero so workflows don't break.
|
||
writeErr(`[gsd] worktree.reap-orphans failed: ${err && (err as Error).message ? (err as Error).message : String(err)}\n`);
|
||
result = [];
|
||
}
|
||
const skippedCount = result.filter((r) => r.status === 'skipped').length;
|
||
if (skippedCount > 0) {
|
||
// Surface skipped entries so operators are aware of unresolved orphans.
|
||
writeErr(`[gsd] worktree.reap-orphans: ${skippedCount} orphan(s) skipped (run with DEBUG=1 for details)\n`);
|
||
}
|
||
write(`${JSON.stringify({ ok: true, reaped: result.filter((r) => r.status === 'reaped').length, entries: result }, null, 2)}\n`);
|
||
}
|
||
|
||
// Unused exports kept for API compatibility
|
||
void parseWorktreeListPaths;
|
||
|
||
// ─── Moved from core.cjs (ADR-857 T0 #1268 rehome-core-squatters) ─────────────
|
||
|
||
/**
|
||
* Resolve the main worktree root when running inside a git worktree, along
|
||
* with the `reason` that produced it (#3050). Callers MUST inspect `reason`
|
||
* before trusting `root` unconditionally — a `reason` of `git_timed_out`
|
||
* means the git subprocess used to distinguish "linked worktree" from
|
||
* "not a repo" never completed, so `root` is a best-effort fallback (cwd),
|
||
* not a confirmed worktree root. Degrading to cwd rather than throwing is
|
||
* intentional (return degraded result on timeout; do not throw) — but the
|
||
* reason must still reach the caller so it can surface the risk instead of
|
||
* silently trusting the wrong root.
|
||
*/
|
||
function resolveWorktreeRoot(cwd: string, deps: WorktreeDeps = {}): { root: string; reason: string } {
|
||
const context = resolveWorktreeContext(cwd, {
|
||
existsSync: deps.existsSync || fs.existsSync,
|
||
execGit: deps.execGit,
|
||
});
|
||
return { root: context.effectiveRoot, reason: context.reason };
|
||
}
|
||
|
||
/**
|
||
* Clear stale worktree metadata references via `git worktree prune`.
|
||
*
|
||
* Destructive linked-worktree removal is disabled by default for safety.
|
||
*
|
||
* @param repoRoot - absolute path to the main (or any) worktree of
|
||
* the repository; used as `cwd` for git commands.
|
||
* @returns list of worktree paths that were removed (always empty)
|
||
*/
|
||
function pruneOrphanedWorktrees(repoRoot: string, deps: WorktreeDeps & { writeErr?: (s: string) => void } = {}): string[] {
|
||
const writeErr = deps.writeErr || ((s: string) => process.stderr.write(s));
|
||
try {
|
||
// `...deps` comes LAST deliberately: `parseWorktreePorcelain` is a declared
|
||
// member of WorktreeDeps and planWorktreePrune already reads
|
||
// `deps.parseWorktreePorcelain` before falling back to the module function,
|
||
// so a caller-supplied parser is an intended override, not an accident.
|
||
// The hard-coded key is only a restatement of that same default. Do not
|
||
// reorder the two — `tests/worktree-safety-reap.test.cjs` pins the override.
|
||
const plan = planWorktreePrune(
|
||
repoRoot,
|
||
{ allowDestructive: false },
|
||
{ parseWorktreePorcelain, ...deps }
|
||
);
|
||
const pruneResult = executeWorktreePrunePlan(plan, deps) as { timedOut?: boolean } | null;
|
||
if (pruneResult && pruneResult.timedOut) {
|
||
writeErr(
|
||
'[gsd-tools] WARNING: worktree health check degraded' +
|
||
' — git worktree prune timed out after 10s.' +
|
||
' Orphaned worktree metadata may remain until the next successful run.\n'
|
||
);
|
||
}
|
||
} catch { /* never crash the caller */ }
|
||
return [];
|
||
}
|
||
|
||
export = {
|
||
// Re-exported for the codebase-drift gate's --name-status parser (#4081):
|
||
// single owner of git C-quoted-path decoding.
|
||
decodeGitQuotedPath,
|
||
resolveWorktreeContext,
|
||
resolveWorktreeLinkage,
|
||
parseWorktreePorcelain,
|
||
planWorktreePrune,
|
||
executeWorktreePrunePlan,
|
||
listLinkedWorktreePaths,
|
||
inspectWorktreeHealth,
|
||
snapshotWorktreeInventory,
|
||
normalizeCleanupManifest,
|
||
planWorktreeWaveCleanup,
|
||
executeWorktreeWaveCleanupPlan,
|
||
WAVE_CLEANUP_WARNING,
|
||
planWaveScopeConformance,
|
||
isSummaryArtifactRelPath,
|
||
cmdWorktreeCleanupWave,
|
||
planWorktreeRecordAgent,
|
||
cmdWorktreeRecordAgent,
|
||
planWorktreeCreate,
|
||
executeWorktreeCreatePlan,
|
||
cmdWorktreeCreate,
|
||
reapOrphanWorktrees,
|
||
cmdWorktreeReapOrphans,
|
||
resolveWorktreeRoot,
|
||
pruneOrphanedWorktrees,
|
||
};
|