Git · 40 min

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

Prerequisites

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

  1. 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 about
    

    See the mess

    git log --oneline --graph
    git status
    

    You now have four commits and one uncommitted change. The problems:

    • config.env with a secret is in history.
    • The last commit has a garbage message (asdfasdf) and adds a junk file scratch.tmp.
    • There's an uncommitted edit to app.py you haven't decided about.

    Keep this window open — you'll fix these one at a time.

    Deliverable for this step: the git log --oneline output showing the four-commit mess.

  2. 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') to app.py and 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 state
    

    git 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 restore handles 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 gone
    

    The 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 status output showing a clean tree after the restore.

  3. 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.env is a committed mistake. If this branch were already pushed, you must
    not rewrite history. git revert creates 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 ..." commit
    

    The file config.env is 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 (with scratch.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 UNSTAGED
    

    After a mixed reset, scratch.tmp is 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 --oneline
    

    The three flavors of reset, worth memorizing:

    Command Commit undone Staging Working tree
    reset --soft yes kept staged untouched
    reset (mixed) yes unstaged untouched
    reset --hard yes discarded overwritten (destructive)

    Rule of thumb: revert anything already pushed; reset only local commits.

    Deliverable for this step: git log --oneline after the revert + reset, showing the secret reverted and the junk commit gone.

  4. 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-title
    

    Git 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 history
    

    If you ever want to bail out mid-conflict, git merge --abort returns you to before the merge.

    Deliverable for this step: the resolved app.py content and the git log --graph showing the merge.

  5. 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~3
    

    An editor opens listing the three commits with pick in front of each. To combine them, keep
    the first as pick and change the others to squash (or s):

    pick   <hash> wip
    squash <hash> more wip
    squash <hash> fix typo
    

    Save and close; Git then lets you write one clean combined message (e.g. Add notes). Use
    reword instead of squash if you only want to fix a message. Confirm:

    git log --oneline    # the three commits are now one
    

    Remember 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. reflog is
    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           # recovered
    

    This is the safety net that makes Git forgiving: as long as a commit existed, reflog can
    usually get you back to it for weeks, even after a --hard reset.

    Deliverable for this step: the git log --oneline after the squash, plus a git reflog snippet showing the recovery.

  6. Submit: document your rescue

    Capture the before/after and your reasoning in a SUBMISSION.md inside 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 URL
    

    Submission criteria (self-check)

    • You used git restore (worktree) and git restore --staged and can explain the difference
    • You reverted the secret commit with git revert (history preserved)
    • You removed the junk commit with git reset and 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 using git 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, hooks and recovering from complex conflicts.