elvis/portfolio
back to all posts
GitApril 10, 2026·7 min read

Advanced Git: rewriting history without losing sleep

Interactive rebase, reflog, and the safety nets that make history rewriting boring — in a good way.

The first time I ran git rebase -i HEAD~5 on purpose, my hands were doing the thing they do right before I click "deploy" on a Friday afternoon. I'd read that interactive rebase could "rewrite history," and something about that phrase rewrite history sounded less like a Git feature and more like a warning label. So I did what I suspect a lot of people do the first time: I made a throwaway branch, copied my real branch onto it just in case, and then rebased the copy, ready to abandon the whole experiment the second anything looked wrong.

Nothing looked wrong. The five commits reordered themselves exactly like the todo list said they would, and I sat there for a second feeling slightly cheated. All that dread, for a command that just did what it said.

That gap between how dangerous rewriting history feels and how dangerous it actually is turned out to be the whole subject. I ended up building a 30-challenge curriculum around it, working through real scenarios in PowerShell on Windows, pasting actual terminal output and fixing actual errors instead of reading about hypothetical ones. And the thing that curriculum was really teaching, underneath all the individual commands, was this: the fear isn't really about rebase. It's about not knowing where the undo button is.

Git almost never deletes anything. It just stops pointing at it.

Here's the fact that should be taught before git rebase -i is ever mentioned, because it's the one that makes everything else feel safe instead of reckless: when you rewrite history, rebase, amend, reset, even a botched force-push; Git doesn't actually throw your old commits away. It creates new ones and moves branch pointers to them. The old commits just become unreferenced. They're still sitting in .git/objects, quietly waiting, usually for about 30 days before garbage collection even considers touching them.

The tool that makes those orphaned commits visible again is git reflog, and it is, without exaggeration, the command that turned Git from something I was cautious around into something I was willing to actually use:

git reflog
# f3a9c21 (HEAD -> main) HEAD@{0}: rebase (finish): returning to refs/heads/main
# a7e88b0 HEAD@{1}: rebase (pick): fix auth middleware ordering
# 9c2d410 HEAD@{2}: rebase (start): checkout HEAD~5
# 1b6e7aa HEAD@{3}: commit: wip - debugging session timeout

Every single thing you've done to HEAD in this repo; every commit, checkout, reset, and rebase step is sitting in that list, timestamped, with a HEAD@{n} you can jump straight back to:

git reset --hard HEAD@{3}

The first time that command pulled me back to a state I thought I'd genuinely lost after a rebase gone sideways and one too many git reset --hard calls in a row, something shifted. I stopped treating history-rewriting commands like a knife without a handle. Reflog is the handle. It was there the whole time; I just hadn't been told to look for it.

Interactive rebase is a todo list, not a magic trick

Once the fear of losing work was off the table, the actual mechanics of git rebase -i turned out to be almost disappointingly simple which, again, is the opposite of how it's usually presented. Run it against a range of commits, and Git doesn't do anything on its own. It hands you a plain text file and waits:

git rebase -i HEAD~5
pick 1b6e7aa wip - debugging session timeout
pick 9c2d410 fix off-by-one in retry loop
pick a7e88b0 wip - actually fix session timeout
pick f3a9c21 add tests
pick 2d8e991 fix typo in test name

That's it. That's the entire trick. It's a todo list, and you're the one editing it; pick to keep a commit as-is, reword to change its message, squash to fold it into the commit above, drop to delete it outright, edit to pause and amend it by hand. My actual list, cleaned up, usually looks more like this:

pick 1b6e7aa wip - debugging session timeout
squash 9c2d410 fix off-by-one in retry loop
squash a7e88b0 wip - actually fix session timeout
reword f3a9c21 add tests
drop 2d8e991 fix typo in test name

Three "wip" commits become one real commit with one real message. The typo fix disappears entirely, because it never deserved its own line in history. It was noise generated by the first commit's mistake, not information anyone needs later. What's left is the history I meant to write the first time, not the history of me figuring it out in real time.

