Delete a local Git branch once the work has landed

git branch prints forty lines. Nine of them are branches from work that shipped months ago, four are spikes that went nowhere, and two have names nobody can decode. Tab completion has become a menu of archaeology. The instinct is to delete the lot and the hesitation is reasonable, because deleting a branch feels like deleting work.

It is not, and the reason is worth knowing precisely rather than vaguely. A branch in Git is a file containing one commit ID. Deleting the branch deletes the name. The commits stay in the object store until garbage collection decides nothing can reach them, and there is a documented window before that happens. What deleting a branch really removes is the guarantee that those commits remain reachable.

What the refusal is actually checking

The command is short and comes in two forms.

git branch -d old-feature
git branch --delete old-feature

The git branch manual states the condition for the safe form: the branch must be fully merged in its upstream branch, or in HEAD if no upstream was set with --track or --set-upstream-to. Read that twice, because the two halves behave differently and the second half is where surprises live.

For a branch with an upstream, the check is against the upstream. A branch tracking origin/old-feature whose commits are all present in origin/old-feature is deletable, regardless of what your main looks like.

For a branch with no upstream, the check is against HEAD, which means whichever branch happens to be checked out at that moment. That is why the same delete command can be refused while standing on one branch and accepted after switching to another. Nothing about the branch changed. The comparison point moved.

This explains the most common false alarm. Work merged through a pull request with a squash merge or a rebase merge does not appear in main as the same commits. The content landed, the commit IDs did not survive, and git branch -d correctly reports that the branch is not fully merged. The refusal is accurate. It is answering a question about commit reachability, not about whether the feature shipped.

The refusal means the commits are not reachable from the comparison point, not that the work is unfinished. Knowing which of those two situations you are in is the whole decision.

Finding out which branches are genuinely finished

Rather than deleting one at a time and reading refusals, ask the question directly. The manual describes --merged as listing only branches merged into the named commit, meaning the branches whose tip commits are reachable from that commit, and the commit argument defaults to HEAD.

git branch --merged main
git branch --no-merged main

The first list is safe to delete under the ordinary rules. The second needs a look. --no-merged is also the more interesting list day to day, because it is the honest inventory of work in progress.

Two other views make the pile easier to read. Sorting by the date of the last commit puts the stale ones together, and branch.sort makes that the default for every listing:

git branch --sort=-committerdate
git config set --global branch.sort -committerdate

And the -vv form shows each branch with its tip and its upstream in square brackets. A bracket reading [origin/old-feature: gone] is the clearest signal available: the branch had an upstream, the remote branch has been deleted, and the work therefore reached the server and was disposed of there. Those are the strongest candidates for deletion on the list.

When the forced form is the right answer

-D is documented as a shortcut for --delete --force, and --force in combination with -d allows deleting the branch irrespective of its merged status, or whether it even points to a valid commit.

git branch -D spike-graphql

Three situations genuinely call for it. A spike that was never meant to land. A branch whose content reached main through a squash or rebase merge, where the refusal is a technicality. And a branch rebuilt from scratch after a bad rebase, where the old version is the thing being discarded.

The habit worth keeping is to look before forcing. One command shows what is about to lose its name:

git log --oneline main..spike-graphql

Empty output means everything on that branch is already reachable from main, so -d would have worked and something about the upstream configuration is confusing the check. Output means there are commits that only this name currently reaches. That is not a reason to stop, but it is a reason to read the subject lines first.

Command Checks merged status Deletes commits Typical use
git branch -d <b> Yes, against upstream or HEAD No Finished work, normal case
git branch -D <b> No No Spikes, squash-merged branches
git branch -r -d origin/<b> No No Removing a stale remote-tracking ref
git push origin --delete <b> No No Deleting the branch on the server
git fetch --prune Not applicable No Removing every remote-tracking ref whose branch is gone

Note what the third column says throughout. None of these commands delete commits. Only garbage collection does that, and only for objects nothing can reach.

Local, remote-tracking, and remote are three different things

The word branch covers three objects, and deleting one does not touch the others.

The local branch lives in refs/heads/. The remote-tracking ref lives in refs/remotes/origin/ and is a read only record of where the remote's branch was at the last fetch. The branch on the server is on the server.

Deleting the local branch leaves the other two alone, which is why git branch --remotes can list branches that were deleted on the server months ago. The manual's own example notes that deleting remote-tracking branches by hand is temporary: the next fetch or pull will create them again unless the remote is configured not to. Pruning is the durable answer.

git fetch --prune
git config set --global fetch.prune true

With fetch.prune set, every fetch removes remote-tracking refs whose branches no longer exist on the remote, which is what makes the gone marker in git branch -vv trustworthy. Tags are governed separately by fetch.pruneTags, and the fetch manual notes it is reasonable to configure that globally to keep a one to one mapping with upstream refs.

Deleting the branch on the server is a push with a flag:

git push origin --delete old-feature

On a hosted service this is often unnecessary. A repository can delete the head branch itself after a pull request merges, through the setting GitHub documents under Settings, then General, then the Pull Requests section, labelled Automatically delete head branches. Turning it on removes one manual step per merge, and combined with fetch.prune it means stale names disappear from your machine without anyone tidying.

One more case. A branch checked out in a linked worktree cannot be deleted while that worktree exists, and git branch highlights such branches in cyan with a plus sign to make them visible in a listing. Remove the worktree first, then the branch.

Clearing a long list without an accident

The obvious move for forty branches is to pipe a list into a delete command, and it is also where the mistakes happen. git branch --merged main is built for people to read, not for programs to consume. The current branch arrives with an asterisk in front of it, every other line arrives with two spaces of padding, and a branch checked out in a linked worktree arrives with a plus sign, which the manual describes alongside the colours it uses for each case. Feeding that output straight into xargs git branch -d sends asterisks and whitespace along as if they were branch names.

