fix(#683): auto-degrade phase execution to sequential on worktree base mismatch (#749)

* 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>
This commit is contained in:
Tom Boucher
2026-06-06 23:40:24 -04:00
committed by GitHub
parent edd3c6c332
commit cf8bd3cd5e
18 changed files with 2077 additions and 9 deletions

View File

@@ -64,8 +64,10 @@
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('node:os');
const path = require('path');
const { assertTightCeiling } = require('../scripts/lib/allowlist-ratchet.cjs');
const { cleanup } = require('./helpers.cjs');
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
@@ -121,10 +123,16 @@ function budgetFor(workflow) {
}
function byteCount(filePath) {
// Match `wc -c`: count every byte on disk, including any trailing newline.
// Deliberately NOT the trailing-newline-stripping logic the old lineCount()
// used — the byte ceilings are calibrated against raw `wc -c` output.
return fs.statSync(filePath).size;
// Count bytes as on an LF checkout, so the budget is platform-independent.
// The tier ceilings are calibrated against `wc -c` on a Unix (LF) checkout,
// but these .md files have no `eol=lf` in .gitattributes, so Windows checks
// them out as CRLF. Counting raw on-disk bytes there adds one byte per line,
// which fails CI on the high-water-mark file (execute-phase.md) on Windows
// ONLY — a false positive that diverges from the LF calibration basis (#683).
// Stripping CR yields the same LF byte count on every platform. Still a raw
// byte count (not the old trailing-newline-stripping lineCount()).
const content = fs.readFileSync(filePath, 'utf-8');
return Buffer.byteLength(content.replace(/\r\n/g, '\n'), 'utf-8');
}
describe('SIZE: workflow byte-size budget', () => {
@@ -549,3 +557,28 @@ describe('workflow progressive disclosure — MVP bodies lazy-loaded (#720)', ()
);
});
});
describe('SIZE: byteCount is line-ending independent (#683 regression)', () => {
// The budget ceilings are calibrated against an LF (Unix) checkout; Windows
// checks these .md files out as CRLF, which previously inflated the count by
// one byte per line and failed CI only on Windows for the high-water file.
test('CRLF and LF content of the same logical file count identically', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-size-eol-'));
try {
const body = 'line one\nline two\nthree — with a multibyte dash\n';
const lfPath = path.join(dir, 'lf.md');
const crlfPath = path.join(dir, 'crlf.md');
fs.writeFileSync(lfPath, body);
fs.writeFileSync(crlfPath, body.replace(/\n/g, '\r\n'));
assert.strictEqual(
byteCount(crlfPath),
byteCount(lfPath),
'byteCount must normalize CRLF so the byte budget is platform-independent'
);
// And it must remain a real LF byte count (not stripped/whitespace-trimmed).
assert.strictEqual(byteCount(lfPath), Buffer.byteLength(body, 'utf-8'));
} finally {
cleanup(dir);
}
});
});

View File

