Accidentally pushing the monorepo to a subfolder's remote
git-splice has no Git remotes for its upstreams. This document explains
why: a remote that points to a subfolder's repository is one mistyped
command away from publishing the whole monorepo. It was written in
October 2026, while the predecessor of git-splice still kept one Git
remote per git subtree.
A subtree remote is an ordinary Git remote. Any push to it that isn't a
git subtree split sends the whole monorepo history there, including
folders that may never be meant to leave it. This affects everyone who uses
git subtree.
It's a slip of the fingers, but it's hard to undo: deleting the branch afterwards doesn't unpublish anything. GitHub keeps the commits reachable by SHA until support purges them, and anyone who fetched or forked in the meantime has a copy (an example with ~4000 commits pushed by mistake). So this shouldn't be left to the user being careful.
How the monorepo ends up on a subtree remote
Typing git push vendor/foo main by mistake is the obvious way, but not
the only one. These cases were checked with Git 2.55 on a monorepo with a
subtree vendor/a/:
-
push.autoSetupRemoteon a repo whose only remote is a subtree remote. A plaingit pushon a new branch picks the only remote, pushes the monorepo there and makes it the upstream:$ git config push.autoSetupRemote true$ git switch -c topic && git push* [new branch] topic -> topicbranch 'topic' set up to track 'vendor/a/topic'.A monorepo without
origin(e.g. local-only, publishing parts of itself) is exactly the setup where this happens. -
Once a branch's upstream is a subtree remote, every later plain
git push, the IDE's "Sync" button or LazyGit'sPgoes there. Besidespush.autoSetupRemoteandgit push -u,remote.pushDefaultandbranch.<name>.pushRemotecan point there too.git switch <name>also creates a branch tracking a subtree remote when only that remote has a branch<name>. -
LazyGit, for a branch without an upstream, suggests
originif it exists and otherwise the first remote (getSuggestedRemote). Withoutorigin, a subtree remote is one Enter away. Withpush.default=currentit doesn't ask at all. -
Shell history.
git push vendor/a mainandgit subtree push --prefix=vendor/a vendor/a mainboth match a reverse search forpush vendor/a. -
git push --all <remote>andgit push --mirror <remote>.
When the remote already has the branch, a plain push is usually rejected
as non-fast-forward. The dangerous cases are new branches and
--force.
Options that keep the remotes
These assume a wrapper around git subtree that names each remote like
its folder and pushes with git subtree push.
1. A pre-push hook that checks content
The wrapper installs a pre-push hook that refuses any pushed commit that
has a folder named like the remote it's pushed to: if remote names equal
folder paths, that's a monorepo commit. It works for plain, forced, by-SHA
and new-branch pushes, and leaves git subtree push and a pushed
git subtree split -b branch alone.
Limits:
- Hooks aren't cloned: every clone needs to install it.
git push --no-verifyskips it.- It competes with hook managers (husky, lefthook, pre-commit) for
pre-push/core.hooksPath. - A false alarm when a subtree has a folder named like its own path
(
lib/lib/).
2. A push URL that can't be pushed to
The well-known trick for a fetch-only remote is
git remote set-url --push <remote> no_push
(e.g.). The wrapper would
then push to the fetch URL explicitly:
git subtree push --prefix=<path> <fetch-url> <branch>.
--no-verifydoesn't get past it, and no hook is needed.- Pushing to a URL doesn't update
refs/remotes/<remote>/*, which a status command relies on, so the wrapper's push must update them itself (or fetch). - Plain
git subtree push <remote>is blocked too. Users would have to pass the URL. - The error is confusing:
'no_push' does not appear to be a git repository. - Like hooks, remote config isn't cloned. But remotes are added per clone anyway, and the wrapper could set it.
- Tools that read the push URL to find out which repository a remote
points to can break. The VS Code GitHub Actions extension stops showing
workflows when
pushurlis invalid (github/vscode-github-actions#298, open since March 2024).
3. A custom remote protocol that marks the remote
Git runs git-remote-<scheme> for a URL <scheme>::<address>
(gitremote-helpers). The
wrapper could ship a git-remote-guard helper and mark protected remotes
with it. There are two variants, both prototyped locally:
-
Push URL only:
git remote set-url --push vendor/a guard::<url>. The helper only refuses, with a clear message. The wrapper pushes to the fetch URL, as in option 2.$ git push vendor/a main:x!! 'vendor/a' is a subtree remote -- push it with the wrapper, not 'git push'fatal: remote helper 'guard' aborted sessionUnlike the hook,
--no-verifydoesn't skip it. -
Fetch and push URL:
url = guard::<url>. The helper declares theconnectcapability, runsgit-upload-packfor fetches, and refusesgit-receive-packunless the wrapper's push set an environment variable. The push then goes through the remote name, so tracking refs stay current. This works for local paths. For ssh, the helper has to runssh <host> git-upload-pack <path>itself. For https,connectdoesn't exist, so the helper would have to proxygit remote-httpsand watch forlist for-push/pushcommands. That's much more work and more fragile.
Concerns:
- The helper must be on the
PATHof every program that runs Git (GUIs, IDEs). Otherwise Git fails with'remote-guard' is not a git command. That fails closed, but it's confusing. - Tools that read remote URLs (
gh, LazyGit's "open in browser", IDEs) may not understandguard::…. The push-URL-only variant keeps the fetch URL readable. - Protocols other than the built-in ones fall under
protocol.allow's default policyuser. That's fine for interactive use, but Git setsGIT_PROTOCOL_FROM_USER=0for submodule operations, which then refuses them. - Prior art:
sethfowler/git-remote-subtree
wanted a remote helper that maps a subtree to a remote
(
subtree::<dir>::on::<branch>::from::<url>). It was never finished.
4. On the server
A server-side rule on the subtree's repository (e.g. GitHub push rulesets that restrict file paths) would hold no matter which client pushes. Still open: whether push rulesets are available for the repositories and plans this matters for (public repositories in particular), and whether a rule can be phrased without knowing the monorepo's folder names.
Prior art: exporting parts of a private monorepo
Large companies publish parts of private monorepos all the time, but not
with git subtree. Google uses Copybara
(PiperOrigin-RevId: trailers on ~31M public commits; Copybara's default
GitOrigin-RevId: on ~4.8M more), Meta uses
ShipIt (fbshipit-source-id:).
Their setups share a structure:
- The public repository is an export of the monorepo, generated by a bot with its own credentials. Developers' clones have no push access to it, often no remote for it, so there is nothing to mistype.
- An allowlist decides what leaves (Copybara's
origin_files = glob([...]), ShipIt's path mappings), and lines marked internal-only can be stripped. - Commit messages and authors can be rewritten on the way out.
- External PRs are imported, reviewed and merged in the monorepo, then
exported back. The PR is closed, not merged, e.g.
abseil-cpp#2087: the
commit on
masteris a new, single-parent commit with the contributor as author andCopybara-Serviceas committer.
Small setups are invisible from the outside: a correct git subtree split
leaves no trace in the public repository, so we can't tell how others with
a private monorepo avoid this, or how often it goes wrong.
What git-splice does instead
Like the large exporters, git-splice leaves nothing to mistype: Git
remotes should only point to repositories of the whole monorepo, never to
a subfolder's. Upstreams are fetched and pushed by URL, from the committed
.splice file, into private refs
(Refs instead of remotes):
- With no remote,
push.autoSetupRemote,remote.pushDefault, tracking branches,git switch <name>, LazyGit's suggestion,--alland--mirrorhave no subfolder repository to pick. git push vendor/a mainfails, becausevendor/a/is neither a remote nor a repository.- Nothing needs installing per clone,
--no-verifyhas nothing to skip, and no tool sees an invalid or unusual URL. git splice pushupdatesrefs/splices/<key>/<branch>itself, sostatusstays current without tracking refs.
What remains is typing an upstream's URL into a plain git push by hand,
which is hard to do by accident.
git splice push itself can publish too much on a smaller scale: it sends
the history the rebuild assigns to the folder. So the rebuild leaves out
what never belonged to the splice: everything before the splice's
mount, the commit where the
folder became this splice. A folder's history from before it got its
.splice stays in the monorepo
(example),
and so does another splice's, e.g. after two splices swapped paths
(example,
#73).