* 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>
396 lines
16 KiB
TypeScript
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 };
|
|
}
|