Files
msd-core/src/worktree-base-ref.cts
Tom Boucher cf8bd3cd5e fix(#683): auto-degrade phase execution to sequential on worktree base mismatch (#749)
* fix(#683): auto-degrade phase execution to sequential on worktree base mismatch

Claude Code forks worktree-isolated executors off the repository default
branch (origin/HEAD), not the orchestrator's HEAD. Running /gsd-execute-phase
on a branch diverged from the default (unmerged milestone/feature branch) left
every executor without the phase's plan files and tripped the
worktree-branch-check guard with `exit 42` — 100% reproducible, all OSes.

- New module src/worktree-base-ref.cts: HEAD-vs-fork-base drift detection
  (origin/HEAD with symbolic-ref fallback) and no-clobber worktree.baseRef
  management, exposed as `worktree base-check` / `worktree set-baseref`.
- execute-phase.md: pre-dispatch, for Claude Code with worktrees enabled,
  auto-degrades the run to sequential on the main tree when a base mismatch
  is detected, recommending worktree.baseRef:"head". The exit-42 guard stays
  as a backstop.
- Installer: fresh local Claude installs set worktree.baseRef:"head" in
  .claude/settings.local.json (no-clobber, respecting an explicit shared
  settings.json value); upgrades print an opt-in notice pointing at
  `gsd-tools worktree set-baseref`.
- Docs: how-to guide, CLI/config reference, planning-config cross-ref.

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

* fix(#683): auto-apply worktree.baseRef on upgrade; gate fresh+upgrade on use_worktrees

Per maintainer direction: on a local Claude Code UPGRADE, set
worktree.baseRef:"head" automatically (no opt-in notice) when the project's
workflow.use_worktrees is enabled, instead of merely printing a remediation
notice. For consistency the FRESH path is now gated the same way: both paths
compute worktrees-enabled once (bounded walk-up read of .planning/config.json,
default enabled unless workflow.use_worktrees === false) and apply the
no-clobber baseRef only when enabled — never overwriting an explicit value in
settings.local.json or a shared settings.json. gsd-tools worktree set-baseref
remains for manual use. Docs + changeset updated; tests hardened (file-exists
assertions, fresh+disabled case, upgrade idempotency).

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

* fix(#683): measure workflow byte-budget on LF, fixing Windows-only CI failure

The workflow-size-budget test failed only on Windows: git checks out the .md
files as CRLF (no eol=lf in .gitattributes) and byteCount used
fs.statSync().size (raw on-disk bytes), counting an extra \r per line. That
inflated execute-phase.md — the XL high-water-mark file pinned near its ceiling
by the tighten-only ratchet — from 88492 LF bytes to ~90245 on Windows, over
the 90000 XL ceiling, while passing on the LF-checkout Mac/Linux runners.

The ceilings are explicitly "calibrated against raw `wc -c`" on an LF checkout,
so the measurement should be LF-based on every platform. byteCount now reads the
file and counts Buffer.byteLength after stripping CR, making the budget
platform-independent (a no-op on LF checkouts; verified statSync === normalized
for all 88 workflow files). No ceilings changed. Added a regression test
asserting CRLF and LF content of the same file count identically.

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

* fix(#683): make worktree-base-ref test path mocks Windows-safe (path.join)

tests/worktree-base-ref.test.cjs keyed its injected readFile/writeFile mocks
(and a few expected `file` values) with forward-slash template literals like
`${claudeDir}/settings.local.json`. The module composes those paths with
path.join(), which emits backslashes on Windows, so the mock keys never matched
the module's lookup → readFile returned null → resolveEffectiveBaseRef /
cmdWorktreeBaseCheck / cmdWorktreeSetBaseRef (and the JSONC variants) failed on
the Windows full-test runner only (they passed on Mac/Linux, and the install
tests passed because they use the real filesystem). The module is correct;
only the test fixtures hardcoded '/'.

All mock keys and path assertions now use path.join(base, ...) mirroring the
module, so they match on every platform (no-op on POSIX). 19 path references
across 16 lines.

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

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-06 23:40:24 -04:00

369 lines
14 KiB
TypeScript

/**
* Worktree base-ref detection and degradation logic (issue #683).
*
* Determines whether a worktree's HEAD has drifted from the fork base that the
* Claude Code harness would use to create a 'fresh' parallel worktree. When
* drift is detected the caller should fall back to sequential execution on the
* main working tree to avoid a base mismatch.
*
* Pure/testable module: all I/O is injectable via the `deps` argument so unit
* tests can run without touching the real filesystem or spawning real git.
*/
import fs from 'node:fs';
import path from 'node:path';
import { execGit as execGitSeam } from './shell-command-projection.cjs';
// ─── Internal helpers ─────────────────────────────────────────────────────────
/**
* Strip JSONC comments (line and block forms) from a string to produce valid JSON.
* Handles comments inside strings correctly (does not strip them).
* Mirrors the same logic in bin/install.js:stripJsonComments.
*/
function stripJsonComments(text: string): string {
let result = '';
let i = 0;
let inString = false;
let stringChar = '';
while (i < text.length) {
// Handle string literals — don't strip comments inside strings
if (inString) {
if (text[i] === '\\') {
result += text[i] + (text[i + 1] || '');
i += 2;
continue;
}
if (text[i] === stringChar) {
inString = false;
}
result += text[i];
i++;
continue;
}
// Start of string
if (text[i] === '"' || text[i] === "'") {
inString = true;
stringChar = text[i];
result += text[i];
i++;
continue;
}
// Line comment
if (text[i] === '/' && text[i + 1] === '/') {
// Skip to end of line
while (i < text.length && text[i] !== '\n') i++;
continue;
}
// Block comment
if (text[i] === '/' && text[i + 1] === '*') {
i += 2;
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++;
i += 2; // skip closing */
continue;
}
result += text[i];
i++;
}
// Remove trailing commas before } or ] (common in JSONC)
return result.replace(/,\s*([}\]])/g, '$1');
}
/**
* Parse a string as JSONC (JSON with comments). Returns the parsed value or
* throws a SyntaxError if the content is genuinely malformed.
*/
function parseJsonc(text: string): unknown {
try {
return JSON.parse(text);
} catch {
return JSON.parse(stripJsonComments(text));
}
}
// ─── Internal types ───────────────────────────────────────────────────────────
type ExecGitFn = (
args: string[],
opts?: { cwd?: string; env?: Record<string, string>; timeout?: number }
) => { exitCode: number | null; stdout: string; stderr: string; signal: string | null; error: unknown };
// ─── Message constants (verbatim — downstream docs/tests depend on these) ─────
function buildMsgDiverged(headSha: string | null, forkRef: string | null, forkSha: string | null): string {
return `⚠ Worktree base mismatch: HEAD (${shortSha(headSha)}) differs from ${forkRef} (${shortSha(forkSha)}). Running this phase sequentially on the main working tree. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
}
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.`;
// ─── Exports ──────────────────────────────────────────────────────────────────
/**
* Returns the first 8 characters of a SHA, or '' if null/empty.
*/
export function shortSha(sha: string | null): string {
if (!sha) return '';
return sha.slice(0, 8);
}
/**
* Extracts settings.worktree.baseRef if it is a string; otherwise null.
* Defensive: settings may be null/undefined, worktree may be missing or
* not an object.
*/
export function readBaseRefFromSettings(settings: unknown): string | null {
if (settings == null || typeof settings !== 'object') return null;
const s = settings as Record<string, unknown>;
if (s.worktree == null || typeof s.worktree !== 'object' || Array.isArray(s.worktree)) return null;
const worktree = s.worktree as Record<string, unknown>;
if (typeof worktree.baseRef !== 'string') return null;
return worktree.baseRef;
}
/**
* No-clobber application of worktree.baseRef = 'head'.
*
* - If baseRef is absent/null/undefined → set to 'head', return changed:true.
* - If already 'head' → skip, return skipped:'already-head'.
* - If any other string → skip without overwriting, return skipped:'explicit-other'.
*
* Mutates `settings` in place and also returns it.
*/
export function applyWorktreeBaseRef(settings: Record<string, unknown>): {
changed: boolean;
settings: object;
skipped: null | 'already-head' | 'explicit-other';
previous: string | null;
} {
// Defensive: caller must pass a plain object — reject null, arrays, and primitives.
if (settings === null || Array.isArray(settings) || typeof settings !== 'object') {
throw new TypeError(`applyWorktreeBaseRef: expected a plain object, got ${settings === null ? 'null' : Array.isArray(settings) ? 'array' : typeof settings}`);
}
// Ensure worktree object exists, preserving any existing keys
if (settings.worktree == null || typeof settings.worktree !== 'object' || Array.isArray(settings.worktree)) {
settings.worktree = {};
}
const worktree = settings.worktree as Record<string, unknown>;
const current = typeof worktree.baseRef === 'string' ? worktree.baseRef : null;
if (current === 'head') {
return { changed: false, settings, skipped: 'already-head', previous: 'head' };
}
if (current !== null) {
// Some other explicit string value — don't overwrite
return { changed: false, settings, skipped: 'explicit-other', previous: current };
}
// Absent/null/undefined → set to 'head'
worktree.baseRef = 'head';
return { changed: true, settings, skipped: null, previous: null };
}
/**
* Reads settings.local.json then settings.json under claudeDir, extracts
* worktree.baseRef from the first file that provides a non-null string value.
*
* deps.readFile(path) must return the file contents or null on any error.
*/
export function resolveEffectiveBaseRef(
claudeDir: string,
deps?: { readFile?: (p: string) => string | null }
): string | null {
const readFile: (p: string) => string | null = deps?.readFile ?? ((p: string) => {
try {
return fs.readFileSync(p, 'utf8');
} catch {
return null;
}
});
const localPath = path.join(claudeDir, 'settings.local.json');
const sharedPath = path.join(claudeDir, 'settings.json');
function parseBaseRef(filePath: string): string | null {
const contents = readFile(filePath);
if (contents == null) return null;
try {
const parsed: unknown = parseJsonc(contents);
return readBaseRefFromSettings(parsed);
} catch {
return null;
}
}
const localRef = parseBaseRef(localPath);
if (localRef !== null) return localRef;
return parseBaseRef(sharedPath);
}
/**
* CLI command: check current worktree base-ref degradation status.
*
* Reads effective baseRef from <cwd>/.claude settings, runs degradation
* evaluation, writes JSON result to stdout (or injected write), and returns
* the result object.
*/
export function cmdWorktreeBaseCheck(
cwd: string,
_args: string[],
deps?: { execGit?: ExecGitFn; readFile?: (p: string) => string | null; write?: (s: string) => void }
): ReturnType<typeof evaluateWorktreeBaseDegrade> {
const claudeDir = path.join(cwd, '.claude');
const effectiveBaseRef = resolveEffectiveBaseRef(
claudeDir,
deps?.readFile ? { readFile: deps.readFile } : undefined
);
const result = evaluateWorktreeBaseDegrade({
cwd,
effectiveBaseRef,
execGit: deps?.execGit,
});
const write = deps?.write ?? ((s: string) => process.stdout.write(s));
write(JSON.stringify(result, null, 2) + '\n');
return result;
}
/**
* CLI command: write worktree.baseRef = 'head' into <cwd>/.claude/settings.local.json.
*
* No-clobber: if the file already has an explicit baseRef that is not 'head',
* the existing value is preserved and output reflects skipped:'explicit-other'.
* If the file contains malformed JSON, throws a clear error rather than
* silently clobbering the user's file.
*/
export function cmdWorktreeSetBaseRef(
cwd: string,
_args: string[],
deps?: {
readFile?: (p: string) => string | null;
writeFile?: (p: string, content: string) => void;
mkdir?: (p: string, opts: { recursive: boolean }) => void;
existsSync?: (p: string) => boolean;
write?: (s: string) => void;
}
): { changed: boolean; skipped: null | 'already-head' | 'explicit-other'; previous: string | null; file: string; baseRef: string } {
const file = path.join(cwd, '.claude', 'settings.local.json');
const readFile: (p: string) => string | null = deps?.readFile ??
((p: string) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } });
const raw = readFile(file);
let settings: Record<string, unknown> = {};
if (raw != null) {
let parsed: unknown;
try {
parsed = parseJsonc(raw);
} catch {
throw new Error(`Refusing to modify ${file}: existing JSON is malformed`);
}
if (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error(`Refusing to modify ${file}: expected a JSON object at the top level`);
}
settings = parsed as Record<string, unknown>;
}
const apply = applyWorktreeBaseRef(settings);
if (apply.changed) {
const dir = path.dirname(file);
const existsSync: (p: string) => boolean = deps?.existsSync ?? fs.existsSync;
const mkdirFn: (p: string, opts: { recursive: boolean }) => void = deps?.mkdir ??
((p: string, opts: { recursive: boolean }) => { fs.mkdirSync(p, opts); });
if (!existsSync(dir)) {
mkdirFn(dir, { recursive: true });
}
const writeFile: (p: string, content: string) => void = deps?.writeFile ??
((p: string, content: string) => { fs.writeFileSync(p, content, 'utf8'); });
writeFile(file, JSON.stringify(settings, null, 2) + '\n');
}
const output = {
changed: apply.changed,
skipped: apply.skipped,
previous: apply.previous,
baseRef: 'head' as const,
file,
};
const write = deps?.write ?? ((s: string) => process.stdout.write(s));
write(JSON.stringify(output, null, 2) + '\n');
return output;
}
/**
* Evaluates whether the current worktree HEAD has diverged from the fork base
* (origin/HEAD) that the Claude Code harness would use when creating a 'fresh'
* parallel worktree.
*
* Returns a structured result with shouldDegrade, reason, and a user-visible
* message when degradation is warranted.
*/
export function evaluateWorktreeBaseDegrade(deps?: {
execGit?: ExecGitFn;
effectiveBaseRef?: string | null;
cwd?: string;
}): {
shouldDegrade: boolean;
reason: string;
message: string | null;
headSha: string | null;
forkRef: string | null;
forkSha: string | null;
} {
const execGit: ExecGitFn = deps?.execGit ?? execGitSeam;
const cwd = deps?.cwd;
const cwdOpts = cwd ? { cwd } : {};
// a. If baseRef is explicitly 'head' the harness forks from HEAD — no mismatch possible.
// Claude Code's worktree.baseRef accepts only "fresh" (= origin/HEAD, the default) or "head".
// Therefore special-casing "head" here and otherwise comparing HEAD against origin/HEAD is
// complete: any non-"head" value (including "fresh" and absent/null) has fresh/origin-HEAD
// semantics and must be evaluated against origin/HEAD. (Reference: Claude Code worktrees docs, #683.)
if (deps?.effectiveBaseRef === 'head') {
return { shouldDegrade: false, reason: 'baseref-head', message: null, headSha: null, forkRef: null, forkSha: null };
}
// b. Resolve HEAD sha.
const headResult = execGit(['rev-parse', 'HEAD'], cwdOpts);
const headStdout = headResult.stdout ? headResult.stdout.trim() : '';
if (headResult.exitCode !== 0 || !headStdout) {
return { shouldDegrade: false, reason: 'no-head', message: null, headSha: null, forkRef: null, forkSha: null };
}
const headSha = headStdout;
// c. Resolve fork base (what the harness forks 'fresh' worktrees from = origin/HEAD).
let forkRef: string | null = null;
let forkSha: string | null = null;
// Try direct origin/HEAD rev-parse first.
const directResult = execGit(['rev-parse', '--verify', '--quiet', 'origin/HEAD'], cwdOpts);
const directStdout = directResult.stdout ? directResult.stdout.trim() : '';
if (directResult.exitCode === 0 && directStdout) {
forkRef = 'origin/HEAD';
forkSha = directStdout;
} else {
// Fall back via symbolic-ref → refs/remotes/origin/HEAD
const symResult = execGit(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], cwdOpts);
const symStdout = symResult.stdout ? symResult.stdout.trim() : '';
if (symResult.exitCode === 0 && symStdout) {
const ref = symStdout;
const symShaResult = execGit(['rev-parse', '--verify', '--quiet', ref], cwdOpts);
const symShaStdout = symShaResult.stdout ? symShaResult.stdout.trim() : '';
if (symShaResult.exitCode === 0 && symShaStdout) {
// Strip leading 'refs/remotes/' to get e.g. 'origin/next'
forkRef = ref.replace(/^refs\/remotes\//, '');
forkSha = symShaStdout;
}
}
}
// d. Evaluate.
if (forkSha === null) {
return { shouldDegrade: true, reason: 'fork-ref-unknown', message: MSG_UNKNOWN, headSha, forkRef: null, forkSha: null };
}
if (forkSha === headSha) {
return { shouldDegrade: false, reason: 'head-matches-fork', message: null, headSha, forkRef, forkSha };
}
const message = buildMsgDiverged(headSha, forkRef, forkSha);
return { shouldDegrade: true, reason: 'head-diverged-from-fork', message, headSha, forkRef, forkSha };
}