From 6a736f109d3c8f1ce1439018923274e25bfc0c1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Robert=20Sch=C3=A4fer?= Date: Tue, 13 Oct 2026 20:38:50 +0200 Subject: [PATCH] docs(readme): retire in favour of git-splice The design of git-subtrees is flawed at its root, in `git subtree` itself: a subtree remote is an ordinary remote that the whole monorepo can be pushed to by mistake (#50), `git subtree split` gets slower as the monorepo's history grows (#30), and subtree merges don't fit a linear history. git-splice keeps the goal without these problems, so point users there. The old documentation stays available at v0.1.2. --- README.md | 245 +++++++++++++----------------------------------------- 1 file changed, 58 insertions(+), 187 deletions(-) diff --git a/README.md b/README.md index e5f29d6..9ed2ccf 100644 --- a/README.md +++ b/README.md @@ -1,192 +1,63 @@ # git-subtrees -Manage multiple git subtrees in a monorepo: keep each folder in sync with -its own repository, to publish a package, mirror a library, or keep a -vendored copy up to date. You don't need a config file. - -## The contract - -A subtree is any git remote whose name is the path of a folder in your -repo: - - git remote add vendor/foo # vendor/foo is now a subtree - -That's all the configuration there is. `git subtrees status` lists what it -found. Everything follows from two rules: - -1. **The remote name is the folder path.** If you move the folder, also run - `git remote rename `. -2. **Your local branch name is the remote branch name.** On `main`, every - subtree syncs with its remote's `main`. On `feature-x`, every subtree - syncs with its remote's `feature-x`. - -Subtrees can't be nested: next to a subtree `vendor/pkg`, there can't be a -remote `vendor/pkg/extra`, even one without a folder. Git 2.51+ already -refuses such remote names; with older versions, every command stops with -an error and tells you how to fix it -([why](test/scenarios/nested-subtrees/README.md)). - -## How it compares - -`git-subtrees` is a layer on `git subtree`, not a replacement: it fills in -`--prefix`, remote and branch from [the contract](#the-contract), so one -command handles every subtree. Wherever plain Git does the job, it calls -Git, hence Bash. - -Other tools keep a config of their own, and most don't follow your branch: - -| Tool | Why it doesn't fit | -| --- | --- | -| `git submodule` | `git switch` doesn't switch the submodules' branches. `--recurse-submodules` only checks out a pinned commit. | -| [git-subrepo](https://github.com/ingydotnet/git-subrepo) | A `.gitrepo` file per folder. Switching branches in the monorepo doesn't switch the branch a subrepo syncs with. | -| [splitsh-lite](https://github.com/splitsh/lite) | One way only: it publishes read-only mirrors. | -| [Josh](https://github.com/josh-project/josh) | The opposite model: the monorepo is authoritative, and people work in filtered views of it. | -| [Copybara](https://github.com/google/copybara) | One repository is the source of truth. Syncing back needs a second, reverse workflow. | - -## Example - -[A walkthrough of every command](walkthrough/README.md) shows what each one -prints, on a throwaway monorepo whose subtree remotes live on the same -machine. To follow along, run `just walkthrough` in a clone of this -repository. - -## Commands - -Each command applies the familiar Git operation to every subtree at once, -using your current branch name as the branch on each remote. You can also -pass paths to limit it to some subtrees. Run `git subtrees --help` -for options. - -| Subcommand | `git` | `git subtrees` | -| --- | --- | --- | -| `status` | Summarizes the **current worktree and branch**. | Summarizes the [sync state](#sync-states) of **every selected subtree and its remote branch**. | -| `diff` | Shows worktree changes that could be **committed**. | Shows committed subtree changes that would be **pushed**. | -| `fetch` | Updates remote-tracking refs from **one remote**. | Updates remote-tracking refs from **every selected subtree remote**, calling out when the matching branch moved. | -| `merge` | Joins **already-fetched** history into the current branch. | Squash-merges **already-fetched** remote changes into **every selected subtree directory**, without touching the network. | -| `pull` | `fetch` + `merge` for the **current repository**. | `fetch` + `merge` for **every selected subtree**. | -| `push` | Pushes the **current repository's refs** to a remote. | Splits and pushes **every selected locally changed subtree** to its matching remote. | -| `prune` | (`git remote prune`) Removes stale tracking refs for **one remote**. | Removes stale tracking refs for **every selected subtree remote**. | -| `init` | Initializes the **current directory** as a Git repository. | Initializes **one path/remote pair inside the monorepo** as a managed subtree (see below). | - -`status`, `diff` and `merge` only use what was last fetched. Run -`git subtrees fetch` first if you need the latest remote state. - -**Pushing a new branch.** If a subtree's remote doesn't have your branch -yet, `push` creates it only when that subtree changed on your branch. So -starting a feature branch doesn't create empty branches on every remote. -"Changed" is measured against the monorepo's base branch. That's `--base -` if you pass it, else `origin/HEAD`, else `init.defaultBranch`. -If none of these work, `push` asks for `--base` instead of guessing. - -**Setting up a subtree.** `git subtrees init ` adds the remote -if it's missing. It never changes the URL of an existing remote. If -`` doesn't exist yet, it runs `git subtree add` to bring in the -remote's content. On a branch the remote doesn't have yet, it adds the -remote's base branch instead (found like for `push`), and your first `push` -creates your branch on top of it. If the remote has neither (e.g. it's -still empty), `init` only registers the remote. If `` already has -exactly the remote's content, e.g. copied in by hand, `init` records that -as the last sync, so later pushes build on the remote's history. If it has -content that isn't related to the remote, `init` stops and asks you to move -the folder aside and merge it back by hand. The monorepo needs at least -one commit: `git subtree add` merges into the current branch, so in a -freshly created repository, run `git commit --allow-empty -m 'initial -commit'` first. - -## Sync states - -`status` reports one of these states for each subtree, and `merge`, `pull` -and `push` act on it. Each linked scenario is a small, tested example of that -state. - -| State | Meaning | What to do | -| --- | --- | --- | -| never fetched | The remote was added but never fetched. ([example](test/scenarios/not-connected/README.md)) | Run `git subtrees fetch`. | -| up to date | Both sides are the same. ([example](test/scenarios/up-to-date/README.md)) | Nothing to do. | -| push | Only your side changed. ([example](test/scenarios/push-ahead/README.md)) | Run `git subtrees push`. | -| pull | Only the remote changed. ([example](test/scenarios/pull-ahead/README.md)) | Run `git subtrees pull`. | -| diverged | Both sides changed since the last sync. ([example](test/scenarios/diverged-common-ancestor/README.md)) | Run `git subtrees pull`, then `push`. On a conflict, resolve it and `git commit` first. | -| unrelated history | Both sides changed and share no history, e.g. the remote was rebuilt from scratch. ([example](test/scenarios/diverged-unrelated-history/README.md)) | Pick a side. `merge`, `pull` and `push` refuse to guess and print the commands to keep either one. | -| no branch on remote | The remote has no branch with your branch's name. ([unchanged](test/scenarios/feature-branch-unchanged/README.md), [changed](test/scenarios/feature-branch-changed/README.md)) | Run `git subtrees push`. It creates the branch only if the subtree changed (see *Pushing a new branch*). | - -More scenarios: - -- [`init-copied-content`](test/scenarios/init-copied-content/README.md): - `init` on a folder copied in by hand with exactly the remote's content. -- [`init-on-feature-branch`](test/scenarios/init-on-feature-branch/README.md): - `init` on a branch the remote doesn't have yet. -- [`init-unrelated-content`](test/scenarios/init-unrelated-content/README.md): - `init` on a folder whose content has nothing to do with the remote. -- [`init-without-commits`](test/scenarios/init-without-commits/README.md): - `init` in a brand-new monorepo that has no commits yet. -- [`nested-subtrees`](test/scenarios/nested-subtrees/README.md): why one - subtree inside another is refused. -- [`shared-remote-url`](test/scenarios/shared-remote-url/README.md): two - subtrees with the same remote URL act like two clones of one repo. - -[How the last sync point is found](docs/last-synced-commit/README.md) -explains how the states are worked out and lists known limitations. - -## Installation - -With [Nix](https://nixos.org/download/), which brings its own Bash, Git and -shell completions: - - nix profile install github:roschaefer/git-subtrees - nix run github:roschaefer/git-subtrees -- status # or try it first - -Otherwise, clone it: - - git clone https://github.com/roschaefer/git-subtrees.git - mkdir -p ~/.local/bin - ln -s "$(pwd)/git-subtrees/git-subtrees" ~/.local/bin/git-subtrees - -Git runs any `git-` executable on your `PATH` as `git `, so make -sure `~/.local/bin` is on it. Symlink only the `git-subtrees` file. The -`lib/` folder must stay next to it. - -Requires: - -- Bash >= 4.4. macOS ships 3.2, so install a newer one (e.g. - `brew install bash`) and put it first on your `PATH`. -- `git subtree`, which most Linux distributions bundle with git. Check with - `git subtree --help`. - -### Shell completions - -`completions/` has completions for bash, zsh and fish. They complete -subcommands and subtree paths. The Nix package installs them; for a clone: - - # bash: source from ~/.bashrc - source /path/to/git-subtrees/completions/git-subtrees.bash - - # zsh: install as `_git-subtrees` on your $fpath, then restart the shell - ln -s /path/to/git-subtrees/completions/git-subtrees.zsh \ - /usr/local/share/zsh/site-functions/_git-subtrees - - # fish - ln -s /path/to/git-subtrees/completions/git-subtrees.fish \ - ~/.config/fish/completions/git-subtrees.fish - -## Development - -With [Nix](https://nixos.org/download/) and -[flakes](https://wiki.nixos.org/wiki/Flakes), run in the clone: - - nix develop # shell with the dev tools and this checkout on PATH - just --list # lint, fmt, test, ci, bench, walkthrough, ... - just walkthrough # try commands by hand in a throwaway monorepo - -Scenario fixtures for the tests are in -[`test/scenarios/`](test/scenarios/README.md), each with a README that -shows the tool's output in that state, checked like the walkthroughs by -`just docs-check`. Pull requests out of draft get a benchmark against -their base in the job summary of the Benchmark workflow. - -Releases come from [release-please](https://github.com/googleapis/release-please): -it keeps a release PR open with the next version and changelog, built from -the Conventional Commits on `main`. Merging it tags and publishes the -release. +> [!IMPORTANT] +> **This project is retired.** Use +> [git-splice](https://github.com/roschaefer/git-splice) instead. + +git-subtrees managed the subtrees of a monorepo with `git subtree`: every +git remote named like a folder was that folder's repository. Building it +showed that this design is flawed at its root, in `git subtree` itself. +No amount of tooling on top fixes these three problems. + +## 1. Remotes are not meant to be homes for subfolders + +A subtree remote is an ordinary git remote. Git, your IDE and LazyGit all +treat it like any other remote, so any push that isn't a +`git subtree split` sends the **whole monorepo** there, including folders +that were never meant to leave it. A mistyped `git push vendor/foo main` +is enough, and so are `push.autoSetupRemote`, an upstream that points at +the subtree remote, or LazyGit suggesting the first remote. Deleting the +branch afterwards doesn't unpublish anything. + +Every mitigation (a pre-push hook, a broken push URL, a custom remote +helper, server-side rules) has gaps, because it fights how git remotes +work. See [#50](https://github.com/roschaefer/git-subtrees/issues/50). + +## 2. It does not scale + +To find out whether a subtree has changes to push, `git subtree split` +walks every commit reachable from `HEAD`, a few milliseconds each, and has +no persistent cache. `status` and `push` get slower as the monorepo's +history grows: 8s at 550 commits, 44s at 5,500. The workarounds either +add commits to your history (`--rejoin`) that rebases and squash merges +lose again, or reimplement `git subtree split`. See +[#30](https://github.com/roschaefer/git-subtrees/issues/30). + +## 3. The history is not linear + +`git subtree add` and `pull` write merge commits into the monorepo, even +with `--squash`. If your `main` is kept linear (squash-merged pull +requests, rebased branches), those merges and the sync points they carry +get lost, or they clutter the history if you keep them. See +[Mastering Git subtrees](https://medium.com/@porteneuve/mastering-git-subtrees-943d29a798ec). + +## What git-splice does differently + +[git-splice](https://github.com/roschaefer/git-splice) keeps the goal: +edit other repositories as folders of your monorepo, test them together, +and sync both ways with plain Git. But: + +- A committed `.splice` file in the folder records its repository and the + last sync point. No subtree remote to push the monorepo to by mistake, + and the sync point travels with your branches. +- A pull lands as one ordinary monorepo commit. Changes cross the splice, + commits don't, so both repositories keep their own linear histories. + +## The old documentation + +The last release is +[v0.1.2](https://github.com/roschaefer/git-subtrees/tree/v0.1.2). Its +README documents every command. ## License