#!/usr/bin/env node // lib/util/park-attempt-files.mjs // A step sent back to the plan (`fix_rounds` 4) is reverted with its new files // left on disk, untracked: nothing the step made is deleted. On --resume the // re-planned step is built fresh, but the stale files were still there, and in // voyage-lekerepo trial 5 (finding 4) an old tests/batch.sh holding the // rejected line would have passed Verify, the manifest and the success // criterion. Only the plan critic caught it. // // Before the fresh build the executor parks them: every file the attempt // created (a `new file mode` entry in step-{N}-upward.diff) that is still // untracked on disk moves to /voyage/parked/step--attempt-/ // under its own path. The git dir, like the test lock's, is outside the work // tree, so a parked file never comes back as a new file of the next build. // Moved, never deleted, and never over a file already parked there. // // Usage: // node lib/util/park-attempt-files.mjs --diff --step --attempt [--cwd ] // Prints {"parked": [paths], "kept": [{"path", "reason", "blocking"}], "to": dir}. // Exit: 0 = no file the attempt made is left in the tree; 1 = one still is // (a header it could not read, a move that failed, a target already taken): // the run stops; 2 = could not run (bad usage, unreadable diff, not a // repository). Neither 1 nor 2 is ever "the tree is clean". import { readFileSync, mkdirSync, renameSync, lstatSync } from 'node:fs'; import { join, dirname, resolve, relative, isAbsolute } from 'node:path'; import { spawnSync } from 'node:child_process'; import { isMain } from './is-main.mjs'; /** * What a unified git diff creates: `paths` read from a `diff --git a/X b/X` * header followed by `new file mode`, and `unread`, the raw headers of new * files whose header is not that form (another prefix, a quoted path). */ export function newFilesInDiff(diffText) { const paths = []; const unread = []; let header = null; for (const line of String(diffText).split('\n')) { if (line.startsWith('diff --git ')) { header = line; continue; } if (header !== null && /^new file mode \d+$/.test(line)) { // Both names are the same for a new file, so a space in it is read too. const m = header.match(/^diff --git a\/(.+) b\/\1$/); if (m) paths.push(m[1]); else unread.push(header); header = null; } } return { paths, unread }; } function lexists(abs) { try { lstatSync(abs); return true; } catch { return false; } } /** * @param {{ diffText: string, step: string|number, attempt: string|number, cwd?: string }} args * @returns {{ parked: string[], kept: Array<{path: string, reason: string, blocking: boolean}>, to: string }} */ export function parkAttemptFiles({ diffText, step, attempt, cwd = process.cwd() }) { const root = resolve(cwd); const gitDir = spawnSync('git', ['rev-parse', '--absolute-git-dir'], { cwd: root, encoding: 'utf8' }).stdout.trim(); const to = join(gitDir, 'voyage', 'parked', `step-${step}-attempt-${attempt}`); const trackedPaths = new Set( spawnSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' }).stdout.split('\0').filter(Boolean), ); const { paths, unread } = newFilesInDiff(diffText); const parked = []; const kept = unread.map((h) => ({ path: h, reason: 'diff header not read: move this file out by hand', blocking: true })); for (const path of paths) { const abs = resolve(root, path); const rel = relative(root, abs); if (isAbsolute(path) || rel === '' || rel.startsWith('..') || isAbsolute(rel) || rel.split(/[\\/]/)[0] === '.git') { kept.push({ path, reason: 'outside the repository: refused', blocking: false }); continue; } if (!lexists(abs)) { kept.push({ path, reason: 'not on disk: nothing to park', blocking: false }); continue; } if (trackedPaths.has(rel.split('\\').join('/'))) { kept.push({ path, reason: 'tracked since the attempt: not the executor\'s to move', blocking: false }); continue; } const target = join(to, rel); if (lexists(target)) { kept.push({ path, reason: `already parked at ${target}: never overwritten, move it by hand`, blocking: true }); continue; } try { mkdirSync(dirname(target), { recursive: true }); renameSync(abs, target); parked.push(rel); } catch (e) { kept.push({ path, reason: `could not move: ${e.message}`, blocking: true }); } } return { parked, kept, to }; } if (isMain(import.meta.url)) { const argv = process.argv.slice(2); const opt = (name) => { const i = argv.indexOf(name); return i === -1 ? undefined : argv[i + 1]; }; const diff = opt('--diff'); const step = opt('--step'); const attempt = opt('--attempt'); const cwd = opt('--cwd') ?? process.cwd(); if (!diff || !/^[0-9]+$/.test(step ?? '') || !/^[0-9]+$/.test(attempt ?? '')) { process.stderr.write('Usage: park-attempt-files.mjs --diff --step --attempt [--cwd ]\n'); process.exit(2); } let diffText; try { diffText = readFileSync(diff, 'utf8'); } catch (e) { process.stderr.write(`park-attempt-files: cannot read ${diff}: ${e.message}\n`); process.exit(2); } if (spawnSync('git', ['rev-parse', '--git-dir'], { cwd, encoding: 'utf8' }).status !== 0) { process.stderr.write(`park-attempt-files: ${cwd} is not a git repository\n`); process.exit(2); } const result = parkAttemptFiles({ diffText, step, attempt, cwd }); process.stdout.write(JSON.stringify(result, null, 2) + '\n'); process.exit(result.kept.some((k) => k.blocking) ? 1 : 0); }