Recover a Deleted Git Branch Before Its Commit Is Pruned
Last edited on August 14, 2026

At 12:02 UTC, incident/checkout-fix pointed to a two-commit change in a Git 2.47.3 fixture. Seconds later, the lab deleted the branch, leaving main unchanged and the work without a normal ref. The lost tip still appeared in the reflog. After the lab deliberately expired that reflog, git fsck found two unreachable commits and reported one dangling tip. Creating refs/heads/rescue/checkout-fix made the exact tip, its parent, and its file content reachable again.

One separate copy established the limit. Once that copy had no rescue ref, no reflog entry, and had run git gc --prune=now, the same commit lookup exited 128. Recovery depends on an object still existing somewhere; a command cannot reconstruct an object pruned from every repository and backup.

This guide is for a developer responding to a deleted local branch or reset that hid unpushed work. It preserves evidence first, searches the least invasive sources, inspects candidates without changing the worktree, creates one rescue ref, and proves content and ancestry before cleanup. The destructive expiry/pruning control runs only inside a marker-owned disposable copy.

Preserve the Object Database Before You Search

Treat the repository as incident evidence. Stop IDE background fetches, scheduled maintenance, deployment jobs, and anyone else who could run git gc, git maintenance, reflog expiration, repacking, or another history rewrite. Do not start with git clean, a fresh clone, or a new checkout. None of those actions is needed to name a surviving object, and a clone may omit unreachable work.

First identify the storage boundary. git rev-parse --path-format=absolute --git-common-dir shows the common Git directory used by a normal checkout or linked worktree. If .git is a file, copying only that worktree is not enough; preserve the repository that owns the common object directory too. For a bare repository, preserve the entire bare directory.

Take a filesystem, volume, or hypervisor snapshot after writers are quiesced. If you make a file-level copy, copy the complete owning repository rather than using git clone. Work only on the copy until you have a verified rescue ref. Also inventory alternate object stores with git rev-parse --git-path objects and objects/info/alternates; a repository can depend on objects held elsewhere.

Worktree selection and object availability are different. A sparse checkout can hide paths without deleting their blobs, while a partial clone can defer objects to a promisor remote. Review the sparse-checkout and partial-clone object boundary before assuming that one local clone is complete.

Input one creates an isolated repository with deterministic commits, disables automatic maintenance only inside that fixture, deletes only the incident branch, and installs marker-checked cleanup. Run all seven tested inputs in the same Bash session. An existing receipt path causes an early stop.

set -Eeuo pipefail
LAB_DIR="$(mktemp -d /tmp/voxfor-git-recovery-179.XXXXXX)"
REPO="$LAB_DIR/repository"
PRUNE_COPY="$LAB_DIR/prune-control"
MARKER="$LAB_DIR/.voxfor-owned"
RECEIPT="$LAB_DIR/receipt.txt"
RECEIPT_COPY="$PWD/git-recovery-receipt-179.txt"

cleanup() {
  if [[ -d "${LAB_DIR:-}" && -f "${MARKER:-}" ]] \
     && [[ "$(<"$MARKER")" == "voxfor-git-recovery-179" ]] \
     && [[ "$LAB_DIR" == /tmp/voxfor-git-recovery-179.* ]]; then
    find "$LAB_DIR" -depth -mindepth 1 -delete
    rmdir "$LAB_DIR"
  fi
}
trap cleanup EXIT
for command_name in git awk grep sha256sum cmp find; do
  command -v "$command_name" >/dev/null
done
[[ ! -e "$RECEIPT_COPY" ]]
printf '%s\n' 'voxfor-git-recovery-179' > "$MARKER"

git init -q -b main "$REPO"
git -C "$REPO" config user.name 'Voxfor Lab'
LAB_EMAIL_LOCAL='lab'
LAB_EMAIL_DOMAIN='invalid.example'
git -C "$REPO" config user.email "${LAB_EMAIL_LOCAL}@${LAB_EMAIL_DOMAIN}"
git -C "$REPO" config gc.auto 0
git -C "$REPO" config maintenance.auto false
printf '%s\n' 'checkout-policy=v1' > "$REPO/README.md"
GIT_AUTHOR_DATE='2026-08-14T12:00:00Z' \
GIT_COMMITTER_DATE='2026-08-14T12:00:00Z' \
  git -C "$REPO" add README.md
