Skip to main content
RunBook Academy

← All labs in Git, CI/CD & GitOps

Lab · intermediate · ~50 min

Lab 5: Recover a "deleted" branch using the reflog

C · SimulationB · Nested virtualisation

Objectives

  • Delete a branch with `git branch -D` and prove that the underlying commits are still reachable in the object store
  • Read `git reflog` and identify the entry that points at the deleted branch tip
  • Recreate the branch from a reflog entry with `git branch <name> <sha>` or `git reset --hard`
  • Distinguish "deleted branch" from "garbage-collected object": the former is recoverable from the reflog; the latter is not
  • Configure reflog expiry and document the window in which reflog-based recovery works
  • State the rule: never `git push --force` away commits you cannot recover another way

Prerequisites

Objective

By the end of this lab you will have deleted a branch “by accident” — using git branch -D, which removes the branch ref without touching the underlying commits — and recovered it from the reflog. You will also have simulated the harder case: the branch was deleted and the repository was garbage-collected, and the reflog was expired. The lab proves the rule that everyone learns the hard way: “deleted” in Git means “the ref is gone”, not “the commits are gone”, and the reflog is the safety net for the first 90 days.

The point is to make the reflog concrete. The reflog is a local, per-clone log of every update to HEAD and to every ref. It is the file Git uses internally to implement git reset --hard @{-1} and similar navigation commands, and it is the file you read by hand when you need to find a commit that “no longer exists”.

Architecture

A single repository with a main branch and two feature branches. One feature branch is deleted and recovered; the other is used to simulate the post-gc failure mode.

flowchart TB
    subgraph Before["before deletion"]
        B1[main · 4 commits]
        B2[feature/rbac · 2 commits]
        B3[feature/observability · 3 commits]
    end

    subgraph After["after git branch -D"]
        A1[main · 4 commits]
        A2["feature/observability · 3 commits"]
        A3["object store still holds all 9 commits"]
    end

    subgraph Reflog["reflog of HEAD"]
        R1["checkout: feature/rbac"]
        R2["commit: F1"]
        R3["commit: F2"]
        R4["branch: Deleted feature/rbac"]
    end

    Before --> After
    After --> Reflog

The branch: Deleted feature/rbac entry in the reflog is what makes the recovery possible: the reflog records that a ref was removed and records the OID it used to point at. The OID is still in the object store because nothing yet has run git gc with --prune=now.

Requirements

  • Git 2.55.x on Linux or macOS.
  • A clean working directory. Nothing outside $HOME/reflog-lab is touched.
  • Standard Unix tools: date, find, rm.
  • No network access.

Scenario

A teammate force-pushed a branch that had been deleted in the local clone. You need the branch tip from before the deletion. The remote does not have it — the deletion happened locally before the push. The reflog does have it, because Git recorded the deletion with the OID the ref used to point at. The recovery is a one-line command, but only if you understand which line in the reflog is the one you want.

A second scenario in the same lab: the branch was deleted six months ago, the reflog has expired, and git gc has pruned the unreachable commits. That is the failure mode the lab simulates in Task 7, and the deliverable documents why the 90-day default matters.

Tasks

Task 1 — Build the repository

LAB="$HOME/reflog-lab"
rm -rf "$LAB"
mkdir -p "$LAB"
cd "$LAB"

git init -b main
git config user.email 'ops@example.com'
git config user.name  'Ops'

# Four commits on main.
echo '# runbook infra' > README.md
git add README.md
git commit -m 'BASE: initial repository'

echo 'config = { region = "eu-west-1" }' > config.tf
git add config.tf
git commit -m 'MAIN-1: pin region'

echo 'tags = { Owner = "platform" }' > tags.tf
git add tags.tf
git commit -m 'MAIN-2: add default tags'

echo 'module "vpc" { source = "./vpc" }' > vpc.tf
git add vpc.tf
git commit -m 'MAIN-3: add vpc module reference'

git log --oneline

The repository has one branch and four commits. The lab adds two feature branches on top of this and then deletes one.

Task 2 — Create the two feature branches

cd "$HOME/reflog-lab"

git branch feature/rbac
git branch feature/observability

# Two commits on feature/rbac
git switch feature/rbac
echo 'rbac = { enabled = true }' > rbac.tf
git add rbac.tf
git commit -m 'F1: add rbac reference'

echo 'rbac = { enabled = true, default_role = "viewer" }' > rbac.tf
git add rbac.tf
git commit -m 'F2: add default role for rbac'

# Three commits on feature/observability
git switch feature/observability
echo 'observability = { enabled = true }' > observability.tf
git add observability.tf
git commit -m 'O1: enable observability'

