Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 45 additions & 7 deletions course-build/scripts/detect-affected-modules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@
// node detect-affected-modules.mjs --acc <acc-repo-path> --from <sha> --to <sha>
// node detect-affected-modules.mjs --acc <path> --to <sha> # 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 <bad-object>`. Instead we fall back to first-run semantics (full regen).

import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
Expand All @@ -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 <bad-object>` 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$/);
Expand All @@ -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);
}

Expand All @@ -74,6 +111,7 @@ function main() {
min,
fromModule: min,
firstRun,
baselineMissing,
}));
}

Expand Down
51 changes: 51 additions & 0 deletions course-build/scripts/selftest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.');
Loading