GIT_AUTHOR_DATE='2026-08-14T12:00:00Z' \
GIT_COMMITTER_DATE='2026-08-14T12:00:00Z' \
  git -C "$REPO" commit -q -m 'baseline checkout policy'
MAIN_TIP="$(git -C "$REPO" rev-parse main)"

git -C "$REPO" switch -q -c incident/checkout-fix
printf '%s\n' 'timeout_ms=2500' 'retry_limit=3' > "$REPO/checkout.conf"
GIT_AUTHOR_DATE='2026-08-14T12:01:00Z' \
GIT_COMMITTER_DATE='2026-08-14T12:01:00Z' \
  git -C "$REPO" add checkout.conf
GIT_AUTHOR_DATE='2026-08-14T12:01:00Z' \
GIT_COMMITTER_DATE='2026-08-14T12:01:00Z' \
  git -C "$REPO" commit -q -m 'add checkout timeout guard'
LOST_PARENT="$(git -C "$REPO" rev-parse HEAD)"
printf '%s\n' 'failure_policy=hold_order' >> "$REPO/checkout.conf"
GIT_AUTHOR_DATE='2026-08-14T12:02:00Z' \
GIT_COMMITTER_DATE='2026-08-14T12:02:00Z' \
  git -C "$REPO" add checkout.conf
GIT_AUTHOR_DATE='2026-08-14T12:02:00Z' \
GIT_COMMITTER_DATE='2026-08-14T12:02:00Z' \
  git -C "$REPO" commit -q -m 'complete checkout failure policy'
LOST_TIP="$(git -C "$REPO" rev-parse HEAD)"
LOST_TREE="$(git -C "$REPO" rev-parse "$LOST_TIP^{tree}")"
EXPECTED_FILE_SHA256="$(git -C "$REPO" show "$LOST_TIP:checkout.conf" \
  | sha256sum | awk '{print $1}')"

git -C "$REPO" switch -q main
git -C "$REPO" branch -D incident/checkout-fix > "$LAB_DIR/branch-delete.txt"

Every object and path changed later belongs to the fixture. A real incident does not need these setup commits; begin from the preserved copy, retain the affected repository’s object format, and never disable or rewrite organization-wide maintenance as a recovery shortcut.

Search the Cheapest Surviving Name First

Before scanning objects, ask whether another name already reaches the work. Check local branches, tags, remote-tracking refs, stashes, pull-request refs, teammates’ complete clones, deployment mirrors, and server backups. A commit reachable from any trustworthy ref is cheaper and less ambiguous than a raw object search.

A remote can help only if the work was pushed. An ephemeral or shallow CI checkout is not a reliable authority for a developer’s unpushed branch; a self-hosted GitHub Actions runner may still contain a useful checkout, but verify its fetch depth, promisor state, repository identity, and retention before relying on it. If a complete Forgejo repository or backup exists, use a tested Forgejo restore rehearsal rather than copying isolated object files by hand.

Useful read-only inventories include git for-each-ref, git log --all --decorate, and git stash list. Record any candidate SHA and the ref that supplied it. Do not merge, reset, or switch yet.

Let the Reflog Name the Lost Tip

A reflog records recent updates to local ref tips and HEAD; it is not part of the shared commit graph and is not normally transferred by fetch or clone. The current git-reflog manual documents both the local log and its expiry controls. Search it before fsck because it supplies time, action, and old tip context.

Captured reflogs include all local entries with full object IDs and ISO dates, requires the exact deleted tip to be present, then records the environment and incident identities.

git -C "$REPO" reflog --all --date=iso \
  --format='%H%x09%gd%x09%gs' > "$LAB_DIR/reflog-before-expire.txt"
grep -F "$LOST_TIP" "$LAB_DIR/reflog-before-expire.txt" >/dev/null
printf 'environment\tgit=%s\tobject_format=%s\tlab_scope=marker_owned_tmp\n' \
  "$(git --version | awk '{print $3}')" \
  "$(git -C "$REPO" rev-parse --show-object-format)" | tee "$RECEIPT"
printf 'deleted_reference\tmain=%s\tlost_parent=%s\tlost_tip=%s\treflog_hit=yes\n' \
  "$MAIN_TIP" "$LOST_PARENT" "$LOST_TIP" | tee -a "$RECEIPT"

On an affected copy, inspect entries rather than trusting a memorable subject alone:

git -C /path/to/preserved-copy reflog --all --date=iso \
  --format='%H%x09%gd%x09%gs'

Multiple commits can share a subject. Keep the full object ID and corroborate author time, parents, tree, changed paths, and file content before creating a ref.

Use git fsck When Every Name Is Gone

Git’s git-fsck manual distinguishes objects reachable from heads, tags, the index, reflogs, and other roots from objects that are unreachable. A dangling commit is an unreachable commit that no other unreachable object uses; it is often the tip of a lost chain. The earlier commits in that chain can be unreachable without each being listed as dangling.

Use git fsck --full --no-reflogs --unreachable on the preserved copy when refs and reflogs do not identify the work. The --no-reflogs flag asks what would be unreachable without reflog roots; it does not itself expire or delete a reflog. Do not confuse it with git reflog expire.

To prove the fallback deterministically, the next block expires reflogs inside the disposable fixture only. The repeated marker and exact-path checks are mandatory. Never adapt the expiry line to an affected repository.

[[ -f "$MARKER" ]]
[[ "$(<"$MARKER")" == "voxfor-git-recovery-179" ]]
[[ "$REPO" == "$LAB_DIR/repository" ]]
git -C "$REPO" reflog expire --expire=now --expire-unreachable=now --all
git -C "$REPO" fsck --full --no-reflogs --unreachable \
  > "$LAB_DIR/unreachable.txt" 2> "$LAB_DIR/unreachable.err"
grep -F "unreachable commit $LOST_TIP" "$LAB_DIR/unreachable.txt" >/dev/null
grep -F "unreachable commit $LOST_PARENT" "$LAB_DIR/unreachable.txt" >/dev/null
git -C "$REPO" fsck --full --no-reflogs \
  > "$LAB_DIR/dangling.txt" 2> "$LAB_DIR/dangling.err"
grep -F "dangling commit $LOST_TIP" "$LAB_DIR/dangling.txt" >/dev/null
UNREACHABLE_COMMIT_COUNT="$(
  awk '$1=="unreachable" && $2=="commit" {count++} END {print count+0}' \
    "$LAB_DIR/unreachable.txt"
)"
DANGLING_COMMIT_COUNT="$(
  awk '$1=="dangling" && $2=="commit" {count++} END {print count+0}' \
    "$LAB_DIR/dangling.txt"
)"
printf 'object_discovery\tunreachable_commits=%s\tdangling_tips=%s\tlost_tip_found=yes\tlost_parent_found=yes\n' \
  "$UNREACHABLE_COMMIT_COUNT" "$DANGLING_COMMIT_COUNT" | tee -a "$RECEIPT"

Save the raw fsck output. Large repositories can contain old rebases, abandoned merges, temporary commits, and pack artifacts; a dangling result is a candidate list, not proof that every object is valuable or that the first SHA is your work.

Inspect a Candidate Without Moving the Worktree

A rescue branch should point to a commit, not a tree or blob. Confirm the type with git cat-file -e "$SHA^{commit}". Then use git show --no-patch, git diff --stat, git ls-tree, and git show "$SHA:path" to inspect identity and content without checking out the candidate or changing the index.

Candidate inspection requires the tip and tree to exist, captures the full commit ID, parent, subject, and author timestamp, and proves that the expected file appears in the diff.

