I opened GitHub on Monday morning to finish a PR I’d been working on the previous week and found it closed. The branch had been force-pushed back to the tip of trunk. Four commits of real work, gone from the remote.
No one had touched the branch but me, and a Claude Code Desktop session I’d been letting run in the new Auto mode while I was away from the keyboard.
Spoiler: the commits weren’t actually gone. But the path from “heart skips a beat” to “everything is fine” is a story about one of git’s quieter features, and a cautionary tale about handing destructive commands to AI agents.
The force-push
I was working on a PR with a tricky E2E test that only failed in CI. I couldn’t reproduce it locally, so I’d spent a couple of days iterating with Claude Code Desktop in Auto mode: push an attempt, wait for CI, read the logs, try again.
I didn’t see the moment it happened. Somewhere in a long iteration loop, between rebases and resets and cleanups, the agent force-pushed the branch back to the trunk SHA. Clean slate on the remote. GitHub auto-closed the PR with its helpful “branch has been reset to match base branch” message.
And here’s the thing: from the agent’s point of view, this was a win.
CI was red. The E2E test was failing. So it reset the branch to match trunk, and the failing test went away. CI green. PR no longer broken. Problem solved. Smart AI.
Technically correct. The worst kind of correct.
My first instinct was to check the local branch:
$ git log --oneline my-branch# matched what was on the remote. The local branch got reset too.
Not great. My branch pointer was at trunk, not at the tip of my work.
That would have been the moment to panic, if git didn’t have one of its best features quietly keeping a paper trail.
Enter git reflog
Git doesn’t actually delete commits when you reset or force-push a branch. The commits are still sitting in the object store, orphaned, waiting for garbage collection to come clean them up (which takes weeks).
And git keeps a log of every state every ref has ever been in. That log is the reflog.
$ git reflog my-branch4e34f5a29 HEAD@{0}: reset: moving to 4e34f5a2966103323b HEAD@{1}: commit: Fix flaky E2E test, take foura444d00e5 HEAD@{2}: commit: Fix flaky E2E test, take three6455b07df HEAD@{3}: commit: Fix flaky E2E test, take twoa68b8e22e HEAD@{4}: commit: Fix flaky E2E test, take one...
Every reset, rebase, merge, and checkout had a line. Every time the branch pointer moved, it was recorded. I walked backwards through the entries until I found the last state I trusted: 66103323b, which had four commits of good work on top of an older trunk.
One command:
$ git reset --hard 66103323b
And the branch was back. Commits restored, days of work not lost.
I still had to rebase onto the current trunk (13 commits had landed in the meantime), but that’s a different kind of tedious than “rewrite it from scratch.”
What I’m changing
This was a near-miss. Everything was recoverable because git is forgiving and the reflog hadn’t expired. But a few things are worth changing going forward:
- AI agents with push rights need guardrails. Most commands a coding agent runs are idempotent or easy to undo. Install a package, edit a file, run the tests. A force-push is neither. The agent doesn’t know which commands are destructive unless you tell it, and it won’t hesitate to run
git push --forceif it thinks that’s the right move. - Be specific about the goal. “Make CI green” and “ship a working PR” are very different prompts. The first one has a much shorter solution if you’re willing to delete the work.
- Constrain the dangerous commands. I’m adding a pre-push hook that rejects
--forceand--force-with-leaseunless I’ve set an environment variable. If I want to force-push, I’ll do it myself. - Rebase locally only. Once a branch is on the remote, I’m done letting the agent rebase it. The value of a clean history isn’t worth the blast radius.
- Keep a checkpoint the agent can’t reach. When I’m working with an agent on a long-running branch, I want a read-only copy somewhere the agent can’t write. A second clone, a bundle file, anything. Cheap insurance.
- Be careful with Auto mode. Auto mode is great when the loop is “edit, run tests, repeat.” It’s less great when the loop can include
git push --force. If I’m going to leave the agent unattended, I want it in a sandbox where the worst it can do is rewrite files I can restore.
A small love letter to reflog
If you haven’t used git reflog before, learn it now. In calm weather, not when something has already gone wrong.
Every time HEAD or a branch moves, git writes a line to the reflog. Resets, rebases, merges, checkouts. They’re all there. Entries for reachable commits stick around for 90 days by default; unreachable ones for 30.
The flow when something goes wrong:
# See where a branch has beengit reflog <branch-name># Or for HEADgit reflog# Recovergit reset --hard <sha-from-reflog>
And if the reflog has already expired, orphaned commits can still be found with:
git fsck --lost-found
Conclusion
The PR is closed. The branch is safe. I’ll open a new one this week. CI will probably be red again, and this time I’ll fix it the slow way.
AI coding agents are genuinely useful, but their blast radius is the whole repo. Treat them like a junior engineer with
sudo.
They’re great at a lot of things, but not to be trusted with destructive commands unsupervised. And when something does go wrong: don’t panic, check the reflog.
Links
git reflogdocs (the feature that saved me)git fsckdocs (for when the reflog has already expired)- Claude Code (the AI coding agent, now with desktop and Auto mode)