Skip to content

smoothbricks/git-auto-remote

Repository files navigation

git-auto-remote

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:

  1. Auto-routing — on branch checkout, detect which remote's root commit(s) the branch descends from and set branch.<name>.pushRemote accordingly. Pre-push hook refuses cross-history pushes.
  2. 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 via git am --empty=drop.

Install

bunx git-auto-remote setup

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

Auto-routing (always on after setup)

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 = private

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

Mirror sync

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

Per-commit classification

Each changed path in a mirror commit is sorted into exactly ONE bucket, in priority order:

  1. matches excludePathsdropped entirely (never in HEAD, never in worktree, not reported)
  2. matches reviewPathsreview (overlaid to the worktree unstaged at pause time)
  3. matches regeneratePathsregenerate (dropped from HEAD; regenerateCommand runs after apply and the output is amended into HEAD)
  4. matches syncPathsincluded (applied to HEAD by git am, author + author-date preserved)
  5. 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.

Partial commit review (interactive)

When a partial is encountered, the tool:

  1. Applies the included subset to HEAD via git am (author + author-date preserved).
  2. Overlays the review subset into the worktree as unstaged modifications (via git apply --3way).
  3. 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>] — shows git diff HEAD <sourceSha> scoped to the paths THIS source commit touched in review/regenerate/outside buckets (not the full sync domain). --raw bypasses the filter for a full unfiltered diff. Forwards other args (--stat, --name-only, etc.) to git 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 to git 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 included patch wouldn't apply cleanly; resolve conflicts in the normal git am way, then mirror continue
  • review-pauseincluded landed (with regenerated output if regenerateCommand ran); review awaits in the worktree
  • pure-review-pause — the source touched ONLY review paths, no HEAD commit was made; staging + mirror continue creates a fresh commit preserving the source's author/email/date/message

Regenerate (derived files like bun.lock)

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

Non-interactive mode (CI)

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

Scripted partial resolution (--on-partial)

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 public

Or configure it permanently:

git config auto-remote.public.partialHandler /path/to/handler.sh

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

Tracking-ref durability

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.

SECURITY: cross-direction push refspecs leak source content

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

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

Commands

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

Bypassing auto-routing on push

If you really need to push across histories, bypass the pre-push safety net with git push --no-verify.

Requirements

Git ≥ 2.34 (for git am --empty=drop). setup checks the version and refuses if too old.

About

Multiple remotes with disjoint histories? Automatically set new branch remote based on parent ancestry.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors