commit 2ca332a216a1e842fba87dd66bbdbffb1b07d96e
parent d0f084d9173ecd05001dcb6708e34ee9caa4f1cf
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 22:55:08 -0400
umtool: re-review — a real out/ beside a leftover says what to do by hand; stat a linked project; leftover skips only for the moved names
R1: when out/ is a real directory and an out.moved-* or out.incoming also
exists, both movers refuse with one sentence naming both, saying the
leftover holds the moved data and to keep one, remove the other by hand,
then run the move (it used to send each move to the other, which refused
again); folders.md says the same. R2: ensureOutDir's project check stats,
so a project directory that is itself a link is accepted. N6: the walk's
leftover skip is only out/clips/share-* followed by .moved-*/.incoming.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 38 insertions(+), 9 deletions(-)
diff --git a/umtool/docs/folders.md b/umtool/docs/folders.md
@@ -27,7 +27,10 @@ first writer (`lib/report/storage.mjs` `ensureOutDir`) or by
would make its tree on the main disk.
- A cut move leaves `out.moved-<stamp>` or `out.incoming` beside the project's
`out`. While one exists, no writer makes a new `out/` and both moves refuse,
- naming it: run the move it names again to finish it.
+ naming it: run the move it names again to finish it. When a real `out/` exists
+ beside the leftover too (a writer made a fresh one after the cut), the moves
+ refuse and say so: the leftover holds the moved data — keep one, remove the
+ other by hand, then run the move.
- It is a READ root (a realpath through the link lands under it), never a write root.
- The walk skips `out`, `clips` and `share-*`, so it never stats a link into a
drive that is not there.
diff --git a/umtool/lib/projects/kinds.mjs b/umtool/lib/projects/kinds.mjs
@@ -63,9 +63,10 @@ export const SKIP_PREFIXES = ["share-"];
/**
* What a cut move leaves beside the directory it moved (lib/report/storage.mjs):
- * `out.moved-<stamp>`, `out.incoming`. Never a project, and possibly large.
+ * `out.moved-<stamp>`, `out.incoming` -- only for the names that move, so a
+ * folder of projects that merely ends in `.incoming` is still walked.
*/
-const MOVE_LEFTOVER = /\.(moved-[^/]*|incoming)$/;
+const MOVE_LEFTOVER = /^(out|clips|share-[^/]*)\.(moved-[^/]*|incoming)$/;
/** Whether the walk skips a directory entry by its name. */
export const skipsDir = (name) =>
diff --git a/umtool/lib/report/storage.mjs b/umtool/lib/report/storage.mjs
@@ -155,7 +155,10 @@ export async function ensureOutDir(projectDir, opts = {}) {
if (problem) throw new Error(`cannot make ${out}: ${problem}`);
// The project must exist before its mirror is made: otherwise the symlink
// fails and leaves an empty mirror on the media root (review N5).
- if ((await pathState(projectDir)).kind !== "dir") throw new Error(`cannot make ${out}: ${projectDir} is not a directory`);
+ // stat, not lstat: a project directory may itself be a link (the walk follows them).
+ if (!(await stat(/* turbopackIgnore: true */ projectDir).then((st) => st.isDirectory(), () => false))) {
+ throw new Error(`cannot make ${out}: ${projectDir} is not a directory`);
+ }
const target = path.join(/* turbopackIgnore: true */ mirror, "out");
// Recursive is safe here: the root itself was just seen to exist.
await mkdir(/* turbopackIgnore: true */ target, { recursive: true });
@@ -346,9 +349,18 @@ export async function leftoversOf(projectDir, name) {
* made a fresh `<name>/` there, and a move that then mirrored it over the
* complete copy, would lose everything but the leftover nobody names.
*/
-async function assertNoLeftovers(projectDir, name, what) {
+async function assertNoLeftovers(projectDir, name, what, { beside = false } = {}) {
const left = await leftoversOf(projectDir, name);
if (!left.length) return;
+ const names = left.map((p) => path.basename(/* turbopackIgnore: true */ p)).join(", ");
+ if (beside) {
+ // A real `<name>/` AND a leftover: running a move again would only refuse
+ // again (re-review R1). Only a person can say which one to keep.
+ throw new Error(
+ `${name}/ and ${names} both exist in ${projectDir}: a move of ${name}/ was cut, and something made a fresh ${name}/ since. ` +
+ `The leftover holds the moved data. Keep one and remove the other by hand, then run the move; ${what}`,
+ );
+ }
throw new Error(
`a move of ${name}/ in ${projectDir} was cut (left: ${left.map((p) => path.basename(/* turbopackIgnore: true */ p)).join(", ")}) — ` +
`run \`umtool storage ${left.some((p) => p.endsWith(".incoming")) ? "move-back" : "move-out"} <project>\` to finish it; ${what}`,
@@ -438,7 +450,7 @@ export async function moveDirToMedia(projectDir, name, opts = {}) {
// A real directory: the move itself -- unless a cut move left something
// behind, in which case this directory is a writer's fresh one.
- await assertNoLeftovers(projectDir, name, "nothing moved");
+ await assertNoLeftovers(projectDir, name, "nothing moved", { beside: true });
const problem = await mediaRootProblem(roots);
if (problem) throw new Error(`cannot move ${src}: ${problem}`);
const measured = await measureTree(src);
@@ -490,7 +502,7 @@ export async function moveDirToLocal(projectDir, name, opts = {}) {
const mirror = roots.tiered ? mediaMirror(projectDir, roots) : null;
const ownCopy = mirror ? path.join(/* turbopackIgnore: true */ mirror, name) : null;
if (s.kind === "dir") {
- await assertNoLeftovers(projectDir, name, "nothing moved");
+ await assertNoLeftovers(projectDir, name, "nothing moved", { beside: true });
// A move-back cut after its rename leaves the media copy behind (review
// L4): report it, never delete it blind.
const left = ownCopy && (await pathState(ownCopy)).kind === "dir" ? ownCopy : undefined;
diff --git a/umtool/lib/report/storage.test.mjs b/umtool/lib/report/storage.test.mjs
@@ -405,8 +405,14 @@ test("a cut move's leftovers are a guard: no fresh out/, no move over them (L5)"
assert.equal(await kind(path.join(w.projectDir, "out")), "missing");
// A writer that made a fresh out/ anyway (an older binary): move-out refuses to mirror it over.
await mkdir(path.join(w.projectDir, "out"));
- await assert.rejects(moveDirToMedia(w.projectDir, "out", w.roots), /was cut .* nothing moved/);
- await assert.rejects(moveDirToLocal(w.projectDir, "out", w.roots), /was cut .* nothing moved/);
+ // Both exist: neither move sends the person to the other, which would only
+ // refuse again (re-review R1); the sentence says what to do by hand.
+ for (const move of [moveDirToMedia, moveDirToLocal]) {
+ await assert.rejects(
+ move(w.projectDir, "out", w.roots),
+ /out\/ and out\.moved-20261001T000000Z both exist .*The leftover holds the moved data\. Keep one and remove the other by hand, then run the move; nothing moved/,
+ );
+ }
assert.deepEqual(await readdir(w.mediaRoot), []);
await rm(path.join(w.projectDir, "out"), { recursive: true });
await rm(parked, { recursive: true });
@@ -428,6 +434,12 @@ test("ensureOutDir for a project that does not exist leaves no empty mirror (N5)
const ghost = path.join(w.reportsRoot, "ghost");
await assert.rejects(ensureOutDir(ghost, w.roots), /is not a directory/);
assert.deepEqual(await readdir(w.mediaRoot), []);
+ // A project directory that is itself a link is a directory (re-review R2).
+ const real = path.join(w.base, "elsewhere-proj");
+ await mkdir(real);
+ const linked = path.join(w.reportsRoot, "linked");
+ await symlink(real, linked);
+ assert.equal(await kind(await ensureOutDir(linked, w.roots)), "link");
} finally {
await w.done();
}
@@ -441,6 +453,7 @@ test("the project walk skips out, clips and share-* (a link into an unplugged dr
assert.ok(SKIP_DIRS.has("out") && SKIP_DIRS.has("clips"));
assert.ok(skipsDir("share-emancipation") && skipsDir("clips") && !skipsDir("shares") && !skipsDir("project"));
assert.ok(skipsDir("out.moved-20261001T000000Z") && skipsDir("out.incoming") && !skipsDir("incoming"));
+ assert.ok(skipsDir("share-x.incoming") && !skipsDir("drafts.incoming") && !skipsDir("old.moved-2026"));
const w = await world();
try {
for (const hidden of ["clips", "share-x"]) {