#!/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 into the report dir under its own path. Moved, // never deleted. A file tracked since then, already gone, or outside the // repository is kept where it is and named. // // Usage: // node lib/util/park-attempt-files.mjs --diff --to [--cwd ] // Prints {"parked": [paths], "kept": [{"path", "reason"}]} on stdout. // Exit: 0 = done (also when nothing was parked), 2 = could not run (bad usage, // unreadable diff, not a repository). 2 never means the tree is clean. import { readFileSync, existsSync, 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'; /** The paths a unified git diff creates (`new file mode` after its `diff --git` line). */ export function newFilesInDiff(diffText) { const paths = []; let current = null; for (const line of String(diffText).split('\n')) { const head = line.match(/^diff --git a\/(\S+) b\/(\S+)$/); if (head) { current = head[2]; continue; } if (line.startsWith('diff --git ')) { current = null; // a quoted or spaced path: not read, so never moved continue; } if (current !== null && /^new file mode \d+$/.test(line)) { paths.push(current); current = null; } } return paths; } function tracked(cwd, path) { return spawnSync('git', ['ls-files', '--error-unmatch', '--', path], { cwd, encoding: 'utf8' }).status === 0; } /** * @param {{ diffText: string, to: string, cwd?: string }} args * @returns {{ parked: string[], kept: Array<{path: string, reason: string}> }} */ export function parkAttemptFiles({ diffText, to, cwd = process.cwd() }) { const root = resolve(cwd); const dest = resolve(root, to); const parked = []; const kept = []; for (const path of newFilesInDiff(diffText)) { 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' }); continue; } if (!existsSync(abs) || !lstatSync(abs).isFile()) { kept.push({ path, reason: 'not on disk as a file: nothing to park' }); continue; } if (tracked(root, rel)) { kept.push({ path, reason: 'tracked since the attempt: not the executor\'s to move' }); continue; } const target = join(dest, rel); try { mkdirSync(dirname(target), { recursive: true }); renameSync(abs, target); parked.push(rel); } catch (e) { kept.push({ path, reason: `could not move: ${e.message}` }); } } return { parked, kept }; } 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 to = opt('--to'); const cwd = opt('--cwd') ?? process.cwd(); if (!diff || !to) { process.stderr.write('Usage: park-attempt-files.mjs --diff --to [--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); } process.stdout.write(JSON.stringify(parkAttemptFiles({ diffText, to, cwd }), null, 2) + '\n'); }