Files
msd-core/src/worktree-base-ref.cts
Tom Boucher ab8b84286e fix(#1013): resolve worktree.baseRef from user/global settings cascade (#1038)
* fix(#1013): resolve worktree.baseRef from user/global settings cascade

cmdWorktreeBaseCheck resolved worktree.baseRef from the project checkout's
.claude/ only (settings.local.json then settings.json). A user/global
worktree.baseRef:"head" — the layer /config writes and the harness honors,
and the only sensible place for a machine-wide preference — was invisible. On a
phase lane (HEAD ahead of origin/HEAD, or no origin/HEAD symref) base-check
returned shouldDegrade:true and execute-phase forced sequential execution,
silently losing the parallel worktree execution the user configured.
CLAUDE_CONFIG_DIR (relocated user config dir) was also ignored.

resolveEffectiveBaseRef now accepts an optional user/global config dir and reads
its settings.json as a third, lowest-precedence layer (project local > project
shared > user/global). cmdWorktreeBaseCheck resolves it via
getGlobalConfigDir('claude'), which honors CLAUDE_CONFIG_DIR. The existing
project-level reads stay as higher-precedence overrides and the injectable
readFile seam is preserved, keeping the unit tests hermetic.

Closes #1013

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

* chore(#1013): backfill changeset PR number to 1038

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

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 11:36:30 -04:00

396 lines
16 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';
import { getGlobalConfigDir } from './runtime-homes.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 files in a 3-layer cascade and extracts worktree.baseRef from
* the first layer that provides a non-null string value. Layers (highest to lowest
* precedence):
* 1. project local — <claudeDir>/settings.local.json
* 2. project shared — <claudeDir>/settings.json
* 3. user/global — <userClaudeDir>/settings.json (only when userClaudeDir is
* provided AND resolves to a different path than claudeDir)
*
* deps.readFile(path) must return the file contents or null on any error.
* userClaudeDir is optional; when absent/null the user/global layer is skipped.
*/
export function resolveEffectiveBaseRef(
claudeDir: string,
deps?: { readFile?: (p: string) => string | null },
userClaudeDir?: 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;
}
}
// Layer 1: project local
const localRef = parseBaseRef(localPath);
if (localRef !== null) return localRef;
// Layer 2: project shared
const sharedRef = parseBaseRef(sharedPath);
if (sharedRef !== null) return sharedRef;
// Layer 3: user/global (only when provided and not the same directory as claudeDir)
if (userClaudeDir && path.resolve(userClaudeDir) !== path.resolve(claudeDir)) {
const userSharedPath = path.join(userClaudeDir, 'settings.json');
const userRef = parseBaseRef(userSharedPath);
if (userRef !== null) return userRef;
}
return null;
}
/**
* CLI command: check current worktree base-ref degradation status.
*
* Reads effective baseRef from <cwd>/.claude settings (3-layer cascade:
* project local → project shared → user/global), runs degradation evaluation,
* writes JSON result to stdout (or injected write), and returns the result object.
*
* deps.userClaudeDir overrides the user/global config directory resolution
* (default: getGlobalConfigDir('claude'), which honours CLAUDE_CONFIG_DIR).
*/
export function cmdWorktreeBaseCheck(
cwd: string,
_args: string[],
deps?: { execGit?: ExecGitFn; readFile?: (p: string) => string | null; write?: (s: string) => void; userClaudeDir?: string | null }
): ReturnType<typeof evaluateWorktreeBaseDegrade> {
const claudeDir = path.join(cwd, '.claude');
const userClaudeDir = Object.prototype.hasOwnProperty.call(deps ?? {}, 'userClaudeDir')
? (deps as { userClaudeDir?: string | null }).userClaudeDir
: getGlobalConfigDir('claude');
const effectiveBaseRef = resolveEffectiveBaseRef(
claudeDir,
deps?.readFile ? { readFile: deps.readFile } : undefined,
userClaudeDir
);
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 };
}