Skip to main content

git-subrepo compared

git-subrepo looks the most like git-splice. The library's files are ordinary monorepo files, a committed file in the folder, .gitrepo, names the library, every pull is one commit, and push sends the folder's changes back.

The difference is what else .gitrepo records: one upstream branch for every monorepo branch, and the monorepo commit the last push came from. On a feature branch that ends in a squash merge, both get in the way.

The setup: https://git.example.com/lib.git, a library with 30 commits, and a monorepo with an app. How these pages run.

$ git subrepo --version
0.4.9

Like git-splice: one commit per pull​

clone adds the library as one commit:

$ git subrepo clone https://git.example.com/lib.git vendor/lib
Subrepo 'https://git.example.com/lib.git' (main) cloned into 'vendor/lib'.
$ cat vendor/lib/.gitrepo
; DO NOT EDIT (unless you know what you are doing)
;
; This subdirectory is a git "subrepo", and this file is maintained by the
; git-subrepo command. See https://github.com/ingydotnet/git-subrepo#readme
;
[subrepo]
remote = https://git.example.com/lib.git
branch = main
commit = bafd496b1f4512a95b69464604f3da428aa9e33b
parent = 7de219808bfd601897cdad67aa5c90b5a2e9bce8
method = merge
cmdver = 0.4.9

You fix the library and use the fix in the app, in one commit, f. parent in .gitrepo still names a, the commit before the clone c:

$ echo "fix" >>vendor/lib/src/parse.txt &&
> echo "call parse()" >>app/main.txt &&
> git commit -q -a -m "fix parse() and call it"

The monorepo's a, c and f on main; HEAD points at f, and a dashed arrow, parent, from f to a

Then you push it:

$ git subrepo push vendor/lib
Subrepo 'vendor/lib' pushed to 'https://git.example.com/lib.git' (main).

The library gets the commit with only the folder's change:

$ git -C "$COMPARISON/upstream/lib.git" show --stat --format=%s main
fix parse() and call it

src/parse.txt | 1 +
1 file changed, 1 insertion(+)

Problem 1: every push is a commit in the monorepo​

The monorepo's history is linear, but push added a commit of its own:

$ git log --graph --format=%s -4
* git subrepo push vendor/lib
* fix parse() and call it
* git subrepo clone https://git.example.com/lib.git vendor/lib
* app commit 40

It's p, which records in .gitrepo the library commit it pushed, and moves parent to the monorepo commit it pushed from, f:

The monorepo's a, c, f and p on main; HEAD points at p, and a dashed arrow, parent, from p to f

