Skip to content
// blog

Git says your branch is not fully merged. Is it?

theBGuy 10 min read
On this page

Your pull request adding cursor pagination to GET /orders was squash-merged yesterday, and the forge deleted its branch afterward, as the repository is set up to do. Now you tidy up, in a clone running Git 2.51 with its default settings. A fetch with --prune brings down both changes at once:

$ git fetch --prune
From …/orders-api
 - [deleted]         (none)     -> origin/orders-pagination
   f641800..ecf45cf  main       -> origin/main

You move to main and take the merge:

$ git switch main
Switched to branch 'main'
Your branch is behind 'origin/main' by 1 commit, and can be fast-forwarded.
  (use "git pull" to update your local branch)
$ git pull
Updating f641800..ecf45cf
Fast-forward
 orders.py | 7 ++++---
 1 file changed, 4 insertions(+), 3 deletions(-)

Then the local branches go. Two look safe to drop: orders-pagination, now merged, and fix-total-rounding, whose commits are all on the remote while its pull request waits on a reviewer. You reach for -d, the delete that checks before it acts:

$ git branch -d orders-pagination fix-total-rounding
error: the branch 'orders-pagination' is not fully merged
hint: If you are sure you want to delete it, run 'git branch -D orders-pagination'
hint: Disable this message with "git config set advice.forceDeleteBranch false"
warning: deleting branch 'fix-total-rounding' that has been merged to
         'refs/remotes/origin/fix-total-rounding', but not yet merged to HEAD
Deleted branch fix-total-rounding (was 3ae8a46).

Git deleted the branch that main has never seen and refused the one you watched merge. The hint has the next command typed out for you, and after enough squash merges you would run it without a second look. This time the refusal is right, and seeing why takes one more minute.

What -d compared

git branch -d deletes only a branch it considers fully merged, and merged is a question about ancestry: walking back from a reference commit, does Git reach the branch’s tip? The reference is the branch’s upstream while that remote-tracking branch exists, and HEAD otherwise: the upstream was never set, or your copy of it has been pruned.

That rule accounts for both results. fix-total-rounding still tracks origin/fix-total-rounding, which holds every commit the branch has, so Git deleted it and warned that HEAD doesn’t have those commits yet. They sit in your copy of the remote as of the last fetch, and -d checked nothing else.

orders-pagination was set to track a remote branch too, but the forge deleted that branch and the prune removed your copy of it (the [deleted] line in the fetch). With nothing left to check against, Git fell back to HEAD, which is main, and main can’t reach the branch tip, as the log shows:

