Git tooling for repositories that track multiple upstreams with disjoint histories — for example a public/private fork split, or a monorepo that mirrors a subset of itself to an open-source repo.
Two features in one CLI, both invoked via git hooks installed by git-auto-remote setup:
- Auto-routing — on branch checkout, detect which remote's root commit(s) the branch descends from and set
branch.<name>.pushRemoteaccordingly. Pre-push hook refuses cross-history pushes. - Mirror sync — on
git pull, cherry-pick new commits from a configured mirror remote (restricted to an allowlist of paths). Empty commits and round-tripped changes drop automatically viagit am --empty=drop.
bunx git-auto-remote setupHooks are idempotent and chainable: if another tool has already installed a hook, git-auto-remote appends its block marked with # >>> git-auto-remote <hook> >>> sentinels. Other tools can detect our presence by grepping for the string git-auto-remote <hook>.
Given a repo with multiple remotes that have disjoint root commits (e.g. public and private), checking out a new branch sets the appropriate pushRemote:
git switch public
git checkout -b feat/open-x # -> pushRemote = public
git switch private
git checkout -b feat/closed-y # -> pushRemote = privatePre-push verifies the push belongs to the target remote's history; cross-fork pushes are rejected (bypass with --no-verify if deliberate).
Decision rules:
| Situation | Action |
|---|---|
| No remotes configured | No-op |
| All remotes share the same root set | Inherit parent branch's pushRemote |
| Remotes have disjoint roots, branch matches exactly one | Route to that remote |
| Branch matches zero remotes | No-op (silent) |
| Branch matches two or more remotes | Refuse to auto-configure, warn |
Any manually configured branch.<name>.pushRemote is always respected.
Opt-in per remote via git config. A remote becomes a "mirror" by setting syncPaths:
# In your private clone, treat the 'public' remote as a mirror of packages/:
git config auto-remote.public.syncPaths "packages"
git config auto-remote.public.syncTargetBranch "private"
git-auto-remote mirror bootstrap public <sha-whose-tree-matches-current-packages/>| Config key | Meaning | Default |
|---|---|---|
auto-remote.<name>.syncPaths |
Space-separated pathspecs to include when cherry-picking. Presence (either here or via syncPathsFile) makes the remote a mirror. |
required |
auto-remote.<name>.syncPathsFile |
Repo-relative path to a newline-separated file of sync paths. Supports # comments. Contents union with syncPaths. |
(none) |
auto-remote.<name>.excludePaths / .excludePathsFile |
Pathspecs that are never synced, even if under syncPaths. Useful for repo-local-only files that live in a shared directory. Dropped silently from the commit; no pause. |
(none) |
auto-remote.<name>.reviewPaths / .reviewPathsFile |
Pathspecs whose changes are brought into the worktree as unstaged at pause time so the user can git add -p / git restore / git commit --amend --no-edit. Orthogonal to syncPaths — a path may be a reviewPath without being a syncPath. Author + author-date are preserved across amends. |
(none) |
auto-remote.<name>.regeneratePaths / .regeneratePathsFile |
Pathspecs for derived files (bun.lock, generated tsconfig references, etc.) that are dropped from incoming patches and regenerated locally. When a source commit touches any of these, regenerateCommand runs after apply and its output is amended into HEAD. |
(none) |
auto-remote.<name>.regenerateCommand |
Shell command (run via sh -c) that produces the regenerate paths from current sources. For nix/devenv repos, wrap with the project shell so tool versions match: devenv shell -c 'bun i'. |
(none) |
auto-remote.<name>.syncBranch |
Remote branch to pull from. | <remote>/HEAD, else main |
auto-remote.<name>.syncTargetBranch |
Local branch that receives replayed commits. | <remote> |
auto-remote.<name>.partialHandler |
Path to a script that resolves "partial" commits. | (none) |
auto-remote.<name>.pushSyncRef |
Push the tracking ref to the remote after each advance (for CI durability). | true |
Each changed path in a mirror commit is sorted into exactly ONE bucket, in priority order:
- matches
excludePaths→ dropped entirely (never in HEAD, never in worktree, not reported) - matches
reviewPaths→ review (overlaid to the worktree unstaged at pause time) - matches
regeneratePaths→ regenerate (dropped from HEAD;regenerateCommandruns after apply and the output is amended into HEAD) - matches
syncPaths→ included (applied to HEAD bygit am, author + author-date preserved) - none of the above → outside (silently dropped, reported in the pause message so you notice)
All three of reviewPaths, regeneratePaths, syncPaths are first-class and independent. Canonical use of regeneratePaths: bun.lock — when upstream bumps it, we drop their version from the patch and run bun i locally to produce our own (matches our package state, avoids binary-file merge pain). Canonical use of reviewPaths: tooling/workspace.gitconfig — sensitive shared config that deserves a human glance before landing.
| Classification | When | Action |
|---|---|---|
| Out-of-scope | included, review, and regenerate all empty |
commit skipped, tracking ref advances |
| Clean | review and outside both empty (may have included and/or regenerate) |
batched git am; if regenerate non-empty, command runs + amends last commit |
| Partial | review or outside non-empty |
breaks the batch; paused for review |
A batched run of clean + out-of-scope commits is applied via a single git format-patch ... | git am --empty=drop --3way.
When a partial is encountered, the tool:
- Applies the
includedsubset to HEAD viagit am(author + author-date preserved). - Overlays the
reviewsubset into the worktree as unstaged modifications (viagit apply --3way). - Pauses for review.
[mirror public] Partial: abc12345 feat: shared lib + private glue
Review (in worktree, unstaged): tooling/workspace.gitconfig
Regenerate (auto-produced): bun.lock
Outside sync scope (dropped): privpkgs/foo.ts
Source: abc12345 feat: shared lib + private glue
Review: git diff # see unstaged review content
Stage: git add -p # pick hunks into the commit
Discard: git restore <paths> # drop review hunks
Diff: git-auto-remote mirror diff # what source changed that didn't land in HEAD
Show: git-auto-remote mirror source # full 'git show' of the source commit
Continue: git-auto-remote mirror continue
Skip: git-auto-remote mirror skip
Lines with empty lists are omitted. All SHAs use an 8-char short form throughout the tool's output.
During a pause the remote name is omitted from the displayed commands (only one pause can be active at a time; the tool resolves the remote from the review-pending marker). The CLI still accepts an explicit <remote> positional for scripting.
mirror diff [<remote>]— showsgit diff HEAD <sourceSha>scoped to the paths THIS source commit touched in review/regenerate/outside buckets (not the full sync domain).--rawbypasses the filter for a full unfiltered diff. Forwards other args (--stat,--name-only, etc.) togit diff.mirror source [<remote>]—git show <sourceSha>for the paused source commit. Convenience so you don't have to copy the SHA out of the pause header. Forwards other args togit show.mirror continue [<remote>]— if you staged any review hunks, amends HEAD with them (author + author-date preserved by--amend --no-edit); any leftover unstaged review content is discarded. Resumes the sync from there.mirror skip [<remote>]— discards the worktree overlay and resets HEAD past the partial commit. Tracking ref already points past the source SHA, so the next pull resumes past it too.
Both commands are unified across three pause sub-cases:
- am-conflict — the
includedpatch wouldn't apply cleanly; resolve conflicts in the normalgit amway, thenmirror continue - review-pause —
includedlanded (with regenerated output ifregenerateCommandran);reviewawaits in the worktree - pure-review-pause — the source touched ONLY review paths, no HEAD commit was made; staging +
mirror continuecreates a fresh commit preserving the source's author/email/date/message
For files that are deterministic from other sources (lockfiles, generated type references, etc.), regeneratePaths + regenerateCommand drops the upstream version from the patch and produces a local version. Trigger: a source commit touched one of regeneratePaths. Behavior: after the patch applies, the command runs (via sh -c); any changes it produces inside regeneratePaths are staged and amended into HEAD with --amend --no-edit (author + author-date preserved). Changes outside regeneratePaths are NOT amended and surface as dirty worktree (the tool treats them as a config error but completes the apply).
Example config for a bun-based repo:
[auto-remote "public"]
regeneratePaths = bun.lock
regenerateCommand = devenv shell -c 'bun i --frozen-lockfile=false'The devenv shell wrapper ensures bun resolves to the project-pinned version regardless of the PATH git inherited (GUI clients, post-applypatch during a bare-terminal git am --continue, CI without the dev env activated).
mirror pull --non-interactive never pauses for a human:
| Situation | Behavior | Exit code |
|---|---|---|
| Up to date | no-op | 0 |
| Only clean commits | all applied | 0 |
| Partial encountered, no handler | nothing applied, ref unchanged (so CI surfaces it again) | 2 |
| Conflict in a clean commit | git am --abort, exit |
2 |
Provide a command that decides what to do with partials. Useful for LLM-in-CI setups:
git-auto-remote mirror pull --non-interactive --on-partial ./ci/llm-amend.sh publicOr configure it permanently:
git config auto-remote.public.partialHandler /path/to/handler.shThe handler is invoked with the partial's subset already applied to HEAD. It may amend HEAD, leave it as-is, or signal skip/punt via exit code:
| Exit code | Meaning | Tool response |
|---|---|---|
| 0 | Resolved; HEAD is the answer | Continue sync |
| 2 | Skip this commit | git reset --hard HEAD~1; ref advances past; continue |
| any other | Punt (can't decide) | Interactive: pause for human. Non-interactive: rewind HEAD + ref, exit 2 |
Handler receives the following env vars:
MIRROR_REMOTE public
MIRROR_SOURCE_SHA abc1234...
MIRROR_SOURCE_SUBJECT feat: shared lib + private glue
MIRROR_INCLUDED_PATHS newline-separated (already in HEAD)
MIRROR_REVIEW_PATHS newline-separated (in worktree, unstaged)
MIRROR_OUTSIDE_PATHS newline-separated (dropped, not in HEAD or worktree)
And positional args: <remote> <source-sha>. Full source diff available via git show $MIRROR_SOURCE_SHA. The handler is invoked with HEAD at the included subset (or unchanged for pure-review-only sources) and review paths as unstaged worktree changes, so typical operations are git add -p && git commit --amend --no-edit or git restore.
The last-synced position per mirror is stored under refs/git-auto-remote/mirror/<remote>/last-synced. With pushSyncRef=true (default), the ref is pushed to the remote after each advance — so CI clones (which start with a fresh .git/) pick up the state automatically.
The tracking ref refs/git-auto-remote/mirror/<remote>/last-synced stores a SHA that originated on <remote>. The commit and its full ancestry live in the local object DB after mirror pull — that's normal.
Pushing this ref to a DIFFERENT remote, however, transfers the ENTIRE object closure (commit + every reachable ancestor + their trees + their blobs) to that other remote. If <remote> contains private content and the destination is a public-facing mirror, the destination's object DB now contains the full private history, accessible to anyone via:
git fetch <public-url> 'refs/git-auto-remote/*:refs/git-auto-remote/*'
git checkout refs/git-auto-remote/mirror/<remote>/last-syncedRule of thumb: configure each remote's push refspec to push only its OWN tracking ref, not other remotes' tracking refs:
# SAFE: each remote stores only refs pointing at its own commits
git config --add remote.public-repo.push 'refs/git-auto-remote/mirror/public-repo/*:refs/git-auto-remote/mirror/public-repo/*'
git config --add remote.private-repo.push 'refs/git-auto-remote/mirror/private-repo/*:refs/git-auto-remote/mirror/private-repo/*'
# (a private remote MAY safely also carry other remotes' refs - private content stays
# private; public-content-on-private is a no-op since public is already public.)
# UNSAFE on a public remote (leaks private commit ancestry):
git config --add remote.public-repo.push 'refs/git-auto-remote/mirror/*:refs/git-auto-remote/mirror/*'Pre-v0.6.1 the tool itself auto-added a force-fetch refspec +refs/git-auto-remote/mirror/*:refs/git-auto-remote/mirror/* to mirror remotes; that was removed in v0.6.1 because it silently clobbered local tracking state on every fetch. Push refspecs are the user's responsibility.
git-auto-remote setup [--quiet] Install hooks
git-auto-remote status Auto-routing status
git-auto-remote detect [ref] Ancestry analysis
git-auto-remote uninstall Remove hook blocks
git-auto-remote mirror list Show configured mirrors
git-auto-remote mirror status [<remote>] [--remotes] Show sync state.
With --remotes, also runs ls-remote
on each mirror remote and compares
tracking refs (drift diagnostic).
git-auto-remote mirror bootstrap <remote> <sha> [--force]
git-auto-remote mirror pull [<remote>] [--non-interactive] [--on-partial <cmd>]
git-auto-remote mirror continue [<remote>] # resolve any pause sub-case
git-auto-remote mirror skip [<remote>] # skip the paused commit
git-auto-remote mirror diff [<remote>] [--raw] [git-diff-args...]
# during a pause: show source-vs-HEAD diff
# scoped to paths THIS commit touched in
# review/regenerate/outside buckets
git-auto-remote mirror source [<remote>] [git-show-args...]
# during a pause: `git show <sourceSha>`
If you really need to push across histories, bypass the pre-push safety net with git push --no-verify.
Git ≥ 2.34 (for git am --empty=drop). setup checks the version and refuses if too old.