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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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; warns when the usage file's `merge=union` rule is missing (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 or `core.commitGraph` is on (both auto-fixable via `mcs sync`), and when auto-push is stuck on a rebase or merge in progress, a missing upstream, or a misnamed file (fix with `/resolve-memories`) |
| **Shared memories remote access** | Auth + network reachability via `git ls-remote origin`; warns (doesn't fail) on issues since local reads still work |

### Dependencies
Expand Down Expand Up @@ -319,13 +319,13 @@ git -C .claude/.memories-repo log --diff-filter=D --oneline -- memories/<file>
git -C .claude/.memories-repo checkout <sha>^ -- memories/<file> # restore it; the next Stop pushes it
```

The Stop hook retries on every turn, so a network blip or a teammate pushing first sorts itself out. A few things never clear up by retrying. SessionStart and the Stop hook name them when they happen, and `/resolve-memories` handles the ones Claude can fix:
The Stop hook retries on every turn, so a network blip or a teammate pushing first sorts itself out. A pull that fails partway through its rebase without a conflict is rolled back on the spot, so it never leaves the checkout stuck. A few things never clear up by retrying. SessionStart and the Stop hook name them when they happen, and `/resolve-memories` handles the ones Claude can fix:

| Blocker | What `/resolve-memories` does |
|---------|-------------------------------|
| **Rebase conflict** — you and a teammate edited the same memory | Merges both versions so neither side's facts are lost, and shows you the result. Asks you when one side deleted the file and the other edited it |
| **Unconventional filename** — the guardrail halts every push | Proposes a `learning_…` / `decision_…` name and renames the file. Asks you when the type is unclear |
| **Rebase left in progress** — the hooks pause rather than commit conflict markers | Finishes it, or aborts it if the conflict is outside `memories/` |
| **Rebase left in progress** — the hooks pause rather than commit conflict markers | Finishes it, or aborts it if the conflict is outside `memories/`. A rebase that can't be finished is cleared with `--quit`, which keeps edits made since it started |
| **No upstream** — commits stay local without a word | Sets the upstream to `origin/<branch>` |
| **Auth** (SSH key not loaded, expired token) | Diagnoses only: run `ssh-add`, refresh credentials, check with `mcs doctor` |
| **No git identity** — the commit itself fails | Diagnoses only: set `git config --global user.name` / `user.email` |
Expand Down Expand Up @@ -368,6 +368,8 @@ Anything not matching `memories/(learning|decision)_*.md` needs renaming — or

**If SessionStart warns about lingering state**, the previous push hit one of the [blockers](#-conflicts-and-stuck-pushes). A network or auth issue clears on the next Stop once it's fixed (`mcs doctor` checks remote access); a rebase conflict or a misnamed file needs `/resolve-memories`.

**If pulls fail with "in the commit graph file but not in the object database"**, the blobless checkout can't lazy-fetch with git's commit-graph on. `mcs sync` turns it off for the checkout (`core.commitGraph=false`), and `mcs doctor` warns when it's still on.

---

## 🧪 Development
Expand Down
2 changes: 2 additions & 0 deletions commands/resolve-memories.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ If nothing is dirty, nothing is unpushed, an upstream exists and no rebase or me

Go straight to step 6 and finish it. Don't commit anything new first. For a merge (`MERGE_HEAD`), resolve the conflicts the same way, then finish with `git -C .claude/.memories-repo commit --no-edit`.

If the rebase can't be finished (its todo file is missing, or `rebase --continue` fails with no conflicted file), or memories were edited after it started, clear it with `git -C .claude/.memories-repo rebase --quit`. Never `--abort` or reset: both overwrite the working tree, and edits made after the rebase started are in no stash. Check that `git -C .claude/.memories-repo symbolic-ref -q HEAD` names the branch again, stop and tell the user if it doesn't, then continue at step 5.

## 3. Filename guardrail

Every dirty file under `memories/` must match `^memories/(learning|decision)_[a-zA-Z0-9_-]+\.md$`, the pattern the Stop hook enforces, defined once in `runtime/lib/naming.mts`. For each offender:
Expand Down
5 changes: 5 additions & 0 deletions runtime/lib/git.mts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,11 @@ export function hasUnionRule(dir: string): boolean {
return top !== "" && gitOut(top, ["check-attr", "merge", "--", USAGE_FILE]).endsWith(": merge: union");
}

/** Whether the clone has the commit-graph turned off, which configure sets on every sync. */
export function commitGraphOff(dir: string): boolean {
return gitOut(dir, ["config", "--type=bool", "--get", "core.commitGraph"]) === "false";
}

/** Whether the binary exists, the question `command -v git` asked. */
export function gitPresent(): boolean {
const r = spawnSync("git", ["--version"], { stdio: "ignore" });
Expand Down
24 changes: 21 additions & 3 deletions runtime/lib/push.mts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { git, gitLines } from "./git.mts";
import { git, gitLines, operationInProgress } from "./git.mts";
import { detail, say } from "./hook-io.mts";
import { RESOLVE_COMMAND, USAGE_FILE } from "./naming.mts";

Expand Down Expand Up @@ -56,13 +56,26 @@ function finishUsageOnlyRebase(repo: string): string | null {
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.`);
say(
`Shared memories: the rebase failed 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);
}
}

// `err` must be git's LC_ALL=C stderr.
function pullFailure(err: string, midRebase: boolean): string {
// Retrying never clears this one, and every Stop would hit it again until the setting is fixed.
if (/commit.graph/i.test(err)) {
return "Shared memories: pull --rebase failed on the checkout's commit-graph. Run `mcs sync` to turn it off; until then every push fails the same way.";
}
return midRebase
? "Shared memories: pull --rebase failed partway and was rolled back. Will retry on next Stop."
: "Shared memories: pull --rebase failed (likely auth or network). Will retry on next Stop.";
}

export function syncToRemote(repo: string, attemptsRaw: string | undefined): void {
const attempts = pushAttempts(attemptsRaw);
for (let attempt = 1; ; attempt++) {
Expand All @@ -71,7 +84,12 @@ export function syncToRemote(repo: string, attemptsRaw: string | undefined): voi
if (!pull.ok) {
const err = pull.stderr.replace(/\n$/, "");
if (!/conflict/i.test(err)) {
say("Shared memories: pull --rebase failed (likely auth or network). Will retry on next Stop.");
// A failure mid-pick (a missing object, say) leaves the rebase behind, and that pauses every later Stop.
// Aborting now is safe: everything is committed or in the autostash.
const midRebase = operationInProgress(repo) === "rebase";
const message = pullFailure(err, midRebase);
if (midRebase) abortRebase(repo, message);
else say(message);
detail(err);
return;
}
Expand Down
19 changes: 16 additions & 3 deletions scripts/configure-memories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
} from "node:fs";
import { dirname, join } from "node:path";
import { changeLine } from "../runtime/lib/commit-message.mts";
import { authorName, git, gitLines, gitOut, gitPresent, hasUnionRule, isWorkTree } from "../runtime/lib/git.mts";
import { authorName, commitGraphOff, 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";

const out = (line: string) => process.stdout.write(`${line}\n`);
Expand Down Expand Up @@ -113,6 +113,19 @@ function ensureUnionAttribute(checkout: string): void {
out(`Added the merge=union rule for ${USAGE_FILE} to ${path}.`);
}

// In a blobless clone the commit-graph can make git refuse to lazy-fetch objects
// it needs mid-rebase, leaving the Stop hook's rebase half-applied.
function ensureCommitGraphOff(checkout: string): void {
if (commitGraphOff(checkout)) return;
if (git(checkout, ["config", "--local", "core.commitGraph", "false"]).ok) out("Turned off core.commitGraph for the memories checkout.");
}

// Every sync, both paths: an already-linked clone predates whatever setting was added last.
function configureClone(checkout: string): void {
ensureUnionAttribute(checkout);
ensureCommitGraphOff(checkout);
}

function stamp(): string {
const d = new Date();
const p = (n: number) => String(n).padStart(2, "0");
Expand All @@ -135,7 +148,7 @@ function main(): void {

// Case 1: healthy.
if (isSymlink(link) && isWorkTree(link)) {
ensureUnionAttribute(link);
configureClone(link);
out("Shared memories already linked — leaving as-is.");
process.exit(0);
}
Expand Down Expand Up @@ -209,7 +222,7 @@ function main(): void {
mkdirSync(join(repoDir, "memories"), { recursive: true });
linkMemories();
}
ensureUnionAttribute(repoDir);
configureClone(repoDir);

let imported = 0;
let skipped = 0;
Expand Down
26 changes: 23 additions & 3 deletions scripts/doctor-memories.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
#!/usr/bin/env -S node --experimental-strip-types --disable-warning=ExperimentalWarning
import { existsSync, lstatSync } from "node:fs";
import { join } from "node:path";
import { hasUnionRule, isWorkTree } from "../runtime/lib/git.mts";
import { USAGE_FILE } from "../runtime/lib/naming.mts";
import { commitGraphOff, hasUnionRule, hasUpstream, isWorkTree, operationInProgress } from "../runtime/lib/git.mts";
import { RESOLVE_COMMAND, USAGE_FILE } from "../runtime/lib/naming.mts";
import { badNames } from "../runtime/lib/pending.mts";

// mcs shellScript doctor contract: 0 pass, 1 fail, 2 warn, 3 skip.
const project = process.env.MCS_PROJECT_PATH || process.cwd();
Expand All @@ -29,9 +30,28 @@ if (!isSymlink(link)) {

// A warning only: without the rule the Stop hook still merges usage conflicts itself.
const warnings: string[] = [];
if (existsSync(join(repo, ".git")) && !hasUnionRule(repo)) {
const checkout = isWorkTree(repo);
if (checkout && !hasUnionRule(repo)) {
warnings.push(`no merge=union rule for ${USAGE_FILE} in the checkout (run 'mcs sync' to add it)`);
}
if (checkout && !commitGraphOff(repo)) {
warnings.push("core.commitGraph is not off in the checkout (run 'mcs sync' to set it)");
}

// Stuck states that pause auto-push. Warnings, since reads still work and mcs sync can't fix them.
// A rebase detaches HEAD, which would also read as a missing upstream.
if (checkout) {
const op = operationInProgress(repo);
if (op !== null) {
warnings.push(`a ${op} is in progress in the checkout, so auto-push is paused (run ${RESOLVE_COMMAND})`);
} else if (!hasUpstream(repo)) {
warnings.push(`the checkout has no upstream branch, so memories never leave this machine (run ${RESOLVE_COMMAND})`);
}
const bad = badNames(repo);
if (bad.length > 0) {
warnings.push(`${bad.length} file(s) the naming guardrail rejects block every push: ${bad.join(", ")} (run ${RESOLVE_COMMAND})`);
}
}

if (problems.length + warnings.length > 0) {
process.stdout.write(`Shared memories setup ${problems.length > 0 ? "is unhealthy" : "needs attention"}:\n`);
Expand Down
21 changes: 20 additions & 1 deletion tests/configure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ const fixtures: readonly ConfigureFixture[] = [
name: "case 5: fresh setup clones and links",
expectCode: 0,
expect: /Cloning shared memories \(branch 'main', sparse\)[\s\S]*Done\. 1 memory file\(s\) available/,
inspect: claude => assert.equal(attributes(claude), "memories/.usage.tsv merge=union\n"),
inspect: claude => {
assert.equal(attributes(claude), "memories/.usage.tsv merge=union\n");
assert.equal(commitGraph(claude), "false");
},
},
{
name: "case 1: an already-linked setup is left alone",
Expand All @@ -61,6 +64,7 @@ const fixtures: readonly ConfigureFixture[] = [
git(claude, "clone", "-q", join(project, "..", "remote.git"), join(claude, ".memories-repo"));
symlinkSync(".memories-repo/memories", join(claude, "memories"));
},
inspect: claude => assert.equal(commitGraph(claude), "false"),
},
{
name: "case 2: an empty memories directory is removed",
Expand Down Expand Up @@ -366,3 +370,18 @@ const usage: readonly ConfigureFixture[] = [
describe("the usage file — behaviour is pinned", () => {
for (const fx of usage) test(fx.name, () => assertParity(fx));
});

const commitGraph = (claude: string): string => git(join(claude, ".memories-repo"), "config", "--get", "core.commitGraph");

describe("the commit-graph setting — behaviour is pinned", () => {
const fx: ConfigureFixture = {
name: "commit-graph: a clone that already has it off is left alone",
expectCode: 0,
pre: (project, claude) => {
linkedCheckout(project, claude);
git(join(claude, ".memories-repo"), "config", "core.commitGraph", "false");
},
inspect: claude => assert.equal(commitGraph(claude), "false"),
};
test(fx.name, () => assertParity(fx));
});
53 changes: 51 additions & 2 deletions tests/differential.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { execFileSync, spawnSync } from "node:child_process";
import { chmodSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { describe, test } from "node:test";
import { operationInProgress } from "../runtime/lib/git.mts";
import {
commit,
dropUnionRule,
Expand Down Expand Up @@ -355,6 +356,13 @@ const rebaseLeftInProgress = (repo: string, project: string) => {
memory(repo, "learning_new_thing.md", "Written while the rebase was stuck.\n");
};

/** Runs `cmd` once the rebase has started, so the pick fails with no conflict to report. */
const failMidRebase = (repo: string, cmd: string) => {
const hook = join(repo, ".git", "hooks", "post-checkout");
writeFileSync(hook, `#!/bin/sh\n[ -e .git/rebase-merge ] && ${cmd}\nexit 0\n`);
chmodSync(hook, 0o755);
};

