feat(memsync): key memory sync by project fingerprint, not local path - #5
feat(memsync): key memory sync by project fingerprint, not local path#5mja00 wants to merge 1 commit into
Conversation
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.
There was a problem hiding this comment.
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/projectfor resolving Claude project real paths from transcripts, deriving fingerprint keys, and persisting a per-PClocalHash → keyindex. - 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.
| 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) | ||
| } |
| 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++ | ||
| } |
| 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 |
| 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 |
|
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 Two things my implementation surfaced that interact with this one. 1. The merge driver silently drops repeated H2 headings — #7.
This matters here specifically: your migration union-merges collapsing directories through 2.
And one genuine question about the migration: when two directories collapse to the same key, how are same-named files other than |
|
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 That makes me wonder about tier ordering rather than key format: the Separately, a small question rather than a request: was a readable 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 |
Problem
claude-memsynckeyed 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/fooon one machine and/home/me/repos/fooon 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>— normalizedoriginremote URL (all ofgit@github.com:Acme/app.git,https://github.com/acme/app,…/app.gitcollapse to one key)r-<hash>— git root-commit SHA, when the repo has no remote (with a shallow-clone guard)The Claude side stays keyed by its local dir name; a new
internal/projectpackage resolves each project's real path from thecwdClaude 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-memmergedriver. 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
rundaemon 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 cleang-key with a unionedMEMORY.md; a legacy path-keyed remote dir migrates and merges on upgrade