echo 'dashboards = ["golden-signals"]' >> observability.tf
git add observability.tf
git commit -m 'O2: declare golden-signals dashboards'

echo 'alerts = { slack_channel = "#ops-alerts" }' >> observability.tf
git add observability.tf
git commit -m 'O3: route alerts to slack'

git log --all --oneline --decorate --graph

The repository now has three branches and nine commits. The lab’s target is feature/rbac, which the next task deletes.

Task 3 — Capture the pre-deletion reflog

cd "$HOME/reflog-lab"

# Note the OID of the feature/rbac tip — this is what we want to recover.
ORIGINAL_TIP="$(git rev-parse feature/rbac)"
echo "feature/rbac tip: $ORIGINAL_TIP"

# Capture the entire reflog for HEAD, which includes every checkout
# and every commit on every branch.
git reflog > reflog-before-delete.txt

# Specifically, capture the reflog for feature/rbac itself.
git reflog show feature/rbac > "$HOME/reflog-lab/feature-rbac-reflog.txt"

cat feature-rbac-reflog.txt

The reflog for feature/rbac has four entries: the branch creation, the F1 commit, the F2 commit, and the checkout back to main. Each entry has the OID, the action (branch: created, commit, checkout: moving from), and the committer email. The OIDs are the single thing the lab will use to recover the branch.

Task 4 — Delete the branch with git branch -D

cd "$HOME/reflog-lab"

git branch -D feature/rbac
# expected: Deleted branch feature/rbac (was <original-tip>).

git branch -a
# expected: only main and feature/observability.

git log --all --oneline --decorate
# feature/rbac is gone. F1 and F2 are no longer reachable from any
# branch tip.

The Deleted branch feature/rbac (was &lt;sha&gt;) line is the message Git prints when a ref is removed. The &lt;sha&gt; is the OID the ref used to point at, and it is exactly what you need to recreate the branch.

Task 5 — Prove the commits are still in the object store

The branch ref is gone, but the commits it pointed at are not. They are still loose objects under .git/objects/, and they are reachable through the reflog.

cd "$HOME/reflog-lab"

# The original tip OID is still a valid object.
git cat-file -t "$ORIGINAL_TIP"
# expected: commit

# The F1 and F2 OIDs (their parents) are still valid objects.
F1="$(git rev-parse "$ORIGINAL_TIP^")"
F2_BASE="$(git rev-parse "$ORIGINAL_TIP")"
git cat-file -t "$F1"
git cat-file -t "$F2_BASE"
# expected: commit (twice)

# The tree OID of F2 — the file content — is still valid.
TREE_OF_F2="$(git rev-parse "$ORIGINAL_TIP^{tree}")"
git cat-file -t "$TREE_OF_F2"
# expected: tree

Every object the branch tip pointed at, directly or transitively, is still in the object store. Nothing has pruned them yet because no garbage collection has run, and nothing has rewritten them. The branch ref was the only thing that was lost.

Task 6 — Recover the branch from the reflog

The reflog records the deletion with the OID. Read it back and use that OID to recreate the branch.

cd "$HOME/reflog-lab"

# The reflog of HEAD now contains a "branch: Deleted" entry.
git reflog show HEAD | head -10

# Specifically, the line for the deletion looks like:
#   <original-tip> HEAD@{N}: branch: Deleted feature/rbac

# Capture the OID from the deletion line. The reflog format is:
#   <sha> <selector>: <action>: <message>
DELETED_SHA="$(git reflog show HEAD \
  | grep 'branch: Deleted' \
  | head -1 \
  | awk '{print $1}')"

echo "recovered from reflog: $DELETED_SHA"

# Confirm it matches the original tip.
test "$DELETED_SHA" = "$ORIGINAL_TIP" && echo "match" || echo "MISMATCH"

# Recreate the branch.
git branch feature/rbac "$DELETED_SHA"

git branch -a
git log --oneline feature/rbac
# expected: F1 and F2 visible on the recreated branch.

# Capture the recovered branch tip and a cat-file snapshot.
{
  echo "recovered sha: $DELETED_SHA"
  echo
  echo "--- git cat-file -p"
  git cat-file -p "$DELETED_SHA"
} > recovered-sha.txt

The branch is back. The recovery is a single command — git branch feature/rbac "$DELETED_SHA" — but that command relies on you being able to identify which line in the reflog is the one you want. The branch: Deleted line is unambiguous.

Task 7 — Simulate the post-gc failure mode

This task makes the recovery fail, so you can see what “expired reflog” actually looks like.

