Has my branch been squash-merged into main?
I have a large number of local development branches, several of which had already been merged into main
. If the branches were fast-forward merged, things are usually fine. You switch to the main
branch and run git branch -d <branch-name>
, which will succeed if all the commits on branch-name
are also present in main
.
If the branch was squashed during merge though, things get interesting. git branch -d <branch-name>
fails because git
can't verify that all the changes in the branch are also present in main
- the commit history is different.
So, how can you verify whether the changes in a branch are present in main
? Easy, just do this1:
branch='my-local-branch-that-was-squash-merged'
[[ $(git cherry main $(git commit-tree $(git rev-parse $branch\^{tree}) -p $(git merge-base main $branch) -m _)) == "-"* ]] && echo "Would delete branch $branch"
Perfectly obvious, of course.
But in case it wasn't, let's break it down:
1. git rev-parse my-local-branch-that-was-squash-merged\^{tree}
git rev-parse
is a complicated command; it does a seemingly infinite number of things. The man
page is distressingly long. But one common use is to get the commit SHA1 of a branch/tag, potentially to feed a downstream command that only takes SHA1 values.
So far so good. git rev-parse my-local-branch-that-was-squash-merged
produces the SHA of the latest commit on that branch.
What's with the ^{tree}
?
A tree object represents the state of the repository at a particular commit - all its files, metadata, subdirectories, etc. The tree object is different from the commit SHA. When a commit is made, a new tree object is created. The commit SHA encodes the difference between the previous tree object and the current tree object.
As you'd expect, diffs between tree objects are identical to diffs between commits:
git diff $(git rev-parse my-local-branch~1) $(git rev-parse my-local-branch)
produces identical results with
git diff $(git rev-parse my-local-branch~1\^{tree}) $(git rev-parse my-local-branch\^{tree})
2. git merge-base main $branch
git merge-base
finds the least common ancestor of the supplied branches. With any two branches, there will definitely be a common ancestor. With multiple branches, it gets a little wacky. The documentation describes some special cases.
The man
page is so clear that it would be shame to reword this:
A common idiom to check "fast-forward-ness" between two commits A and B is (or at least used to be) to compute the merge base between A and B, and check if it is the same as A, in which case, A is an ancestor of B. You will see this idiom used often in older scripts.
A=$(git rev-parse --verify A) if test "$A" = "$(git merge-base A B)" then ... A is an ancestor of B ... fi
In modern git, you can say this in a more direct way:
if git merge-base --is-ancestor A B then ... A is an ancestor of B ... fiinstead.
In our case, we want to find the least common ancestor between main
and $branch
. At what point did $branch
diverge from main
?
3. git commit-tree $(git rev-parse $branch\^{tree}) -p $(git merge-base main $branch) -m _
git commit-tree
creates a new commit object, much like git commit
. This is used under the hood by git commit
.
Unlike git commit
though, it does not create an actual commit to the repository. It produces a temporary commit object that can be used for inspection.
-m
lets you specify a message. Since this is a temporary commit object, we give it the dummy message_
.-p
lets you specify the parent commit. We're using the common ancestor of$branch
andmain
.
This produces a commit object containing all of the contents of $branch
applied onto that common ancestor, where $branch
diverged from main
.
4. git cherry main $(git commit-tree $(git rev-parse $branch\^{tree}) -p $(git merge-base main $branch) -m _)
git cherry upstream head
answers the question: Are there any commits in head
that are yet to be applied to upstream
?
upstream
is main
, the reference point. head
is the temp commit object we just created - one commit tacked on to the merge-base
commit.
The merge-base
commit is already a part of main
. So, the question is, does this temp commit object need to be added to main
or not?
git cherry
will give us the answer.
Scenario 1: a branch ahead of main
$ branch=my-local-branch-ahead-of-main
$ x=$(git commit-tree $(git rev-parse $branch\^{tree}) -p $mergeBase -m _)
$ echo $x
f8d73acd82787a108b78e3b20b87d4a4a3b9e12b
$ git cherry main $x
+ f8d73acd82787a108b78e3b20b87d4a4a3b9e12b
This says: I took the tree object of the latest commit on the branch, diffed it with the divergence point from main
, and used it to create a temp commit.
Since the branch is ahead of main, the changes in that temp commit are yet to be applied to main.
Scenario 2: a branch squash-merged into main
If you take the tree object of the branch and diff it with the merge base, you get all the contents of the branch. That's made into a temp commit. The contents of this temp commit are already present in main
, since the branch was squash-merged already.
There are equivalent commits in main
that contain the contents of this temp commit.
Let's make a temp commit again:
$ branch=my-local-branch-that-was-squash-merged
$ x=$(git commit-tree $(git rev-parse $branch\^{tree}) -p $(git merge-base main $branch) -m _ )
$ echo $x
43f6b88ccdfa6fd509e65fd187259c4bf353a952
When you run git cherry main $x
, it tells you that the contents of x
are already present in main
, signified by the -
sign. You can safely remove x
from your branch.
$ git cherry main $x
- 43f6b88ccdfa6fd509e65fd187259c4bf353a952
This tells you that the branch has been squash-merged into main
- its contents are present there already.
5. [[ $(git cherry main $(git commit-tree $(git rev-parse $branch\^{tree}) -p $(git merge-base main $branch) -m _)) == "-"* ]]
The [[ <expression> == "-"* ]]
is just a way to check whether the output of git cherry
starts with a -
character. If it does, the contents of your branch are already in main
- it's been squash-merged.
6. [[ $(git cherry main $(git commit-tree $(git rev-parse $branch\^{tree}) -p $(git merge-base main $branch) -m _)) == "-"* ]] && echo "Would delete branch $branch"
If that condition evaluated to true, yeah, you'd delete that branch. Forcefully.
Use git branch -D $branch
. You've earned it.
This git-fu is not my own; I found it at https://github.com/not-an-aardvark/git-delete-squashed↩