* 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>
369 lines
14 KiB
TypeScript
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 };
|
|
}
|