That distinction — the history you meant to write versus the history of you figuring it out is, I think, the actual argument for rewriting history at all. Nobody reviewing a pull request six months from now benefits from watching you debug live. They benefit from a commit called fix session timeout on token refresh that does exactly what it says.

When rebase actually gets scary: conflicts mid-flight

I don't want to pretend it's all clean todo-list editing, because the real fear — the one that's actually justified, shows up when a rebase hits a conflict partway through. Git pauses, drops you into a half-finished state, and tells you a file has conflict markers in it. The first time this happened to me, mid-squash, on a branch with a coworker's changes layered on top of mine, I genuinely considered just deleting the whole repo and re-cloning.

What actually gets you through it is remembering that a rebase-in-progress isn't one big atomic operation. It's replaying commits one at a time, and it will tell you exactly which one it's stuck on. Fix the conflict like you would any other, in the file itself, then:

git add <the file you just fixed>
git rebase --continue

And if it turns out you've made things worse, or a second conflict shows up three commits later that makes you question your commit ordering entirely, there is always, always, an exit:

git rebase --abort

That command puts you back exactly where you started, as if the rebase never happened. Once I actually internalized that --abort is a real, complete undo, not a "mostly undo" conflicts during rebase stopped being a crisis and started being just another kind of merge conflict, the same species I already knew how to resolve. The scary part was never the conflict. It was not knowing I could always walk it back.

Amend is rebase's smaller, calmer sibling

Not everything needs the full interactive machinery. If the only thing wrong is the commit you just made a typo in the message, a forgotten file, one more small fix: git commit --amend does the job without ceremony:

git add forgotten-file.ts
git commit --amend --no-edit

It's technically rewriting history too. The commit gets a new hash, same as any rebase would produce, but because it only ever touches the single most recent commit, it never has the branching, conflict-prone complexity that makes multi-commit rebases feel dangerous. I think of it as the training wheels version: same underlying mechanism, a fraction of the blast radius. Anyone nervous about rewriting history should start here and only reach for -i once amend stops feeling risky.

What actually makes it boring: The three habits, not the commands

After thirty challenges' worth of deliberately breaking and un-breaking my own history, the thing that stuck wasn't any single Git command. It was three small habits that, together, make the scary stuff structurally hard to get wrong:

  • Branch before you rewrite. git branch backup-before-rebase costs one second and turns every rewrite into a reversible experiment instead of a one-way door.
  • Check status before and after. git status mid-rebase tells you exactly which commit you're stuck on and what it wants from you, half of "rebase panic" is just not reading the very clear instructions Git is already printing.
  • Trust the reflog is there before you need it, not after. I stopped being afraid of --hard resets the day I actually believed HEAD@{n} would catch me. Believing it before the mistake, not scrambling to remember it exists during one, is the actual difference between calm and panic.

None of those are Git commands, exactly. They're just the difference between treating history-rewriting as something that happens to you mid-crisis, and something you do on purpose, with an exit already in your back pocket.

What I actually learned

I don't think the lesson here is "rewrite history as often as possible". Plenty of commits are fine left exactly as they landed, and constantly grooming a branch that's still purely your own, unshared work is its own kind of wasted motion. The lesson is narrower: the commands that feel destructive are usually just the commands nobody walked you through a safety net for. Interactive rebase isn't reckless once you know --abort always exists. A force-push isn't reckless once you understand what --force-with-lease protects against that plain --force doesn't. History rewriting isn't reckless once reflog stops being a party trick and starts being the first thing you reach for.

That's really what the 30-challenge version of this taught me that no single tutorial had: not the commands themselves, but the muscle memory of reaching for the safety net before the mistake, instead of googling for it in a panic after. Once that muscle memory is there, rewriting history stops being the part of Git you're careful around, and starts being just another tool you reach for without thinking twice.