fix(#3050): consolidate the spawn-timeout predicate and propagate the unresolved-root reason (#3060)

* chore(#3050): changeset and review artifacts for the follow-up

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* chore(#3050): backfill changeset pr number

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-08-04 17:21:24 -04:00
committed by GitHub
parent 24066e536e
commit 4eb8e3648c
15 changed files with 530 additions and 253 deletions

View File

@@ -2,4 +2,4 @@
type: Fixed
pr: 3054
---
**Worktree safety gates no longer report success when they could not check** — a git command that timed out (a locked index, a stalled network mount) was treated the same as "this is not a git repository", so the base-divergence gate answered "safe to run parallel worktrees" without ever resolving the fork base, and worktree-context resolution silently fell back to the current directory. Both now degrade instead. Worktree creation also no longer skips its root-confinement check when the caller omits the root. (#3050)
**Worktree safety gates no longer report success when they could not check** — a git command that timed out (a locked index, a stalled network mount) was treated the same as "this is not a git repository", so the base-divergence gate answered "safe to run parallel worktrees" without ever resolving the fork base, and worktree-context resolution silently fell back to the current directory. The base-divergence gate now degrades to sequential execution instead of assuming safety. Worktree-context resolution still falls back to the current directory (there is no safer default), but now surfaces a loud warning that planning artifacts may be written to the wrong tree instead of silently trusting it. Worktree creation also no longer skips its root-confinement check when the caller omits the root. (#3050)

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3060
---
**Worktree timeout guards now fire on Windows** — the checks that detect a timed-out git command required the process to report a SIGTERM signal, which Node does not guarantee on every platform, so on Windows they could silently never fire and the guard they protect would pass without having verified anything. The check is now a single shared predicate keyed on the timeout code alone. (#3050)

View File

@@ -133,7 +133,7 @@ Diagnostic-output convention for the Resolution Provenance principle (ADR-1411 P
Leaf module owning the **out-of-band** half of ADR-1411's "corrupt is not absent" amendment (epic #1879). Where a read already returns a provenance envelope the cause is named in-band (`ConfigResolution.reason`, #1880); where a read returns a bare sentinel or a plausible default it cannot extend, the return value is preserved exactly and the cause is surfaced here instead. Interface: `UNUSABLE_REASON` (frozen reason enum — one entry per condition that has an emitting call site; adding a reason is three coordinated changes: enum + call site + the test locking `Object.keys(...).sort()`), `warnUnusableInput({reason, source?, content?}) → boolean` (returns whether this call actually wrote, so tests assert emission *counts* on a typed surface rather than scraping stderr), plus the `_resetUnusableInputWarningsForTests` / `_unusableInputWarningCountForTests` seams. Dedup key is `<normalized source>\0<reason>` — **both halves are load-bearing**: keying on the path alone would let a second, different fault on the same file go unreported, and keying on message prose would couple the guard to wording (ADR-1411 dedup clause). Path separators are deliberately **not** normalized: an earlier revision folded backslashes to `/` so two spellings of one Windows path would not double-report, but a backslash is a legal filename character on Linux and macOS, so that folding collapsed two genuinely distinct POSIX files onto one key and swallowed the second file's diagnostic. The trade is now one-directional — two spellings of one Windows path may report twice (noise), but two distinct files can never silence each other (lost signal), and ADR-1411 ranks the swallow the worse failure; ASCII control characters are stripped from the source before it is keyed or written, because the key separator is NUL (a crafted path could otherwise forge a collision) and because a path carrying ANSI escapes would replay into the operator's terminal. Callers with no path (in-memory content) fall back to a short content digest so *different* bad inputs still key differently. The diagnostic is **unconditional** — a deliberate divergence from ADR-227's never-implemented `GSD_DEBUG` opt-in, since "an opt-in nobody sets is indistinguishable from the silence #1879 is about" — and **never throws**: a failed stderr write is swallowed so a degraded read is never escalated into a crash. Adopted by `extractFrontmatter` (#1882, `frontmatter_unterminated`) and by `getRoadmapPhaseInternal`/`getMilestoneInfo` (#1881, `roadmap_unreadable`); `planning-workspace`/`verify` (#1883) follow. #1881 detects on the errno alone: `platformReadSync` returns `null` for ENOENT and its callers convert that to an errno-less Error, so reporting unconditionally in those catches would flag every project without a ROADMAP.md as corrupt. Exists as a shared seam rather than a per-site copy because four sites need identical behavior and four hand-rolled copies is `DEFECT.GENERATIVE-FIX` by construction. Source of truth: `gsd-core/bin/lib/unusable-input.cjs` (generated from `src/unusable-input.cts`). Test anchor: `tests/unusable-input.test.cjs`. See Resolution Provenance, Config Loader Module.
### Worktree Safety Policy Module
CJS Module owning worktree lifecycle safety policy for the GSD orchestration layer. Interface: `resolveWorktreeContext(cwd, deps) → WorktreeContext` (linked-worktree root mapping), `parseWorktreePorcelain(output) → WorktreeEntry[]` (porcelain parser, skips detached HEAD), `planWorktreePrune(repoRoot, opts, deps) → PrunePlan` (metadata-prune plan, never destructive by default), `executeWorktreePrunePlan(plan, deps) → PruneResult` (executes prune; degrades gracefully on git timeout), `listLinkedWorktreePaths(repoRoot, deps) → LinkedPathsResult`, `inspectWorktreeHealth(repoRoot, opts, deps) → HealthResult` (orphan + stale detection), `snapshotWorktreeInventory(repoRoot, opts, deps) → InventoryResult`, `planWorktreeWaveCleanup(repoRoot, manifest) → CleanupPlan` (manifest-scoped, fail-closed), `executeWorktreeWaveCleanupPlan(plan, deps) → CleanupResult`, `planWorktreeRecordAgent(manifestRaw, fields) → RecordAgentPlan` (write-strict per-agent manifest append; validates each field at write time via the same `normalizeCleanupManifestEntry` rules the reader enforces; fail-closed on a missing/garbled field or a duplicate `(worktree_path, branch)` the reader would dedup away), `cmdWorktreeRecordAgent(cwd, args, deps) → RecordAgentCmdResult` (thin deps-injectable IO wrapper for the `worktree record-agent` verb), `planWorktreeCreate(fields) → WorktreeCreatePlan` (write-strict `worktree create` planner — same missing-field-hint and `normalizeCleanupManifestEntry` validation as `planWorktreeRecordAgent`, pure/no-git), `executeWorktreeCreatePlan(plan, repoRoot, deps) → WorktreeCreateResult` (bounded `git rev-parse --verify` base check THEN `git worktree add -b <branch> <path> <base>`; fail-closed `base_unresolved`/`git_timeout`/`worktree_add_failed`; returns `cwd` — the working directory an executor spawn would use), `cmdWorktreeCreate(cwd, args, deps) → WorktreeCreateCmdResult` (CLI verb: plans, creates the worktree, then appends the manifest entry so it is immediately manageable by cleanup-wave/reap-orphans; dedupes by `(worktree_path, branch)`). #2584 ADR-1239 Codex-binding amendment, Phase 2: `worktree create` is the git-worktree-creation primitive for `dispatch.isolation: orchestrator-worktree` hosts — declared and testable but UNCONSUMED (no scheduler calls it yet; Phase 3 wires it). Source of truth: `gsd-core/bin/lib/worktree-safety.cjs`. Timeout path: all git subprocess calls are bounded; callers receive `ok:false, reason:'git_timed_out'` rather than a thrown exception. Test anchor: `tests/worktree-safety.test.cjs`. The `core.cjs` re-export spine was retired in epic #1267: this module absorbed the two thin compositional wrappers that squatted in Core — `resolveWorktreeRoot(cwd, deps)` (a projection over `resolveWorktreeContext`) and `pruneOrphanedWorktrees(...)` (sequences `planWorktreePrune` + `executeWorktreePrunePlan` with a timeout warning) — so callers reach this single worktree-lifecycle seam directly. `gitWorktreeInfoInternal` did NOT move here — worktree-info detection belongs to the Git Query Module.
CJS Module owning worktree lifecycle safety policy for the GSD orchestration layer. Interface: `resolveWorktreeContext(cwd, deps) → WorktreeContext` (linked-worktree root mapping), `parseWorktreePorcelain(output) → WorktreeEntry[]` (porcelain parser, skips detached HEAD), `planWorktreePrune(repoRoot, opts, deps) → PrunePlan` (metadata-prune plan, never destructive by default), `executeWorktreePrunePlan(plan, deps) → PruneResult` (executes prune; degrades gracefully on git timeout), `listLinkedWorktreePaths(repoRoot, deps) → LinkedPathsResult`, `inspectWorktreeHealth(repoRoot, opts, deps) → HealthResult` (orphan + stale detection), `snapshotWorktreeInventory(repoRoot, opts, deps) → InventoryResult`, `planWorktreeWaveCleanup(repoRoot, manifest) → CleanupPlan` (manifest-scoped, fail-closed), `executeWorktreeWaveCleanupPlan(plan, deps) → CleanupResult`, `planWorktreeRecordAgent(manifestRaw, fields) → RecordAgentPlan` (write-strict per-agent manifest append; validates each field at write time via the same `normalizeCleanupManifestEntry` rules the reader enforces; fail-closed on a missing/garbled field or a duplicate `(worktree_path, branch)` the reader would dedup away), `cmdWorktreeRecordAgent(cwd, args, deps) → RecordAgentCmdResult` (thin deps-injectable IO wrapper for the `worktree record-agent` verb), `planWorktreeCreate(fields) → WorktreeCreatePlan` (write-strict `worktree create` planner — same missing-field-hint and `normalizeCleanupManifestEntry` validation as `planWorktreeRecordAgent`, pure/no-git), `executeWorktreeCreatePlan(plan, repoRoot, deps) → WorktreeCreateResult` (bounded `git rev-parse --verify` base check THEN `git worktree add -b <branch> <path> <base>`; fail-closed `base_unresolved`/`git_timeout`/`worktree_add_failed`; returns `cwd` — the working directory an executor spawn would use), `cmdWorktreeCreate(cwd, args, deps) → WorktreeCreateCmdResult` (CLI verb: requires `--root` — confinement is mandatory, not opt-in; omitting it fails closed with `reason:'root_required'` before any git side effect, rather than silently creating an unconfined worktree (#3050); plans, creates the worktree, then appends the manifest entry so it is immediately manageable by cleanup-wave/reap-orphans; dedupes by `(worktree_path, branch)`). #2584 ADR-1239 Codex-binding amendment, Phase 2: `worktree create` is the git-worktree-creation primitive for `dispatch.isolation: orchestrator-worktree` hosts — declared and testable but UNCONSUMED (no scheduler calls it yet; Phase 3 wires it). Source of truth: `gsd-core/bin/lib/worktree-safety.cjs`. Timeout path: all git subprocess calls are bounded; callers receive `ok:false, reason:'git_timed_out'` rather than a thrown exception. Test anchor: `tests/worktree-safety.test.cjs`. The `core.cjs` re-export spine was retired in epic #1267: this module absorbed the two thin compositional wrappers that squatted in Core — `resolveWorktreeRoot(cwd, deps) → {root, reason}` (a projection over `resolveWorktreeContext`; returns the `reason` alongside `root` — a `git_timed_out` reason means `root` is a best-effort cwd fallback, not a confirmed resolution, and callers must surface that risk rather than trust it silently, #3050) and `pruneOrphanedWorktrees(...)` (sequences `planWorktreePrune` + `executeWorktreePrunePlan` with a timeout warning) — so callers reach this single worktree-lifecycle seam directly. `gitWorktreeInfoInternal` did NOT move here — worktree-info detection belongs to the Git Query Module.
### Worktree Lifecycle Module
Workflow contract seam covering agent worktree lifecycle orchestration rules. The `worktree_branch_check` block lives in one canonical fragment (`gsd-core/references/worktree-branch-check.md`) that `execute-phase.md`, `quick.md`, `diagnose-issues.md`, and `execute-plan.md` embed at dispatch. Key invariants: `worktree_branch_check` is **verify-only and fail-closed** — the orchestrator owns worktree lifecycle and base recovery, so the sub-agent holds no state-correction primitives; HEAD attachment verified via `git symbolic-ref`; positive allow-list `^worktree-agent-*` enforced; `git update-ref` on protected refs is prohibited; on base mismatch the sub-agent halts with `exit 42` and surfaces to the orchestrator (#48); the orchestrator runs a cwd-drift guard at `execute_waves` entry that resolves the worktree root and refuses drift into an agent worktree (#48); #1856: that refusal now also reports what the agent worktree holds — commits ahead of the resolved base and uncommitted files, both with true counts plus a truncation notice — and the commit/switch/merge-or-cherry-pick sequence to integrate them, because `re-run from the orchestrator worktree` alone silently meant abandoning work that lives only on the agent branch. The refusal condition and exit code are unchanged, and every added command is diagnostic and `|| true`-guarded so a failure degrades to the plain refusal; cleanup is manifest-scoped (`WAVE_WORKTREE_MANIFEST`) not global-discovery-based; worktree spawning is sequential (one `run_in_background` at a time to avoid `config.lock` contention). Test anchor: `tests/worktree.test.cjs`.
@@ -880,7 +880,7 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
## Shell Command Projection Module (expanded glossary entry, 2026-05-13)
Module owning all OS-facing I/O for the tool: runtime-aware command-text rendering (hook commands, PATH action lines, shim scripts), subprocess dispatch (execGit, execNpm, execTool, probeTty), and platform file I/O (platformWriteSync, platformReadSync, platformEnsureDir). Single seam for platform-conditional logic — one place to fix any shell or file write regression across Windows, macOS, and Linux. Lives in `gsd-core/bin/lib/shell-command-projection.cjs`. See ADR-0009 (superseded "does not execute" constraint) and ADR-0010 (superseded File Operation Engine).
Module owning all OS-facing I/O for the tool: runtime-aware command-text rendering (hook commands, PATH action lines, shim scripts), subprocess dispatch (execGit, execNpm, execTool, probeTty, isSpawnTimeout), and platform file I/O (platformWriteSync, platformReadSync, platformEnsureDir). Single seam for platform-conditional logic — one place to fix any shell or file write regression across Windows, macOS, and Linux. `isSpawnTimeout` is the single shared "did this subprocess time out" predicate (error.code==='ETIMEDOUT' only — cross-platform-safe; does not require signal==='SIGTERM'), consumed by worktree-safety.cts, worktree-base-ref.cts, commands.cts, and this module's own dispatchGsdCommand (#3050 — "Generative Fix Divergence"). Lives in `gsd-core/bin/lib/shell-command-projection.cjs`. See ADR-0009 (superseded "does not execute" constraint) and ADR-0010 (superseded File Operation Engine).
Invariants:
- Result shape: all exec* functions return `{ exitCode, stdout, stderr }`; never throw on non-zero exit code.

View File

@@ -719,10 +719,23 @@ node gsd-tools.cjs worktree set-baseref
| `head-matches-fork` | `false` | HEAD and `origin/HEAD` are the same commit |
| `head-diverged-from-fork` | `true` | Branch is ahead of or diverged from `origin/HEAD` |
| `fork-ref-unknown` | `true` | `origin/HEAD` could not be resolved |
| `no-head` | `false` | Not in a git repo (no `HEAD`) |
| `no-head` | `false` | Not in a git repo (no `HEAD`) — `git rev-parse HEAD` exited 128 (definitive), or exited 0 with empty stdout |
| `head-unresolvable` | `true` | `git rev-parse HEAD` did not return a definitive answer (timed out, `git` missing, or any other non-128 failure) — fails closed rather than being treated as `no-head` |
**`worktree set-baseref`** applies a no-clobber write of `worktree.baseRef:"head"` to `.claude/settings.local.json`. If the file already contains an explicit `baseRef` value other than `"head"`, the existing value is preserved and `skipped:"explicit-other"` is returned. Malformed JSON causes an error rather than a silent overwrite. Both fresh installs and upgrades of GSD Core run this automatically when `workflow.use_worktrees` is enabled (the default); the command is also available for manual use — for example, to apply the setting when worktrees were toggled on after installation, or to re-apply it after a settings change.
### Worktree creation
```bash
# Create an agent worktree and atomically record it in the wave cleanup manifest.
# Returns JSON: { ok, reason, entry, manifest_path } (exit 0), or
# { ok:false, reason, hint } with a non-zero exit on a rejected/failed create.
node gsd-tools.cjs worktree create \
--manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> --root <dir>
```
**`worktree create`** validates and records the manifest entry BEFORE running any git command, then runs `git worktree add` for the validated `{path, branch, base}`, and only on success finalizes the manifest write — a rejected entry or a failed `git worktree add` never leaves a partially-recorded manifest or an unmanifested worktree on disk. `--root` is **mandatory** (#3050): the fail-closed root-confinement check resolves `--path` and `--root` and rejects (`reason:"path_outside_root"`) unless `--path` resolves strictly inside `--root` — this closes a prior gap where an unconfined `--path` (no `--root` check at all) could point a spawned executor's worktree anywhere on the filesystem. Omitting `--root` fails closed with `reason:"root_required"` rather than silently skipping confinement. All other flags share `worktree record-agent`'s validation rules above (`--branch` namespace, non-empty/non-whitespace `--path`/`--branch`/`--base`, `--agent-id` required).
### Wave-manifest recording
The execute-phase orchestrator records each spawned executor's worktree identity into a wave cleanup manifest so the matching `cleanup-wave` reader can later merge and remove exactly those worktrees.

View File

@@ -3355,6 +3355,37 @@ function skipsRootResolution(command) {
return SKIP_ROOT_RESOLUTION.has(command);
}
/**
* Resolve the worktree root for a given cwd, warning to stderr when git
* could not determine it (reason 'git_timed_out') rather than silently
* trusting a best-effort fallback (#3050). Extracted from main() so it can
* be driven directly in tests via injected deps.
*
* @param {string} cwd
* @param {{ existsSync?: (p: string) => boolean, resolveWorktreeRoot?: (cwd: string) => { root: string, reason: string }, writeWarning?: (msg: string) => void }} [deps]
* @returns {string} resolved cwd
*/
function resolveMainWorktreeCwd(cwd, deps = {}) {
const existsSync = deps.existsSync || fs.existsSync;
const resolveWorktreeRoot = deps.resolveWorktreeRoot || require('./lib/worktree-safety.cjs').resolveWorktreeRoot;
const writeWarning = deps.writeWarning || ((msg) => process.stderr.write(msg));
if (existsSync(path.join(cwd, '.planning'))) {
return cwd;
}
const { root: worktreeRoot, reason: worktreeRootReason } = resolveWorktreeRoot(cwd);
if (worktreeRootReason === 'git_timed_out') {
writeWarning(
'WARNING: could not determine the git worktree root (git timed out). ' +
'Planning artifacts (STATE.md, ROADMAP.md, etc.) may be written to the ' +
`wrong tree — proceeding with "${worktreeRoot}" as a best-effort fallback. ` +
'Retry the command; if this persists, check for a stalled filesystem mount ' +
'or a stale git index lock (.git/index.lock) in this worktree.\n'
);
}
return worktreeRoot;
}
async function main() {
let args = process.argv.slice(2);
@@ -3412,13 +3443,7 @@ async function main() {
// Resolve worktree root: in a linked worktree, .planning/ lives in the main worktree.
// However, in monorepo worktrees where the subdirectory itself owns .planning/,
// skip worktree resolution — the CWD is already the correct project root.
const { resolveWorktreeRoot } = require('./lib/worktree-safety.cjs');
if (!fs.existsSync(path.join(cwd, '.planning'))) {
const worktreeRoot = resolveWorktreeRoot(cwd);
if (worktreeRoot !== cwd) {
cwd = worktreeRoot;
}
}
cwd = resolveMainWorktreeCwd(cwd);
// Optional workstream override for parallel milestone work.
// Priority: --ws flag > GSD_WORKSTREAM env var > session/shared pointer > null.
@@ -3678,5 +3703,6 @@ module.exports = {
HOST_COMMAND_ROUTERS,
TOP_LEVEL_USAGE,
skipsRootResolution,
resolveMainWorktreeCwd,
};

View File

@@ -8,7 +8,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { execGit, platformWriteSync, platformReadSync, platformEnsureDir } from './shell-command-projection.cjs';
import { execGit, platformWriteSync, platformReadSync, platformEnsureDir, isSpawnTimeout } from './shell-command-projection.cjs';
import { requireSafePath, sanitizeForDisplay } from './security.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import ioMod = require('./io.cjs');
@@ -923,11 +923,10 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
// exit is a real I/O failure, not a missing file.
const rmResult = execGit(['rm', '--cached', '--ignore-unmatch', file], { cwd });
if (rmResult.exitCode !== 0) {
const rmErr: NodeJS.ErrnoException | null = rmResult.error;
stagingFailures.push({
file,
error: rmResult.stderr || rmResult.stdout,
timed_out: rmResult.signal === 'SIGTERM' && rmErr?.code === 'ETIMEDOUT',
timed_out: isSpawnTimeout(rmResult),
});
}
} else {
@@ -938,17 +937,13 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
if (addResult.exitCode === 0) {
stagedPaths.push(file);
} else {
// `SpawnResultOutput.error` is typed `Error | null`; widen to the errno
// shape by ANNOTATION rather than assertion — `Error` is assignable to
// `NodeJS.ErrnoException` (its extra fields are optional), so an `as`
// cast here trips no-unnecessary-type-assertion.
const addErr: NodeJS.ErrnoException | null = addResult.error;
stagingFailures.push({
file,
error: addResult.stderr || addResult.stdout,
// The projection exposes a timeout distinctly (#2608 AC5); this is the
// same SIGTERM+ETIMEDOUT idiom worktree-safety.cts uses.
timed_out: addResult.signal === 'SIGTERM' && addErr?.code === 'ETIMEDOUT',
// The projection exposes a timeout distinctly (#2608 AC5); this uses
// the shared isSpawnTimeout predicate (shell-command-projection.cts)
// also used by worktree-safety.cts and worktree-base-ref.cts (#3050).
timed_out: isSpawnTimeout(addResult),
});
}
}
@@ -1127,11 +1122,10 @@ function cmdCommitToSubrepo(cwd: string, message: string | undefined, files: str
if (addResult.exitCode === 0) {
stagedRelPaths.push(relativePath);
} else {
const addErr: NodeJS.ErrnoException | null = addResult.error;
subStagingFailures.push({
file,
error: addResult.stderr || addResult.stdout,
timed_out: addResult.signal === 'SIGTERM' && addErr?.code === 'ETIMEDOUT',
timed_out: isSpawnTimeout(addResult),
});
}
}

View File

@@ -499,6 +499,27 @@ interface SpawnResultOutput {
error: Error | null;
}
/**
* Returns true when a spawn/exec result indicates the subprocess was killed
* by a timeout, i.e. it never completed and reported a real answer. This is
* the single shared definition of "did this subprocess time out" — worktree
* safety (src/worktree-safety.cts) and worktree base-ref detection
* (src/worktree-base-ref.cts) both call this instead of maintaining their
* own copies (#3050 — "Generative Fix Divergence").
*
* Only `error.code === 'ETIMEDOUT'` is checked. Node.js guarantees this
* cross-platform when `spawnSync`'s `timeout` option fires. The `signal ===
* 'SIGTERM'` check some earlier code paired with it is platform-fragile —
* Windows does not necessarily report SIGTERM the same way — and pairing it
* in as a REQUIRED conjunct risks a false NEGATIVE (a timeout that silently
* fails to trip the guard) on that platform. There is no false-positive risk
* from dropping it: an externally-delivered SIGTERM (not a timeout) leaves
* `error` null, so `error.code === 'ETIMEDOUT'` alone still won't match it.
*/
export function isSpawnTimeout(result: { error?: unknown }): boolean {
return (result.error as NodeJS.ErrnoException | null | undefined)?.code === 'ETIMEDOUT';
}
function _spawnResult(result: { error?: NodeJS.ErrnoException | null; status?: number | null; stdout?: Buffer | string | null; stderr?: Buffer | string | null; signal?: NodeJS.Signals | null }, program: string): SpawnResultOutput {
if (result.error && result.error.code === 'ENOENT') {
return { exitCode: 127, stdout: '', stderr: `${program}: not found`, signal: null, error: result.error };
@@ -615,9 +636,9 @@ export function resolveGsdToolsPath(): string {
* NEVER throws. Degrades to `{ ok:false, ... }` on:
* - a missing/invalid "family" (validated locally, no subprocess spawned)
* - ENOENT / a missing gsd-tools.cjs (via the injectable `gsdToolsPath`)
* - a wall-clock timeout (`timedOut:true`, mirroring the
* `signal === 'SIGTERM' && error.code === 'ETIMEDOUT'` idiom already used
* by worktree-safety.cts)
* - a wall-clock timeout (`timedOut:true`, via the shared `isSpawnTimeout`
* predicate defined above in this file — also used by worktree-safety.cts
* and worktree-base-ref.cts)
* - any other unanticipated throw from the underlying spawn (defensive
* try/catch — execTool itself is spawnSync-based and does not throw).
*/
@@ -674,16 +695,9 @@ export function dispatchGsdCommand({
};
}
// Mirrors the established `result.error && (result.error as
// NodeJS.ErrnoException).code === ...` idiom (graphify.cts, worktree-safety.cts):
// narrow away null via `!== null` FIRST, then cast — asserting `Error | null`
// to `NodeJS.ErrnoException | null` directly (paired with optional chaining)
// trips a typescript-eslint no-unnecessary-type-assertion false positive for
// this exact narrowing shape (all of ErrnoException's extra fields over Error
// are optional).
const timedOut = result.signal === 'SIGTERM'
&& result.error !== null
&& (result.error as NodeJS.ErrnoException).code === 'ETIMEDOUT';
// Delegates to the single shared predicate defined above in this file
// (#3050 — "Generative Fix Divergence") instead of a local inline copy.
const timedOut = isSpawnTimeout(result);
return {
ok: result.exitCode === 0 && !timedOut,

View File

@@ -13,7 +13,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { execGit as execGitSeam } from './shell-command-projection.cjs';
import { execGit as execGitSeam, isSpawnTimeout } from './shell-command-projection.cjs';
import { getGlobalConfigDir } from './runtime-homes.cjs';
// ─── Internal helpers ─────────────────────────────────────────────────────────
@@ -98,18 +98,19 @@ function buildMsgDiverged(headSha: string | null, forkRef: string | null, forkSh
const MSG_UNKNOWN = `⚠ Cannot determine the worktree fork base (origin/HEAD unresolved). Running this phase sequentially on the main working tree to avoid a base mismatch. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
const MSG_HEAD_UNRESOLVABLE = `⚠ Cannot determine the worktree base (git rev-parse HEAD timed out or could not complete). Running this phase sequentially on the main working tree to avoid an unverified base mismatch. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683, #3050.`;
const MSG_HEAD_UNRESOLVABLE = `⚠ Cannot determine the worktree base (git rev-parse HEAD did not return a definitive answer). Running this phase sequentially on the main working tree to avoid an unverified base mismatch. Note: worktree.baseRef:"head" would silence this check without verifying the base — it skips the comparison rather than resolving it. Retry; if it persists, check for a stalled filesystem mount or a stale git index lock (.git/index.lock). See #683, #3050.`;
/**
* Returns true when an execGit result indicates the subprocess was killed by
* a timeout (SIGTERM + ETIMEDOUT), mirroring the idiom already established in
* worktree-safety.cts's execGitDefault. A timeout means the command genuinely
* could not complete — it must never be treated the same as a clean non-zero
* exit (e.g. "not a git repository"), which DID complete and reported a real
* answer.
* a timeout. A timeout means the command genuinely could not complete — it
* must never be treated the same as a clean non-zero exit (e.g. "not a git
* repository"), which DID complete and reported a real answer.
*
* Delegates to the single shared predicate in shell-command-projection.cts
* (#3050 — "Generative Fix Divergence"); do not reimplement this locally.
*/
function isExecGitTimeout(result: { signal: string | null; error: unknown }): boolean {
return result.signal === 'SIGTERM' && (result.error as NodeJS.ErrnoException | null | undefined)?.code === 'ETIMEDOUT';
return isSpawnTimeout(result);
}
// ─── Exports ──────────────────────────────────────────────────────────────────
@@ -372,9 +373,29 @@ export function evaluateWorktreeBaseDegrade(deps?: {
return { shouldDegrade: true, reason: 'head-unresolvable', message: MSG_HEAD_UNRESOLVABLE, headSha: null, forkRef: null, forkSha: null };
}
const headStdout = headResult.stdout ? headResult.stdout.trim() : '';
if (headResult.exitCode !== 0 || !headStdout) {
// exit 128 is git's definitive "not a git repository" answer — it completed
// and genuinely reported no HEAD. Only this specific, confirmed outcome
// stays a benign non-degrade; every other non-success outcome below is
// NOT a definitive answer from git and must fail closed (#3050).
if (headResult.exitCode === 128) {
return { shouldDegrade: false, reason: 'no-head', message: null, headSha: null, forkRef: null, forkSha: null };
}
// Exit 0 with empty stdout is pinned as benign no-degrade by an existing
// regression guard (tests/worktree-base-ref.test.cjs — "git rev-parse HEAD
// returns empty stdout"). Left unchanged deliberately; flagged in the
// #3050 review for a product-intent call rather than silently flipped.
if (headResult.exitCode === 0 && !headStdout) {
return { shouldDegrade: false, reason: 'no-head', message: null, headSha: null, forkRef: null, forkSha: null };
}
if (headResult.exitCode !== 0) {
// Any other non-success outcome (e.g. exit 127 — git missing — or any
// other non-zero, non-128 exit) is not a definitive "not a repo" answer.
// Fail closed instead of silently treating it as benign.
// (`!headStdout` was previously OR'd in here but is unreachable: the
// exitCode===0 && !headStdout case is already handled above, and every
// other branch here has exitCode!==0 already true — #3050 review.)
return { shouldDegrade: true, reason: 'head-unresolvable', message: MSG_HEAD_UNRESOLVABLE, headSha: null, forkRef: null, forkSha: null };
}
const headSha = headStdout;
// c. Resolve fork base (what the harness forks 'fresh' worktrees from = origin/HEAD).

View File

@@ -10,7 +10,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { execGit as execGitSeam, posixNormalize } from './shell-command-projection.cjs';
import { execGit as execGitSeam, posixNormalize, isSpawnTimeout } 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
@@ -38,11 +38,13 @@ type ExecGitFn = (args: string[], opts?: { cwd?: string; timeout?: number }) =>
* (args, opts) shape — see worktree-safety-policy.test.cjs.
*
* Return shape: { exitCode, stdout, stderr, timedOut, error, signal }
* - timedOut: true when spawnSync reports SIGTERM + ETIMEDOUT
* - timedOut: derived via the shared `isSpawnTimeout` predicate
* (shell-command-projection.cts) — true when spawnSync's `error.code`
* is `ETIMEDOUT`; does not require `signal === 'SIGTERM'` (#3050).
*/
function execGitDefault(args: string[], opts: { cwd?: string; timeout?: number } = {}): GitResult {
const result = execGitSeam(args, { ...opts, timeout: opts.timeout ?? DEFAULT_GIT_TIMEOUT_MS });
const timedOut = result.signal === 'SIGTERM' && (result.error as NodeJS.ErrnoException)?.code === 'ETIMEDOUT';
const timedOut = isSpawnTimeout(result);
return { ...result, timedOut };
}
@@ -1800,15 +1802,22 @@ void parseWorktreeListPaths;
// ─── Moved from core.cjs (ADR-857 T0 #1268 rehome-core-squatters) ─────────────
/**
* Resolve the main worktree root when running inside a git worktree.
* In a linked worktree, .planning/ lives in the main worktree, not in the linked one.
* Returns the main worktree path, or cwd if not in a worktree.
* 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): string {
function resolveWorktreeRoot(cwd: string, deps: WorktreeDeps = {}): { root: string; reason: string } {
const context = resolveWorktreeContext(cwd, {
existsSync: fs.existsSync,
existsSync: deps.existsSync || fs.existsSync,
execGit: deps.execGit,
});
return context.effectiveRoot;
return { root: context.effectiveRoot, reason: context.reason };
}
/**

View File

@@ -3838,3 +3838,56 @@ describe('gsd-tools.cjs dispatch/help/skip-list parity (DEFECT.GENERATIVE-FIX, #
assert.equal(typeof gsdTools.skipsRootResolution, 'function');
});
});
// ─────────────────────────────────────────────────────────────────────────────
// resolveMainWorktreeCwd — #3050: gsd-tools must surface (not silently
// swallow) a git_timed_out worktree-root resolution, since writing planning
// artifacts to the wrong tree is the exact fail-open the issue names.
// ─────────────────────────────────────────────────────────────────────────────
describe('gsd-tools.cjs resolveMainWorktreeCwd (#3050)', () => {
const { resolveMainWorktreeCwd } = require('../gsd-core/bin/gsd-tools.cjs');
test('emits a WARNING to stderr and still resolves the fallback root on git_timed_out', () => {
const warnings = [];
const resolved = resolveMainWorktreeCwd('/repo/wt', {
existsSync: () => false,
resolveWorktreeRoot: () => ({ root: '/repo/wt', reason: 'git_timed_out' }),
writeWarning: (msg) => warnings.push(msg),
});
assert.equal(resolved, '/repo/wt');
assert.equal(warnings.length, 1, 'must emit exactly one warning on git_timed_out');
assert.match(warnings[0], /git timed out/i);
assert.match(warnings[0], /wrong tree/i);
});
test('does NOT warn on a benign reason (linked_worktree)', () => {
const warnings = [];
const resolved = resolveMainWorktreeCwd('/repo/wt', {
existsSync: () => false,
resolveWorktreeRoot: () => ({ root: '/repo', reason: 'linked_worktree' }),
writeWarning: (msg) => warnings.push(msg),
});
assert.equal(resolved, '/repo');
assert.deepEqual(warnings, [], 'must not warn for a benign, definitive resolution');
});
test('does NOT warn on a benign reason (not_git_repo)', () => {
const warnings = [];
const resolved = resolveMainWorktreeCwd('/repo/wt', {
existsSync: () => false,
resolveWorktreeRoot: () => ({ root: '/repo/wt', reason: 'not_git_repo' }),
writeWarning: (msg) => warnings.push(msg),
});
assert.equal(resolved, '/repo/wt');
assert.deepEqual(warnings, []);
});
test('short-circuits (never calls resolveWorktreeRoot) when .planning already exists in cwd', () => {
const resolved = resolveMainWorktreeCwd('/repo/wt', {
existsSync: () => true,
resolveWorktreeRoot: () => { throw new Error('must not be called when .planning exists locally'); },
writeWarning: () => { throw new Error('must not warn when short-circuited'); },
});
assert.equal(resolved, '/repo/wt');
});
});

View File

@@ -54,8 +54,13 @@ const LIB = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
* returning the parsed JSON result and the git argv list that was actually
* executed (so "git commit never ran" is asserted directly, not inferred).
*/
function commitWithFailingAdd({ cwd, files, failFor = [], stderr = 'fatal: injected staging failure', timeout = false, amend = false }) {
function commitWithFailingAdd({ cwd, files, failFor = [], stderr = 'fatal: injected staging failure', timeout = false, amend = false, gitVerb = 'add' }) {
const callsOut = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2608-')), 'calls.json');
// `timeout` is `false` | `true` (alias for `'posix'`) | `'posix'` | `'windows'` —
// #3050: the shared isSpawnTimeout predicate only requires `error.code ===
// 'ETIMEDOUT'`, NOT `signal === 'SIGTERM'` (Windows does not reliably report
// SIGTERM), so both shapes must be proven to still read as a timeout.
const timeoutShape = timeout === true ? 'posix' : timeout;
const script = `
const path = require('path');
const LIB = ${JSON.stringify(LIB)};
@@ -63,19 +68,27 @@ const projection = require(path.join(LIB, 'shell-command-projection.cjs'));
const { cmdCommit } = require(path.join(LIB, 'commands.cjs'));
const failFor = ${JSON.stringify(failFor)};
const stderrText = ${JSON.stringify(stderr)};
const timedOut = ${JSON.stringify(timeout)};
const timeoutShape = ${JSON.stringify(timeoutShape)};
const gitVerb = ${JSON.stringify(gitVerb)};
const real = projection.execGit;
const calls = [];
projection.execGit = (args, opts) => {
calls.push(args);
if (args[0] === 'add' && failFor.includes(args[args.length - 1])) {
if (timedOut) {
// The exact shape spawnSync produces on a timeout, which
if (args[0] === gitVerb && failFor.includes(args[args.length - 1])) {
if (timeoutShape === 'posix') {
// The exact shape spawnSync produces on a POSIX timeout, which
// shell-command-projection surfaces as signal + error.code.
const e = new Error('spawnSync git ETIMEDOUT');
e.code = 'ETIMEDOUT';
return { exitCode: 1, stdout: '', stderr: stderrText, signal: 'SIGTERM', error: e };
}
if (timeoutShape === 'windows') {
// Windows shape: spawnSync's timeout kill does not reliably report
// signal:'SIGTERM' — only error.code:'ETIMEDOUT' is guaranteed (#3050).
const e = new Error('spawnSync git ETIMEDOUT');
e.code = 'ETIMEDOUT';
return { exitCode: 1, stdout: '', stderr: stderrText, signal: null, error: e };
}
return { exitCode: 128, stdout: '', stderr: stderrText, signal: null, error: null };
}
return real(args, opts);
@@ -117,16 +130,29 @@ function committedFiles(cwd) {
* which carried the identical defect (failed `git add` dropped, commit proceeds
* with the subset that staged).
*/
function subrepoCommitWithFailingAdd({ cwd, files, failFor = [] }) {
function subrepoCommitWithFailingAdd({ cwd, files, failFor = [], timeout = false }) {
// See commitWithFailingAdd above for the timeoutShape rationale (#3050).
const timeoutShape = timeout === true ? 'posix' : timeout;
const script = `
const path = require('path');
const LIB = ${JSON.stringify(LIB)};
const projection = require(path.join(LIB, 'shell-command-projection.cjs'));
const { cmdCommitToSubrepo } = require(path.join(LIB, 'commands.cjs'));
const failFor = ${JSON.stringify(failFor)};
const timeoutShape = ${JSON.stringify(timeoutShape)};
const real = projection.execGit;
projection.execGit = (args, opts) => {
if (args[0] === 'add' && failFor.includes(args[args.length - 1])) {
if (timeoutShape === 'posix') {
const e = new Error('spawnSync git ETIMEDOUT');
e.code = 'ETIMEDOUT';
return { exitCode: 1, stdout: '', stderr: 'fatal: injected subrepo staging failure', signal: 'SIGTERM', error: e };
}
if (timeoutShape === 'windows') {
const e = new Error('spawnSync git ETIMEDOUT');
e.code = 'ETIMEDOUT';
return { exitCode: 1, stdout: '', stderr: 'fatal: injected subrepo staging failure', signal: null, error: e };
}
return { exitCode: 128, stdout: '', stderr: 'fatal: injected subrepo staging failure', signal: null, error: null };
}
return real(args, opts);
@@ -309,6 +335,51 @@ describe('#2608: commit --files fails closed when git add fails', () => {
assert.equal(result.failures[0].timed_out, true);
});
// #3050 item 4: this site (commands.cts's `git add` staging loop) now routes
// through the shared isSpawnTimeout predicate, which drops the `signal ===
// 'SIGTERM'` requirement — a Windows-shaped timeout (no signal, only
// error.code === 'ETIMEDOUT') must still be detected.
test('a staging timeout is reported as staging_timeout even without SIGTERM (Windows shape, #3050)', () => {
const { result } = commitWithFailingAdd({
cwd: tmpDir,
files: ['.planning/ARCHITECTURE.md'],
failFor: ['.planning/ARCHITECTURE.md'],
stderr: '',
timeout: 'windows',
});
assert.equal(result.reason, 'staging_timeout');
assert.equal(result.failures[0].timed_out, true);
});
// #3050 item 4: the `git rm --cached` branch of the same staging loop (the
// default-mode "stage the deletion" path, distinct from `git add` above)
// carries its own inline copy of the timeout check pre-fix. Drive it
// directly: default mode (no explicit --files) stages '.planning/', and
// when that path is absent on disk the loop takes the `git rm --cached`
// branch instead of `git add`.
test('a `git rm --cached` timeout in default mode is reported as staging_timeout, POSIX and Windows shapes (#3050)', () => {
// Mid-test fixture mutation (simulating an absent '.planning/' on disk),
// not teardown; the outer afterEach still runs helpers.cleanup(tmpDir) on
// the whole tmpDir.
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- see comment above
fs.rmSync(path.join(tmpDir, '.planning'), { recursive: true, force: true });
for (const shape of ['posix', 'windows']) {
const { result } = commitWithFailingAdd({
cwd: tmpDir,
files: undefined,
failFor: ['.planning/'],
gitVerb: 'rm',
stderr: '',
timeout: shape,
});
assert.equal(result.reason, 'staging_timeout', `shape=${shape}`);
assert.equal(result.failures[0].timed_out, true, `shape=${shape}`);
}
});
test('an ordinary non-zero git add is NOT reported as a timeout', () => {
// Boundary: the timeout carve-out must not swallow the ordinary case.
const { result } = commitWithFailingAdd({

View File

@@ -1,9 +1,10 @@
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const { describe, test, beforeEach, afterEach, mock } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const fs = require('node:fs');
const childProcess = require('node:child_process');
const {
execGit,
@@ -113,7 +114,7 @@ describe('dispatchGsdCommand', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempDir(); });
afterEach(() => { cleanup(tmpDir); });
afterEach(() => { cleanup(tmpDir); mock.restoreAll(); });
test('resolveGsdToolsPath resolves to the real gsd-tools.cjs on disk', () => {
const toolsPath = resolveGsdToolsPath();
@@ -171,6 +172,26 @@ describe('dispatchGsdCommand', () => {
assert.equal(result.timedOut, true);
});
});
// #3050 item 4: this site (dispatchGsdCommand's `timedOut` derivation) now
// routes through the shared isSpawnTimeout predicate, which drops the
// `signal === 'SIGTERM'` requirement — a Windows-shaped timeout (no signal,
// only error.code === 'ETIMEDOUT') must still be detected. The real-timeout
// test above only exercises whatever shape THIS OS's spawnSync happens to
// produce (SIGTERM on POSIX); mocking spawnSync proves the Windows shape too.
test('Windows-shaped timeout (no signal, error.code ETIMEDOUT) is still reported as timedOut:true (#3050)', () => {
mock.method(childProcess, 'spawnSync', () => ({
status: null,
stdout: '',
stderr: '',
signal: null,
error: Object.assign(new Error('spawnSync ETIMEDOUT'), { code: 'ETIMEDOUT' }),
}));
const result = dispatchGsdCommand({ family: 'progress', subcommand: 'json', cwd: tmpDir });
assert.equal(result.ok, false);
assert.equal(result.timedOut, true);
});
});
// ─── probeTty ────────────────────────────────────────────────────────────────

View File

@@ -300,6 +300,86 @@ describe('evaluateWorktreeBaseDegrade', () => {
assert.strictEqual(result.reason, 'no-head');
});
// ─── #3050: fail-closed matrix for git rev-parse HEAD outcomes ─────────────
// DECIDED RULE: degrade UNLESS git completed and gave a definitive answer.
// - timeout → degrade, reason 'head-unresolvable'
// - exitCode === 128 → NO degrade, reason 'no-head' (unchanged)
// - exit 0 with non-empty sha → proceed (unchanged)
// - anything else (127, other → degrade, reason 'head-unresolvable'
// non-zero, exit 0 empty stdout
// is pinned separately above)
test('git rev-parse HEAD TIMES OUT → shouldDegrade:true, reason "head-unresolvable" (#3050)', () => {
const timedOutErr = new Error('spawnSync git ETIMEDOUT');
timedOutErr.code = 'ETIMEDOUT';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: null, stdout: '', stderr: '', signal: 'SIGTERM', error: timedOutErr },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-unresolvable');
assert.ok(result.message, 'a fail-closed degrade must carry a non-null explanatory message');
assert.strictEqual(result.headSha, null);
});
test('cross-platform: timeout WITHOUT signal set (Windows shape) still degrades (#3050)', () => {
// Node.js guarantees error.code === 'ETIMEDOUT' cross-platform when the
// spawnSync `timeout` option fires; `signal` reporting is the
// platform-fragile half and must not be required to detect a timeout.
const timedOutErr = new Error('spawnSync git ETIMEDOUT');
timedOutErr.code = 'ETIMEDOUT';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: null, stdout: '', stderr: '', signal: null, error: timedOutErr },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-unresolvable');
});
test('git missing (exitCode 127) → degrade, reason "head-unresolvable" (#3050)', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 127, stdout: '', stderr: 'git: not found', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-unresolvable');
});
// Boundary coverage: 128 is the ONLY benign non-zero exit (definitive "not a
// git repository"). 129 (limit+1) must NOT be swept into that carve-out.
test('exitCode 129 (limit+1 boundary, just past the 128 carve-out) → degrade, reason "head-unresolvable" (#3050)', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 129, stdout: '', stderr: 'fatal: something else', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-unresolvable');
});
test('other non-zero, non-128 exit → degrade, reason "head-unresolvable" (#3050)', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 1, stdout: '', stderr: 'fatal: something else', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-unresolvable');
});
test('exitCode 128 ("not a git repository") still does NOT degrade (#3050 regression guard)', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'no-head');
});
test('HEAD == origin/HEAD → no degrade, reason head-matches-fork', () => {
const HEAD_SHA = 'aabbccdd11223344aabbccdd11223344aabbccdd';
const result = evaluateWorktreeBaseDegrade({

View File

@@ -1,186 +0,0 @@
'use strict';
/**
* Worktree fail-open-guards regression tests (#3050).
*
* Covers three defects where a git TIMEOUT was silently collapsed into a
* benign "not a git repo" / "current directory" outcome instead of failing
* closed:
*
* 1. evaluateWorktreeBaseDegrade (worktree-base-ref.cjs) — a `rev-parse
* HEAD` timeout must degrade (shouldDegrade:true), not be treated the
* same as "not a git repository".
* 2. resolveWorktreeContext (worktree-safety.cjs) — a `rev-parse
* --git-dir`/`--git-common-dir` timeout must be distinguishable from
* the genuine not-a-git-repo case, not silently fall back to
* current_directory with an identical reason.
* 3. cmdWorktreeCreate (worktree-safety.cjs) — root confinement must not
* be skippable by omitting `--root`; it must fail closed.
*
* All tests drive the real production functions through the injected
* execGit/deps seam — no real filesystem or git subprocess is touched.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const BASE_REF_MODULE_PATH = path.join(
__dirname, '..', 'gsd-core', 'bin', 'lib', 'worktree-base-ref.cjs'
);
const WORKTREE_SAFETY_MODULE_PATH = path.join(
__dirname, '..', 'gsd-core', 'bin', 'lib', 'worktree-safety.cjs'
);
const { evaluateWorktreeBaseDegrade } = require(BASE_REF_MODULE_PATH);
const {
resolveWorktreeContext,
cmdWorktreeCreate,
} = require(WORKTREE_SAFETY_MODULE_PATH);
// ─── helpers ────────────────────────────────────────────────────────────────
/**
* Build a mock execGit result mirroring the real shape spawnSync-derived
* results carry: {exitCode, stdout, stderr, signal, error}.
*/
function gitResult({ exitCode = 0, stdout = '', stderr = '', signal = null, error = null } = {}) {
return { exitCode, stdout, stderr, signal, error };
}
/** A timed-out git result: SIGTERM + ETIMEDOUT, per the established idiom. */
function timedOutResult() {
const err = new Error('spawnSync git ETIMEDOUT');
err.code = 'ETIMEDOUT';
return gitResult({ exitCode: null, signal: 'SIGTERM', error: err });
}
/** A genuine "not a git repository" result (git exits 128). */
function notAGitRepoResult() {
return gitResult({ exitCode: 128, stderr: 'fatal: not a git repository (or any of the parent directories): .git' });
}
// ─── DEFECT 1: evaluateWorktreeBaseDegrade must fail closed on HEAD timeout ──
describe('evaluateWorktreeBaseDegrade — HEAD resolution timeout (#3050 DEFECT 1)', () => {
test('rev-parse HEAD TIMES OUT → shouldDegrade:true (fail closed, distinct reason)', () => {
const execGit = (args) => {
assert.deepStrictEqual(args, ['rev-parse', 'HEAD']);
return timedOutResult();
};
const result = evaluateWorktreeBaseDegrade({ execGit, effectiveBaseRef: null, cwd: '/repo' });
assert.strictEqual(result.shouldDegrade, true);
assert.notStrictEqual(result.reason, 'no-head');
assert.ok(result.message, 'a fail-closed degrade must carry a non-null explanatory message');
assert.strictEqual(result.headSha, null);
});
test('genuine not-a-git-repo (exit 128) → shouldDegrade:false, reason "no-head" (regression guard — unchanged)', () => {
const execGit = () => notAGitRepoResult();
const result = evaluateWorktreeBaseDegrade({ execGit, effectiveBaseRef: null, cwd: '/repo' });
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'no-head');
assert.strictEqual(result.message, null);
});
test('HEAD resolves but fork ref is unresolvable → reason "fork-ref-unknown" (regression guard — unchanged)', () => {
const execGit = (args) => {
if (args[0] === 'rev-parse' && args[1] === 'HEAD') return gitResult({ stdout: 'aaaa111\n' });
// origin/HEAD direct rev-parse and symbolic-ref fallback both fail.
return gitResult({ exitCode: 1 });
};
const result = evaluateWorktreeBaseDegrade({ execGit, effectiveBaseRef: null, cwd: '/repo' });
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'fork-ref-unknown');
assert.strictEqual(result.headSha, 'aaaa111');
});
test('HEAD == fork sha → shouldDegrade:false, reason "head-matches-fork" (regression guard — unchanged)', () => {
const sha = 'deadbeef00000000000000000000000000000000';
const execGit = (args) => {
if (args[0] === 'rev-parse' && args[1] === 'HEAD') return gitResult({ stdout: `${sha}\n` });
if (args.includes('origin/HEAD')) return gitResult({ stdout: `${sha}\n` });
return gitResult({ exitCode: 1 });
};
const result = evaluateWorktreeBaseDegrade({ execGit, effectiveBaseRef: null, cwd: '/repo' });
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'head-matches-fork');
});
test('HEAD diverged from fork → shouldDegrade:true, reason "head-diverged-from-fork" (regression guard — unchanged)', () => {
const headSha = 'aaaaaaaa00000000000000000000000000000000';
const forkSha = 'bbbbbbbb00000000000000000000000000000000';
const execGit = (args) => {
if (args[0] === 'rev-parse' && args[1] === 'HEAD') return gitResult({ stdout: `${headSha}\n` });
if (args.includes('origin/HEAD')) return gitResult({ stdout: `${forkSha}\n` });
return gitResult({ exitCode: 1 });
};
const result = evaluateWorktreeBaseDegrade({ execGit, effectiveBaseRef: null, cwd: '/repo' });
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-diverged-from-fork');
});
test('baseRef:"head" short-circuits before any git call → shouldDegrade:false (regression guard — unchanged)', () => {
const execGit = () => { throw new Error('execGit must not be called when baseRef is "head"'); };
const result = evaluateWorktreeBaseDegrade({ execGit, effectiveBaseRef: 'head', cwd: '/repo' });
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'baseref-head');
});
});
// ─── DEFECT 2: resolveWorktreeContext must distinguish timeout from not-a-repo ──
describe('resolveWorktreeContext — git-dir resolution timeout (#3050 DEFECT 2)', () => {
test('git rev-parse --git-dir TIMES OUT → reason is distinguishable from not_git_repo', () => {
const execGit = (args) => {
if (args.includes('--git-dir')) return { ...timedOutResult(), timedOut: true };
return { ...notAGitRepoResult(), timedOut: false };
};
const context = resolveWorktreeContext('/repo', { execGit, existsSync: () => false });
assert.notStrictEqual(context.reason, 'not_git_repo');
});
test('genuine not-a-git-repo (both calls exit 128, no timeout) → reason "not_git_repo" (regression guard — unchanged)', () => {
const execGit = () => ({ ...notAGitRepoResult(), timedOut: false });
const context = resolveWorktreeContext('/repo', { execGit, existsSync: () => false });
assert.strictEqual(context.effectiveRoot, '/repo');
assert.strictEqual(context.mode, 'current_directory');
assert.strictEqual(context.reason, 'not_git_repo');
});
});
// ─── DEFECT 3: worktree create root confinement must not be skippable ───────
describe('cmdWorktreeCreate — root confinement is mandatory (#3050 DEFECT 3)', () => {
// process.exitCode is global; cmdWorktreeCreate sets it as a side effect on
// its failure paths (it doubles as the CLI entry point). Calling it directly
// in-process — rather than through a spawned subprocess — means that side
// effect leaks into THIS test file's own process.exitCode, which makes the
// whole file exit non-zero even though every assertion passes (mirrors the
// documented hazard + `withExitCode` guard in tests/worktree-safety.test.cjs
// around cmdWorktreeRecordAgent). Save/restore it so this test can assert
// on the failure path without poisoning the file's own exit status.
function withExitCode(fn) {
const saved = process.exitCode;
try { return fn(); } finally { process.exitCode = saved; }
}
test('omitting --root fails closed instead of silently skipping confinement', () => {
const deps = {
readFile: () => JSON.stringify({ orchestrator_root: '/repo', worktrees: [] }),
writeFile: () => { throw new Error('writeFile must not be called — confinement must fail before any write'); },
execGit: () => { throw new Error('execGit must not be called — confinement must fail before any git side effect'); },
write: () => {},
writeErr: () => {},
};
const result = withExitCode(() => cmdWorktreeCreate('/repo', [
'--manifest', 'manifest.json',
'--agent-id', 'agent-1',
'--path', '/repo/.claude/worktrees/agent-1',
'--branch', 'worktree-agent-agent-1',
'--base', 'abc1234',
], deps));
assert.strictEqual(result.ok, false);
assert.notStrictEqual(result.reason, 'created');
});
});

View File

@@ -15,9 +15,10 @@
* - tests/bug-3384-worktree-cleanup-manifest.test.cjs (manifest-scoped cleanup module)
*/
const { describe, test } = require('node:test');
const { describe, test, afterEach, mock } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const childProcess = require('node:child_process');
const fc = require('fast-check');
const { createTempGitProject, createTempDir, cleanup } = require('./helpers.cjs');
@@ -140,6 +141,149 @@ describe('resolveWorktreeContext', () => {
'must return effectiveRoot string even on timeout'
);
});
// ─── #3050 DEFECT 2: timeout must be distinguishable from not_git_repo ─────
test('git rev-parse --git-dir/--git-common-dir TIMES OUT → reason "git_timed_out" (#3050)', () => {
const execGit = (args) => {
if (args.includes('--git-dir')) return { ...makeTimeoutStub()(args), timedOut: true };
return { exitCode: 128, stdout: '', stderr: 'fatal: not a git repository', timedOut: false };
};
const context = resolveWorktreeContext('/repo', { execGit, existsSync: () => false });
assert.strictEqual(context.reason, 'git_timed_out');
assert.notStrictEqual(context.reason, 'not_git_repo');
assert.strictEqual(context.effectiveRoot, '/repo');
});
// ─── #3050 item 7: drive the REAL spawn seam, not a hand-set execGit stub ──
// Every test above injects deps.execGit with a hand-set `timedOut`, so the
// production execGitDefault → shell-command-projection's execGit →
// isSpawnTimeout chain is never actually exercised, and a Windows-shaped
// timeout (spawnSync reports no `signal`, only `error.code === 'ETIMEDOUT'`)
// is unexercised on this half. This test omits deps.execGit entirely so
// resolveWorktreeContext falls through to the real execGitDefault, and
// mocks node:child_process.spawnSync — the actual primitive
// shell-command-projection.cjs's execGit wraps — to return that exact
// Windows shape.
describe('execGitDefault (real spawn seam)', () => {
afterEach(() => {
mock.restoreAll();
});
test('Windows-shaped timeout (no signal, error.code ETIMEDOUT) is detected as a real timeout (#3050)', () => {
mock.method(childProcess, 'spawnSync', () => ({
status: null,
stdout: '',
stderr: '',
signal: null,
error: Object.assign(new Error('spawnSync git ETIMEDOUT'), { code: 'ETIMEDOUT' }),
}));
const context = resolveWorktreeContext('/repo', { existsSync: () => false });
assert.strictEqual(context.reason, 'git_timed_out');
assert.strictEqual(context.effectiveRoot, '/repo');
});
test('POSIX-shaped timeout (SIGTERM + error.code ETIMEDOUT) is also detected as a real timeout', () => {
mock.method(childProcess, 'spawnSync', () => ({
status: null,
stdout: '',
stderr: '',
signal: 'SIGTERM',
error: Object.assign(new Error('spawnSync git ETIMEDOUT'), { code: 'ETIMEDOUT' }),
}));
const context = resolveWorktreeContext('/repo', { existsSync: () => false });
assert.strictEqual(context.reason, 'git_timed_out');
});
test('externally-delivered SIGTERM with no ETIMEDOUT error is NOT reported as a timeout', () => {
// Boundary: the timeout carve-out must not swallow a plain non-zero exit
// that merely happens to carry a signal, absent an ETIMEDOUT error.
mock.method(childProcess, 'spawnSync', () => ({
status: null,
stdout: '',
stderr: 'fatal: not a git repository',
signal: 'SIGTERM',
error: null,
}));
const context = resolveWorktreeContext('/repo', { existsSync: () => false });
assert.notStrictEqual(context.reason, 'git_timed_out');
});
});
});
// ─── #3050 item 4: shared timeout predicate — single source, no divergence ──
// worktree-safety.cjs's execGitDefault and worktree-base-ref.cjs's
// isExecGitTimeout both now delegate to shell-command-projection.cjs's
// isSpawnTimeout. This describe block asserts the parity half of that claim
// for worktree-base-ref's PUBLIC evaluateWorktreeBaseDegrade — driving it
// against the same synthetic spawn results the shared predicate is tested
// with directly, so a future edit that reintroduces a local, diverging copy
// of the timeout check in worktree-base-ref.cts will make this test fail
// rather than silently drift. (worktree-safety.cjs's own half of this parity
// — execGitDefault via resolveWorktreeContext — is covered separately above,
// by "execGitDefault (real spawn seam)", which drives the real
// node:child_process.spawnSync primitive rather than a synthetic result.)
describe('shared isSpawnTimeout predicate — parity for worktree-base-ref evaluateWorktreeBaseDegrade (#3050)', () => {
const { isSpawnTimeout } = require(path.join(
__dirname, '..', 'gsd-core', 'bin', 'lib', 'shell-command-projection.cjs'
));
const { evaluateWorktreeBaseDegrade } = require(path.join(
__dirname, '..', 'gsd-core', 'bin', 'lib', 'worktree-base-ref.cjs'
));
const cases = [
{
name: 'SIGTERM + ETIMEDOUT (POSIX shape)',
result: { signal: 'SIGTERM', error: Object.assign(new Error('x'), { code: 'ETIMEDOUT' }) },
expectTimeout: true,
},
{
name: 'no signal + ETIMEDOUT (Windows shape)',
result: { signal: null, error: Object.assign(new Error('x'), { code: 'ETIMEDOUT' }) },
expectTimeout: true,
},
{
name: 'externally-delivered SIGTERM, no error (not a timeout)',
result: { signal: 'SIGTERM', error: null },
expectTimeout: false,
},
{
name: 'clean non-zero exit, no signal, no error',
result: { signal: null, error: null },
expectTimeout: false,
},
];
for (const { name, result, expectTimeout } of cases) {
test(`isSpawnTimeout(${name}) === ${expectTimeout}, and evaluateWorktreeBaseDegrade agrees`, () => {
assert.strictEqual(isSpawnTimeout(result), expectTimeout);
// exitCode 128 ("not a git repository") is git's own definitive,
// completed answer — the ONLY non-timeout, non-success outcome that
// does not degrade. Pairing it with each non-timeout signal/error
// combination means: if isExecGitTimeout ever mis-classifies one of
// these as a timeout, this assertion flips from 'no-head' (no
// degrade) to 'head-unresolvable' (degrade) and the test fails —
// a real behavioral divergence signal, not a same-reason coincidence.
const execGit = () => ({
exitCode: expectTimeout ? null : 128,
stdout: '',
stderr: '',
signal: result.signal,
error: result.error,
});
const degradeResult = evaluateWorktreeBaseDegrade({ execGit, effectiveBaseRef: null, cwd: '/repo' });
if (expectTimeout) {
assert.strictEqual(degradeResult.shouldDegrade, true);
assert.strictEqual(degradeResult.reason, 'head-unresolvable');
} else {
assert.strictEqual(degradeResult.shouldDegrade, false);
assert.strictEqual(degradeResult.reason, 'no-head');
}
});
}
});
// ─── parseWorktreePorcelain ───────────────────────────────────────────────────
@@ -3122,12 +3266,24 @@ describe('worktree-safety: resolveWorktreeRoot and pruneOrphanedWorktrees reloca
describe('worktree-safety: resolveWorktreeRoot behaviour', () => {
const worktreeSafety = require(WORKTREE_SAFETY_PATH);
test('resolveWorktreeRoot(createTempGitProject()) returns a non-empty string', (t) => {
test('resolveWorktreeRoot(createTempGitProject()) returns {root, reason} with a non-empty root', (t) => {
const dir = createTempGitProject('gsd-wt-root-');
t.after(() => cleanup(dir));
const result = worktreeSafety.resolveWorktreeRoot(dir);
assert.ok(typeof result === 'string' && result.length > 0,
`Expected non-empty string, got: ${JSON.stringify(result)}`);
assert.ok(result && typeof result === 'object', 'must return an object, not a bare string');
assert.ok(typeof result.root === 'string' && result.root.length > 0,
`Expected non-empty root string, got: ${JSON.stringify(result)}`);
assert.ok(typeof result.reason === 'string' && result.reason.length > 0,
`Expected non-empty reason string, got: ${JSON.stringify(result)}`);
});
test('resolveWorktreeRoot propagates git_timed_out via the injected execGit seam (#3050)', () => {
const result = worktreeSafety.resolveWorktreeRoot('/repo/wt', {
existsSync: () => false,
execGit: makeTimeoutStub(),
});
assert.strictEqual(result.reason, 'git_timed_out');
assert.strictEqual(result.root, '/repo/wt');
});
});