What reset --hard really destroys, deleted branches, dropped stashes, undoing rebase, amend and merge, force-push recovery on a server, corrupt repository repair and what makes loss permanent
▶Prefer to watch? This short video runs this page's main commands in a real lab, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.
Git recovery: lost commits, force-pushes, corrupt repositories, and being ready for them
git reflog; git fsck --lost-found
Git is very hard to lose committed work in, and very easy to lose uncommitted work in. Almost everything people call "I lost my commits" is recoverable for weeks, and almost everything people call "I lost my changes" is not. Knowing the difference, and where Git keeps its safety nets, is the whole skill. This page is a rescue manual: what Git remembers, where to look, how to get a deleted branch, a dropped stash, a rebased or merged branch, a force-pushed history and a corrupted repository back, and what to set up beforehand so the next incident is a five-minute job.
The Git page and the Git for Sysadmins page cover the everyday undo commands (revert, restore, reset, reflog basics, removing a secret). This page starts where those stop. Every scenario below was reproduced for real in throwaway repositories with Git 2.43.0 on Ubuntu 24.04, and the output is real, including two tests that failed on the first try and what that taught (the force-with-lease test and the corruption repair; both are described honestly). The users are called Alice, Bob and Lab; the commit ids change on every run, so yours will differ.
The rules of rescue
1. STOP. Do not run more commands "to see what happens". Every checkout, reset and commit moves things around; the more you do, the harder it is to find the state you want.
2. MAKE A COPY of the repository before you try anything: cp -a .git .git.backup (or the whole folder). It takes seconds and turns every mistake into a non-event.
3. LOOK before you act: git reflog, git status, git branch -a, git stash list, git fsck --lost-found.
4. RECOVER INTO A NEW BRANCH or a new name (git branch rescued SHA), not by moving the branch you are on. You can always move things later.
5. NEVER run git gc, git prune, git reflog expire, git clean or git filter-repo in the middle of an incident. They are what makes lost things permanently lost (section 12 shows exactly how).
6. If other people share the repository, tell them before you push anything.
Where lost things live
Git does not delete data when you "lose" it; it just stops pointing at it. Objects that nothing points to stay in the object store until garbage collection removes them after a grace period. These are the places that still point to, or can find, what you lost:
git reflog every place HEAD has been (and, with git reflog show BRANCH, every place a branch has been) on THIS clone, with the reason. Kept 90 days for reachable entries, 30 days for unreachable ones
ORIG_HEAD the position of the branch just before a risky operation (merge, rebase, reset, pull)
git fsck --lost-found lists dangling objects (commits and blobs nothing points to) and writes the blobs into .git/lost-found/
git fsck --no-reflogs --unreachable the strict version: everything unreachable, ignoring even the reflog
remote-tracking refs origin/main is where the server's branch was when you last fetched; after the next fetch the previous value is origin/main@{1}
other clones a colleague's copy, a CI cache, a backup, a deployment checkout: any of them may still have the commit you lost
the server's object store a bare repository keeps the old commits (as dangling objects) until its own gc runs
git stash list and git stash drop's printed sha a stash is a commit, so it can be found again
You can ask for older positions by number or by time (the real output of a small repository):
$ git reflog --date=relative | head -4
fc8e305 HEAD@{2 minutes ago}: checkout: moving from feature/tls to main
8cdb3e0 HEAD@{2 minutes ago}: commit: tls: minimum version
c061190 HEAD@{2 minutes ago}: commit: tls: add certificate path
fc8e305 HEAD@{2 minutes ago}: checkout: moving from main to feature/tls
$ git log -g --format="%gd %h %gs" | head -3
HEAD@{0} fc8e305 checkout: moving from feature/tls to main
HEAD@{1} 8cdb3e0 commit: tls: minimum version
HEAD@{2} c061190 commit: tls: add certificate path
git log -g shows the reflog in the format of a log, so you can use any log format (--format, --stat). Time forms such as main@{yesterday} and HEAD@{2.hours.ago} also work, but only as far back as the reflog goes (Git warns when the log is shorter than the time you asked for).
A deleted branch
You deleted a branch with commits that were never merged (git branch -D). Git prints the old tip when it deletes it; keep that line:
$ git branch -D feature/tls
Deleted branch feature/tls (was 8cdb3e0).
$ git branch feature/tls-restored 8cdb3e0
$ git log --oneline feature/tls-restored | head -3
8cdb3e0 tls: minimum version
c061190 tls: add certificate path
fc8e305 v3: add logging
If you missed the message, the reflog of HEAD remembers every commit you made while that branch was checked out, and the checkout away from it:
$ git reflog | head -6
fc8e305 HEAD@{0}: checkout: moving from feature/tls to main
8cdb3e0 HEAD@{1}: commit: tls: minimum version
c061190 HEAD@{2}: commit: tls: add certificate path
fc8e305 HEAD@{3}: checkout: moving from main to feature/tls
fc8e305 HEAD@{4}: commit: v3: add logging
f569019 HEAD@{5}: commit: v2: add retries
The entry just before "moving from feature/tls to main" is the tip of the deleted branch: HEAD@{1}. Make a branch from it (git branch NAME 8cdb3e0, or git switch -c NAME HEAD@{1}). A branch whose commits were made by SOMEONE ELSE in another clone is not in your reflog: use the other clone, or the server (section 8). If the branch was never checked out in this clone at all, git fsck --lost-found lists its tip as a dangling commit.
What git reset --hard destroys, exactly
git reset --hard throws away changes in the working tree and the index. People fear it more than they should, and trust it less than they should. What survives depends on the state of each file. The experiment: four files in four different states, then git reset --hard:
$ git status --short
M a.txt (tracked, modified, NOT staged)
M b.txt (tracked, modified, staged with git add)
A new.txt (new file, staged)
?? notes.txt (untracked)
$ git reset --hard
$ git status --short
?? notes.txt
$ ls; tail -1 a.txt; tail -1 b.txt
a.txt b.txt notes.txt
committed a
committed b
The result, file by file:
a.txt (modified, not staged) LOST. The edit existed only in the file; Git never saw it. No command brings it back. (An editor's backup or swap file, a backup system or a snapshot might)
b.txt (modified AND staged) recoverable: git add stored the content in the object store, and reset left it dangling
new.txt (new, staged) recoverable for the same reason (the file itself is gone from the folder)
notes.txt (untracked) untouched: reset --hard does not delete untracked files (git clean does, and cannot be undone)
The recoverable ones are found with fsck:
$ git fsck --lost-found
dangling blob 352d7ef91402e2d45c55b326cb3bb3a4caa5576b
dangling blob 1c9a35380dc4db77d34575e00ae95dafc432022c
$ for b in $(git fsck --lost-found 2>/dev/null | awk '/dangling blob/{print $3}'); do echo "blob ${b:0:7}: $(git cat-file -p $b | tr '\n' '|')"; done
blob 352d7ef: committed b|staged edit to b|
blob 1c9a353: brand new staged file|
$ ls .git/lost-found/other
1c9a35380dc4db77d34575e00ae95dafc432022c 352d7ef91402e2d45c55b326cb3bb3a4caa5576b
Two blobs, with the content of the staged edit to b.txt and of the new staged file. fsck --lost-found also wrote them as files into .git/lost-found/other/, named by their ids, so you can copy them out. Blobs have no file names (a blob is only content), so you identify them by reading them. The lesson for daily work: git add (or a quick commit on a throwaway branch) BEFORE a risky operation turns "unsaved" into "recoverable". git stash does it for you (a stash is a commit). And git restore FILE, git checkout -- FILE and git clean -fd are, like reset --hard, one-way for anything unstaged.
A dropped stash
git stash drop and git stash pop remove the entry from the list. Git prints the id of the stash commit when it drops:
$ git stash push -m "wip retry logic"; git stash drop
Dropped refs/stash@{0} (aa93ac737d724f0683c244a84916668908c92980)
$ git stash list | wc -l
0
Take the printed id and apply it, or store it back in the stash list:
$ git stash apply aa93ac7
$ git stash store -m "recovered wip" aa93ac7; git stash list
stash@{0}: recovered wip
If you do not have the id, look for it. A stash is not one commit but a small bundle (a commit for the index and a commit for the work tree, joined in a merge), so fsck lists more than one unreachable commit. My first attempt in the lab picked the wrong one (the first in the list, "index on main"), and git refused with "is not a stash-like commit". The right one is the commit with TWO parents:
$ for s in $(git fsck --no-reflogs --unreachable | awk '/unreachable commit/{print $3}'); do echo " ${s:0:7} $(git log -1 --format='%s' $s) parents: $(git log -1 --format='%p' $s | wc -w)"; done
a5c1d83 index on main: 85db22a base parents: 1
aa93ac7 On main: wip retry logic parents: 2
aa93ac7 is the stash itself ("On main: ..." is the message pattern, "WIP on ..." when you gave no message) with two parents; a5c1d83 is its "index" part. git stash apply on the right one brought the work back (the line "work in progress: new retry logic" reappeared in app.conf). Untracked files are only in a stash if you used git stash -u.
Undoing a rebase, an amend and a merge
Rebase. A rebase rewrites the commits (they get NEW ids). Git remembers where the branch was just before in ORIG_HEAD, and in the reflog:
$ git rebase main
Successfully rebased and updated refs/heads/topic.
$ git log --oneline | head -4
fc5900a topic 2
ab92f8d topic 1
7a05fdd main moves on
5d18011 base
$ git rev-parse --short ORIG_HEAD; git log --oneline ORIG_HEAD | head -3
6889eee
6889eee topic 2
955248c topic 1
5d18011 base
$ git reset --hard ORIG_HEAD
$ git log --oneline | head -3
6889eee topic 2
955248c topic 1
5d18011 base
The two topic commits had new ids after the rebase (fc5900a and ab92f8d instead of 6889eee and 955248c), and git reset --hard ORIG_HEAD put the branch back exactly. ORIG_HEAD is overwritten by the next operation that sets it, so use it immediately; later, find the position in the reflog (git reflog, the line before "rebase (start)"). A rebase in progress that went wrong: git rebase --abort restores the branch itself.
Amend. git commit --amend replaces the last commit. If you amended the wrong thing (here: a secret file was added to the commit), the old commit is the previous reflog entry:
$ git reflog | head -3
be92b05 HEAD@{0}: commit (amend): topic 3 (amended by mistake: secret added)
af9ab5b HEAD@{1}: commit: topic 3 (good commit)
6889eee HEAD@{2}: reset: moving to ORIG_HEAD
$ git reset --soft HEAD@{1}
$ git log --oneline | head -1; git status --short
af9ab5b topic 3 (good commit)
A secret.txt
--soft moved the branch back to the good commit and left the amended content (the secret file) staged, so you can unstage it (git restore --staged secret.txt) or recommit differently. Use --hard instead to throw the amend away completely. If the commit with the mistake has been PUSHED, do not rewrite it: remove the mistake in a new commit, or, for a secret, rotate the secret first (see the Git for Sysadmins page).
Merge. Before it is pushed, undo a merge with ORIG_HEAD. Once it is shared, revert it with -m 1 (keep the first parent, the branch you merged INTO):
$ git merge --no-ff topic -m "Merge topic"; git log --oneline --graph | head -4
* d780d87 Merge topic
|\
| * af9ab5b topic 3 (good commit)
| * 6889eee topic 2
$ git reset --hard ORIG_HEAD (not pushed yet: simply go back)
$ git log --oneline | head -2
7a05fdd main moves on
5d18011 base
$ git merge --no-ff topic -m "Merge topic"
$ git revert -m 1 --no-edit HEAD (already pushed: add a commit that cancels the merge)
[main a6ac69f] Revert "Merge topic"
$ git log --oneline | head -3
a6ac69f Revert "Merge topic"
d780d87 Merge topic
7a05fdd main moves on
The revert is safe for shared history, but it has a famous trap. Git now considers the topic branch's commits "already merged" and cancelled, so merging the same branch again brings nothing:
$ git merge --no-ff topic -m "merge again"
Already up to date.
To get the changes back later, either revert the revert (git revert a6ac69f) or add NEW commits to the branch and merge it again (only the new commits come across, which is usually not what you want). Know this before you revert a merge on a branch that is not finished.
Commits made on a detached HEAD
git switch --detach, a checkout of a tag or a commit, or an interrupted rebase leaves you on "no branch". Commits made there belong to no branch, and switching away leaves them behind. They are in the reflog:
$ git switch --detach HEAD~1; (make a commit "detached work"); git switch main
$ git reflog | head -3
d13c994 HEAD@{0}: checkout: moving from 3844706f47508f3f2b12cf2b571aedcb845b1247 to main
3844706 HEAD@{1}: commit: detached work
7e03a4c HEAD@{2}: checkout: moving from main to HEAD~1
$ git branch rescued 3844706
$ git log --oneline rescued | head -2
3844706 detached work
7e03a4c c1
The commit "detached work" (3844706) was found as HEAD@{1} and made into a branch. The "moving from <sha> to main" line is the sign: the sha it names is where you stood. When you MEAN to work detached, run git switch -c NAME before committing.
A force-push on a shared repository
Someone rewrites a shared branch (git push --force after a reset or rebase) and the team's commits disappear from the server. This is the most common serious accident with Git. It is almost always recoverable, because the server still has the old commits. The lab reproduced it with a bare repository as the server and two clones (Alice and Bob). Bob has three commits; Alice resets her branch two commits back, adds one commit and force-pushes:
$ git push --force origin main (Alice rewrites history)
$ git -C server.git log --oneline main
382b82b v2 rewritten
825ded7 v1
The server's main has lost "v2 important work" and "v3". Where are they?
1. On the server, as dangling objects: the old tip is no longer pointed to, but it is still in the object store until the server's gc prunes it:
$ git -C server.git fsck --lost-found --no-reflogs
dangling commit 572b94b1aaaeed84157ded2a4b0225c066a6b81d
(only the TIP is dangling; its parents are reachable from it).
2. In every clone that fetched before the push. Bob's clone has them: his origin/main still points at the old tip, because that ref only moves when he fetches. Do NOT fetch yet (or, if you already did, the fetch recorded the old value, see below):
$ git rev-parse --short origin/main (in Bob's clone, before he fetches)
572b94b
(A freshly cloned repository has no reflog for origin/main yet: git reflog show origin/main prints nothing.) If Bob HAS already fetched, the fetch wrote a "forced-update" entry, and the previous value is one step back; the lab confirmed it in a second run:
$ git fetch origin
+ 908e03c...7047d9a main -> origin/main (forced update)
$ git reflog show origin/main | head -3
7047d9a refs/remotes/origin/main@{0}: fetch origin: forced-update
$ git log --oneline 'origin/main@{1}' | head -2
908e03c v2 important
7ef3f90 v1
So origin/main@{1} is the old tip (908e03c) even though the list shows one line: use the @{1} form.
3. Restore: push the old commit back to the branch, from any clone that has it (or on the server itself with git update-ref):
$ git push --force origin 572b94b:refs/heads/main (from Bob's clone)
$ git -C server.git log --oneline main
572b94b v3
bb05bee v2 important work
825ded7 v1
The branch is back to its previous state. Do this BEFORE anyone pulls the bad history, and tell people not to pull in the meantime; anyone who already fetched the rewritten branch has to reset to the restored one (git fetch; git reset --hard origin/main, after saving any local commits with git branch backup).
A reflog on the server. By default a BARE repository has no reflog at all, which is why the commands above have to dig in the object store:
$ git -C server.git reflog
(no output)
$ git -C server.git config core.logAllRefUpdates
(empty: off for bare repositories)
Turn it on, and a force-push becomes a one-line recovery on the server. The lab did it: with core.logAllRefUpdates set to true in the bare repository, Alice pushed two commits, then reset and force-pushed:
$ git -C server.git reflog show main
ba53e6a main@{0}: push
9f06f9d main@{1}: push
$ git -C server.git log --oneline 'main@{1}' | head -3
9f06f9d v2: important work
28377ba v1: first
$ git -C server.git update-ref refs/heads/main 'main@{1}'
$ git -C server.git log --oneline main
9f06f9d v2: important work
28377ba v1: first
main@{1} was the position before the forced push, and update-ref put the branch back, with no clone needed. Use core.logAllRefUpdates always on a server repository; it also logs tags and notes. (If your server is GitHub, GitLab or similar, use their tools instead: the "protected branch" setting, the activity or "push events" log that shows the old sha of a force-push, and the support or API route for restoring a ref.)
Prevention, both tested. Make the server refuse history rewrites on shared branches:
$ git -C server.git config receive.denyNonFastForwards true
$ git push --force origin main
To server.git
! [remote rejected] main -> main (non-fast-forward)
error: failed to push some refs to 'server.git'
The server rejected the forced push. Hosting services call this a protected branch (and can also forbid deletion: receive.denyDeletes on your own server). And teach people --force-with-lease instead of --force: it only overwrites the branch if it is still where YOU last saw it. The lab's first attempt at this test was flawed (the pusher had fetched first, so the lease was satisfied and the push succeeded); redone properly, with Bob pushing after Alice's last fetch:
$ git push --force-with-lease origin main
To server.git
! [rejected] main -> main (stale info)
$ git -C server.git log --oneline main | head -1
1eed3fd bob: important change
$ git push --force origin main (plain force: overwrites it)
$ git -C server.git log --oneline main | head -2
52206bc alice: change (she has NOT fetched bob's push)
1952df0 base
With --force-with-lease the push was refused ("stale info": the server's branch is not where Alice thought) and Bob's commit stayed on the server; with plain --force Bob's work was silently overwritten. Set an alias (git config --global alias.pushf "push --force-with-lease") and make plain --force a rare event. Note that fetching before the push (even automatically, by an editor integration) refreshes the "lease", so --force-with-lease protects against surprises, not against a pusher who has already looked at the remote and ignored it.
Getting back a deleted file or an old version
$ git rm app.conf; git commit -m "remove app.conf by mistake"
$ git log --oneline --diff-filter=D -- app.conf
77f8607 remove app.conf by mistake
$ git checkout 77f8607~1 -- app.conf (the file as it was in the commit BEFORE the deletion)
$ git status --short
A app.conf
--diff-filter=D lists the commits that deleted the path; checking the file out of the parent of that commit (the ~1) brings it back, staged. This works however far back the deletion was, as long as the history exists. To find a commit by what it removed or added, use git log -S "text" -- path (the pickaxe), and git log --all --full-history -- path when the file lived on another branch. git restore --source=SHA -- path is the modern spelling (it does not stage the file, add --staged --worktree for both).
A corrupt repository
Symptoms: "object file ... is empty", "unable to read", "bad object", "loose object ... is corrupt", "fatal: Failed to traverse parents of commit", or "invalid reflog entry". Causes: a full disk or a power cut while Git was writing (the file was created and left empty or short), bad storage, a copy of .git made while Git was running, or a sync tool that damaged files. The lab truncated one object file to zero bytes (what a crash can leave) and looked at the damage:
$ git log --oneline
error: object file .git/objects/8d/ae5290d31698dd93a0305bd1048578fe1fe582 is empty
error: Could not read 8dae5290d31698dd93a0305bd1048578fe1fe582
fatal: Failed to traverse parents of commit b1b168925e242dd066c8f39b31ad59f40e08c298
$ git fsck
error: object file .git/objects/8d/ae5290d31698dd93a0305bd1048578fe1fe582 is empty
error: unable to mmap .git/objects/8d/ae5290d31698dd93a0305bd1048578fe1fe582: No such file or directory
error: 8dae5290d31698dd93a0305bd1048578fe1fe582: object corrupt or missing: .git/objects/8d/ae5290d31698dd93a0305bd1048578fe1fe582
error: HEAD: invalid reflog entry 8dae5290d31698dd93a0305bd1048578fe1fe582
The commit before the tip is unreadable, so log stops with a "fatal"; fsck names the object (its id is the folder 8d plus the file name) and the reflog entries that point to it. The tip commit itself still reads (its parent line names the damaged object); the history behind it is cut. git fsck is read-only and safe, and it is the first command to run. (Lines starting "dangling" in a healthy repository are harmless leftovers, not damage.)
Repair when the objects exist somewhere else (a remote, another clone, a backup). First remove the damaged FILE: Git will not replace an object that it believes it has.
Wrong, but instructive: a plain fetch does nothing. After removing the empty file, git fetch printed no output, and fsck still reported the damage:
$ rm .git/objects/8d/ae5290d31698dd93a0305bd1048578fe1fe582
$ git fetch origin
(no output: the remote has nothing new, so nothing was transferred)
$ git fsck | head -1
broken link from commit b1b168925e242dd066c8f39b31ad59f40e08c298
Right: two repairs that worked in the lab.
1. git fetch --refetch (Git 2.36 and newer) discards the assumption that existing objects are fine and downloads everything again as for a new clone:
$ git fetch --refetch origin
$ git fsck; echo "fsck output above is empty = healthy"
$ git log --oneline | wc -l
3
2. Copy the object from a repository that has it (the remote's objects folder, a backup, a colleague's clone), file to the same path:
$ cp ../remote.git/objects/8d/ae5290d31698dd93a0305bd1048578fe1fe582 .git/objects/8d/
$ git fsck
(no output)
For objects that live inside pack files in the other repository (not as loose files), use the refetch approach, or git --git-dir=OTHER cat-file -p SHA to check it exists, or fetch from that repository directly (git fetch /path/to/other.git). If NO copy of the object exists anywhere, that object and its content are gone: the commits after it (the tip) are intact, but history through the missing object is cut, and files unique to it must come from a backup or working copies. This is why section 13 matters.
Check the whole repository after any repair: git fsck --full, then git gc only when it is clean. Also check the disk first: if it filled up once, find out why (df -h, the Disk Full runbook) or the next write will break something again. For a repository on shaky storage, git config transfer.fsckObjects true makes Git verify objects it receives (documented, not tested here), and a recent Git has core.fsync settings to flush data more carefully (not tested).
The stale lock file
A crashed git command can leave .git/index.lock (or a lock for a ref) behind, and the next command refuses:
$ touch .git/index.lock; git add app.conf
fatal: Unable to create '.git/index.lock': File exists.
Another git process seems to be running in this repository, e.g.
an editor opened by 'git commit'. Please make sure all processes
are terminated then try again. If it still fails, a git process
may have crashed in this repository earlier:
remove the file manually to continue.
$ rm -f .git/index.lock; git add app.conf (no git process was running)
First CHECK that no git process is really running (ps aux | grep '[g]it', or an editor with an open commit message); removing the lock of a live process can corrupt the index. If you are sure, remove the lock file and run git status. The same applies to .git/refs/heads/NAME.lock and .git/HEAD.lock.
How long Git keeps things, and what makes them truly gone
The defaults (the lab asked Git, none was set explicitly):
$ git config --get gc.reflogExpire || echo "not set (default 90 days)"
not set (default 90 days)
gc.reflogExpireUnreachable: not set (default 30 days)
gc.pruneExpire: not set (default 2 weeks)
So reflog entries for reachable commits last 90 days, entries for commits no longer on any branch 30 days, and unreferenced objects survive until git gc prunes them after two weeks. Automatic gc runs now and then after commands that add many objects. The lab deleted a branch and then ran the cleanup commands step by step, checking whether the commit still existed (git cat-file -t SHA says "commit" if it does):
$ git branch -D scratch (tip adf9ab7)
$ git cat-file -t adf9ab7
commit
$ git gc
$ git cat-file -t adf9ab7 after a normal git gc: commit (the reflog and the 2-week grace period protect it)
$ git reflog expire --expire=now --all; git gc
$ git cat-file -t adf9ab7 after reflog expire + gc: commit (unreachable, but not yet old enough to prune)
$ git gc --prune=now
$ git cat-file -t adf9ab7
fatal: Not a valid object name adf9ab7 after gc --prune=now: gone for good
Two safety nets protected the commit (the reflog and the two-week grace period); only after both were removed on purpose did it disappear. So a normal git gc is harmless, and git reflog expire --expire=now plus git gc --prune=now are the commands that make a loss permanent. They also appear in "make the repository smaller" recipes and in git filter-repo workflows: read those instructions with this in mind, and do the backup first.
Be ready: backups and server-side protections
A recovery that starts with "I have a copy" is a small job. Four habits, all tested:
Back up before you rewrite or clean: cp -a .git .git.backup (this is the simplest and complete: it copied the whole repository, 272K in the lab, in a moment), or a bundle, a single file that contains the commits and refs:
$ git bundle create ../backup.bundle --all; ls -l ../backup.bundle | awk '{print "bundle size:", $5, "bytes"}'
bundle size: 872 bytes
$ git bundle verify ../backup.bundle | tail -1
The bundle uses this hash algorithm: sha1
$ git clone -q backup.bundle restored; git -C restored log --oneline | wc -l
3
verify says the bundle is complete and valid (its last line shows the hash algorithm), and cloning from the bundle gave back all three commits.
Keep a mirror on another machine: git clone --mirror URL backup.git, refreshed with git remote update (cron job, see the scheduling page), so a deleted or force-pushed server repository can be restored from the copy. A mirror refreshed without --prune keeps branches that were deleted on the server (this was not tested here, so check it on your setup); keep a few generations of the mirror either way.
Give server repositories a reflog (git config core.logAllRefUpdates always), refuse non-fast-forward pushes on shared branches (receive.denyNonFastForwards true, or protected branches on the hosting service) and deletions (receive.denyDeletes true). All tested above.
Commit early and often on a branch (including work in progress: a WIP commit is recoverable, an unsaved edit is not), push the branch to the server at least daily, and use git stash instead of reset when you only want a clean tree.
Keep the working-directory backup too: Git protects what you committed or staged. Everything else is up to your editor's backups and your file-level backups.
Server-side hygiene that avoids corruption: do not run two writers over a network filesystem that does not honour locks, never copy a repository while a push is in progress (use git clone --mirror or a snapshot), keep the disk below 80 percent, and run git fsck on important repositories now and then (a cron job that mails when it finds errors).
Symptom to action
"I deleted a branch" Deleted branch NAME (was SHA): git branch NAME SHA; else the reflog of HEAD, else fsck --lost-found
"I did git reset --hard and lost work" committed: reflog; staged: fsck --lost-found (blobs, no names); unstaged and untracked-then-cleaned: gone, check editor backups
"I dropped a stash" the sha printed by drop; else the unreachable commit with TWO parents; git stash store -m MSG SHA
"I rebased and it is a mess" git rebase --abort if still going; else git reset --hard ORIG_HEAD (right away) or the reflog entry before the rebase started
"I amended the wrong commit" git reset --soft HEAD@{1} (keeps the changes) or --hard (drops them)
"I merged the wrong branch" not pushed: git reset --hard ORIG_HEAD. Pushed: git revert -m 1 MERGE_SHA (and remember the re-merge trap)
"My commits vanished after a checkout" you were on a detached HEAD: the reflog, then git branch NAME SHA
"Someone force-pushed over our branch" the old sha is in a clone's reflog (origin/main), a server reflog (if enabled) or a dangling object on the server: push it back or update-ref it; prevent with receive.denyNonFastForwards and --force-with-lease
"I deleted a file and committed it" git log --diff-filter=D -- PATH, then git checkout SHA~1 -- PATH
"error: object file ... is empty / corrupt" git fsck; remove the damaged file; git fetch --refetch, or copy the object from another copy; if no copy exists, the object is lost
"fatal: Unable to create ... index.lock" make sure no git process is running, then remove the lock file
"I need it back and I already ran gc --prune=now" it is gone from this repository: look in other clones, the server, a bundle or a mirror, and in backups
"Everything looks wrong and I do not know why" stop, cp -a .git .git.backup, then git status, git reflog and git fsck, and ask before you run anything that deletes
Tips
Committed work is almost never lost: the reflog keeps it for 90 days. Unstaged work in tracked files and untracked files are the real risks.
After any accident, stop, copy .git, and look (reflog, fsck) before you act. Recover into a NEW branch.
git add before a risky command: staged content survives a reset --hard and can be found with git fsck --lost-found.
Keep the sha that git prints when it deletes a branch or drops a stash.
Use ORIG_HEAD right after a merge, rebase or reset; use the reflog later; use git reset --soft HEAD@{1} to undo an amend and keep your changes.
Revert a pushed merge with -m 1, and remember that the same branch cannot simply be merged again.
Make force-push hard: protected branches or receive.denyNonFastForwards on the server, a reflog on the server, and --force-with-lease in everybody's habits.
git fetch does not repair a damaged object; git fetch --refetch (Git 2.36+) does, as does copying the object from another copy of the repository. Run git fsck first and again after.
Never run gc --prune=now, reflog expire --expire=now, git clean or filter-repo during an incident.
Back up with cp -a .git, a bundle, and a mirror on another machine, and test that you can restore from them.