Lock during updates, repair duplicate ids, keep file identity on save

- CLI holds a lock file from load to save, so concurrent commands can't
  lose an update; stale locks from crashed runs are taken over
- load() renumbers duplicate ids from the old add() bug instead of
  refusing the file
- save() keeps owner, group and setgid on the temp file, and falls back to
  an in-place write for hard-linked files, files whose owner/group can't
  be kept, and directories that aren't writable

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
christianandClaude Opus 5.5 committed 2026-10-21 00:49:47 +02:00
1 parent f09d20cb84
commit dab492da1e
5 files changed
+128 -17

No files matched your search

+34 -2
View File
@@ -1,7 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, writeFileSync, readFileSync, existsSync, chmodSync } from "node:fs";
import { spawnSync, spawn } from "node:child_process";
import { mkdtempSync, writeFileSync, readFileSync, existsSync, chmodSync, utimesSync } from "node:fs";
import { tmpdir } from "node:os";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
@@ -49,6 +49,38 @@ test("add fails readably when no ids are left, without saving", () => {
assert.equal(readFileSync(file, "utf8"), before);
});
test("concurrent adds don't lose updates or reuse ids", async () => {
const file = tempFile();
const runs = Array.from({ length: 12 }, (_, i) =>
new Promise((done) => {
const child = spawn(process.execPath, [CLI, "add", `t${i}`], { env: { ...process.env, TODO_FILE: file } });
child.on("exit", done);
}));
assert.deepEqual(await Promise.all(runs), Array(12).fill(0));
const todos = JSON.parse(readFileSync(file, "utf8"));
assert.equal(todos.length, 12);
assert.deepEqual(todos.map((t) => t.id).sort((a, b) => a - b), Array.from({ length: 12 }, (_, i) => i + 1));
assert.equal(existsSync(`${file}.lock`), false);
});
test("a stale lock from a crashed run is taken over", () => {
const file = tempFile();
writeFileSync(`${file}.lock`, "");
const old = new Date(Date.now() - 60_000);
utimesSync(`${file}.lock`, old, old);
const res = run(file, "add", "a");
assert.equal(res.status, 0, res.stderr);
assert.equal(existsSync(`${file}.lock`), false);
});
test("rm on a file with duplicate ids removes only the first of them", () => {
const file = tempFile();
writeFileSync(file, JSON.stringify([{ id: 2, text: "b", done: false }, { id: 2, text: "c", done: false }]));
const res = run(file, "rm", "2");
assert.equal(res.status, 0, res.stderr);
assert.equal(res.stdout.trim(), "[ ] 3. c");
});
test("add with no text fails without saving", () => {
const file = tempFile();
const res = run(file, "add", " ");