cd "$HOME/reflog-lab"

# Move to a separate branch with a unique tip.
git switch -c feature/shortlived
echo 'shortlived = true' > shortlived.tf
git add shortlived.tf
git commit -m 'SL1: shortlived marker'

SHORTLIVED_TIP="$(git rev-parse HEAD)"
echo "shortlived tip: $SHORTLIVED_TIP"

# Confirm it exists.
git cat-file -t "$SHORTLIVED_TIP"
# expected: commit

# Aggressive gc: prune unreachable loose objects NOW, expire the
# reflog NOW. This is what would happen in a real repository only
# after months of disuse plus an admin who actively chose this; the
# lab does it deliberately to demonstrate the failure mode.
git reflog expire --expire=now --all
git gc --prune=now --aggressive

# Now ask: is the commit still there?
git cat-file -t "$SHORTLIVED_TIP" 2>&1 || echo "object gone"
git reflog show feature/shortlived 2>&1 || echo "reflog gone"

git fsck --unreachable --no-reflogs 2>&1 | head -20
# expected: nothing. The commit is no longer reachable from any ref,
# no longer reachable from any reflog, and gc has pruned the loose
# object. It is gone from this clone.

The commit is gone. git cat-file -t errors out with Not a valid object, git reflog returns nothing, and git fsck --unreachable reports no unreachable objects. The recovery path that worked in Task 6 does not work here.

Task 8 — Document the recovery window

The default reflog expiry is 90 days for reachable entries and 30 days for unreachable entries. A “deleted branch” entry in the reflog is reachable (it is in HEAD’s reflog), so the 90-day window applies.

# check-shell-blocks: allow-invalid
cd "$HOME/reflog-lab"

# What does this clone's reflog expiry look like?
git config --get gc.reflogExpire
# expected: 90 days, the default
git config --get gc.reflogExpireUnreachable
# expected: 30 days, the default

# Show the help text for the relevant config keys, so the recovery
# window is documented alongside the config that controls it.
git config --help gc.reflogExpire 2>/dev/null | head -10 || true

cat > "$HOME/reflog-lab/recovery-window.md" <<'EOF'
# Reflog recovery window

The default reflog expiry on Git 2.55.x is:

- 90 days for entries reachable from any ref (`gc.reflogExpire`,
  default `90 days`).
- 30 days for entries not reachable from any ref
  (`gc.reflogExpireUnreachable`, default `30 days`).

A "branch: Deleted" reflog entry is in `HEAD`'s reflog, so it is the
reachable-entry case: **90 days**. Within that window, the OID of
the deleted branch is recoverable from any clone that performed the
deletion.

Outside the window, or after `git reflog expire --expire=now --all`
followed by `git gc --prune=now`, the commit may be eligible for
pruning. The pruning grace period for unreachable loose objects is
`gc.pruneExpire` (default `2 weeks`), and for unreachable packed
objects it is `gc.prunePackExpire` (default `never`, unless
explicitly set).

A recovery strategy for production:

1. Treat the reflog as a 90-day safety net for accidental deletions.
2. Treat the remote's object store as a longer-term safety net for
   force-pushed branches — if the commits were pushed before deletion,
   they are still in the remote's object store even if the ref has
   been deleted there.
3. Treat a regular `git clone` of any reachable commit as a third
   safety net, because every clone has its own reflog and its own
   reachability graph.

If all three of those are gone, the commit is gone. There is no
fourth recovery path.
EOF

ls -l recovery-window.md

The 90-day window is the headline. The remote’s object store and other clones are secondary safety nets, and the deliverable makes the distinction explicit.

Task 9 — Capture the deliverables

cd "$HOME/reflog-lab"

ls -l reflog-before-delete.txt \
       reflog-after-delete.txt \
       recovered-sha.txt \
       recovery-window.md

# Capture the post-recovery reflog snapshot.
git reflog > reflog-after-delete.txt

Validation

  • git branch -a after Task 4 lists only main, feature/observability, and the recreated feature/rbac. The recreated branch tip matches recovered-sha.txt.
  • git cat-file -t "$ORIGINAL_TIP" returns commit after Task 4 and after Task 6, and errors out after Task 7.
  • git reflog show feature/rbac after Task 4 returns the entry branch: Deleted feature/rbac with the original OID. After Task 6, git log --oneline feature/rbac shows F1 and F2.
  • git fsck --unreachable --no-reflogs after Task 7 reports no dangling or unreachable commits — the shortlived commit has been pruned.
  • git config --get gc.reflogExpire returns 90 days (or the default that matches the platform).
  • The deliverables reflog-before-delete.txt, reflog-after-delete.txt, recovered-sha.txt, and recovery-window.md exist and are non-empty.

Expected Outcome

A repository with one branch deleted and recovered, one branch deleted and pruned, and a written note documenting the recovery window.

$HOME/reflog-lab/
├── .git/
│   ├── logs/                     # the reflog, the recovery source of truth
│   ├── objects/                  # loose + packed objects
│   └── refs/heads/               # main, feature/observability, feature/rbac
├── config.tf
├── observability.tf
├── README.md
├── recovered-sha.txt             # the OID recovered from the reflog
├── recovery-window.md            # the 90-day expiry note
├── reflog-after-delete.txt       # reflog snapshot after recovery
├── reflog-before-delete.txt      # reflog snapshot before the deletion
├── rbac.tf
├── shortlived.tf                 # committed, then pruned in Task 7
├── tags.tf
└── vpc.tf

You can answer two questions with confidence: “is the deleted branch recoverable?” (yes, within 90 days from any clone that performed the deletion) and “what does unrecoverable look like?” (a clone whose git fsck --unreachable is empty).

Troubleshooting

git reflog show feature/rbac returns nothing after the deletion. git reflog show &lt;ref&gt; works for refs that exist. After git branch -D feature/rbac, the ref no longer exists, so the command errors. Use git reflog show HEAD (which includes the “branch: Deleted” line) or git reflog show --all (which is the union of every ref’s reflog, deduplicated).

git branch feature/rbac "$DELETED_SHA" refuses with “not a valid commit”. The OID from the reflog is not a commit SHA — it might be a tree SHA from the wrong line, or a SHA that was correctly read but is no longer in the object store because of an earlier git gc. Confirm with git cat-file -t "$DELETED_SHA", which will print commit if the object is recoverable.

Task 7’s gc does not prune the shortlived commit. The commit is still reachable from a ref — for example, you did not delete feature/shortlived before running gc. Re-run Task 7 with the branch deleted first, then gc, then the fsck check.

git config --get gc.reflogExpire returns nothing. The default is implicit; the config key is unset unless someone has changed it. The deliverable’s documentation note explains what the default is, and the validation section allows for “default matches documentation” rather than “the literal value is set”.

Cleanup

LAB="$HOME/reflog-lab"

# Keep the deliverables.
mv "$LAB"/reflog-before-delete.txt "$LAB"/reflog-after-delete.txt \
   "$LAB"/recovered-sha.txt "$LAB"/recovery-window.md \
   "$HOME"/ 2>/dev/null

rm -rf "$LAB"

find "$HOME" -maxdepth 1 -name 'reflog-lab' -print
# expected: (no output)

If you ran the lab in an existing repository by accident, recreate the deleted branch from that repository’s reflog:

cd /path/to/that-repo
git reflog show HEAD | grep 'branch: Deleted'
# Identify the OID from the deletion line.
git branch $NAME "$RECOVERED_SHA"

If the reflog has expired and the commit is no longer reachable, the only recovery is from another clone that still has the OID or from the remote’s object store (which is reachable by OID through git fetch origin &lt;sha&gt; even if the ref has been deleted there).

What You Learned

  • “Deleted branch” means “the ref is gone”. The commits are still in the object store until git gc prunes them.
  • The reflog records every change to every ref. A branch deletion is recorded as branch: Deleted &lt;name&gt; (was &lt;sha&gt;), and the SHA is recoverable.
  • Recovery is a single command. git branch &lt;name&gt; &lt;sha&gt; recreates the branch at the OID the reflog identifies.
  • The recovery window is 90 days by default. After that, the reflog entries expire, and after git gc --prune=now, the unreachable commits are pruned.
  • Three safety nets exist in production. The local reflog (90 days), the remote’s object store (longer, if the commits were pushed), and any other clone’s reflog and object store. If all three are gone, the commit is gone.
  • git reflog expire --expire=now --all followed by git gc --prune=now is destructive. It is what an admin runs when they want commits to be unrecoverable, or what a CI job runs by accident when it tries to “clean up”. Avoid it on production clones without a separate backup.

Deliverables

  • · reflog-before-delete.txt — the reflog of the to-be-deleted branch before deletion
  • · reflog-after-delete.txt — the reflog of `HEAD` after the deletion, with the branch tip recoverable from the `branch: Deleted` line
  • · recovered-sha.txt — the OID recovered from the reflog, plus the `git cat-file -p` header showing the commit is intact
  • · recovery-window.md — a short note documenting the default 90-day reflog expiry and what it means for recovery

Verification status

Last reviewed
2026-08-25
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.