Files
msd-core/tests/bug-2774-worktree-cleanup-workspace-safety.test.cjs
Tom Boucher 23b52f1a14 fix(3597): windows test parity batch — CRLF parsers, ESM file URLs, posix-tmp, bash-only skips, retry bump
Six independent windows-only failure clusters identified from the
windows-22 CI log on 1be0e4e2:

- scripts/command-contract-helpers.cjs: parseFrontmatter split on `\n`
  → on Windows checkout (autocrlf=true) every line carries trailing \r,
  lines.indexOf('---', 1) returns -1, all fields read as missing. Drove
  the bulk of three "67 subtests failed" suite-level errors covering
  workstreams.md, workspace.md, verify-work.md, add-tests.md, etc.
  Fix: split(/\r?\n/).

- tests/enh-3271-sdk-adr-structure.test.cjs: same CRLF pattern — H2
  captures pulled '\r' into headings like "decision\r", breaking
  equality checks on "## Decision" / "## Consequences". Fix: same.

- tests/runtime-bridge-sync-smoke.test.cjs: await import(BRIDGE_PATH)
  passed a Windows absolute path to Node's ESM loader, which rejects
  with "Only URLs with a scheme in: file, data, and node are
  supported." Fix: wrap once at module scope with pathToFileURL.
  pathToFileURL is a no-op for POSIX absolute paths.

- tests/bug-2957-claude-global-postinstall-message.test.cjs: hardcoded
  '/tmp/gsd-test-settings.json' resolved to D:\tmp\... on Windows
  where the parent dir doesn't exist → ENOENT on fs.writeFileSync
  inside finishInstall. Fix: os.tmpdir() + pid suffix.

- tests/bug-2774-worktree-cleanup-workspace-safety.test.cjs: the
  "while/read loop" subtest and the "end-to-end against real git
  worktrees" describe both assert POSIX shell behavior (process
  substitution `< <(...)`, RUNNER~1 8.3-shortname mismatch). Skip on
  win32 with explicit reasons (satisfies the
  no-unconditional-win32-skip guard).

- tests/bug-2838-summary-rescue-gitignored-planning.test.cjs: entire
  describe extracts bash rescue blocks from workflow .md files and
  runs them; the shell contract itself is the test's point. Skip on
  win32 with reason.

- tests/helpers.cjs cleanup(): bumped rmSync retry budget from
  10×100ms to 20×250ms — 1s wasn't enough for Windows Defender's
  deferred handle release; bumping to 5s should absorb the residual
  EBUSY failures observed in bug-1736 / bug-2248 / bug-2698 after the
  first retry bump landed in 1be0e4e2.

Validated: holodeck (ubuntu docker) 11224/0 pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 12:03:44 -04:00

350 lines
13 KiB
JavaScript

/**
* Bug #2774 — Worktree cleanup destroys parent workspace .git
*
* The cleanup blocks in execute-phase.md and quick.md previously used an
* EXCLUSION-based filter:
*
* git worktree list --porcelain | grep "^worktree " | grep -v "$(pwd)$" | sed ...
*
* That filter only excludes the literal `$(pwd)`. When a GSD project is itself
* a git worktree of an upstream main repo (the multi-workspace case, including
* the cross-drive Windows case where `git worktree list` reports the registry
* path as e.g. `E:/...` while `$(pwd)` resolves to `C:/...`), every other
* worktree — including the workspace itself — is wiped, taking the
* workspace's `.git` pointer file with it.
*
* The fix is INCLUSION-based: only target paths matching the agent worktree
* convention (`.claude/worktrees/agent-`), the namespace under which Claude
* Code's `isolation="worktree"` always creates executor worktrees.
*
* These tests assert the cleanup block in BOTH workflow files:
* 1. Includes only paths matching `.claude/worktrees/agent-` (positive filter)
* 2. Does NOT rely on `grep -v "$(pwd)$"` as the sole guard (negative filter)
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { cleanup } = require('./helpers.cjs');
const isWindows = process.platform === 'win32';
// The exact discovery pipeline from get-shit-done/workflows/quick.md and
// get-shit-done/workflows/execute-phase.md (line: `WORKTREES=$(git worktree
// list --porcelain | grep "^worktree " | grep "\.claude/worktrees/agent-" |
// sed 's/^worktree //')`). We invoke it as a standalone shell pipeline
// against either real `git worktree list --porcelain` output (in the
// end-to-end case) or piped-in fixture text (in the unit case).
// Note: execSync runs with `shell: '/bin/sh'` by default, which interprets the
// command string directly — no extra `bash -c '...'` wrapper needed. The
// pipeline string below is the verbatim shell from quick.md / execute-phase.md
// (the RHS of the `WORKTREES=$(...)` substitution).
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;
}
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'
);
});
});
});