* test(#3664): failing-first rows for the config-dir foreign-agent warning * fix(#3664): warn when config-dir targets a foreign-agent destination * test(#3664): fold the foreign-agent warning rows into the install-regressions suite * fix(#3664): close review findings — kimi-agents kind, gsd.md ownership, e2e gate * test(#3664): sync boolean call sites and the path-vocab registries * chore(#3664): backfill changeset pr number * test(#3663): skip the posix case-pin on win32 where folding is the fix --------- Co-authored-by: sim <sim@local>
609 lines
26 KiB
JavaScript
609 lines
26 KiB
JavaScript
'use strict';
|
||
|
||
/**
|
||
* Tests for `src/health-diagnostic-rules/worktree-health.cts` (Phase 11,
|
||
* #3309, ADR-3180 §8.2/§8.3/§8.5) — group "Worktree health": W020 (×3
|
||
* internal conditions), W017 (orphan), W027 (NEW — split-off stale-worktree
|
||
* subject).
|
||
*
|
||
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
|
||
*
|
||
* Fixture provenance (§8.5 + CONTRIBUTING "Fixture provenance (#2371)"): every
|
||
* fixture here is built via the REAL `buildPlanningSnapshot(cwd)` against a
|
||
* REAL temp directory. The `git worktree list` seam is mocked at
|
||
* `child_process.spawnSync` — REUSED, not re-derived, from
|
||
* `tests/planning-snapshot.test.cjs`'s `mockGitWorktreeListOk` /
|
||
* `mockGitWorktreeListTimeout` helpers (that file's own comment cites this as
|
||
* "the repo's convention for driving the real execGit rather than a hand-set
|
||
* deps.execGit stub", since `buildWorktreeHealthField`
|
||
* (`src/planning-snapshot.cts`) accepts no `deps` parameter to inject
|
||
* `execGit` directly — mirrors `tests/worktree-safety.test.cjs`'s
|
||
* "execGitDefault (real spawn seam)" section). Orphan/stale findings use REAL
|
||
* `fs.existsSync`/`fs.statSync` against real temp-dir paths (no mocking
|
||
* needed: a genuinely-absent path is naturally an orphan; a real directory
|
||
* with an old mtime, set via `fs.utimesSync`, is naturally stale). Only the
|
||
* 'unverified' finding (statSync throws on an existing path) needs a
|
||
* `fs.statSync` passthrough mock, scoped to one target path.
|
||
*
|
||
* TDD RED: `src/health-diagnostic-rules/worktree-health.cts` does not exist
|
||
* yet at the start of this batch — this file's
|
||
* `require('../../gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs')`
|
||
* throws MODULE_NOT_FOUND until this batch's implementation lands.
|
||
*/
|
||
|
||
const { test, describe } = require('node:test');
|
||
const assert = require('node:assert/strict');
|
||
const fs = require('node:fs');
|
||
const path = require('node:path');
|
||
const childProcess = require('node:child_process');
|
||
|
||
const { createTempDir, cleanup } = require('../helpers.cjs');
|
||
|
||
const worktreeHealth = require('../../gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs');
|
||
const { RULES } = worktreeHealth;
|
||
|
||
const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs');
|
||
const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = require('../../gsd-core/bin/lib/health-diagnostic.cjs');
|
||
const { inspectWorktreeHealth } = require('../../gsd-core/bin/lib/worktree-safety.cjs');
|
||
|
||
function planningDirOf(cwd) {
|
||
return path.join(cwd, '.planning');
|
||
}
|
||
|
||
function ruleFor(code) {
|
||
const rule = RULES.find((r) => r.code === code);
|
||
assert.ok(rule, `rule ${code} not found in RULES`);
|
||
return rule;
|
||
}
|
||
|
||
// ─── Reused git-porcelain mocking (tests/planning-snapshot.test.cjs) ───────
|
||
|
||
function mockGitWorktreeListOk(t, porcelain) {
|
||
t.mock.method(childProcess, 'spawnSync', () => ({
|
||
status: 0,
|
||
stdout: porcelain,
|
||
stderr: '',
|
||
signal: null,
|
||
error: null,
|
||
}));
|
||
}
|
||
|
||
function mockGitWorktreeListTimeout(t) {
|
||
t.mock.method(childProcess, 'spawnSync', () => ({
|
||
status: null,
|
||
stdout: '',
|
||
stderr: '',
|
||
signal: null,
|
||
error: Object.assign(new Error('spawnSync git ETIMEDOUT'), { code: 'ETIMEDOUT' }),
|
||
}));
|
||
}
|
||
|
||
// New: outright failure (non-zero exit, NOT a timeout) — the second of
|
||
// W020's two scan-level conditions.
|
||
function mockGitWorktreeListFailed(t) {
|
||
t.mock.method(childProcess, 'spawnSync', () => ({
|
||
status: 1,
|
||
stdout: '',
|
||
stderr: 'fatal: some git error',
|
||
signal: null,
|
||
error: null,
|
||
}));
|
||
}
|
||
|
||
// Builds `git worktree list --porcelain` output for the given paths, in
|
||
// order. Entry 0 is always treated as "the main worktree" by
|
||
// `listLinkedWorktreePaths`'s own `.slice(1)` and dropped before any
|
||
// finding is computed — mirrors the exact block shape used by
|
||
// tests/worktree-safety.test.cjs's inspectWorktreeHealth fixtures.
|
||
function buildPorcelain(paths) {
|
||
const lines = [];
|
||
paths.forEach((p, i) => {
|
||
lines.push(`worktree ${p}`);
|
||
lines.push(`HEAD ${'a'.repeat(40)}`);
|
||
lines.push(`branch refs/heads/${i === 0 ? 'main' : `feat-${i}`}`);
|
||
lines.push('');
|
||
});
|
||
return lines.join('\n');
|
||
}
|
||
|
||
// ─── RULES shape ────────────────────────────────────────────────────────────
|
||
|
||
describe('RULES (worktree-health group)', () => {
|
||
test('exports exactly 3 rules: W020, W017, W027', () => {
|
||
assert.deepEqual(RULES.map((r) => r.code).sort(), ['W017', 'W020', 'W027']);
|
||
});
|
||
|
||
test('all three are severity WARNING', () => {
|
||
assert.equal(ruleFor('W020').severity, SEVERITY.WARNING);
|
||
assert.equal(ruleFor('W017').severity, SEVERITY.WARNING);
|
||
assert.equal(ruleFor('W027').severity, SEVERITY.WARNING);
|
||
});
|
||
});
|
||
|
||
// ─── W020 — worktree health scan itself is degraded ────────────────────────
|
||
|
||
describe('W020 — worktree health scan degraded', () => {
|
||
test('fires the git_timed_out-specific scan-degraded message when git worktree list times out', (t) => {
|
||
const cwd = createTempDir('gsd-3309-w020-timeout-');
|
||
t.after(() => cleanup(cwd));
|
||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||
mockGitWorktreeListTimeout(t);
|
||
|
||
const snapshot = buildPlanningSnapshot(cwd);
|
||
const diagnostics = ruleFor('W020').check(snapshot);
|
||
|
||
assert.equal(diagnostics.length, 1);
|
||
assert.deepEqual(diagnostics[0], {
|
||
code: 'W020',
|
||
severity: SEVERITY.WARNING,
|
||
message:
|
||
'Worktree health check degraded: git worktree list timed out after 10s — orphan/stale worktrees could not be inspected',
|
||
remedy: {
|
||
action: REMEDY_ACTION.ADVISE,
|
||
risk: REMEDY_RISK.NONE,
|
||
args: {
|
||
command:
|
||
'Run: git worktree list --porcelain to diagnose; check for .git/index.lock or a hung git process',
|
||
},
|
||
},
|
||
});
|
||
});
|
||
|
||
// `inspectWorktreeHealth`'s `reason` field ('git_timed_out' vs
|
||
// 'git_list_failed') is carried straight through on
|
||
// `PlanningSnapshot.worktreeHealth` (`planning-snapshot.cts`'s
|
||
// `buildWorktreeHealthField`), so `checkW020` distinguishes the two scan
|
||
// failures with distinct messages/remedies (verify.cts:2204-2219) — this
|
||
// asserts the git_list_failed-specific one.
|
||
test('fires the git_list_failed-specific message when git worktree list fails outright (not a timeout)', (t) => {
|
||
const cwd = createTempDir('gsd-3309-w020-failed-');
|
||
t.after(() => cleanup(cwd));
|
||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||
mockGitWorktreeListFailed(t);
|
||
|
||
const snapshot = buildPlanningSnapshot(cwd);
|
||
const diagnostics = ruleFor('W020').check(snapshot);
|
||
|
||
assert.equal(diagnostics.length, 1);
|
||
assert.deepEqual(diagnostics[0], {
|
||
code: 'W020',
|
||
severity: SEVERITY.WARNING,
|
||
message:
|
||
'Worktree health check degraded: git worktree list failed — orphan/stale worktrees could not be inspected',
|
||
remedy: {
|
||
action: REMEDY_ACTION.ADVISE,
|
||
risk: REMEDY_RISK.NONE,
|
||
args: {
|
||
command:
|
||
'Run: git worktree list --porcelain to diagnose; check git repository state and permissions',
|
||
},
|
||
},
|
||
});
|
||
});
|
||
|
||
test('fires once per unverified finding — exact port of verify.cts:2256-2263', (t) => {
|
||
const cwd = createTempDir('gsd-3309-w020-unverified-');
|
||
t.after(() => cleanup(cwd));
|
||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||
|
||
const unverifiedPath = path.join(cwd, 'wt-unverified');
|
||
fs.mkdirSync(unverifiedPath, { recursive: true }); // existsSync must be true
|
||
|
||
const originalStatSync = fs.statSync;
|
||
t.mock.method(fs, 'statSync', function mockedStatSync(p, ...rest) {
|
||
if (p === unverifiedPath) {
|
||
throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' });
|
||
}
|
||
return originalStatSync.call(fs, p, ...rest);
|
||
});
|
||
|
||
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', unverifiedPath]));
|
||
|
||
const snapshot = buildPlanningSnapshot(cwd);
|
||
const diagnostics = ruleFor('W020').check(snapshot);
|
||
|
||
assert.equal(diagnostics.length, 1);
|
||
assert.deepEqual(diagnostics[0], {
|
||
code: 'W020',
|
||
severity: SEVERITY.WARNING,
|
||
message: `Worktree health check degraded: could not stat ${unverifiedPath} — presence/staleness could not be verified`,
|
||
remedy: {
|
||
action: REMEDY_ACTION.ADVISE,
|
||
risk: REMEDY_RISK.NONE,
|
||
args: { command: 'Check filesystem permissions on the worktree path, or investigate why statSync failed for it' },
|
||
},
|
||
});
|
||
});
|
||
|
||
test('does not fire when the scan succeeds and no finding is unverified', (t) => {
|
||
const cwd = createTempDir('gsd-3309-w020-clean-');
|
||
t.after(() => cleanup(cwd));
|
||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo']));
|
||
|
||
const snapshot = buildPlanningSnapshot(cwd);
|
||
assert.deepEqual(ruleFor('W020').check(snapshot), []);
|
||
});
|
||
});
|
||
|
||
// ─── W017 — orphan git worktree ─────────────────────────────────────────────
|
||
|
||
describe('W017 — orphan git worktree', () => {
|
||
test('fires once per orphan finding (path no longer exists on disk)', (t) => {
|
||
const cwd = createTempDir('gsd-3309-w017-1-');
|
||
t.after(() => cleanup(cwd));
|
||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||
|
||
const orphanPath = path.join(cwd, 'wt-orphan-does-not-exist');
|
||
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', orphanPath]));
|
||
|
||
const snapshot = buildPlanningSnapshot(cwd);
|
||
const diagnostics = ruleFor('W017').check(snapshot);
|
||
|
||
assert.equal(diagnostics.length, 1);
|
||
assert.deepEqual(diagnostics[0], {
|
||
code: 'W017',
|
||
severity: SEVERITY.WARNING,
|
||
message: `Orphan git worktree: ${orphanPath} (path no longer exists on disk)`,
|
||
remedy: {
|
||
action: REMEDY_ACTION.ADVISE,
|
||
risk: REMEDY_RISK.NONE,
|
||
args: { command: 'git worktree prune' },
|
||
},
|
||
});
|
||
});
|
||
|
||
test('does not fire for stale or unverified findings — isolates from W020/W027', (t) => {
|
||
const cwd = createTempDir('gsd-3309-w017-2-');
|
||
t.after(() => cleanup(cwd));
|
||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||
|
||
const stalePath = path.join(cwd, 'wt-stale');
|
||
fs.mkdirSync(stalePath, { recursive: true });
|
||
fs.utimesSync(stalePath, new Date(), new Date(Date.now() - 2 * 60 * 60 * 1000));
|
||
|
||
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', stalePath]));
|
||
|
||
const snapshot = buildPlanningSnapshot(cwd);
|
||
assert.deepEqual(ruleFor('W017').check(snapshot), []);
|
||
});
|
||
});
|
||
|
||
// ─── W027 — stale git worktree (NEW, split off pre-migration 'W017') ──────
|
||
|
||
describe('W027 — stale git worktree', () => {
|
||
test('fires once per stale finding; message/remedy check for uncommitted work BEFORE any removal and present --force only as an explicit opt-in (#3280)', (t) => {
|
||
const cwd = createTempDir('gsd-3309-w027-1-');
|
||
t.after(() => cleanup(cwd));
|
||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||
|
||
const stalePath = path.join(cwd, 'wt-stale');
|
||
fs.mkdirSync(stalePath, { recursive: true });
|
||
fs.utimesSync(stalePath, new Date(), new Date(Date.now() - 2 * 60 * 60 * 1000));
|
||
|
||
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', stalePath]));
|
||
|
||
const snapshot = buildPlanningSnapshot(cwd);
|
||
const diagnostics = ruleFor('W027').check(snapshot);
|
||
|
||
assert.equal(diagnostics.length, 1);
|
||
const d = diagnostics[0];
|
||
assert.equal(d.code, 'W027');
|
||
assert.equal(d.severity, SEVERITY.WARNING);
|
||
assert.ok(
|
||
d.message.startsWith(`Stale git worktree: ${stalePath} (last modified `),
|
||
`message must start with the stale-worktree prefix and path: ${d.message}`,
|
||
);
|
||
// #3280: staleness is a pure mtime heuristic and carries no information
|
||
// about whether the tree is clean, so the remediation must establish a
|
||
// cleanliness check FIRST and keep --force an explicit opt-in — never an
|
||
// unconditional `Run: git worktree remove <path> --force` instruction an
|
||
// agent can execute verbatim over uncommitted work.
|
||
const cleanlinessIdx = d.message.indexOf(`git -C ${stalePath} status --porcelain`);
|
||
const removeIdx = d.message.indexOf('git worktree remove');
|
||
const forceIdx = d.message.indexOf('--force');
|
||
assert.ok(cleanlinessIdx !== -1, `message must include the cleanliness check: ${d.message}`);
|
||
assert.ok(removeIdx !== -1, `message must include the removal command: ${d.message}`);
|
||
assert.ok(
|
||
cleanlinessIdx < removeIdx,
|
||
`cleanliness check must come BEFORE the removal command: ${d.message}`,
|
||
);
|
||
assert.ok(
|
||
forceIdx > removeIdx,
|
||
`--force must not be part of the base removal instruction: ${d.message}`,
|
||
);
|
||
assert.ok(
|
||
d.message.includes('if clean run:') && d.message.includes('only add --force to discard changes'),
|
||
`removal must be conditional and --force an explicit discard opt-in: ${d.message}`,
|
||
);
|
||
assert.ok(
|
||
!d.message.endsWith(`Run: git worktree remove ${stalePath} --force`),
|
||
`message must not end with the old unconditional forced-removal instruction: ${d.message}`,
|
||
);
|
||
assert.deepEqual(d.remedy, {
|
||
action: REMEDY_ACTION.ADVISE,
|
||
risk: REMEDY_RISK.NONE,
|
||
args: {
|
||
command:
|
||
'git -C <path> status --porcelain; if clean: git worktree remove <path>; add --force only to discard changes',
|
||
},
|
||
});
|
||
});
|
||
|
||
test('#3280: neither message nor remedy reads as an unconditional forced removal', (t) => {
|
||
const cwd = createTempDir('gsd-3280-w027-unconditional-');
|
||
t.after(() => cleanup(cwd));
|
||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||
|
||
const stalePath = path.join(cwd, 'wt-stale');
|
||
fs.mkdirSync(stalePath, { recursive: true });
|
||
fs.utimesSync(stalePath, new Date(), new Date(Date.now() - 2 * 60 * 60 * 1000));
|
||
|
||
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', stalePath]));
|
||
|
||
const snapshot = buildPlanningSnapshot(cwd);
|
||
const diagnostics = ruleFor('W027').check(snapshot);
|
||
assert.equal(diagnostics.length, 1);
|
||
|
||
const unconditionalRe = /(?:^|[.;:]\s*)Run:\s*git worktree remove\s+\S+\s+--force\s*$/;
|
||
assert.match(diagnostics[0].message, /git -C \S+ status --porcelain/);
|
||
assert.ok(!unconditionalRe.test(diagnostics[0].message), `message must not be an unconditional forced removal: ${diagnostics[0].message}`);
|
||
assert.ok(
|
||
!/^git worktree remove <path> --force$/.test(diagnostics[0].remedy.args.command),
|
||
`remedy must not be the bare forced-removal command: ${diagnostics[0].remedy.args.command}`,
|
||
);
|
||
assert.ok(
|
||
diagnostics[0].remedy.args.command.startsWith('git -C <path> status --porcelain'),
|
||
`remedy must lead with the cleanliness check: ${diagnostics[0].remedy.args.command}`,
|
||
);
|
||
});
|
||
|
||
test('does not fire for orphan or unverified findings — isolates from W017/W020', (t) => {
|
||
const cwd = createTempDir('gsd-3309-w027-2-');
|
||
t.after(() => cleanup(cwd));
|
||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||
|
||
const orphanPath = path.join(cwd, 'wt-orphan-does-not-exist');
|
||
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', orphanPath]));
|
||
|
||
const snapshot = buildPlanningSnapshot(cwd);
|
||
assert.deepEqual(ruleFor('W027').check(snapshot), []);
|
||
});
|
||
|
||
// Regression proof (restores `verify.cts:2233-2242`'s pre-migration
|
||
// behavior via `PlanningSnapshot.cwd`, see the rule module's own header
|
||
// comment): the active session's cwd is a LINKED (non-first) `git worktree
|
||
// list` entry, not the main repo root — `buildPlanningSnapshot(cwd)` is
|
||
// called with `cwd` itself listed as entry index 1 (not the dropped
|
||
// index-0 "main" entry) and made stale. W027 must NOT fire for it.
|
||
test('excludes the active session\'s own (stale) worktree — matches snapshot.cwd exactly', (t) => {
|
||
const cwd = createTempDir('gsd-3309-w027-3-');
|
||
t.after(() => cleanup(cwd));
|
||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||
fs.utimesSync(cwd, new Date(), new Date(Date.now() - 2 * 60 * 60 * 1000));
|
||
|
||
// Entry 0 is a fake "main repo" (dropped by listLinkedWorktreePaths's own
|
||
// .slice(1)); entry 1 is cwd itself — the active session's worktree.
|
||
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', cwd]));
|
||
|
||
const snapshot = buildPlanningSnapshot(cwd);
|
||
assert.equal(snapshot.cwd, path.resolve(cwd), 'snapshot.cwd must be the resolved active cwd');
|
||
|
||
const diagnostics = ruleFor('W027').check(snapshot);
|
||
assert.deepEqual(
|
||
diagnostics,
|
||
[],
|
||
'the active worktree must be excluded from stale-worktree diagnostics, matching pre-migration behavior',
|
||
);
|
||
});
|
||
|
||
test('excludes the active session\'s own (stale) worktree when cwd is NESTED under the worktree path — mirrors verify.cts:2239-2241\'s startsWith check', (t) => {
|
||
const cwd = createTempDir('gsd-3309-w027-4-');
|
||
t.after(() => cleanup(cwd));
|
||
const worktreePath = path.join(cwd, 'wt-active');
|
||
const nestedCwd = path.join(worktreePath, 'sub', 'dir');
|
||
fs.mkdirSync(nestedCwd, { recursive: true });
|
||
fs.mkdirSync(planningDirOf(nestedCwd), { recursive: true });
|
||
fs.utimesSync(worktreePath, new Date(), new Date(Date.now() - 2 * 60 * 60 * 1000));
|
||
|
||
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', worktreePath]));
|
||
|
||
const snapshot = buildPlanningSnapshot(nestedCwd);
|
||
const diagnostics = ruleFor('W027').check(snapshot);
|
||
|
||
assert.deepEqual(
|
||
diagnostics,
|
||
[],
|
||
'a worktree that is an ancestor of the active cwd must also be excluded',
|
||
);
|
||
});
|
||
|
||
test('a DIFFERENT stale worktree (not the active cwd, not an ancestor of it) still fires', (t) => {
|
||
const cwd = createTempDir('gsd-3309-w027-5-');
|
||
t.after(() => cleanup(cwd));
|
||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||
|
||
const otherStalePath = path.join(cwd, 'wt-other-stale');
|
||
fs.mkdirSync(otherStalePath, { recursive: true });
|
||
fs.utimesSync(otherStalePath, new Date(), new Date(Date.now() - 2 * 60 * 60 * 1000));
|
||
|
||
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', otherStalePath]));
|
||
|
||
const snapshot = buildPlanningSnapshot(cwd);
|
||
const diagnostics = ruleFor('W027').check(snapshot);
|
||
|
||
assert.equal(diagnostics.length, 1, 'a stale worktree distinct from the active cwd must still be flagged');
|
||
assert.equal(diagnostics[0].code, 'W027');
|
||
});
|
||
|
||
// ─── #3280 AC7 — staleness-threshold boundary, through the clock seam ────
|
||
//
|
||
// The classification W027 consumes is `ageMs > staleAfterMs` (STRICTLY
|
||
// greater) in `snapshotWorktreeInventory` (`src/worktree-safety.cts`). These
|
||
// drive the REAL `inspectWorktreeHealth` (the owner of that comparison and
|
||
// of the `nowMs` clock-injection seam, #1191) with BOTH clocks fixed — a
|
||
// pinned `nowMs` and an mtime set via `fs.utimesSync` — so no wall-clock is
|
||
// ever read, and assert the classification at the boundary and just either
|
||
// side of it. The git seam is exercised through `deps.execGit` (a real
|
||
// parameter of `inspectWorktreeHealth`, unlike `buildPlanningSnapshot`'s
|
||
// hardcoded `execGit`).
|
||
describe('#3280 AC7 — staleness threshold boundary (fixed clock seam)', () => {
|
||
const STALE_AFTER_MS = 60 * 60 * 1000;
|
||
const FIXED_NOW_MS = 2 * 60 * 60 * 1000; // arbitrary pinned "now" (epoch + 2h)
|
||
|
||
function probeAtAge(t, ageMs) {
|
||
const cwd = createTempDir('gsd-3280-w027-boundary-');
|
||
t.after(() => cleanup(cwd));
|
||
const wtPath = path.join(cwd, 'wt-boundary');
|
||
fs.mkdirSync(wtPath, { recursive: true });
|
||
const fixedMtime = new Date(FIXED_NOW_MS - ageMs);
|
||
fs.utimesSync(wtPath, fixedMtime, fixedMtime);
|
||
|
||
const result = inspectWorktreeHealth(
|
||
cwd,
|
||
{ staleAfterMs: STALE_AFTER_MS, nowMs: FIXED_NOW_MS },
|
||
{
|
||
execGit: () => ({
|
||
exitCode: 0,
|
||
stdout: buildPorcelain(['/fake/main-repo', wtPath]),
|
||
stderr: '',
|
||
signal: null,
|
||
error: null,
|
||
timedOut: false,
|
||
}),
|
||
},
|
||
);
|
||
return { result, wtPath };
|
||
}
|
||
|
||
test('age exactly at the threshold is NOT stale (comparison is strictly greater)', (t) => {
|
||
const { result } = probeAtAge(t, STALE_AFTER_MS);
|
||
assert.equal(result.ok, true);
|
||
assert.deepEqual(
|
||
result.findings,
|
||
[],
|
||
'a worktree exactly at the staleness threshold must not be classified stale',
|
||
);
|
||
});
|
||
|
||
test('age 1ms past the threshold IS stale (the finding kind that drives W027)', (t) => {
|
||
const { result, wtPath } = probeAtAge(t, STALE_AFTER_MS + 1);
|
||
assert.equal(result.ok, true);
|
||
assert.equal(result.findings.length, 1);
|
||
assert.equal(result.findings[0].kind, 'stale');
|
||
assert.equal(result.findings[0].path, wtPath);
|
||
assert.equal(result.findings[0].ageMinutes, 60);
|
||
});
|
||
|
||
test('age 1ms inside the threshold is NOT stale', (t) => {
|
||
const { result } = probeAtAge(t, STALE_AFTER_MS - 1);
|
||
assert.equal(result.ok, true);
|
||
assert.deepEqual(result.findings, [], 'a worktree just inside the staleness threshold must not be classified stale');
|
||
});
|
||
});
|
||
});
|
||
|
||
// ─── W027 — #3663: path-casing provenance mismatch (win32 fold, posix strict) ──
|
||
//
|
||
// snapshot.cwd is raw process-cwd-derived (no casing normalization) while
|
||
// finding.path is git-worktree-list-derived (canonical on-disk casing, forward
|
||
// slashes). On win32 the two can spell the same directory differently, which
|
||
// made checkW027 flag the ACTIVE worktree as stale. The fold happens ONLY on
|
||
// win32 — POSIX comparison stays case-sensitive (criterion 2 in the issue's
|
||
// triage brief: differently-cased paths are genuinely different directories
|
||
// on case-sensitive filesystems).
|
||
|
||
describe('W027 — #3663 isActiveWorktreePath casing/separator normalization', () => {
|
||
test('isActiveWorktreePath folds casing on win32', () => {
|
||
assert.equal(
|
||
worktreeHealth.isActiveWorktreePath('C:\\Repos\\P\\.claude\\worktrees\\Wt', 'c:/repos/p/.claude/worktrees/Wt', 'win32'),
|
||
true,
|
||
'the same directory spelled with different casing must match on win32',
|
||
);
|
||
});
|
||
|
||
test('isActiveWorktreePath folds casing for nested cwd on win32', () => {
|
||
assert.equal(
|
||
worktreeHealth.isActiveWorktreePath('c:/REPOS/p/.claude/worktrees/wt/sub/dir', 'C:\\Repos\\p\\.claude\\worktrees\\WT', 'win32'),
|
||
true,
|
||
'a cwd nested under the worktree (differently cased) must match on win32',
|
||
);
|
||
});
|
||
|
||
test('isActiveWorktreePath stays case-sensitive on posix', () => {
|
||
assert.equal(
|
||
worktreeHealth.isActiveWorktreePath('/tmp/A/wt', '/tmp/a/wt', 'linux'),
|
||
false,
|
||
'differently-cased paths are different directories on case-sensitive filesystems',
|
||
);
|
||
});
|
||
|
||
test('isActiveWorktreePath matches exact paths on posix', () => {
|
||
assert.equal(worktreeHealth.isActiveWorktreePath('/tmp/a/wt', '/tmp/a/wt', 'linux'), true);
|
||
assert.equal(worktreeHealth.isActiveWorktreePath('/tmp/a/wt/sub', '/tmp/a/wt', 'linux'), true);
|
||
assert.equal(worktreeHealth.isActiveWorktreePath('/tmp/a/wtx', '/tmp/a/wt', 'linux'), false, 'boundary segment wtx must not prefix-match wt');
|
||
});
|
||
|
||
test('isActiveWorktreePath does not fold distinct worktrees together on win32', () => {
|
||
assert.equal(
|
||
worktreeHealth.isActiveWorktreePath('C:\\Repos\\p\\.claude\\worktrees\\alpha', 'C:\\Repos\\p\\.claude\\worktrees\\beta', 'win32'),
|
||
false,
|
||
);
|
||
});
|
||
|
||
test('isActiveWorktreePath normalizes separators and trailing slashes', () => {
|
||
assert.equal(
|
||
worktreeHealth.isActiveWorktreePath('C:/Repos/p/wt/', 'C:\\Repos\\p\\wt', 'win32'),
|
||
true,
|
||
'mixed separators and a trailing slash must not break the comparison',
|
||
);
|
||
});
|
||
|
||
test('isActiveWorktreePath keeps the segment boundary under win32 folding', () => {
|
||
assert.equal(
|
||
worktreeHealth.isActiveWorktreePath('C:\\Repos\\p\\wt-alphabeta', 'C:\\Repos\\p\\wt-alpha', 'win32'),
|
||
false,
|
||
'a sibling worktree whose name extends another (wt-alphabeta vs wt-alpha) must not prefix-match',
|
||
);
|
||
});
|
||
|
||
test('isActiveWorktreePath treats a drive root as the ancestor it is', () => {
|
||
// 'C:/' strips to 'c:'; the + '/' guard makes the comparison
|
||
// startsWith('c:/') — the drive root contains everything on it, which is
|
||
// correct ancestor semantics (and unreachable via git worktree list).
|
||
assert.equal(worktreeHealth.isActiveWorktreePath('C:\\Repos\\p\\wt', 'C:/', 'win32'), true);
|
||
assert.equal(worktreeHealth.isActiveWorktreePath('/srv/app', '/', 'linux'), true);
|
||
});
|
||
|
||
test('W027 still fires for differently-cased paths on posix (case-sensitive pin)', (t) => {
|
||
// POSIX-only by definition: on win32 the #3663 fix correctly FOLDS the
|
||
// casing, so the differently-cased path IS the active worktree and must
|
||
// NOT be flagged — that win32 behavior is pinned by the predicate rows
|
||
// above (isActiveWorktreePath … 'win32').
|
||
if (process.platform === 'win32') {
|
||
t.skip('win32 folds casing by design (#3663); the posix case-sensitivity pin has no meaning there');
|
||
return;
|
||
}
|
||
const cwd = createTempDir('gsd-3663-w027-posix-case-');
|
||
t.after(() => cleanup(cwd));
|
||
fs.mkdirSync(planningDirOf(cwd), { recursive: true });
|
||
|
||
// A stale finding whose path differs from snapshot.cwd ONLY by casing.
|
||
// The variant flips a letter of the FIXED fixture prefix — deriving it
|
||
// from the random mkdtemp suffix would flake (1/62 the suffix is already
|
||
// 'X') or pass vacuously (60/62 it differs by a different character, not
|
||
// case). On POSIX the two spellings are different directories, so the
|
||
// stale finding MUST still be reported — folding here would be a
|
||
// criterion-2 regression.
|
||
const stalePath = cwd.replace('gsd-3663-w027-posix-case-', 'gsd-3663-w027-posix-Case-');
|
||
fs.mkdirSync(stalePath, { recursive: true });
|
||
fs.utimesSync(stalePath, new Date(), new Date(Date.now() - 2 * 60 * 60 * 1000));
|
||
mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', stalePath]));
|
||
|
||
const snapshot = buildPlanningSnapshot(cwd);
|
||
const diagnostics = ruleFor('W027').check(snapshot);
|
||
assert.equal(diagnostics.length, 1, 'differently-cased path is a distinct worktree on posix and must still be flagged');
|
||
assert.equal(diagnostics[0].code, 'W027');
|
||
});
|
||
});
|