* 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>
This commit is contained in:
5
.changeset/1013-worktree-baseref-user-global-cascade.md
Normal file
5
.changeset/1013-worktree-baseref-user-global-cascade.md
Normal file
@@ -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)
|
||||
@@ -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 |
|
||||
|---|---|---|
|
||||
|
||||
@@ -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<string, unknown>): {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 — <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 }
|
||||
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 <cwd>/.claude settings, runs degradation
|
||||
* evaluation, writes JSON result to stdout (or injected write), and returns
|
||||
* the result object.
|
||||
* 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 }
|
||||
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
|
||||
deps?.readFile ? { readFile: deps.readFile } : undefined,
|
||||
userClaudeDir
|
||||
);
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
cwd,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user