@@ -0,0 +1,773 @@
'use strict';
/**
* Worktree Base-Ref Module — unit tests
*
* Seam: gsd-core/bin/lib/worktree-base-ref.cjs
* Interface: shortSha, readBaseRefFromSettings, applyWorktreeBaseRef,
* resolveEffectiveBaseRef, evaluateWorktreeBaseDegrade
*
* Issue #683: worktree base-mismatch detection and degradation logic.
* All tests use dependency injection (inline stubs) — no real filesystem
* or real git is exercised.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const path = require('node:path');
const MODULE_PATH = path.join(
__dirname, '..', 'gsd-core', 'bin', 'lib', 'worktree-base-ref.cjs'
);
const {
shortSha,
readBaseRefFromSettings,
applyWorktreeBaseRef,
resolveEffectiveBaseRef,
evaluateWorktreeBaseDegrade,
cmdWorktreeBaseCheck,
cmdWorktreeSetBaseRef,
} = require(MODULE_PATH);
// ─── shortSha ────────────────────────────────────────────────────────────────
describe('shortSha', () => {
test('returns first 8 chars of a full sha', () => {
assert.strictEqual(shortSha('abc123def456789'), 'abc123de');
});
test('returns the string itself when shorter than 8 chars', () => {
assert.strictEqual(shortSha('abc12'), 'abc12');
});
test('returns empty string for null', () => {
assert.strictEqual(shortSha(null), '');
});
test('returns empty string for empty string', () => {
assert.strictEqual(shortSha(''), '');
});
test('returns exactly 8 chars when sha is exactly 8 chars', () => {
assert.strictEqual(shortSha('12345678'), '12345678');
});
});
// ─── readBaseRefFromSettings ─────────────────────────────────────────────────
describe('readBaseRefFromSettings', () => {
test('returns baseRef when present as a string', () => {
assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: 'head' } }), 'head');
});
test('returns baseRef value "fresh"', () => {
assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: 'fresh' } }), 'fresh');
});
test('returns null when worktree is missing', () => {
assert.strictEqual(readBaseRefFromSettings({}), null);
});
test('returns null when settings is null', () => {
assert.strictEqual(readBaseRefFromSettings(null), null);
});
test('returns null when settings is undefined', () => {
assert.strictEqual(readBaseRefFromSettings(undefined), null);
});
test('returns null when worktree is not an object (string)', () => {
assert.strictEqual(readBaseRefFromSettings({ worktree: 'not-an-object' }), null);
});
test('returns null when baseRef is a number (non-string)', () => {
assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: 42 } }), null);
});
test('returns null when baseRef is null', () => {
assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: null } }), null);
});
test('returns null when baseRef is undefined', () => {
assert.strictEqual(readBaseRefFromSettings({ worktree: { baseRef: undefined } }), null);
});
});
// ─── applyWorktreeBaseRef ─────────────────────────────────────────────────────
describe('applyWorktreeBaseRef', () => {
test('sets baseRef to "head" when absent, returns changed:true', () => {
const settings = {};
const result = applyWorktreeBaseRef(settings);
assert.strictEqual(result.changed, true);
assert.strictEqual(result.skipped, null);
assert.strictEqual(result.previous, null);
assert.strictEqual(result.settings.worktree.baseRef, 'head');
});
test('sets baseRef to "head" when worktree key is missing entirely', () => {
const settings = { other: 'value' };
const result = applyWorktreeBaseRef(settings);
assert.strictEqual(result.changed, true);
assert.strictEqual(settings.worktree.baseRef, 'head');
});
test('sets baseRef to "head" when worktree.baseRef is null', () => {
const settings = { worktree: { baseRef: null, otherKey: 'keep' } };
const result = applyWorktreeBaseRef(settings);
assert.strictEqual(result.changed, true);
assert.strictEqual(settings.worktree.baseRef, 'head');
});
test('sets baseRef to "head" when worktree.baseRef is undefined', () => {
const settings = { worktree: { baseRef: undefined } };
const result = applyWorktreeBaseRef(settings);
assert.strictEqual(result.changed, true);
assert.strictEqual(settings.worktree.baseRef, 'head');
});
test('preserves other worktree.* keys when setting baseRef', () => {
const settings = { worktree: { otherKey: 'preserved', anotherKey: 123 } };
applyWorktreeBaseRef(settings);
assert.strictEqual(settings.worktree.otherKey, 'preserved');
assert.strictEqual(settings.worktree.anotherKey, 123);
assert.strictEqual(settings.worktree.baseRef, 'head');
});
test('mutates settings in place and returns the same object reference', () => {
const settings = {};
const result = applyWorktreeBaseRef(settings);
assert.strictEqual(result.settings, settings);
});
test('returns already-head skip when baseRef is already "head"', () => {
const settings = { worktree: { baseRef: 'head' } };
const result = applyWorktreeBaseRef(settings);
assert.strictEqual(result.changed, false);
assert.strictEqual(result.skipped, 'already-head');
assert.strictEqual(result.previous, 'head');
assert.strictEqual(settings.worktree.baseRef, 'head');
});
test('returns explicit-other skip when baseRef is "fresh", does NOT overwrite', () => {
const settings = { worktree: { baseRef: 'fresh' } };
const result = applyWorktreeBaseRef(settings);
assert.strictEqual(result.changed, false);
assert.strictEqual(result.skipped, 'explicit-other');
assert.strictEqual(result.previous, 'fresh');
assert.strictEqual(settings.worktree.baseRef, 'fresh');
});
test('returns explicit-other skip for any other string value', () => {
const settings = { worktree: { baseRef: 'some-branch' } };
const result = applyWorktreeBaseRef(settings);
assert.strictEqual(result.changed, false);
assert.strictEqual(result.skipped, 'explicit-other');
assert.strictEqual(result.previous, 'some-branch');
});
});
// ─── resolveEffectiveBaseRef ──────────────────────────────────────────────────
describe('resolveEffectiveBaseRef', () => {
// Helper to build a path-keyed readFile stub
function makeReadFile(files) {
return (p) => (Object.prototype.hasOwnProperty.call(files, p) ? files[p] : null);
}
test('returns baseRef from settings.local.json when present', () => {
const claudeDir = '/repo/.claude';
const deps = {
readFile: makeReadFile({
[path.join(claudeDir, 'settings.local.json')]: JSON.stringify({ worktree: { baseRef: 'head' } }),
[path.join(claudeDir, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'fresh' } }),
}),
};
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'head');
});
test('falls back to settings.json when settings.local.json has no baseRef', () => {
const claudeDir = '/repo/.claude';
const deps = {
readFile: makeReadFile({
[path.join(claudeDir, 'settings.local.json')]: JSON.stringify({ other: 'value' }),
[path.join(claudeDir, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'fresh' } }),
}),
};
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'fresh');
});
test('returns null when both files are missing', () => {
const claudeDir = '/repo/.claude';
const deps = { readFile: () => null };
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), null);
});
test('returns null when both files exist but have no baseRef', () => {
const claudeDir = '/repo/.claude';
const deps = {
readFile: makeReadFile({
[path.join(claudeDir, 'settings.local.json')]: JSON.stringify({ other: 'value' }),
[path.join(claudeDir, 'settings.json')]: JSON.stringify({ other: 'value2' }),
}),
};
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), null);
});
test('ignores malformed JSON in settings.local.json and falls back', () => {
const claudeDir = '/repo/.claude';
const deps = {
readFile: makeReadFile({
[path.join(claudeDir, 'settings.local.json')]: 'not valid json {{{',
[path.join(claudeDir, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'head' } }),
}),
};
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'head');
});
test('ignores malformed JSON in settings.json', () => {
const claudeDir = '/repo/.claude';
const deps = {
readFile: makeReadFile({
[path.join(claudeDir, 'settings.local.json')]: null,
[path.join(claudeDir, 'settings.json')]: 'not valid json',
}),
};
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), null);
});
test('settings.local.json null baseRef falls back to settings.json', () => {
const claudeDir = '/repo/.claude';
const deps = {
readFile: makeReadFile({
[path.join(claudeDir, 'settings.local.json')]: JSON.stringify({ worktree: { baseRef: null } }),
[path.join(claudeDir, 'settings.json')]: JSON.stringify({ worktree: { baseRef: 'fresh' } }),
}),
};
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'fresh');
});
});
// ─── evaluateWorktreeBaseDegrade ──────────────────────────────────────────────
describe('evaluateWorktreeBaseDegrade', () => {
// Stub helper: matches on args.join(' ') and returns canned results
function makeExecGit(responses) {
return function stubExecGit(args, _opts) {
const key = args.join(' ');
if (Object.prototype.hasOwnProperty.call(responses, key)) {
return responses[key];
}
// Default: fail with a helpful error to surface unexpected calls
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
};
}
test('effectiveBaseRef="head" → no degrade, reason baseref-head, execGit never called', () => {
let called = false;
const result = evaluateWorktreeBaseDegrade({
execGit: () => { called = true; return { exitCode: 0, stdout: '', stderr: '', signal: null, error: null }; },
effectiveBaseRef: 'head',
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'baseref-head');
assert.strictEqual(result.message, null);
assert.strictEqual(result.headSha, null);
assert.strictEqual(result.forkRef, null);
assert.strictEqual(result.forkSha, null);
assert.strictEqual(called, false, 'execGit must not be called when effectiveBaseRef is head');
});
test('git rev-parse HEAD fails → no degrade, reason no-head', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 128, stdout: '', stderr: 'fatal: not a git repo', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'no-head');
assert.strictEqual(result.headSha, null);
});
test('git rev-parse HEAD returns empty stdout → no degrade, reason no-head', () => {
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: '', stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'no-head');
});
test('HEAD == origin/HEAD → no degrade, reason head-matches-fork', () => {
const HEAD_SHA = 'aabbccdd11223344aabbccdd11223344aabbccdd';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'head-matches-fork');
assert.strictEqual(result.headSha, HEAD_SHA);
assert.strictEqual(result.forkRef, 'origin/HEAD');
assert.strictEqual(result.forkSha, HEAD_SHA);
assert.strictEqual(result.message, null);
});
test('HEAD != origin/HEAD → degrade, reason head-diverged-from-fork, MSG_DIVERGED', () => {
const HEAD_SHA = 'deadbeef11223344deadbeef11223344deadbeef';
const FORK_SHA = 'cafebabe11223344cafebabe11223344cafebabe';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: FORK_SHA, stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-diverged-from-fork');
assert.strictEqual(result.headSha, HEAD_SHA);
assert.strictEqual(result.forkRef, 'origin/HEAD');
assert.strictEqual(result.forkSha, FORK_SHA);
// Verify message contains the short SHAs and the issue reference
const expectedMsg = `⚠ Worktree base mismatch: HEAD (${HEAD_SHA.slice(0, 8)}) differs from origin/HEAD (${FORK_SHA.slice(0, 8)}). 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.`;
assert.strictEqual(result.message, expectedMsg);
});
test('origin/HEAD fails but symbolic-ref resolves to refs/remotes/origin/next', () => {
const HEAD_SHA = 'aaaa1111bbbb2222aaaa1111bbbb2222aaaa1111';
const FORK_SHA = 'cccc3333dddd4444cccc3333dddd4444cccc3333';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
'symbolic-ref --quiet refs/remotes/origin/HEAD': { exitCode: 0, stdout: 'refs/remotes/origin/next', stderr: '', signal: null, error: null },
'rev-parse --verify --quiet refs/remotes/origin/next': { exitCode: 0, stdout: FORK_SHA, stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.forkRef, 'origin/next');
assert.strictEqual(result.forkSha, FORK_SHA);
// HEAD != FORK_SHA in this fixture → degrade
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-diverged-from-fork');
assert.ok(result.message !== null);
assert.ok(result.message.includes('origin/next'));
});
test('origin/HEAD fails AND symbolic-ref fails → degrade, reason fork-ref-unknown, MSG_UNKNOWN', () => {
const HEAD_SHA = 'eeee5555ffff6666eeee5555ffff6666eeee5555';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
'symbolic-ref --quiet refs/remotes/origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'fork-ref-unknown');
assert.strictEqual(result.forkRef, null);
assert.strictEqual(result.forkSha, null);
const expectedMsg = `⚠ 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.`;
assert.strictEqual(result.message, expectedMsg);
});
test('cwd is passed through to execGit calls', () => {
const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678';
const capturedOpts = [];
const result = evaluateWorktreeBaseDegrade({
cwd: '/some/worktree',
execGit: (args, opts) => {
capturedOpts.push(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: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null };
throw new Error(`Unexpected: ${key}`);
},
});
assert.strictEqual(result.shouldDegrade, false);
assert.ok(capturedOpts.length > 0);
for (const opts of capturedOpts) {
assert.strictEqual(opts && opts.cwd, '/some/worktree');
}
});
test('symbolic-ref resolves but subsequent rev-parse fails → falls through to fork-ref-unknown', () => {
const HEAD_SHA = 'abcd1234abcd1234abcd1234abcd1234abcd1234';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
'symbolic-ref --quiet refs/remotes/origin/HEAD': { exitCode: 0, stdout: 'refs/remotes/origin/main', stderr: '', signal: null, error: null },
'rev-parse --verify --quiet refs/remotes/origin/main': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'fork-ref-unknown');
assert.strictEqual(result.forkRef, null);
assert.strictEqual(result.forkSha, null);
});
});
// ─── cmdWorktreeBaseCheck ─────────────────────────────────────────────────────
describe('cmdWorktreeBaseCheck', () => {
function makeExecGitCheck(responses) {
return function stubExecGit(args, _opts) {
const key = args.join(' ');
if (Object.prototype.hasOwnProperty.call(responses, key)) {
return responses[key];
}
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
};
}
test('baseRef=head in settings → shouldDegrade false, reason baseref-head; write emits valid JSON', () => {
const cwd = '/repo';
const claudeDir = '/repo/.claude';
let written = '';
const deps = {
readFile: (p) => {
if (p === path.join(claudeDir, 'settings.local.json')) return JSON.stringify({ worktree: { baseRef: 'head' } });
return null;
},
execGit: makeExecGitCheck({}),
write: (s) => { written += s; },
};
const result = cmdWorktreeBaseCheck(cwd, [], deps);
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'baseref-head');
const parsed = JSON.parse(written);
assert.deepStrictEqual(parsed, result);
});
test('diverged shas → shouldDegrade true; captured JSON parses correctly', () => {
const cwd = '/repo';
const HEAD_SHA = 'deadbeef11223344deadbeef11223344deadbeef';
const FORK_SHA = 'cafebabe11223344cafebabe11223344cafebabe';
let written = '';
const deps = {
readFile: () => null,
execGit: makeExecGitCheck({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA, stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: FORK_SHA, stderr: '', signal: null, error: null },
}),
write: (s) => { written += s; },
};
const result = cmdWorktreeBaseCheck(cwd, [], deps);
assert.strictEqual(result.shouldDegrade, true);
const parsed = JSON.parse(written);
assert.strictEqual(parsed.shouldDegrade, true);
assert.strictEqual(parsed.reason, 'head-diverged-from-fork');
});
});
// ─── cmdWorktreeSetBaseRef ────────────────────────────────────────────────────
describe('cmdWorktreeSetBaseRef', () => {
test('readFile returns {} → changed true, writeFile called with worktree.baseRef "head"', () => {
const cwd = '/repo';
const file = path.join(cwd, '.claude', 'settings.local.json');
let writtenPath = null;
let writtenContent = null;
let written = '';
const deps = {
readFile: () => '{}',
existsSync: () => true,
mkdir: () => {},
writeFile: (p, content) => { writtenPath = p; writtenContent = content; },
write: (s) => { written += s; },
};
const result = cmdWorktreeSetBaseRef(cwd, [], deps);
assert.strictEqual(result.changed, true);
assert.strictEqual(result.file, file);
assert.strictEqual(result.baseRef, 'head');
assert.strictEqual(writtenPath, file);
const parsedWritten = JSON.parse(writtenContent);
assert.strictEqual(parsedWritten.worktree.baseRef, 'head');
const parsedOutput = JSON.parse(written);
assert.strictEqual(parsedOutput.changed, true);
});
test('readFile returns explicit-other → changed false, skipped explicit-other, writeFile NOT called', () => {
const cwd = '/repo';
let writeFileCalled = false;
let written = '';
const deps = {
readFile: () => JSON.stringify({ worktree: { baseRef: 'fresh' } }),
existsSync: () => true,
mkdir: () => {},
writeFile: () => { writeFileCalled = true; },
write: (s) => { written += s; },
};
const result = cmdWorktreeSetBaseRef(cwd, [], deps);
assert.strictEqual(result.changed, false);
assert.strictEqual(result.skipped, 'explicit-other');
assert.strictEqual(result.previous, 'fresh');
assert.strictEqual(writeFileCalled, false, 'writeFile must NOT be called for explicit-other');
const parsedOutput = JSON.parse(written);
assert.strictEqual(parsedOutput.changed, false);
assert.strictEqual(parsedOutput.skipped, 'explicit-other');
});
test('readFile returns malformed JSON → throws refusing-to-modify error', () => {
const cwd = '/repo';
const file = path.join(cwd, '.claude', 'settings.local.json');
const deps = {
readFile: () => '{',
existsSync: () => true,
mkdir: () => {},
writeFile: () => {},
write: () => {},
};
assert.throws(
() => cmdWorktreeSetBaseRef(cwd, [], deps),
(err) => {
assert.ok(err instanceof Error, 'must throw an Error');
assert.ok(err.message.includes('Refusing to modify'), `message should contain "Refusing to modify", got: ${err.message}`);
assert.ok(err.message.includes(file), `message should contain file path, got: ${err.message}`);
return true;
}
);
});
test('readFile returns null (missing file) → treated as {} → changed true', () => {
const cwd = '/repo';
let writeFileCalled = false;
const deps = {
readFile: () => null,
existsSync: () => false,
mkdir: () => {},
writeFile: () => { writeFileCalled = true; },
write: () => {},
};
const result = cmdWorktreeSetBaseRef(cwd, [], deps);
assert.strictEqual(result.changed, true);
assert.strictEqual(writeFileCalled, true);
});
// FIX 2: non-object top-level JSON must be rejected with a clear error
test('readFile returns "[]" (array) → throws /expected a JSON object/', () => {
const cwd = '/repo';
const deps = {
readFile: () => '[]',
existsSync: () => true,
mkdir: () => {},
writeFile: () => {},
write: () => {},
};
assert.throws(
() => cmdWorktreeSetBaseRef(cwd, [], deps),
/expected a JSON object/
);
});
test('readFile returns "42" (primitive) → throws /expected a JSON object/', () => {
const cwd = '/repo';
const deps = {
readFile: () => '42',
existsSync: () => true,
mkdir: () => {},
writeFile: () => {},
write: () => {},
};
assert.throws(
() => cmdWorktreeSetBaseRef(cwd, [], deps),
/expected a JSON object/
);
});
});
// FIX 2: applyWorktreeBaseRef must reject non-object/array/null inputs
describe('applyWorktreeBaseRef — non-object inputs (FIX 2)', () => {
test('applyWorktreeBaseRef(null) → throws TypeError', () => {
assert.throws(
() => applyWorktreeBaseRef(null),
TypeError
);
});
test('applyWorktreeBaseRef([]) → throws TypeError', () => {
assert.throws(
() => applyWorktreeBaseRef([]),
TypeError
);
});
});
// ─── FIX 2: JSONC support ─────────────────────────────────────────────────────
describe('resolveEffectiveBaseRef — JSONC (FIX 2)', () => {
function makeReadFile(files) {
return (p) => (Object.prototype.hasOwnProperty.call(files, p) ? files[p] : null);
}
test('returns baseRef from settings.local.json with // line comments', () => {
const claudeDir = '/repo/.claude';
const jsonc = [
'// this is a comment',
'{',
' // another comment',
' "worktree": {',
' "baseRef": "head" // inline comment',
' }',
'}',
].join('\n');
const deps = {
readFile: makeReadFile({
[path.join(claudeDir, 'settings.local.json')]: jsonc,
}),
};
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'head');
});
test('returns baseRef from settings.local.json with /* */ block comments', () => {
const claudeDir = '/repo/.claude';
const jsonc = [
'/* block comment */',
'{',
' "worktree": { /* inline block */ "baseRef": "fresh" }',
'}',
'/* trailing block */',
].join('\n');
const deps = {
readFile: makeReadFile({
[path.join(claudeDir, 'settings.local.json')]: jsonc,
}),
};
assert.strictEqual(resolveEffectiveBaseRef(claudeDir, deps), 'fresh');
});
});
describe('cmdWorktreeSetBaseRef — JSONC (FIX 2)', () => {
test('commented-but-valid settings.local.json → updates it (changed true) rather than throwing', () => {
const cwd = '/repo';
const jsonc = [
'// user comment',
'{',
' // another comment',
' "other": "value"',
'}',
].join('\n');
let writtenContent = null;
const deps = {
readFile: () => jsonc,
existsSync: () => true,
mkdir: () => {},
writeFile: (_p, content) => { writtenContent = content; },
write: () => {},
};
const result = cmdWorktreeSetBaseRef(cwd, [], deps);
assert.strictEqual(result.changed, true, 'must set baseRef when absent (even in JSONC file)');
assert.ok(writtenContent !== null, 'must write the updated file');
const parsed = JSON.parse(writtenContent);
assert.strictEqual(parsed.worktree.baseRef, 'head');
});
test('JSONC with explicit baseRef="fresh" → skipped explicit-other, does not throw', () => {
const cwd = '/repo';
const jsonc = [
'// user comment',
'{',
' "worktree": {',
' // keeps the fork base fixed',
' "baseRef": "fresh"',
' }',
'}',
].join('\n');
let writeFileCalled = false;
const deps = {
readFile: () => jsonc,
existsSync: () => true,
mkdir: () => {},
writeFile: () => { writeFileCalled = true; },
write: () => {},
};
const result = cmdWorktreeSetBaseRef(cwd, [], deps);
assert.strictEqual(result.changed, false);
assert.strictEqual(result.skipped, 'explicit-other');
assert.strictEqual(writeFileCalled, false);
});
test('genuinely malformed JSON (after stripping comments) still throws refusing-to-modify', () => {
const cwd = '/repo';
const file = path.join(cwd, '.claude', 'settings.local.json');
// This is malformed even after comment stripping
const malformed = '// comment\n{ "key": }';
const deps = {
readFile: () => malformed,
existsSync: () => true,
mkdir: () => {},
writeFile: () => {},
write: () => {},
};
assert.throws(
() => cmdWorktreeSetBaseRef(cwd, [], deps),
(err) => {
assert.ok(err instanceof Error);
assert.ok(err.message.includes('Refusing to modify'), `got: ${err.message}`);
assert.ok(err.message.includes(file), `got: ${err.message}`);
return true;
}
);
});
});
// ─── FIX 3: defensive trim on git SHAs ────────────────────────────────────────
describe('evaluateWorktreeBaseDegrade — defensive trim on SHAs (FIX 3)', () => {
function makeExecGit(responses) {
return function stubExecGit(args, _opts) {
const key = args.join(' ');
if (Object.prototype.hasOwnProperty.call(responses, key)) {
return responses[key];
}
throw new Error(`Unexpected execGit call: ${JSON.stringify(args)}`);
};
}
test('HEAD with trailing newline still matches origin/HEAD — no degrade', () => {
const SHA = 'aabbccdd11223344aabbccdd11223344aabbccdd';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: SHA + '\n', stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: SHA + '\n', stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, false);
assert.strictEqual(result.reason, 'head-matches-fork');
});
test('HEAD with trailing whitespace still diverges correctly from different origin/HEAD', () => {
const HEAD_SHA = 'deadbeef11223344deadbeef11223344deadbeef';
const FORK_SHA = 'cafebabe11223344cafebabe11223344cafebabe';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA + '\n', stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 0, stdout: FORK_SHA + '\r\n', stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.shouldDegrade, true);
assert.strictEqual(result.reason, 'head-diverged-from-fork');
// After trimming, headSha and forkSha should be clean
assert.strictEqual(result.headSha, HEAD_SHA);
assert.strictEqual(result.forkSha, FORK_SHA);
});
test('symbolic-ref stdout with trailing newline resolves correctly', () => {
const HEAD_SHA = 'aaaa1111bbbb2222aaaa1111bbbb2222aaaa1111';
const FORK_SHA = 'cccc3333dddd4444cccc3333dddd4444cccc3333';
const result = evaluateWorktreeBaseDegrade({
execGit: makeExecGit({
'rev-parse HEAD': { exitCode: 0, stdout: HEAD_SHA + '\n', stderr: '', signal: null, error: null },
'rev-parse --verify --quiet origin/HEAD': { exitCode: 1, stdout: '', stderr: '', signal: null, error: null },
'symbolic-ref --quiet refs/remotes/origin/HEAD': { exitCode: 0, stdout: 'refs/remotes/origin/next\n', stderr: '', signal: null, error: null },
'rev-parse --verify --quiet refs/remotes/origin/next': { exitCode: 0, stdout: FORK_SHA + '\n', stderr: '', signal: null, error: null },
}),
});
assert.strictEqual(result.forkRef, 'origin/next');
assert.strictEqual(result.forkSha, FORK_SHA);
assert.strictEqual(result.shouldDegrade, true);
});
});