const edges: readonly Fixture[] = [
{
name: "edge: a locked index aborts the turn instead of reporting nothing",
Expand Down Expand Up @@ -457,6 +465,47 @@ const edges: readonly Fixture[] = [
hook: "pull",
setup: rebaseLeftInProgress,
},
{
name: "edge: a pull that fails mid-rebase without a conflict is rolled back",
expect: /^Shared memories: pull --rebase failed partway and was rolled back\. Will retry on next Stop\.$/m,
hook: "autopush",
setup: (repo, project) => {
teammatePushes(project, "learning_theirs_new.md");
memory(repo, "learning_ours_own.md", "Ours.\n");
// Picking our commit has to write a new tree; the abort only reads.
failMidRebase(repo, "chmod -R a-w .git/objects");
},
inspect: repo => {
execFileSync("chmod", ["-R", "u+w", join(repo, ".git", "objects")]);
assert.equal(operationInProgress(repo), null, "the rebase was left behind");
},
},
{
name: "edge: a commit-graph failure names mcs sync instead of a retry",
expect: /commit-graph\. Run `mcs sync` to turn it off/,
hook: "autopush",
setup: (repo, project) => {
teammatePushes(project, "learning_theirs_new.md");
memory(repo, "learning_ours_own.md", "Ours.\n");
// git's wording from a blobless clone on 2.50; no current git reproduces the failure itself.
failMidRebase(
repo,
"echo 'fatal: commit 0123abc exists in commit-graph but not in the object database' >&2; chmod -R a-w .git/objects",
);
},
inspect: repo => execFileSync("chmod", ["-R", "u+w", join(repo, ".git", "objects")]),
},
{
name: "edge: a mid-rebase failure whose abort also fails pauses for resolve",
expect: /^Shared memories: the rebase failed AND --abort failed .* Run \/resolve-memories to finish it\.$/m,
hook: "autopush",
setup: (repo, project) => {
teammatePushes(project, "learning_theirs_new.md");
memory(repo, "learning_ours_own.md", "Ours.\n");
// An untracked file where our commit adds one blocks both the pick and the reset back.
failMidRebase(repo, "echo stray > memories/learning_ours_own.md");
},
},
];

describe("audited edges — behaviour is pinned", () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"stdout": "Cloning shared memories (branch 'main', sparse) into <TMP>/work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to <TMP>/work/project/.claude/.memories-repo/.git/info/attributes.\n",
"stdout": "Cloning shared memories (branch 'main', sparse) into <TMP>/work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to <TMP>/work/project/.claude/.memories-repo/.git/info/attributes.\nTurned off core.commitGraph for the memories checkout.\n",
"stderr": "Cloning into '<TMP>/work/project/.claude/.memories-repo'...\nwarning: --filter is ignored in local clones; use file:// instead.\ndone.\n",
"code": 2,
"git": ".memories-repo memories"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"stdout": "Cloning shared memories (branch 'main', sparse) into <TMP>/work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to <TMP>/work/project/.claude/.memories-repo/.git/info/attributes.\nDone. 1 memory file(s) available at <TMP>/work/project/.claude/memories.\n",
"stdout": "Cloning shared memories (branch 'main', sparse) into <TMP>/work/project/.claude/.memories-repo...\nAdded the merge=union rule for memories/.usage.tsv to <TMP>/work/project/.claude/.memories-repo/.git/info/attributes.\nTurned off core.commitGraph for the memories checkout.\nDone. 1 memory file(s) available at <TMP>/work/project/.claude/memories.\n",
"stderr": "Cloning into '<TMP>/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"
Expand Down
Loading
Loading