git -C "$REPO" cat-file -e "$LOST_TIP^{commit}"
git -C "$REPO" cat-file -e "$LOST_TREE^{tree}"
git -C "$REPO" show --no-patch --format='%H%n%P%n%s%n%aI' "$LOST_TIP" \
  > "$LAB_DIR/inspection.txt"
grep -Fx 'complete checkout failure policy' "$LAB_DIR/inspection.txt" >/dev/null
git -C "$REPO" diff --stat main.."$LOST_TIP" \
  > "$LAB_DIR/diff-stat.txt"
grep -F 'checkout.conf' "$LAB_DIR/diff-stat.txt" >/dev/null
printf 'inspection\tsubject=%s\tparent=%s\ttree=%s\tchanged_path=checkout.conf\n' \
  "$(git -C "$REPO" show -s --format=%s "$LOST_TIP")" \
  "$(git -C "$REPO" show -s --format=%P "$LOST_TIP")" \
  "$LOST_TREE" | tee -a "$RECEIPT"

On a real incident, compare the candidate with the intended base in both directions. Inspect every commit in the recovered-only range, verify sensitive or generated files are not accidentally included, and ask the author to confirm the change. If several candidates look plausible, create separate namespaced rescue refs rather than guessing which history should replace a shared branch.

Once the history is durable, run Git bisect against the rescued range only if the problem is a regression. Bisect is not a recovery mechanism; it should not start until the missing commits have a stable name.

Create a Rescue Ref, Then Prove Reachability and Content

Git’s git-branch manual allows a branch to be created directly at a start point. A namespaced name such as rescue/checkout-fix keeps recovery separate from the deleted branch and avoids pretending the history is ready to merge or deploy.

Creating the ref is necessary but insufficient. Resolve the new ref back to the expected full SHA, verify parent ancestry, inspect at least one known path or tree, compare an expected content hash when available, and rerun fsck --unreachable. In the fixture, the lost tip must disappear from the unreachable report.

git -C "$REPO" branch rescue/checkout-fix "$LOST_TIP"
RECOVERED_TIP="$(git -C "$REPO" rev-parse refs/heads/rescue/checkout-fix)"
[[ "$RECOVERED_TIP" == "$LOST_TIP" ]]
git -C "$REPO" merge-base --is-ancestor "$LOST_PARENT" rescue/checkout-fix
git -C "$REPO" cat-file -e "rescue/checkout-fix:checkout.conf"
RECOVERED_FILE_SHA256="$(
  git -C "$REPO" show rescue/checkout-fix:checkout.conf \
    | sha256sum | awk '{print $1}'
)"
[[ "$RECOVERED_FILE_SHA256" == "$EXPECTED_FILE_SHA256" ]]
git -C "$REPO" fsck --full --no-reflogs --unreachable \
  > "$LAB_DIR/after-recovery.txt" 2> "$LAB_DIR/after-recovery.err"
if grep -F "$LOST_TIP" "$LAB_DIR/after-recovery.txt" >/dev/null; then
  printf '%s\n' 'Recovered tip still reported unreachable' >&2
  exit 1
fi
printf 'recovered\tref=refs/heads/rescue/checkout-fix\ttip=%s\tparent_reachable=yes\tfile_sha256=%s\tfsck_unreachable=no\n' \
  "$RECOVERED_TIP" "$RECOVERED_FILE_SHA256" | tee -a "$RECEIPT"

Do not force-update the original shared branch during incident recovery. Push the rescue ref to a protected remote only after repository owners agree that it contains no secrets and the remote policy accepts it. Open a review from the rescue ref, then decide whether to merge, cherry-pick, or restore the original branch name.

Deployment automation should retain an immutable release identity instead of relying only on a moving branch. A Git-based VPS deployment with an explicit rollback is easier to investigate when every release maps to a durable commit or tag.

Treat Expiry Plus Pruning as the Destructive Boundary

Reflog expiration removes a recovery root; pruning can remove the object itself once no live root reaches it and the object satisfies the applicable expiry policy. The current git-gc documentation warns that immediate pruning increases corruption risk when another process is writing. Maintenance configuration, reflog policy, object age, cruft packs, alternates, hosting implementation, and concurrent operations all affect retention, so “you always have 30 days” is not a recovery guarantee.

