Files
msd-core/tests/worktree.test.cjs
Tom Boucher 7ad1a5edf5 fix(3668): isolate --local install from global gsd-sdk (#89)
* fix(3668): isolate --local install from global gsd-sdk

- `buildGsdSdkVersionMismatchReport` now accepts `opts.isLocal`; when
  true it sets `fix_command` to `npx get-shit-done-cc@latest --claude
  --local` instead of `npm install -g …`, removing the misleading global
  upgrade suggestion for local installs.
- Propagate `isLocal` from `installSdkIfNeeded` into the mismatch report
  builder so the right fix_command reaches the renderer.
- Export `buildGsdSdkVersionMismatchReport` and
  `renderGsdSdkVersionMismatchReport` so tests can assert on the IR
  contract directly.
- Add `command -v gsd-sdk … elif node "$GSD_TOOLS"` preflight SDK
  resolution block to all 69 workflow files that called bare `gsd-sdk`
  with no fallback, matching the pattern established in update.md,
  execute-phase.md, and quick.md.
- Add `tests/bug-3668-local-install-sdk-soft-dep.test.cjs` with 5 tests
  covering Defects 1-3, including a CI lint guard that blocks future
  workflow regressions.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* changeset: add Fixed entry for #3668

* fix(3668): add allow-test-rule to suppress false lint-no-source-grep violation

The test reads workflow .md files (product content) to assert structural
invariants — not .cjs source files. The file-presence check is the only
viable IR for markdown guard patterns. Add the // allow-test-rule annotation
so lint-no-source-grep passes.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3668): fix do.md false-positive and discuss-phase.md size overflow

Two CI failures introduced by the 69-workflow preflight block:

1. do.md: the path `bin/gsd-tools.cjs` contains `/gsd-tools` which the
   bug-2954 parity test regex `/\/gsd[:-]([a-z][a-z0-9-]*)/g` mistakenly
   extracts as a slash command named `tools`. Fix: store the shim filename
   in _GSD_SHIM_NAME so the path construction no longer contains a static
   `/gsd-tools` literal. Also wire $GSD_SDK into the actual query call.

