7

When merging branches with --squash, git does no longer seem to be able to determine whether a branch has been fully merged. How can I quickly check this with existing git commands?

To reproduce, create a new git repository:

$ mkdir tmp
$ cd tmp
$ git init
$ echo "bla" > ans
$ git add .
$ git commit -m "First commit"

Create branch new-branch with commits:

$ git checkout -b new-branch
$ echo "blabla" >> ans
$ echo "blupp" > zwa
$ git add .
$ git commit -m "Commit on new-branch"

Create branch another-branch with commits:

$ git checkout master
$ git checkout -b another-branch
$ echo "test" >> ans
$ echo "three" > dra
$ git add .
$ git commit -m "Commit on another-branch"

Merge:

$ git checkout master
$ git merge --squash new-branch
$ git commit -m "Squash new-branch"
$ git merge --squash another-branch
$ git mergetool
$ git commit -m "Squash another-branch"
$ git clean -f

Both branches have been merged. I should be able to delete them now, right? Unfortunately, no:

$ git branch -d new-branch
> error: The branch 'new-branch' is not fully merged.
> If you are sure you want to delete it, run 'git branch -D new-branch'.
$ git branch -d another-branch
> error: The branch 'another-branch' is not fully merged.
> If you are sure you want to delete it, run 'git branch -D another-branch'.

I could use option -D, but usually someone else merges my stuff, and I want to check whether the merge was successful. Trying to use git diff:

$ git diff new-branch
$ git diff ..new-branch
$ git diff ...new-branch
$ git diff new-branch..
$ git diff new-branch...

All the statements above yield a non-empty diff. Another attempt:

$ git branch --merged
> * master

The two branches do not appear in the list of merged branches, so this didn't work either.

How can I tell whether the branches have been merged?

digory doo
  • 1,978
  • 2
  • 23
  • 37
  • 1
    Repeat the merge with `--no-commit`, and if you see that no changes are missing (and all conflicts have been resolved in the expected way), your branch has been merged. – j6t Sep 12 '19 at 07:56
  • This does not work nicely. git first thinks there is another conflict. git mergetool then sees everything is fine. I would wish to be able to tell in a single command without having to call mergetool. – digory doo Sep 12 '19 at 07:59
  • 1
    Then you can't. Change your workflow to not use `--squash`. – j6t Sep 12 '19 at 08:00
  • 2
    @j6t I would agree. `--squash` might be git's number one cargo cult trope. I don't say it's always bad practice of course, but we see so many people here with problems down the line with this feature, who when asked about why squashing in the first place answer with "because I guess I should" or "because they said so"... I don't assume anything about you specifically, @digory doo, please don't take it for you, it's just a general observation. – Romain Valeri Sep 12 '19 at 08:25

4 Answers4

7

You cannot get what you want. You can get several things that may or may not be good enough. In particular, if you use a plain git merge (with --no-ff if/when required), Git records the commits. Git isn't really concerned with files, as it's about commits. Git isn't really very much concerned about branches either: it's still all about commits.

Illustration, with graph-drawings

Using your example, creating a new empty repository and stopping at this point:

$ git commit -m "First commit"

you now have a repository with exactly one commit in it. That one commit has some big ugly hash ID, but I'll just use the letter A to stand in for it. The repository now has one named branch, master, which holds the hash ID of this one commit A, so we can draw it like this:

A   <-- master (HEAD)

Now we run your second series of commands:

$ git checkout -b new-branch

At this point we have:

A   <-- new-branch (HEAD), master
$ echo "blabla" >> ans
$ echo "blupp" > zwa
$ git add .
$ git commit -m "Commit on new-branch"

This creates new commit B, dragging the name new-branch forward:

A   <-- master
 \
  B   <--  new-branch (HEAD)

Now we use your third series of commands (I'll trim one down a bit):

$ git checkout -b another-branch master

This switches back to commit A, creates a new branch name pointing to it, attaches HEAD to the new branch, and leaves us with:

A   <-- another-branch (HEAD), master
 \
  B   <--  new-branch
$ echo "test" >> ans
$ echo "three" > dra
$ git add .
$ git commit -m "Commit on another-branch"

This creates third commit C, dragging the name another-branch forward to it:

  C   <-- another-branch (HEAD)
 /
A   <-- master
 \
  B   <--  new-branch

Now we take on the hard part, the two merge commands. We start with:

$ git checkout master

This extracts the contents of commit A into the index/staging-area and your work-tree, and attaches the name HEAD to the name master:

  C   <-- another-branch
 /
A   <-- master (HEAD)
 \
  B   <--  new-branch
$ git merge --squash new-branch

This does a merge operation that consists of diffing commit A vs commit A (which shows no differences of course) and then commit A vs commit B (which shows some differences). The changes discovered in these two diffs get combined—without any conflict, since one set of changes is "do nothing"—and Git stops short of making a new commit, so we need:

$ git commit -m "Squash new-branch"

which does make the new commit. I'll call this D:

  C   <-- another-branch
 /
A---D   <-- master (HEAD)
 \
  B   <--  new-branch

Note that commit D has no backwards-looking connection to commit B; it remembers only the hash ID of existing commit A. Had you used:

git merge --no-ff new-branch

to make D, we'd have a connecting line (should be an arrow but arrow fonts don't always work right on every browser) going down-and-right from D to B:

