Kaushik's Blog

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

instead.

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.

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.


  1. This git-fu is not my own; I found it at https://github.com/not-an-aardvark/git-delete-squashed