git for-each-ref exists for exactly this. It prints nothing except what the format asks for.

git for-each-ref --merged main --format='%(refname:short)' refs/heads/

Same question, no decoration. The manual gives --merged[=<object>] the same meaning it has in git branch: only refs whose tips are reachable from the named commit, defaulting to HEAD when the commit is left out. Restricting the pattern to refs/heads/ keeps remote-tracking refs and tags out of the answer, which a bare --merged on git branch -a would not.

From there, two habits keep it safe. The first is to exclude the names that have to survive, before anything destructive is attached to the pipeline:

git for-each-ref --merged main --format='%(refname:short)' refs/heads/ \
  | grep -vE '^(main|develop|release/.*)$'

The second is to read that output once, on its own, and only then add the delete. The list is the decision. The delete is bookkeeping.

... | xargs -n 1 git branch -d

-n 1 earns its place. One branch per invocation means a refusal stops that branch and not the rest of the run, so a surprise in the middle does not quietly take twenty names down with it. Keeping -d rather than -D in a loop is the other half of the same idea: the refusals are the point, and they are cheap to read afterwards.

The same shape produces the gone list without anyone squinting at brackets. %(upstream:track) prints [gone] when the upstream ref can no longer be found, which the for-each-ref manual documents next to the ahead and behind counters:

git for-each-ref --format='%(refname:short) %(upstream:track)' refs/heads/ \
  | grep '\[gone\]'

Those are the branches whose work reached the server and was disposed of there. On a repository where fetch.prune is on, that is about as close to an authoritative list of finished work as Git will hand over, and unlike --merged it does not care whether the merge was a squash.

One caveat applies to all of it. Neither --merged nor the gone marker knows anything about work that was never pushed and never merged. A branch carrying local-only commits with no upstream configured appears in neither list, which is the correct outcome rather than a gap, and worth remembering before reaching for a wider filter to catch the last few names.

Getting a branch back after deleting it

The commits survive the deletion. Recovering the name means finding the commit ID and pointing a new branch at it.

The catch is that deleting a branch deletes that branch's own reflog along with the ref, so git reflog old-feature will not help. What does help is the HEAD reflog, which the manual notes records branch switching in addition to recent actions. If the branch was ever checked out, HEAD's reflog holds the commit it was at.

git reflog
git branch old-feature <commit>

git branch accepts a start point as its second argument, so this recreates the name at that commit without checking anything out. The commit from a git branch -D run also appears in the command output itself, which prints the abbreviated ID it deleted, so a terminal scrollback is often the fastest source.

When the reflog has already been trimmed, the remaining route is to look for commits nothing points at:

git fsck --unreachable --no-reflogs
git fsck --lost-found

The second form is easier to work with. The fsck manual documents --lost-found as writing dangling objects into .git/lost-found/commit/ or .git/lost-found/other/ depending on type, which turns the search into reading a folder rather than parsing command output. That only helps if folders beginning with a dot are visible, which is one of the small practical differences between file managers.

There is a clock on this. gc.reflogExpire removes reflog entries older than its value and defaults to 90 days. gc.reflogExpireUnreachable applies to entries not reachable from the current tip and defaults to 30 days, which the manual explains is deliberately more aggressive because such entries usually come from git commit --amend or git rebase. A deleted branch's commits fall in the second category. Roughly a month is the realistic recovery window, not forever.

What to change first

Set fetch.prune to true and branch.sort to -committerdate, then run git branch -vv and delete everything marked gone, since those branches reached the server and were removed there. Keep -d as the default and treat its refusal as information rather than an obstacle. If the branch listing is the thing that has become unreadable, a window where the folder, the shell and the file being edited sit together makes the tidying a two minute job rather than a task to postpone, which is the working shape Atriens is built for.

Frequently asked questions

Does deleting a local branch delete the commits?

No. A branch is a name pointing at one commit, and deleting it removes the name. The commits stay in the repository until garbage collection removes objects nothing can reach, and the reflog keeps them reachable for a documented period first. That is why recovery is usually a matter of finding the commit ID.

Why does git say the branch is not fully merged when the pull request was merged?

Because a squash merge or a rebase merge creates new commits rather than bringing yours across unchanged. The content landed, the commit IDs did not, and the check looks at commit reachability. The refusal is technically correct, and -D is the appropriate response once git log --oneline main..<branch> confirms the subject lines are ones that shipped.

How do I delete a branch on the remote as well?

git push origin --delete <branch> removes it on the server. Deleting your local branch does not affect the remote, and deleting the remote branch does not remove your local one or your remote-tracking ref. git fetch --prune cleans up the remote-tracking refs afterwards.

Can I delete the branch I am currently on?

No. The safe form compares against HEAD when no upstream is set, and the branch HEAD points at cannot be removed while it is checked out. Switch to another branch first. The same applies to any branch checked out in a linked worktree, which has to be removed before the branch can go.

How do I delete many merged branches at once?

Start from git branch --merged main, read the list, and remove anything that should be kept before passing the rest to git branch -d. Piping the list straight into a delete command is where accidents happen, because the output includes the current branch and can include long lived branches such as a release line.

How long do I have to recover a deleted branch?

gc.reflogExpireUnreachable defaults to 30 days and governs entries not reachable from the current tip, which is where a deleted branch's commits sit. gc.reflogExpire defaults to 90 days for reachable entries. Treat a month as the practical window, and recover from git reflog plus git branch <name> <commit> well inside it.

Back to all posts