Skip to content

feat(memsync): key memory sync by project fingerprint, not local path - #5

Open
mja00 wants to merge 1 commit into
MarimerLLC:mainfrom
mja00:feat/fingerprint-keyed-sync
Open

feat(memsync): key memory sync by project fingerprint, not local path#5
mja00 wants to merge 1 commit into
MarimerLLC:mainfrom
mja00:feat/fingerprint-keyed-sync

Conversation

@mja00

@mja00 mja00 commented Jul 13, 2026

Copy link
Copy Markdown

Problem

claude-memsync keyed the sync repo by Claude's per-project directory name, which is the project's absolute path with separators replaced by dashes (-Users-me-code-foo). That name differs per machine, so a repo checked out at /Users/me/code/foo on one machine and /home/me/repos/foo on another was seen as two separate projects and their memories never merged. This was previously documented as the "same paths required" limitation.

Solution

Key the mirror by a machine-independent fingerprint instead of the local path:

  • g-<hash> — normalized origin remote URL (all of git@github.com:Acme/app.git, https://github.com/acme/app, …/app.git collapse to one key)
  • r-<hash> — git root-commit SHA, when the repo has no remote (with a shallow-clone guard)
  • the dash-encoded path — fallback for non-git dirs (unchanged behavior; only merges across identical paths)

The Claude side stays keyed by its local dir name; a new internal/project package resolves each project's real path from the cwd Claude records in its session transcripts, derives the key, and keeps a per-PC ~/.claudesync/.state/index.json (gitignored) mapping localHash ↔ key. Reconcile/copy/remove and inbound propagation are now key-addressed. Projects synced from another machine but not yet opened locally materialize lazily on first open.

Migration: the first run after upgrading auto-migrates existing path-keyed mirror dirs to fingerprint keys, union-merging any that collapse to the same key via the existing claude-memmerge driver. Both machines must be upgraded for a project's divergent histories to converge. This is a pre-1.0 on-disk layout change → minor version bump.

Also: the foreground run daemon now logs startup and sync activity — previously it was silent, which read as a hang.

Verification

  • go build ./... && go vet ./... && gofmt -l . && go test ./... all clean
  • New unit tests: URL normalization, fingerprint tiering (remote/root/path incl. clone + shallow), cwd extraction, index round-trip, cross-path union reconcile, migration idempotency
  • End-to-end with a bare-repo remote: two "machines" at different paths but the same repo converge to one g- key with a unioned MEMORY.md; a legacy path-keyed remote dir migrates and merges on upgrade

Mirror directories are now keyed by a machine-independent fingerprint
(normalized git remote URL, then root-commit SHA, then the path fallback for
non-git dirs) instead of Claude's path-derived directory name, so the same repo
checked out at different paths on different machines syncs and merges. Real
project paths are recovered from the cwd in Claude's session transcripts, and a
per-PC .state/index.json maps localHash to key. Legacy path-keyed layouts
auto-migrate on upgrade, union-merging collisions. The foreground daemon now
logs startup and sync activity so it no longer looks hung.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR changes claude-memsync’s on-disk addressing so synced projects are keyed by a machine-independent fingerprint (git remote URL / root commit) instead of Claude’s per-machine path-derived project directory name, enabling memories to converge across different checkout paths. It also introduces per-PC index state to map Claude’s local project dir names to fingerprint keys, adds layout migration for existing mirrors, and improves daemon logging/observability.

Changes:

  • Add internal/project for resolving Claude project real paths from transcripts, deriving fingerprint keys, and persisting a per-PC localHash → key index.
  • Update sync/reconcile + watcher/propagation paths to use fingerprint-keyed mirror directories, plus add legacy layout migration.
  • Update docs and tests to cover URL normalization, fingerprint tiering, index persistence, reconcile unioning, and migration idempotency.

Reviewed changes

Copilot reviewed 13 out of 13 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
README.md Documents new internal/project package and migration capability.
internal/sync/reconcile_test.go Updates Reconcile call signature for index-aware reconciliation.
internal/sync/mirror.go Implements fingerprint-keyed reconciliation, copy/remove helpers, and layout migration.
internal/sync/loop.go Integrates project index into daemon flow; adds migration + more logging; updates inbound propagation.
internal/sync/keyed_test.go Adds tests for cross-path convergence and legacy-dir migration/union behavior.
internal/project/resolve.go Adds transcript-based cwd/path resolution for Claude projects.
internal/project/resolve_test.go Tests cwd extraction behavior across transcript variants.
internal/project/index.go Adds per-PC index structure + build/save/load APIs.
internal/project/index_test.go Tests mixed-project indexing, save/load round-trip, and cache-on-miss behavior.
internal/project/fingerprint.go Adds fingerprint derivation (remote/root/path) and URL normalization helpers.
internal/project/fingerprint_test.go Tests URL normalization and fingerprint tiering.
docs/claude-memsync.md Updates docs to reflect fingerprint-keyed behavior, migration, and new state files.
cmd/claude-memsync/init.go Builds index + runs migration during init before first reconcile.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/sync/mirror.go
Comment on lines +101 to 110
if mirrorEntries, err := os.ReadDir(r.Mirror); err == nil {
for _, e := range mirrorEntries {
if !e.IsDir() || seenKey[e.Name()] || project.IsFingerprintKey(e.Name()) {
continue
}
pairs = append(pairs, pair{e.Name(), e.Name()})
}
} else if !errors.Is(err, fs.ErrNotExist) {
return rep, fmt.Errorf("read mirror %s: %w", r.Mirror, err)
}
Comment thread internal/sync/loop.go
Comment on lines 341 to 350
switch {
case strings.HasPrefix(status, "D"):
_ = os.Remove(filepath.Join(roots.Claude, hash, "memory", name))
removeFromClaude(roots, idx, key, name)
applied++
default:
if err := CopyToClaude(roots, hash, name); err != nil {
if err := CopyToClaude(roots, idx, key, name); err != nil {
log.Printf("propagate %s: %v", path, err)
} else {
applied++
}
Comment on lines +55 to +73
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), maxJSONLLine)
needle := []byte(`"cwd"`)
for sc.Scan() {
line := sc.Bytes()
if !bytes.Contains(line, needle) {
continue
}
var rec struct {
Cwd string `json:"cwd"`
}
if err := json.Unmarshal(line, &rec); err != nil {
continue
}
if rec.Cwd != "" {
return rec.Cwd, true
}
}
return "", false
Comment thread internal/project/index.go
Comment on lines +122 to +130
var raw indexJSON
if err := json.Unmarshal(b, &raw); err != nil {
return nil, err
}
idx := NewIndex()
for lh, e := range raw.Entries {
idx.byLocal[lh] = e
}
return idx, nil
@JeePeeTee

Copy link
Copy Markdown

Independent data point: I hit the same limitation and built the same capability in a fork before finding this PR, so this is offered as review input rather than a competing proposal.

I landed on the same core idea — key by the normalized origin URL — but yours is more complete on three axes mine punts on: automatic resolution from the session cwd (mine needs an explicit per-project command), the root-commit tier for repos with no remote, and lazy materialization. That last one solves a wart mine has: projects synced from another machine but never opened locally leave directories sitting in ~/.claude/projects that the user has to clean up by hand.

Two things my implementation surfaced that interact with this one.

1. The merge driver silently drops repeated H2 headings — #7.

blocksByKey maps on the normalized heading, so a MEMORY.md with two ## Notes sections (or two differing only in case, since keys are lowercased) collapses into one entry and all but the last is discarded. It fires even when both sides are byte-identical.

This matters here specifically: your migration union-merges collapsing directories through claude-memmerge, so the bug would drop sections at exactly the moment two machines' memories are being consolidated — the least recoverable point. Worth landing first, if you agree with the fix there.

2. Reconcile's Claude-side walk doesn't share the watcher's ignore predicate.

handleEvent filters through shouldIgnoreFile, which catches Claude's atomic-write leftovers (<name>.tmp.<pid>.<hash>, trailing .tmp). The reconcile walk skips only dot-prefixed names, so those get copied into the mirror on every pass. They're gitignored, so it's clutter rather than corruption — but since this PR rewrites those walks anyway it may be cheap to fold in. Happy to send it as a separate PR if that's easier to review.

And one genuine question about the migration: when two directories collapse to the same key, how are same-named files other than MEMORY.md handled? The normal Reconcile path preserves the mirror copy as <name>.from-remote-<random> and keeps the local one. Does migration do the same, or does one side win outright?

@JeePeeTee

Copy link
Copy Markdown

One more piece of field evidence, since I ran a URL-derived scheme in production for a day across five projects before finding this PR.

A repo rename breaks the g- key. One of my projects was renamed on the remote (Foo.CrmLightFoo.CrmLite). The derived key changed, so the mirror directory had to be migrated and every machine re-pointed at the new key. This applies to your scheme identically, since g- hashes the URL — a rename produces a different hash and the project silently forks into two keys, with each machine landing on whichever key it computed most recently.

That makes me wonder about tier ordering rather than key format: the r- root-commit tier is immune to renames, but it's currently the fallback for repos with no remote. Is there a case for preferring the root commit when one is available, or for detecting "this key has no directory but a sibling key does" as a rename signal? Not obvious either way — root commits have their own problems (shallow clones, which you already guard, and repos that get re-inited) — but the rename case is one I actually hit rather than theorised.

Separately, a small question rather than a request: was a readable g- key considered, e.g. github.com-acme-app rather than a hash? The reason I ask is that recovery procedures involve the user running rm -rf ~/.claudesync/projects/<key>, and getting that right against an opaque hash is harder than against a name.

I assumed path length was the reason for hashing and measured it before asking. On Windows with a fairly long home directory, my longest real sync path is 140 characters with readable keys versus 128 with a g- plus 16 hex digits — both far under MAX_PATH of 260, and a deeply nested group namespace only adds about 16 more. So path length doesn't seem to force the choice, but there may well be a reason I'm not seeing, and it's your call — the scheme works either way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants