The branch is gone from git branch, but the commits are not deleted — they
exist as unreachable objects, pinned by the reflog of whichever ref most
recently pointed at them, and reachable via git fsck --unreachable until the
reflog entries expire. Recovery is a matter of finding the right SHA before
that window closes.
← All break/fix scenarios in Git, CI/CD & GitOps
Deleted branch recovery via reflog
Reported symptoms
- ●A feature branch that has been active for three days is gone from `git branch`
- ●The remote tracking branch `origin/feature/payments-csv` does not exist either
- ●No PR exists against `main` for that work; the branch was never merged
- ●A teammate says "I cleaned up some stale branches yesterday with `git branch -D`"
- ●The team can name the last commit message on the missing branch but cannot name its SHA
- ●Nothing in the team chat or CI references a successful push of that branch
Evidence
- · `git reflog --all | grep -i payments-csv` is empty — the local reflog records branch checks, not branch deletions, once the branch ref itself is gone
- · `git reflog` shows two `checkout:` entries for the engineer who last held the branch: `payments-csv → main`
- · `git fsck --unreachable --no-reflogs` shows three commit objects with the messages from the missing branch, plus their trees and blobs
- · `git fsck --unreachable` (with reflogs) shows the same three commits plus more — the reflog is keeping them alive
- · `git log --all --oneline --source` does not show the branch tip on any named ref
- · The PR list for the repo on GitHub contains no record of a branch named `feature/payments-csv`
- · `git for-each-ref` returns no ref whose name contains `payments-csv`
Diagnosis and resolutionclick to reveal
Root cause
`git branch -D feature/payments-csv` deletes the ref pointing at the branch tip. It does not delete the commits, the trees, or the blobs — those objects are pinned by the reflog of whichever ref most recently pointed at them (typically HEAD at the time of checkout, or `ORIG_HEAD` after a merge) and remain reachable until that reflog entry expires or `git gc --prune=now` runs. The recovery window is therefore not "until the next garbage collect" but "until the relevant reflog entry expires" — a default of 90 days for reachable entries, 30 for unreachable ones. The work was recoverable in this incident because the engineer who deleted the branch did not, in the same command, run a prune; the team would have been stuck only if `git gc --prune=now --aggressive` had been executed in the intervening hours on a bare repo that no one remembered to check.
Remediation
Use `git fsck --unreachable --no-reflogs` to enumerate the dangling commits, then `git log <dangling-sha>` on each until the branch tip is identified by its commit message. Create a fresh branch at that SHA with `git branch feature/payments-csv <sha>` and verify the tree with `git checkout` plus a working-tree diff against what the engineer remembers. Push the recovered branch under a new name (for example `feature/payments-csv-recovered`) so the recovery does not depend on a force push, then rebase or merge any work that depended on the original branch into the recovered one. If the engineer had run `git reflog show feature/payments-csv` before deletion, that reflog would have shown the full SHA list directly — the lesson to capture is that deletion is recoverable up to a point, but only if the recovery starts before the reflog expires.
Verification
The recovered branch contains all three days of commits in the original order. The working tree of a checkout at each commit matches the engineer's memory — not just the tip but at least the merge-base and one mid-history commit, so a corrupted object in the middle of the chain cannot pass verification by hiding under a healthy tip. The branch is on the remote. A `git fsck --unreachable` after recovery does not report any commits that were reachable through the recovered reflog entries — meaning the recovery did not orphan anything else. The deletion itself is captured in a runbook entry so the next person does not have to discover the recovery path from scratch.
Prevention
Treat a branch deletion as a destructive operation and let it be governed by the same control surface as a force push. Configure `receive.denyDeletes` on shared repositories so the remote can refuse a branch deletion, configure `branch.<name>.pushRemote` and a matching branch-protection rule that pins the remote ref, and run a daily job that pushes every local branch to a backup remote under the same name — so even an accidental local `-D` has a mirror that survives. Most importantly, refuse to delete a branch with `git branch -d` (lowercase, refuses if not merged) and require an explicit `--force` for `-D`, so that "clean up stale branches" cannot mean "delete branches we have not reviewed".