A---D
 \ /
  B

but we don't.

Next:

$ git merge --squash another-branch

This time the merge operation consists of diffing commit A (the merge base) with commit D, to see what we changed, then diffing commit A vs commit C (the tip commit of another-branch), to see what they changed. The merge is actually conflicted—we both changed the file ans, in lines that abut at the end of the file—so most forms of merge will stop here with a conflict. Hence you needed:

$ git mergetool

to resolve the conflict, though we could do that in shell with:

$ cat << END > ans
bla
blabla
test
END
$ git add ans

(you can substitute whatever you want for the merge result in the here-document section before the END). The last step here is to commit the merge. Since it is a squash-merge, rather than a real merge, once again we do not have a backwards link to commit C, even though we used commit C to do the merge:

$ git commit -m "Squash another-branch"

This makes new commit/snapshot E, so let's draw that:

  C   <-- another-branch
 /
A---D--E   <-- master (HEAD)
 \
  B   <--  new-branch

Note that new commit E has no connection back to C. Using the name master, Git starts at commit E, walks back to D, then walks back to A. Commit A has no parent—it's the very first commit we ever made, after all—so the action stops at this point. Commits B and C are not found in this process.

Why git branch -d fails

If we don't use git mergetool, we don't need git clean to clean up its junk files, so I'll skip over that and proceed to:

$ git branch -d new-branch
> error: The branch 'new-branch' is not fully merged.
> If you are sure you want to delete it, run 'git branch -D new-branch'.

What this is telling you is that from commit E—where you are now—there's no way to find commit B. That's true; we will not find B in a walk from E back to the root. In fact, the name new-branch is the only way we have to find commit B. (Remember, B stands in for some random-looking hash ID that we would never be able to guess.) If we do delete the name new-branch, we will lose commit B.

Since Git is all about commits, losing a commit would be bad. Git won't discard the name, and hence lose the commit, unless you force it.

The same goes for commit C, find-able only through the name another-branch. The git branch -d command will refuse to delete it as commit C is not an ancestor of current commit E.

If we'd used regular git merge—with --no-ff the first time, since otherwise Git would have cheated with a fast-forward instead of a merge—we'd have, at this point, this graph:

  C___  <-- another-branch
 /    \
A---D--E   <-- master (HEAD)
 \ /
  B   <--  new-branch

Now each request, to delete the names new-branch and another-branch, would be "safe", because starting at commit E, Git can walk back to commits D and C. From D, Git can walk back to commits A and B. Commits B and C are therefore on branch master, as well as being on branches new-branch and another-branch respectively. It's safe to delete the name new-branch as commit B is protected by being on master. It's safe to delete the name another-branch as commit C is protected by being on master.

Commits are history

Fundamentally, using git merge --squash is a way to tell Git: I'm going to throw away some commits / history. If we have:

...--A--B--C--D--E   <-- branch1 (HEAD)
            \
             F--G--H   <-- branch2

and we run git merge --squash branch2, we'll make a new commit on the current branch branch1 that is the result of combining the diff from C to E—what we did on branch1—with the diff from C to H—what they did on branch2. After successfully making this new commit:

...--A--B--C--D--E--FGH   <-- branch1 (HEAD)
            \
             F--G--H   <-- branch2

the only sensible thing to do with branch2 is to delete it. Git doesn't immediately delete it because we might have some other plans for some of its commits—for instance, maybe we want to cherry-pick G or H as a new commit in some other branch—but eventually we should kill it off. But our new FGH combined commit doesn't remember the hash of commit H, so deleting the name branch2 will lose commits F, G, and H; so git branch requires that we force this deletion.

Note that if there is some additional commit beyond H, find-able through some other name:

...--A--B--C--D--E--FGH   <-- branch1 (HEAD)
            \
             F--G--H   <-- branch2
                    \
                     I--J   <-- branch3

this additional name will keep commit J alive, and J will keep I which keeps H which keeps G which keeps F. Here, git branch -d branch2 will still fail from branch1—commit H is not an ancestor of commit FGH—but will succeed when run from branch3.

