After a few months on a shared repository, the branch list starts to look like an archaeological dig. There are feature branches from tickets closed six months ago, a hotfix-DO-NOT-DELETE that nobody dares touch, and a dozen origin/feature-* tracking refs for branches that no longer exist on the remote. Understanding exactly how local branches, remote branches, and remote tracking refs relate to each other is the foundation of keeping this under control.
The Mental Model: Local vs Remote Tracking Branches#
Before touching any delete commands, it helps to have a clear picture of what Git actually stores.
graph LR
subgraph "Your Machine"
A[main] -->|tracks| B[origin/main]
C[feature-x] -->|tracks| D[origin/feature-x]
E[old-branch]
end
subgraph "GitHub / GitLab"
F[main]
G[feature-x]
end
B -.->|last fetched state of| F
D -.->|last fetched state of| G
origin/main is not the remote branch itself. It is your local copy of what the remote looked like the last time you ran git fetch. This distinction matters when you start pruning stale refs.
Listing Branches#
Before you delete anything, know what you have.
# Local branches only
git branch
# Remote tracking branches only
git branch -r
# All branches (local + remote tracking)
git branch -a
# Branches already merged into main
git branch --merged main
# Branches not yet merged into main
git branch --no-merged mainThe --merged and --no-merged filters are the safest way to identify what is safe to remove. Any branch in --merged main has already had its work incorporated, so the commits are not going anywhere even if you delete the branch pointer.
Deleting Local Branches#
# Safe delete: refuses if branch is not fully merged
git branch -d feature/login
# Force delete: bypasses the merge check
git branch -D feature/loginThe -d vs -D distinction is not arbitrary. -d is a guard: Git checks whether the tip of the branch you want to delete is reachable from HEAD or from the upstream tracking branch. If the answer is no, it refuses. -D overrides that check entirely.
git branch -D on unmerged work destroys commits that are not reachable from any other ref. The reflog will save you for 30 days, but after that the objects are garbage collected. Do not force-delete without double-checking git log feature/branch --oneline first.
You cannot delete the branch you are currently on. Switch to main (or any other branch) first:
git switch main
git branch -d feature/loginDeleting Remote Branches#
There are two syntaxes that achieve the same thing:
# Modern, explicit syntax (preferred)
git push origin --delete feature/login
# Legacy colon syntax (still works, same effect)
git push origin :feature/loginThe colon syntax comes from Git’s refspec format: src:dst. An empty src means “push nothing to this destination”, which deletes the ref on the remote. It is worth knowing because you will encounter it in older scripts and documentation.
Deleting a remote branch does not automatically remove the origin/feature/login tracking ref from your local repository. That requires pruning.
Pruning Stale Remote Tracking Refs#
Once a branch is deleted on the remote, your local origin/feature/login ref is orphaned. It points to a remote that no longer exists. Git calls these “stale” tracking refs.
# Fetch and prune in one step (recommended for day-to-day use)
git fetch --prune
# Prune without fetching new updates
git remote prune originThe difference: git fetch --prune contacts the remote, downloads any new refs, and removes any local tracking refs that are no longer on the remote. git remote prune origin only removes stale tracking refs – it does not download anything new. For most workflows, git fetch --prune is what you want.
You can also configure Git to prune automatically on every fetch:
git config --global fetch.prune trueWith this setting, git fetch behaves like git fetch --prune everywhere.
Renaming Branches#
Renaming a local branch is straightforward:
# Rename the current branch
git branch -m new-name
# Rename a specific branch (while on a different branch)
git branch -m old-name new-nameTo propagate the rename to the remote, you need to push the new name and delete the old one:
git push origin new-name
git push origin --delete old-name
# Update the upstream tracking for your local branch
git branch --set-upstream-to=origin/new-name new-nameIf your team has opened a pull request against the old branch name on GitHub or GitLab, renaming will require updating the PR’s base branch before deleting the old remote ref.
Protecting Branches from Accidental Deletion#
Platform-level branch protection rules are your last line of defense against mistakes that git branch -D cannot protect against.
On GitHub: Settings > Branches > Branch protection rules. Check “Restrict deletions” to prevent anyone (including admins) from pushing a delete ref for the protected branch.
On GitLab: Settings > Repository > Protected branches. Set “Allowed to delete” to “No one” for branches like main and release.
Branch protection rules do not prevent local deletion with git branch -D. They only block the git push --delete that would remove the branch from the remote. Local branches are always under your own control.
Quick Reference#
| Goal | Command |
|---|---|
| Delete local (safe) | git branch -d branch-name |
| Delete local (force) | git branch -D branch-name |
| Delete remote | git push origin --delete branch-name |
| List merged into main | git branch --merged main |
| Prune stale tracking refs | git fetch --prune |
| Rename current branch | git branch -m new-name |
If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.