Skip to main content
RunBook Academy

← All break/fix scenarios in Git, CI/CD & GitOps

intermediategit-history~30 min

Accidental hard reset

Reported symptoms

  • A feature branch tip is five commits behind where it was yesterday, with no merge commit in between
  • Working tree no longer contains changes that were definitely there in the last commit
  • No one touched the branch — only the engineer who is now staring at `git status`
  • A `git push origin feature/auth` returned `Everything up-to-date` two days ago
  • The five missing commits are not on `origin/feature/auth` either
  • The engineer remembers typing `git reset --hard HEAD~5` to "tidy up" before pushing

Evidence

  • · `git reflog` shows the offending entry at the top: `<sha> reset: moving to HEAD~5`, immediately preceded by `commit: <the-good-sha>`
  • · `git fsck --dangling --no-reflogs` reports five commit objects as dangling, each with the original commit messages
  • · `git log --oneline feature/auth` shows the truncated history; the missing commits are not on any named ref
  • · `git log --oneline origin/feature/auth` agrees with the local tip — the remote was never updated
  • · `git push --force-with-lease origin feature/auth` is a no-op because there is nothing new to push
  • · The `.git/ORIG_HEAD` from the reset still exists and points at the pre-reset tip
  • · `git show <dangling-sha>` renders the original commit message and diff exactly as it was
  • · `git config --get push.default` returns the project default; the branch was created locally and never pushed
Diagnosis and resolutionclick to reveal

Root cause

`git reset --hard HEAD~5` rewrites HEAD, the index, and the working tree in one operation. The commits themselves are not destroyed — Git only deletes unreachable objects during `gc` once the reflog entry for them has expired — but every named ref (branches, tags, the remote tracking branch) still points where it pointed before the reset, which is now the truncated history. The five removed commits survive as dangling objects reachable through the reflog for a default 90 days, then are pruned. The reason "a week of work" is recoverable at all is that `ORIG_HEAD` and the per-ref reflog captured the pre-reset tip at the moment the reset ran, and that captured SHA is what the engineer needs to recover. The reason it could have been unrecoverable is the same: if `gc` had run with `--prune=now` between the reset and the discovery, the dangling commits would be gone.

Remediation

The recovery path is short and the engineer should walk it before doing anything else: open `git reflog`, find the SHA immediately before the `reset:` line, and create a branch from it with `git branch feature/auth <sha>`. Verify with `git log feature/auth` that the five commits are back, then `git checkout feature/auth`. If `ORIG_HEAD` is still set, that SHA can be used directly. Push the recovered branch to a new remote ref name (`feature/auth-recovered`) rather than overwriting the (now stale) origin ref, so that a later `git push --force` cannot accidentally re-truncate the history. Once the recovered content is safely on the remote, decide whether the original `feature/auth` should be deleted or pointed at the recovery — and decide that from the repository, not from the engineer's terminal, because the decision is now a code-review question, not a typing question.

Verification

The branch contains the five commits and `git log` shows them in the expected order. The working tree matches the original commit's tree — not just the head, but a mid-history checkout at each of the five recovered commits, to confirm the content is intact at every step rather than only at the tip. The recovered branch is on the remote (`git ls-remote origin feature/auth-recovered` resolves). A second `git fsck` finds no new dangling objects, meaning the recovery did not orphan anything else. The lesson itself is verified: the engineer can name, in plain language, what `git reset --hard HEAD~5` changes and what it does not, and can name one alternative command (`git reset --soft HEAD~5`) that would have given them the same tidy history without losing the working tree.

Prevention

Make history rewrite require the same effort that other destructive operations require. Configure `receive.denyNonFastForwards` on the remote so a force push is impossible without explicit bypass, configure `push.default = simple` so an unpushed branch is never silently created on the remote, and add a `pre-push` hook that warns when the local tip is being force-pushed over a non-empty remote ref. Most importantly, treat the absence of a push as a signal: a local-only branch is recoverable only for as long as the reflog survives, and the only structural control that outlives any single developer's habits is a policy that says "no commit lives only on one laptop" — combined with a CI check that fails any PR whose head commit has no remote ancestor.

The engineer ran git reset --hard HEAD~5 thinking they were cleaning up before pushing; in fact, they rewound both the branch pointer and the working tree and lost a week’s worth of local-only commits. The commits are not deleted — they are dangling objects reachable through the reflog — which is why the recovery is short and is also why a window exists during which a single git gc --prune=now would have made it impossible.