Git Reflog Forensics for CI Incident Triage¶
Authoritative guide to recovering from catastrophic Git operations using the reflog — Git's internal event log — and git fsck --lost-found. This is distinct from the basic reflog rescue guide in docs/troubleshooting/; this document covers forensic analysis, automation, and cleanup for CI pipelines.
Table of Contents¶
- How the Reflog Works
- Recovering Commits After a Bad Reset
- Recovering Commits After a Forced Rebase
- Reflog vs Dangling Objects: What
fsck --lost-foundFinds - CI Pipeline Incident Triage Playbook
- The
reflog expire --expire=nowCleanup Lever - Automating Reflog Recovery in CI
- Reflog Pitfalls and Gotchas
1. How the Reflog Works¶
Every time a ref (branch, HEAD, stash, etc.) moves, Git appends a timestamped entry to the reflog for that ref:
HEAD@{2026-08-29 14:23:01} checkout: moving from main to feat/x
HEAD@{2026-08-29 14:23:45} commit: feat: add telemetry endpoint
HEAD@{2026-08-29 14:24:10} reset: moving to HEAD~1
HEAD@{2026-08-29 14:24:10} commit (amend): feat: add telemetry endpoint
The reflog lives in .git/logs/ and is per-reference:
- .git/logs/HEAD — every HEAD movement
- .git/logs/refs/heads/main — every branch tip movement
- .git/logs/refs/stash — stash operations
- .git/logs/refs/original/ — pre-filter-repo backup (if enabled)
Key insight: Reflog entries are local. Cloned repositories do not share reflogs. If a developer pushes after a destructive operation, collaborators who pulled will have different reflog entries.
Reflog entries expire on a schedule (gc.reflogExpireUnreachable, default 90 days). Entries for reachable commits expire at 90 days by default; unreachable (dangling) entries expire at 30 days.
2. Recovering Commits After a Bad Reset¶
Scenario¶
A developer ran git reset --hard HEAD~5 to remove 5 commits, then continued working. The commits are gone from git log but not from Git's object store.
Step 1: Find the Lost Commits in Reflog¶
# Show HEAD reflog with timestamps and SHA-1s
git reflog --date=iso
# Filter to find the lost commits by commit message keyword
git reflog | grep -i "feat: telemetry"
# Find commits reachable from the reset target but now dangling
git reflog show HEAD@{1} # the commit before the reset
git reflog show HEAD@{2} # two operations ago
Step 2: Identify the Target SHA¶
git reflog --all --format="%H %s" | grep "reset: moving to"
# Output: abc1234 reset: moving to HEAD~5
# def5678 reset: moving to abc1234
Step 3: Verify the Commit Is Dangling¶
git fsck --unreachable --no-reflogs 2>&1 | grep "unreachable commit"
# or with verbose output
git fsck --unreachable --no-reflogs --full 2>&1
Step 4: Recover¶
# Fastest: reset back to the pre-reset state
git reset --hard HEAD@{1}
# Selective: cherry-pick specific commits by SHA
git cherry-pick abc1234 def5678
# Or branch from a dangling commit
git branch recovery-branch abc1234
git checkout recovery-branch
Validation¶
# Confirm the branch matches the reflog entry
git log --oneline recovery-branch -10
git log --oneline HEAD@{1} -10 # compare
# Run CI checks before merging recovery branch
git push origin recovery-branch
3. Recovering Commits After a Forced Rebase¶
Scenario¶
A force-push (git push --force) or an interactive rebase (git rebase -i) rewrote 12 commits. The remote is updated; collaborators who haven't pulled have divergent history.
Step 1: Check Local and Remote-Side Reflog¶
# Check the remote reflog (requires access to the Git server's .git directory)
# For Forgejo/Gitea: check .git/logs/refs/heads/ on the server
# Check local origin/BRANCH reflog before next fetch
git reflog show origin/main --date=iso
Step 2: Recover from Local Reflog¶
# List all HEAD movements — look for the pre-rebase state
git reflog --all --format="%H %gd %s" | head -30
# The old commit SHAs are still valid objects until gc runs
git fsck --unreachable --no-reflogs --commits
# Cherry-pick the range that was dropped
OLD_BASE=$(git rev-parse HEAD@{10}) # adjust index
NEW_BASE=$(git rev-parse HEAD) # current HEAD
git cherry-pick ${OLD_BASE}..HEAD@{8} # pick dropped commits
Step 3: Remote-Side Recovery (Server-Side)¶
If you have SSH access to the Git server:
# On the server (Forgejo/Gitea bare repo)
ssh git-server "cd /data/git/repos/owner/repo.git && \
git reflog show HEAD --date=iso | tail -20"
# Identify the pre-force-push SHA
# Reset the remote branch back (requires direct server write access)
ssh git-server "cd /data/git/repos/owner/repo.git && \
git update-ref refs/heads/main abc1234def"
Step 4: Re-Establish Correct Remote State¶
# After recovering locally, force-push the correct history
git push --force-with-lease origin main
# Notify collaborators to re-clone or rebase
git fetch origin
git reset --hard origin/main
4. Reflog vs Dangling Objects: What fsck --lost-found Finds¶
| Command | Finds | Lifetime |
|---|---|---|
git reflog |
Every ref movement with context | Default 90d reachable / 30d unreachable |
git fsck --unreachable --no-reflogs |
Objects not reachable from any ref (ignores reflog) | Until git gc --prune=now |
git fsck --lost-found |
Same as above + writes dangling blobs/commits to .git/lost-found/ |
Same |
git reflog expire --expire=now |
Removes reflog entries (objects survive if reachable via another path) | Immediate |
Using git fsck --lost-found for Forensic Analysis¶
# Full forensic scan — finds ALL unreachable objects
git fsck --unreachable --no-reflogs --full 2>&1 | tee fsck-report.txt
# Find dangling commits only
git fsck --unreachable --no-reflogs --commits 2>&1
# Write all dangling objects to lost-found for inspection
git fsck --unreachable --no-reflogs --lost-found
# Inspect individual dangling commits
ls .git/lost-found/commit/
for sha in $(ls .git/lost-found/commit/); do
git log --oneline $sha -1
done
# Inspect dangling blobs (test artifacts, credentials that were filtered)
ls .git/lost-found/blob/
git cat-file -p $(ls .git/lost-found/blob/ | head -1)
Dangling Blobs After Filter-repo (Security Audit)¶
If you ran git filter-repo --path-glob '*.env' --invert-paths, removed secrets are still dangling blobs. This is a security audit scenario:
git fsck --unreachable --no-reflogs --lost-found
# Blobs are in .git/lost-found/blob/
# Compare against known leaked SHA list
5. CI Pipeline Incident Triage Playbook¶
Incident: CI Pipeline Accidentally Reset main to Wrong SHA¶
#!/bin/bash
# ci-reflog-rescue.sh — run this in CI after a bad pipeline reset
set -euo pipefail
AFFECTED_BRANCH="${1:-main}"
REPORT_FILE="reflog-rescue-report.txt"
{
echo "=== Git Reflog Forensic Report ==="
echo "Timestamp: $(date -Iseconds)"
echo "Affected branch: $AFFECTED_BRANCH"
echo ""
echo "=== Last 20 HEAD movements ==="
git reflog show HEAD --date=iso | tail -20
echo ""
echo "=== Last 20 branch tip movements ==="
git reflog show "$AFFECTED_BRANCH" --date=iso | tail -20
echo ""
echo "=== Unreachable commits ==="
git fsck --unreachable --no-reflogs --commits 2>&1 || true
echo ""
echo "=== Unreachable blobs (count) ==="
git fsck --unreachable --no-reflogs --lost-found 2>&1 | grep -c "dangling blob" || echo "0"
echo ""
echo "=== Current HEAD vs origin/$AFFECTED_BRANCH ==="
echo "Local HEAD: $(git rev-parse HEAD)"
echo "Remote HEAD: $(git rev-parse origin/$AFFECTED_BRANCH)"
echo "Diverged: $(git merge-base HEAD origin/$AFFECTED_BRANCH && echo 'yes' || echo 'no')"
} > "$REPORT_FILE"
cat "$REPORT_FILE"
Incident: Pipeline Did a git rebase -i That Dropped Commits¶
# Find the pre-rebase HEAD in the reflog
git reflog --date=iso | grep -E "rebase|reset|pick" | tail -20
# Get the SHA before rebase
PRE_REBASE_SHA=$(git reflog | grep "rebase (start)" | awk '{print $1}' | tail -1)
# Cherry-pick the dropped commits
git cherry-pick ${PRE_REBASE_SHA}..HEAD@{1}
# Push the fix
git push --force-with-lease origin "$(git branch --show-current)"
6. The reflog expire --expire=now Cleanup Lever¶
What It Does¶
git reflog expire removes old reflog entries. With --expire=now, it removes all entries for that ref immediately, regardless of age. This is the cleanup lever after you have successfully recovered your commits.
Critical safety:
reflog expire --expire=nowonly deletes reflog entries, NOT the objects they reference — if a dangling commit is still reachable through another reflog entry or another branch, the object persists untilgit gc --prune=nowruns.
Safe Cleanup Sequence¶
# Step 1: Verify recovery is complete
git fsck --unreachable --no-reflogs --commits | wc -l
# If > 0, you may still need those objects
# Step 2: Verify the reflog is no longer needed for recovery
git reflog show HEAD --date=iso | tail -5
# Step 3: Expire old entries
git reflog expire --expire=now --all
# Step 4: Run garbage collection to actually remove dangling objects
git gc --prune=now --aggressive
# Step 5: Verify disk space is recovered
du -sh .git/
git count-objects -Hv
When to Use --expire=now¶
- After a successful recovery: You have cherry-picked or reset to the correct state and want to prevent accidental reuse of stale reflog entries.
- Before a repository transfer: Clean the reflog before handing off to another team.
- CI runner disk pressure: CI runners with shallow clones do not benefit from reflog; expire immediately after confirming no pending recoveries.
# One-liner for CI runner cleanup (after confirming no pending recoveries)
git reflog expire --expire=now --all && git gc --prune=now --quiet
7. Automating Reflog Recovery in CI¶
Scheduled Reflog Snapshot (Backup)¶
#!/bin/bash
# backup-reflog.sh — run before every destructive CI operation
REPO_DIR="${1:-.}"
BACKUP_DIR="${REPO_DIR}/.git/reflog-backups"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
mkdir -p "$BACKUP_DIR"
# Snapshot all reflogs
for ref in HEAD refs/heads/* refs/stash; do
[ -f "${REPO_DIR}/.git/logs/${ref}" ] || continue
cp "${REPO_DIR}/.git/logs/${ref}" \
"${BACKUP_DIR}/$(echo $ref | tr '/' '_')_${TIMESTAMP}"
done
echo "Reflog snapshot saved: $BACKUP_DIR/"
ls -lh "$BACKUP_DIR/"
Restore from Snapshot¶
#!/bin/bash
# restore-reflog.sh
BACKUP_DIR="${1:-.}"
TIMESTAMP="${2:-}" # e.g. 20260829_142301
if [ -z "$TIMESTAMP" ]; then
echo "Usage: $0 <backup_dir> <timestamp>"
exit 1
fi
for backup in "${BACKUP_DIR}"/*_"${TIMESTAMP}"; do
[ -f "$backup" ] || continue
ref_name=$(basename "$backup" | sed "s/_${TIMESTAMP}$//" | tr '_' '/')
target=".git/logs/$ref_name"
mkdir -p "$(dirname "$target")"
cp "$backup" "$target"
echo "Restored: $ref_name"
done
8. Reflog Pitfalls and Gotchas¶
| Pitfall | Explanation | Prevention |
|---|---|---|
| Reflog is local only | Clone to a new machine and all reflogs are gone | Back up reflogs before risky operations (see Section 7) |
| Reflog entries expire | Default 90 days for reachable, 30 days for unreachable | Set gc.reflogExpireUnreachable = never for critical repos |
git gc --prune=now removes dangling objects |
Run after reflog expire and your dangling commits vanish |
Always verify with git fsck before gc |
| Shallow clones have truncated reflogs | git clone --depth N only keeps N latest commits in reflog |
Use full clones for recovery-critical workflows |
| Reflog is not a substitute for backups | Reflog is append-only but can be expired or lost with disk failure | Combine reflog + server-side backups + Git hosting redundancy |
| Force-push to shared branch loses collaborators' reflogs | When you force-push, teammates who have not pulled will push stale refs | Always use --force-with-lease instead of --force |
| Object store corruption hides reflog entries | If .git/objects/ is corrupted, dangling objects referenced by reflog may be unreadable |
Regular git fsck --full health checks in CI |
Configuration for Recovery-Critical Repos¶
# ~/.gitconfig or repo .git/config
[gc]
# Never auto-expire unreachable reflog entries
reflogExpireUnreachable = never
# Aggressive pruning when gc runs
prune = now
[fetch]
# Preserve reflog on fetch (for collaborator recovery)
prune = false
[push]
# Warn before force-push to protected branches
default = simple
Summary¶
| Scenario | Command |
|---|---|
| Find lost commits | git reflog --date=iso |
| List dangling objects | git fsck --unreachable --no-reflogs --full |
| Recover a dropped commit | git cherry-pick <sha> |
| Recover from reset | git reset --hard HEAD@{1} |
| Recover from rebase | git reset --hard HEAD@{n} |
| Inspect lost-found | git fsck --lost-found && ls .git/lost-found/ |
| Clean reflog safely | git reflog expire --expire=now --all && git gc --prune=now |
| Backup reflogs | cp .git/logs/* reflog-backups/ |
When to use this guide vs. docs/troubleshooting/reflog-rescue.md: That guide covers single-user, single-machine recovery. This guide covers forensic analysis, CI automation, fsck --lost-found, server-side recovery, and cleanup levers — everything a DevOps engineer needs for production incident response.