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
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.

2 participants