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

@@ -31,6 +31,10 @@ const {
const {
resolveAntigravityGlobalDir,
} = require('../gsd-core/bin/lib/runtime-homes.cjs');
const {
applyWorktreeBaseRef,
readBaseRefFromSettings,
} = require('../gsd-core/bin/lib/worktree-base-ref.cjs');
/**
* Runtimes that register hyphen-form `name:` per #2808 AND copy agent bodies
@@ -8585,6 +8589,10 @@ function install(isGlobal, runtime = 'claude', options = {}) {
// agentsSrc is declared here (let, not const) because installCodexConfig() inside the
// Codex config block below also references it, and that block is outside the try scope.
let agentsSrc = path.join(src, 'agents');
// Capture upgrade signal BEFORE files are written (#683). Must be declared at function
// scope (outside the try block below) so it is accessible in the settings section later.
// Absent VERSION = fresh install; present VERSION = upgrade/re-install.
const priorInstallExisted = fs.existsSync(path.join(targetDir, 'gsd-core', 'VERSION'));
try {
installerMigrationResult = runInstallerMigrations({
configDir: targetDir,
@@ -10116,6 +10124,68 @@ function install(isGlobal, runtime = 'claude', options = {}) {
? buildHookCommand(targetDir, 'gsd-update-banner.js', hookOpts)
: localCmd('gsd-update-banner.js'));
// #683: Set worktree.baseRef:"head" in settings.local.json for local Claude installs.
// Both fresh and upgrade paths apply only when worktrees are enabled for the project.
// Never applies to global installs, non-Claude runtimes, or when the user already
// has an explicit baseRef in EITHER settings.local.json OR settings.json (no-clobber).
// Guard: skip entirely when settings is not a plain object (e.g. parsed to [] or primitive)
// to avoid crashing applyWorktreeBaseRef on unexpected top-level shapes.
if (isLocalClaude && settings !== null && typeof settings === 'object' && !Array.isArray(settings)) {
// Read shared settings.json baseRef so no-clobber spans both files (#683 FIX 1).
// shared settings.json no-clobber is checked here; settings.local.json no-clobber
// is enforced inside applyWorktreeBaseRef itself.
const sharedSettingsForBaseRef = readSettings(path.join(targetDir, 'settings.json')) || {};
const sharedBaseRef = readBaseRefFromSettings(sharedSettingsForBaseRef);
// Compute worktrees-enabled ONCE for both fresh and upgrade paths (FIX A: DRY + consistency).
// Read workflow.use_worktrees from .planning/config.json by walking up from
// targetDir (same walk-up pattern as readGsdRuntimeProfileResolver). Defaults
// to enabled (true) when the file is missing, unreadable, or the key is absent;
// only boolean false disables (string "false" stays enabled).
let worktreesEnabled = true; // default: enabled
try {
let probeDir = path.resolve(targetDir);
for (let depth = 0; depth < 8; depth += 1) {
const candidate = path.join(probeDir, '.planning', 'config.json');
if (fs.existsSync(candidate)) {
try {
const parsed = JSON.parse(stripJsonComments(fs.readFileSync(candidate, 'utf-8')));
if (parsed && typeof parsed === 'object' &&
parsed.workflow && parsed.workflow.use_worktrees === false) {
worktreesEnabled = false;
}
} catch {
// Malformed config.json — treat as enabled (safe fallback).
}
break;
}
const parent = path.dirname(probeDir);
if (parent === probeDir) break;
probeDir = parent;
}
} catch {
// Any unexpected error reading .planning — default to enabled.
}
if (worktreesEnabled && sharedBaseRef === null) {
if (!priorInstallExisted) {
// Fresh install — apply no-clobber baseRef set.
// canonical no-clobber logic: src/worktree-base-ref.cts applyWorktreeBaseRef (#683)
const { changed } = applyWorktreeBaseRef(settings);
if (changed) {
console.log(` ${green}✓${reset} Set worktree.baseRef:"head" for Claude Code worktrees (forks phase worktrees off HEAD; #683)`);
}
} else {
// Upgrade — auto-apply no-clobber baseRef set when worktrees are enabled.
const { changed } = applyWorktreeBaseRef(settings);
if (changed) {
console.log(` ${green}✓${reset} Enabled worktree.baseRef:"head" for Claude Code worktrees (forks phase worktrees off HEAD; #683)`);
}
}
}
// When worktreesEnabled is false: do nothing, print nothing (both fresh and upgrade).
}
persistActiveProfileMarker();
return {
settingsPath,