From 3eb650f52925395cbd170297d2fada4a2e52aec5 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 13 Sep 2026 07:50:05 +0000 Subject: [PATCH] feat(load): every file /load replaces is kept beside it as .bak-NNN. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @profullstack/synconfig 0.1.3 copies a file to `aliases.bak-001.json` (the next time `bak-002`, never reused) before a load replaces it, so a load — forced, or on a machine that never synced — can no longer be the thing that loses an edit. moshcode takes the bump and says where each previous copy went: loaded revision 6 — 1 file written replaced aliases.json (previous copy: aliases.bak-001.json) `/load --json` carries `backups: [{ path, backup }]`. A backup is never a synced file: the allowlist names files exactly. The house rule behind it, after ~/.moshcode was lost today to an installer with no copy of anything: before an overwrite, a numbered .bak beside the original, every time. 0.98.2. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014HMV1QDcVY1xiaWWrZQM4S --- package.json | 4 ++-- pnpm-lock.yaml | 10 +++++----- src/settings-sync.mjs | 17 ++++++++++++++--- test/settings-sync.test.mjs | 36 ++++++++++++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index d0a355d8..1646fec6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moshcode", - "version": "0.98.1", + "version": "0.98.2", "type": "module", "description": "moshcode — a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript", "repository": { @@ -36,6 +36,6 @@ "@moshcoder/moshpit-dns": "^0.5.0" }, "dependencies": { - "@profullstack/synconfig": "^0.1.1" + "@profullstack/synconfig": "^0.1.3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dfb0b66c..675a42db 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@profullstack/synconfig': - specifier: ^0.1.1 - version: 0.1.1 + specifier: ^0.1.3 + version: 0.1.3 devDependencies: '@moshcoder/moshpit-dns': specifier: ^0.5.0 @@ -23,12 +23,12 @@ packages: engines: {node: '>=20'} hasBin: true - '@profullstack/synconfig@0.1.1': - resolution: {integrity: sha512-gX/tZ0j0QShtAf2/Mpr40Fev0h7HVoh5jl3tVbFVrSfuQHalthiwVgZ8lQKzhv+0kpxvVex4qkT8xMFHSopklA==} + '@profullstack/synconfig@0.1.3': + resolution: {integrity: sha512-Ty/90ibgu2DVlablDeoFdItcobK+c8cMQFvuTfJOUlCfyvQZWoPjgt0JHPwTvq+ynOQ4i1ivBQ3K3PY/S4rObA==} engines: {node: '>=22'} snapshots: '@moshcoder/moshpit-dns@0.5.0': {} - '@profullstack/synconfig@0.1.1': {} + '@profullstack/synconfig@0.1.3': {} diff --git a/src/settings-sync.mjs b/src/settings-sync.mjs index 199f7393..5b458a61 100644 --- a/src/settings-sync.mjs +++ b/src/settings-sync.mjs @@ -328,18 +328,26 @@ export function planApply(files, { home = os.homedir() } = {}) { })); } -/** Write the snapshot's files. Returns the plan, with `written` marked. */ +/** + * Write the snapshot's files. Returns the plan, with `written` marked and, + * for a file that existed with other content, `backup`: where its previous + * content went (`aliases.bak-001.json`, beside it — synconfig's rule, so a + * load can never be the thing that loses an edit). + */ export function applyFiles(files, { home = os.homedir() } = {}) { const plan = planApply(files, { home }); + const backups = new Map(); const written = new Set( applySnapshotFiles( moshcodeDir(home), files, plan.map((item) => ({ path: item.path, status: item.action })), + { onBackup: (path, backup) => backups.set(path, backup) }, ), ); for (const item of plan) { if (written.has(item.path)) item.written = true; + if (backups.has(item.path)) item.backup = backups.get(item.path); } return plan; } @@ -714,12 +722,15 @@ export async function loadCommand(argv = [], { saveMarker(markerFor({ revision, digest: digestFiles(files), files, host: hostname, api: endpoint(creds) }), home); const written = applied.filter((p) => p.written); + const backups = written.filter((w) => w.backup).map((w) => ({ path: w.path, backup: w.backup })); if (json) { - emit({ status: "loaded", revision, from, files: written.map((w) => w.path), rejected }); + emit({ status: "loaded", revision, from, files: written.map((w) => w.path), backups, rejected }); return 0; } write(`loaded revision ${revision}${from ? ` from ${from}` : ""} — ${plural(written.length, "file")} written`); - for (const item of written) write(` ${item.action === "new" ? "added " : "replaced"} ${item.path}`); + for (const item of written) { + write(` ${item.action === "new" ? "added " : "replaced"} ${item.path}${item.backup ? ash(` (previous copy: ${item.backup})`) : ""}`); + } for (const r of rejected) write(` ignored ${r.path} — ${r.reason}`); // Names only, and only the missing ones. The snapshot records what the source diff --git a/test/settings-sync.test.mjs b/test/settings-sync.test.mjs index 0cba6e7c..2aa41962 100644 --- a/test/settings-sync.test.mjs +++ b/test/settings-sync.test.mjs @@ -465,6 +465,42 @@ test("/load refuses to overwrite a file edited since the last sync", async () => assert.equal(code2, 0); assert.equal(read(dir, "aliases.json"), '{"gs":"git log"}'); assert.equal(loadMarker(dir).revision, 6); + // And the edit it overwrote is still on disk, beside the file, and named. + assert.equal(read(dir, "aliases.bak-001.json"), '{"gs":"git status --short"}', "a forced load must leave the previous content behind"); + assert.match(forced.text(), /replaced aliases\.json.*previous copy: aliases\.bak-001\.json/); +}); + +test("a replaced file is copied to .bak-NNN. first, numbered and never reused", () => { + // The house rule, as synconfig applies it: a load can never be the thing + // that loses an edit. A new file has nothing to back up; a second + // replacement takes the next number and leaves the first copy alone. + const dir = home({ aliases: '{"gs":"git status"}' }); + const first = applyFiles({ "aliases.json": { content: '{"gs":"git log"}' }, "herd/rules.json": { content: "{}" } }, { home: dir }); + assert.equal(first.find((p) => p.path === "aliases.json").backup, "aliases.bak-001.json"); + assert.equal(first.find((p) => p.path === "herd/rules.json").backup, undefined, "a new file has no previous content"); + assert.equal(read(dir, "aliases.bak-001.json"), '{"gs":"git status"}'); + assert.equal(fs.statSync(path.join(dir, ".moshcode", "aliases.bak-001.json")).mode & 0o777, 0o600); + + const second = applyFiles({ "aliases.json": { content: '{"gs":"git diff"}' } }, { home: dir }); + assert.equal(second[0].backup, "aliases.bak-002.json"); + assert.equal(read(dir, "aliases.bak-001.json"), '{"gs":"git status"}', "the first copy must survive the second"); + assert.equal(read(dir, "aliases.bak-002.json"), '{"gs":"git log"}'); + // A backup is never a synced file: the allowlist names files exactly. + assert.equal(isSyncable("aliases.bak-001.json"), false); +}); + +test("/load --json lists the backups it made", async () => { + const dir = home({ aliases: '{"gs":"git status"}' }); + const write = lines(); + const code = await loadCommand(["--json", "--force"], { + home: dir, creds: CREDS, write, + fetchImpl: stubFetch([[200, { revision: 7, snapshot: snapshotFor({ "aliases.json": { content: '{"gs":"git log"}' } }) }]]), + installed: { engines: [], tools: [] }, + }); + assert.equal(code, 0); + const out = JSON.parse(write.text()); + assert.equal(out.status, "loaded"); + assert.deepEqual(out.backups, [{ path: "aliases.json", backup: "aliases.bak-001.json" }]); }); test("/load --dry-run reports the plan and writes nothing", async () => {