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-labis 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 <sha>) line is the message Git
prints when a ref is removed. The <sha> 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 -aafter Task 4 lists onlymain,feature/observability, and the recreatedfeature/rbac. The recreated branch tip matchesrecovered-sha.txt.git cat-file -t "$ORIGINAL_TIP"returnscommitafter Task 4 and after Task 6, and errors out after Task 7.git reflog show feature/rbacafter Task 4 returns the entrybranch: Deleted feature/rbacwith the original OID. After Task 6,git log --oneline feature/rbacshows F1 and F2.git fsck --unreachable --no-reflogsafter Task 7 reports no dangling or unreachable commits — the shortlived commit has been pruned.git config --get gc.reflogExpirereturns90 days(or the default that matches the platform).- The deliverables
reflog-before-delete.txt,reflog-after-delete.txt,recovered-sha.txt, andrecovery-window.mdexist 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 <ref> 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 <sha> 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 gcprunes them. - The reflog records every change to every ref. A branch
deletion is recorded as
branch: Deleted <name> (was <sha>), and the SHA is recoverable. - Recovery is a single command.
git branch <name> <sha>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 --allfollowed bygit gc --prune=nowis 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.