A squash merge or rebase can leave that commit behind, as problem 3 shows (git-subrepo#464, #600).

Problem 2: a feature branch pushes to main​

.gitrepo names one upstream branch, for every monorepo branch. A second fix, on a feature branch:

$ git switch -q -c feature &&
> echo "fix 2" >>vendor/lib/src/parse.txt &&
> git commit -q -a -m "fix 2"

push sends it to main, the branch in .gitrepo, with no pull request to review it:

$ git subrepo push vendor/lib
Subrepo 'vendor/lib' pushed to 'https://git.example.com/lib.git' (main).

The library's main has it now:

$ git -C "$COMPARISON/upstream/lib.git" log --format=%s -3 main
fix 2
fix parse() and call it
lib commit 30

To push to another branch, --branch names it and --update writes it into .gitrepo. After a push, that takes a fetch first, or it fails with "Local repository does not contain" the commit it pushed:

$ echo "fix 3" >>vendor/lib/src/parse.txt &&
> git commit -q -a -m "fix 3" &&
> git subrepo fetch vendor/lib
Fetched 'vendor/lib' from 'https://git.example.com/lib.git' (main).
$ git subrepo push --branch feature --update vendor/lib
Subrepo 'vendor/lib' pushed to 'https://git.example.com/lib.git' (feature).

Now the feature branch syncs with feature (0.4.9 writes it even without --update, git-subrepo#313):

$ git config --file vendor/lib/.gitrepo subrepo.branch
feature

Problem 3: after a squash merge, the last sync is lost​

The feature branch is squash-merged into main, as a pull request would be:

$ git switch -q main &&
> git merge -q --squash feature &&
> git commit -q -m "feature (squashed)"
Squash commit -- not updating HEAD

.gitrepo comes along with the merge: main now syncs with feature, and parent names a commit main doesn't have:

$ git config --file vendor/lib/.gitrepo subrepo.branch
feature
$ parent="$(git config --file vendor/lib/.gitrepo subrepo.parent)" &&
> git merge-base --is-ancestor "$parent" HEAD || echo "not in main"
not in main

g and h are fix 2 and fix 3, q and r their push commits, and s the squash merge. s has h's changes, but not h:

The monorepo's c, f, p and s on main; g, q, h and r on the feature branch, which starts at p; a dashed arrow, parent, from s to h, and HEAD at s

So the next push from main stops, and asks to set parent to p by hand:

$ echo "fix 4" >>vendor/lib/src/parse.txt &&
> git commit -q -a -m "fix 4" &&
> git subrepo push vendor/lib
git-subrepo: The last sync point (where upstream and the subrepo were equal) is not an ancestor.
This is usually caused by a rebase affecting that commit.
To recover set the subrepo parent in 'vendor/lib/.gitrepo'
to '5bd8dcf5c365cbc7ca13e4e13e51c68260584d43'
and validate the subrepo by comparing with 'git subrepo branch vendor/lib'
[1]

A rebase does the same, as the message says: the pushed commit gets a new hash, and parent names the old one.

How git-splice avoids them​

The library goes back to its 30 commits, without the pushes above:

$ git -C "$COMPARISON/upstream/lib.git" branch -q -f main bafd496 &&
> git -C "$COMPARISON/upstream/lib.git" branch -q -D feature

The same steps, in a copy of the monorepo from before the library. .splice names the library and the synced commit, no branch and no monorepo commit:

$ cd ../splice-monorepo && git splice clone https://git.example.com/lib.git vendor/lib
=== vendor/lib: fetching https://git.example.com/lib.git
ok vendor/lib: cloned bafd496 from main
$ cat vendor/lib/.splice
[splice]
id = f34fc447facba9d0
commit = bafd496b1f4512a95b69464604f3da428aa9e33b
[upstream "origin"]
url = https://git.example.com/lib.git
$ echo "fix" >>vendor/lib/src/parse.txt &&
> echo "call parse()" >>app/main.txt &&
> git commit -q -a -m "fix parse() and call it"
$ git splice push vendor/lib
ok vendor/lib: pushed 5069701 to main

push records nothing in the monorepo. The library gets f', a copy of f with only the folder's change, and the last sync is still c: B, the boundary:

The monorepo's c and f on main; B points at c and HEAD at f; below, the library's k and f prime, with main at f prime

On a feature branch, push goes to the upstream branch of the same name, ready for a pull request:

$ git switch -q -c feature &&
> echo "fix 2" >>vendor/lib/src/parse.txt &&
> git commit -q -a -m "fix 2" &&
> git splice push vendor/lib
?? vendor/lib: upstream has no 'feature' branch yet -- this push creates it
ok vendor/lib: pushed 6b3df25 to feature
$ echo "fix 3" >>vendor/lib/src/parse.txt &&
> git commit -q -a -m "fix 3" &&
> git splice push vendor/lib
ok vendor/lib: pushed 79da5cd to feature

The same squash merge:

$ git switch -q main &&
> git merge -q --squash feature &&
> git commit -q -m "feature (squashed)"
Squash commit -- not updating HEAD

The same history, without push commits, and g' and h' on the library's feature:

The monorepo's c, f and s on main, and g and h on the feature branch, which starts at f; B points at c, HEAD at s; below, the library's k, f prime, g prime and h prime, with main at f prime and feature at h prime

main syncs with main again. B is the newest commit that changed .splice, here c: it comes from the history, not from a stored commit, so a squash merge can't strand it. The next push sends the squashed commit and the new one:

$ echo "fix 4" >>vendor/lib/src/parse.txt &&
> git commit -q -a -m "fix 4" &&
> git splice push vendor/lib
ok vendor/lib: pushed 940e7a2 to main
$ git -C "$COMPARISON/upstream/lib.git" log --format=%s -4 main
fix 4
feature (squashed)
fix parse() and call it
lib commit 30

After a rebase of pushed commits, status shows the splice as diverged, and git splice push --force replaces the upstream branch, as git push --force would.

Problem 4: fetched refs are keyed by folder​

git-subrepo keeps what it fetched under the folder's path, refs/subrepo/<path>/; git-splice under the upstream, refs/splices/<key>/heads/<branch>. That matters when another branch uses the folder for another library:

$ git init -q -b main ../other &&
> echo "other" >../other/README &&
> git -C ../other add README &&
> git -C ../other commit -q -m "other library" &&
> git init -q --bare "$COMPARISON/upstream/other.git" &&
> git -C ../other push -q https://git.example.com/other.git main

In git-subrepo's monorepo, vendor/lib/ was last fetched from lib.git:

$ cd ../monorepo && git log -1 --format=%s refs/subrepo/vendor/lib/fetch
fix 3

A branch from before the library clones the other one into the same folder:

$ git switch -q -c other ':/^app commit 40' &&
> git subrepo clone https://git.example.com/other.git vendor/lib &&
> git switch -q main
Subrepo 'https://git.example.com/other.git' (main) cloned into 'vendor/lib'.

Back on main, vendor/lib/ still names lib.git, but its refs hold the other library, and status shows that one's commit:

$ git subrepo status vendor/lib | grep -E 'Remote URL|Upstream Ref'
Remote URL: https://git.example.com/lib.git
Upstream Ref: 3ea1a83
$ git log -1 --format=%s refs/subrepo/vendor/lib/fetch
other library

pull and push fetch first, so they still reach the right library; status doesn't. With git-splice:

$ cd ../splice-monorepo &&
> git switch -q -c other ':/^app commit 40' &&
> git splice clone https://git.example.com/other.git vendor/lib &&
> git switch -q main
=== vendor/lib: fetching https://git.example.com/other.git
=== vendor/lib: upstream has no 'other' branch -- using 'main'; your first push creates 'other'
ok vendor/lib: cloned 3ea1a83 from main

Each upstream has its own refs, so main's stay as they were:

$ git for-each-ref --format='%(refname) %(subject)' refs/splices
refs/splices/lib/heads/feature fix 3
refs/splices/lib/heads/main fix 4
refs/splices/other/heads/main other library
$ git splice status
On branch main

up to date vendor/lib

After git mv​

In git-subrepo's monorepo, a move changes the key too: the next fetch starts new refs under the new path, and the old ones stay behind:

$ cd ../monorepo &&
> mkdir libs &&
> git mv vendor/lib libs/lib &&
> git commit -q -m "move the library" &&
> git subrepo fetch libs/lib
Fetched 'libs/lib' from 'https://git.example.com/lib.git' (feature).
$ git for-each-ref --format='%(refname)' refs/subrepo
refs/subrepo/libs/lib/fetch
refs/subrepo/vendor/lib/branch
refs/subrepo/vendor/lib/commit
refs/subrepo/vendor/lib/fetch
refs/subrepo/vendor/lib/push

In git-splice's, the refs stay where they are:

$ cd ../splice-monorepo &&
> mkdir libs &&
> git mv vendor/lib libs/lib &&
> git commit -q -m "move the library" &&
> git splice fetch libs/lib
ok libs/lib fetched
$ git for-each-ref --format='%(refname)' refs/splices
refs/splices/lib/heads/feature
refs/splices/lib/heads/main
refs/splices/other/heads/main

In short​

git-subrepogit-splice
Monorepo historylinearlinear
Commits per syncone per pull, one per pushone per pull
Upstream branchthe one in .gitrepo, for every monorepo branchthe monorepo branch's name, default-branch in .splice on the default branch, or one kept with --track
Sync point, the upstream commit last syncedcommit in .gitrepo, written on clone, pull and pushcommit in .splice, written on clone and pull
Where the last sync is in the monorepoparent in .gitrepo, a stored commitderived: the newest commit that changed .splice
After a squash mergepush stops until .gitrepo is fixed by handpush works
After rebasing pushed commitspush stops until .gitrepo is fixed by handdiverged, push --force as with Git
Fetched refs keyed byfolder pathupstream URL