The precise definition of when a branch-name delete must be forced has evolved a bit over time. Git used to just use the current commit (HEAD) and the branch-tip to decide if a deletion was safe. Now it also considers the branch's upstream setting, if the branch has one. If the branch tip commit is an ancestor of the branch's upstream's tip commit, but not of the current commit, Git now deletes the branch with a warning. The commits are safe—they're protected by the upstream name, at least for the moment—but Git isn't sure you really meant to do this, so it prints a warning including the hash ID that was stored in the branch name, to allow you to restore the branch name using that hash ID.

torek
  • 448,244
  • 59
  • 642
  • 775
  • 1
    Thanks for this very elaborate answer. However, it still lacks a practical answer how I can solve my problem. The best answer so far seems to be, _don't use squash in the first place_. – digory doo Sep 16 '19 at 06:42
  • 1
    The point of all this is just that: there *isn't* a solution. If you don't like what squash does, don't squash. :-) Hence the very first sentence, "you cannot get what you want"... – torek Sep 16 '19 at 15:23
1

How can I tell whether the branches have been merged?

By squashing the commit range and using git cherry master <squashed commit>.[1]

(Update: see “Addendum 2” for a script that gives you the “squashed merge”, if it exists)

Your example is a special case though, since there is only one commit on new-branch which is not also in master. See section prefix “Checking for a squash merge” for how to deal with that (which also goes into git cherry and git cherry-pick).

A more interesting history

But say you have three commits on new-branch which are not in master. Here’s a modification of your setup script:[2]

t=$(mktemp -d)
cd $t
git init
main=$(git var GIT_DEFAULT_BRANCH)
echo "bla1" > ans
git add .
git commit -m "A"
echo "bla2" >> ans
git add .
git commit -m "B"
git checkout -b new-branch
echo "la1" >> ans
git add .
git commit -m "X"
echo "la2" >> ans
git add .
git commit -m "Y"
echo "la3" >> ans
git add .
git commit -m "Z"
git checkout $main
git merge --squash new-branch
git commit -m "C"

master (or whatever git var GIT_DEFAULT_BRANCH is):

A -- B -- C

C is the squashed commit.

new-branch:

A -- B -- X -- Y -- Z

X, Y, and Z have been squashed into C on master.

Now, first a sanity check: none of the commits of new-branch are recognized as having anything to do with master:

$ git cherry $main new-branch
+ 684749f894128d5c7d80070f945805c164b998d4
+ 2a52d29d77182ad0c4ef8bceca876428d339b106
+ bb090a348919af77b1bfa8fafd2649418574b36f

The plus-sign means “no” (see next section for an explanation).

But now we can squash the branch and see:

$ # Assuming that we can do a checkout
$ git checkout --detach new-branch
$ # Squash the commits[3]
$ git reset --soft $(git merge-base $main @) && git commit -m 'squash'
$ # Set a temporary tag so that I can refer to it later
$ git tag squash
$ git cherry -v $main @
- 5e0021b2ca26acbe8d10aaf0d99fe45b89831331 squash

Yes! The squashed version of the branch is in master. You can force-delete your branch, if you want.

… But what if master keeps going? Will git cherry still report that new-branch is “merged”? Yes:

$ git checkout $main
$ echo 'la4' >> ans
$ git add .
$ git commit -m "D"
$ git checkout squash
$ git cherry -v $main squash
- 81e7f1c8947d8390cdfe4e8f98462341ca552e4d squash

Merge conflicts

The original setup with three branches seems to involve a merge conflict:

git mergetool

So how do you find “squash merges” in the face of conflicts using this approach with git cherry?

You cannot. That’s impossible.

If you want to stick to squashing as well as being able to find them later, then you need to only do git merge --squash if it doesn’t conflict. And if it does conflict you need to (1) rebase the branch on master and fix the conflicts (2) retry the squash-merge.

If you have merge conflicts then the conflict resolution will necessarily give a different “change”/diff than what you would have gotten if the squash merge applied cleanly. So it’s impossible for git cherry to figure out whether or not your branch has been squashed to master.

Checking for a squash merge with only one commit on the branch

Back to the original example:

$ git checkout new-branch
$ git cherry -v master
- 38decef0caa57abd0babf7bd2582d3af7a02abd2 Commit on new-branch

The output here is a bit backwards: the minus sign means that it has been applied to “upstream” (master) and that you in turn can drop it (you don’t need to keep it since it’s been applied to upstream); if it wasn’t applied then there would be a plus sign there instead (meaning: keep this one ’cause it ain’t been applied yet).

You could try to apply it with git cherry-pick, though. But it would skip it:

$ git checkout master
$ git cherry-pick new-branch
On branch master
You are currently cherry-picking commit 38decef.
  (all conflicts fixed: run "git cherry-pick --continue")
  (use "git cherry-pick --skip" to skip this patch)
  (use "git cherry-pick --abort" to cancel the cherry-pick operation)

nothing to commit, working tree clean
The previous cherry-pick is now empty, possibly due to conflict resolution.
If you wish to commit it anyway, use:

    git commit --allow-empty

