diff --git a/course-build/scripts/detect-affected-modules.mjs b/course-build/scripts/detect-affected-modules.mjs index fac5667..427e551 100755 --- a/course-build/scripts/detect-affected-modules.mjs +++ b/course-build/scripts/detect-affected-modules.mjs @@ -11,11 +11,16 @@ // node detect-affected-modules.mjs --acc --from --to // node detect-affected-modules.mjs --acc --to # no --from: treat as first run // -// Output (stdout, JSON): { "modules": [4,6], "min": 4, "fromModule": 4, "firstRun": false } -// modules sorted unique affected module numbers (1..7) -// min smallest affected module (the cascade root), or null when none -// fromModule generator --from value = min (module N produces start-of-module-(N+1)) -// firstRun true when --from was absent/empty (caller should treat as full regen) +// Output (stdout, JSON): { "modules": [4,6], "min": 4, "fromModule": 4, "firstRun": false, "baselineMissing": false } +// modules sorted unique affected module numbers (1..7) +// min smallest affected module (the cascade root), or null when none +// fromModule generator --from value = min (module N produces start-of-module-(N+1)) +// firstRun true when --from was absent/empty OR unreachable (caller: full regen) +// baselineMissing true when a --from was supplied but is not a reachable commit in --acc +// +// Robustness: if the supplied --from is not a reachable commit in the ACC repo (e.g. the +// ACC history was rewritten/force-pushed and the recorded baseline was orphaned), we do NOT +// crash on `git diff `. Instead we fall back to first-run semantics (full regen). import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; @@ -39,6 +44,25 @@ function git(cwd, ...args) { return execFileSync('git', args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }); } +// True when `rev` resolves to a commit object that is actually present in the repo at `cwd`. +// Guards against a recorded baseline SHA that was orphaned by an ACC history rewrite, which +// would otherwise make `git diff ` abort with "fatal: bad object". +function commitExists(cwd, rev) { + try { + execFileSync('git', ['rev-parse', '--verify', '--quiet', `${rev}^{commit}`], { cwd, stdio: 'ignore' }); + return true; + } catch (err) { + // `git rev-parse --verify --quiet` exits 1 (silently) for a well-formed but + // unknown/unreachable revision. Any other failure (bad --acc path, not a git repo, + // git missing) exits 128 or fails to spawn; rethrow so real errors fail fast instead + // of masquerading as a missing baseline and silently triggering a full regen. + if (err && err.status === 1) return false; + throw err; + } +} + +export { commitExists }; + function moduleForPath(p) { // content/NN-*.md let m = p.match(/^content\/(\d{2})-.*\.md$/); @@ -53,11 +77,24 @@ export { moduleForPath }; function main() { const args = parseArgs(process.argv.slice(2)); - const firstRun = !args.from || args.from.trim() === ''; + // Normalize revisions once so the existence check and the diff always use the exact + // same strings (a stray newline/space, e.g. from a file, must not let the check pass + // while `git diff` still aborts on a bad revision). + const from = (args.from || '').trim(); + const to = (args.to || '').trim(); + const hasFrom = from !== ''; + + // A supplied baseline that is no longer reachable in the ACC repo (history rewrite / orphaned + // SHA) is treated as a missing baseline: fall back to first-run instead of crashing on git diff. + const baselineMissing = hasFrom && !commitExists(args.acc, from); + if (baselineMissing) { + console.error(`WARN: baseline commit ${from} is not reachable in --acc; treating as first run (full regen).`); + } + const firstRun = !hasFrom || baselineMissing; let files = []; if (!firstRun) { - const out = git(args.acc, 'diff', '--name-only', `${args.from}`, `${args.to}`); + const out = git(args.acc, 'diff', '--name-only', from, to); files = out.split('\n').map(s => s.trim()).filter(Boolean); } @@ -74,6 +111,7 @@ function main() { min, fromModule: min, firstRun, + baselineMissing, })); } diff --git a/course-build/scripts/selftest.mjs b/course-build/scripts/selftest.mjs index fb94e67..2bae9b5 100755 --- a/course-build/scripts/selftest.mjs +++ b/course-build/scripts/selftest.mjs @@ -44,5 +44,56 @@ check('M04=asset', bySource[4] === 'asset'); check('M05=seed', bySource[5] === 'seed'); check('M06=seed', bySource[6] === 'seed'); +// Baseline reachability + first-run fallback (integration: real temp git repo). +// Guards the regenerate workflow against an orphaned .last-acc-sha crashing `git diff`. +console.log('detect-affected-modules baseline fallback:'); +{ + const { execFileSync } = await import('node:child_process'); + const { mkdtempSync, writeFileSync, mkdirSync, rmSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join } = await import('node:path'); + const { commitExists } = await import('./detect-affected-modules.mjs'); + + const script = resolve(__dirname, 'detect-affected-modules.mjs'); + const repo = mkdtempSync(join(tmpdir(), 'acc-detect-')); + const g = (...a) => execFileSync('git', a, { cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + const detect = (...a) => JSON.parse(execFileSync(process.execPath, [script, '--acc', repo, ...a], { encoding: 'utf8' })); + try { + g('init', '-q'); + g('config', 'user.email', 'test@example.com'); + g('config', 'user.name', 'test'); + mkdirSync(join(repo, 'content')); + writeFileSync(join(repo, 'content', '03-x.md'), 'a\n'); + g('add', '-A'); g('commit', '-q', '-m', 'c1'); + const from = g('rev-parse', 'HEAD').trim(); + writeFileSync(join(repo, 'content', '05-y.md'), 'b\n'); + g('add', '-A'); g('commit', '-q', '-m', 'c2'); + const to = g('rev-parse', 'HEAD').trim(); + + check('commitExists true for real commit', commitExists(repo, from) === true); + const BOGUS = 'b17669201ec145c91db7175e1fa4a1d60ba9fc01'; + check('commitExists false for orphaned SHA', commitExists(repo, BOGUS) === false); + + // A non-git directory is a real error, not a missing baseline: must rethrow, not return false. + const notARepo = mkdtempSync(join(tmpdir(), 'acc-norepo-')); + let threw = false; + try { commitExists(notARepo, from); } catch { threw = true; } finally { rmSync(notARepo, { recursive: true, force: true }); } + check('commitExists rethrows on non-git dir (fails fast)', threw === true); + + const good = detect('--from', from, '--to', to); + check('valid baseline diffs (module 5 detected)', good.min === 5 && good.firstRun === false && good.baselineMissing === false); + + // A baseline with surrounding whitespace (e.g. read from a file) must be normalized + // so the existence check and the diff agree; it must not fall into a spurious regen. + const padded = detect('--from', `\n ${from}\n`, '--to', to); + check('whitespace-padded baseline is normalized (still module 5)', padded.min === 5 && padded.firstRun === false); + + const missing = detect('--from', BOGUS, '--to', to); + check('orphaned baseline -> firstRun (no crash)', missing.firstRun === true && missing.baselineMissing === true); + } finally { + rmSync(repo, { recursive: true, force: true }); + } +} + if (failures) { console.error(`\n${failures} check(s) failed.`); process.exit(1); } console.log('\nAll self-test checks passed.');