2. discuss-phase.md: the file was at 499 lines (the 500-line budget from
   #2551). Adding the 11-line preflight block pushed it to 510, failing
   workflow-size-budget.test.cjs. Fix: compress the 11-line preflight +
   2-line invocations into 3 lines (one-liner guard + two $GSD_SDK calls)
   returning the file to 499 lines while retaining the command -v guard
   required by bug-3668-local-install-sdk-soft-dep.test.cjs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3668): wire \$GSD_SDK through all workflow callsites (#3797)

PR #3797 introduced the resolution preflight block (setting \$GSD_SDK) in
69 workflows but left every downstream gsd-sdk callsite using the bare
command. On local-only installs the preflight exits cleanly, then the
very next line fails with 'command not found'. This is the structural
gap the Codex review flagged.

Changes:
- 687 bare `gsd-sdk` callsites replaced with `\$GSD_SDK` across 75
  workflow files (all bash/sh fenced blocks excluding the resolution
  guard blocks themselves)
- execute-phase.md: was missing the preflight block entirely — added
  the standard 11-line resolution block at the initialize step
- execute-phase.md: inline `if command -v gsd-sdk` availability guard
  (legacy #3384 fallback) replaced with `\$GSD_SDK` + error fallback
  since the new preflight guarantees SDK availability or exits 1
- 6 sub-workflow files (discuss-phase/modes/*, execute-phase/steps/*)
  that have no preflight of their own but use \$GSD_SDK — these are
  loaded by parent workflows that set the variable; callsites updated
  to use \$GSD_SDK so they work when variable is in scope

Transformation script used: /private/tmp/fw2.js (regex-based fence
parser with segment join invariant verification — preserves all blank
lines and prose formatting).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(3668): upgrade CI guard to detect bare callsite routing (#3797)

The previous Defect 3 test checked that 'command -v gsd-sdk' appeared
as a string in the file — a guard-presence check, not a callsite-routing
check. A workflow with the preflight block but 40 bare gsd-sdk calls
below it passed the old test. This is exactly the bug state PR #3797
was supposed to fix.

Upgraded test:
- Parses each workflow file into markdown segments using a regex-based
  fence extractor (preserves all content invariantly)
- Skips bash/sh blocks that contain 'command -v gsd-sdk' (those are
  resolution guards — bare references there are expected)
- Flags any remaining bash/sh block line that invokes gsd-sdk without
  the \$ prefix (isBareGsdSdkInvocation predicate)
- Counter-test proves the predicate correctly flags real callsite lines
  and correctly exempts guard assignments, comments, and \$GSD_SDK refs

Also adds helper functions parseMarkdownSegments, isBareGsdSdkInvocation,
and findMdFiles which are used by both the upgraded Defect 3 test and
the counter-test.

This test would have caught the originally-shipped bug: the preflight
block was present but callsites still used bare gsd-sdk.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tests): update workflow content tests to accept \$GSD_SDK callsite form (#3797)

Six regression tests assert on the exact textual pattern of gsd-sdk calls
inside workflow .md files. After the #3797 callsite replacement (687 bare
`gsd-sdk` invocations replaced with `\$GSD_SDK`), these tests failed because
they searched for the literal string `gsd-sdk query <cmd>` which no longer
appears at callsites.

Updated each test to accept both the pre-#3797 bare form and the post-#3797
variable form using `(?:\$GSD_SDK|gsd-sdk)` regex alternation (or two-branch
`includes()` checks for non-regex assertions). The structural invariants each
test enforces are unchanged — we're accepting the same behavioral contract
through the new callsite surface.

Tests fixed:
- bug-2334-quick-gsd-sdk-preflight: find init.quick call via \$GSD_SDK or bare
- bug-2661-roadmap-sync-parallel: roadmap.update-plan-progress call pattern
- bug-3360-codex-execute-phase-worktrees: RUNTIME config-get call detection
- bug-3381-verify-work-workstream: init.verify-work / phase.mvp-mode calls
- enh-2433-todo-phase-linking: commit call in new-milestone.md
- enh-2792-namespace-skills: validate.context invocation in context_check step

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tests): update remaining workflow content tests to accept \$GSD_SDK form (#3797)

After #3797 callsite replacement, ultraplan-phase.test.cjs and worktree-cleanup.test.cjs
still assert bare gsd-sdk form. Update to accept either \$GSD_SDK or gsd-sdk. Also trim
the execute-phase.md preflight comment to stay within the XL line-count budget (1810).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3668): adopt inline-per-fence SDK resolution + restore safety semantics

The brief offered three options:
  (a) inline preflight block per fence
  (b) wrapper script
  (c) shared shell fragment sourced at the top

71 of 72 workflow files already had inline preflight blocks (just broken ones).
Option (b)/(c) would have required changes to install.js + a new shared artifact,
with significant risk of breaking the install pipeline. Option (a) was the path
of least resistance and least new blast radius.

**BLOCKER 1+2+3 (quick.md — GSD_SDK never assigned):**
- quick.md had 12 `$GSD_SDK` references but zero `GSD_SDK=` assignments.
- Added proper local-first preflight block with `git rev-parse --show-toplevel`
  path (not the broken `CLAUDE_FILE_PATHS` which is always empty in Claude Code).
- Each Bash fence in Claude Code runs as a fresh `bash -c`, so env vars don't
  persist. The preflight block must appear in every fence that uses $GSD_SDK.

**BLOCKER 4 (execute-phase.md — || exit 1 dropped):**
- Restored `|| exit 1` after every `worktree.cleanup-wave` call. SDK safety
  refusals (drift detection #3174, deletion block #2384) must surface, not be
  swallowed by the old `|| { fallback }` branch.

**F5 (verify-work.md untyped fence):**
- Changed bare `gsd-sdk` in an untyped fence to `$GSD_SDK`.
- Changed fence tag from untyped to `bash`.

**F6 (non-recursive readdirSync):**
- Defect 2 test now uses `findMdFiles` (recursive) to cover workflow
  subdirectories, not the flat `fs.readdirSync` that missed subdirs.

**F7 (lint misses untyped fences):**
- `parseMarkdownSegments` now treats `lang === ''` fences as bash-fences.

**F8 (missing propagation test):**
- Added two propagation tests in the Defect 3 describe block.

**F9 (priority inverted — global before local):**
- All 72 workflow files now check `[ -f "$GSD_TOOLS" ]` before `command -v gsd-sdk`.
- Path: `$(git rev-parse --show-toplevel 2>/dev/null || pwd)/get-shit-done/bin/gsd-tools.cjs`

**F10/F11 (broken quoting):**
- Changed `GSD_SDK="node "$GSD_TOOLS""` → `GSD_SDK="node $GSD_TOOLS"` across all files.

**SDK-absence fallback removal:**
- The old `|| { STATE_BACKUP=...; while IFS=...WAS_DELETED...; done }` fallback
  code was dead — preflight now exits if neither local nor global SDK exists.
  Removed from quick.md, execute-phase.md. Tests updated to verify SDK delegation
  rather than inline shell mechanics.

**Tests updated:**
- bug-2384, bug-2501, bug-2838, bug-3091, bug-3195, bug-3521, bug-3668,
  worktree-cleanup — all updated to reflect SDK delegation contract.
- Defect 2 test now uses bash-fence scan (not raw content) to skip docs-only
  gsd-sdk prose references (e.g. discuss-phase/modes/text.md).

Closes #3668

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(3668): restore _GSD_SHIM_NAME indirection in do.md to prevent false-positive

The top commit re-introduced a literal /get-shit-done/bin/gsd-tools.cjs path
in do.md, causing bug-2954 test to match /gsd-tools as an unshipped slash
command. Restore the _GSD_SHIM_NAME variable indirection (from ff9939e5) to
break the literal path while preserving local-first preference order.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(tests): update worktree.test.cjs to accept SDK delegation contract (#3797)

Mirror the contract update already applied to worktree-cleanup.test.cjs:
- pre-merge deletion check tests: accept worktree.cleanup-wave + deletion
  mention as valid (inline --diff-filter=D was in the removed shell fallback)
- quick.md bug-2431 tests (lock-aware, unlock retry, residual warning): accept
  worktree.cleanup-wave delegation as sufficient (these safety behaviors are
  now handled internally by the SDK cleanup-wave command)

execute-phase.md tests unchanged: it retains inline .git/worktrees/, locked,
git worktree unlock, and Residual worktree in its cleanup-tail snippet.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* test(3668): refactor bug-2384 and bug-2838 from grep to structured assertions

Replace content.includes() on readFileSync-bound variables with parser
functions that split lines and return typed boolean fields, matching the
project's no-source-grep contract (lint-no-source-grep rule F/G).

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 14:54:20 -04:00

757 lines
30 KiB
JavaScript

// allow-test-rule: source-text-is-the-product
// Workflow markdown is the installed orchestration contract.
'use strict';
/**
* Worktree Lifecycle Module — branch-check and workspace-safety tests
*
* Seam: get-shit-done/workflows/{execute-phase,execute-plan,quick}.md,
* agents/gsd-executor.md
*
* Split from the consolidated 13→2 worktree cluster (≤800 LOC/file):
* - tests/bug-2015-worktree-base-branch.test.cjs (#2015: reset --hard)
* - tests/bug-2075-worktree-deletion-safeguards.test.cjs (#2075: git clean prohibition)
* - tests/bug-2431-worktree-locked-surfacing.test.cjs (#2431: locked-worktree errors)
* - tests/bug-2774-worktree-cleanup-workspace-safety.test.cjs (#2774: discovery pipeline)
*
* See also: worktree-cleanup.test.cjs (#2924, #1496, #1756, #1977, #1511, #3384, #3425)
* worktree-safety.test.cjs (safety function unit tests)
*/
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { execSync } = require('child_process');
const fs = require('node:fs');
const path = require('node:path');
const os = require('node:os');
const { cleanup } = require('./helpers.cjs');
const REPO_ROOT = path.join(__dirname, '..');
const EXECUTE_PHASE_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'execute-phase.md');
const EXECUTE_PLAN_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'execute-plan.md');
const QUICK_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'quick.md');
const EXECUTOR_AGENT_PATH = path.join(REPO_ROOT, 'agents', 'gsd-executor.md');
const DIAGNOSE_PATH = path.join(REPO_ROOT, 'get-shit-done', 'workflows', 'diagnose-issues.md');
const GIT_INTEGRATION_PATH = path.join(REPO_ROOT, 'get-shit-done', 'references', 'git-integration.md');
const isWindows = process.platform === 'win32';
// ─── Helpers ──────────────────────────────────────────────────────────────────
function extractNamedBlock(markdown, blockName) {
const open = `<${blockName}>`;
const close = `</${blockName}>`;
const start = markdown.indexOf(open);
if (start === -1) return null;
const end = markdown.indexOf(close, start + open.length);
if (end === -1) return null;
return markdown.slice(start + open.length, end);
}
/**
* Extract all fenced code blocks (```...```) from a markdown chunk.
* Returns array of { lang, body } objects.
*/
function extractFencedCodeBlocks(markdown) {
const blocks = [];
const lines = markdown.split('\n');
let inFence = false;
let fenceLang = '';
let buffer = [];
for (const line of lines) {
const trimmed = line.trimStart();
if (trimmed.startsWith('```')) {
if (!inFence) {
inFence = true;
fenceLang = trimmed.slice(3).trim();
buffer = [];
} else {
blocks.push({ lang: fenceLang, body: buffer.join('\n') });
inFence = false;
fenceLang = '';
buffer = [];
}
} else if (inFence) {
buffer.push(line);
}
}
return blocks;
}
/**
* Tokenize a shell-like script into individual statements (split on `;`, `&&`, `||`, newlines)
* and return commands as arrays of word tokens. Handles `$(cmd ...)` command substitution
* and `VAR=$(cmd ...)` assignments by extracting the inner command. This is intentionally
* simple — adequate for asserting on the presence of well-known git invocations.
*/
function shellStatements(script) {
const statements = [];
const lines = script.split('\n');
for (let raw of lines) {
const line = raw.replace(/#.*$/, '').trim();
if (!line) continue;
// Split on shell statement separators
const parts = line.split(/(?:&&|\|\||;)/);
for (const part of parts) {
let trimmed = part.trim();
if (!trimmed) continue;
// Strip leading `VAR=` assignments so the substituted command surfaces as cmd[0].
// Then unwrap `$(...)` command substitution.
const assignMatch = trimmed.match(/^[A-Za-z_][A-Za-z0-9_]*=(.*)$/);
if (assignMatch) trimmed = assignMatch[1];
const subMatch = trimmed.match(/^\$\((.*?)\)?$/);
if (subMatch) trimmed = subMatch[1];
// Also handle leading `$(` without closing paren (paren may have been split off)
if (trimmed.startsWith('$(')) trimmed = trimmed.slice(2);
// Strip trailing closing parens left over from substitution
trimmed = trimmed.replace(/\)+\s*$/, '').trim();
if (!trimmed) continue;
// Strip surrounding quotes on the leading word
statements.push(trimmed.split(/\s+/).filter(Boolean));
}
}
return statements;
}
/**
* Find the line index of the first command matching a predicate.
* Returns -1 when not found.
*/
function findCommandIndex(statements, predicate) {
for (let i = 0; i < statements.length; i++) {
if (predicate(statements[i])) return i;
}
return -1;
}
const DISCOVERY_PIPELINE =
'grep "^worktree " | grep "\\.claude/worktrees/agent-" | sed \'s/^worktree //\'';
function runDiscoveryAgainstFixture(porcelain) {
const out = execSync(DISCOVERY_PIPELINE, {
input: porcelain,
encoding: 'utf-8',
});
return out.split('\n').filter((l) => l.length > 0);
}
function runDiscoveryAgainstRepo(repoCwd) {
const out = execSync(
`git worktree list --porcelain | ${DISCOVERY_PIPELINE}`,
{ cwd: repoCwd, encoding: 'utf-8' }
);
return out.split('\n').filter((l) => l.length > 0);
}
function makeTempUpstreamRepo(prefix) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
execSync('git init -b main', { cwd: tmpDir, stdio: 'pipe' });
execSync('git config user.email "test@test.com"', { cwd: tmpDir, stdio: 'pipe' });
execSync('git config user.name "Test"', { cwd: tmpDir, stdio: 'pipe' });
execSync('git config commit.gpgsign false', { cwd: tmpDir, stdio: 'pipe' });
fs.writeFileSync(path.join(tmpDir, 'README.md'), '# upstream\n');
execSync('git add -A', { cwd: tmpDir, stdio: 'pipe' });
execSync('git commit -m "initial"', { cwd: tmpDir, stdio: 'pipe' });
return tmpDir;
}
// ─── #2015: reset --hard not --soft ─────────────────────────────────────────
describe('worktree_branch_check must use reset --hard not reset --soft (#2015)', () => {
test('execute-phase.md worktree_branch_check does not use reset --soft', () => {
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
// Extract the worktree_branch_check block
const blockMatch = content.match(/<worktree_branch_check>([\s\S]*?)<\/worktree_branch_check>/);
assert.ok(blockMatch, 'execute-phase.md must contain a <worktree_branch_check> block');
const block = blockMatch[1];
assert.ok(
!block.includes('reset --soft'),
'worktree_branch_check must not use reset --soft (leaves working tree files unchanged). Use reset --hard instead.'
);
});
test('execute-phase.md worktree_branch_check uses reset --hard for base correction', () => {
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
const blockMatch = content.match(/<worktree_branch_check>([\s\S]*?)<\/worktree_branch_check>/);
assert.ok(blockMatch, 'execute-phase.md must contain a <worktree_branch_check> block');
const block = blockMatch[1];
assert.ok(
block.includes('reset --hard'),
'worktree_branch_check must use reset --hard to correctly reset both HEAD and working tree to the expected base'
);
});
test('quick.md worktree_branch_check does not use reset --soft', () => {
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
const blockMatch = content.match(/<worktree_branch_check>([\s\S]*?)<\/worktree_branch_check>/);
assert.ok(blockMatch, 'quick.md must contain a <worktree_branch_check> block');
const block = blockMatch[1];
assert.ok(
!block.includes('reset --soft'),
'quick.md worktree_branch_check must not use reset --soft. Use reset --hard instead.'
);
});
test('quick.md worktree_branch_check uses reset --hard for base correction', () => {
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
const blockMatch = content.match(/<worktree_branch_check>([\s\S]*?)<\/worktree_branch_check>/);
assert.ok(blockMatch, 'quick.md must contain a <worktree_branch_check> block');
const block = blockMatch[1];
assert.ok(
block.includes('reset --hard'),
'quick.md worktree_branch_check must use reset --hard to correctly reset both HEAD and working tree'
);
});
});
// ─── #2075: worktree deletion safeguards ────────────────────────────────────
describe('bug-2075: worktree deletion safeguards', () => {
describe('Failure Mode B: git clean prohibition in executor agent', () => {
test('gsd-executor.md explicitly prohibits git clean in worktree context', () => {
const content = fs.readFileSync(EXECUTOR_AGENT_PATH, 'utf-8');
// Must have an explicit prohibition section mentioning git clean
const prohibitsGitClean = (
content.includes('git clean') &&
(
/NEVER.*git clean/i.test(content) ||
/git clean.*NEVER/i.test(content) ||
/do not.*git clean/i.test(content) ||
/git clean.*prohibited/i.test(content) ||
/prohibited.*git clean/i.test(content) ||
/forbidden.*git clean/i.test(content) ||
/git clean.*forbidden/i.test(content) ||
/must not.*git clean/i.test(content) ||
/git clean.*must not/i.test(content)
)
);
assert.ok(
prohibitsGitClean,
'gsd-executor.md must explicitly prohibit git clean — running it inside a worktree deletes files committed on the feature branch (#2075 Failure Mode B)'
);
});
test('gsd-executor.md git clean prohibition explains the worktree data-loss risk', () => {
const content = fs.readFileSync(EXECUTOR_AGENT_PATH, 'utf-8');
// The prohibition must be accompanied by a reason — not just a bare rule
// Look for the word "worktree" near the git clean prohibition
const gitCleanIdx = content.indexOf('git clean');
assert.ok(gitCleanIdx > -1, 'gsd-executor.md must mention git clean (to prohibit it)');
// Extract context around the git clean mention (500 chars either side)
const contextStart = Math.max(0, gitCleanIdx - 500);
const contextEnd = Math.min(content.length, gitCleanIdx + 500);
const context = content.slice(contextStart, contextEnd);
const hasWorktreeRationale = (
/worktree/i.test(context) ||
/delete/i.test(context) ||
/untracked/i.test(context)
);
assert.ok(
hasWorktreeRationale,
'The git clean prohibition in gsd-executor.md must explain why: git clean in a worktree deletes files that appear untracked but are committed on the feature branch'
);
});
});
describe('Failure Mode A: worktree_branch_check audit across all worktree-spawning workflows', () => {
test('execute-phase.md has worktree_branch_check block with --hard reset', () => {
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
const blockMatch = content.match(/<worktree_branch_check>([\s\S]*?)<\/worktree_branch_check>/);
assert.ok(
blockMatch,
'execute-phase.md must contain a <worktree_branch_check> block'
);
const block = blockMatch[1];
assert.ok(
block.includes('reset --hard'),
'execute-phase.md worktree_branch_check must use git reset --hard (not --soft)'
);
assert.ok(
!block.includes('reset --soft'),
'execute-phase.md worktree_branch_check must not use git reset --soft'
);
});
test('quick.md has worktree_branch_check block with --hard reset', () => {
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
const blockMatch = content.match(/<worktree_branch_check>([\s\S]*?)<\/worktree_branch_check>/);
assert.ok(
blockMatch,
'quick.md must contain a <worktree_branch_check> block'
);
const block = blockMatch[1];
assert.ok(
block.includes('reset --hard'),
'quick.md worktree_branch_check must use git reset --hard (not --soft)'
);
assert.ok(
!block.includes('reset --soft'),
'quick.md worktree_branch_check must not use git reset --soft'
);
});
test('diagnose-issues.md has worktree_branch_check instruction for spawned agents', () => {
const content = fs.readFileSync(DIAGNOSE_PATH, 'utf-8');
assert.ok(
content.includes('worktree_branch_check'),
'diagnose-issues.md must include worktree_branch_check instruction for spawned debug agents'
);
assert.ok(
content.includes('reset --hard'),
'diagnose-issues.md worktree_branch_check must instruct agents to use git reset --hard'
);
});
});
describe('Defense-in-depth: post-commit deletion check (from #1977)', () => {
test('gsd-executor.md task_commit_protocol has post-commit deletion verification', () => {
const content = fs.readFileSync(EXECUTOR_AGENT_PATH, 'utf-8');
assert.ok(
content.includes('--diff-filter=D'),
'gsd-executor.md must include --diff-filter=D to detect accidental file deletions after each commit'
);
// Must have a warning about unexpected deletions
assert.ok(
content.includes('DELETIONS') || content.includes('WARNING'),
'gsd-executor.md must emit a warning when a commit includes unexpected file deletions'
);
});
});
describe('Defense-in-depth: pre-merge deletion check (from #1977)', () => {
test('execute-phase.md worktree merge section has pre-merge deletion check', () => {
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
const worktreeCleanupStart = content.indexOf('Worktree cleanup');
assert.ok(
worktreeCleanupStart > -1,
'execute-phase.md must have a worktree cleanup section'
);
const cleanupSection = content.slice(worktreeCleanupStart);
// After #3797 architectural fix: deletion check is handled by SDK worktree.cleanup-wave.
// Accept either (a) --diff-filter=D inline OR (b) SDK delegation with deletion mention.
const hasInlineDiffFilterCheck = cleanupSection.includes('--diff-filter=D');
const hasSdkDelegationWithDeletionMention = (
cleanupSection.includes('worktree.cleanup-wave') &&
(cleanupSection.includes('deletion') || cleanupSection.includes('BLOCKED'))
);
assert.ok(
hasInlineDiffFilterCheck || hasSdkDelegationWithDeletionMention,
'execute-phase.md cleanup section must either include --diff-filter=D or delegate to SDK (worktree.cleanup-wave) with documented deletion-diff validation (#2384/#3797)',
);
});
test('quick.md worktree merge section has pre-merge deletion check', () => {
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
// Find the worktree cleanup block (starts after "Worktree cleanup")
const worktreeCleanupStart = content.indexOf('Worktree cleanup');
assert.ok(
worktreeCleanupStart > -1,
'quick.md must have a worktree cleanup section'
);
const cleanupSection = content.slice(worktreeCleanupStart);
// After #3797 architectural fix: deletion check is handled by SDK worktree.cleanup-wave.
// Accept either (a) --diff-filter=D / diff-filter inline OR (b) SDK delegation with deletion mention.
const hasInlineDiffFilterCheck = (
cleanupSection.includes('--diff-filter=D') || cleanupSection.includes('diff-filter')
);
const hasSdkDelegationWithDeletionMention = (
cleanupSection.includes('worktree.cleanup-wave') &&
(cleanupSection.includes('deletion') || cleanupSection.includes('BLOCKED'))
);
assert.ok(
hasInlineDiffFilterCheck || hasSdkDelegationWithDeletionMention,
'quick.md cleanup section must either check for file deletions inline or delegate to SDK (worktree.cleanup-wave) with documented deletion-diff validation (#2384/#3797)',
);
});
});
});
// ─── #2431: locked-worktree error surfacing ──────────────────────────────────
describe('bug-2431: worktree teardown must surface locked-worktree errors', () => {
test('quick.md exists', () => {
assert.ok(fs.existsSync(QUICK_PATH), 'quick.md should exist');
});
test('execute-phase.md exists', () => {
assert.ok(fs.existsSync(EXECUTE_PHASE_PATH), 'execute-phase.md should exist');
});
test('quick.md: no silent worktree remove pattern', () => {
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
const silentRemovePattern = /git worktree remove[^\n]*--force\s+2>\/dev\/null\s*\|\|\s*true/;
assert.ok(!silentRemovePattern.test(content), 'quick.md: must not contain silent git worktree remove pattern');
});
test('execute-phase.md: no silent worktree remove pattern', () => {
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
const silentRemovePattern = /git worktree remove[^\n]*--force\s+2>\/dev\/null\s*\|\|\s*true/;
assert.ok(!silentRemovePattern.test(content), 'execute-phase.md: must not contain silent git worktree remove pattern');
});
test('quick.md: has lock-aware detection block', () => {
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
// After #3797 architectural fix: quick.md delegates entirely to SDK worktree.cleanup-wave,
// which handles lock detection internally. Accept either inline .git/worktrees/.../locked
// check OR SDK delegation (the SDK documents locked-worktree handling).
const hasInlineLockCheck = content.includes('.git/worktrees/') && content.includes('locked');
const hasSdkDelegation = content.includes('worktree.cleanup-wave');
assert.ok(
hasInlineLockCheck || hasSdkDelegation,
'quick.md: must include lock-aware detection (.git/worktrees/.../locked check) or delegate to SDK worktree.cleanup-wave (#2431/#3797)'
);
});
test('execute-phase.md: has lock-aware detection block', () => {
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
assert.ok(
content.includes('.git/worktrees/') && content.includes('locked'),
'execute-phase.md: must include lock-aware detection'
);
});
test('quick.md: has git worktree unlock retry', () => {
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
// After #3797 architectural fix: quick.md delegates entirely to SDK worktree.cleanup-wave.
// Accept either inline "git worktree unlock" OR SDK delegation.
const hasInlineUnlock = content.includes('git worktree unlock');
const hasSdkDelegation = content.includes('worktree.cleanup-wave');
assert.ok(
hasInlineUnlock || hasSdkDelegation,
'quick.md: must include "git worktree unlock" retry attempt or delegate to SDK worktree.cleanup-wave (#2431/#3797)'
);
});
test('execute-phase.md: has git worktree unlock retry', () => {
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
assert.ok(content.includes('git worktree unlock'), 'execute-phase.md: must include "git worktree unlock" retry attempt');
});
test('quick.md: has user-visible warning on residual worktree', () => {
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
// After #3797 architectural fix: quick.md delegates entirely to SDK worktree.cleanup-wave,
// which surfaces residual worktree warnings internally. Accept either inline warning
// OR SDK delegation.
const hasInlineWarning = content.includes('Residual worktree') || content.includes('manual cleanup');
const hasSdkDelegation = content.includes('worktree.cleanup-wave');
assert.ok(
hasInlineWarning || hasSdkDelegation,
'quick.md: must include user-visible warning when worktree removal fails, or delegate to SDK worktree.cleanup-wave (#2431/#3797)'
);
});
test('execute-phase.md: has user-visible warning on residual worktree', () => {
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
assert.ok(
content.includes('Residual worktree') || content.includes('manual cleanup'),
'execute-phase.md: must include user-visible warning when worktree removal fails'
);
});
});
// ─── #2774: cleanup pipeline workspace safety ────────────────────────────────
describe('bug #2774 — worktree cleanup pipeline must not target the parent workspace', () => {
describe('discovery pipeline (unit)', () => {
test('selects only the agent worktree when workspace itself is a worktree', () => {
// Fixture mirrors the multi-workspace setup: upstream main + sibling
// workspace worktree + agent worktree under workspace's
// `.claude/worktrees/agent-` namespace.
const porcelain = [
'worktree /Users/dev/upstream/get-shit-done',
'HEAD abc123',
'branch refs/heads/main',
'',
'worktree /Users/dev/workspaces/feature-x',
'HEAD def456',
'branch refs/heads/workspace/feature-x',
'',
'worktree /Users/dev/workspaces/feature-x/.claude/worktrees/agent-deadbeef',
'HEAD 789abc',
'branch refs/heads/worktree-agent-deadbeef',
'',
].join('\n');
const discovered = runDiscoveryAgainstFixture(porcelain);
assert.deepEqual(
discovered,
['/Users/dev/workspaces/feature-x/.claude/worktrees/agent-deadbeef'],
'pipeline must select only the agent-spawned worktree, never the ' +
'workspace or upstream main repo'
);
});
test('selects nothing when no agent worktrees exist', () => {
const porcelain = [
'worktree /Users/dev/upstream/get-shit-done',
'HEAD abc123',
'branch refs/heads/main',
'',
'worktree /Users/dev/workspaces/feature-x',
'HEAD def456',
'branch refs/heads/workspace/feature-x',
'',
].join('\n');
const discovered = runDiscoveryAgainstFixture(porcelain);
assert.deepEqual(discovered, []);
});
test('selects multiple agent worktrees and excludes non-agent paths', () => {
const porcelain = [
'worktree /repo/main',
'HEAD a',
'branch refs/heads/main',
'',
'worktree /repo/main/.claude/worktrees/agent-aaa',
'HEAD b',
'branch refs/heads/agent-aaa',
'',
'worktree /repo/main/.claude/worktrees/agent-bbb',
'HEAD c',
'branch refs/heads/agent-bbb',
'',
'worktree /repo/main/some-other-dir',
'HEAD d',
'branch refs/heads/feature',
'',
].join('\n');
const discovered = runDiscoveryAgainstFixture(porcelain);
assert.deepEqual(discovered.sort(), [
'/repo/main/.claude/worktrees/agent-aaa',
'/repo/main/.claude/worktrees/agent-bbb',
]);
});
test('selects agent worktree even when path contains whitespace', () => {
// Regression for CodeRabbit feedback on PR #2778: `for WT in $WORKTREES`
// splits on whitespace and would emit broken half-paths like
// "/Users/dev/My" and "Workspace/.claude/worktrees/agent-xyz". The
// pipeline output itself is line-delimited and preserves the full path —
// the workflow's loop must consume it line-by-line via `while IFS= read`.
const porcelain = [
'worktree /Users/dev/My Workspace',
'HEAD def456',
'branch refs/heads/workspace/feature-x',
'',
'worktree /Users/dev/My Workspace/.claude/worktrees/agent-deadbeef',
'HEAD 789abc',
'branch refs/heads/worktree-agent-deadbeef',
'',
].join('\n');
const discovered = runDiscoveryAgainstFixture(porcelain);
assert.deepEqual(
discovered,
['/Users/dev/My Workspace/.claude/worktrees/agent-deadbeef'],
'pipeline output must preserve whitespace-bearing agent worktree path on a single line'
);
});
test('while/read loop iterates each whitespace-bearing path exactly once',
{ skip: isWindows ? 'POSIX bash process-substitution `< <(...)` under test; not portable to cmd.exe / git-bash variance' : false },
() => {
// Verify the actual consumer pattern from quick.md / execute-phase.md:
// while IFS= read -r WT; do ...; done < <(<pipeline>)
// Counts the lines yielded to the loop body. With the previous
// `for WT in $WORKTREES` form, a path containing one space would yield
// 2 iterations (broken halves). The `while/read` form yields exactly 1.
const porcelain = [
'worktree /tmp/has space/.claude/worktrees/agent-aaa',
'HEAD a',
'branch refs/heads/agent-aaa',
'',
'worktree /tmp/two spaces/.claude/worktrees/agent-bbb',
'HEAD b',
'branch refs/heads/agent-bbb',
'',
].join('\n');
// Mirror the workflow's loop verbatim. Print one line per iteration with
// a sentinel so we can count and inspect what the loop actually saw.
const script = `
while IFS= read -r WT; do
[ -z "$WT" ] && continue
printf 'ITER:%s\\n' "$WT"
done < <(${DISCOVERY_PIPELINE})
`;
// bash needed for process substitution `< <(...)`.
const out = execSync(`bash -c '${script.replace(/'/g, `'\\''`)}'`, {
input: porcelain,
encoding: 'utf-8',
});
const iterations = out
.split('\n')
.filter((l) => l.startsWith('ITER:'))
.map((l) => l.slice('ITER:'.length));
assert.deepEqual(
iterations,
[
'/tmp/has space/.claude/worktrees/agent-aaa',
'/tmp/two spaces/.claude/worktrees/agent-bbb',
],
'while/read loop must yield exactly one iteration per worktree, with whitespace preserved'
);
});
});
describe('end-to-end against real git worktrees',
{ skip: isWindows ? 'POSIX shell discovery pipeline under test + Windows 8.3 short-name (RUNNER~1) vs long-name path mismatch in temp dirs' : false },
() => {
let upstream;
let workspace;
let agentWorktree;
let workspacesParent;
beforeEach(() => {
// Build the multi-worktree scenario from #2774:
// upstream/ <- main repo
// workspace/ <- worktree of upstream (the "workspace")
// workspace/.claude/worktrees/agent-XXXX/ <- agent worktree
upstream = makeTempUpstreamRepo('gsd-2774-upstream-');
workspacesParent = fs.mkdtempSync(
path.join(os.tmpdir(), 'gsd-2774-workspaces-')
);
workspace = path.join(workspacesParent, 'feature-x');
execSync(`git worktree add -b workspace/feature-x "${workspace}"`, {
cwd: upstream,
stdio: 'pipe',
});
const agentDir = path.join(workspace, '.claude', 'worktrees');
fs.mkdirSync(agentDir, { recursive: true });
agentWorktree = path.join(agentDir, 'agent-deadbeef');
execSync(
`git worktree add -b worktree-agent-deadbeef "${agentWorktree}"`,
{ cwd: upstream, stdio: 'pipe' }
);
});
afterEach(() => {
try {
execSync('git worktree prune', { cwd: upstream, stdio: 'pipe' });
} catch (_) {
/* ignore */
}
cleanup(upstream);
cleanup(workspacesParent);
});
test('discovery from inside workspace returns only the agent worktree', () => {
const discovered = runDiscoveryAgainstRepo(workspace);
// Resolve symlinks (macOS /var → /private/var) for stable comparison.
const expected = fs.realpathSync(agentWorktree);
const actual = discovered.map((p) => fs.realpathSync(p));
assert.deepEqual(
actual,
[expected],
'pipeline must list only the agent worktree, not the workspace or upstream'
);
});
test('running cleanup loop on discovered paths preserves workspace .git', () => {
const workspaceGitBefore = fs.readFileSync(
path.join(workspace, '.git'),
'utf-8'
);
assert.ok(
fs.existsSync(path.join(upstream, '.git')),
'precondition: upstream .git must exist'
);
const discovered = runDiscoveryAgainstRepo(workspace);
assert.equal(
discovered.length,
1,
'precondition: exactly one agent worktree should be discovered'
);
// Execute the cleanup behavior end-to-end: `git worktree remove --force`
// each discovered path. This mirrors the workflow's cleanup loop.
for (const wt of discovered) {
execSync(`git worktree remove --force "${wt}"`, {
cwd: workspace,
stdio: 'pipe',
});
}
// Agent worktree dir must be gone.
assert.equal(
fs.existsSync(agentWorktree),
false,
'agent worktree dir should be removed by cleanup'
);
// Workspace `.git` pointer file must still exist and be unchanged —
// the regression we are guarding against.
assert.ok(
fs.existsSync(path.join(workspace, '.git')),
'workspace .git pointer must survive cleanup (regression #2774)'
);
assert.equal(
fs.readFileSync(path.join(workspace, '.git'), 'utf-8'),
workspaceGitBefore,
'workspace .git pointer contents must be unchanged'
);
// Upstream repo's .git directory must also be intact.
assert.ok(
fs.existsSync(path.join(upstream, '.git')),
'upstream .git must survive cleanup'
);
// Workspace must still be a functional git worktree.
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
cwd: workspace,
encoding: 'utf-8',
}).trim();
assert.equal(
branch,
'workspace/feature-x',
'workspace must still be a functional worktree on its branch'
);
});
});
});