One repo, two keyboards: syncing work between Windows and a Mac
During the day I sit at a Windows desktop, in the evening at a MacBook. Same project, two keyboards. Moving the code between them isn't the problem — git has done that since day one. The problem is moving the work in progress in my head: what was decided yesterday, what the agent I work with remembers, which hooks and scripts a session has available. This article describes the setup I use for that: one private repo as the single source of truth — for code and for context — plus the automation that means I never have to think about syncing at all.
(Day-to-day git work with Claude Code — commits, PRs, rescue situations — is covered by the Git and GitHub cheat sheet for Claude Code. This is the layer above it: multiple machines, one project.)
The foundation: the remote as the single source of truth
First, what not to do: don't copy folders over USB, and don't put the repo inside a folder synced by iCloud, Dropbox, or OneDrive. A sync client doesn't see .git atomically — it transfers files one by one, mid-write, and lock files and half-transferred objects can corrupt a repository in ways that only surface a week later. Git should be the one doing the syncing — a sync client just gets in its way.
The rest is a two-command discipline:
git pull --ff-only # before I start working
# ... work, commits ...
git push # before I leave the machine--ff-only means a pull never silently manufactures a merge commit. If the machines have genuinely diverged — commits on both sides — the command fails and I deal with it consciously, instead of discovering it later in a weird history.
Which yields the one hard rule: never walk away from a machine with unpushed commits. A commit can absolutely be unfinished — that's what branches and wip: prefixes are for. But an unpushed commit is a commit that doesn't exist on the other machine.
The second layer: sync the context, not just the code
If this were only about code, the article would have ended a paragraph ago. But with Claude Code, the repo carries more than code:
CLAUDE.md— instructions for the agent: conventions, project structure, workflow rulesmemory/— the agent's persistent memory, plain markdown files: decisions, section specs, feedback, work in progress.claude/settings.json— shared hooks (more on those in a moment)tools/— the scripts those hooks run
When all of this is versioned together with the code, the second machine isn't a clean install. I sit down at the MacBook and the agent knows what we decided on Wednesday at the Windows desktop — because it reads it from the same files it wrote there.
One condition: a private repo. The agent's memory contains internal project notes, decisions and their reasoning — none of which belongs in a public repository.
And one exception: paths don't sync. C:\Users\... on Windows is not /Users/... on a Mac. Per-machine settings therefore live in a gitignored local config (.claude/settings.local.json), generated by an init script that runs once after cloning:
./claude-init.sh # writes local paths, installs the per-machine bitsThe split is then simple: everything shared between machines is versioned (instructions, memory, hooks, scripts); everything per-machine is gitignored (local paths, builds, node_modules).
Automating the ritual
The "pull before work, push after" ritual has one weakness: it relies on me. And after a weekend, I won't remember. Claude Code can run commands on session events — and two of those events map exactly onto the two ends of the ritual:
{
"hooks": {
"SessionStart": [
{
"matcher": "startup|resume",
"hooks": [{ "type": "command", "command": "bash tools/sync-pull.sh" }]
}
],
"Stop": [
{ "hooks": [{ "type": "command", "command": "bash tools/unpushed-check.sh" }] }
]
}
}SessionStart pulls for me. The core of the script:
git fetch --prune origin
git merge --ff-only '@{u}' # silent when there's nothing to do; shouts only on real divergenceStop — the end of an agent response — counts unpushed commits and nags:
n=$(git log --oneline @{u}.. 2>/dev/null | wc -l | tr -d ' ')
[ "$n" != "0" ] && echo "📌 You have $n unpushed commits — git push so the other machine sees them."The effect in practice: I open a session on the MacBook and the repo is current before I've typed the first prompt. And since the settings.json with the hooks is itself part of the repo, every machine behaves this way automatically — the automation syncs through the same channel as everything else.
Mac ↔ Windows traps
The things you don't learn from documentation — only from being bitten.
Line endings. Windows git with core.autocrlf=true checks out text files with CRLF — including bash scripts. A trivial script survives that; a script with conditionals and functions dies on an invisible \r at the end of a line. And since a script committed from Windows will also run via a hook on the Mac, it's a trap with a delayed trigger. The once-and-for-all fix, in the repo:
# .gitattributes
*.sh text eol=lf
Git identity on a new machine. My first commit on a fresh MacBook ended with "Author identity unknown". Nothing dramatic, but it belongs in the new-machine checklist, not in mid-task debugging:
git config --global user.name "Firstname Lastname"
git config --global user.email "mail@example.com"A branch deleted on the remote. A feature branch merged and deleted from the other machine: on the first machine I'm still standing on it, and git pull fails with "no such ref was fetched". It looks like a conflict, but it's just a dead reference:
git fetch --prune # clean up references to deleted remote branches
git checkout main && git pull --ff-onlyThat's why the sync script above distinguishes three states: a live upstream (pull it), a branch fully contained in origin/main (say "switch to main"), and a genuine divergence (the only case where it should shout).
Tools missing on the other platform. lsof is absent from git-bash on Windows; netstat behaves differently on a Mac. Scripts shared across platforms need a guard — command -v lsof >/dev/null || exit 0 — so that a missing tool means silent degradation, not a broken hook.
Checklist: a new machine in ten minutes
gh repo clone <user>/<repo> # 1. outside iCloud/OneDrive/Dropbox folders!
cd <repo>
git config --global user.name "..." # 2. identity, if the machine is fresh
git config --global user.email "..."
./claude-init.sh # 3. local config, per-machine installs
npm install # 4. project dependenciesAnd a fifth step worth not skipping: a verification round-trip. Commit from the new machine, push, pull on the old one — and the same in reverse. Once both directions pass, hooks included, the machine is a full citizen. Mine caught both the missing identity and the CRLF in scripts.
Conclusion
I'm not syncing files, I'm syncing a workplace. The measure of success is simple — I sit down at the other machine, open a session, and the first prompt can be "let's continue". No explaining where we left off. Git carries the code, the repo carries the context, and the hooks carry the discipline I could never rely on myself to keep.