diff --git a/.changeset/1013-worktree-baseref-user-global-cascade.md b/.changeset/1013-worktree-baseref-user-global-cascade.md new file mode 100644 index 000000000..ad0ce36c2 --- /dev/null +++ b/.changeset/1013-worktree-baseref-user-global-cascade.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1038 +--- +**`worktree base-check` now honors a user/global `worktree.baseRef:"head"` (and `CLAUDE_CONFIG_DIR`)** — base-check resolved `baseRef` from the project checkout's `.claude/` only, so a machine-wide `head` set via `/config` (the layer the harness itself honors) was invisible. On any phase/feature lane it returned `shouldDegrade:true` and `execute-phase` silently forced sequential execution, losing the parallel worktree execution the user configured. Resolution now falls back to the user/global `settings.json` (via `getGlobalConfigDir('claude')`, honoring `CLAUDE_CONFIG_DIR`) below the existing project-local and project-shared layers. (#1013) diff --git a/docs/CLI-TOOLS.md b/docs/CLI-TOOLS.md index 27e774b51..14aaba631 100644 --- a/docs/CLI-TOOLS.md +++ b/docs/CLI-TOOLS.md @@ -435,7 +435,7 @@ node gsd-tools.cjs worktree base-check node gsd-tools.cjs worktree set-baseref ``` -**`worktree base-check`** reads `worktree.baseRef` from `.claude/settings.local.json` (then `.claude/settings.json`) and compares the current `HEAD` SHA against `origin/HEAD`. The `shouldDegrade` field is `true` when the execute-phase orchestrator will fall back to sequential execution. Possible `reason` values: +**`worktree base-check`** reads `worktree.baseRef` from a three-layer cascade — `.claude/settings.local.json`, then `.claude/settings.json`, then the user/global `settings.json` under `CLAUDE_CONFIG_DIR` (or `~/.claude`) — and compares the current `HEAD` SHA against `origin/HEAD`. Project-level settings take precedence over the user/global layer, so a machine-wide `worktree.baseRef:"head"` set via `/config` is honored when no project override exists. The `shouldDegrade` field is `true` when the execute-phase orchestrator will fall back to sequential execution. Possible `reason` values: | `reason` | `shouldDegrade` | Meaning | |---|---|---| diff --git a/src/worktree-base-ref.cts b/src/worktree-base-ref.cts index 47dab4315..d18ddfff2 100644 --- a/src/worktree-base-ref.cts +++ b/src/worktree-base-ref.cts @@ -14,6 +14,7 @@ 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 ───────────────────────────────────────────────────────── @@ -160,14 +161,21 @@ export function applyWorktreeBaseRef(settings: Record): { } /** - * Reads settings.local.json then settings.json under claudeDir, extracts - * worktree.baseRef from the first file that provides a non-null string value. + * 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 — /settings.local.json + * 2. project shared — /settings.json + * 3. user/global — /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 } + deps?: { readFile?: (p: string) => string | null }, + userClaudeDir?: string | null ): string | null { const readFile: (p: string) => string | null = deps?.readFile ?? ((p: string) => { try { @@ -191,28 +199,47 @@ export function resolveEffectiveBaseRef( } } + // Layer 1: project local const localRef = parseBaseRef(localPath); if (localRef !== null) return localRef; - return parseBaseRef(sharedPath); + // 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 /.claude settings, runs degradation - * evaluation, writes JSON result to stdout (or injected write), and returns - * the result object. + * Reads effective baseRef from /.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 } + deps?: { execGit?: ExecGitFn; readFile?: (p: string) => string | null; write?: (s: string) => void; userClaudeDir?: string | null } ): ReturnType { 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 + deps?.readFile ? { readFile: deps.readFile } : undefined, + userClaudeDir ); const result = evaluateWorktreeBaseDegrade({ cwd, diff --git a/tests/worktree-base-ref.test.cjs b/tests/worktree-base-ref.test.cjs index 5e47c02b1..3b6b28fd0 100644 --- a/tests/worktree-base-ref.test.cjs +++ b/tests/worktree-base-ref.test.cjs @@ -433,6 +433,8 @@ describe('cmdWorktreeBaseCheck', () => { }, execGit: makeExecGitCheck({}), write: (s) => { written += s; }, + // Hermetic: point userClaudeDir at a non-existent path so real ~/.claude is never read + userClaudeDir: '/nonexistent-hermetic-user-dir', }; const result = cmdWorktreeBaseCheck(cwd, [], deps); assert.strictEqual(result.shouldDegrade, false); @@ -453,6 +455,8 @@ describe('cmdWorktreeBaseCheck', () => { 'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: FORK_SHA, stderr: '', signal: null, error: null }, }), write: (s) => { written += s; }, + // Hermetic: point userClaudeDir at a non-existent path so real ~/.claude is never read + userClaudeDir: '/nonexistent-hermetic-user-dir', }; const result = cmdWorktreeBaseCheck(cwd, [], deps); assert.strictEqual(result.shouldDegrade, true); @@ -771,3 +775,147 @@ describe('evaluateWorktreeBaseDegrade — defensive trim on SHAs (FIX 3)', () => assert.strictEqual(result.shouldDegrade, true); }); }); + +// ─── resolveEffectiveBaseRef — user/global layer (#1013) ───────────────────── + +describe('resolveEffectiveBaseRef — user/global layer (#1013)', () => { + function makeReadFile(files) { + return (p) => (Object.prototype.hasOwnProperty.call(files, p) ? files[p] : null); + } + + const USER_CLAUDE_DIR = '/home/user/.claude'; + const claudeDir = '/repo/.claude'; + + test('(a) user/global settings.json provides baseRef:"head" when both project files absent', () => { + const deps = { + readFile: makeReadFile({ + [path.join(USER_CLAUDE_DIR, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'head' } }), + }), + }; + assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps, USER_CLAUDE_DIR), 'head'); + }); + + test('(b) project local "fresh" OVERRIDES user/global "head" → returns "fresh"', () => { + const deps = { + readFile: makeReadFile({ + [path.join(claudeDir, 'settings.local.json')]: JSON.stringify({ worktree: { baseRef: 'fresh' } }), + [path.join(USER_CLAUDE_DIR, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'head' } }), + }), + }; + assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps, USER_CLAUDE_DIR), 'fresh'); + }); + + test('(c) project shared "fresh" (no local) OVERRIDES user/global "head" → returns "fresh"', () => { + const deps = { + readFile: makeReadFile({ + [path.join(claudeDir, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'fresh' } }), + [path.join(USER_CLAUDE_DIR, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'head' } }), + }), + }; + assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps, USER_CLAUDE_DIR), 'fresh'); + }); + + test('(d) userClaudeDir undefined → behaves as before, returns null when both project files absent', () => { + const deps = { readFile: () => null }; + assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps, undefined), null); + }); + + test('(d) userClaudeDir null → behaves as before, returns null when both project files absent', () => { + const deps = { readFile: () => null }; + assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps, null), null); + }); + + test('user/global settings.json absent → returns null (no fallback beyond user layer)', () => { + const deps = { + readFile: makeReadFile({ + // user settings.json present but has no baseRef + [path.join(USER_CLAUDE_DIR, 'settings.json')]: JSON.stringify({ other: 'value' }), + }), + }; + assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps, USER_CLAUDE_DIR), null); + }); + + test('userClaudeDir === claudeDir → does not double-read (avoids re-reading shared settings.json)', () => { + // When project dir IS the user dir (cwd is home), the user layer should be skipped + // to avoid reading settings.json twice. This is enforced by the path.resolve comparison. + const sameDir = '/home/.claude'; + let readCount = 0; + const deps = { + readFile: (p) => { + readCount++; + if (p === path.join(sameDir, 'settings.local.json')) return null; + if (p === path.join(sameDir, 'settings.json')) return JSON.stringify({ worktree: { baseRef: 'head' } }); + return null; + }, + }; + // resolveEffectiveBaseRef(sameDir, deps, sameDir) — userClaudeDir === claudeDir + const result = resolveEffectiveBaseRef(sameDir, deps, sameDir); + assert.strictEqual(result, 'head'); // still reads shared settings.json (the project layer) + // The shared settings.json should have been read exactly once (project layer), not twice + assert.strictEqual(readCount, 2, 'only local + shared should be read; user layer skipped when same dir'); + }); +}); + +// ─── cmdWorktreeBaseCheck — user/global cascade (#1013 KEY REGRESSION) ─────── + +describe('cmdWorktreeBaseCheck — user/global cascade (#1013)', () => { + // Phase-lane execGit: origin/HEAD probe fails (no symref either) → fork-ref-unknown → degrade + function makePhaseLaneExecGit(HEAD_SHA) { + return function stubExecGit(args, _opts) { + const key = args.join(' '); + if (key === 'rev-parse HEAD') { + return { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null }; + } + if (key === 'rev-parse --verify --quiet origin/HEAD') { + return { exitCode: 1, stdout: '', stderr: '', signal: null, error: null }; + } + if (key === 'symbolic-ref --quiet refs/remotes/origin/HEAD') { + return { exitCode: 1, stdout: '', stderr: '', signal: null, error: null }; + } + throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`); + }; + } + + const HEAD_SHA = 'phase1lane11223344phase1lane11223344phase'; + const USER_CLAUDE_DIR = '/home/user/.claude'; + const cwd = '/repo'; + const claudeDir = '/repo/.claude'; + + test('(e positive) user/global head + phase lane → shouldDegrade:false (KEY REGRESSION)', () => { + // This is the exact bug: user set worktree.baseRef:"head" in their global settings, + // but without the fix that setting was invisible and the phase lane triggered degrade. + const deps = { + execGit: makePhaseLaneExecGit(HEAD_SHA), + readFile: (p) => { + // Project files: no baseRef + if (p === path.join(claudeDir, 'settings.local.json')) return null; + if (p === path.join(claudeDir, 'settings.json')) return null; + // User/global file: baseRef = "head" + if (p === path.join(USER_CLAUDE_DIR, 'settings.json')) { + return JSON.stringify({ worktree: { baseRef: 'head' } }); + } + return null; + }, + write: () => {}, + userClaudeDir: USER_CLAUDE_DIR, + }; + const result = cmdWorktreeBaseCheck(cwd, [], deps); + assert.strictEqual(result.shouldDegrade, false, + 'user/global worktree.baseRef:"head" must suppress degrade on a phase lane'); + assert.strictEqual(result.reason, 'baseref-head'); + }); + + test('(e negative) NO user/global head + same phase lane → shouldDegrade:true (proves lane degrades)', () => { + // Without a user/global head, the phase lane must still degrade (proves the positive test is real) + const deps = { + execGit: makePhaseLaneExecGit(HEAD_SHA), + readFile: () => null, // no project or user settings + write: () => {}, + userClaudeDir: '/nonexistent-hermetic-dir-no-global', + }; + const result = cmdWorktreeBaseCheck(cwd, [], deps); + assert.strictEqual(result.shouldDegrade, true, + 'without user/global head, a phase lane must degrade'); + assert.strictEqual(result.reason, 'fork-ref-unknown'); + }); +});