$ git log --oneline -3
ecf45cf Paginate GET /orders with a cursor (#41)
f641800 Log slow order queries
203f8e5 Add order totals
$ git log --oneline main..orders-pagination
15a12e8 Cap page size at 100
b3e4656 Return a next-page cursor
f8e9356 Accept a cursor on GET /orders

main..orders-pagination lists the commits on the branch that main can’t reach: all three of them.

Why a squash merge leads here

A squash merge takes the combined change of a pull request and records it as one new commit on the target branch. ecf45cf carries the content of the commits under review without being their descendant, so their hashes appear nowhere in main’s history. A rebase merge does the same thing another way, copying each commit under a new hash.

While the branch’s upstream survives in your clone, -d checks against it, and a refusal then means something: you have commits the upstream doesn’t. Once the forge deletes the branch and your prune follows, the check falls back to HEAD (here main). After either kind of merge, HEAD can’t reach the branch tip, so -d refuses whether or not anything on the branch is missing from main. In a repository that deletes merged branches, and a clone that prunes, every squash-merged branch draws that refusal, most with nothing at risk, and the one that matters looks like all the rest.

Look at the list again. The pull request had two commits. You committed the third, Cap page size at 100, the evening before the merge, after the approval came through. You meant to open it as a follow-up and never pushed it, so it isn’t in ecf45cf. -D would have deleted the only branch that held it.

Ask about content instead

Memory makes a poor check. You won’t always recall what a pull request carried, and the commit list can’t help, because ancestry is the very thing the squash took away. Content can: does main already hold every change the branch makes?

Git can answer without touching your checkout, using the in-memory merge from the merge-preview post (Git 2.38 or newer). Merge the branch into main on paper, then compare the result with main itself:

$ git merge-tree --write-tree main orders-pagination
88e375a964674ab963783b7d7d5101deb60c861b
$ git diff main 88e375a964674ab963783b7d7d5101deb60c861b
diff --git a/db.py b/db.py
index abf2921..f580ea8 100644
--- a/db.py
+++ b/db.py
@@ -1,5 +1,8 @@
 from store import query
 
+MAX_PAGE_SIZE = 100
+
 
 def fetch_orders(customer_id, limit, after=None):
+    limit = min(limit, MAX_PAGE_SIZE)
     return query("orders", customer=customer_id, limit=limit, after=after)

A single line from merge-tree means a clean merge, and that hash is the tree it produces. The diff against main is everything the merge adds: the page-size cap, and nothing from the other two commits, whose lines main already has. Had the diff come back empty, -D would lose nothing, because main would hold every change on the branch.

A plain git diff main orders-pagination looks like a shortcut, but on a busy repository it also shows, in reverse, every later change on main that the branch lacks. The merged tree differs from main only where the branch brings something new. Run the check against a main you have just pulled, as in this story, or against origin/main after a fetch; a stale local main reports the whole pull request as missing.

If merge-tree reports a conflict, its first line is still the tree, and the CONFLICT lines after it say what kind. For a clash inside a file, diff the tree against main as before: the conflict markers set both versions side by side and show whether the clash is your new work or lines main has edited since. A binary file, or a file one side deleted, gets no markers. Git keeps one side’s copy whole, so the diff can show the file as new, or show nothing at all, and after a conflict an empty diff is not the all-clear.

Keeping the commit main doesn’t have

The cap needs a branch of its own, built on the new main. The obvious move is to rebase the old branch and let Git sort out what main has. On this branch, Git doesn’t:

$ git switch orders-pagination
Switched to branch 'orders-pagination'
Your branch is based on 'origin/orders-pagination', but the upstream is gone.
  (use "git branch --unset-upstream" to fixup)
$ git rebase main
Auto-merging orders.py
CONFLICT (content): Merge conflict in orders.py
error: could not apply f8e9356... Accept a cursor on GET /orders
hint: Resolve all conflicts manually, mark them as resolved with
hint: "git add/rm <conflicted_files>", then run "git rebase --continue".
hint: You can instead skip this commit: run "git rebase --skip".
hint: To abort and get back to the state before "git rebase", run "git rebase --abort".
hint: Disable this message with "git config set advice.mergeConflict false"
Could not apply f8e9356... # Accept a cursor on GET /orders
$ git rebase --abort

A plain rebase replays all three commits, including the two that main holds in squashed form. Replayed one at a time against their own combined result, the first runs into lines the squash changed further, and Git stops to ask. Whether it stops depends on how the squashed commits overlap, so a stop says nothing about new work. (A rebase merge, or the squash of a one-commit pull request, is different: plain rebase sees that those copies match their originals and skips them.)

--abort puts everything back. You want to replay only the commits after the ones the squash carried, and --onto does just that: take what comes after b3e4656, the last commit the pull request had, and set it on top of main. If you don’t remember which commit that was, the pull request’s commit list on the forge shows the hashes; take the newest one your own branch has.

$ git rebase --onto main b3e4656
Successfully rebased and updated refs/heads/orders-pagination.
$ git log --oneline -3
e3f013f Cap page size at 100
ecf45cf Paginate GET /orders with a cursor (#41)
f641800 Log slow order queries

The log shows the cap alone on top of the squash merge. Hand --onto the wrong base, the tip itself for instance, and it still reports success, with nothing on top. git reset --hard ORIG_HEAD restores the branch as it was before the rebase. Then give the branch a name for what it holds, and publish it under that name:

$ git branch -m cap-page-size
$ git push -u origin cap-page-size
To …/orders-api.git
 * [new branch]      cap-page-size -> cap-page-size
branch 'cap-page-size' set up to track 'origin/cap-page-size'.

The branch you came to delete now carries the follow-up, ready for its own pull request, and nothing on it is at risk.

If -D already ran

git branch -d and -D print the tip they remove, (was <hash>), as the fix-total-rounding line above shows, and git branch <name> <hash> brings the branch back. The hard-reset post covers finding the hash without that line: HEAD’s reflog, and what to do when HEAD never stood on the branch.

A branch remade from a hash comes back without its upstream. Deleting a branch also deletes its branch.<name> section from your repository’s config, so the remade branch tracks nothing until you push with -u or name an upstream with git branch -u. When the remote still has every commit the deleted branch had, one switch rebuilds the branch from the remote, tracking included. fix-total-rounding qualifies, since its -d went through; for any other branch, check first that the command git log --oneline origin/<name>..<hash> prints nothing:

$ git switch fix-total-rounding
branch 'fix-total-rounding' set up to track 'origin/fix-total-rounding'.
Switched to a new branch 'fix-total-rounding'

The switch builds from the remote’s copy. For a branch that was ahead of its remote, it restores the name and leaves out the commits you never pushed, so remake that kind from the hash.

Git keeps a deleted branch’s commits for a while, but its grace periods count from the last time HEAD moved onto or off the branch’s tip, usually the switch away from it, and from when its objects were last written, not from when you deleted it. By default garbage collection drops HEAD reflog lines pointing at unreachable commits once they are a month old, and it prunes unreachable objects by how long ago they were last written. A branch you last touched in the spring can be past both limits already, so the next git gc, including one Git starts on its own, may take it.

Or don’t do any of this

GitDesktop’s Clean up branches keeps two answers to the merged question side by side and lets neither stand in for the other. A branch the default branch has reached and moved past (the ancestry test -d makes) starts out checked. One that a recent pull request merged, which is how squash and rebase merges show up when the app is connected to your forge, gets a badge naming that pull request (“merged #41”). In the guide’s words, the match “goes by branch name, so it never pre-checks a row on its own — it badges one for you to confirm.”

Archive is the dialog’s default, with Delete beside it. Archiving folds branches into a collapsed “Archived” section of the switcher and leaves every commit where it is. It is one setting per branch in your repository’s local config, and Unarchive deletes it. Most cleanups only need a shorter list, and an archived branch still points at its commits, so garbage collection leaves them alone.

A squash merge lands your changes and strands your commits.

Written while building GitDesktop.

A desktop Git client for GitHub, GitLab and Bitbucket — free and open source, with AI you control or hide entirely.