From ba51bb3ca338d754f76a2554e5c7fa90f6f857d6 Mon Sep 17 00:00:00 2001 From: Bruno Guidolim Date: Thu, 15 Oct 2026 18:02:33 +0200 Subject: [PATCH] Share the memory pack's usage file without blocking or conflicting pushes - Exempt memories/.usage.tsv from the naming guardrail and give usage-only commits their own subject - Add a per-clone merge=union rule on sync, with a doctor warning and a Stop-hook fallback for clones without it - Autostash the SessionStart pull and stop reporting fresh usage rows as lingering state --- README.md | 20 ++- commands/resolve-memories.md | 1 + runtime/lib/commit-message.mts | 7 +- runtime/lib/git.mts | 8 + runtime/lib/naming.mts | 8 + runtime/lib/pending.mts | 13 +- runtime/lib/push.mts | 67 +++++-- runtime/pull.mts | 7 +- scripts/configure-memories.ts | 57 +++++- scripts/doctor-memories.ts | 17 +- tests/configure.test.ts | 88 ++++++++- tests/differential.test.ts | 170 +++++++++++++++++- ...no-memories-tree-exits-2-without-done.json | 2 +- ...-symlink-with-no-checkout-is-replaced.json | 2 +- ...d-md-file-migrates-but-is-not-counted.json | 2 +- ...n-the-migration-backup-is-left-behind.json | 2 +- ...ry-beside-a-live-checkout-is-relinked.json | 2 +- ...e-project-path-is-not-normalised-away.json | 2 +- ...ated-file-conflicting-imports-nothing.json | 2 +- ...deleted-files-straddle-the-reason-cap.json | 2 +- ...an-already-linked-setup-is-left-alone.json | 2 +- ...named-migrated-file-is-left-untracked.json | 2 +- ...kdown-file-is-not-counted-as-a-memory.json | 2 +- ...emories-directory-migrates-and-pushes.json | 2 +- ...reviously-deleted-memory-is-held-back.json | 2 +- ...n-empty-memories-directory-is-removed.json | 2 +- ...-shared-copy-wins-a-filename-conflict.json | 2 +- ...-checkout-with-no-symlink-is-relinked.json | 2 +- .../case-5-fresh-setup-clones-and-links.json | 2 +- ...g-union-rule-for-the-usage-file-warns.json | 6 + ...nion-rule-is-listed-with-the-failures.json | 6 + ...dangling-symlink-is-skipped-not-fatal.json | 2 +- ...tion-commit-is-not-reported-as-a-push.json | 2 +- ...eady-has-the-union-rule-is-left-alone.json | 6 + ...y-the-fallback-without-the-union-rule.json | 6 + ...d-merges-cleanly-under-the-union-rule.json | 6 + ...s-base-marker-and-keeps-its-base-rows.json | 6 + ...mmitted-as-usage-not-as-a-memory-edit.json | 6 + ...s-not-lingering-state-at-sessionstart.json | 6 + ...named-one-blocks-on-the-bad-name-only.json | 6 + ...age-a-fresh-clone-gets-the-union-rule.json | 6 + ...-is-merged-and-left-for-the-next-stop.json | 6 + ...-file-is-appended-onto-the-shared-one.json | 6 + ...oved-in-when-the-shared-repo-has-none.json | 6 + ...ng-beside-the-usage-file-still-pauses.json | 6 + ...mory-leaves-the-subject-to-the-memory.json | 6 + ...nion-rule-beside-its-other-attributes.json | 6 + ...y-the-fallback-without-the-union-rule.json | 6 + ...es-merge-cleanly-under-the-union-rule.json | 6 + ...y-the-fallback-without-the-union-rule.json | 6 + ...es-merge-cleanly-under-the-union-rule.json | 6 + ...mate-s-usage-rows-over-our-dirty-ones.json | 6 + tests/harness.ts | 20 +++ tests/scripts.test.ts | 18 +- tests/unit.test.ts | 36 +++- 55 files changed, 630 insertions(+), 71 deletions(-) create mode 100644 tests/golden/doctor-a-missing-union-rule-for-the-usage-file-warns.json create mode 100644 tests/golden/doctor-a-missing-union-rule-is-listed-with-the-failures.json create mode 100644 tests/golden/usage-a-clone-that-already-has-the-union-rule-is-left-alone.json create mode 100644 tests/golden/usage-a-compaction-against-an-append-is-merged-by-the-fallback-without-the-union-rule.json create mode 100644 tests/golden/usage-a-compaction-against-an-append-merges-cleanly-under-the-union-rule.json create mode 100644 tests/golden/usage-a-diff3-style-conflict-loses-its-base-marker-and-keeps-its-base-rows.json create mode 100644 tests/golden/usage-a-dirty-usage-file-alone-is-committed-as-usage-not-as-a-memory-edit.json create mode 100644 tests/golden/usage-a-dirty-usage-file-alone-is-not-lingering-state-at-sessionstart.json create mode 100644 tests/golden/usage-a-dirty-usage-file-beside-a-badly-named-one-blocks-on-the-bad-name-only.json create mode 100644 tests/golden/usage-a-fresh-clone-gets-the-union-rule.json create mode 100644 tests/golden/usage-a-local-usage-file-alone-is-merged-and-left-for-the-next-stop.json create mode 100644 tests/golden/usage-a-local-usage-file-is-appended-onto-the-shared-one.json create mode 100644 tests/golden/usage-a-local-usage-file-is-moved-in-when-the-shared-repo-has-none.json create mode 100644 tests/golden/usage-a-memory-conflicting-beside-the-usage-file-still-pauses.json create mode 100644 tests/golden/usage-a-usage-file-changed-with-a-memory-leaves-the-subject-to-the-memory.json create mode 100644 tests/golden/usage-an-existing-clone-gets-the-union-rule-beside-its-other-attributes.json create mode 100644 tests/golden/usage-appends-on-both-sides-are-merged-by-the-fallback-without-the-union-rule.json create mode 100644 tests/golden/usage-appends-on-both-sides-merge-cleanly-under-the-union-rule.json create mode 100644 tests/golden/usage-in-place-edits-on-both-sides-are-merged-by-the-fallback-without-the-union-rule.json create mode 100644 tests/golden/usage-in-place-edits-on-both-sides-merge-cleanly-under-the-union-rule.json create mode 100644 tests/golden/usage-sessionstart-fast-forwards-a-teammate-s-usage-rows-over-our-dirty-ones.json diff --git a/README.md b/README.md index e8ebecb..bd34339 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ Each runs as `node --experimental-strip-types --disable-warning=ExperimentalWarn | Script | When | What It Does | |--------|------|-------------| -| **configure-memories.ts** | `mcs sync` | Sparse-clones the shared repo, sets up the symlink, migrates any pre-existing `.claude/memories/` into the shared folder | +| **configure-memories.ts** | `mcs sync` | Sparse-clones the shared repo, sets up the symlink, adds the `merge=union` rule for [the usage file](#-the-usage-file), migrates any pre-existing `.claude/memories/` into the shared folder | ### CLAUDE.local.md Section @@ -130,7 +130,7 @@ Each runs as `node --experimental-strip-types --disable-warning=ExperimentalWarn | Check | What It Verifies | |-------|-----------------| -| **Shared memories setup** | Sparse checkout exists and the symlink resolves to a live git repo (auto-fixable via `mcs sync`) | +| **Shared memories setup** | Sparse checkout exists and the symlink resolves to a live git repo; warns when the usage file's `merge=union` rule is missing (auto-fixable via `mcs sync`) | | **Shared memories remote access** | Auth + network reachability via `git ls-remote origin`; warns (doesn't fail) on issues since local reads still work | ### Dependencies @@ -226,7 +226,8 @@ On engineer disks, the pack materializes as: │ ├── README.md, LICENSE, etc. # any root-level files your repo ships │ └── memories/ │ ├── learning_*.md -│ └── decision_*.md +│ ├── decision_*.md +│ └── .usage.tsv # last-read dates, written by the memory pack └── memories -> .memories-repo/memories # symlink Claude Code reads ``` @@ -335,6 +336,19 @@ The Stop hook retries on every turn, so a network blip or a teammate pushing fir --- +## 📊 The Usage File + +The memory pack records the day each memory was last opened in `memories/.usage.tsv`, and this pack shares it like any memory, so the team sees which memories anyone still reads. The memory pack owns the format; this pack only moves it: + +- **Guardrail**: exactly `memories/.usage.tsv` is exempt from the naming rule. +- **Commits**: a change to it alone commits as `: update memory usage`; beside memory edits it is left out of the message. +- **Merging**: `mcs sync` adds `memories/.usage.tsv merge=union` to the checkout's `.git/info/attributes`, so two clones updating it never conflict. Nothing is committed to the team's repo. A clone that hasn't re-synced still pushes: when the usage file is the only conflict, the Stop hook keeps both sides' lines and finishes the rebase itself. A conflict on any real memory still pauses for `/resolve-memories`. +- **SessionStart**: rows written after the last Stop are autostashed around the fast-forward, and don't count as lingering state; the next Stop pushes them. +- **Migration**: a pre-existing local `.usage.tsv` is appended onto the shared one. Duplicate rows are expected; the memory pack compacts them. +- **Doctor**: warns when the `merge=union` rule is missing. + +--- + ## 🔧 Troubleshooting ```bash diff --git a/commands/resolve-memories.md b/commands/resolve-memories.md index 0b2e017..19ce332 100644 --- a/commands/resolve-memories.md +++ b/commands/resolve-memories.md @@ -49,6 +49,7 @@ Set it: `git -C .claude/.memories-repo branch --set-upstream-to=origin/ * subject and one line per file in the body. Who and what, not which machine: * the date is already in the commit. */ -export function commitMessage(who: string, changes: readonly Change[]): string { +export function commitMessage(who: string, all: readonly Change[]): string { + // Read dates are bookkeeping; listing them would make every commit read as a memory edit. + const changes = all.filter((c) => c.path !== USAGE_FILE); const [only] = changes; - if (only === undefined) return `${who}: update memories`; + if (only === undefined) return all.length > 0 ? `${who}: update memory usage` : `${who}: update memories`; if (changes.length === 1) return `${who}: ${only.kind} ${names(only)}`; const summary = ORDER.flatMap((k) => { diff --git a/runtime/lib/git.mts b/runtime/lib/git.mts index cabd406..39ec5ce 100644 --- a/runtime/lib/git.mts +++ b/runtime/lib/git.mts @@ -1,5 +1,6 @@ import { spawnSync } from "node:child_process"; import { existsSync } from "node:fs"; +import { USAGE_FILE } from "./naming.mts"; export type GitRun = { readonly ok: boolean; @@ -95,6 +96,13 @@ export function authorName(dir: string): string { return /^(.*?) f === USAGE_FILE || ALLOWED_PATTERN.test(f); diff --git a/runtime/lib/pending.mts b/runtime/lib/pending.mts index 9768949..dbab8f9 100644 --- a/runtime/lib/pending.mts +++ b/runtime/lib/pending.mts @@ -1,8 +1,15 @@ import { gitLines } from "./git.mts"; -import { ALLOWED_PATTERN } from "./naming.mts"; +import { isAllowedPath, USAGE_FILE } from "./naming.mts"; + +const dirtyLines = (repo: string): string[] => gitLines(repo, ["status", "--porcelain", "--", "memories/"]); export function uncommittedCount(repo: string): number { - return gitLines(repo, ["status", "--porcelain", "--", "memories/"]).length; + return dirtyLines(repo).length; +} + +/** Uncommitted memories a Stop should have pushed. The usage file is excluded: it gains rows after every Stop. */ +export function lingeringCount(repo: string): number { + return dirtyLines(repo).filter((l) => l.slice(3) !== USAGE_FILE).length; } /** Dirty paths under memories/ that fail the naming rule: `{ diff --name-only HEAD ; untracked } | sort -u`. */ @@ -10,5 +17,5 @@ export function badNames(repo: string): string[] { const tracked = gitLines(repo, ["diff", "--name-only", "HEAD", "--", "memories/"]); const untracked = gitLines(repo, ["ls-files", "--others", "--exclude-standard", "--full-name", "--", "memories/"]); const dirty = [...new Set([...tracked, ...untracked])].sort(); - return dirty.filter((f) => !ALLOWED_PATTERN.test(f)); + return dirty.filter((f) => !isAllowedPath(f)); } diff --git a/runtime/lib/push.mts b/runtime/lib/push.mts index d164af1..a5cea79 100644 --- a/runtime/lib/push.mts +++ b/runtime/lib/push.mts @@ -1,6 +1,8 @@ -import { git } from "./git.mts"; +import { readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { git, gitLines } from "./git.mts"; import { detail, say } from "./hook-io.mts"; -import { RESOLVE_COMMAND } from "./naming.mts"; +import { RESOLVE_COMMAND, USAGE_FILE } from "./naming.mts"; export function pushAttempts(raw: string | undefined): number { if (raw === undefined || raw === "") return 12; @@ -21,6 +23,48 @@ function sleep(ms: number): void { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } +const CONFLICT_MARKER = /^(<{7}|\|{7}|={7}|>{7})( |$)/; + +const unmergedPaths = (repo: string): string[] => gitLines(repo, ["diff", "--name-only", "--diff-filter=U"]); + +const PAUSED = `Shared memories: auto-push paused — rebase conflict. Run ${RESOLVE_COMMAND} to merge it.`; + +// Clones synced before the union rule existed still conflict on the usage file, +// and that must never pause the memory pushes behind it. Returns why the rebase +// has to be aborted, or null once it finished. +function finishUsageOnlyRebase(repo: string): string | null { + let unmerged = unmergedPaths(repo); + if (unmerged.length === 0) return PAUSED; + for (; unmerged.length > 0; unmerged = unmergedPaths(repo)) { + if (unmerged.some((f) => f !== USAGE_FILE)) return PAUSED; + + // Dropping the markers is exactly what `merge=union` writes, and it keeps rows + // the memory pack appended before this read; one landing before the write is lost. + const path = join(repo, USAGE_FILE); + try { + const lines = readFileSync(path, "utf8").split("\n"); + writeFileSync(path, lines.filter((l) => !CONFLICT_MARKER.test(l)).join("\n")); + } catch { + break; + } + if (!git(repo, ["add", "--", USAGE_FILE]).ok) break; + if (git(repo, ["rebase", "--continue"], { env: { GIT_EDITOR: "true" } }).ok) return null; + } + return `Shared memories: could not finish the rebase after merging ${USAGE_FILE}. Will retry on next Stop.`; +} + +function abortRebase(repo: string, why: string): void { + const abort = git(repo, ["rebase", "--abort"]); + if (!abort.ok) { + say( + `Shared memories: rebase conflict AND --abort failed — repo may be in a half-rebased state. Run ${RESOLVE_COMMAND} to finish it.`, + ); + detail(abort.stderr.replace(/\n$/, "")); + } else { + say(why); + } +} + export function syncToRemote(repo: string, attemptsRaw: string | undefined): void { const attempts = pushAttempts(attemptsRaw); for (let attempt = 1; ; attempt++) { @@ -28,21 +72,16 @@ export function syncToRemote(repo: string, attemptsRaw: string | undefined): voi const pull = git(repo, ["pull", "--rebase", "--autostash", "--quiet"], { env: { LC_ALL: "C" } }); if (!pull.ok) { const err = pull.stderr.replace(/\n$/, ""); - if (/conflict/i.test(err)) { - const abort = git(repo, ["rebase", "--abort"]); - if (!abort.ok) { - say( - `Shared memories: rebase conflict AND --abort failed — repo may be in a half-rebased state. Run ${RESOLVE_COMMAND} to finish it.`, - ); - detail(abort.stderr.replace(/\n$/, "")); - } else { - say(`Shared memories: auto-push paused — rebase conflict. Run ${RESOLVE_COMMAND} to merge it.`); - } - } else { + if (!/conflict/i.test(err)) { say("Shared memories: pull --rebase failed (likely auth or network). Will retry on next Stop."); detail(err); + return; + } + const why = finishUsageOnlyRebase(repo); + if (why !== null) { + abortRebase(repo, why); + return; } - return; } const push = git(repo, ["push", "--quiet"]); diff --git a/runtime/pull.mts b/runtime/pull.mts index d1d1558..89a80ad 100755 --- a/runtime/pull.mts +++ b/runtime/pull.mts @@ -2,7 +2,7 @@ import { additionalContext, failOpen, isJsonStream, readStdin, warn } from "./lib/hook-io.mts"; import { aheadOfUpstream, git, gitPresent, hasUpstream, isWorkTree, operationInProgress } from "./lib/git.mts"; import { RESOLVE_COMMAND } from "./lib/naming.mts"; -import { uncommittedCount } from "./lib/pending.mts"; +import { lingeringCount } from "./lib/pending.mts"; import { memoriesRepo, projectRoot } from "./lib/paths.mts"; const NAME = "memories_pull"; @@ -35,9 +35,10 @@ failOpen(NAME, () => { return; } - git(repo, ["pull", "--ff-only", "--quiet"]); + // Usage rows written after the last Stop would otherwise block every incoming change to that file. + git(repo, ["pull", "--ff-only", "--autostash", "--quiet"]); - const uncommitted = uncommittedCount(repo); + const uncommitted = lingeringCount(repo); const unpushed = aheadOfUpstream(repo); if (uncommitted === 0 && unpushed === 0) return; diff --git a/scripts/configure-memories.ts b/scripts/configure-memories.ts index dfa73b3..2fb3f6e 100755 --- a/scripts/configure-memories.ts +++ b/scripts/configure-memories.ts @@ -1,8 +1,21 @@ #!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning -import { existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, symlinkSync, unlinkSync } from "node:fs"; -import { join } from "node:path"; -import { authorName, git, gitLines, gitOut, gitPresent, isWorkTree } from "../runtime/lib/git.mts"; -import { ALLOWED_PATTERN, RESOLVE_COMMAND } from "../runtime/lib/naming.mts"; +import { + appendFileSync, + existsSync, + lstatSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + rmdirSync, + statSync, + symlinkSync, + unlinkSync, +} from "node:fs"; +import { dirname, join } from "node:path"; +import { authorName, git, gitLines, gitOut, gitPresent, hasUnionRule, isWorkTree } from "../runtime/lib/git.mts"; +import { ALLOWED_PATTERN, isAllowedPath, RESOLVE_COMMAND, USAGE_ATTRIBUTE, USAGE_FILE } from "../runtime/lib/naming.mts"; import { changeLine } from "../runtime/lib/commit-message.mts"; const out = (line: string) => process.stdout.write(`${line}\n`); @@ -77,6 +90,29 @@ function restoreBackup(): void { } } +const readText = (p: string): string => { + try { + return readFileSync(p, "utf8"); + } catch { + return ""; + } +}; + +const appendLine = (p: string, text: string): void => { + const current = readText(p); + appendFileSync(p, `${current === "" || current.endsWith("\n") ? "" : "\n"}${text}`); +}; + +// Per clone, in info/attributes, so the team's repo never carries a file the pack owns. +function ensureUnionAttribute(checkout: string): void { + if (hasUnionRule(checkout)) return; + const path = gitOut(checkout, ["rev-parse", "--path-format=absolute", "--git-path", "info/attributes"]); + if (path === "") return; + mkdirSync(dirname(path), { recursive: true }); + appendLine(path, `${USAGE_ATTRIBUTE}\n`); + out(`Added the merge=union rule for ${USAGE_FILE} to ${path}.`); +} + function stamp(): string { const d = new Date(); const p = (n: number) => String(n).padStart(2, "0"); @@ -99,6 +135,7 @@ function main(): void { // Case 1: healthy. if (isSymlink(link) && isWorkTree(link)) { + ensureUnionAttribute(link); out("Shared memories already linked — leaving as-is."); process.exit(0); } @@ -174,6 +211,7 @@ function main(): void { mkdirSync(join(repoDir, "memories"), { recursive: true }); linkMemories(); } + ensureUnionAttribute(repoDir); let imported = 0; let skipped = 0; @@ -196,7 +234,11 @@ function main(): void { } if (!entry.isFile()) continue; const target = join(link, name); - if (exists(target)) { + // Reads from before the switch stay meaningful: readers take the latest date per memory. + if (`memories/${name}` === USAGE_FILE) { + appendLine(target, readFileSync(from, "utf8")); + unlinkSync(from); + } else if (exists(target)) { skipped++; } else if (deletedHistory.has(`memories/${name}`)) { resurrected.push(name); @@ -237,10 +279,11 @@ function main(): void { if (imported > 0) { const untracked = gitLines(repoDir, ["ls-files", "--others", "--exclude-standard", "--full-name", "--", "memories/"]); const good = untracked.filter((f) => ALLOWED_PATTERN.test(f)); - const bad = untracked.filter((f) => !ALLOWED_PATTERN.test(f)); + const bad = untracked.filter((f) => !isAllowedPath(f)); if (good.length > 0) { - for (const f of good) { + // Unchanged, it stages nothing; merged, it travels with the memories it describes. + for (const f of exists(join(link, ".usage.tsv")) ? [...good, USAGE_FILE] : good) { const add = git(repoDir, ["add", "--", f], { inheritStderr: true }); if (!add.ok) process.exit(add.exit ?? 1); } diff --git a/scripts/doctor-memories.ts b/scripts/doctor-memories.ts index 97c2661..0ffd48b 100755 --- a/scripts/doctor-memories.ts +++ b/scripts/doctor-memories.ts @@ -1,7 +1,8 @@ #!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning import { existsSync, lstatSync } from "node:fs"; import { join } from "node:path"; -import { isWorkTree } from "../runtime/lib/git.mts"; +import { hasUnionRule, isWorkTree } from "../runtime/lib/git.mts"; +import { USAGE_FILE } from "../runtime/lib/naming.mts"; // mcs shellScript doctor contract: 0 pass, 1 fail, 2 warn, 3 skip. const project = process.env["MCS_PROJECT_PATH"] || process.cwd(); @@ -26,9 +27,15 @@ if (!isSymlink(link)) { problems.push("symlink exists but doesn't resolve to a git checkout (run 'mcs sync' to repair)"); } -if (problems.length > 0) { - process.stdout.write("Shared memories setup is unhealthy:\n"); - for (const p of problems) process.stdout.write(` - ${p}\n`); - process.exit(1); +// A warning only: without the rule the Stop hook still merges usage conflicts itself. +const warnings: string[] = []; +if (existsSync(join(repo, ".git")) && !hasUnionRule(repo)) { + warnings.push(`no merge=union rule for ${USAGE_FILE} in the checkout (run 'mcs sync' to add it)`); +} + +if (problems.length + warnings.length > 0) { + process.stdout.write(`Shared memories setup ${problems.length > 0 ? "is unhealthy" : "needs attention"}:\n`); + for (const p of [...problems, ...warnings]) process.stdout.write(` - ${p}\n`); + process.exit(problems.length > 0 ? 1 : 2); } process.exit(0); diff --git a/tests/configure.test.ts b/tests/configure.test.ts index 771c2db..90ef3b9 100644 --- a/tests/configure.test.ts +++ b/tests/configure.test.ts @@ -1,8 +1,9 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { chmodSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { git, readGolden, runConfigure, type ConfigureFixture, type RunResult } from "./harness.ts"; +import { USAGE_ATTRIBUTE } from "../runtime/lib/naming.mts"; +import { attributesFile, commit, git, readGolden, runConfigure, writeGolden, type ConfigureFixture, type RunResult } from "./harness.ts"; /** @@ -11,6 +12,8 @@ import { git, readGolden, runConfigure, type ConfigureFixture, type RunResult } * was removed. A drift here is a change in what the pack does, not a stale test. */ function check(fx: ConfigureFixture, actual: RunResult): void { + // A deviation's golden is the bash recording, which re-recording would overwrite. + if (process.env["UPDATE_GOLDENS"] === "1" && !fx.deviation) writeGolden(fx.name, actual); const golden = readGolden(fx.name); assert.ok(golden, `no golden recorded for "${fx.name}"`); @@ -299,3 +302,84 @@ describe("configure-memories — deliberate fixes to defects in the bash", () => assert.match(r.stdout, /Done\. 0 memory file\(s\) available at/); }); }); + +const attributes = (claude: string): string => readFileSync(attributesFile(join(claude, ".memories-repo")), "utf8"); +const linkedCheckout = (project: string, claude: string): void => { + git(claude, "clone", "-q", join(project, "..", "remote.git"), join(claude, ".memories-repo")); + symlinkSync(".memories-repo/memories", join(claude, "memories")); +}; +const seedUsage = (clone: string): void => { + writeFileSync(join(clone, "memories", ".usage.tsv"), "# since: 2026-10-01\nlearning_shared_seed.md\t2026-10-01\n"); + commit(clone, "seed usage"); + git(clone, "push", "-q"); +}; + +const usage: readonly ConfigureFixture[] = [ + { + name: "usage: an existing clone gets the union rule beside its other attributes", + expectCode: 0, + expect: /Added the merge=union rule for memories\/\.usage\.tsv/, + pre: (project, claude) => { + linkedCheckout(project, claude); + writeFileSync(attributesFile(join(claude, ".memories-repo")), "*.md diff"); + }, + inspect: (claude) => assert.equal(attributes(claude), "*.md diff\nmemories/.usage.tsv merge=union\n"), + }, + { + name: "usage: a clone that already has the union rule is left alone", + expectCode: 0, + pre: (project, claude) => { + linkedCheckout(project, claude); + writeFileSync(attributesFile(join(claude, ".memories-repo")), `${USAGE_ATTRIBUTE}\n`); + }, + inspect: (claude) => assert.equal(attributes(claude), "memories/.usage.tsv merge=union\n"), + }, + { + name: "usage: a fresh clone gets the union rule", + expectCode: 0, + inspect: (claude) => assert.equal(attributes(claude), "memories/.usage.tsv merge=union\n"), + }, + { + name: "usage: a local usage file is moved in when the shared repo has none", + expectCode: 0, + expect: /Pushed migrated memories/, + pre: (_project, claude) => { + local(claude, "learning_mine_own.md"); + local(claude, ".usage.tsv", "# since: 2026-09-01\nlearning_mine_own.md\t2026-09-02\n"); + }, + inspect: (claude) => { + const repo = join(claude, ".memories-repo"); + assert.equal(readFileSync(join(claude, "memories", ".usage.tsv"), "utf8"), "# since: 2026-09-01\nlearning_mine_own.md\t2026-09-02\n"); + assert.match(git(repo, "show", "--name-only", "--format=", "HEAD"), /memories\/\.usage\.tsv/, "travels with the migration commit"); + assert.equal(git(repo, "status", "--porcelain"), "", "nothing left for the next Stop"); + }, + }, + { + name: "usage: a local usage file is appended onto the shared one", + expectCode: 0, + expect: /Pushed migrated memories/, + seed: seedUsage, + pre: (_project, claude) => { + local(claude, "learning_mine_own.md"); + local(claude, ".usage.tsv", "# since: 2026-09-01\nlearning_mine_own.md\t2026-09-02"); + }, + inspect: (claude) => { + assert.equal( + readFileSync(join(claude, "memories", ".usage.tsv"), "utf8"), + "# since: 2026-10-01\nlearning_shared_seed.md\t2026-10-01\n# since: 2026-09-01\nlearning_mine_own.md\t2026-09-02", + ); + assert.match(git(join(claude, ".memories-repo"), "show", "--name-only", "--format=", "HEAD"), /memories\/\.usage\.tsv/); + }, + }, + { + name: "usage: a local usage file alone is merged and left for the next Stop", + expectCode: 0, + seed: seedUsage, + pre: (_project, claude) => local(claude, ".usage.tsv", "# since: 2026-09-01\nlearning_shared_seed.md\t2026-09-02\n"), + inspect: (claude) => assert.equal(git(join(claude, ".memories-repo"), "status", "--porcelain"), "M memories/.usage.tsv"), + }, +]; + +describe("the usage file — behaviour is pinned", () => { + for (const fx of usage) test(fx.name, () => assertParity(fx)); +}); diff --git a/tests/differential.test.ts b/tests/differential.test.ts index a582d6c..60bc0ff 100644 --- a/tests/differential.test.ts +++ b/tests/differential.test.ts @@ -1,8 +1,10 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; -import { git, memory, readGolden, rejectAllPushes, runHook, writeGolden, type Fixture, type RunResult } from "./harness.ts"; +import { commit, dropUnionRule, git, memory, readGolden, rejectAllPushes, runHook, writeGolden, type Fixture, type RunResult } from "./harness.ts"; /** * Behaviour is pinned to tests/golden/. The goldens were first recorded from the @@ -105,11 +107,6 @@ describe("pull — behaviour is pinned", () => { for (const fx of pull) test(fx.name, () => assertParity(fx)); }); -const commit = (repo: string, msg: string) => { - git(repo, "add", "-A"); - git(repo, "commit", "-qm", msg); -}; - const autopush: readonly Fixture[] = [ { name: "autopush: nothing pending", hook: "autopush" }, { @@ -198,13 +195,13 @@ describe("autopush — behaviour is pinned", () => { }); /** A second clone that pushes first, so our push is rejected as non-fast-forward. */ -const otherClonePushes = (repo: string, project: string, file: string, body: string) => { +const otherClonePushes = (repo: string, project: string, file: string, body: string, more: Readonly> = {}) => { const work = dirname(project); const other = join(work, "other"); git(work, "clone", "-q", join(work, "remote.git"), other); git(other, "config", "user.email", "o@example.com"); git(other, "config", "user.name", "Other"); - writeFileSync(join(other, "memories", file), body); + for (const [f, b] of Object.entries({ [file]: body, ...more })) writeFileSync(join(other, "memories", f), b); git(other, "add", "-A"); git(other, "commit", "-qm", "from the other clone"); git(other, "push", "-q"); @@ -490,6 +487,161 @@ describe("audited edges — behaviour is pinned", () => { for (const fx of edges) test(fx.name, () => assertParity(fx)); }); +const USAGE_SEED = "# since: 2026-10-01\nlearning_seed_topic.md\t2026-10-01\nlearning_seed_topic.md\t2026-10-02\n"; + +const usageFile = (repo: string): string => join(repo, "memories", ".usage.tsv"); + +/** Committed and pushed before anyone diverges, so both sides share it as the base. */ +const seedUsage = (repo: string): void => { + writeFileSync(usageFile(repo), USAGE_SEED); + commit(repo, "seed usage"); + git(repo, "push", "-q"); +}; + +/** A teammate pushes `theirs` as the usage file, then we leave `ours` dirty for the Stop hook. */ +const divergingUsage = + (theirs: string, ours: string, unionRule: boolean) => + (repo: string, project: string): void => { + seedUsage(repo); + otherClonePushes(repo, project, ".usage.tsv", theirs); + writeFileSync(usageFile(repo), ours); + if (!unionRule) dropUnionRule(repo); + }; + +/** Union semantics: every row either side wrote survives, and no marker leaks into the file. */ +const keepsEveryLine = + (...sides: string[]) => + (repo: string): void => { + const merged = readFileSync(usageFile(repo), "utf8").split("\n"); + for (const line of sides.flatMap((t) => t.split("\n")).filter(Boolean)) { + assert.ok(merged.includes(line), `merged usage file lost ${JSON.stringify(line)}`); + } + assert.ok(!merged.some((l) => /^(<{7}|\|{7}|={7}|>{7})/.test(l)), "conflict markers leaked into the usage file"); + assert.equal(git(repo, "rev-list", "@{u}..HEAD", "--count"), "0", "the merge was pushed"); + }; + +const APPENDED_THEIRS = `${USAGE_SEED}learning_theirs_one.md\t2026-10-03\n`; +const APPENDED_OURS = `${USAGE_SEED}learning_ours_one.md\t2026-10-04\n`; +const COMPACTED_THEIRS = "# since: 2026-10-01\nlearning_seed_topic.md\t2026-10-02\nlearning_theirs_one.md\t2026-10-03\n"; +const EDITED_THEIRS = "# since: 2026-10-01\nlearning_seed_topic.md\t2026-10-05\nlearning_seed_topic.md\t2026-10-02\n"; +const EDITED_OURS = "# since: 2026-10-01\nlearning_seed_topic.md\t2026-10-06\nlearning_seed_topic.md\t2026-10-02\n"; + +/** Each scenario runs twice: merged by the union rule, and by the Stop hook's fallback without it. */ +const USAGE_SCENARIOS = [ + { clean: "appends on both sides merge", fallback: "appends on both sides are merged", theirs: APPENDED_THEIRS, ours: APPENDED_OURS }, + { clean: "a compaction against an append merges", fallback: "a compaction against an append is merged", theirs: COMPACTED_THEIRS, ours: APPENDED_OURS }, + { clean: "in-place edits on both sides merge", fallback: "in-place edits on both sides are merged", theirs: EDITED_THEIRS, ours: EDITED_OURS }, +] as const; + +// The seed rows a compaction rewrote are the one thing a union need not keep. +const newRows = (ours: string): string => (ours.startsWith(USAGE_SEED) ? ours.slice(USAGE_SEED.length) : ours); + +const usage: readonly Fixture[] = [ + ...USAGE_SCENARIOS.flatMap(({ clean, fallback, theirs, ours }): Fixture[] => [ + { + name: `usage: ${clean} cleanly under the union rule`, + expect: /^$/, + hook: "autopush", + setup: divergingUsage(theirs, ours, true), + inspect: keepsEveryLine(theirs, newRows(ours)), + }, + { + name: `usage: ${fallback} by the fallback without the union rule`, + expect: /^$/, + hook: "autopush", + setup: divergingUsage(theirs, ours, false), + inspect: keepsEveryLine(theirs, newRows(ours)), + }, + ]), + { + name: "usage: a dirty usage file alone is committed as usage, not as a memory edit", + expect: /^$/, + hook: "autopush", + setup: (repo) => writeFileSync(usageFile(repo), "# since: 2026-10-01\nlearning_seed_topic.md\t2026-10-01\n"), + }, + { + name: "usage: a usage file changed with a memory leaves the subject to the memory", + expect: /^$/, + hook: "autopush", + setup: (repo) => { + writeFileSync(usageFile(repo), "# since: 2026-10-01\nlearning_seed_topic.md\t2026-10-01\n"); + memory(repo, "learning_new_thing.md", "A brand new lesson.\n"); + }, + }, + { + name: "usage: a dirty usage file beside a badly named one blocks on the bad name only", + expect: /unconventional filename\(s\):\n {2}- memories\/scratch\.md\nRename/, + hook: "autopush", + setup: (repo) => { + writeFileSync(usageFile(repo), "# since: 2026-10-01\n"); + memory(repo, "scratch.md"); + }, + }, + { + // Base rows survive too; they are older dates, which readers already outrank. + name: "usage: a diff3-style conflict loses its base marker and keeps its base rows", + expect: /^$/, + hook: "autopush", + setup: (repo, project) => { + divergingUsage(EDITED_THEIRS, EDITED_OURS, false)(repo, project); + git(repo, "config", "merge.conflictStyle", "diff3"); + }, + inspect: keepsEveryLine(USAGE_SEED, EDITED_THEIRS, EDITED_OURS), + }, + { + name: "usage: a dirty usage file alone is not lingering state at SessionStart", + expect: /^$/, + hook: "pull", + setup: (repo) => writeFileSync(usageFile(repo), `${USAGE_SEED}learning_seed_topic.md\t2026-10-03\n`), + }, + { + name: "usage: SessionStart fast-forwards a teammate's usage rows over our dirty ones", + expect: /^$/, + hook: "pull", + setup: divergingUsage(APPENDED_THEIRS, APPENDED_OURS, true), + inspect: (repo) => { + assert.equal(git(repo, "rev-parse", "HEAD"), git(repo, "rev-parse", "origin/main"), "the teammate's commit was pulled"); + const merged = readFileSync(usageFile(repo), "utf8").split("\n"); + for (const row of ["learning_theirs_one.md\t2026-10-03", "learning_ours_one.md\t2026-10-04"]) { + assert.ok(merged.includes(row), `lost ${JSON.stringify(row)}`); + } + assert.equal(git(repo, "stash", "list"), "", "nothing stranded in the stash"); + }, + }, + { + name: "usage: a memory conflicting beside the usage file still pauses", + expect: /^Shared memories: auto-push paused — rebase conflict\. Run \/resolve-memories to merge it\.$/m, + hook: "autopush", + setup: (repo, project) => { + seedUsage(repo); + otherClonePushes(repo, project, ".usage.tsv", EDITED_THEIRS, { "learning_seed_topic.md": "Their version of the seed.\n" }); + writeFileSync(usageFile(repo), EDITED_OURS); + memory(repo, "learning_seed_topic.md", "Our version of the seed.\n"); + dropUnionRule(repo); + }, + }, +]; + +describe("the usage file — behaviour is pinned", () => { + for (const fx of usage) test(fx.name, () => assertParity(fx)); + + // Without this the fallback fixtures would pass just as well if git never conflicted. + test("every scenario the fallback covers really conflicts without the union rule", () => { + const dir = mkdtempSync(join(tmpdir(), "sm-usage-")); + try { + for (const { theirs, ours } of USAGE_SCENARIOS) { + writeFileSync(join(dir, "base"), USAGE_SEED); + writeFileSync(join(dir, "theirs"), theirs); + writeFileSync(join(dir, "ours"), ours); + const r = spawnSync("git", ["merge-file", "-p", "ours", "base", "theirs"], { cwd: dir, encoding: "utf8" }); + assert.ok((r.status ?? 0) > 0, `no conflict between ${JSON.stringify(ours)} and ${JSON.stringify(theirs)}`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + /** * Asserted directly rather than pinned to a golden, and deliberately so. * diff --git a/tests/golden/audited-a-bootstrap-branch-with-no-memories-tree-exits-2-without-done.json b/tests/golden/audited-a-bootstrap-branch-with-no-memories-tree-exits-2-without-done.json index 9ee30e3..13fea35 100644 --- a/tests/golden/audited-a-bootstrap-branch-with-no-memories-tree-exits-2-without-done.json +++ b/tests/golden/audited-a-bootstrap-branch-with-no-memories-tree-exits-2-without-done.json @@ -1,5 +1,5 @@ { - "stdout": "Cloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\n", + "stdout": "Cloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\n", "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", "code": 2, "git": ".memories-repo memories" diff --git a/tests/golden/audited-a-dangling-symlink-with-no-checkout-is-replaced.json b/tests/golden/audited-a-dangling-symlink-with-no-checkout-is-replaced.json index 2410219..68d8fa5 100644 --- a/tests/golden/audited-a-dangling-symlink-with-no-checkout-is-replaced.json +++ b/tests/golden/audited-a-dangling-symlink-with-no-checkout-is-replaced.json @@ -1,5 +1,5 @@ { - "stdout": "Cloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", + "stdout": "Cloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", "code": 0, "git": ".memories-repo memories\nlearning_shared_seed.md" diff --git a/tests/golden/audited-a-dotted-md-file-migrates-but-is-not-counted.json b/tests/golden/audited-a-dotted-md-file-migrates-but-is-not-counted.json index 0e4f697..9a6760d 100644 --- a/tests/golden/audited-a-dotted-md-file-migrates-but-is-not-counted.json +++ b/tests/golden/audited-a-dotted-md-file-migrates-but-is-not-counted.json @@ -1,5 +1,5 @@ { - "stdout": "Found pre-existing /work/project/.claude/memories with 3 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nMigration: imported 3 local memory file(s); 0 conflict(s) (shared version kept).\nPushed migrated memories to the shared branch.\nNote: these migrated files don't match the naming convention and were left untracked:\n - memories/.hidden\n - memories/.hidden.md\nRename them to memories/learning__.md or memories/decision__.md so they can be shared with the team.\nDone. 2 memory file(s) available at /work/project/.claude/memories.\n", + "stdout": "Found pre-existing /work/project/.claude/memories with 3 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nMigration: imported 3 local memory file(s); 0 conflict(s) (shared version kept).\nPushed migrated memories to the shared branch.\nNote: these migrated files don't match the naming convention and were left untracked:\n - memories/.hidden\n - memories/.hidden.md\nRename them to memories/learning__.md or memories/decision__.md so they can be shared with the team.\nDone. 2 memory file(s) available at /work/project/.claude/memories.\n", "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", "code": 0, "git": ".memories-repo memories\n.hidden .hidden.md learning_mine_own.md learning_shared_seed.md" diff --git a/tests/golden/audited-a-subdirectory-in-the-migration-backup-is-left-behind.json b/tests/golden/audited-a-subdirectory-in-the-migration-backup-is-left-behind.json index 82d855e..f6f5548 100644 --- a/tests/golden/audited-a-subdirectory-in-the-migration-backup-is-left-behind.json +++ b/tests/golden/audited-a-subdirectory-in-the-migration-backup-is-left-behind.json @@ -1,5 +1,5 @@ { - "stdout": "Found pre-existing /work/project/.claude/memories with 2 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nMigration: imported 1 local memory file(s); 0 conflict(s) (shared version kept); 0 previously-deleted file(s) held back.\n Kept at /work/project/.claude/.memories-migration- for review.\nPushed migrated memories to the shared branch.\nDone. 2 memory file(s) available at /work/project/.claude/memories.\n", + "stdout": "Found pre-existing /work/project/.claude/memories with 2 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nMigration: imported 1 local memory file(s); 0 conflict(s) (shared version kept); 0 previously-deleted file(s) held back.\n Kept at /work/project/.claude/.memories-migration- for review.\nPushed migrated memories to the shared branch.\nDone. 2 memory file(s) available at /work/project/.claude/memories.\n", "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", "code": 0, "git": ".memories-migration- .memories-repo memories\nlearning_mine_own.md learning_shared_seed.md" diff --git a/tests/golden/audited-a-symlink-to-a-non-git-directory-beside-a-live-checkout-is-relinked.json b/tests/golden/audited-a-symlink-to-a-non-git-directory-beside-a-live-checkout-is-relinked.json index d6a99a6..fa293aa 100644 --- a/tests/golden/audited-a-symlink-to-a-non-git-directory-beside-a-live-checkout-is-relinked.json +++ b/tests/golden/audited-a-symlink-to-a-non-git-directory-beside-a-live-checkout-is-relinked.json @@ -1,5 +1,5 @@ { - "stdout": "Memories checkout present, (re)linking /work/project/.claude/memories → .memories-repo/memories...\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", + "stdout": "Memories checkout present, (re)linking /work/project/.claude/memories → .memories-repo/memories...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", "stderr": "", "code": 0, "git": ".memories-repo memories\nlearning_shared_seed.md" diff --git a/tests/golden/audited-a-trailing-slash-on-the-project-path-is-not-normalised-away.json b/tests/golden/audited-a-trailing-slash-on-the-project-path-is-not-normalised-away.json index c826bb0..dbd831f 100644 --- a/tests/golden/audited-a-trailing-slash-on-the-project-path-is-not-normalised-away.json +++ b/tests/golden/audited-a-trailing-slash-on-the-project-path-is-not-normalised-away.json @@ -1,5 +1,5 @@ { - "stdout": "Cloning shared memories (branch 'main', sparse) into /work/project//.claude/.memories-repo...\nDone. 1 memory file(s) available at /work/project//.claude/memories.\n", + "stdout": "Cloning shared memories (branch 'main', sparse) into /work/project//.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nDone. 1 memory file(s) available at /work/project//.claude/memories.\n", "stderr": "Cloning into '/work/project//.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", "code": 0, "git": ".memories-repo memories\nlearning_shared_seed.md" diff --git a/tests/golden/audited-every-migrated-file-conflicting-imports-nothing.json b/tests/golden/audited-every-migrated-file-conflicting-imports-nothing.json index 83626e6..60e1c34 100644 --- a/tests/golden/audited-every-migrated-file-conflicting-imports-nothing.json +++ b/tests/golden/audited-every-migrated-file-conflicting-imports-nothing.json @@ -1,5 +1,5 @@ { - "stdout": "Found pre-existing /work/project/.claude/memories with 1 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nMigration: imported 0 local memory file(s); 1 conflict(s) (shared version kept); 0 previously-deleted file(s) held back.\n Kept at /work/project/.claude/.memories-migration- for review.\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", + "stdout": "Found pre-existing /work/project/.claude/memories with 1 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nMigration: imported 0 local memory file(s); 1 conflict(s) (shared version kept); 0 previously-deleted file(s) held back.\n Kept at /work/project/.claude/.memories-migration- for review.\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", "code": 0, "git": ".memories-migration- .memories-repo memories\nlearning_shared_seed.md" diff --git a/tests/golden/audited-twenty-one-previously-deleted-files-straddle-the-reason-cap.json b/tests/golden/audited-twenty-one-previously-deleted-files-straddle-the-reason-cap.json index d55a986..2c2a0f6 100644 --- a/tests/golden/audited-twenty-one-previously-deleted-files-straddle-the-reason-cap.json +++ b/tests/golden/audited-twenty-one-previously-deleted-files-straddle-the-reason-cap.json @@ -1,5 +1,5 @@ { - "stdout": "Found pre-existing /work/project/.claude/memories with 21 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nMigration: imported 0 local memory file(s); 0 conflict(s) (shared version kept); 21 previously-deleted file(s) held back.\n Kept at /work/project/.claude/.memories-migration- for review.\n\nHeld back 21 file(s) previously deleted from the shared repo:\n - learning_gone_00.md\n deleted by remove many\n - learning_gone_01.md\n deleted by remove many\n - learning_gone_02.md\n deleted by remove many\n - learning_gone_03.md\n deleted by remove many\n - learning_gone_04.md\n deleted by remove many\n - learning_gone_05.md\n deleted by remove many\n - learning_gone_06.md\n deleted by remove many\n - learning_gone_07.md\n deleted by remove many\n - learning_gone_08.md\n deleted by remove many\n - learning_gone_09.md\n deleted by remove many\n - learning_gone_10.md\n deleted by remove many\n - learning_gone_11.md\n deleted by remove many\n - learning_gone_12.md\n deleted by remove many\n - learning_gone_13.md\n deleted by remove many\n - learning_gone_14.md\n deleted by remove many\n - learning_gone_15.md\n deleted by remove many\n - learning_gone_16.md\n deleted by remove many\n - learning_gone_17.md\n deleted by remove many\n - learning_gone_18.md\n deleted by remove many\n - learning_gone_19.md\n deleted by remove many\n - learning_gone_20.md\n\nSomeone removed these deliberately, so they were not re-imported or pushed.\nTo re-add one anyway: cp '/work/project/.claude/.memories-migration-/' '/work/project/.claude/memories/'\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", + "stdout": "Found pre-existing /work/project/.claude/memories with 21 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nMigration: imported 0 local memory file(s); 0 conflict(s) (shared version kept); 21 previously-deleted file(s) held back.\n Kept at /work/project/.claude/.memories-migration- for review.\n\nHeld back 21 file(s) previously deleted from the shared repo:\n - learning_gone_00.md\n deleted by remove many\n - learning_gone_01.md\n deleted by remove many\n - learning_gone_02.md\n deleted by remove many\n - learning_gone_03.md\n deleted by remove many\n - learning_gone_04.md\n deleted by remove many\n - learning_gone_05.md\n deleted by remove many\n - learning_gone_06.md\n deleted by remove many\n - learning_gone_07.md\n deleted by remove many\n - learning_gone_08.md\n deleted by remove many\n - learning_gone_09.md\n deleted by remove many\n - learning_gone_10.md\n deleted by remove many\n - learning_gone_11.md\n deleted by remove many\n - learning_gone_12.md\n deleted by remove many\n - learning_gone_13.md\n deleted by remove many\n - learning_gone_14.md\n deleted by remove many\n - learning_gone_15.md\n deleted by remove many\n - learning_gone_16.md\n deleted by remove many\n - learning_gone_17.md\n deleted by remove many\n - learning_gone_18.md\n deleted by remove many\n - learning_gone_19.md\n deleted by remove many\n - learning_gone_20.md\n\nSomeone removed these deliberately, so they were not re-imported or pushed.\nTo re-add one anyway: cp '/work/project/.claude/.memories-migration-/' '/work/project/.claude/memories/'\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", "code": 0, "git": ".memories-migration- .memories-repo memories\nlearning_shared_seed.md" diff --git a/tests/golden/case-1-an-already-linked-setup-is-left-alone.json b/tests/golden/case-1-an-already-linked-setup-is-left-alone.json index 7f7892d..8f79e81 100644 --- a/tests/golden/case-1-an-already-linked-setup-is-left-alone.json +++ b/tests/golden/case-1-an-already-linked-setup-is-left-alone.json @@ -1,5 +1,5 @@ { - "stdout": "Shared memories already linked — leaving as-is.\n", + "stdout": "Added the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nShared memories already linked — leaving as-is.\n", "stderr": "", "code": 0, "git": ".memories-repo memories\nlearning_shared_seed.md" diff --git a/tests/golden/case-2-a-badly-named-migrated-file-is-left-untracked.json b/tests/golden/case-2-a-badly-named-migrated-file-is-left-untracked.json index d0f89b6..5d2e348 100644 --- a/tests/golden/case-2-a-badly-named-migrated-file-is-left-untracked.json +++ b/tests/golden/case-2-a-badly-named-migrated-file-is-left-untracked.json @@ -1,5 +1,5 @@ { - "stdout": "Found pre-existing /work/project/.claude/memories with 2 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nMigration: imported 2 local memory file(s); 0 conflict(s) (shared version kept).\nPushed migrated memories to the shared branch.\nNote: these migrated files don't match the naming convention and were left untracked:\n - memories/scratch.md\nRename them to memories/learning__.md or memories/decision__.md so they can be shared with the team.\nDone. 3 memory file(s) available at /work/project/.claude/memories.\n", + "stdout": "Found pre-existing /work/project/.claude/memories with 2 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nMigration: imported 2 local memory file(s); 0 conflict(s) (shared version kept).\nPushed migrated memories to the shared branch.\nNote: these migrated files don't match the naming convention and were left untracked:\n - memories/scratch.md\nRename them to memories/learning__.md or memories/decision__.md so they can be shared with the team.\nDone. 3 memory file(s) available at /work/project/.claude/memories.\n", "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", "code": 0, "git": ".memories-repo memories\nlearning_mine_own.md learning_shared_seed.md scratch.md" diff --git a/tests/golden/case-2-a-migrated-non-markdown-file-is-not-counted-as-a-memory.json b/tests/golden/case-2-a-migrated-non-markdown-file-is-not-counted-as-a-memory.json index 3a67069..473f29e 100644 --- a/tests/golden/case-2-a-migrated-non-markdown-file-is-not-counted-as-a-memory.json +++ b/tests/golden/case-2-a-migrated-non-markdown-file-is-not-counted-as-a-memory.json @@ -1,5 +1,5 @@ { - "stdout": "Found pre-existing /work/project/.claude/memories with 2 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nMigration: imported 2 local memory file(s); 0 conflict(s) (shared version kept).\nPushed migrated memories to the shared branch.\nNote: these migrated files don't match the naming convention and were left untracked:\n - memories/notes.txt\nRename them to memories/learning__.md or memories/decision__.md so they can be shared with the team.\nDone. 2 memory file(s) available at /work/project/.claude/memories.\n", + "stdout": "Found pre-existing /work/project/.claude/memories with 2 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nMigration: imported 2 local memory file(s); 0 conflict(s) (shared version kept).\nPushed migrated memories to the shared branch.\nNote: these migrated files don't match the naming convention and were left untracked:\n - memories/notes.txt\nRename them to memories/learning__.md or memories/decision__.md so they can be shared with the team.\nDone. 2 memory file(s) available at /work/project/.claude/memories.\n", "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", "code": 0, "git": ".memories-repo memories\nlearning_mine_own.md learning_shared_seed.md notes.txt" diff --git a/tests/golden/case-2-a-populated-memories-directory-migrates-and-pushes.json b/tests/golden/case-2-a-populated-memories-directory-migrates-and-pushes.json index b14a6a0..3ec09e7 100644 --- a/tests/golden/case-2-a-populated-memories-directory-migrates-and-pushes.json +++ b/tests/golden/case-2-a-populated-memories-directory-migrates-and-pushes.json @@ -1,5 +1,5 @@ { - "stdout": "Found pre-existing /work/project/.claude/memories with 1 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nMigration: imported 1 local memory file(s); 0 conflict(s) (shared version kept).\nPushed migrated memories to the shared branch.\nDone. 2 memory file(s) available at /work/project/.claude/memories.\n", + "stdout": "Found pre-existing /work/project/.claude/memories with 1 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nMigration: imported 1 local memory file(s); 0 conflict(s) (shared version kept).\nPushed migrated memories to the shared branch.\nDone. 2 memory file(s) available at /work/project/.claude/memories.\n", "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", "code": 0, "git": ".memories-repo memories\nlearning_mine_own.md learning_shared_seed.md" diff --git a/tests/golden/case-2-a-previously-deleted-memory-is-held-back.json b/tests/golden/case-2-a-previously-deleted-memory-is-held-back.json index 68d7f45..d5d1dad 100644 --- a/tests/golden/case-2-a-previously-deleted-memory-is-held-back.json +++ b/tests/golden/case-2-a-previously-deleted-memory-is-held-back.json @@ -1,5 +1,5 @@ { - "stdout": "Found pre-existing /work/project/.claude/memories with 1 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nMigration: imported 0 local memory file(s); 0 conflict(s) (shared version kept); 1 previously-deleted file(s) held back.\n Kept at /work/project/.claude/.memories-migration- for review.\n\nHeld back 1 file(s) previously deleted from the shared repo:\n - learning_curated_away.md\n deleted by remove it\n\nSomeone removed these deliberately, so they were not re-imported or pushed.\nTo re-add one anyway: cp '/work/project/.claude/.memories-migration-/' '/work/project/.claude/memories/'\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", + "stdout": "Found pre-existing /work/project/.claude/memories with 1 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nMigration: imported 0 local memory file(s); 0 conflict(s) (shared version kept); 1 previously-deleted file(s) held back.\n Kept at /work/project/.claude/.memories-migration- for review.\n\nHeld back 1 file(s) previously deleted from the shared repo:\n - learning_curated_away.md\n deleted by remove it\n\nSomeone removed these deliberately, so they were not re-imported or pushed.\nTo re-add one anyway: cp '/work/project/.claude/.memories-migration-/' '/work/project/.claude/memories/'\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", "code": 0, "git": ".memories-migration- .memories-repo memories\nlearning_shared_seed.md" diff --git a/tests/golden/case-2-an-empty-memories-directory-is-removed.json b/tests/golden/case-2-an-empty-memories-directory-is-removed.json index 36e3d01..24a7e66 100644 --- a/tests/golden/case-2-an-empty-memories-directory-is-removed.json +++ b/tests/golden/case-2-an-empty-memories-directory-is-removed.json @@ -1,5 +1,5 @@ { - "stdout": "Found empty /work/project/.claude/memories — removing and proceeding with fresh setup.\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", + "stdout": "Found empty /work/project/.claude/memories — removing and proceeding with fresh setup.\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", "code": 0, "git": ".memories-repo memories\nlearning_shared_seed.md" diff --git a/tests/golden/case-2-the-shared-copy-wins-a-filename-conflict.json b/tests/golden/case-2-the-shared-copy-wins-a-filename-conflict.json index 83626e6..60e1c34 100644 --- a/tests/golden/case-2-the-shared-copy-wins-a-filename-conflict.json +++ b/tests/golden/case-2-the-shared-copy-wins-a-filename-conflict.json @@ -1,5 +1,5 @@ { - "stdout": "Found pre-existing /work/project/.claude/memories with 1 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nMigration: imported 0 local memory file(s); 1 conflict(s) (shared version kept); 0 previously-deleted file(s) held back.\n Kept at /work/project/.claude/.memories-migration- for review.\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", + "stdout": "Found pre-existing /work/project/.claude/memories with 1 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nMigration: imported 0 local memory file(s); 1 conflict(s) (shared version kept); 0 previously-deleted file(s) held back.\n Kept at /work/project/.claude/.memories-migration- for review.\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", "code": 0, "git": ".memories-migration- .memories-repo memories\nlearning_shared_seed.md" diff --git a/tests/golden/case-3-a-live-checkout-with-no-symlink-is-relinked.json b/tests/golden/case-3-a-live-checkout-with-no-symlink-is-relinked.json index d6a99a6..fa293aa 100644 --- a/tests/golden/case-3-a-live-checkout-with-no-symlink-is-relinked.json +++ b/tests/golden/case-3-a-live-checkout-with-no-symlink-is-relinked.json @@ -1,5 +1,5 @@ { - "stdout": "Memories checkout present, (re)linking /work/project/.claude/memories → .memories-repo/memories...\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", + "stdout": "Memories checkout present, (re)linking /work/project/.claude/memories → .memories-repo/memories...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", "stderr": "", "code": 0, "git": ".memories-repo memories\nlearning_shared_seed.md" diff --git a/tests/golden/case-5-fresh-setup-clones-and-links.json b/tests/golden/case-5-fresh-setup-clones-and-links.json index 2410219..68d8fa5 100644 --- a/tests/golden/case-5-fresh-setup-clones-and-links.json +++ b/tests/golden/case-5-fresh-setup-clones-and-links.json @@ -1,5 +1,5 @@ { - "stdout": "Cloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", + "stdout": "Cloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", "code": 0, "git": ".memories-repo memories\nlearning_shared_seed.md" diff --git a/tests/golden/doctor-a-missing-union-rule-for-the-usage-file-warns.json b/tests/golden/doctor-a-missing-union-rule-for-the-usage-file-warns.json new file mode 100644 index 0000000..025e9b0 --- /dev/null +++ b/tests/golden/doctor-a-missing-union-rule-for-the-usage-file-warns.json @@ -0,0 +1,6 @@ +{ + "stdout": "Shared memories setup needs attention:\n - no merge=union rule for memories/.usage.tsv in the checkout (run 'mcs sync' to add it)\n", + "stderr": "", + "code": 2, + "git": "status-all: \nlog-all: seed\ntracked-all: memories/learning_seed_topic.md\nstatus: \nlog: seed\nhead-body: \nupstream: origin/main\nunpushed: 0\ntracked: memories/learning_seed_topic.md\nworktree: learning_seed_topic.md\nremote-log: seed\nremote-files: memories/learning_seed_topic.md" +} diff --git a/tests/golden/doctor-a-missing-union-rule-is-listed-with-the-failures.json b/tests/golden/doctor-a-missing-union-rule-is-listed-with-the-failures.json new file mode 100644 index 0000000..a75fbb5 --- /dev/null +++ b/tests/golden/doctor-a-missing-union-rule-is-listed-with-the-failures.json @@ -0,0 +1,6 @@ +{ + "stdout": "Shared memories setup is unhealthy:\n - symlink missing at /work/project/.claude/memories (run 'mcs sync' to relink)\n - no merge=union rule for memories/.usage.tsv in the checkout (run 'mcs sync' to add it)\n", + "stderr": "", + "code": 1, + "git": "status-all: \nlog-all: seed\ntracked-all: memories/learning_seed_topic.md\nstatus: \nlog: seed\nhead-body: \nupstream: origin/main\nunpushed: 0\ntracked: memories/learning_seed_topic.md\nworktree: learning_seed_topic.md\nremote-log: seed\nremote-files: memories/learning_seed_topic.md" +} diff --git a/tests/golden/unchecked-a-dangling-symlink-is-skipped-not-fatal.json b/tests/golden/unchecked-a-dangling-symlink-is-skipped-not-fatal.json index 82d855e..f6f5548 100644 --- a/tests/golden/unchecked-a-dangling-symlink-is-skipped-not-fatal.json +++ b/tests/golden/unchecked-a-dangling-symlink-is-skipped-not-fatal.json @@ -1,5 +1,5 @@ { - "stdout": "Found pre-existing /work/project/.claude/memories with 2 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nMigration: imported 1 local memory file(s); 0 conflict(s) (shared version kept); 0 previously-deleted file(s) held back.\n Kept at /work/project/.claude/.memories-migration- for review.\nPushed migrated memories to the shared branch.\nDone. 2 memory file(s) available at /work/project/.claude/memories.\n", + "stdout": "Found pre-existing /work/project/.claude/memories with 2 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nMigration: imported 1 local memory file(s); 0 conflict(s) (shared version kept); 0 previously-deleted file(s) held back.\n Kept at /work/project/.claude/.memories-migration- for review.\nPushed migrated memories to the shared branch.\nDone. 2 memory file(s) available at /work/project/.claude/memories.\n", "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", "code": 0, "git": ".memories-migration- .memories-repo memories\nlearning_mine_own.md learning_shared_seed.md" diff --git a/tests/golden/unchecked-a-failed-migration-commit-is-not-reported-as-a-push.json b/tests/golden/unchecked-a-failed-migration-commit-is-not-reported-as-a-push.json index 74a14b9..e364ad7 100644 --- a/tests/golden/unchecked-a-failed-migration-commit-is-not-reported-as-a-push.json +++ b/tests/golden/unchecked-a-failed-migration-commit-is-not-reported-as-a-push.json @@ -1,5 +1,5 @@ { - "stdout": "Found pre-existing /work/project/.claude/memories with 1 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nMigration: imported 1 local memory file(s); 0 conflict(s) (shared version kept).\n", + "stdout": "Found pre-existing /work/project/.claude/memories with 1 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nMigration: imported 1 local memory file(s); 0 conflict(s) (shared version kept).\n", "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\nfatal: empty ident name (for <>) not allowed\n", "code": 128, "git": ".memories-repo memories\nlearning_mine_own.md learning_shared_seed.md" diff --git a/tests/golden/usage-a-clone-that-already-has-the-union-rule-is-left-alone.json b/tests/golden/usage-a-clone-that-already-has-the-union-rule-is-left-alone.json new file mode 100644 index 0000000..7f7892d --- /dev/null +++ b/tests/golden/usage-a-clone-that-already-has-the-union-rule-is-left-alone.json @@ -0,0 +1,6 @@ +{ + "stdout": "Shared memories already linked — leaving as-is.\n", + "stderr": "", + "code": 0, + "git": ".memories-repo memories\nlearning_shared_seed.md" +} diff --git a/tests/golden/usage-a-compaction-against-an-append-is-merged-by-the-fallback-without-the-union-rule.json b/tests/golden/usage-a-compaction-against-an-append-is-merged-by-the-fallback-without-the-union-rule.json new file mode 100644 index 0000000..89ab5c1 --- /dev/null +++ b/tests/golden/usage-a-compaction-against-an-append-is-merged-by-the-fallback-without-the-union-rule.json @@ -0,0 +1,6 @@ +{ + "stdout": "", + "stderr": "", + "code": 0, + "git": "status-all: \nlog-all: Fixture: update memory usage | from the other clone | seed usage | seed\ntracked-all: memories/.usage.tsv | memories/learning_seed_topic.md\nstatus: \nlog: Fixture: update memory usage | from the other clone | seed usage | seed\nhead-body: \nupstream: origin/main\nunpushed: 0\ntracked: memories/.usage.tsv | memories/learning_seed_topic.md\nworktree: .usage.tsv learning_seed_topic.md\nremote-log: Fixture: update memory usage | from the other clone | seed usage | seed\nremote-files: memories/.usage.tsv | memories/learning_seed_topic.md" +} diff --git a/tests/golden/usage-a-compaction-against-an-append-merges-cleanly-under-the-union-rule.json b/tests/golden/usage-a-compaction-against-an-append-merges-cleanly-under-the-union-rule.json new file mode 100644 index 0000000..89ab5c1 --- /dev/null +++ b/tests/golden/usage-a-compaction-against-an-append-merges-cleanly-under-the-union-rule.json @@ -0,0 +1,6 @@ +{ + "stdout": "", + "stderr": "", + "code": 0, + "git": "status-all: \nlog-all: Fixture: update memory usage | from the other clone | seed usage | seed\ntracked-all: memories/.usage.tsv | memories/learning_seed_topic.md\nstatus: \nlog: Fixture: update memory usage | from the other clone | seed usage | seed\nhead-body: \nupstream: origin/main\nunpushed: 0\ntracked: memories/.usage.tsv | memories/learning_seed_topic.md\nworktree: .usage.tsv learning_seed_topic.md\nremote-log: Fixture: update memory usage | from the other clone | seed usage | seed\nremote-files: memories/.usage.tsv | memories/learning_seed_topic.md" +} diff --git a/tests/golden/usage-a-diff3-style-conflict-loses-its-base-marker-and-keeps-its-base-rows.json b/tests/golden/usage-a-diff3-style-conflict-loses-its-base-marker-and-keeps-its-base-rows.json new file mode 100644 index 0000000..89ab5c1 --- /dev/null +++ b/tests/golden/usage-a-diff3-style-conflict-loses-its-base-marker-and-keeps-its-base-rows.json @@ -0,0 +1,6 @@ +{ + "stdout": "", + "stderr": "", + "code": 0, + "git": "status-all: \nlog-all: Fixture: update memory usage | from the other clone | seed usage | seed\ntracked-all: memories/.usage.tsv | memories/learning_seed_topic.md\nstatus: \nlog: Fixture: update memory usage | from the other clone | seed usage | seed\nhead-body: \nupstream: origin/main\nunpushed: 0\ntracked: memories/.usage.tsv | memories/learning_seed_topic.md\nworktree: .usage.tsv learning_seed_topic.md\nremote-log: Fixture: update memory usage | from the other clone | seed usage | seed\nremote-files: memories/.usage.tsv | memories/learning_seed_topic.md" +} diff --git a/tests/golden/usage-a-dirty-usage-file-alone-is-committed-as-usage-not-as-a-memory-edit.json b/tests/golden/usage-a-dirty-usage-file-alone-is-committed-as-usage-not-as-a-memory-edit.json new file mode 100644 index 0000000..408db8e --- /dev/null +++ b/tests/golden/usage-a-dirty-usage-file-alone-is-committed-as-usage-not-as-a-memory-edit.json @@ -0,0 +1,6 @@ +{ + "stdout": "", + "stderr": "", + "code": 0, + "git": "status-all: \nlog-all: Fixture: update memory usage | seed\ntracked-all: memories/.usage.tsv | memories/learning_seed_topic.md\nstatus: \nlog: Fixture: update memory usage | seed\nhead-body: \nupstream: origin/main\nunpushed: 0\ntracked: memories/.usage.tsv | memories/learning_seed_topic.md\nworktree: .usage.tsv learning_seed_topic.md\nremote-log: Fixture: update memory usage | seed\nremote-files: memories/.usage.tsv | memories/learning_seed_topic.md" +} diff --git a/tests/golden/usage-a-dirty-usage-file-alone-is-not-lingering-state-at-sessionstart.json b/tests/golden/usage-a-dirty-usage-file-alone-is-not-lingering-state-at-sessionstart.json new file mode 100644 index 0000000..1bb4044 --- /dev/null +++ b/tests/golden/usage-a-dirty-usage-file-alone-is-not-lingering-state-at-sessionstart.json @@ -0,0 +1,6 @@ +{ + "stdout": "", + "stderr": "", + "code": 0, + "git": "status-all: ?? memories/.usage.tsv\nlog-all: seed\ntracked-all: memories/learning_seed_topic.md\nstatus: ?? memories/.usage.tsv\nlog: seed\nhead-body: \nupstream: origin/main\nunpushed: 0\ntracked: memories/learning_seed_topic.md\nworktree: .usage.tsv learning_seed_topic.md\nremote-log: seed\nremote-files: memories/learning_seed_topic.md" +} diff --git a/tests/golden/usage-a-dirty-usage-file-beside-a-badly-named-one-blocks-on-the-bad-name-only.json b/tests/golden/usage-a-dirty-usage-file-beside-a-badly-named-one-blocks-on-the-bad-name-only.json new file mode 100644 index 0000000..32ac38b --- /dev/null +++ b/tests/golden/usage-a-dirty-usage-file-beside-a-badly-named-one-blocks-on-the-bad-name-only.json @@ -0,0 +1,6 @@ +{ + "stdout": "Shared memories: skipping auto-push — unconventional filename(s):\n - memories/scratch.md\nRename to memories/learning__.md or memories/decision__.md so the guardrail accepts them.\nOr run /resolve-memories to have Claude rename them.\n", + "stderr": "", + "code": 0, + "git": "status-all: ?? memories/.usage.tsv | ?? memories/scratch.md\nlog-all: seed\ntracked-all: memories/learning_seed_topic.md\nstatus: ?? memories/.usage.tsv | ?? memories/scratch.md\nlog: seed\nhead-body: \nupstream: origin/main\nunpushed: 0\ntracked: memories/learning_seed_topic.md\nworktree: .usage.tsv learning_seed_topic.md scratch.md\nremote-log: seed\nremote-files: memories/learning_seed_topic.md" +} diff --git a/tests/golden/usage-a-fresh-clone-gets-the-union-rule.json b/tests/golden/usage-a-fresh-clone-gets-the-union-rule.json new file mode 100644 index 0000000..68d8fa5 --- /dev/null +++ b/tests/golden/usage-a-fresh-clone-gets-the-union-rule.json @@ -0,0 +1,6 @@ +{ + "stdout": "Cloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", + "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", + "code": 0, + "git": ".memories-repo memories\nlearning_shared_seed.md" +} diff --git a/tests/golden/usage-a-local-usage-file-alone-is-merged-and-left-for-the-next-stop.json b/tests/golden/usage-a-local-usage-file-alone-is-merged-and-left-for-the-next-stop.json new file mode 100644 index 0000000..2f9a541 --- /dev/null +++ b/tests/golden/usage-a-local-usage-file-alone-is-merged-and-left-for-the-next-stop.json @@ -0,0 +1,6 @@ +{ + "stdout": "Found pre-existing /work/project/.claude/memories with 1 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nMigration: imported 0 local memory file(s); 0 conflict(s) (shared version kept).\nDone. 1 memory file(s) available at /work/project/.claude/memories.\n", + "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", + "code": 0, + "git": ".memories-repo memories\n.usage.tsv learning_shared_seed.md" +} diff --git a/tests/golden/usage-a-local-usage-file-is-appended-onto-the-shared-one.json b/tests/golden/usage-a-local-usage-file-is-appended-onto-the-shared-one.json new file mode 100644 index 0000000..70b686d --- /dev/null +++ b/tests/golden/usage-a-local-usage-file-is-appended-onto-the-shared-one.json @@ -0,0 +1,6 @@ +{ + "stdout": "Found pre-existing /work/project/.claude/memories with 2 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nMigration: imported 1 local memory file(s); 0 conflict(s) (shared version kept).\nPushed migrated memories to the shared branch.\nDone. 2 memory file(s) available at /work/project/.claude/memories.\n", + "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", + "code": 0, + "git": ".memories-repo memories\n.usage.tsv learning_mine_own.md learning_shared_seed.md" +} diff --git a/tests/golden/usage-a-local-usage-file-is-moved-in-when-the-shared-repo-has-none.json b/tests/golden/usage-a-local-usage-file-is-moved-in-when-the-shared-repo-has-none.json new file mode 100644 index 0000000..70b686d --- /dev/null +++ b/tests/golden/usage-a-local-usage-file-is-moved-in-when-the-shared-repo-has-none.json @@ -0,0 +1,6 @@ +{ + "stdout": "Found pre-existing /work/project/.claude/memories with 2 entr(y/ies) — staging for migration...\n → backing up to /work/project/.claude/.memories-migration-\nCloning shared memories (branch 'main', sparse) into /work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nMigration: imported 1 local memory file(s); 0 conflict(s) (shared version kept).\nPushed migrated memories to the shared branch.\nDone. 2 memory file(s) available at /work/project/.claude/memories.\n", + "stderr": "Cloning into '/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n", + "code": 0, + "git": ".memories-repo memories\n.usage.tsv learning_mine_own.md learning_shared_seed.md" +} diff --git a/tests/golden/usage-a-memory-conflicting-beside-the-usage-file-still-pauses.json b/tests/golden/usage-a-memory-conflicting-beside-the-usage-file-still-pauses.json new file mode 100644 index 0000000..df537a8 --- /dev/null +++ b/tests/golden/usage-a-memory-conflicting-beside-the-usage-file-still-pauses.json @@ -0,0 +1,6 @@ +{ + "stdout": "Shared memories: auto-push paused — rebase conflict. Run /resolve-memories to merge it.\n", + "stderr": "", + "code": 0, + "git": "status-all: \nlog-all: Fixture: update learning_seed_topic | seed usage | seed\ntracked-all: memories/.usage.tsv | memories/learning_seed_topic.md\nstatus: \nlog: Fixture: update learning_seed_topic | seed usage | seed\nhead-body: \nupstream: origin/main\nunpushed: 1\ntracked: memories/.usage.tsv | memories/learning_seed_topic.md\nworktree: .usage.tsv learning_seed_topic.md\nremote-log: from the other clone | seed usage | seed\nremote-files: memories/.usage.tsv | memories/learning_seed_topic.md" +} diff --git a/tests/golden/usage-a-usage-file-changed-with-a-memory-leaves-the-subject-to-the-memory.json b/tests/golden/usage-a-usage-file-changed-with-a-memory-leaves-the-subject-to-the-memory.json new file mode 100644 index 0000000..481e98f --- /dev/null +++ b/tests/golden/usage-a-usage-file-changed-with-a-memory-leaves-the-subject-to-the-memory.json @@ -0,0 +1,6 @@ +{ + "stdout": "", + "stderr": "", + "code": 0, + "git": "status-all: \nlog-all: Fixture: add learning_new_thing | seed\ntracked-all: memories/.usage.tsv | memories/learning_new_thing.md | memories/learning_seed_topic.md\nstatus: \nlog: Fixture: add learning_new_thing | seed\nhead-body: \nupstream: origin/main\nunpushed: 0\ntracked: memories/.usage.tsv | memories/learning_new_thing.md | memories/learning_seed_topic.md\nworktree: .usage.tsv learning_new_thing.md learning_seed_topic.md\nremote-log: Fixture: add learning_new_thing | seed\nremote-files: memories/.usage.tsv | memories/learning_new_thing.md | memories/learning_seed_topic.md" +} diff --git a/tests/golden/usage-an-existing-clone-gets-the-union-rule-beside-its-other-attributes.json b/tests/golden/usage-an-existing-clone-gets-the-union-rule-beside-its-other-attributes.json new file mode 100644 index 0000000..8f79e81 --- /dev/null +++ b/tests/golden/usage-an-existing-clone-gets-the-union-rule-beside-its-other-attributes.json @@ -0,0 +1,6 @@ +{ + "stdout": "Added the merge=union rule for memories/.usage.tsv to /work/project/.claude/.memories-repo/.git/info/attributes.\nShared memories already linked — leaving as-is.\n", + "stderr": "", + "code": 0, + "git": ".memories-repo memories\nlearning_shared_seed.md" +} diff --git a/tests/golden/usage-appends-on-both-sides-are-merged-by-the-fallback-without-the-union-rule.json b/tests/golden/usage-appends-on-both-sides-are-merged-by-the-fallback-without-the-union-rule.json new file mode 100644 index 0000000..89ab5c1 --- /dev/null +++ b/tests/golden/usage-appends-on-both-sides-are-merged-by-the-fallback-without-the-union-rule.json @@ -0,0 +1,6 @@ +{ + "stdout": "", + "stderr": "", + "code": 0, + "git": "status-all: \nlog-all: Fixture: update memory usage | from the other clone | seed usage | seed\ntracked-all: memories/.usage.tsv | memories/learning_seed_topic.md\nstatus: \nlog: Fixture: update memory usage | from the other clone | seed usage | seed\nhead-body: \nupstream: origin/main\nunpushed: 0\ntracked: memories/.usage.tsv | memories/learning_seed_topic.md\nworktree: .usage.tsv learning_seed_topic.md\nremote-log: Fixture: update memory usage | from the other clone | seed usage | seed\nremote-files: memories/.usage.tsv | memories/learning_seed_topic.md" +} diff --git a/tests/golden/usage-appends-on-both-sides-merge-cleanly-under-the-union-rule.json b/tests/golden/usage-appends-on-both-sides-merge-cleanly-under-the-union-rule.json new file mode 100644 index 0000000..89ab5c1 --- /dev/null +++ b/tests/golden/usage-appends-on-both-sides-merge-cleanly-under-the-union-rule.json @@ -0,0 +1,6 @@ +{ + "stdout": "", + "stderr": "", + "code": 0, + "git": "status-all: \nlog-all: Fixture: update memory usage | from the other clone | seed usage | seed\ntracked-all: memories/.usage.tsv | memories/learning_seed_topic.md\nstatus: \nlog: Fixture: update memory usage | from the other clone | seed usage | seed\nhead-body: \nupstream: origin/main\nunpushed: 0\ntracked: memories/.usage.tsv | memories/learning_seed_topic.md\nworktree: .usage.tsv learning_seed_topic.md\nremote-log: Fixture: update memory usage | from the other clone | seed usage | seed\nremote-files: memories/.usage.tsv | memories/learning_seed_topic.md" +} diff --git a/tests/golden/usage-in-place-edits-on-both-sides-are-merged-by-the-fallback-without-the-union-rule.json b/tests/golden/usage-in-place-edits-on-both-sides-are-merged-by-the-fallback-without-the-union-rule.json new file mode 100644 index 0000000..89ab5c1 --- /dev/null +++ b/tests/golden/usage-in-place-edits-on-both-sides-are-merged-by-the-fallback-without-the-union-rule.json @@ -0,0 +1,6 @@ +{ + "stdout": "", + "stderr": "", + "code": 0, + "git": "status-all: \nlog-all: Fixture: update memory usage | from the other clone | seed usage | seed\ntracked-all: memories/.usage.tsv | memories/learning_seed_topic.md\nstatus: \nlog: Fixture: update memory usage | from the other clone | seed usage | seed\nhead-body: \nupstream: origin/main\nunpushed: 0\ntracked: memories/.usage.tsv | memories/learning_seed_topic.md\nworktree: .usage.tsv learning_seed_topic.md\nremote-log: Fixture: update memory usage | from the other clone | seed usage | seed\nremote-files: memories/.usage.tsv | memories/learning_seed_topic.md" +} diff --git a/tests/golden/usage-in-place-edits-on-both-sides-merge-cleanly-under-the-union-rule.json b/tests/golden/usage-in-place-edits-on-both-sides-merge-cleanly-under-the-union-rule.json new file mode 100644 index 0000000..89ab5c1 --- /dev/null +++ b/tests/golden/usage-in-place-edits-on-both-sides-merge-cleanly-under-the-union-rule.json @@ -0,0 +1,6 @@ +{ + "stdout": "", + "stderr": "", + "code": 0, + "git": "status-all: \nlog-all: Fixture: update memory usage | from the other clone | seed usage | seed\ntracked-all: memories/.usage.tsv | memories/learning_seed_topic.md\nstatus: \nlog: Fixture: update memory usage | from the other clone | seed usage | seed\nhead-body: \nupstream: origin/main\nunpushed: 0\ntracked: memories/.usage.tsv | memories/learning_seed_topic.md\nworktree: .usage.tsv learning_seed_topic.md\nremote-log: Fixture: update memory usage | from the other clone | seed usage | seed\nremote-files: memories/.usage.tsv | memories/learning_seed_topic.md" +} diff --git a/tests/golden/usage-sessionstart-fast-forwards-a-teammate-s-usage-rows-over-our-dirty-ones.json b/tests/golden/usage-sessionstart-fast-forwards-a-teammate-s-usage-rows-over-our-dirty-ones.json new file mode 100644 index 0000000..9e29a90 --- /dev/null +++ b/tests/golden/usage-sessionstart-fast-forwards-a-teammate-s-usage-rows-over-our-dirty-ones.json @@ -0,0 +1,6 @@ +{ + "stdout": "", + "stderr": "", + "code": 0, + "git": "status-all: M memories/.usage.tsv\nlog-all: from the other clone | seed usage | seed\ntracked-all: memories/.usage.tsv | memories/learning_seed_topic.md\nstatus: M memories/.usage.tsv\nlog: from the other clone | seed usage | seed\nhead-body: \nupstream: origin/main\nunpushed: 0\ntracked: memories/.usage.tsv | memories/learning_seed_topic.md\nworktree: .usage.tsv learning_seed_topic.md\nremote-log: from the other clone | seed usage | seed\nremote-files: memories/.usage.tsv | memories/learning_seed_topic.md" +} diff --git a/tests/harness.ts b/tests/harness.ts index 7e85017..437d429 100644 --- a/tests/harness.ts +++ b/tests/harness.ts @@ -4,6 +4,8 @@ import { hostname, tmpdir, userInfo } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { USAGE_ATTRIBUTE } from "../runtime/lib/naming.mts"; + const REPO = dirname(dirname(fileURLToPath(import.meta.url))); export type HookName = "pull" | "autopush"; @@ -80,6 +82,8 @@ export type Fixture = { readonly expect?: RegExp; /** The abort diagnostic is a documented stderr-only deviation; compare the rest of stderr. */ readonly abortsDiffer?: boolean; + /** Asserts on what the snapshot leaves out, such as file contents, before the temp dir goes. */ + readonly inspect?: (repo: string, project: string) => void; }; /** @@ -208,6 +212,16 @@ export function rejectAllPushes(remote: string): void { chmodSync(hook, 0o755); } +export const commit = (repo: string, msg: string): void => { + git(repo, "add", "-A"); + git(repo, "commit", "-qm", msg); +}; + +export const attributesFile = (repo: string): string => join(repo, ".git", "info", "attributes"); + +/** A clone synced before the union rule existed. */ +export const dropUnionRule = (repo: string): void => rmSync(attributesFile(repo)); + export const memory = (repo: string, name: string, body = "A shared lesson.\n"): string => { const path = join(repo, "memories", name); writeFileSync(path, body); @@ -233,6 +247,8 @@ export function makeProject(): { root: string; work: string; project: string; re git(repo, "add", "-A"); git(repo, "commit", "-qm", "seed"); git(repo, "push", "-q", "-u", "origin", "main"); + // What configure-memories writes to every clone. + writeFileSync(attributesFile(repo), `${USAGE_ATTRIBUTE}\n`); // The ancestor Node resolves module type from. "commonjs" is the hostile case. writeFileSync(join(project, "package.json"), '{"name":"consumer","type":"commonjs"}\n'); @@ -297,6 +313,7 @@ export function runHook(fx: Fixture): RunResult { fx.betweenRuns?.(repo, project); last = invoke(project, fx.hook, fx); } + fx.inspect?.(repo, project); return normalizeRun({ ...last, git: gitState(repo) }, root); } finally { rmSync(root, { recursive: true, force: true }); @@ -351,6 +368,8 @@ export type ConfigureFixture = { readonly trailingSlash?: boolean; /** Skipped when running as root, where the fixture's premise does not hold. */ readonly skipAsRoot?: boolean; + /** Asserts on what the listing leaves out, such as file contents, before the temp dir goes. */ + readonly inspect?: (claude: string) => void; /** * A deliberate departure from the recorded bash behaviour. The golden keeps * recording what the bash did; this states what we now do instead, and why. @@ -404,6 +423,7 @@ export function runConfigure(fx: ConfigureFixture): RunResult { ...fx.env, }, }); + fx.inspect?.(claude); const listing = `${entries(claude).join(" ")}\n${entries(join(claude, "memories")).join(" ")}`; return normalizeRun( { stdout: r.stdout ?? "", stderr: r.stderr ?? "", code: r.status ?? -1, git: listing.trim() }, diff --git a/tests/scripts.test.ts b/tests/scripts.test.ts index 0698dd0..134e5b3 100644 --- a/tests/scripts.test.ts +++ b/tests/scripts.test.ts @@ -2,7 +2,7 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; import { rmSync, symlinkSync, mkdirSync, unlinkSync } from "node:fs"; import { join } from "node:path"; -import { git, readGolden, runScript, type RunResult, type ScriptFixture } from "./harness.ts"; +import { dropUnionRule, git, readGolden, runScript, writeGolden, type RunResult, type ScriptFixture } from "./harness.ts"; /** @@ -11,6 +11,7 @@ import { git, readGolden, runScript, type RunResult, type ScriptFixture } from " * was removed. A drift here is a change in what the pack does, not a stale test. */ function check(name: string, actual: RunResult, expect?: RegExp): void { + if (process.env["UPDATE_GOLDENS"] === "1") writeGolden(name, actual); const golden = readGolden(name); assert.ok(golden, `no golden recorded for "${name}"`); assert.equal(actual.stdout, golden.stdout, `stdout drifted from recorded behaviour for "${name}"`); @@ -66,6 +67,21 @@ const fixtures: readonly ScriptFixture[] = [ symlinkSync(join(project, "elsewhere"), link); }, }, + { + name: "doctor: a missing union rule for the usage file warns", + script: "doctor-memories", + expectCode: 2, + setup: dropUnionRule, + }, + { + name: "doctor: a missing union rule is listed with the failures", + script: "doctor-memories", + expectCode: 1, + setup: (repo, project) => { + dropUnionRule(repo); + unlinkSync(join(claude(project), "memories")); + }, + }, { name: "doctor-remote: reachable remote passes", script: "doctor-memories-remote", expectCode: 0 }, { name: "doctor-remote: no checkout skips", diff --git a/tests/unit.test.ts b/tests/unit.test.ts index 46e7806..b572d22 100644 --- a/tests/unit.test.ts +++ b/tests/unit.test.ts @@ -1,11 +1,12 @@ import { test, describe } from "node:test"; import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import { stripJsonComments } from "./harness.ts"; +import { makeProject, memory, stripJsonComments } from "./harness.ts"; import { isJsonStream } from "../runtime/lib/hook-io.mts"; -import { ALLOWED_PATTERN } from "../runtime/lib/naming.mts"; +import { ALLOWED_PATTERN, USAGE_FILE } from "../runtime/lib/naming.mts"; +import { badNames } from "../runtime/lib/pending.mts"; import { commitMessage, parseNameStatus, type Change } from "../runtime/lib/commit-message.mts"; import { jitterMs, pushAttempts } from "../runtime/lib/push.mts"; @@ -23,6 +24,24 @@ describe("the naming guardrail", () => { for (const f of bad) test(`rejects ${f}`, () => assert.ok(!ALLOWED_PATTERN.test(f))); }); +describe("the usage file is exempt from the naming guardrail, and nothing else is", () => { + test("badNames passes the usage file and still flags its look-alikes", () => { + const { root, repo } = makeProject(); + try { + writeFileSync(join(repo, USAGE_FILE), "# since: 2026-10-01\n"); + memory(repo, "usage.tsv"); + memory(repo, ".usage.tsv.bak"); + mkdirSync(join(repo, "memories", "sub")); + writeFileSync(join(repo, "memories", "sub", ".usage.tsv"), ""); + assert.deepEqual(badNames(repo), ["memories/.usage.tsv.bak", "memories/sub/.usage.tsv", "memories/usage.tsv"]); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test("the naming pattern itself still rejects it", () => assert.ok(!ALLOWED_PATTERN.test(USAGE_FILE))); +}); + describe("the retry budget", () => { for (const [raw, n] of [[undefined, 12], ["", 12], ["banana", 12], ["-1", 12], ["3.5", 12], ["0", 1], ["1", 1], ["7", 7]] as const) { test(`${JSON.stringify(raw)} yields ${n}`, () => assert.equal(pushAttempts(raw), n)); @@ -57,6 +76,17 @@ describe("commit messages", () => { assert.equal(msg, "Ana: 2 added, 1 updated, 1 removed\n\n+ decision_a\n+ learning_b\n~ decision_c\n- learning_z"); }); + test("a usage-only change says so instead of naming a memory", () => { + assert.equal(commitMessage("Ana", [{ kind: "update", path: USAGE_FILE }]), "Ana: update memory usage"); + assert.equal(commitMessage("Ana", [{ kind: "add", path: USAGE_FILE }]), "Ana: update memory usage"); + }); + + test("the usage file is left out of a message that has memory changes", () => { + const usage: Change = { kind: "update", path: USAGE_FILE }; + assert.equal(commitMessage("Ana", [usage, add("learning_a_b")]), "Ana: add learning_a_b"); + assert.equal(commitMessage("Ana", [add("learning_b"), usage, upd("decision_c")]), "Ana: 1 added, 1 updated\n\n+ learning_b\n~ decision_c"); + }); + test("nothing staged still yields a usable subject", () => { assert.equal(commitMessage("Ana", []), "Ana: update memories"); });