Only the separate negative-control copy undergoes destructive maintenance. It copies the marker-owned fixture, confirms the rescue SHA in that copy, deletes only the copy’s rescue branch, expires only the copy’s reflog, and runs immediate pruning there. The old tip must become unreadable. Do not run this block on real evidence.

[[ -f "$MARKER" ]]
[[ "$(<"$MARKER")" == "voxfor-git-recovery-179" ]]
[[ "$PRUNE_COPY" == "$LAB_DIR/prune-control" ]]
mkdir "$PRUNE_COPY"
cp -a "$REPO/." "$PRUNE_COPY/"
[[ "$(git -C "$PRUNE_COPY" rev-parse rescue/checkout-fix)" == "$LOST_TIP" ]]
git -C "$PRUNE_COPY" branch -D rescue/checkout-fix \
  > "$LAB_DIR/prune-branch-delete.txt"
git -C "$PRUNE_COPY" reflog expire --expire=now --expire-unreachable=now --all
git -C "$PRUNE_COPY" gc --prune=now
set +e
git -C "$PRUNE_COPY" cat-file -e "$LOST_TIP^{commit}" \
  2> "$LAB_DIR/pruned-cat-file.err"
PRUNED_LOOKUP_RC=$?
set -e
[[ "$PRUNED_LOOKUP_RC" -ne 0 ]]
grep -F 'Not a valid object name' "$LAB_DIR/pruned-cat-file.err" >/dev/null
printf 'prune_boundary\tcopy_only=yes\tgc_prune=now\tlost_tip_lookup_rc=%s\tlost_tip_present=no\n' \
  "$PRUNED_LOOKUP_RC" | tee -a "$RECEIPT"

Finally, input seven validates every state, saves the receipt outside the lab, removes only the marker-owned directory, and proves the fixture is absent.

grep -F $'deleted_reference\tmain=' "$RECEIPT" >/dev/null
grep -F $'object_discovery\tunreachable_commits=2\tdangling_tips=1' \
  "$RECEIPT" >/dev/null
grep -F $'inspection\tsubject=complete checkout failure policy' \
  "$RECEIPT" >/dev/null
grep -F $'recovered\tref=refs/heads/rescue/checkout-fix' \
  "$RECEIPT" >/dev/null
grep -F $'prune_boundary\tcopy_only=yes' "$RECEIPT" >/dev/null
cp "$RECEIPT" "$RECEIPT_COPY"
cleanup
trap - EXIT
[[ ! -e "$LAB_DIR" ]]
printf 'cleanup\tlab_absent=yes\n' | tee -a "$RECEIPT_COPY"

Both full fixture runs were reproduced on 2026-08-14. Both runs produced the same Git version, object IDs, counts, tree, subject, file hash, recovery result, pruning failure, and cleanup result.

environment git=2.47.3 object_format=sha1 lab_scope=marker_owned_tmp
deleted_reference main=651d37b67c7a39fca1de294210d8bd5baacdeec5 lost_parent=b8d223df3d5edf8fe05fac29a9968db8bf2f934e lost_tip=66eb6805aa9ac9d2fb45fdd1d3dc6d771a16f764 reflog_hit=yes
object_discovery unreachable_commits=2 dangling_tips=1 lost_tip_found=yes lost_parent_found=yes
inspection subject=complete checkout failure policy parent=b8d223df3d5edf8fe05fac29a9968db8bf2f934e tree=f6544d213a96ad9faa365b5d56e05a66104ee808 changed_path=checkout.conf
recovered ref=refs/heads/rescue/checkout-fix tip=66eb6805aa9ac9d2fb45fdd1d3dc6d771a16f764 parent_reachable=yes file_sha256=6fa271fb740c5286e51006cdf57fa68652069a34796759b1beb593b3ffe19b4a fsck_unreachable=no
prune_boundary copy_only=yes gc_prune=now lost_tip_lookup_rc=128 lost_tip_present=no
cleanup lab_absent=yes

