* 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>
273 lines
9.3 KiB
JavaScript
273 lines
9.3 KiB
JavaScript
'use strict';
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
|
|
const {
|
|
resolveWorktreeContext,
|
|
parseWorktreePorcelain,
|
|
planWorktreePrune,
|
|
executeWorktreePrunePlan,
|
|
listLinkedWorktreePaths,
|
|
inspectWorktreeHealth,
|
|
snapshotWorktreeInventory,
|
|
} = require('../get-shit-done/bin/lib/worktree-safety.cjs');
|
|
|
|
describe('worktree-safety policy module', () => {
|
|
test('resolveWorktreeContext prefers current directory when .planning exists', () => {
|
|
const context = resolveWorktreeContext('/repo/wt', {
|
|
existsSync: () => true,
|
|
execGit: () => ({ exitCode: 1, stdout: '', stderr: '' }),
|
|
});
|
|
assert.strictEqual(context.effectiveRoot, '/repo/wt');
|
|
assert.strictEqual(context.reason, 'has_local_planning');
|
|
assert.strictEqual(context.mode, 'current_directory');
|
|
});
|
|
|
|
test('resolveWorktreeContext maps linked worktree to common-dir parent', () => {
|
|
const context = resolveWorktreeContext('/repo/wt', {
|
|
existsSync: () => false,
|
|
execGit: (args) => {
|
|
if (args[1] === '--git-dir') return { exitCode: 0, stdout: '.git/worktrees/wt', stderr: '' };
|
|
if (args[1] === '--git-common-dir') return { exitCode: 0, stdout: '../.git', stderr: '' };
|
|
return { exitCode: 1, stdout: '', stderr: '' };
|
|
},
|
|
});
|
|
assert.strictEqual(context.effectiveRoot, '/repo');
|
|
assert.strictEqual(context.reason, 'linked_worktree');
|
|
assert.strictEqual(context.mode, 'linked_worktree_root');
|
|
});
|
|
|
|
test('resolveWorktreeContext falls back when git metadata is unavailable', () => {
|
|
const context = resolveWorktreeContext('/repo/wt', {
|
|
existsSync: () => false,
|
|
execGit: () => ({ exitCode: 1, stdout: '', stderr: '' }),
|
|
});
|
|
assert.strictEqual(context.effectiveRoot, '/repo/wt');
|
|
assert.strictEqual(context.reason, 'not_git_repo');
|
|
});
|
|
|
|
test('resolveWorktreeContext keeps cwd for main worktree checkout', () => {
|
|
const context = resolveWorktreeContext('/repo/main', {
|
|
existsSync: () => false,
|
|
execGit: (args) => {
|
|
if (args[1] === '--git-dir') return { exitCode: 0, stdout: '.git', stderr: '' };
|
|
if (args[1] === '--git-common-dir') return { exitCode: 0, stdout: '.git', stderr: '' };
|
|
return { exitCode: 1, stdout: '', stderr: '' };
|
|
},
|
|
});
|
|
assert.strictEqual(context.effectiveRoot, '/repo/main');
|
|
assert.strictEqual(context.reason, 'main_worktree');
|
|
assert.strictEqual(context.mode, 'current_directory');
|
|
});
|
|
|
|
test('parseWorktreePorcelain skips detached HEAD entries', () => {
|
|
const porcelain = [
|
|
'worktree /repo/main',
|
|
'HEAD deadbeef',
|
|
'branch refs/heads/main',
|
|
'',
|
|
'worktree /repo/wt-detached',
|
|
'HEAD cafe1234',
|
|
'detached',
|
|
'',
|
|
'worktree /repo/wt-feature',
|
|
'HEAD f00dbabe',
|
|
'branch refs/heads/feature-x',
|
|
'',
|
|
].join('\n');
|
|
const parsed = parseWorktreePorcelain(porcelain);
|
|
assert.deepStrictEqual(parsed, [
|
|
{ path: '/repo/main', branch: 'main' },
|
|
{ path: '/repo/wt-feature', branch: 'feature-x' },
|
|
]);
|
|
});
|
|
|
|
test('planWorktreePrune is non-destructive by default', () => {
|
|
const plan = planWorktreePrune('/repo/main', {}, {
|
|
execGit: () => ({ exitCode: 0, stdout: 'worktree /repo/main\nbranch refs/heads/main\n', stderr: '' }),
|
|
parseWorktreePorcelain: () => [{ path: '/repo/main', branch: 'main' }],
|
|
});
|
|
assert.strictEqual(plan.action, 'metadata_prune_only');
|
|
assert.strictEqual(plan.reason, 'worktrees_present');
|
|
assert.strictEqual(plan.destructiveModeRequested, false);
|
|
});
|
|
|
|
test('planWorktreePrune keeps metadata-prune action when destructive mode is requested (scaffold)', () => {
|
|
const plan = planWorktreePrune('/repo/main', { allowDestructive: true }, {
|
|
execGit: () => ({ exitCode: 0, stdout: '', stderr: '' }),
|
|
parseWorktreePorcelain: () => [],
|
|
});
|
|
assert.strictEqual(plan.action, 'metadata_prune_only');
|
|
assert.strictEqual(plan.reason, 'no_worktrees');
|
|
assert.strictEqual(plan.destructiveModeRequested, true);
|
|
});
|
|
|
|
test('planWorktreePrune skips when git worktree list fails', () => {
|
|
const plan = planWorktreePrune('/repo/main', {}, {
|
|
execGit: () => ({ exitCode: 2, stdout: '', stderr: 'fatal' }),
|
|
});
|
|
assert.strictEqual(plan.action, 'skip');
|
|
assert.strictEqual(plan.reason, 'git_list_failed');
|
|
});
|
|
|
|
test('planWorktreePrune still metadata-prunes when porcelain parser throws', () => {
|
|
const plan = planWorktreePrune('/repo/main', {}, {
|
|
execGit: () => ({ exitCode: 0, stdout: 'not-porcelain', stderr: '' }),
|
|
parseWorktreePorcelain: () => {
|
|
throw new Error('parse failed');
|
|
},
|
|
});
|
|
assert.strictEqual(plan.action, 'metadata_prune_only');
|
|
assert.strictEqual(plan.reason, 'no_worktrees');
|
|
});
|
|
|
|
test('executeWorktreePrunePlan runs git worktree prune for metadata plan', () => {
|
|
const calls = [];
|
|
const result = executeWorktreePrunePlan(
|
|
{ repoRoot: '/repo/main', action: 'metadata_prune_only', reason: 'worktrees_present' },
|
|
{
|
|
execGit: (args, opts) => {
|
|
calls.push({ cwd: opts.cwd, args });
|
|
return { exitCode: 0, stdout: '', stderr: '' };
|
|
},
|
|
}
|
|
);
|
|
assert.strictEqual(result.ok, true);
|
|
assert.deepStrictEqual(calls, [{ cwd: '/repo/main', args: ['worktree', 'prune'] }]);
|
|
});
|
|
|
|
test('executeWorktreePrunePlan returns skip for missing plan', () => {
|
|
const result = executeWorktreePrunePlan(null, {
|
|
execGit: () => ({ exitCode: 0, stdout: '', stderr: '' }),
|
|
});
|
|
assert.strictEqual(result.ok, false);
|
|
assert.strictEqual(result.action, 'skip');
|
|
assert.strictEqual(result.reason, 'missing_plan');
|
|
});
|
|
|
|
test('executeWorktreePrunePlan returns skip plan unchanged without git call', () => {
|
|
let called = false;
|
|
const result = executeWorktreePrunePlan(
|
|
{ repoRoot: '/repo/main', action: 'skip', reason: 'git_list_failed' },
|
|
{
|
|
execGit: () => {
|
|
called = true;
|
|
return { exitCode: 0, stdout: '', stderr: '' };
|
|
},
|
|
}
|
|
);
|
|
assert.strictEqual(result.ok, false);
|
|
assert.strictEqual(result.action, 'skip');
|
|
assert.strictEqual(result.reason, 'git_list_failed');
|
|
assert.strictEqual(called, false);
|
|
});
|
|
|
|
test('executeWorktreePrunePlan rejects unsupported actions', () => {
|
|
const result = executeWorktreePrunePlan(
|
|
{ repoRoot: '/repo/main', action: 'remove_missing_paths', reason: 'explicit' },
|
|
{
|
|
execGit: () => ({ exitCode: 0, stdout: '', stderr: '' }),
|
|
}
|
|
);
|
|
assert.strictEqual(result.ok, false);
|
|
assert.strictEqual(result.action, 'remove_missing_paths');
|
|
assert.strictEqual(result.reason, 'unsupported_action');
|
|
});
|
|
|
|
test('listLinkedWorktreePaths parses porcelain and skips first/main path', () => {
|
|
const listed = listLinkedWorktreePaths('/repo/main', {
|
|
execGit: () => ({
|
|
exitCode: 0,
|
|
stdout: [
|
|
'worktree /repo/main',
|
|
'HEAD aaa',
|
|
'branch refs/heads/main',
|
|
'',
|
|
'worktree /repo/wt-a',
|
|
'HEAD bbb',
|
|
'branch refs/heads/feat-a',
|
|
'',
|
|
'worktree /repo/wt-b',
|
|
'HEAD ccc',
|
|
'detached',
|
|
'',
|
|
].join('\n'),
|
|
stderr: '',
|
|
}),
|
|
});
|
|
assert.strictEqual(listed.ok, true);
|
|
assert.deepStrictEqual(listed.paths, ['/repo/wt-a', '/repo/wt-b']);
|
|
});
|
|
|
|
test('inspectWorktreeHealth reports orphan and stale findings', () => {
|
|
const health = inspectWorktreeHealth(
|
|
'/repo/main',
|
|
{ staleAfterMs: 60 * 60 * 1000, nowMs: 2 * 60 * 60 * 1000 },
|
|
{
|
|
execGit: () => ({
|
|
exitCode: 0,
|
|
stdout: [
|
|
'worktree /repo/main',
|
|
'HEAD aaa',
|
|
'branch refs/heads/main',
|
|
'',
|
|
'worktree /repo/wt-orphan',
|
|
'HEAD bbb',
|
|
'branch refs/heads/feat-a',
|
|
'',
|
|
'worktree /repo/wt-stale',
|
|
'HEAD ccc',
|
|
'branch refs/heads/feat-b',
|
|
'',
|
|
].join('\n'),
|
|
stderr: '',
|
|
}),
|
|
existsSync: p => p !== '/repo/wt-orphan',
|
|
statSync: () => ({ mtimeMs: 0 }),
|
|
}
|
|
);
|
|
|
|
assert.strictEqual(health.ok, true);
|
|
assert.deepStrictEqual(health.findings, [
|
|
{ kind: 'orphan', path: '/repo/wt-orphan' },
|
|
{ kind: 'stale', path: '/repo/wt-stale', ageMinutes: 120 },
|
|
]);
|
|
});
|
|
|
|
test('snapshotWorktreeInventory returns typed linked-worktree entries', () => {
|
|
const inventory = snapshotWorktreeInventory(
|
|
'/repo/main',
|
|
{ staleAfterMs: 60 * 60 * 1000, nowMs: 2 * 60 * 60 * 1000 },
|
|
{
|
|
execGit: () => ({
|
|
exitCode: 0,
|
|
stdout: [
|
|
'worktree /repo/main',
|
|
'HEAD aaa',
|
|
'branch refs/heads/main',
|
|
'',
|
|
'worktree /repo/wt-a',
|
|
'HEAD bbb',
|
|
'branch refs/heads/feat-a',
|
|
'',
|
|
'worktree /repo/wt-b',
|
|
'HEAD ccc',
|
|
'branch refs/heads/feat-b',
|
|
'',
|
|
].join('\n'),
|
|
stderr: '',
|
|
}),
|
|
existsSync: p => p !== '/repo/wt-b',
|
|
statSync: () => ({ mtimeMs: 0 }),
|
|
}
|
|
);
|
|
|
|
assert.strictEqual(inventory.ok, true);
|
|
assert.deepStrictEqual(inventory.entries, [
|
|
{ path: '/repo/wt-a', exists: true, isStale: true, ageMinutes: 120 },
|
|
{ path: '/repo/wt-b', exists: false, isStale: false, ageMinutes: null },
|
|
]);
|
|
});
|
|
});
|