Git & collaboration, section 2 of 2, 3h

When something goes wrong in git

By the end: You can recover lost work, undo in the right way for the situation, and handle a committed secret correctly.

  • The reflog remembers everything
  • restore, reset and revert: which one
  • Resolving a merge conflict calmly
  • Stash and cherry-pick
  • A secret was committed

New to git & collaboration? Git records the history of a set of files: every change, who made it, and when. It lets several people edit the same project at once without overwriting each other, and lets you return to any earlier state. GitHub is a website that hosts those histories and wraps a review conversation around them. Why it matters

This section is the set of moves for the moment something in git has gone wrong: commits that seem to have vanished after a reset or a rebase, a change you need to undo, a merge conflict, or a secret that is now in the history. The fact that makes most of it recoverable is that git almost never throws anything away, and the reflog lists every position HEAD has held. The exception is the leaked secret, which is why that part starts with revoking the key rather than rewriting history.

You almost never lose a commit#

git reflog                     # every place HEAD has been, with ids
git branch rescue 3f9c2a1      # put a branch on the commit you thought was gone

After a bad reset or rebase, the old commits still exist for weeks. The reflog lists them. Find the one you want and point a branch at it.

Worth doing once for real, in a repository you do not care about, so that the muscle exists before the panic does. Here is a hard reset two commits too far and the recovery, in a scratch repository. Your hashes will differ.

git log --oneline -1
# fdd7b5c commit 4 on main

git reset --hard HEAD~2
git log --oneline -1
# 480943e commit 2                 <- two commits of work apparently gone

git reflog -4
# 480943e HEAD@{0}: reset: moving to HEAD~2
# fdd7b5c HEAD@{1}: commit: commit 4 on main
# 5c7464c HEAD@{2}: checkout: moving from feature to main
# 435ccff HEAD@{3}: commit: extend feature.py

git reset --hard 'HEAD@{1}'      # or the hash; quote it, the braces are shell syntax
git log --oneline -1
# fdd7b5c commit 4 on main
HEAD@{1} means where HEAD was one move ago. The reflog is local and per-repository, so it records your moves and not a colleague's, and entries expire after 90 days by default.

A deleted branch is the same problem with one extra step, because the branch name is gone and so its own reflog went with it. Two things still know where it was. The delete command printed the hash, and the HEAD reflog recorded every commit you made while you were on it.

git branch -D feature
# Deleted branch feature (was 435ccff).        <- keep this line

git reflog | grep feature
# 5c7464c HEAD@{3}: checkout: moving from feature to main
# 435ccff HEAD@{4}: commit: extend feature.py
# b2245a1 HEAD@{5}: commit: add feature.py
# 5c7464c HEAD@{6}: checkout: moving from main to feature

git branch feature 435ccff
git log --oneline -1 feature
# 435ccff extend feature.py
If the scrollback is gone too, git fsck --no-reflogs lists unreachable commits: dangling commit 435ccff786a96bd38d49c6603703194bdb9f2991.

The one thing that is genuinely lost is work that was never committed. A hard reset overwrites your files from the commit, and there is no record of what was in them, so a morning of uncommitted editing is simply gone. There is an exception worth knowing, and it is the strongest argument for staging work you are not ready to commit: git add writes the file into the object database immediately, and objects are not deleted by reset.

echo "half an hour of work" >> app.py
git add app.py                   # this line is the difference
git reset --hard
git status --short               # nothing: the file is back to the committed version

git fsck --lost-found
# dangling blob 8233b38978eca16f6ab63b4fc1475cb5fc945dd2

cat .git/lost-found/other/8233b38978eca16f6ab63b4fc1475cb5fc945dd2
# line 1
# line 2
# line 3
# line 4
# half an hour of work
Run without the git add, git fsck --lost-found prints nothing at all and the work is unrecoverable. That is the entire difference.

git add is the cheapest insurance in the tool

Staging costs nothing, commits nothing and tells nobody, and it makes the current state of a file recoverable from that moment on. Before any command you are unsure about — a reset, a rebase, a checkout of another branch, a merge — stage everything with git add -A. If it goes wrong, the content is in the object database and git fsck --lost-found will find it.

Which undo?#

Discard changes to a file you have not committed   git restore <file>
Unstage a file                                      git restore --staged <file>
Undo local commits not yet pushed                   git reset <commit>   (keeps changes)
Undo a commit that others already have              git revert <commit>  (new commit)

The rule: never rewrite history other people have pulled. Revert adds a new commit that undoes the old one, which is safe on shared branches.

Merge conflicts#

A conflict means both sides changed the same lines. Git marks them in the file. Read both versions, decide what the result should be, which is sometimes a mix, remove the markers, run the tests, then add and continue. If it goes badly, git merge --abort or git rebase --abort returns you to where you started.

