- Why not just sync the folder?
- Why not use existing memory services?
- How syncing works?
- What actually gets synced?
- The identity problem
- Merge driver
- An ordering mistake
- Unattended syncs
- The core idea
For years, we obsessed over the tools we used to write software. Tabs or spaces. Single Quote vs Double Quote. Vim or Emacs. IntelliJ or VS Code. We tuned our editors, collected plugins, and argued over keybindings because productivity came from making the machine adapt to us.
That is changing. The tool is no longer just something we configure. It is something we teach. AI coding assistants learn our preferences, pick up project conventions, remember architectural decisions, and gradually become better teammates. Like every good teammate, they become more valuable with shared history. Memory is no longer just a feature. It is becoming part of the development environment itself.
Claude Code’s memory system is genuinely good once it accumulates. It writes files under ~/.claude/projects/<hash>/memory/, one per topic, and maintains an index that’s loaded into every session. Over time, it builds a surprisingly rich profile of how you like to work, the conventions your project follows, and the codebase’s sharp edges. The catch is that this memory lives on a single machine. Switch to another computer, and Claude starts learning from scratch. I move between a Mac, a WSL setup, and an Amazon Workspace depending on where I am working. Each environment feels like a fresh start for Claude, even though the projects and my preferences haven’t changed. Over time, I realized I wasn’t working with one Claude across three machines. I was working with three different Claudes, each remembering a different version of me and my projects.
Why not just sync the folder?
The obvious solution is to point ~/.claude at a shared folder using Google Drive, OneDrive, iCloud, or Dropbox. That works well for documents, but Claude’s memory isn’t just a collection of files. Multiple machines can update the same memory concurrently, and generic file sync tools have no understanding of those semantics. When conflicts happen, they typically overwrite one version, create duplicate files, or leave you to resolve them manually. What I wanted wasn’t file synchronization, but memory reconciliation.
Why not use existing memory services?
I also looked at persistent memory services like Supermemory. They solve a much broader problem by centralizing, indexing, and semantically retrieving knowledge across applications. My requirements were much narrower. I only wanted to synchronize Claude Code’s native memory, keep everything under my control, and avoid sending project context to a third party. More importantly, Claude’s memory is already structured exactly the way Claude expects to consume it. Rather than introducing another system to manage memory, I wanted to preserve that format and simply make it available across all my machines.
That left me looking for something that could reconcile concurrent changes instead of pretending they never happened. Git has spent decades solving exactly that problem. It already knows how to detect conflicting edits, preserve history, and merge changes from multiple writers.
So I built claude-sync, a small Python tool that treats Claude’s memory as data to be reconciled through Git rather than a directory to be mirrored wholesale. It syncs on a schedule via cron instead of running as a background daemon, because a few minutes of staleness is a perfectly reasonable tradeoff for a simpler and more reliable design.
How syncing works?
Every sync follows the same high level flow. First, claude-sync collects local changes from Claude’s memory and commits them to a private Git repository. It then reconciles those changes with the remote and finally applies the merged result back into every local Claude installation. That makes the Git repository the transport layer for memory, while ~/.claude remains Claude Code’s source of truth.
The mechanics are a little more nuanced than a simple git push and git pull. Projects need a stable identity across machines, concurrent edits need to be reconciled rather than overwritten, and deletions need to propagate without mistaking a worktree that has never seen a file for one that intentionally removed it. Each of those problems turned out to have a surprisingly different solution.
What actually gets synced?
Three things, deliberately narrow:
~/.claude/CLAUDE.md, the global instructions. ~/.claude/skills/, since a skill I write on one machine is useless if it does not exist on the others. And ~/.claude/projects/<hash>/memory/, per repository, which is the part that matters most.
Notably absent: the rest of settings.json. I do sync two keys out of it, enabledPlugins and extraKnownMarketplaces, because I want the same plugins active everywhere. Everything else in that file, hook wiring, reasoning effort, model overrides, stays local. Those are preferences about how this machine runs Claude, not things Claude has learned about me or the project.
The synced data lives in its own private Git repository, completely separate from the tool itself. That lets claude-sync remain open source while the data it carries, including working notes about production systems, stays private. You choose where the repository lives and who can access it. The tool doesn’t depend on any third-party service and works with any Git host.
The identity problem
The first challenge was figuring out how to recognize the same project across different machines. Claude stores each project’s memory under a directory named after a hash of its absolute path. That works locally, but the hash becomes meaningless once the same repository is cloned elsewhere. My Mac, WSL machine, and Amazon Workspace all produced different hashes for the same project.
The obvious place to recover the project’s identity was from Claude’s own session history. Every session transcript records the working directory (cwd), so I used its basename as the shared identifier.
def resolve_project_basename(project_dir: Path) -> Optional[str]:
for jsonl_path in sorted(project_dir.glob("*.jsonl")):
with jsonl_path.open() as f:
for line in f:
record = json.loads(line)
cwd = record.get("cwd")
if cwd:
return Path(cwd).name
return NoneThe approach had a hidden assumption: that the session transcript would always be available. It doesn’t. Claude Code eventually rotates old .jsonl files out, and once the last transcript for a project disappeared, so did the only reliable mapping from an opaque hash to a human-readable project name. The memory was still there, but the sync tool no longer knew where it belonged in the shared Git repository, so it skipped that project entirely.
The fix was to cache the mapping the first time it was successfully resolved and reuse it even after the original transcript was gone.
def resolve_project_basename_cached(project_dir: Path, name_cache: dict) -> Optional[str]:
name = resolve_project_basename(project_dir)
if name is not None:
name_cache[project_dir.name] = name
return name
return name_cache.get(project_dir.name)With the cache in place, I thought the identity problem was solved. But Git worktrees exposed another flaw.
A worktree lives in its own directory, myrepo-feature-x, for example, sitting alongside the main checkout. Its cwd naturally resolves to that directory’s basename, so the sync tool happily treated it as an entirely different project. Every worktree started with a fresh, empty memory, disconnected from everything the main checkout had already learned.
The real fix wasn’t to special-case worktrees. It was to stop trusting directory names as identity altogether and ask Git what the project actually is: its origin remote. Every worktree shares the same remotes as its parent repository, so using the normalized remote URL as the project key automatically maps every worktree back to the same shared memory.
def resolve_project_key(cwd: Path) -> str:
remote = get_git_remote_url(cwd)
if remote:
return normalize_remote_url(remote)
return cwd.namenormalize_remote_url() is what makes this approach reliable. git@github.com:org/repo.git, https://github.com/org/repo.git, and ssh://git@github.com/org/repo.git are three different strings pointing to the same repository, and they all normalize to the same key: github.com/org/repo. The directory name only comes back into play as a last resort, when there is no configured remote or the original working directory has disappeared.
Looking back, every bug came from the same flawed assumption. I kept treating incidental details, a hashed path, a session transcript that could disappear, a directory name, as if they were identity. The only stable identity was the one Git already knew: the repository itself.
Merge driver
The index file, MEMORY.md, is the one most likely to collide. Two machines, offline from each other, each learn something new and append a line. Git’s default merge would produce conflict markers in the middle of what is supposed to be a clean bullet list, and I did not want to hand-resolve that every week.
claude-sync registers a custom merge driver instead, wired up through .gitattributes in the data repo:
def union_merge(local_text: str, remote_text: str) -> str:
result = []
seen = set()
for line in local_text.splitlines():
if line not in seen:
result.append(line)
seen.add(line)
for line in remote_text.splitlines():
if line not in seen:
result.append(line)
seen.add(line)
return ("\n".join(result) + "\n") if result else ""It is not a real three-way merge, it does not track deletions, and I’m fine with that. MEMORY.md is append-mostly by nature: entries get added far more often than removed, and a union of both sides’ lines is almost always the correct outcome. Optimizing for the common case here beat building something more “correct” that I would need to debug later.
An ordering mistake
This bug was entirely self-inflicted. My first implementation pulled remote changes and applied them before collecting local updates. That meant a memory file Claude had just written could be overwritten before it was ever captured.
The fix was simply to reverse the order: collect and commit local changes first, reconcile with the remote, and only then apply the merged state back into ~/.claude. Nothing novel, just the way git works.
Unattended syncs
The last issue wasn’t with the sync logic at all. Everything worked perfectly from a terminal but failed under cron. The culprit was authentication. Cron couldn’t access my interactive SSH agent or the macOS keychain.
The simplest fix was a dedicated SSH deploy key with access only to the private memory repository, passed through GIT_SSH_COMMAND. Once that was in place, unattended sync became completely reliable.
The core idea
None of this is exotic. It is file hashing, a scheduled git pull/git push, a merge driver, and a deploy key. The core idea is to stop thinking of ~/.claude as a folder to keep identical across machines and start thinking of memory specifically as the one thing worth reconciling, while everything else about a machine’s Claude setup stays exactly as local as it should be.
Source code has always traveled with us. Increasingly, the context around that code matters just as much. As AI assistants become a more permanent part of the development workflow, I think that context should be just as portable as the repositories they help us build.