Accept the lab only when the deleted tip is present in the reflog, both lost commits appear in the no-reflog unreachable set, only the chain tip is dangling, candidate object types and the expected changed path match, the rescue ref resolves to the exact full SHA, its parent and file content are reachable, the rescued tip is absent from the unreachable report, the isolated prune copy can no longer read it, and marker-owned cleanup leaves the saved receipt. Accept a real recovery only after the preserved source identity, commit type, parents, tree or known file content, author confirmation, new ref, and a second complete repository or protected remote all agree.

If any candidate identity, ancestry, tree, file hash, or ownership check fails, stop: retain the untouched snapshot, do not expire reflogs or run pruning, and create no shared branch update. If a rescue ref points at the wrong surviving object, first create a correctly verified ref or preserve the object ID elsewhere, then delete only the mistaken rescue ref; removing a name is not rollback if it was the object’s last root. If every available repository reports the object missing, switch to backups, patches, editor local history, build artifacts, or a teammate’s complete clone and document that Git-object recovery ended at the pruning boundary.

Deleted Branch Recovery Questions

Does deleting a Git branch delete its commits?

Deleting a branch removes one reference. Commits remain available while another ref, reflog entry, index state, alternate, or other object still keeps them reachable—or while their unreachable objects have not been pruned. Recovery is possible only while the required commit, tree, and blob objects still exist.

Should I use git reflog or git fsck first?

Use refs, remotes, and the local reflog first because they provide context about names and updates. Use git fsck --full --no-reflogs --unreachable as a fallback when those names are gone. Preserve the repository before either search and inspect every candidate before creating a branch.

What is the difference between a dangling and an unreachable commit?

An unreachable commit is not reachable from the roots Git is considering. A dangling commit is an unreachable commit that no other unreachable object points to, so it often represents the tip of a lost chain. Earlier commits in the same chain may be unreachable without each being dangling.

How long does Git keep a deleted branch recoverable?

There is no safe universal deadline. Reflog expiry, object age, garbage-collection settings, maintenance schedules, cruft packs, alternates, and hosting platforms differ. Freeze maintenance and preserve the complete object database immediately instead of planning around a default retention period.

Can I run git gc while recovering a lost commit?

Do not run garbage collection, reflog expiry, aggressive repacking, or pruning on the affected repository during recovery. The lab’s immediate pruning runs only in a separate marker-owned copy to demonstrate the failure boundary. Keep the verified rescue ref and at least one complete second copy before normal maintenance resumes.

Can a remote, shallow clone, or partial clone restore the work?

A remote can return the commit only if it received that object through a push or another retained ref. A shallow clone may lack older parents, and a partial clone may rely on a promisor remote for omitted objects. Verify object existence with git cat-file, ancestry, fetch depth, and the common object directory rather than assuming every clone is complete.

Can I recover the commit without checking it out?

Yes. git cat-file, git show, git diff, and git ls-tree can inspect an object directly. Create a namespaced rescue branch at the verified SHA only after those checks. You do not need to switch the worktree, reset the current branch, or modify the index to make the commit reachable.

Should the recovered object become a branch or a tag?

Use a rescue branch when the recovered history needs review, additional commits, cherry-picking, or merging. Use an annotated tag for a fixed evidence point or released identity. During an incident, a namespaced rescue branch is usually easier to review; decide the permanent shared name after content and ownership are confirmed.

Leave the Commit Under a Durable Name

A successful fsck search is not the finish line. Recovery is complete when a verified ref names the exact commit, the required trees and blobs are readable, the parent chain matches the intended history, another complete repository or protected remote retains it, and the team has decided how the work reenters normal review.

Record the affected repository, common object directory, object format, full rescued SHA, old and new ref names, discovery source, author confirmation, content or tree hashes, verifier, timestamp, secondary-copy location, and maintenance hold/release. That receipt turns a lucky local discovery into a repeatable recovery decision—and keeps the next cleanup job from becoming the event that destroys the last copy.

Share this Post

Leave a Reply

Your email address will not be published. Required fields are marked *