* 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:
5
.changeset/noble-cranes-sing.md
Normal file
5
.changeset/noble-cranes-sing.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 749
|
||||
---
|
||||
**Phase execution no longer halts with `exit 42` (worktree base mismatch) when run on a branch diverged from the default branch (#683).** Claude Code forks worktree-isolated executors off the repository default branch (`origin/HEAD`), so running `/gsd-execute-phase` on an unmerged milestone/feature branch left every executor without the phase's plan files and tripped the `worktree-branch-check` guard (100% reproducible, all OSes). Execute-phase now detects this before dispatch and automatically degrades to sequential execution on the main working tree, recommending the permanent fix `worktree.baseRef:"head"`. Both fresh installs and upgrades of GSD Core set `worktree.baseRef:"head"` in `.claude/settings.local.json` automatically (no-clobber) when `workflow.use_worktrees` is enabled (the default); `gsd-tools worktree set-baseref` remains available for manual use (e.g. after toggling worktrees on later). The `exit 42` guard remains as a backstop.
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -118,6 +118,7 @@ build/
|
||||
/gsd-core/bin/lib/install-profiles.cjs
|
||||
/gsd-core/bin/lib/intel.cjs
|
||||
/gsd-core/bin/lib/installer-migrations.cjs
|
||||
/gsd-core/bin/lib/worktree-base-ref.cjs
|
||||
/gsd-core/bin/lib/worktree-safety.cjs
|
||||
/gsd-core/bin/lib/planning-workspace.cjs
|
||||
/gsd-core/bin/lib/runtime-artifact-layout.cjs
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -421,6 +421,34 @@ node gsd-tools.cjs websearch <query> [--limit N] [--freshness day|week|month]
|
||||
|
||||
---
|
||||
|
||||
## Worktree Commands
|
||||
|
||||
Diagnose and configure the worktree fork base used by Claude Code's `isolation="worktree"` executor dispatch. These commands address the branch-divergence condition described in [Fix the worktree base-mismatch (exit 42) error](how-to/fix-worktree-base-mismatch.md).
|
||||
|
||||
```bash
|
||||
# Check whether the current HEAD has diverged from the worktree fork base.
|
||||
# Returns JSON: { shouldDegrade, reason, message, headSha, forkRef, forkSha }
|
||||
node gsd-tools.cjs worktree base-check
|
||||
|
||||
# Write worktree.baseRef:"head" into .claude/settings.local.json (no-clobber).
|
||||
# Returns JSON: { changed, skipped, previous, baseRef, file }
|
||||
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:
|
||||
|
||||
| `reason` | `shouldDegrade` | Meaning |
|
||||
|---|---|---|
|
||||
| `baseref-head` | `false` | `worktree.baseRef:"head"` is set; no mismatch possible |
|
||||
| `head-matches-fork` | `false` | HEAD and `origin/HEAD` are the same commit |
|
||||
| `head-diverged-from-fork` | `true` | Branch is ahead of or diverged from `origin/HEAD` |
|
||||
| `fork-ref-unknown` | `true` | `origin/HEAD` could not be resolved |
|
||||
| `no-head` | `false` | Not in a git repo (no `HEAD`) |
|
||||
|
||||
**`worktree set-baseref`** applies a no-clobber write of `worktree.baseRef:"head"` to `.claude/settings.local.json`. If the file already contains an explicit `baseRef` value other than `"head"`, the existing value is preserved and `skipped:"explicit-other"` is returned. Malformed JSON causes an error rather than a silent overwrite. Both fresh installs and upgrades of GSD Core run this automatically when `workflow.use_worktrees` is enabled (the default); the command is also available for manual use — for example, to apply the setting when worktrees were toggled on after installation, or to re-apply it after a settings change.
|
||||
|
||||
---
|
||||
|
||||
## Graphify
|
||||
|
||||
Build, query, and inspect the project knowledge graph in `.planning/graphs/`. Requires `graphify.enabled: true` in `config.json` (see [Configuration Reference](CONFIGURATION.md#graphify-settings)).
|
||||
@@ -471,6 +499,7 @@ User-facing entry point: `/gsd-graphify` (see [Command Reference](COMMANDS.md#gs
|
||||
| Audit | `lib/audit.cjs` | Phase/milestone audit queue handlers; `audit-open` helper |
|
||||
| GSD2 Import | `lib/gsd2-import.cjs` | Reverse-migration importer from GSD-2 projects (backs `/gsd-import --from-gsd2`) |
|
||||
| Intel | `lib/intel.cjs` | Queryable codebase intelligence index (backs `/gsd-map-codebase --query`) |
|
||||
| Worktree Base Ref | `lib/worktree-base-ref.cjs` | Worktree fork-base detection and `worktree base-check` / `set-baseref` commands (#683) |
|
||||
|
||||
---
|
||||
|
||||
@@ -498,4 +527,5 @@ API keys configured via `/gsd-settings` (`brave_search`, `firecrawl`, `exa_searc
|
||||
- [Commands](COMMANDS.md)
|
||||
- [Configuration](CONFIGURATION.md)
|
||||
- [Architecture](ARCHITECTURE.md)
|
||||
- [Fix the worktree base-mismatch (exit 42) error](how-to/fix-worktree-base-mismatch.md)
|
||||
- [docs index](README.md)
|
||||
|
||||
@@ -245,7 +245,7 @@ All workflow toggles follow the **absent = enabled** pattern. If a key is missin
|
||||
| `workflow.max_discuss_passes` | number | `3` | Maximum number of question rounds in discuss-phase before the workflow stops asking. Useful in headless/auto mode to prevent infinite discussion loops. |
|
||||
| `workflow.skip_discuss` | boolean | `false` | When `true`, `/gsd-autonomous` bypasses the discuss-phase entirely, writing minimal CONTEXT.md from the ROADMAP phase goal. Useful for projects where developer preferences are fully captured in PROJECT.md/REQUIREMENTS.md. Added in v1.28 |
|
||||
| `workflow.text_mode` | boolean | `false` | Replaces AskUserQuestion TUI menus with plain-text numbered lists. Required for Claude Code remote sessions (`/rc` mode) where TUI menus don't render. Can also be set per-session with `--text` flag on discuss-phase. Added in v1.28 |
|
||||
| `workflow.use_worktrees` | boolean | `true` | When `false`, disables git worktree isolation for parallel execution. Users who prefer sequential execution or whose environment does not support worktrees can disable this. Added in v1.31 |
|
||||
| `workflow.use_worktrees` | boolean | `true` | When `false`, disables git worktree isolation for parallel execution. Users who prefer sequential execution or whose environment does not support worktrees can disable this. Added in v1.31. **Branch-divergence note:** when your branch is ahead of `origin/HEAD`, GSD auto-degrades to sequential and prints a warning. Set `worktree.baseRef:"head"` in `.claude/settings.local.json` (run `node gsd-tools.cjs worktree set-baseref`) to restore parallel execution. See [Fix the worktree base-mismatch (exit 42) error](how-to/fix-worktree-base-mismatch.md). |
|
||||
| `workflow.worktree_skip_hooks` | boolean | `false` | When `true`, executor agents in worktree mode pass `--no-verify` (skipping pre-commit hooks) and post-wave hook validation runs against the merged result instead. Opt-in escape hatch for projects whose hooks cannot run in agent worktrees. Default `false` runs hooks on every commit (#2924). |
|
||||
| `workflow.code_review` | boolean | `true` | Enable `/gsd-code-review` and `/gsd-code-review --fix` commands. When `false`, the commands exit with a configuration gate message. Added in v1.34 |
|
||||
| `workflow.code_review_depth` | string | `standard` | Default review depth for `/gsd-code-review`: `quick` (pattern-matching only), `standard` (per-file analysis), or `deep` (cross-file with import graphs). Can be overridden per-run with `--depth=`. Added in v1.34 |
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"generated": "2026-06-06",
|
||||
"generated": "2026-06-07",
|
||||
"families": {
|
||||
"agents": [
|
||||
"gsd-advisor-researcher",
|
||||
@@ -350,6 +350,7 @@
|
||||
"workstream-inventory.cjs",
|
||||
"workstream-name-policy.cjs",
|
||||
"workstream.cjs",
|
||||
"worktree-base-ref.cjs",
|
||||
"worktree-safety.cjs"
|
||||
],
|
||||
"hooks": [
|
||||
|
||||
@@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
|
||||
|
||||
---
|
||||
|
||||
## CLI Modules (86 shipped)
|
||||
## CLI Modules (87 shipped)
|
||||
|
||||
Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||
|
||||
@@ -461,6 +461,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||
| `workstream-inventory.cjs` | Shared workstream inventory projection: state fields, phase/plan/summary counts, roadmap phase count, and active marker — thin orchestrator that delegates pure projection to `workstream-inventory-builder.cjs` |
|
||||
| `workstream-name-policy.cjs` | Canonical workstream name validation (`isValidActiveWorkstreamName`, `hasInvalidPathSegment`, `validateWorkstreamName`) and slug normalization (`toWorkstreamSlug`) |
|
||||
| `workstream.cjs` | Workstream CRUD, migration, session-scoped active pointer |
|
||||
| `worktree-base-ref.cjs` | Worktree base-ref drift detection and degrade decision (`evaluateWorktreeBaseDegrade`) plus no-clobber `worktree.baseRef` settings management for the `base-check`/`set-baseref` subcommands (#683) |
|
||||
| `worktree-safety.cjs` | Worktree-root resolution and non-destructive prune policy decisions; owns W017 health-check logic |
|
||||
|
||||
[`docs/CLI-TOOLS.md`](CLI-TOOLS.md) may describe a subset of these modules; when it disagrees with the filesystem, this table and the directory listing are authoritative.
|
||||
|
||||
@@ -33,6 +33,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md)
|
||||
- [Migrate from GSD 2](how-to/migrate-from-gsd-2.md) — upgrade an existing GSD 2 project to GSD Core
|
||||
- [Update GSD](how-to/update-gsd.md) — re-run the installer to pick up the latest release
|
||||
- [Clean up get-shit-done-cc](cleanup-get-shit-done-cc.md) — remove leftover old-package artifacts that cause a spurious `⬆ /gsd:update` indicator after migrating to `@opengsd/gsd-core`
|
||||
- [Fix the worktree base-mismatch (exit 42) error](how-to/fix-worktree-base-mismatch.md) — resolve the branch-divergence condition that halts parallel phase execution
|
||||
- [Recover and troubleshoot](how-to/recover-and-troubleshoot.md) — fix common problems, rebuild context, and uninstall
|
||||
|
||||
---
|
||||
|
||||
143
docs/how-to/fix-worktree-base-mismatch.md
Normal file
143
docs/how-to/fix-worktree-base-mismatch.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# How to fix the worktree base-mismatch (exit 42) error
|
||||
|
||||
**Goal:** Understand why `/gsd-execute-phase` halts with `FATAL: worktree base mismatch` / exit 42 when your branch is ahead of the default branch, and choose the right fix to restore normal — or parallel — execution.
|
||||
|
||||
**Prerequisites:** GSD Core is installed and you have an active project. You have run `/gsd-execute-phase` and either seen the exit-42 error or the one-line `⚠ Worktree base mismatch` warning.
|
||||
|
||||
---
|
||||
|
||||
## What you will see
|
||||
|
||||
When you run `/gsd-execute-phase` on a branch that is ahead of the repository's default branch (for example, an unmerged milestone branch, a long-lived feature branch, or a branch with commits not yet in `origin/HEAD`), you may see one of two messages:
|
||||
|
||||
**Automatic-degrade warning (phase still completes):**
|
||||
|
||||
```
|
||||
⚠ Worktree base mismatch: HEAD (abc12345) differs from origin/HEAD (def67890).
|
||||
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.
|
||||
```
|
||||
|
||||
The phase runs to completion sequentially; nothing is blocked. This is the runtime mitigation.
|
||||
|
||||
**Exit-42 halt (older installs or misconfigured environments):**
|
||||
|
||||
```
|
||||
FATAL: worktree base mismatch
|
||||
```
|
||||
|
||||
All worktree-isolated executors halt immediately. Zero progress is made.
|
||||
|
||||
---
|
||||
|
||||
## Why this happens
|
||||
|
||||
Claude Code's `isolation="worktree"` forks executor worktrees from the repository's default branch (`origin/HEAD`), not from your current `HEAD`. When your branch contains commits that `origin/HEAD` does not have — plan files, new source files, anything added since the branch diverged — those files are absent inside each worktree. GSD's `worktree-branch-check` safety guard correctly refuses to act on a worktree that does not match the orchestrator's state, and exits with code 42.
|
||||
|
||||
This is the guard working as designed: it prevents silent data loss or phantom edits in the wrong tree. The error is a branch-state condition, not an OS-specific or hardware issue.
|
||||
|
||||
---
|
||||
|
||||
## Option 1 — Do nothing (you are already unblocked)
|
||||
|
||||
If you saw the `⚠ Worktree base mismatch` warning rather than an exit-42 halt, GSD has already automatically degraded to sequential execution on the main working tree for this run. The phase will complete. No action is required.
|
||||
|
||||
Use this option when:
|
||||
|
||||
- You are on a diverged branch temporarily
|
||||
- You do not care about parallel execution for this phase
|
||||
- You want to merge back to the default branch soon
|
||||
|
||||
---
|
||||
|
||||
## Option 2 — Permanent fix: set `worktree.baseRef: "head"` (recommended)
|
||||
|
||||
This option restores parallel worktree execution on diverged branches. It tells Claude Code to fork executor worktrees from your current `HEAD` instead of `origin/HEAD`, so the plan files and branch-only commits are present in every worktree.
|
||||
|
||||
Run the convenience command from your project root:
|
||||
|
||||
```bash
|
||||
node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" worktree set-baseref
|
||||
```
|
||||
|
||||
This writes `worktree.baseRef: "head"` into `.claude/settings.local.json` in your project root. It is no-clobber: if you already have an explicit `baseRef` set to something else, it leaves your value in place and tells you.
|
||||
|
||||
To verify the result:
|
||||
|
||||
```bash
|
||||
node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" worktree base-check
|
||||
```
|
||||
|
||||
The output is JSON. When `shouldDegrade` is `false` and `reason` is `"baseref-head"`, parallel worktrees will work on any branch.
|
||||
|
||||
Alternatively, set the value by hand in `.claude/settings.local.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"worktree": {
|
||||
"baseRef": "head"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Fresh installs and upgrades of GSD Core both set `worktree.baseRef:"head"` automatically in `.claude/settings.local.json` (no-clobber) when `workflow.use_worktrees` is enabled (the default). You can also apply or re-apply it manually at any time with `gsd-tools worktree set-baseref` — for example, if you toggled worktrees on after the initial install.
|
||||
|
||||
Use this option when:
|
||||
|
||||
- You regularly work on long-lived or milestone branches
|
||||
- You want parallel phase execution (faster, lower context-window pressure)
|
||||
- You are a solo developer or team working on a feature branch for an extended period
|
||||
|
||||
---
|
||||
|
||||
## Option 3 — Fallback: disable worktrees entirely
|
||||
|
||||
If worktrees are causing persistent problems beyond the base-mismatch (for example, your environment does not support them), disable them permanently for this project:
|
||||
|
||||
Add or edit `.planning/config.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"workflow": {
|
||||
"use_worktrees": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
All executor agents will then run sequentially on the main working tree for every phase. This is equivalent to what the automatic degrade does, but permanent.
|
||||
|
||||
Use this option when:
|
||||
|
||||
- Worktrees are consistently problematic in your environment
|
||||
- You prefer sequential execution for auditability or tooling reasons
|
||||
- You are on a platform or CI setup that does not support git worktrees
|
||||
|
||||
See also: [`workflow.use_worktrees`](../CONFIGURATION.md#workflow-toggles) in the configuration reference.
|
||||
|
||||
---
|
||||
|
||||
## The exit-42 backstop
|
||||
|
||||
The `worktree-branch-check` guard (exit 42) remains active in all execution modes as a safety backstop. It fires only when an executor worktree's branch does not match the expected orchestrator state — a condition that should not arise once you have applied one of the options above. If you continue to see exit 42 after setting `worktree.baseRef: "head"`, run `/gsd-forensics` to investigate.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Situation | Recommended action |
|
||||
|-----------|-------------------|
|
||||
| Saw the warning, phase completed | Nothing — degrade handled it automatically |
|
||||
| Regularly on diverged branches, want parallel execution | `worktree set-baseref` (Option 2) |
|
||||
| Worktrees consistently problematic | Set `workflow.use_worktrees: false` (Option 3) |
|
||||
| Still seeing exit 42 after fixes | Run `/gsd-forensics "exit 42 after fix"` |
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [Recover and troubleshoot](recover-and-troubleshoot.md)
|
||||
- [Debug a failed execution](debug-a-failed-execution.md)
|
||||
- [Configuration reference — workflow toggles](../CONFIGURATION.md#workflow-toggles)
|
||||
- [CLI Tools reference — worktree commands](../CLI-TOOLS.md#worktree-commands)
|
||||
- [docs index](../README.md)
|
||||
@@ -153,6 +153,18 @@ Check whether the plan is too ambitious. Plans should have two or three tasks at
|
||||
|
||||
For systematic diagnosis of what went wrong, see [Debug a failed execution](debug-a-failed-execution.md).
|
||||
|
||||
### If you see "FATAL: worktree base mismatch" or the exit-42 warning
|
||||
|
||||
This happens when your current branch is ahead of the repository's default branch (for example, an unmerged milestone or feature branch). Claude Code forks executor worktrees from `origin/HEAD`, not your `HEAD`, so plan files that exist only on your branch are absent inside the worktree.
|
||||
|
||||
Since the fix landed, GSD automatically degrades to sequential execution on the main working tree and prints a one-line warning — the phase will complete without any action from you. To restore parallel execution permanently, run:
|
||||
|
||||
```bash
|
||||
node "$HOME/.claude/gsd-core/bin/gsd-tools.cjs" worktree set-baseref
|
||||
```
|
||||
|
||||
For a full explanation and all available options, see [Fix the worktree base-mismatch (exit 42) error](fix-worktree-base-mismatch.md).
|
||||
|
||||
### If parallel execution causes build lock errors or pre-commit hook failures
|
||||
|
||||
This is caused by multiple agents triggering build tools simultaneously. GSD handles this automatically since v1.26. If you are on an older version, or still seeing contention, disable parallel execution:
|
||||
@@ -312,12 +324,14 @@ Also audit which MCP servers are enabled. Every enabled MCP server injects its t
|
||||
| Update broke local changes | `/gsd-update --reapply` |
|
||||
| Want session summary | `/gsd-pause-work --report` |
|
||||
| Parallel execution build errors | Update GSD or set `parallelization.enabled: false` |
|
||||
| Worktree base mismatch / exit 42 | Auto-degraded to sequential (no action needed); run `worktree set-baseref` to restore parallelism |
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [Debug a failed execution](debug-a-failed-execution.md)
|
||||
- [Fix the worktree base-mismatch (exit 42) error](fix-worktree-base-mismatch.md)
|
||||
- [Install on your runtime](install-on-your-runtime.md)
|
||||
- [Commands](../COMMANDS.md)
|
||||
- [docs index](../README.md)
|
||||
|
||||
@@ -83,6 +83,7 @@ export default tseslint.config(
|
||||
'gsd-core/bin/lib/intel.cjs',
|
||||
'gsd-core/bin/lib/installer-migrations.cjs',
|
||||
'gsd-core/bin/lib/worktree-safety.cjs',
|
||||
'gsd-core/bin/lib/worktree-base-ref.cjs',
|
||||
'gsd-core/bin/lib/planning-workspace.cjs',
|
||||
'gsd-core/bin/lib/runtime-artifact-layout.cjs',
|
||||
'gsd-core/bin/lib/command-routing-hub.cjs',
|
||||
|
||||
@@ -1232,8 +1232,12 @@ async function runCommand(command, args, cwd, raw, defaultValue, originalCommand
|
||||
worktreeSafety.cmdWorktreeCleanupWave(cwd, args.slice(2));
|
||||
} else if (subcommand === 'reap-orphans') {
|
||||
worktreeSafety.cmdWorktreeReapOrphans(cwd);
|
||||
} else if (subcommand === 'base-check') {
|
||||
require('./lib/worktree-base-ref.cjs').cmdWorktreeBaseCheck(cwd, args.slice(2));
|
||||
} else if (subcommand === 'set-baseref') {
|
||||
require('./lib/worktree-base-ref.cjs').cmdWorktreeSetBaseRef(cwd, args.slice(2));
|
||||
} else {
|
||||
error('Unknown worktree subcommand. Available: cleanup-wave, reap-orphans', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
error('Unknown worktree subcommand. Available: cleanup-wave, reap-orphans, base-check, set-baseref', ERROR_REASON.SDK_UNKNOWN_COMMAND);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ Configuration options for `.planning/` directory behavior.
|
||||
| `git.phase_branch_template` | `"gsd/phase-{phase}-{slug}"` | Branch template for phase strategy |
|
||||
| `git.milestone_branch_template` | `"gsd/{milestone}-{slug}"` | Branch template for milestone strategy |
|
||||
| `git.quick_branch_template` | `null` | Optional branch template for quick-task runs |
|
||||
| `workflow.use_worktrees` | `true` | Whether executor agents run in isolated git worktrees. Set to `false` to disable worktrees — agents execute sequentially on the main working tree instead. Recommended for solo developers or when worktree merges cause issues. |
|
||||
| `workflow.use_worktrees` | `true` | Whether executor agents run in isolated git worktrees. Set to `false` to disable worktrees — agents execute sequentially on the main working tree instead. Recommended for solo developers or when worktree merges cause issues. Note: if your branch is ahead of `origin/HEAD` (a diverged milestone or feature branch), GSD auto-degrades to sequential and prints a warning; set `worktree.baseRef:"head"` in `.claude/settings.local.json` to restore parallel execution. See the branch-divergence note below. |
|
||||
| `workflow.subagent_timeout` | `300000` | Timeout in milliseconds for parallel subagent tasks (e.g. codebase mapping). Increase for large codebases or slower models. Default: 300000 (5 minutes). |
|
||||
| `workflow.inline_plan_threshold` | `2` | Plans with this many tasks or fewer execute inline (Pattern C) instead of spawning a subagent. Avoids ~14K token spawn overhead for small plans. Set to `0` to always spawn subagents. |
|
||||
| `manager.flags.discuss` | `""` | Flags passed to `/gsd:discuss-phase` when dispatched from manager (e.g. `"--auto --analyze"`) |
|
||||
@@ -385,6 +385,8 @@ Several config fields affect each other or trigger special behavior:
|
||||
|
||||
8. **`sub_repos` auto-sync** -- On every config load, GSD scans for child directories with `.git` and updates the `sub_repos` array if the filesystem has changed. Legacy `multiRepo: true` is automatically migrated to a detected `sub_repos` array.
|
||||
|
||||
9. **`workflow.use_worktrees` and branch divergence** -- When `use_worktrees` is `true` (default), executor worktrees are forked from `origin/HEAD` by the Claude Code harness. If your current branch has commits that `origin/HEAD` does not (for example an unmerged milestone or feature branch), GSD automatically degrades to sequential execution for that run and prints a one-line `⚠ Worktree base mismatch` warning. To restore parallel execution permanently, set `worktree.baseRef:"head"` in `.claude/settings.local.json` (run `node gsd-tools.cjs worktree set-baseref`). This makes the harness fork worktrees from the live HEAD instead of `origin/HEAD`. Both fresh installs and upgrades of GSD Core set this automatically (no-clobber) when `use_worktrees` is enabled; you can also run the command manually at any time. Setting `workflow.use_worktrees: false` is the alternative if worktrees are not needed at all.
|
||||
|
||||
---
|
||||
|
||||
## Example Configurations
|
||||
|
||||
@@ -92,6 +92,16 @@ if [ "$RUNTIME" = "codex" ] && [ "$USE_WORKTREES" != "false" ]; then
|
||||
fi
|
||||
# Sweep orphaned locked worktrees from prior crashed sessions before spawning executors (#3707).
|
||||
[ "$USE_WORKTREES" != "false" ] && gsd_run query worktree.reap-orphans 2>/dev/null || true
|
||||
# Auto-degrade to sequential if HEAD has diverged from the worktree fork base (#683).
|
||||
# Only applies to Claude Code (isolation="worktree" is Claude-Code-specific).
|
||||
if [ "$RUNTIME" = "claude" ] && [ "$USE_WORKTREES" != "false" ]; then
|
||||
_SHOULD_DEGRADE=$(gsd_run query worktree.base-check --pick shouldDegrade 2>/dev/null || true)
|
||||
if [ "$_SHOULD_DEGRADE" = "true" ]; then
|
||||
_DEGRADE_MSG=$(gsd_run query worktree.base-check --pick message 2>/dev/null || true)
|
||||
[ -n "$_DEGRADE_MSG" ] && printf '%s\n' "$_DEGRADE_MSG" >&2
|
||||
USE_WORKTREES=false
|
||||
fi
|
||||
fi
|
||||
```
|
||||
Codex maps subagents to `spawn_agent`, which has no direct Codex mapping for Claude Code's `isolation="worktree"` parameter. Failing closed prevents main-checkout edits while the workflow believes agents are isolated.
|
||||
|
||||
@@ -111,6 +121,8 @@ fi
|
||||
|
||||
When `USE_WORKTREES` (project-level) is `false`, all executor agents run without `isolation="worktree"` — they execute sequentially on the main working tree instead of in parallel worktrees. The per-plan decision below has no effect when worktrees are project-disabled.
|
||||
|
||||
`USE_WORKTREES` is also automatically set to `false` for the duration of a run when `worktree base-check` detects that the orchestrator HEAD has diverged from the worktree fork base (the #683 condition — e.g. an unmerged milestone or feature branch). This check runs only when `RUNTIME=claude` because `isolation="worktree"` is a Claude Code-specific feature; other runtimes do not use it. The auto-degrade prints a one-line warning to stderr and falls through to the sequential path so executors do not hit the exit-42 worktree-branch-check halt. To restore parallel worktree execution, set `worktree.baseRef:"head"` in `.claude/settings.local.json` (or run `gsd-tools worktree set-baseref`) — this makes the fork base track the live HEAD instead of a fixed remote ref. The `worktree-branch-check` exit-42 guard inside each executor remains in place as a backstop.
|
||||
|
||||
Read context window size for adaptive prompt enrichment:
|
||||
|
||||
```bash
|
||||
|
||||
368
src/worktree-base-ref.cts
Normal file
368
src/worktree-base-ref.cts
Normal file
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* Worktree base-ref detection and degradation logic (issue #683).
|
||||
*
|
||||
* Determines whether a worktree's HEAD has drifted from the fork base that the
|
||||
* Claude Code harness would use to create a 'fresh' parallel worktree. When
|
||||
* drift is detected the caller should fall back to sequential execution on the
|
||||
* main working tree to avoid a base mismatch.
|
||||
*
|
||||
* Pure/testable module: all I/O is injectable via the `deps` argument so unit
|
||||
* tests can run without touching the real filesystem or spawning real git.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { execGit as execGitSeam } from './shell-command-projection.cjs';
|
||||
|
||||
// ─── Internal helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Strip JSONC comments (line and block forms) from a string to produce valid JSON.
|
||||
* Handles comments inside strings correctly (does not strip them).
|
||||
* Mirrors the same logic in bin/install.js:stripJsonComments.
|
||||
*/
|
||||
function stripJsonComments(text: string): string {
|
||||
let result = '';
|
||||
let i = 0;
|
||||
let inString = false;
|
||||
let stringChar = '';
|
||||
while (i < text.length) {
|
||||
// Handle string literals — don't strip comments inside strings
|
||||
if (inString) {
|
||||
if (text[i] === '\\') {
|
||||
result += text[i] + (text[i + 1] || '');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (text[i] === stringChar) {
|
||||
inString = false;
|
||||
}
|
||||
result += text[i];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// Start of string
|
||||
if (text[i] === '"' || text[i] === "'") {
|
||||
inString = true;
|
||||
stringChar = text[i];
|
||||
result += text[i];
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
// Line comment
|
||||
if (text[i] === '/' && text[i + 1] === '/') {
|
||||
// Skip to end of line
|
||||
while (i < text.length && text[i] !== '\n') i++;
|
||||
continue;
|
||||
}
|
||||
// Block comment
|
||||
if (text[i] === '/' && text[i + 1] === '*') {
|
||||
i += 2;
|
||||
while (i < text.length && !(text[i] === '*' && text[i + 1] === '/')) i++;
|
||||
i += 2; // skip closing */
|
||||
continue;
|
||||
}
|
||||
result += text[i];
|
||||
i++;
|
||||
}
|
||||
// Remove trailing commas before } or ] (common in JSONC)
|
||||
return result.replace(/,\s*([}\]])/g, '$1');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a string as JSONC (JSON with comments). Returns the parsed value or
|
||||
* throws a SyntaxError if the content is genuinely malformed.
|
||||
*/
|
||||
function parseJsonc(text: string): unknown {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return JSON.parse(stripJsonComments(text));
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Internal types ───────────────────────────────────────────────────────────
|
||||
|
||||
type ExecGitFn = (
|
||||
args: string[],
|
||||
opts?: { cwd?: string; env?: Record<string, string>; timeout?: number }
|
||||
) => { exitCode: number | null; stdout: string; stderr: string; signal: string | null; error: unknown };
|
||||
|
||||
// ─── Message constants (verbatim — downstream docs/tests depend on these) ─────
|
||||
|
||||
function buildMsgDiverged(headSha: string | null, forkRef: string | null, forkSha: string | null): string {
|
||||
return `⚠ Worktree base mismatch: HEAD (${shortSha(headSha)}) differs from ${forkRef} (${shortSha(forkSha)}). Running this phase sequentially on the main working tree. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
|
||||
}
|
||||
|
||||
const MSG_UNKNOWN = `⚠ Cannot determine the worktree fork base (origin/HEAD unresolved). Running this phase sequentially on the main working tree to avoid a base mismatch. To keep parallel worktrees, set worktree.baseRef:"head" in .claude/settings.local.json (or run: gsd-tools worktree set-baseref). See #683.`;
|
||||
|
||||
// ─── Exports ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the first 8 characters of a SHA, or '' if null/empty.
|
||||
*/
|
||||
export function shortSha(sha: string | null): string {
|
||||
if (!sha) return '';
|
||||
return sha.slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts settings.worktree.baseRef if it is a string; otherwise null.
|
||||
* Defensive: settings may be null/undefined, worktree may be missing or
|
||||
* not an object.
|
||||
*/
|
||||
export function readBaseRefFromSettings(settings: unknown): string | null {
|
||||
if (settings == null || typeof settings !== 'object') return null;
|
||||
const s = settings as Record<string, unknown>;
|
||||
if (s.worktree == null || typeof s.worktree !== 'object' || Array.isArray(s.worktree)) return null;
|
||||
const worktree = s.worktree as Record<string, unknown>;
|
||||
if (typeof worktree.baseRef !== 'string') return null;
|
||||
return worktree.baseRef;
|
||||
}
|
||||
|
||||
/**
|
||||
* No-clobber application of worktree.baseRef = 'head'.
|
||||
*
|
||||
* - If baseRef is absent/null/undefined → set to 'head', return changed:true.
|
||||
* - If already 'head' → skip, return skipped:'already-head'.
|
||||
* - If any other string → skip without overwriting, return skipped:'explicit-other'.
|
||||
*
|
||||
* Mutates `settings` in place and also returns it.
|
||||
*/
|
||||
export function applyWorktreeBaseRef(settings: Record<string, unknown>): {
|
||||
changed: boolean;
|
||||
settings: object;
|
||||
skipped: null | 'already-head' | 'explicit-other';
|
||||
previous: string | null;
|
||||
} {
|
||||
// Defensive: caller must pass a plain object — reject null, arrays, and primitives.
|
||||
if (settings === null || Array.isArray(settings) || typeof settings !== 'object') {
|
||||
throw new TypeError(`applyWorktreeBaseRef: expected a plain object, got ${settings === null ? 'null' : Array.isArray(settings) ? 'array' : typeof settings}`);
|
||||
}
|
||||
// Ensure worktree object exists, preserving any existing keys
|
||||
if (settings.worktree == null || typeof settings.worktree !== 'object' || Array.isArray(settings.worktree)) {
|
||||
settings.worktree = {};
|
||||
}
|
||||
const worktree = settings.worktree as Record<string, unknown>;
|
||||
const current = typeof worktree.baseRef === 'string' ? worktree.baseRef : null;
|
||||
|
||||
if (current === 'head') {
|
||||
return { changed: false, settings, skipped: 'already-head', previous: 'head' };
|
||||
}
|
||||
if (current !== null) {
|
||||
// Some other explicit string value — don't overwrite
|
||||
return { changed: false, settings, skipped: 'explicit-other', previous: current };
|
||||
}
|
||||
// Absent/null/undefined → set to 'head'
|
||||
worktree.baseRef = 'head';
|
||||
return { changed: true, settings, skipped: null, previous: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads settings.local.json then settings.json under claudeDir, extracts
|
||||
* worktree.baseRef from the first file that provides a non-null string value.
|
||||
*
|
||||
* deps.readFile(path) must return the file contents or null on any error.
|
||||
*/
|
||||
export function resolveEffectiveBaseRef(
|
||||
claudeDir: string,
|
||||
deps?: { readFile?: (p: string) => string | null }
|
||||
): string | null {
|
||||
const readFile: (p: string) => string | null = deps?.readFile ?? ((p: string) => {
|
||||
try {
|
||||
return fs.readFileSync(p, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const localPath = path.join(claudeDir, 'settings.local.json');
|
||||
const sharedPath = path.join(claudeDir, 'settings.json');
|
||||
|
||||
function parseBaseRef(filePath: string): string | null {
|
||||
const contents = readFile(filePath);
|
||||
if (contents == null) return null;
|
||||
try {
|
||||
const parsed: unknown = parseJsonc(contents);
|
||||
return readBaseRefFromSettings(parsed);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const localRef = parseBaseRef(localPath);
|
||||
if (localRef !== null) return localRef;
|
||||
|
||||
return parseBaseRef(sharedPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function cmdWorktreeBaseCheck(
|
||||
cwd: string,
|
||||
_args: string[],
|
||||
deps?: { execGit?: ExecGitFn; readFile?: (p: string) => string | null; write?: (s: string) => void }
|
||||
): ReturnType<typeof evaluateWorktreeBaseDegrade> {
|
||||
const claudeDir = path.join(cwd, '.claude');
|
||||
const effectiveBaseRef = resolveEffectiveBaseRef(
|
||||
claudeDir,
|
||||
deps?.readFile ? { readFile: deps.readFile } : undefined
|
||||
);
|
||||
const result = evaluateWorktreeBaseDegrade({
|
||||
cwd,
|
||||
effectiveBaseRef,
|
||||
execGit: deps?.execGit,
|
||||
});
|
||||
const write = deps?.write ?? ((s: string) => process.stdout.write(s));
|
||||
write(JSON.stringify(result, null, 2) + '\n');
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* CLI command: write worktree.baseRef = 'head' into <cwd>/.claude/settings.local.json.
|
||||
*
|
||||
* No-clobber: if the file already has an explicit baseRef that is not 'head',
|
||||
* the existing value is preserved and output reflects skipped:'explicit-other'.
|
||||
* If the file contains malformed JSON, throws a clear error rather than
|
||||
* silently clobbering the user's file.
|
||||
*/
|
||||
export function cmdWorktreeSetBaseRef(
|
||||
cwd: string,
|
||||
_args: string[],
|
||||
deps?: {
|
||||
readFile?: (p: string) => string | null;
|
||||
writeFile?: (p: string, content: string) => void;
|
||||
mkdir?: (p: string, opts: { recursive: boolean }) => void;
|
||||
existsSync?: (p: string) => boolean;
|
||||
write?: (s: string) => void;
|
||||
}
|
||||
): { changed: boolean; skipped: null | 'already-head' | 'explicit-other'; previous: string | null; file: string; baseRef: string } {
|
||||
const file = path.join(cwd, '.claude', 'settings.local.json');
|
||||
const readFile: (p: string) => string | null = deps?.readFile ??
|
||||
((p: string) => { try { return fs.readFileSync(p, 'utf8'); } catch { return null; } });
|
||||
|
||||
const raw = readFile(file);
|
||||
let settings: Record<string, unknown> = {};
|
||||
if (raw != null) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = parseJsonc(raw);
|
||||
} catch {
|
||||
throw new Error(`Refusing to modify ${file}: existing JSON is malformed`);
|
||||
}
|
||||
if (parsed === null || Array.isArray(parsed) || typeof parsed !== 'object') {
|
||||
throw new Error(`Refusing to modify ${file}: expected a JSON object at the top level`);
|
||||
}
|
||||
settings = parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
const apply = applyWorktreeBaseRef(settings);
|
||||
|
||||
if (apply.changed) {
|
||||
const dir = path.dirname(file);
|
||||
const existsSync: (p: string) => boolean = deps?.existsSync ?? fs.existsSync;
|
||||
const mkdirFn: (p: string, opts: { recursive: boolean }) => void = deps?.mkdir ??
|
||||
((p: string, opts: { recursive: boolean }) => { fs.mkdirSync(p, opts); });
|
||||
if (!existsSync(dir)) {
|
||||
mkdirFn(dir, { recursive: true });
|
||||
}
|
||||
const writeFile: (p: string, content: string) => void = deps?.writeFile ??
|
||||
((p: string, content: string) => { fs.writeFileSync(p, content, 'utf8'); });
|
||||
writeFile(file, JSON.stringify(settings, null, 2) + '\n');
|
||||
}
|
||||
|
||||
const output = {
|
||||
changed: apply.changed,
|
||||
skipped: apply.skipped,
|
||||
previous: apply.previous,
|
||||
baseRef: 'head' as const,
|
||||
file,
|
||||
};
|
||||
const write = deps?.write ?? ((s: string) => process.stdout.write(s));
|
||||
write(JSON.stringify(output, null, 2) + '\n');
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluates whether the current worktree HEAD has diverged from the fork base
|
||||
* (origin/HEAD) that the Claude Code harness would use when creating a 'fresh'
|
||||
* parallel worktree.
|
||||
*
|
||||
* Returns a structured result with shouldDegrade, reason, and a user-visible
|
||||
* message when degradation is warranted.
|
||||
*/
|
||||
export function evaluateWorktreeBaseDegrade(deps?: {
|
||||
execGit?: ExecGitFn;
|
||||
effectiveBaseRef?: string | null;
|
||||
cwd?: string;
|
||||
}): {
|
||||
shouldDegrade: boolean;
|
||||
reason: string;
|
||||
message: string | null;
|
||||
headSha: string | null;
|
||||
forkRef: string | null;
|
||||
forkSha: string | null;
|
||||
} {
|
||||
const execGit: ExecGitFn = deps?.execGit ?? execGitSeam;
|
||||
const cwd = deps?.cwd;
|
||||
const cwdOpts = cwd ? { cwd } : {};
|
||||
|
||||
// a. If baseRef is explicitly 'head' the harness forks from HEAD — no mismatch possible.
|
||||
// Claude Code's worktree.baseRef accepts only "fresh" (= origin/HEAD, the default) or "head".
|
||||
// Therefore special-casing "head" here and otherwise comparing HEAD against origin/HEAD is
|
||||
// complete: any non-"head" value (including "fresh" and absent/null) has fresh/origin-HEAD
|
||||
// semantics and must be evaluated against origin/HEAD. (Reference: Claude Code worktrees docs, #683.)
|
||||
if (deps?.effectiveBaseRef === 'head') {
|
||||
return { shouldDegrade: false, reason: 'baseref-head', message: null, headSha: null, forkRef: null, forkSha: null };
|
||||
}
|
||||
|
||||
// b. Resolve HEAD sha.
|
||||
const headResult = execGit(['rev-parse', 'HEAD'], cwdOpts);
|
||||
const headStdout = headResult.stdout ? headResult.stdout.trim() : '';
|
||||
if (headResult.exitCode !== 0 || !headStdout) {
|
||||
return { shouldDegrade: false, reason: 'no-head', message: null, headSha: null, forkRef: null, forkSha: null };
|
||||
}
|
||||
const headSha = headStdout;
|
||||
|
||||
// c. Resolve fork base (what the harness forks 'fresh' worktrees from = origin/HEAD).
|
||||
let forkRef: string | null = null;
|
||||
let forkSha: string | null = null;
|
||||
|
||||
// Try direct origin/HEAD rev-parse first.
|
||||
const directResult = execGit(['rev-parse', '--verify', '--quiet', 'origin/HEAD'], cwdOpts);
|
||||
const directStdout = directResult.stdout ? directResult.stdout.trim() : '';
|
||||
if (directResult.exitCode === 0 && directStdout) {
|
||||
forkRef = 'origin/HEAD';
|
||||
forkSha = directStdout;
|
||||
} else {
|
||||
// Fall back via symbolic-ref → refs/remotes/origin/HEAD
|
||||
const symResult = execGit(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], cwdOpts);
|
||||
const symStdout = symResult.stdout ? symResult.stdout.trim() : '';
|
||||
if (symResult.exitCode === 0 && symStdout) {
|
||||
const ref = symStdout;
|
||||
const symShaResult = execGit(['rev-parse', '--verify', '--quiet', ref], cwdOpts);
|
||||
const symShaStdout = symShaResult.stdout ? symShaResult.stdout.trim() : '';
|
||||
if (symShaResult.exitCode === 0 && symShaStdout) {
|
||||
// Strip leading 'refs/remotes/' to get e.g. 'origin/next'
|
||||
forkRef = ref.replace(/^refs\/remotes\//, '');
|
||||
forkSha = symShaStdout;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// d. Evaluate.
|
||||
if (forkSha === null) {
|
||||
return { shouldDegrade: true, reason: 'fork-ref-unknown', message: MSG_UNKNOWN, headSha, forkRef: null, forkSha: null };
|
||||
}
|
||||
if (forkSha === headSha) {
|
||||
return { shouldDegrade: false, reason: 'head-matches-fork', message: null, headSha, forkRef, forkSha };
|
||||
}
|
||||
const message = buildMsgDiverged(headSha, forkRef, forkSha);
|
||||
return { shouldDegrade: true, reason: 'head-diverged-from-fork', message, headSha, forkRef, forkSha };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
773
tests/worktree-base-ref.test.cjs
Normal file
773
tests/worktree-base-ref.test.cjs
Normal 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);
|
||||
});
|
||||
});
|
||||
609
tests/worktree-baseref-install.test.cjs
Normal file
609
tests/worktree-baseref-install.test.cjs
Normal 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)'
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user