Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down Expand Up @@ -36,6 +36,6 @@
"@moshcoder/moshpit-dns": "^0.5.0"
},
"dependencies": {
"@profullstack/synconfig": "^0.1.1"
"@profullstack/synconfig": "^0.1.3"
}
}
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 14 additions & 3 deletions src/settings-sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions test/settings-sync.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>.bak-NNN.<ext> 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 () => {
Expand Down
Loading