readVerificationStatus() decided a phase's verification was `stale` (a *-SUMMARY.md newer than the *-VERIFICATION.md) by comparing filesystem mtimes. mtimes are assigned at checkout time and are not preserved by `git clone` / `cp -R`, and any unrelated `touch` / reformat / editor-save re-stales a valid report — so a committed phase declaring `status: passed` could silently read `stale` on a fresh clone purely from checkout order, falsely rewriting a ROADMAP row and blocking milestone close (#2022 gate). Each file's effective "last changed" time is now its git commit time when the file is committed AND clean, and its mtime otherwise (uncommitted or working-tree-dirty). Both are real wall-clock change times, so a summary committed after — or edited after — the verification reads stale, while a clean fresh clone stays passed. Git commit time is content-tied and clone- stable; mtime is retained only where it is the true last-changed signal. Implementation: - Two bounded git calls per phase (never one-per-file): `git log --first-parent --format=%ct --name-only` for commit times, and `git diff --name-only HEAD` to drop dirty files. readVerificationStatus runs per-phase in the init/roadmap listing loops, so per-file spawning would fan out to P×(S+1) git processes ("Unbounded Subprocesses"). - `--first-parent` so merge commits report their file lists (plain `--name-only` omits merge diffs and would under-date merge-landed content). - The dirty-check fails SAFE: if `git diff` is inconclusive (errors / exits non-zero) the commit times are discarded so every file falls back to mtime, never trusting a possibly-stale commit time (no false "not stale"). - Paths matched back by `/`-bounded suffix (root vs nested `plans/` can't collide) and passed after `--` (dash-named files can't be read as flags). - A phase with no summaries skips git entirely; the scan short-circuits on the first stale summary. A `phaseCleanCommitTimesMs` seam keeps the unit tests hermetic (no git spawn); the resolver's two-call error handling is unit-tested via an injected execGit; two real-git integration tests lock the end-to-end path, the committed-then-edited (dirty) regression, and the `--` argv guard.
This commit is contained in:
@@ -32,6 +32,7 @@ const { cleanup } = require('./helpers.cjs');
|
||||
const {
|
||||
VERIFIER_STATUSES,
|
||||
VERIFICATION_ROUTING_TABLE,
|
||||
defaultPhaseCleanCommitTimesMs,
|
||||
readVerificationStatus,
|
||||
} = require('../gsd-core/bin/lib/verification.cjs');
|
||||
|
||||
@@ -329,7 +330,9 @@ describe('verification-status', () => {
|
||||
setMtime(verificationPath, '2026-01-01T00:00:00.000Z');
|
||||
setMtime(summaryPath, '2026-01-01T00:01:00.000Z');
|
||||
|
||||
const result = readVerificationStatus(dir);
|
||||
// git times unavailable → mtime-fallback path (#2348). Injected so the
|
||||
// test stays hermetic (no git spawn) regardless of tmpdir repo state.
|
||||
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() });
|
||||
assert.equal(result.status, 'stale');
|
||||
assert.match(result.next_action, /stale/i);
|
||||
assert.equal(result.next_command, '/gsd:verify-work 01');
|
||||
@@ -372,7 +375,8 @@ describe('verification-status', () => {
|
||||
setMtime(verificationPath, '2026-01-01T00:00:00.000Z');
|
||||
setMtime(summaryPath, '2026-01-01T00:01:00.000Z');
|
||||
|
||||
const result = readVerificationStatus(dir);
|
||||
// git times unavailable → mtime-fallback path (#2348).
|
||||
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs: () => new Map() });
|
||||
assert.equal(result.status, 'stale');
|
||||
assert.equal(result.next_command, '/gsd:verify-work 01');
|
||||
} finally {
|
||||
@@ -380,6 +384,404 @@ describe('verification-status', () => {
|
||||
}
|
||||
});
|
||||
|
||||
// ── #2348: staleness derived from git commit time, not filesystem mtime ────
|
||||
//
|
||||
// The verification staleness gate must survive a fresh `git clone` / `cp -R`
|
||||
// and an unrelated `touch`. It compares git commit times (content-tied) and
|
||||
// only falls back to mtime when a file has no commit time (uncommitted / no
|
||||
// repo), always reading both sides of a comparison from the same clock.
|
||||
|
||||
// Injectable per-phase git-commit-time resolver: given the phase-relative file
|
||||
// names, returns Map<file, epoch-ms>. A file whose basename is absent from
|
||||
// `byBase` resolves to "no git time" (uncommitted / not in git) → mtime clock.
|
||||
const phaseCleanTimes = (byBase) => (_phaseDir, files) => {
|
||||
const m = new Map();
|
||||
for (const file of files) {
|
||||
const base = file.split(/[\\/]/).pop();
|
||||
if (Object.prototype.hasOwnProperty.call(byBase, base)) m.set(file, byBase[base]);
|
||||
}
|
||||
return m;
|
||||
};
|
||||
|
||||
// git availability for the real-subprocess integration test below.
|
||||
const GIT_AVAILABLE = (() => {
|
||||
try {
|
||||
require('node:child_process').execFileSync('git', ['--version'], { stdio: 'ignore' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
})();
|
||||
|
||||
test('committed passed verification is NOT stale from mtime skew alone when the summary was not committed later (#2348)', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-parent-'));
|
||||
const dir = path.join(baseDir, '02-clone-skew');
|
||||
fs.mkdirSync(dir);
|
||||
try {
|
||||
const verificationPath = path.join(dir, '02-VERIFICATION.md');
|
||||
const summaryPath = path.join(dir, '02-02-SUMMARY.md');
|
||||
writeVerificationMd(dir, '02-VERIFICATION.md', 'passed');
|
||||
fs.writeFileSync(summaryPath, '# Summary');
|
||||
// Filesystem mtimes reproduce the reported 49s checkout skew (summary newer).
|
||||
setMtime(verificationPath, '2026-07-16T22:53:49.000Z');
|
||||
setMtime(summaryPath, '2026-07-16T22:54:38.000Z');
|
||||
// But in git both were committed together — the summary is not newer.
|
||||
const phaseCleanCommitTimesMs = phaseCleanTimes({
|
||||
'02-VERIFICATION.md': Date.parse('2026-07-16T22:50:00.000Z'),
|
||||
'02-02-SUMMARY.md': Date.parse('2026-07-16T22:50:00.000Z'),
|
||||
});
|
||||
|
||||
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs });
|
||||
assert.equal(
|
||||
result.status,
|
||||
'passed',
|
||||
'mtime skew alone must not override a committed passing verification',
|
||||
);
|
||||
assert.equal(result.next_command, '');
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('committed verification IS stale when the summary was committed later, even if its mtime is older — git clock wins (#2348)', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-parent-'));
|
||||
const dir = path.join(baseDir, '02-git-stale');
|
||||
fs.mkdirSync(dir);
|
||||
try {
|
||||
const verificationPath = path.join(dir, '02-VERIFICATION.md');
|
||||
const summaryPath = path.join(dir, '02-02-SUMMARY.md');
|
||||
writeVerificationMd(dir, '02-VERIFICATION.md', 'passed');
|
||||
fs.writeFileSync(summaryPath, '# Summary');
|
||||
// mtimes point the OTHER way (verification newer) to prove git is authoritative.
|
||||
setMtime(verificationPath, '2026-07-16T23:00:00.000Z');
|
||||
setMtime(summaryPath, '2026-07-16T22:00:00.000Z');
|
||||
const phaseCleanCommitTimesMs = phaseCleanTimes({
|
||||
'02-VERIFICATION.md': Date.parse('2026-07-16T22:50:00.000Z'),
|
||||
'02-02-SUMMARY.md': Date.parse('2026-07-16T22:55:00.000Z'), // committed later
|
||||
});
|
||||
|
||||
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs });
|
||||
assert.equal(result.status, 'stale');
|
||||
assert.equal(result.next_command, '/gsd:verify-work 02');
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('git-clock staleness boundary: summary committed at V-1 / V / V+1 relative to verification (#2348)', () => {
|
||||
const V = Date.parse('2026-07-16T22:50:00.000Z');
|
||||
for (const { deltaMs, expected } of [
|
||||
{ deltaMs: -1, expected: 'passed' },
|
||||
{ deltaMs: 0, expected: 'passed' },
|
||||
{ deltaMs: 1, expected: 'stale' },
|
||||
]) {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-boundary-'));
|
||||
const dir = path.join(baseDir, '03-boundary');
|
||||
fs.mkdirSync(dir);
|
||||
try {
|
||||
const verificationPath = path.join(dir, '03-VERIFICATION.md');
|
||||
const summaryPath = path.join(dir, '03-03-SUMMARY.md');
|
||||
writeVerificationMd(dir, '03-VERIFICATION.md', 'passed');
|
||||
fs.writeFileSync(summaryPath, '# Summary');
|
||||
setMtime(verificationPath, '2026-07-16T22:50:00.000Z');
|
||||
setMtime(summaryPath, '2026-07-16T22:50:00.000Z');
|
||||
const phaseCleanCommitTimesMs = phaseCleanTimes({
|
||||
'03-VERIFICATION.md': V,
|
||||
'03-03-SUMMARY.md': V + deltaMs,
|
||||
});
|
||||
|
||||
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs });
|
||||
assert.equal(
|
||||
result.status,
|
||||
expected,
|
||||
`summary committed at V${deltaMs >= 0 ? '+' : ''}${deltaMs}ms should be ${expected}`,
|
||||
);
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a committed-clean verification is stale when a summary is edited afterward (dirty) — the edit is not shadowed by the summary commit time (#2348 dirty regression)', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-dirty-'));
|
||||
const dir = path.join(baseDir, '02-dirty-summary');
|
||||
fs.mkdirSync(dir);
|
||||
try {
|
||||
const verificationPath = path.join(dir, '02-VERIFICATION.md');
|
||||
const summaryPath = path.join(dir, '02-02-SUMMARY.md');
|
||||
writeVerificationMd(dir, '02-VERIFICATION.md', 'passed');
|
||||
fs.writeFileSync(summaryPath, '# Summary');
|
||||
// Verification is committed & clean at 22:50. The summary is DIRTY (edited
|
||||
// on disk after its commit) so it is absent from the clean-commit map and
|
||||
// must be timed by its mtime — a later edit at 22:54.
|
||||
setMtime(verificationPath, '2026-07-16T22:50:00.000Z'); // unused (clean → commit time)
|
||||
setMtime(summaryPath, '2026-07-16T22:54:00.000Z');
|
||||
const phaseCleanCommitTimesMs = phaseCleanTimes({
|
||||
'02-VERIFICATION.md': Date.parse('2026-07-16T22:50:00.000Z'),
|
||||
// '02-02-SUMMARY.md' intentionally omitted → treated as dirty → mtime.
|
||||
});
|
||||
|
||||
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs });
|
||||
assert.equal(
|
||||
result.status,
|
||||
'stale',
|
||||
'a dirty summary edited after the verification must stale it via mtime, not be shadowed by an equal/earlier commit time',
|
||||
);
|
||||
assert.equal(result.next_command, '/gsd:verify-work 02');
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('both files uncommitted (no clean-commit time) fall back to mtime ordering (#2348)', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-uncommitted-'));
|
||||
const dir = path.join(baseDir, '02-uncommitted');
|
||||
fs.mkdirSync(dir);
|
||||
try {
|
||||
const verificationPath = path.join(dir, '02-VERIFICATION.md');
|
||||
const summaryPath = path.join(dir, '02-02-SUMMARY.md');
|
||||
writeVerificationMd(dir, '02-VERIFICATION.md', 'passed');
|
||||
fs.writeFileSync(summaryPath, '# Summary');
|
||||
// Neither file is committed → empty clean map → pure mtime comparison.
|
||||
setMtime(verificationPath, '2026-07-16T23:00:00.000Z');
|
||||
setMtime(summaryPath, '2026-07-16T22:00:00.000Z'); // summary older → not stale
|
||||
const phaseCleanCommitTimesMs = phaseCleanTimes({});
|
||||
|
||||
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs });
|
||||
assert.equal(result.status, 'passed', 'summary older on the mtime clock → not stale');
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('the git-commit-time resolver is invoked at most once per phase, regardless of summary count (#2348 no per-file fan-out)', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-fanout-'));
|
||||
const dir = path.join(baseDir, '01-fanout');
|
||||
fs.mkdirSync(dir);
|
||||
try {
|
||||
writeVerificationMd(dir, '01-VERIFICATION.md', 'passed');
|
||||
for (const n of ['01', '02', '03']) {
|
||||
fs.writeFileSync(path.join(dir, `01-${n}-SUMMARY.md`), '# Summary');
|
||||
}
|
||||
let calls = 0;
|
||||
let filesSeen = 0;
|
||||
const phaseCleanCommitTimesMs = (_phaseDir, files) => {
|
||||
calls += 1;
|
||||
filesSeen = files.length;
|
||||
return new Map();
|
||||
};
|
||||
|
||||
readVerificationStatus(dir, { phaseCleanCommitTimesMs });
|
||||
assert.equal(calls, 1, 'exactly one git walk for the whole phase, not one per summary file');
|
||||
assert.equal(filesSeen, 4, 'the single walk receives the verification file + all 3 summaries');
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
});
|
||||
|
||||
test('a phase with no summary files performs zero git walks and is never stale (#2348)', () => {
|
||||
const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-nosummary-'));
|
||||
const dir = path.join(baseDir, '01-no-summary');
|
||||
fs.mkdirSync(dir);
|
||||
try {
|
||||
writeVerificationMd(dir, '01-VERIFICATION.md', 'passed');
|
||||
let calls = 0;
|
||||
const phaseCleanCommitTimesMs = () => {
|
||||
calls += 1;
|
||||
return new Map();
|
||||
};
|
||||
|
||||
const result = readVerificationStatus(dir, { phaseCleanCommitTimesMs });
|
||||
assert.equal(result.status, 'passed');
|
||||
assert.equal(calls, 0, 'no summaries → nothing can be newer → skip the git subprocess entirely');
|
||||
} finally {
|
||||
cleanup(baseDir);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
'real git: a summary committed after the verification reads stale via the real git clock, even for a dash-named file (#2348 end-to-end + `--` argv guard)',
|
||||
{ skip: GIT_AVAILABLE ? false : 'git binary not available' },
|
||||
() => {
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-realgit-'));
|
||||
const runGit = (args, extraEnv) =>
|
||||
execFileSync('git', args, {
|
||||
cwd: repo,
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', ...(extraEnv || {}) },
|
||||
});
|
||||
const commitEnvAt = (iso) => ({ GIT_AUTHOR_DATE: iso + '+00:00', GIT_COMMITTER_DATE: iso + '+00:00' });
|
||||
try {
|
||||
runGit(['init', '-q']);
|
||||
runGit(['config', 'user.email', 'test@example.com']);
|
||||
runGit(['config', 'user.name', 'Test']);
|
||||
runGit(['config', 'commit.gpgsign', 'false']);
|
||||
|
||||
const dir = path.join(repo, '.planning', 'phases', '01-real');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const verificationPath = path.join(dir, '01-VERIFICATION.md');
|
||||
// A leading-dash filename exercises the `--` pathspec guard in the real
|
||||
// `git log` argv: if `--` were dropped git would read it as a flag.
|
||||
const summaryName = '-danger-SUMMARY.md';
|
||||
const summaryPath = path.join(dir, summaryName);
|
||||
|
||||
fs.writeFileSync(verificationPath, '---\nstatus: passed\n---\n');
|
||||
runGit(['add', '--', verificationPath]);
|
||||
runGit(['commit', '-q', '-m', 'add verification'], commitEnvAt('2026-07-16T22:50:00'));
|
||||
|
||||
fs.writeFileSync(summaryPath, '# Summary');
|
||||
runGit(['add', '--', summaryPath]);
|
||||
runGit(['commit', '-q', '-m', 'add summary later'], commitEnvAt('2026-07-16T22:55:00'));
|
||||
|
||||
// Make mtimes claim the OPPOSITE order so only the git clock can stale it.
|
||||
setMtime(summaryPath, '2000-01-01T00:00:00.000Z');
|
||||
setMtime(verificationPath, '2030-01-01T00:00:00.000Z');
|
||||
|
||||
// No seam injected → the real defaultPhaseCleanCommitTimesMs / execGit path.
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(
|
||||
result.status,
|
||||
'stale',
|
||||
'summary committed after the verification must read stale on the real git clock, and the dash-named file must resolve through the `--` pathspec guard',
|
||||
);
|
||||
assert.equal(result.next_command, '/gsd:verify-work 01');
|
||||
} finally {
|
||||
cleanup(repo);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'real git: a committed summary edited on disk (dirty) reads stale via mtime, not shadowed by its commit time (#2348 dirty regression, end-to-end)',
|
||||
{ skip: GIT_AVAILABLE ? false : 'git binary not available' },
|
||||
() => {
|
||||
const { execFileSync } = require('node:child_process');
|
||||
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2348-realgit-dirty-'));
|
||||
const runGit = (args, extraEnv) =>
|
||||
execFileSync('git', args, {
|
||||
cwd: repo,
|
||||
stdio: 'pipe',
|
||||
env: { ...process.env, GIT_TERMINAL_PROMPT: '0', ...(extraEnv || {}) },
|
||||
});
|
||||
const commitEnvAt = (iso) => ({ GIT_AUTHOR_DATE: iso + '+00:00', GIT_COMMITTER_DATE: iso + '+00:00' });
|
||||
try {
|
||||
runGit(['init', '-q']);
|
||||
runGit(['config', 'user.email', 'test@example.com']);
|
||||
runGit(['config', 'user.name', 'Test']);
|
||||
runGit(['config', 'commit.gpgsign', 'false']);
|
||||
|
||||
const dir = path.join(repo, '.planning', 'phases', '01-real');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const verificationPath = path.join(dir, '01-VERIFICATION.md');
|
||||
const summaryPath = path.join(dir, '01-01-SUMMARY.md');
|
||||
|
||||
fs.writeFileSync(verificationPath, '---\nstatus: passed\n---\n');
|
||||
fs.writeFileSync(summaryPath, '# Summary');
|
||||
// Commit BOTH together — identical commit time, so commit time alone
|
||||
// would read "not stale".
|
||||
runGit(['add', '--', verificationPath, summaryPath]);
|
||||
runGit(['commit', '-q', '-m', 'add phase'], commitEnvAt('2026-07-16T22:50:00'));
|
||||
|
||||
// Edit the summary again WITHOUT committing → working tree diverges from HEAD.
|
||||
fs.writeFileSync(summaryPath, '# Summary edited');
|
||||
setMtime(verificationPath, '2026-07-16T22:50:00.000Z'); // clean → commit time used
|
||||
setMtime(summaryPath, '2026-07-16T22:54:00.000Z'); // dirty → this later mtime is used
|
||||
|
||||
const result = readVerificationStatus(dir);
|
||||
assert.equal(
|
||||
result.status,
|
||||
'stale',
|
||||
'a committed-then-edited (dirty) summary must read stale via mtime, not be shadowed by its now-stale commit time',
|
||||
);
|
||||
assert.equal(result.next_command, '/gsd:verify-work 01');
|
||||
} finally {
|
||||
cleanup(repo);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── #2348: default resolver two-call error handling (hermetic, injected execGit) ──
|
||||
|
||||
const okResult = (stdout) => ({ exitCode: 0, stdout, stderr: '', signal: null, error: null });
|
||||
const errResult = () => ({
|
||||
exitCode: 127,
|
||||
stdout: '',
|
||||
stderr: 'git: not found',
|
||||
signal: null,
|
||||
error: new Error('ENOENT'),
|
||||
});
|
||||
const nonzeroResult = () => ({ exitCode: 128, stdout: '', stderr: 'fatal', signal: null, error: null });
|
||||
// Fake execGit dispatching on the git subcommand (args[0]).
|
||||
const fakeExecGit = ({ log, diff }) => (args) => {
|
||||
if (args[0] === 'log') return log;
|
||||
if (args[0] === 'diff') return diff;
|
||||
throw new Error(`unexpected git ${args.join(' ')}`);
|
||||
};
|
||||
// Reverse-chronological `git log --name-only` fixture: summary newer than verification.
|
||||
const LOG_OUT = [
|
||||
'2000',
|
||||
'',
|
||||
'.planning/phases/01-x/01-01-SUMMARY.md',
|
||||
'',
|
||||
'1000',
|
||||
'',
|
||||
'.planning/phases/01-x/01-VERIFICATION.md',
|
||||
].join('\n');
|
||||
const FILES = ['01-VERIFICATION.md', '01-01-SUMMARY.md'];
|
||||
|
||||
test('resolver: parses commit times and drops a file the dirty-check reports (#2348)', () => {
|
||||
const map = defaultPhaseCleanCommitTimesMs(
|
||||
'/repo/.planning/phases/01-x',
|
||||
FILES,
|
||||
fakeExecGit({ log: okResult(LOG_OUT), diff: okResult('.planning/phases/01-x/01-01-SUMMARY.md') }),
|
||||
);
|
||||
assert.equal(map.get('01-VERIFICATION.md'), 1000 * 1000, 'verification commit time (seconds→ms)');
|
||||
assert.equal(map.has('01-01-SUMMARY.md'), false, 'dirty summary dropped → will use mtime');
|
||||
});
|
||||
|
||||
test('resolver: clean tree (dirty-check reports nothing) keeps all commit times (#2348)', () => {
|
||||
const map = defaultPhaseCleanCommitTimesMs(
|
||||
'/repo/.planning/phases/01-x',
|
||||
FILES,
|
||||
fakeExecGit({ log: okResult(LOG_OUT), diff: okResult('') }),
|
||||
);
|
||||
assert.equal(map.get('01-VERIFICATION.md'), 1000 * 1000);
|
||||
assert.equal(map.get('01-01-SUMMARY.md'), 2000 * 1000);
|
||||
});
|
||||
|
||||
test('resolver: FAILS SAFE (empty map) when the dirty-check errors after git log succeeds (#2348)', () => {
|
||||
const map = defaultPhaseCleanCommitTimesMs(
|
||||
'/repo/.planning/phases/01-x',
|
||||
FILES,
|
||||
fakeExecGit({ log: okResult(LOG_OUT), diff: errResult() }),
|
||||
);
|
||||
assert.equal(
|
||||
map.size,
|
||||
0,
|
||||
'an inconclusive dirty-check must discard commit times so every file falls back to mtime',
|
||||
);
|
||||
});
|
||||
|
||||
test('resolver: FAILS SAFE (empty map) when the dirty-check exits non-zero (#2348)', () => {
|
||||
const map = defaultPhaseCleanCommitTimesMs(
|
||||
'/repo/.planning/phases/01-x',
|
||||
FILES,
|
||||
fakeExecGit({ log: okResult(LOG_OUT), diff: nonzeroResult() }),
|
||||
);
|
||||
assert.equal(map.size, 0);
|
||||
});
|
||||
|
||||
test('resolver: empty map (mtime fallback) when git log itself fails (#2348)', () => {
|
||||
const map = defaultPhaseCleanCommitTimesMs(
|
||||
'/repo/.planning/phases/01-x',
|
||||
FILES,
|
||||
// diff would throw if consulted — proves log-failure short-circuits before it.
|
||||
fakeExecGit({ log: errResult(), diff: undefined }),
|
||||
);
|
||||
assert.equal(map.size, 0);
|
||||
});
|
||||
|
||||
// ── Task 2 (B1): ship.md gate sentinel contract anchor ────────────────────
|
||||
//
|
||||
// The deleted tests/ship-586-verification-routing.test.cjs was the only
|
||||
|
||||
Reference in New Issue
Block a user