Back to Journal

Rewriting history without losing it

Listen to this articleAI narration
0:00 / 0:00

Two repositories needed their main branches cleaned up. Weeks of commits—bug fixes, statistics features, patient notes, search components—had been pushed directly to main. The work was valid, but it belonged on a feature branch. The goal: move everything to a separate branch, reset main to an earlier state, and push both.

Simple in theory. The execution required careful ordering—and a recovery when one reset went too far.

The strategy

The approach relies on how Git branches work internally. A branch is just a pointer to a commit. Creating a new branch before resetting preserves the full commit chain, because the new pointer still references the original HEAD.

Branch Pointer Mechanicsfcdd1f5fd08e7c9562b6b37c8600Step 1: Create branch at current HEADmaintemp-stashed-changesStep 2: Reset main to target commitmain (reset)temp-stashed-changesstill points to 37c8600Commits between fcdd1f5 and 37c8600are preserved on temp-stashed-changes

The sequence matters. Branch first, then reset. If you reset first, those commits become orphaned—reachable only through git reflog until garbage collection removes them.

# 1. Create the safety branch (don't switch to it)
git branch temp-stashed-changes
 
# 2. Reset main to the target commit
git reset --hard fcdd1f5
 
# 3. Force-push the reset main
git push --force-with-lease
 
# 4. Push the new branch
git push -u origin temp-stashed-changes

The --force-with-lease flag is critical over --force. It checks that the remote ref matches what you last fetched. If someone pushed to main between your fetch and your force-push, it fails instead of silently overwriting their work.

Going too far back

The first repository went smoothly. The second didn't. The reset command went too far back—past the intended commit, deep into early project history. And the force-push had already been executed.

At this point, remote main was pointing at a commit from weeks ago. But the local temp-stashed-changes branch still had everything. That's the entire point of creating the branch first—it's a local reference that isn't affected by resets or force-pushes on other branches.

Recovery After Over-ResetRemote main (after force-push)Points to 5c8d1c9 — way too far backMissing 30+ commits of real workLocal temp-stashed-changesStill points to 37c8600 — full historyEvery commit intact and reachableFix: reset main to the correct commit from temp-stashed-changesgit reset --hard fcdd1f5 && git push --force-with-lease

The fix was straightforward. The commit fcdd1f5 still existed locally because temp-stashed-changes referenced it. Git doesn't delete commits that are reachable from any branch. Reset main to the correct hash, force-push again.

# Fix the over-reset by targeting the exact commit
git reset --hard fcdd1f5
git push --force-with-lease

Using the commit hash directly instead of HEAD~N eliminates counting errors. HEAD~6 means "six commits before HEAD"—but if HEAD already moved, the offset is wrong. A hash is absolute.

The phantom files

After resetting main, git status showed twenty-plus untracked files: new components, services, hooks, utility functions. Files that existed in the newer commits but not in the older commit main now pointed to.

git reset --hard restores tracked files to the target commit's state. But files that were added in newer commits and don't exist in the target commit become untracked. Git won't delete files it doesn't know about—that's by design. It protects against accidental data loss.

# Preview what would be removed
git clean -n -d
 
# Remove untracked files and directories
git clean -fd

One file needed to be kept—a checklist document that wasn't part of either branch's history but was still useful. Copied it out before cleaning:

cp CHECKLIST.md ~/CHECKLIST.md.bak
git clean -fd
cp ~/CHECKLIST.md.bak CHECKLIST.md

The HEAD~N ambiguity

The second repository threw an unexpected error:

git reset HEAD~6
fatal: ambiguous argument 'HEAD~6': unknown revision or path not in the working tree

This error surfaces when Git can't resolve the reference. Common causes: detached HEAD state, a shallow clone with insufficient depth, or an orphan branch with no parent commits. The fix was bypassing relative references entirely and using the absolute commit hash.

# Instead of relative reference
git reset --hard HEAD~6     # fails
 
# Use absolute hash
git reset --hard 885ec1b    # works

Relative references depend on the current state of HEAD. Absolute hashes don't. When in doubt, git log the target branch and copy the hash directly.

Lessons

Branch before you reset. The order is everything. A branch created at HEAD preserves the full commit chain regardless of what happens to other branches afterward. It costs nothing and gives you a complete rollback path.

Use commit hashes over relative offsets. HEAD~N requires you to count correctly and assumes HEAD is where you think it is. A hash is deterministic. Copy it from git log, paste it into the command.

--force-with-lease over --force, always. It's a single flag that prevents you from overwriting someone else's pushed work. The only scenario where --force is justified is when you explicitly want to overwrite regardless—which is almost never.

Untracked files survive resets. git reset --hard only affects tracked files. New files introduced in later commits become orphaned on disk after a reset. git clean -fd handles them, but check for anything worth keeping first.

Local refs outlive remote state. Force-pushing changes the remote. It doesn't touch local branches. As long as a local branch points to a commit, that commit and its entire ancestry are safe from garbage collection. Your local repo is the source of truth during history rewrites.