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.
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-changesThe --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.
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-leaseUsing 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 -fdOne 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.mdThe 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 # worksRelative 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.