refactor(#3309): add worktree-health health-diagnostic rules
W020 (3 conditions), W017, W027 — git worktree list degradation, orphan and stale worktree checks, migrated onto the frozen rule table per ADR-3180 §8.2. W027 has a documented fidelity reduction: rules have no cwd access, so it can no longer exclude the active worktree.
This commit is contained in:
186
src/health-diagnostic-rules/worktree-health.cts
Normal file
186
src/health-diagnostic-rules/worktree-health.cts
Normal file
@@ -0,0 +1,186 @@
|
||||
/**
|
||||
* Health Diagnostic — Worktree health rules (Phase 11, #3309, ADR-3180
|
||||
* §8.2/§8.3/§8.5).
|
||||
*
|
||||
* Group: "Worktree health" (design doc, "Rule table organization" table) —
|
||||
* W020 (×3 internal conditions, one subject: "the worktree health scan
|
||||
* itself is degraded", design doc "Rejected alternatives" §3), W017 (orphan
|
||||
* worktree), W027 (NEW — the split-off "stale worktree" subject, design
|
||||
* doc's "New codes for the two split subjects" section).
|
||||
*
|
||||
* Ported behavior-preserving from `cmdValidateHealth`
|
||||
* (`src/verify.cts:2193-2268`), the exact call sites for W020/W017/W027 (the
|
||||
* pre-migration source still names the split-off stale-worktree site
|
||||
* 'W017' — this batch is what actually applies the W027 split).
|
||||
*
|
||||
* KNOWN GAPS (found while building, reported rather than papered over — see
|
||||
* this batch's dispatch report for full detail):
|
||||
*
|
||||
* 1. W020's original THREE conditions were git_timed_out / git_list_failed /
|
||||
* a per-finding 'unverified' kind, each with its own message. The first
|
||||
* two are scan-level failures reported by `inspectWorktreeHealth`'s own
|
||||
* `reason` field ('git_timed_out' vs 'git_list_failed' vs
|
||||
* 'not_a_git_repo') — but `planning-snapshot.cts`'s
|
||||
* `buildWorktreeHealthField` discards `reason` entirely and only
|
||||
* preserves `scope: SCOPE.UNREADABLE` for ANY `!result.ok` case. This
|
||||
* rule therefore CANNOT distinguish "git timed out" from "git worktree
|
||||
* list failed outright" from the snapshot alone — both collapse to the
|
||||
* same `checkScanDegraded` branch below, which emits one reasonable
|
||||
* combined message instead of the original's two separate ones. Fixing
|
||||
* this precisely requires extending `PlanningSnapshot.worktreeHealth`
|
||||
* with the discarded `reason` field — an snapshot-field enhancement
|
||||
* outside this rule-file batch's scope, flagged here rather than guessed
|
||||
* around.
|
||||
* 2. W027's original exclusion of the active session's own worktree
|
||||
* (`verify.cts:2233-2242`, comparing `finding.path` against
|
||||
* `process.cwd()`) happens at the `cmdValidateHealth` call site, NOT
|
||||
* inside `inspectWorktreeHealth`/`worktree-safety.cts`. Confirmed by
|
||||
* direct read: `inspectWorktreeHealth` (`src/worktree-safety.cts:352-397`)
|
||||
* performs no cwd comparison, and `listLinkedWorktreePaths`
|
||||
* (`src/worktree-safety.cts:321-338`) only drops the FIRST `git worktree
|
||||
* list` entry (assumed main worktree) via `.slice(1)` — it does not know
|
||||
* which entry, if any, is the ACTIVE session's cwd, which is commonly a
|
||||
* LINKED (non-first) worktree in this repo's own multi-worktree workflow.
|
||||
* A `Rule.check(snapshot)` has no ambient `process.cwd()` access (§8.1
|
||||
* rule 1 forbids it), and `PlanningSnapshot` carries no
|
||||
* "active worktree path" field to filter against. This is a REAL,
|
||||
* unclosed gap: `checkW027` below reports every 'stale' finding,
|
||||
* INCLUDING the active session's own worktree, which is a behavior
|
||||
* change from the pre-migration code. Closing it precisely requires
|
||||
* either a new snapshot field carrying the active worktree path/cwd, or
|
||||
* moving the exclusion into `inspectWorktreeHealth` itself — both are
|
||||
* snapshot/owner changes outside this rule-file batch's scope.
|
||||
*
|
||||
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
|
||||
*
|
||||
* ADR-457 build-at-publish: source in
|
||||
* src/health-diagnostic-rules/worktree-health.cts, compiled to
|
||||
* gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs (gitignored).
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- type-only; erased at compile time, no runtime require emitted
|
||||
import type planningSnapshotMod = require('../planning-snapshot.cjs');
|
||||
|
||||
type PlanningSnapshot = ReturnType<typeof planningSnapshotMod.buildPlanningSnapshot>;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import healthDiagnosticMod = require('../health-diagnostic.cjs');
|
||||
const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod;
|
||||
type Diagnostic = healthDiagnosticMod.Diagnostic;
|
||||
type Rule = healthDiagnosticMod.Rule;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningScopeMod = require('../planning-scope.cjs');
|
||||
const { SCOPE } = planningScopeMod;
|
||||
|
||||
// ─── W020 — worktree health scan itself is degraded (verify.cts:2203-2264) ─
|
||||
//
|
||||
// ONE rule, THREE internal conditions, all the same subject ("the worktree
|
||||
// health scan itself is degraded" — design doc "Rejected alternatives" §3):
|
||||
// (a) `git worktree list` timed out, (b) `git worktree list` failed
|
||||
// outright, (c) a specific 'unverified' finding (existsSync ok, statSync
|
||||
// threw). (a) and (b) collapse to a single combined message per the
|
||||
// module-doc gap note above; (c) is a per-finding, exact port of
|
||||
// `verify.cts:2256-2263`.
|
||||
|
||||
function checkW020(snapshot: PlanningSnapshot): Diagnostic[] {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
|
||||
// (a)+(b) — scan-level degradation. GAP: cannot distinguish timeout from
|
||||
// outright failure from `scope` alone (see module doc, gap 1).
|
||||
if (snapshot.worktreeHealth.scope === SCOPE.UNREADABLE) {
|
||||
diagnostics.push({
|
||||
code: 'W020',
|
||||
severity: SEVERITY.WARNING,
|
||||
message:
|
||||
'Worktree health check degraded: git worktree list timed out or 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 for .git/index.lock, a hung git process, or repository permissions',
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// (c) — per-finding 'unverified' (existsSync ok, statSync threw).
|
||||
for (const finding of snapshot.worktreeHealth.value) {
|
||||
if (finding.kind !== 'unverified') continue;
|
||||
diagnostics.push({
|
||||
code: 'W020',
|
||||
severity: SEVERITY.WARNING,
|
||||
message: `Worktree health check degraded: could not stat ${finding.path} — 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' },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
// ─── W017 — orphan git worktree (verify.cts:2222-2229) ─────────────────────
|
||||
//
|
||||
// `finding.kind === 'orphan'` — path no longer exists on disk. One
|
||||
// Diagnostic per orphan finding. Remedy mirrors the exact original literal
|
||||
// fix, `verify.cts:2227`: `'Run: git worktree prune'`.
|
||||
|
||||
function checkW017(snapshot: PlanningSnapshot): Diagnostic[] {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
for (const finding of snapshot.worktreeHealth.value) {
|
||||
if (finding.kind !== 'orphan') continue;
|
||||
diagnostics.push({
|
||||
code: 'W017',
|
||||
severity: SEVERITY.WARNING,
|
||||
message: `Orphan git worktree: ${finding.path} (path no longer exists on disk)`,
|
||||
remedy: {
|
||||
action: REMEDY_ACTION.ADVISE,
|
||||
risk: REMEDY_RISK.NONE,
|
||||
args: { command: 'git worktree prune' },
|
||||
},
|
||||
});
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
// ─── W027 — stale git worktree (verify.cts:2232-2249, the split-off half of
|
||||
// the pre-migration 'W017' site) ─────────────────────────────────────────
|
||||
//
|
||||
// `finding.kind === 'stale'` — age-based. GAP: does NOT exclude the active
|
||||
// session's own worktree (see module doc, gap 2) — the original's
|
||||
// `process.cwd()` comparison cannot be reproduced from `snapshot` alone.
|
||||
// Per this batch's brief: the interpolated command (with the real path)
|
||||
// lives in `message`; `remedy.args.command` stays a static `<path>`
|
||||
// template, mirroring the split the brief specifies.
|
||||
|
||||
function checkW027(snapshot: PlanningSnapshot): Diagnostic[] {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
for (const finding of snapshot.worktreeHealth.value) {
|
||||
if (finding.kind !== 'stale') continue;
|
||||
diagnostics.push({
|
||||
code: 'W027',
|
||||
severity: SEVERITY.WARNING,
|
||||
message: `Stale git worktree: ${finding.path} (last modified ${finding.ageMinutes} minutes ago). Run: git worktree remove ${finding.path} --force`,
|
||||
remedy: {
|
||||
action: REMEDY_ACTION.ADVISE,
|
||||
risk: REMEDY_RISK.NONE,
|
||||
args: { command: 'git worktree remove <path> --force' },
|
||||
},
|
||||
});
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
// ─── Exports ────────────────────────────────────────────────────────────────
|
||||
|
||||
const RULES: Rule[] = [
|
||||
{ code: 'W020', severity: SEVERITY.WARNING, check: checkW020 },
|
||||
{ code: 'W017', severity: SEVERITY.WARNING, check: checkW017 },
|
||||
{ code: 'W027', severity: SEVERITY.WARNING, check: checkW027 },
|
||||
];
|
||||
|
||||
export = { RULES };
|
||||
336
tests/health-diagnostic-rules/worktree-health.test.cjs
Normal file
336
tests/health-diagnostic-rules/worktree-health.test.cjs
Normal file
@@ -0,0 +1,336 @@
|
||||
'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');
|
||||
|
||||
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 combined 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 or 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 for .git/index.lock, a hung git process, or repository permissions',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// GAP (documented in the rule module's own header comment, gap 1):
|
||||
// `buildWorktreeHealthField` discards `inspectWorktreeHealth`'s `reason`
|
||||
// field ('git_timed_out' vs 'git_list_failed'), so the snapshot alone
|
||||
// cannot distinguish a timeout from an outright failure. This rule
|
||||
// therefore fires the IDENTICAL combined message for both — asserted
|
||||
// explicitly here, per §8.5 fixture-proof, rather than left undocumented.
|
||||
test('GAP: fires the SAME combined message when git worktree list fails outright (not a timeout) — scope alone cannot distinguish', (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.equal(
|
||||
diagnostics[0].message,
|
||||
'Worktree health check degraded: git worktree list timed out or failed — orphan/stale worktrees could not be inspected',
|
||||
);
|
||||
});
|
||||
|
||||
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 carries the interpolated command, args.command is a static <path> template', (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}`,
|
||||
);
|
||||
assert.ok(
|
||||
d.message.endsWith(`minutes ago). Run: git worktree remove ${stalePath} --force`),
|
||||
`message must end with the interpolated remove command: ${d.message}`,
|
||||
);
|
||||
assert.deepEqual(d.remedy, {
|
||||
action: REMEDY_ACTION.ADVISE,
|
||||
risk: REMEDY_RISK.NONE,
|
||||
args: { command: 'git worktree remove <path> --force' },
|
||||
});
|
||||
});
|
||||
|
||||
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), []);
|
||||
});
|
||||
|
||||
// GAP (documented in the rule module's own header comment, gap 2): the
|
||||
// pre-migration `process.cwd()` exclusion of the ACTIVE session's own
|
||||
// worktree cannot be reproduced here — `Rule.check(snapshot)` has no
|
||||
// ambient cwd access, and `PlanningSnapshot` carries no "which entry is
|
||||
// the active worktree" field. This test recreates the real-world shape the
|
||||
// module doc calls out: 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 fires for it anyway,
|
||||
// demonstrating the gap rather than silently passing.
|
||||
test('GAP: fires for the active session\'s own (stale) worktree — no cwd-based exclusion is possible from snapshot alone', (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);
|
||||
const diagnostics = ruleFor('W027').check(snapshot);
|
||||
|
||||
assert.equal(diagnostics.length, 1, 'the active worktree is NOT excluded — this is the documented gap');
|
||||
assert.equal(diagnostics[0].code, 'W027');
|
||||
assert.ok(diagnostics[0].message.includes(cwd));
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user