The default markers give you less than you need, and it is worth seeing why on a small one. Two branches both edited a two-line config file: main changed the timeout from 30 to 45 and the retries from 3 to 5, the other branch changed only the timeout, to 120.

git merge raise-timeout
# Auto-merging config.py
# CONFLICT (content): Merge conflict in config.py
# Automatic merge failed; fix conflicts and then commit the result.

cat config.py
# <<<<<<< HEAD
# TIMEOUT = 45
# RETRIES = 5
# =======
# TIMEOUT = 120
# RETRIES = 3
# >>>>>>> raise-timeout
Four lines, and nothing here says which side changed what. RETRIES looks like a disagreement between 5 and 3.

Read that as it stands and you have a genuine choice to make about TIMEOUT and an invented one about RETRIES. Only one side touched RETRIES, so 5 is simply the newer value and 3 is what it always was, but the markers do not tell you that and picking a side at random here quietly reverts someone's work. Turning on a conflict style that also shows the common ancestor fixes it permanently.

git config --global merge.conflictStyle zdiff3     # needs git 2.35 or newer

git merge --abort && git merge raise-timeout
cat config.py
# <<<<<<< HEAD
# TIMEOUT = 45
# RETRIES = 5
# ||||||| bcf6972
# TIMEOUT = 30
# RETRIES = 3
# =======
# TIMEOUT = 120
# RETRIES = 3
# >>>>>>> raise-timeout
The middle block is what both sides started from. Now RETRIES is obvious - 3 became 5 on one side and nobody touched it on the other - and only TIMEOUT is a real decision.

With the base visible the resolution is mechanical: for each hunk, work out what each side did to the base, and write the file that does both. Here that is TIMEOUT = 120 and RETRIES = 5, which is a line neither branch contains. That is normal and is the reason the advice is to resolve rather than to choose a side; the tooling offers you --ours and --theirs and they are almost always both wrong.

# edit config.py to the version that is actually correct, markers removed
git add config.py
git status --short
# M  config.py
git commit --no-edit          # keeps the generated merge message

git log --graph --oneline --all
# *   6979262 Merge branch 'raise-timeout'
# |\
# | * e2cea00 raise timeout for slow model calls
# * | 9d9cc89 tune timeout and retries
# |/
# * bcf6972 base config
Run the tests between the edit and the commit. A merge that compiles is not a merge that is correct, and a broken merge commit is awkward to bisect through later.

One consequence of that two-parent shape, which catches people out perhaps once in a career and is unpleasant when it does: reverting a merge needs telling which side to keep. A merge undid nothing on its own, so undoing it is ambiguous, and git refuses to guess.

git revert HEAD
# error: commit 6979262629e9e33b0ccc42687601201053afe878 is a merge but no -m option was given.
# fatal: revert failed

git revert -m 1 HEAD          # -m 1 means "keep the first parent", which is the branch
                              # you merged into, almost always main
cat config.py
# TIMEOUT = 45
# RETRIES = 5                 <- main as it was before the merge
Merging that branch again afterwards prints Already up to date and changes nothing, because its commits are still ancestors of main - only their effect was undone. To bring the work back you revert the revert.

Small tools#

  • git stash puts uncommitted changes aside so you can switch branches; git stash pop brings them back.
  • git cherry-pick <commit> copies one commit onto the current branch, useful for moving a fix to a release branch.

A secret was committed#

  1. 1.Revoke and replace the secret immediately. Assume it is already compromised; deleting it from git does not un-leak it.
  2. 2.Then remove it from the code and history if your team's policy requires, which rewrites history and needs coordination.
  3. 3.Add a secret scanner to pre-commit or CI so it does not happen again.

Where people go wrong

Force-pushing to a shared branch to 'clean up'. Everyone else's history now disagrees with the remote.

If this comes up in an interview

You pushed an API key to a public repository. What do you do?

Revoke the key and issue a new one first, because it may already have been copied by automated scanners within minutes. Then remove it from the code and, if needed, from history, and add secret scanning to prevent a repeat. Check the provider's logs for misuse.

Then they push further

Why is deleting the commit not enough?

Anyone who cloned or scanned the repository already has it, forks keep it, and caches may too. Only revoking the key removes the risk.

What gets marked up. Revoking first is the whole answer. Candidates who start with rewriting history are marked down.

What is the difference between reset and revert?

Reset moves the branch pointer back, rewriting history, which is fine for local commits. Revert makes a new commit that undoes an earlier one, keeping history intact, which is what you use on shared branches.

Build this: In a scratch repository, make five commits, hard reset back three, then recover them with the reflog. Create and resolve a merge conflict. Revert a middle commit and read the resulting history.

This is one section of Git & collaboration, 8 hours across 2 sections. Read it in full to get the whole topic in order.

Stop 6 of 50 on the AI Engineer route

0 of 50 topics done · 0h behind you, 603h to go

Next on this routeTyping & validationChecking that model output is what you claimed.11h

What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.