Otherwise, please use 'git cherry-pick --skip'

git cherry-pick uses git cherry (or equivalent) to check if the “patch” has been applied it, and skips applying it if it has.

# Can just back out of this redundant cherry-pick
git cherry-pick --abort

Addendum 1: commits versus patches

Commits are snapshots; two commits that introduce the same change may have different hashes (I guess “OID” is the correct term).

But git cherry doesn’t care about that. It only cares about the changes. Namely:[4]

The equivalence test is based on the diff, after removing whitespace and
line numbers. git-cherry therefore detects when commits have been
"copied" by means of git-cherry-pick(1), git-am(1) or git-rebase(1).

And for the “more interesting history” setup we have:

git diff $main~ $main
$ diff --git a/ans b/ans
index 637f070..afd7d7d 100644
--- a/ans
+++ b/ans
@@ -1,2 +1,5 @@
 bla1
 bla2
+la1
+la2
+la3

And for the branch:

$ git diff $(git merge-base $main new-branch) new-branch
diff --git a/ans b/ans
index 637f070..afd7d7d 100644
--- a/ans
+++ b/ans
@@ -1,2 +1,5 @@
 bla1
 bla2
+la1
+la2
+la3

And for that temporary squash:

$ git diff squash~ squash
diff --git a/ans b/ans
index 637f070..afd7d7d 100644
--- a/ans
+++ b/ans
@@ -1,2 +1,5 @@
 bla1
 bla2
+la1
+la2
+la3

The changes are all the same.

Addendum 2: script for finding the squash merge

Using git checkout and git reset isn’t convenient. Let’s use git patch-id instead:

#!/usr/bin/env bash

# Find out if a commit has been “squash merged” upstream
# The name is a homage to `git-when-merged`: https://github.com/mhagger/git-when-merged

upstream=$1
yours=$2

# Get the patch-id of the diff of the squash
patch_id=$(git diff $(git merge-base $upstream $yours) $yours \
    | git patch-id --stable | cut -d' ' -f1)
git rev-list $yours..$upstream | while read -r line ; do
    other_patch_id=$(git diff $line~ $line | git patch-id --stable | cut -d' ' -f1)
    if [[ $patch_id = $other_patch_id ]]; then
        printf "$line\n"
        exit 0
    fi
done

# Not found
exit 1

I’ve tested this on some of my “squash merges” in the repository at $dayjob.

This might be very slow if you have a decent-sized history and if the commit isn’t squashed yet. (I have only tested “happy cases”.) It could perhaps be expanded to use --since on git rev-list.

Notes

  1. All of this uses git patch-id (or equivalent). I would recommend (just in case + for reproducibility) that you set:

    git config --global patchid.stable true
    

    In order to make sure that you’re not using the deprecated, legacy behavior.

  2. I will also omit the third branch for simplicity (I didn’t understand that there was supposed to be a merge conflict (git mergetool) until I saw that torek pointed it out.) You didn’t say anything about possible merge conflicts being significant. But see the “Merge Conflict” section.

  3. Taken from

  4. man git cherry as of git version 2.40.0

Guildenstern
  • 2,179
  • 1
  • 17
  • 39
-1

Your branches were successfully merged. You are getting below message error: The branch 'new-branch' is not fully merged. Because code is not pushed to remote branch yet. Please push the code after that git branch -d new-branch command will work without any error.

The -d option stands for --delete, which would delete the local branch, only if you have already pushed and merged it with your remote branches. The -D option stands for --delete --force, which deletes the branch regardless of its push and merge status, so be careful using this one!

  • That's not right. Try my example and push it to some server. git will still think the branches are not merged. The reason is that `squash` creates new commits and does not link the old commits. – digory doo Sep 12 '19 at 14:04
-1

You can print only the merged branches with:

git branch --merged

So if you see your branch there when you are on the master branch, it means it is merged.

The reason why you don't see it is because that is what "--squash" is for. It does not merge at all, it includes the changes and then you commit. It is confusing but there is actually no merge. Everything happens as if you typed the changes that happened to be similar in another branch.

The good thing to note though is that "mergetool" works anyway. I didn't know that.

Now it would perfectly work with "diff", but the problem is that you included 2 branches, and with a conflict on top of it. So you will not get a result.

I also understand you are looking for an answer and my advice would be to clone your master branch somewhere else, do a real merge of these branches, and then do a diff between your real working repo and your clone.

Does that make sense? Then you'll know for sure.

Mig
  • 662
  • 4
  • 13
  • Nope, neither of the branches are listed. git thinks they are not merged. Did you try my example? – digory doo Sep 12 '19 at 13:59
  • My apologies, I've edited my answer with more details to be explicit and provided a workaround. – Mig Sep 13 '19 at 05:56