View File

@@ -0,0 +1,609 @@
/**
* Tests for #683: installer sets worktree.baseRef:"head" in settings.local.json
* for local Claude Code installs.
*
* Cases:
* 1. Fresh local install: writes worktree.baseRef:"head" automatically (no-clobber).
* 2. Fresh install with pre-existing explicit baseRef: does NOT clobber it.
* 3a. Upgrade + isLocalClaude + use_worktrees absent/true → auto-applies baseRef.
* 3b. Upgrade + use_worktrees === false → does NOT apply baseRef.
* 3c. Upgrade + explicit baseRef already present (local or shared) → unchanged (no-clobber).
* 4. Idempotency: re-running a fresh-style install when baseRef is already "head"
* does not duplicate or error.
* 5. Global Claude install: does NOT set worktree.baseRef (only local Claude).
*/
'use strict';
process.env.GSD_TEST_MODE = '1';
const { describe, test, before, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { execFileSync } = require('child_process');
const INSTALL_SRC = path.join(__dirname, '..', 'bin', 'install.js');
const BUILD_SCRIPT = path.join(__dirname, '..', 'scripts', 'build-hooks.js');
const { install, finishInstall } = require(INSTALL_SRC);
const { cleanup } = require('./helpers.cjs');
// ─── Ensure hooks/dist/ is populated before install tests ────────────────────
before(() => {
execFileSync(process.execPath, [BUILD_SCRIPT], {
encoding: 'utf-8',
stdio: 'pipe',
});
});
// ─── Helper: run both install phases (mirrors installAllRuntimes two-phase) ──
function runInstall(isGlobal, opts = {}) {
const { shouldInstallStatusline = false } = opts;
const result = install(isGlobal, 'claude');
finishInstall(
result.settingsPath,
result.settings,
result.statuslineCommand,
shouldInstallStatusline,
'claude',
isGlobal
);
return { result };
}
// ─── Helper: write .planning/config.json in the project root ─────────────────
// For a local Claude install, targetDir = <cwd>/.claude, so project root = cwd.
// .planning/config.json lives at <cwd>/.planning/config.json.
function writePlanningConfig(projectRoot, config) {
const planningDir = path.join(projectRoot, '.planning');
fs.mkdirSync(planningDir, { recursive: true });
fs.writeFileSync(path.join(planningDir, 'config.json'), JSON.stringify(config, null, 2) + '\n');
}
// ─── Case 1: fresh local install writes worktree.baseRef:"head" ──────────────
describe('#683 case 1: fresh local Claude install sets worktree.baseRef:"head"', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-fresh-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('settings.local.json contains worktree.baseRef:"head" after fresh install', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
runInstall(false);
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
assert.ok(
fs.existsSync(localSettingsPath),
'.claude/settings.local.json must exist after local Claude install'
);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.ok(
settings && typeof settings === 'object',
'settings.local.json must be a valid JSON object'
);
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
'head',
'worktree.baseRef must be "head" after a fresh local Claude install (#683)'
);
});
});
// ─── Case 2: fresh install does not clobber a pre-existing explicit baseRef ──
describe('#683 case 2: fresh install does not clobber existing explicit worktree.baseRef', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-noclobber-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('pre-existing explicit baseRef is preserved on fresh install', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Pre-populate settings.local.json with an explicit non-"head" baseRef
const claudeDir = path.join(tmpDir, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
fs.writeFileSync(localSettingsPath, JSON.stringify({ worktree: { baseRef: 'main' } }, null, 2) + '\n');
runInstall(false);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
'main',
'An explicit worktree.baseRef must not be overwritten by the installer (#683 no-clobber)'
);
});
});
// ─── Case 3a: upgrade + use_worktrees absent → auto-applies baseRef ──────────
describe('#683 case 3a: upgrade + use_worktrees absent → auto-applies worktree.baseRef', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-upgrade-on-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('upgrade auto-applies worktree.baseRef:"head" when use_worktrees is absent', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Simulate a prior install by pre-creating the VERSION file.
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
fs.writeFileSync(versionPath, '1.0.0');
// No .planning/config.json — use_worktrees defaults to enabled (true).
runInstall(false);
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
assert.ok(
fs.existsSync(localSettingsPath),
'.claude/settings.local.json must exist after upgrade'
);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
'head',
'upgrade must auto-apply worktree.baseRef:"head" when use_worktrees is absent (#683)'
);
});
test('upgrade auto-applies worktree.baseRef:"head" when use_worktrees is true', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Simulate a prior install by pre-creating the VERSION file.
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
fs.writeFileSync(versionPath, '1.0.0');
// .planning/config.json with use_worktrees: true
writePlanningConfig(tmpDir, { workflow: { use_worktrees: true } });
runInstall(false);
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
assert.ok(fs.existsSync(localSettingsPath), '.claude/settings.local.json must exist after upgrade');
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
'head',
'upgrade must auto-apply worktree.baseRef:"head" when use_worktrees:true (#683)'
);
});
});
// ─── Case 3b: upgrade + use_worktrees === false → does NOT apply baseRef ─────
describe('#683 case 3b: upgrade + use_worktrees:false → does NOT apply worktree.baseRef', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-upgrade-off-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('upgrade does not apply worktree.baseRef when use_worktrees is false', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Simulate a prior install by pre-creating the VERSION file.
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
fs.writeFileSync(versionPath, '1.0.0');
// .planning/config.json with use_worktrees: false
writePlanningConfig(tmpDir, { workflow: { use_worktrees: false } });
runInstall(false);
// finishInstall always writes settings.local.json for local Claude installs.
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
assert.ok(
fs.existsSync(localSettingsPath),
'.claude/settings.local.json must exist after upgrade (finishInstall writes it)'
);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree,
undefined,
'upgrade must NOT apply worktree block when use_worktrees:false (#683)'
);
});
});
// ─── Case 3c: upgrade + explicit baseRef already present → no-clobber ─────────
describe('#683 case 3c: upgrade + explicit baseRef present → no-clobber (unchanged)', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-upgrade-noclobber-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('upgrade preserves an explicit worktree.baseRef set by the user in local settings', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Simulate prior install with user-set explicit baseRef
const claudeDir = path.join(tmpDir, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const versionPath = path.join(claudeDir, 'gsd-core', 'VERSION');
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
fs.writeFileSync(versionPath, '1.0.0');
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
fs.writeFileSync(localSettingsPath, JSON.stringify({ worktree: { baseRef: 'fresh' } }, null, 2) + '\n');
runInstall(false);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
'fresh',
'upgrade must preserve an explicit user-set worktree.baseRef in local settings (#683 no-clobber)'
);
});
test('upgrade does not inject baseRef when shared settings.json already has one', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Simulate prior install
const claudeDir = path.join(tmpDir, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const versionPath = path.join(claudeDir, 'gsd-core', 'VERSION');
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
fs.writeFileSync(versionPath, '1.0.0');
// Shared settings.json has an explicit baseRef; settings.local.json does not.
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
fs.writeFileSync(sharedSettingsPath, JSON.stringify({ worktree: { baseRef: 'main' } }, null, 2) + '\n');
runInstall(false);
// finishInstall always writes settings.local.json for local Claude installs.
// Shared no-clobber: sharedBaseRef !== null → installer must not inject.
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
assert.ok(
fs.existsSync(localSettingsPath),
'.claude/settings.local.json must exist after upgrade (finishInstall writes it)'
);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
undefined,
'upgrade must NOT inject worktree.baseRef to settings.local.json when shared settings.json already has one (#683 no-clobber)'
);
});
});
// ─── Case 4: idempotency — re-running fresh-style install when already "head" ─
describe('#683 case 4: idempotency — re-installing when worktree.baseRef already "head"', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-idem-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('second install does not error or duplicate worktree block', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Run once (sets baseRef:"head")
runInstall(false);
// Remove the VERSION file to simulate a fresh-style re-install (e.g. forced reinstall)
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
if (fs.existsSync(versionPath)) {
fs.unlinkSync(versionPath);
}
// Run again — should be idempotent
runInstall(false);
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
'head',
'worktree.baseRef must still be "head" after idempotent re-install (#683)'
);
// Ensure worktree block wasn't duplicated into an array or otherwise corrupted
assert.strictEqual(
typeof settings.worktree,
'object',
'worktree must be a plain object after idempotent re-install'
);
assert.ok(
!Array.isArray(settings.worktree),
'worktree must not have been duplicated into an array'
);
});
});
// ─── Case 2b (FIX 1): fresh install does not clobber baseRef set in shared settings.json ──
describe('#683 case 2b (FIX 1): fresh install does not clobber worktree.baseRef in shared settings.json', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-shared-noclobber-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('shared settings.json with worktree.baseRef:"fresh" → installer must NOT write baseRef to settings.local.json and must NOT print ✓ notice', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Pre-populate only the SHARED settings.json with an explicit baseRef.
// settings.local.json does NOT exist — this is a fresh install otherwise.
const claudeDir = path.join(tmpDir, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const sharedSettingsPath = path.join(claudeDir, 'settings.json');
fs.writeFileSync(sharedSettingsPath, JSON.stringify({ worktree: { baseRef: 'fresh' } }, null, 2) + '\n');
runInstall(false);
// settings.local.json must either not exist or have no worktree.baseRef.
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
if (fs.existsSync(localSettingsPath)) {
const localSettings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
localSettings.worktree && localSettings.worktree.baseRef,
undefined,
'installer must NOT write worktree.baseRef to settings.local.json when shared settings.json already has an explicit baseRef (#683 FIX 1)'
);
}
// If settings.local.json wasn't written, the test passes (no injection occurred).
});
});
// ─── Case 6 (FIX 1): non-object settings.local.json does not crash installer ──
describe('#683 FIX 1: non-object settings.local.json does not crash the installer', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-nonobj-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('settings.local.json containing [] does not throw during fresh local install', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// Pre-populate settings.local.json with an array — valid JSON but non-object
const claudeDir = path.join(tmpDir, '.claude');
fs.mkdirSync(claudeDir, { recursive: true });
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
fs.writeFileSync(localSettingsPath, '[]');
// Must not throw — baseRef logic must be silently skipped for non-objects
assert.doesNotThrow(() => runInstall(false));
});
test('settings.local.json containing "null" JSON value does not crash via #683 block (FIX 1 guard)', () => {
// The #683 block guard check: `settings !== null && typeof settings === 'object' && !Array.isArray(settings)`
// For the array case the crash was directly applyWorktreeBaseRef([]). Test the guard in isolation
// by verifying applyWorktreeBaseRef is not called with a non-object.
// (A literal null parses and readSettings returns null → hits the null early-return, so no crash.)
// This test verifies the guard path — checking that readSettings returning null before #683 is handled.
// The array case below covers the actual fix.
assert.ok(true, 'placeholder: null is handled by the null early-return above the #683 block');
});
});
// ─── Case 7: fresh + use_worktrees:false → does NOT write worktree.baseRef ───
describe('#683 case 7: fresh local Claude install + use_worktrees:false → does NOT set worktree.baseRef', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-fresh-off-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('fresh install with use_worktrees:false does not write worktree.baseRef', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// No VERSION file → fresh install.
// .planning/config.json with use_worktrees: false → must suppress baseRef.
writePlanningConfig(tmpDir, { workflow: { use_worktrees: false } });
runInstall(false);
// finishInstall always writes settings.local.json for local Claude installs.
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
assert.ok(
fs.existsSync(localSettingsPath),
'.claude/settings.local.json must exist after fresh local Claude install'
);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree,
undefined,
'fresh install must NOT write worktree.baseRef when use_worktrees:false (#683 FIX A)'
);
});
test('fresh install with absent .planning/config.json still applies worktree.baseRef (default enabled)', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// No VERSION file → fresh install.
// No .planning/config.json → worktreesEnabled defaults to true.
runInstall(false);
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
assert.ok(
fs.existsSync(localSettingsPath),
'.claude/settings.local.json must exist after fresh local Claude install'
);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
'head',
'fresh install must apply worktree.baseRef:"head" when .planning/config.json is absent (default enabled; #683 FIX A)'
);
});
});
// ─── Case 8: upgrade idempotency — two upgrade runs produce exactly one baseRef ─
describe('#683 case 8: upgrade→upgrade idempotency — two upgrade runs do not duplicate worktree.baseRef', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-idem2-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('two consecutive upgrade runs leave settings.local.json with exactly one worktree.baseRef:"head"', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
process.chdir(tmpDir);
// First upgrade run: VERSION present → upgrade path.
const versionPath = path.join(tmpDir, '.claude', 'gsd-core', 'VERSION');
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
fs.writeFileSync(versionPath, '1.0.0');
// use_worktrees defaults to enabled (no .planning/config.json)
runInstall(false);
// Restore VERSION so the second run is also an upgrade.
if (!fs.existsSync(versionPath)) {
fs.mkdirSync(path.dirname(versionPath), { recursive: true });
}
fs.writeFileSync(versionPath, '1.0.0');
runInstall(false);
const localSettingsPath = path.join(tmpDir, '.claude', 'settings.local.json');
assert.ok(
fs.existsSync(localSettingsPath),
'.claude/settings.local.json must exist after two upgrade runs'
);
const settings = JSON.parse(fs.readFileSync(localSettingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree && settings.worktree.baseRef,
'head',
'worktree.baseRef must be "head" after two upgrade runs (#683 idempotency)'
);
assert.strictEqual(
typeof settings.worktree,
'object',
'worktree must be a plain object after two upgrade runs'
);
assert.ok(
!Array.isArray(settings.worktree),
'worktree must not have been duplicated into an array after two upgrade runs'
);
});
});
// ─── Case 5: global Claude install does NOT set worktree.baseRef ─────────────
describe('#683 case 5: global Claude install does NOT set worktree.baseRef', () => {
let tmpDir;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-683-global-'));
});
afterEach(() => {
cleanup(tmpDir);
});
test('global install does not write worktree.baseRef', (t) => {
const origCwd = process.cwd();
t.after(() => { process.chdir(origCwd); });
// Point CLAUDE_CONFIG_DIR at a tmpDir subdir to avoid polluting ~/.claude
const configDir = path.join(tmpDir, '.claude');
fs.mkdirSync(configDir, { recursive: true });
const origEnv = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = configDir;
t.after(() => {
if (origEnv === undefined) {
delete process.env.CLAUDE_CONFIG_DIR;
} else {
process.env.CLAUDE_CONFIG_DIR = origEnv;
}
});
runInstall(true);
const settingsPath = path.join(configDir, 'settings.json');
if (fs.existsSync(settingsPath)) {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
assert.strictEqual(
settings.worktree,
undefined,
'global Claude install must not write worktree.baseRef into settings.json (#683)'
);
}
});
});