* refactor(shell-projection): migrate planning-workspace.cjs tty probe to probeTty seam (#3466) Replaces direct execFileSync('tty') with probeTty() from the shell-projection seam. Removes try/catch — probeTty() returns null on error/non-tty/win32. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate commands.cjs to execGit seam (#3466) - Replaces execSync('git diff --cached --name-only') with execGit array call - Migrates 14 existing execGit(cwd, args) callers from core.cjs's local wrapper to the seam's execGit(args, { cwd }) signature - Drops execGit from the core.cjs destructure to resolve naming collision Drops try/catch around git diff — execGit returns exitCode without throwing, so the no-staged-files / not-a-git-repo case is detected by exitCode !== 0. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate check-latest-version.cjs to execNpm seam (#3466) Routes the default-spawn path through execNpm — execNpm owns the win32 shell-flag policy. The injection point remains spawnSync-shaped for test compatibility; an internal adapter translates { exitCode } → { status }. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate init.cjs git calls to execGit seam (#3466) Replaces 3 execSync calls with execGit array-args: - detectChildRepos: git status --porcelain - cmdInitNewWorkspace: git --version (worktree availability probe) - cmdRemoveWorkspace: git status --porcelain Drops 3 try/catch blocks — execGit returns exitCode without throwing, so best-effort handling becomes a clean exitCode === 0 check. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate core.cjs to execGit seam delegation (#3466) - Removes direct require('child_process') from core.cjs - Replaces execFileSync('git check-ignore') with seam's execGit - Local execGit wrapper now a thin adapter delegating to seam — keeps the legacy (cwd, args) positional signature and derived timedOut field for the verify.cjs and worktree-safety.cjs consumers that are out of Phase 2 scope (the wrapper proper would only be removed once those consumers migrate, tracked separately) Extends the seam's _spawnResult to expose signal and error fields so callers can compute timedOut without bypassing the seam. The Phase 1 test suite asserts on required field presence only, so the extension is backward-compatible. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): migrate graphify.cjs to execTool seam (#3466) - execGraphify: spawnSync('graphify', ...) → execTool with env passthrough, preserving the ENOENT/TIMEOUT/EXIT_NONZERO typed reason mapping using the seam's signal/error fields - checkGraphifyInstalled: spawnSync('graphify', ['--help']) → execTool - checkGraphifyVersion strategy 1: graphify --version via execTool - checkGraphifyVersion strategy 2: python3 importlib.metadata via execTool Adds env option to execTool — graphify needs PYTHONUNBUFFERED=1 to drain buffered stdout on long-running operations. Changes seam internals to access spawnSync/execFileSync via the non-destructured childProcess module reference. Destructured imports capture references at load time and are un-mockable by mock.method(childProcess, 'spawnSync', ...) — which breaks all the graphify subprocess tests. Non-destructured access restores mockability without changing public behavior. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feat(shell-projection): execGit defaults to non-interactive git env (#3466) Bakes GIT_TERMINAL_PROMPT=0 and GCM_INTERACTIVE=never into execGit's default env. Without these, a credential prompt or terminal-input probe blocks the git subprocess indefinitely until our 10s timeout kills it — surfacing as a generic timeout instead of the actual auth-prompt cause. These were previously set ad-hoc in worktree-safety.cjs's local execGitDefault wrapper. Moving them to the seam makes them the consistent default for every git call across the codebase. Callers can override via opts.env. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor(shell-projection): remove core.cjs local execGit wrapper; migrate verify + worktree-safety (#3466) Completes the Phase 2 "remove local execGit wrapper" criterion. All callers now use the shell-projection seam's execGit(args, opts) signature directly. - core.cjs: delete the local execGit wrapper and the execGit export. The isGitIgnored seam check now calls execGit from the seam. Worktree-safety function calls drop their execGit DI passthrough — worktree-safety's internal execGitDefault now delegates to the seam and adds timedOut. - verify.cjs: 6 callers migrate from execGit(cwd, args) to execGit(args, { cwd }). Imports execGit from the seam directly. The inspectWorktreeHealth DI passes the seam's execGit (worktree-safety now matches that shape). - worktree-safety.cjs: local execGitDefault becomes a thin adapter over the seam — no more direct spawnSync. 11 internal callers migrate to the new shape. DI contract for tests changes from (cwd, args) → (args, opts). - graphify.cjs: 2 remaining execGit callers migrate from core.cjs (now removed) to the seam directly. - test mocks updated in 3 worktree-safety test files to match the new (args, opts) DI shape — most mocks were shape-agnostic and required no changes; only those that destructured cwd/args needed updates. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * chore(changeset): add entry for shell-projection Phase 2 migration (#3466) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix(pr3476): address CodeRabbit review findings --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
236 lines
9.2 KiB
JavaScript
236 lines
9.2 KiB
JavaScript
// allow-test-rule: source-text-is-the-product
|
|
// Workflow markdown is the installed orchestration contract, and the CJS policy
|
|
// module is the callable safety seam for worktree cleanup.
|
|
|
|
'use strict';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const {
|
|
planWorktreeWaveCleanup,
|
|
executeWorktreeWaveCleanupPlan,
|
|
} = require('../get-shit-done/bin/lib/worktree-safety.cjs');
|
|
|
|
const EXECUTE_PHASE_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'execute-phase.md');
|
|
const QUICK_PATH = path.join(__dirname, '..', 'get-shit-done', 'workflows', 'quick.md');
|
|
|
|
function readWorkflow(filePath) {
|
|
return fs.readFileSync(filePath, 'utf8');
|
|
}
|
|
|
|
describe('bug #3384: worktree cleanup is manifest-scoped and fail-closed', () => {
|
|
test('cleanup plan includes only manifest entries and never discovers global agent worktrees', () => {
|
|
const plan = planWorktreeWaveCleanup('/repo/main', {
|
|
worktrees: [
|
|
{
|
|
agent_id: 'a1',
|
|
worktree_path: '/repo/.claude/worktrees/agent-a1',
|
|
branch: 'worktree-agent-a1',
|
|
expected_base: 'abc123',
|
|
},
|
|
],
|
|
});
|
|
|
|
assert.equal(plan.ok, true);
|
|
assert.deepEqual(plan.entries.map((entry) => ({
|
|
agent_id: entry.agent_id,
|
|
worktree_path: entry.worktree_path,
|
|
branch: entry.branch,
|
|
expected_base: entry.expected_base,
|
|
})), [{
|
|
agent_id: 'a1',
|
|
worktree_path: '/repo/.claude/worktrees/agent-a1',
|
|
branch: 'worktree-agent-a1',
|
|
expected_base: 'abc123',
|
|
}]);
|
|
assert.equal(plan.discovery, 'manifest');
|
|
});
|
|
|
|
test('cleanup plan rejects entries without expected base or disposable branch namespace', () => {
|
|
const plan = planWorktreeWaveCleanup('/repo/main', {
|
|
worktrees: [
|
|
{
|
|
agent_id: 'missing-base',
|
|
worktree_path: '/repo/.claude/worktrees/agent-missing-base',
|
|
branch: 'worktree-agent-missing-base',
|
|
},
|
|
{
|
|
agent_id: 'feature-branch',
|
|
worktree_path: '/repo/.claude/worktrees/agent-feature',
|
|
branch: 'feature/user-work',
|
|
expected_base: 'abc123',
|
|
},
|
|
],
|
|
});
|
|
|
|
assert.equal(plan.ok, false);
|
|
assert.equal(plan.reason, 'empty_manifest');
|
|
assert.deepEqual(plan.entries, []);
|
|
});
|
|
|
|
test('cleanup executor does not delete a branch when worktree removal fails', () => {
|
|
const calls = [];
|
|
const plan = {
|
|
ok: true,
|
|
repoRoot: '/repo/main',
|
|
action: 'cleanup_wave',
|
|
discovery: 'manifest',
|
|
entries: [{
|
|
agent_id: 'a1',
|
|
worktree_path: '/repo/.claude/worktrees/agent-a1',
|
|
branch: 'worktree-agent-a1',
|
|
expected_base: 'abc123',
|
|
}],
|
|
};
|
|
|
|
const result = executeWorktreeWaveCleanupPlan(plan, {
|
|
execGit: (args, opts) => {
|
|
calls.push({ cwd: opts.cwd, args });
|
|
const key = args.join(' ');
|
|
if (key === '-C /repo/.claude/worktrees/agent-a1 rev-parse --abbrev-ref HEAD') {
|
|
return { exitCode: 0, stdout: 'worktree-agent-a1', stderr: '' };
|
|
}
|
|
if (key === 'merge-base HEAD worktree-agent-a1') {
|
|
return { exitCode: 0, stdout: 'abc123', stderr: '' };
|
|
}
|
|
if (key === 'diff --diff-filter=D --name-only HEAD...worktree-agent-a1') {
|
|
return { exitCode: 0, stdout: '', stderr: '' };
|
|
}
|
|
if (key.startsWith('merge worktree-agent-a1')) {
|
|
return { exitCode: 0, stdout: '', stderr: '' };
|
|
}
|
|
if (key === 'worktree remove /repo/.claude/worktrees/agent-a1 --force') {
|
|
return { exitCode: 1, stdout: '', stderr: 'locked' };
|
|
}
|
|
if (key === 'branch -D worktree-agent-a1') {
|
|
throw new Error('branch deletion must not run after remove failure');
|
|
}
|
|
return { exitCode: 0, stdout: '', stderr: '' };
|
|
},
|
|
});
|
|
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.entries[0].status, 'blocked');
|
|
assert.equal(result.entries[0].reason, 'worktree_remove_failed');
|
|
assert.equal(calls.some((call) => call.args.join(' ') === 'branch -D worktree-agent-a1'), false);
|
|
});
|
|
|
|
test('cleanup executor stops on merge conflict and records remaining manifest entries', () => {
|
|
const plan = {
|
|
ok: true,
|
|
repoRoot: '/repo/main',
|
|
action: 'cleanup_wave',
|
|
discovery: 'manifest',
|
|
entries: [
|
|
{
|
|
agent_id: 'a1',
|
|
worktree_path: '/repo/.claude/worktrees/agent-a1',
|
|
branch: 'worktree-agent-a1',
|
|
expected_base: 'abc123',
|
|
},
|
|
{
|
|
agent_id: 'a2',
|
|
worktree_path: '/repo/.claude/worktrees/agent-a2',
|
|
branch: 'worktree-agent-a2',
|
|
expected_base: 'abc123',
|
|
},
|
|
],
|
|
};
|
|
|
|
const result = executeWorktreeWaveCleanupPlan(plan, {
|
|
execGit: (args) => {
|
|
const key = args.join(' ');
|
|
if (key === '-C /repo/.claude/worktrees/agent-a1 rev-parse --abbrev-ref HEAD') {
|
|
return { exitCode: 0, stdout: 'worktree-agent-a1', stderr: '' };
|
|
}
|
|
if (key === 'merge-base HEAD worktree-agent-a1') {
|
|
return { exitCode: 0, stdout: 'abc123', stderr: '' };
|
|
}
|
|
if (key === 'diff --diff-filter=D --name-only HEAD...worktree-agent-a1') {
|
|
return { exitCode: 0, stdout: '', stderr: '' };
|
|
}
|
|
if (key === '-C /repo/.claude/worktrees/agent-a1 status --porcelain --untracked-files=all') {
|
|
return { exitCode: 0, stdout: '', stderr: '' };
|
|
}
|
|
if (key.startsWith('merge worktree-agent-a1')) {
|
|
return { exitCode: 1, stdout: '', stderr: 'CONFLICT' };
|
|
}
|
|
throw new Error(`unexpected git call after conflict: ${key}`);
|
|
},
|
|
});
|
|
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.entries[0].status, 'blocked');
|
|
assert.equal(result.entries[0].reason, 'merge_failed');
|
|
assert.deepEqual(result.pending.map((entry) => entry.branch), ['worktree-agent-a2']);
|
|
});
|
|
|
|
test('cleanup executor blocks dirty worktrees before merge/remove/delete', () => {
|
|
const calls = [];
|
|
const plan = {
|
|
ok: true,
|
|
repoRoot: '/repo/main',
|
|
action: 'cleanup_wave',
|
|
discovery: 'manifest',
|
|
entries: [{
|
|
agent_id: 'a1',
|
|
worktree_path: '/repo/.claude/worktrees/agent-a1',
|
|
branch: 'worktree-agent-a1',
|
|
expected_base: 'abc123',
|
|
}],
|
|
};
|
|
|
|
const result = executeWorktreeWaveCleanupPlan(plan, {
|
|
execGit: (args) => {
|
|
calls.push(args.join(' '));
|
|
const key = args.join(' ');
|
|
if (key === '-C /repo/.claude/worktrees/agent-a1 rev-parse --abbrev-ref HEAD') {
|
|
return { exitCode: 0, stdout: 'worktree-agent-a1', stderr: '' };
|
|
}
|
|
if (key === 'merge-base HEAD worktree-agent-a1') {
|
|
return { exitCode: 0, stdout: 'abc123', stderr: '' };
|
|
}
|
|
if (key === 'diff --diff-filter=D --name-only HEAD...worktree-agent-a1') {
|
|
return { exitCode: 0, stdout: '', stderr: '' };
|
|
}
|
|
if (key === '-C /repo/.claude/worktrees/agent-a1 status --porcelain --untracked-files=all') {
|
|
return { exitCode: 0, stdout: '?? scratch.txt', stderr: '' };
|
|
}
|
|
throw new Error(`unexpected git call after dirty check: ${key}`);
|
|
},
|
|
});
|
|
|
|
assert.equal(result.ok, false);
|
|
assert.equal(result.entries[0].reason, 'worktree_dirty');
|
|
assert.equal(calls.some((call) => call.startsWith('merge worktree-agent-a1')), false);
|
|
assert.equal(calls.some((call) => call === 'worktree remove /repo/.claude/worktrees/agent-a1 --force'), false);
|
|
assert.equal(calls.some((call) => call === 'branch -D worktree-agent-a1'), false);
|
|
});
|
|
|
|
test('execute-phase contract requires a cleanup manifest instead of global worktree discovery', () => {
|
|
const content = readWorkflow(EXECUTE_PHASE_PATH);
|
|
assert.match(content, /WAVE_WORKTREE_MANIFEST/);
|
|
assert.match(content, /worktree\.cleanup-wave/);
|
|
assert.match(content, /atomically append `\{agent_id, worktree_path, branch, expected_base\}`/);
|
|
assert.match(content, /try\{if\(!p\)throw new Error\("WAVE_WORKTREE_MANIFEST is unset"\)/);
|
|
assert.match(content, /WT_PATHS_FILE=.*gsd-worktree-paths-/);
|
|
assert.doesNotMatch(content, /done < <\(node -e 'const fs=require\("fs"\);const p=process\.env\.WAVE_WORKTREE_MANIFEST/);
|
|
assert.doesNotMatch(content, /done < <\(git worktree list --porcelain \| grep "\^worktree " \| grep "\\\.claude\/worktrees\/agent-"/);
|
|
});
|
|
|
|
test('quick contract requires a cleanup manifest instead of global worktree discovery', () => {
|
|
const content = readWorkflow(QUICK_PATH);
|
|
assert.match(content, /WAVE_WORKTREE_MANIFEST|QUICK_WORKTREE_MANIFEST/);
|
|
assert.match(content, /worktree\.cleanup-wave/);
|
|
assert.match(content, /mktemp "\$\{TMPDIR:-\/tmp\}\/gsd-quick-worktree-/);
|
|
assert.match(content, /append its returned `\{agent_id, worktree_path, branch, expected_base\}`/);
|
|
assert.match(content, /try\{if\(!p\)throw new Error\("QUICK_WORKTREE_MANIFEST is unset"\)/);
|
|
assert.match(content, /WT_PATHS_FILE=.*gsd-worktree-paths-/);
|
|
assert.doesNotMatch(content, /done < <\(node -e 'const fs=require\("fs"\);const p=process\.env\.QUICK_WORKTREE_MANIFEST/);
|
|
assert.doesNotMatch(content, /done < <\(git worktree list --porcelain \| grep "\^worktree " \| grep "\\\.claude\/worktrees\/agent-"/);
|
|
});
|
|
});
|