How to Sync One Git Repo Across Two Macs Without Breaking It
File sync tools destroy a git working tree. I now sync one repo across two Macs with a private Forgejo over Tailscale, a separate bare mirror repo per machine, and a background job that snapshots work in progress every 45 seconds. The working tree on both machines stays untouched.
This post explains why the obvious approaches fail, and the design that finally held.
Part of the series on running real infrastructure from a home server. Earlier posts covered the self-hosted stack and hardening the box that runs it.
1. The problem
I work on one large codebase from two machines. A desktop at home and a laptop everywhere else. I want to close the laptop mid-edit, open the desktop, and keep going.
Git already solves this, if you commit. The problem is the half hour of uncommitted work: a staged hunk, a scratch file, a debug print you are not ready to name.
2. Why file sync fails
The first instinct is to put the repo inside a sync folder. Do not do this.
A git repository is not a folder of files. It is a folder of files plus an index, a set of refs, and a lock discipline. A sync client copies those pieces in whatever order it feels like, from two machines at once, with no idea that .git/index must agree with the objects around it.
The result is not a merge conflict. It is a corrupted index, a detached ref, or a stash that vanished. You lose an afternoon and you learn nothing.
3. Why not just push to the remote
The next instinct is to push work in progress to the shared remote. That fails for a social reason rather than a technical one.
- A push every 45 seconds fills the shared history with noise.
- Teammates see half-finished branches they cannot interpret.
- Force pushes become routine, which is how someone eventually loses a commit.
Sync traffic and collaboration traffic are different workloads. They should not share a namespace.
4. The design
Three pieces, each with one job.
A private hub
I run Forgejo in Docker on the home server, on a port reachable only over Tailscale. I picked it over GitLab purely on footprint. GitLab wants several gigabytes of RAM before it does anything useful. Forgejo runs on SQLite and idles at a few hundred megabytes, which matters when the same box also runs everything else I own.
A separate mirror repo
Each machine has its own bare repo, kept well away from the working tree:
~/.local/share/work-sync.git # bare, never checked out
~/Code/work # the working tree, untouched by sync
Sync commits are written into the bare repo under a namespaced ref, one namespace per machine:
refs/wip/laptop/main
refs/wip/desktop/main
Nothing under refs/heads/ is ever touched by the sync job, so a normal git push still behaves exactly as it always did.
A snapshot job
A launchd agent fires every 45 seconds. It builds a commit from the current working tree without touching the real index, using a temporary index file:
export GIT_INDEX_FILE=$(mktemp)
git add -A
TREE=$(git write-tree)
COMMIT=$(git commit-tree "$TREE" -p "$(git rev-parse HEAD)" -m "wip snapshot")
git update-ref "refs/wip/$MACHINE/$BRANCH" "$COMMIT"
rm -f "$GIT_INDEX_FILE"
This is the important part. GIT_INDEX_FILE redirects staging to a throwaway file, so the snapshot never disturbs what you have staged. Your git status looks identical before and after the job runs.
5. Picking work up on the other machine
The other machine fetches the peer namespace and you restore whatever you want:
# see what the laptop was doing
git log --oneline refs/wip/laptop/main -5
# restore into the working tree without moving HEAD
git checkout refs/wip/laptop/main -- .
Nothing is automatic here, and that is on purpose. An automatic restore would overwrite whatever you were editing. The job guarantees the work exists somewhere. Choosing to apply it stays a human decision.
6. What it costs
- Roughly 1,900 snapshot commits a day, all in a bare repo nobody reads.
- A periodic prune, since those refs are never garbage collected on their own.
- One more service to keep alive on the home server.
Against that: I have not lost uncommitted work since, and the shared remote history stayed clean enough that nobody on the team knows this exists.
The takeaway
Do not sync a git repo as files. Sync it as git, into a namespace nobody else reads, from a job that writes through a temporary index. The working tree is the one thing that must never be touched by automation.