Untangle a Messy Git History
Take a repository full of real mistakes — a committed secret, a bad merge, a wrong commit — and fix it safely with restore, revert, reset, an interactive rebase and reflog. The hands-on companion to Git Essentials.
Problem
Everyone can commit on a clean day. The skill that separates a confident developer is what you do when the history is a mess: you committed a password, the last commit was wrong, a merge went sideways, and you're afraid that "fixing" it will destroy work. In this lab you'll create a repository that has these exact problems on purpose, then repair each one with the right tool — learning the crucial difference between `restore`, `revert` and `reset`, resolving a real merge conflict by hand, cleaning up local commits with an interactive rebase, and rescuing "lost" work with `reflog`. You'll finish knowing that in Git, almost nothing is truly unrecoverable — if you know where to look.
Objectives
- Read a repository's true state with
git status,git log --oneline --graphandgit reflog - Undo uncommitted changes with
git restore, and choose between--stagedand worktree restores - Reverse a bad commit two ways:
git revert(safe, keeps history) vsgit reset(rewrites history) — and know when each is correct - Resolve a real merge conflict by editing the conflict markers and completing the merge
- Clean up local commits with an interactive rebase (
squash,reword) before sharing - Recover a commit you "lost" via
git reflog
Prerequisites
- Git 2.30+ installed (
git --version) and a basic identity configured (git config --global user.name/user.email) - A terminal (PowerShell, bash or zsh) and any text editor
- Comfort with the everyday commands from the Git Essentials course:
add,commit,branch,merge - No remote/GitHub account needed — everything happens in a local repo you create in Step 1
What you will build
You'll build a deliberately broken repository and then fix it. Each problem maps to one Git
recovery skill, so by the end you'll have a mental decision tree: "the change isn't committed →
restore; the bad commit is already pushed → revert; it's only local → reset or rebase; I lost a
commit → reflog."
The golden rule of rewriting history
One idea governs everything below:
Rewrite freely what you haven't shared. Never rewrite what others may have pulled.
reset and rebase rewrite history — they change commit IDs. That's perfect for cleaning up
your own local work before pushing, and dangerous on a shared branch (it forces everyone else into
conflicts). revert is the opposite: it adds a new commit that undoes an old one, so history is
preserved and safe to share. Keeping this line straight is the whole point of the lab.
How to work through this lab
Run every command and read the output — especially git status and git log between steps, so
you see each repair take effect. Do the steps in order; each builds the scenario for the next.
Steps
-
Build the broken repository
You'll create the mess yourself so it's reproducible. Run these commands in an empty folder.
mkdir git-rescue && cd git-rescue git init echo "# Notes App" > README.md git add README.md && git commit -m "Initial commit" echo "print('hello')" > app.py git add app.py && git commit -m "Add app" # Mistake A: commit a secret echo "API_KEY=sk-live-supersecret-123" > config.env git add config.env && git commit -m "Add config" # Mistake B: a wrong, embarrassing commit message + junk file echo "temporary junk" > scratch.tmp git add scratch.tmp && git commit -m "asdfasdf" echo "print('goodbye')" >> app.py # an uncommitted change you'll decide aboutSee the mess
git log --oneline --graph git statusYou now have four commits and one uncommitted change. The problems:
-
config.envwith a secret is in history. - The last commit has a garbage message (
asdfasdf) and adds a junk filescratch.tmp. - There's an uncommitted edit to
app.pyyou haven't decided about.
Keep this window open — you'll fix these one at a time.
Deliverable for this step: the
git log --onelineoutput showing the four-commit mess. -
-
Undo uncommitted changes with restore
The easiest case first: a change you haven't committed and now want to throw away.
Discard a worktree change
You added
print('goodbye')toapp.pyand decided against it. It's not staged, so:git status # app.py shown as "modified", not staged git restore app.py # discard the change in the working tree git status # clean again; app.py back to its committed stategit restore <file>overwrites the file with its version from the last commit. This is
destructive for uncommitted work — there's no undo, because Git never recorded that edit.
Use it only when you truly want the change gone.Unstage without losing work
The other half of
restorehandles staged files. Suppose you stage something by accident:echo "draft" > draft.txt git add draft.txt # now staged git restore --staged draft.txt # unstage it, but KEEP the file and its content git status # draft.txt is back to "untracked", not goneThe distinction to remember:
-
git restore <file>→ discard worktree changes (destructive). -
git restore --staged <file>→ move a file out of the staging area, keeping your edits.
Delete the stray file so the tree is clean:
rm draft.txt.Deliverable for this step: the
git statusoutput showing a clean tree after the restore. -
-
Reverse a bad commit: revert vs reset
Now the committed mistakes. There are two philosophies, and choosing right is the core skill.
revert — safe, keeps history (use for shared commits)
The secret in
config.envis a committed mistake. If this branch were already pushed, you must
not rewrite history.git revertcreates a new commit that undoes the target:git log --oneline # find the hash of "Add config" git revert <hash-of-add-config> # opens an editor for the revert message; save it git log --oneline # original commit still there + a new "Revert ..." commitThe file
config.envis now removed by the new commit, but the old commit still exists in
history. (In real life a leaked secret must also be rotated — reverting hides it from HEAD,
not from the repo's past. More on that in the reflection.)reset — rewrites history (only for local, unshared commits)
The junk commit
asdfasdf(withscratch.tmp) is still local only, so you can rewrite it
away cleanly. Move the branch pointer back, keeping the changes unstaged:git reset --soft HEAD~1 # undo the last commit, KEEP changes staged # or: git reset HEAD~1 # (mixed, the default) undo commit, keep changes UNSTAGEDAfter a mixed reset,
scratch.tmpis back as an untracked file — delete it and it's gone from
the intended history:rm scratch.tmp git status # clean; the bad commit is gone git log --onelineThe three flavors of reset, worth memorizing:
Command Commit undone Staging Working tree reset --softyes kept staged untouched reset(mixed)yes unstaged untouched reset --hardyes discarded overwritten (destructive) Rule of thumb: revert anything already pushed; reset only local commits.
Deliverable for this step:
git log --onelineafter the revert + reset, showing the secret reverted and the junk commit gone. -
Resolve a real merge conflict
Conflicts scare people because they interrupt a merge midway. Create one on purpose and finish it.
Set up two diverging branches
git switch -c feature-title # change the same line the main branch will also change echo "print('hello from feature')" > app.py git commit -am "Feature: change greeting" git switch main echo "print('hello from main')" > app.py git commit -am "Main: change greeting"Both branches edited the same line of
app.py. Now merge:git merge feature-titleGit stops with
CONFLICT (content): Merge conflict in app.py. This is not an error — it's Git
asking you to decide, because it can't know which version is right.Read and resolve the markers
Open
app.py. You'll see conflict markers:<<<<<<< HEAD print('hello from main') ======= print('hello from feature') >>>>>>> feature-title-
<<<<<<< HEAD…=======→ your current branch (main). -
=======…>>>>>>> feature-title→ the incoming branch.
Edit the file to the final content you want — keep one side, the other, or combine them — and
delete all three marker lines. For example:print('hello from main and feature')Then mark it resolved and complete the merge:
git add app.py git commit # completes the merge (default merge message is fine) git log --oneline --graph # see the merge commit joining both lines of historyIf you ever want to bail out mid-conflict,
git merge --abortreturns you to before the merge.Deliverable for this step: the resolved
app.pycontent and thegit log --graphshowing the merge. -
-
Clean up local commits with interactive rebase, and recover with reflog
Two power tools to finish: polishing local history before sharing, and rescuing lost work.
Interactive rebase — tidy your own commits
Make a couple of small, messy commits on a branch:
git switch -c cleanup-demo echo "line 1" > notes.txt && git commit -am "wip" echo "line 2" >> notes.txt && git commit -am "more wip" echo "line 3" >> notes.txt && git commit -am "fix typo"Three noisy commits that should really be one. Rewrite them before anyone sees them:
git rebase -i HEAD~3An editor opens listing the three commits with
pickin front of each. To combine them, keep
the first aspickand change the others tosquash(ors):pick <hash> wip squash <hash> more wip squash <hash> fix typoSave and close; Git then lets you write one clean combined message (e.g.
Add notes). Use
rewordinstead ofsquashif you only want to fix a message. Confirm:git log --oneline # the three commits are now oneRemember the golden rule: this rewrites commit IDs, so only do it on commits you haven't pushed.
reflog — recover a "lost" commit
Rebase and reset move branch pointers; the old commits aren't deleted immediately.
reflogis
Git's private diary of everywhere HEAD has been. Simulate a disaster and recover:git reset --hard HEAD~1 # "oops, I destroyed my last commit" git log --oneline # it's gone from the branch git reflog # every HEAD move, each with a hash # find the line for the commit you lost, then: git reset --hard <hash-from-reflog> # or: git cherry-pick <hash> git log --oneline # recoveredThis is the safety net that makes Git forgiving: as long as a commit existed,
reflogcan
usually get you back to it for weeks, even after a--hardreset.Deliverable for this step: the
git log --onelineafter the squash, plus agit reflogsnippet showing the recovery. -
Submit: document your rescue
Capture the before/after and your reasoning in a
SUBMISSION.mdinside the repo.Build your submission
# Git Rescue Lab — <your name> ## 1. The mess (Step 1) <paste the initial `git log --oneline` with the four bad commits> ## 2. restore What I discarded and the `git status` clean tree after it. ## 3. revert vs reset - The secret commit I reverted (hash) and why I used revert. - The junk commit I reset away and why reset was safe here. <paste `git log --oneline` after both> ## 4. Merge conflict The final resolved line of app.py, and how I chose it. ## 5. Rebase + reflog <paste `git log --oneline` after the squash> <paste the `git reflog` line I used to recover> ## Reflection (3-4 sentences) In my own words: when to use revert vs reset, and why reflog means almost nothing in Git is truly lost.Submit
git add SUBMISSION.md git commit -m "Git rescue lab submission" # push to a public repo or gist, then submit that URLSubmission criteria (self-check)
- You used
git restore(worktree) andgit restore --stagedand can explain the difference - You reverted the secret commit with
git revert(history preserved) - You removed the junk commit with
git resetand explained why it was safe (local only) - You resolved a real merge conflict by editing markers and completing the merge
- You squashed 3 commits into 1 with
git rebase -i - You recovered a
--hard-reset commit usinggit reflog - Your reflection correctly states the revert-vs-reset rule
What's next
You can now repair almost any local history problem with confidence. In Git Advanced you'll
push these further —stash,worktrees,hooksand recovering from complex conflicts. - You used