I often commit things that I don’t want to keep: debug settings, a hard-coded value, a quick test. The plan is always to drop them before the review. To do that, I open git rebase -i, find the line, change pick to drop and save. It works, but only for the branch that I have checked out. When I work with stacked pull requests, the second one built on top of the first, the branch below keeps the commit, unless I remember --update-refs.
Git 2.56 adds a shorter way:
git history drop <commit>
It removes one commit and replays everything after it onto its parent. There is no todo list, no editor, and no rebase that stops halfway. And every local branch that contains the commit follows, even the ones that I don’t have checked out.
In this article, I show how it works step by step: a small API project, two stacked pull requests, and a debug token in the first one. I remove it first the old way, and then with the new command. Every step has the commands and the real output, so you can repeat it in your own terminal.
This is my fourth article about Git 2.56. In the first one, about git add --resolved, I showed how I installed Git 2.56 with mise without compiling anything. The second one is about git branch --delete-merged, and the third one about git bisect --reset-when-found.
How it works
git history drop <commit> [--dry-run] [--update-refs=(branches|head)]
[--empty=(drop|keep|abort)]
git historyis an experimental command for simple history edits without a rebase. Git 2.55 already hadfixup,rewordandsplit.dropis new in 2.56; Git 2.55 answerserror: unknown subcommand: `drop'.- By default (
--update-refs=branches), every local branch that contains the commit is rewritten.--update-refs=headchanges only the current branch. Remote-tracking branches likeorigin/mainare never touched. --dry-runcreates the new commits, but only prints which branches would move.--emptydecides what happens to a commit that becomes empty, like a revert of the dropped commit:drop(the default) removes it,keepkeeps it,abortstops.- If something is in the way, like a conflict or local changes, it stops before it moves any branch. You are never left in the middle of a rebase.
Step by step
The commit IDs in your terminal will be different from mine, and that’s normal.
Step 1: A server and a clone
demo $ git init --bare api.git
Initialized empty Git repository in …/api.git/
demo $ git clone api.git api
Cloning into 'api'...
warning: You appear to have cloned an empty repository.
done.
demo $ cd api
api (main) $ git status
On branch main
No commits yet
nothing to commit (create/copy files and use "git add" to track)api.git plays the server, like GitHub or GitLab. I work in a clone of it, which is still empty.
Step 2: A README, the first commit and a push
api (main) $ vim README.md
1 # api
2
3 A tiny HTTP API: users, orders and billing.
"README.md" [New] 3L, 51B writtenThe README.
api (main) $ git add README.md
api (main) $ git commit -m "Initial commit"
[main (root-commit) dae88c9] Initial commit
1 file changed, 3 insertions(+)
create mode 100644 README.md
api (main) $ git push
To …/api.git
* [new branch] main -> mainThe first commit goes to the server.
Step 3: Pull request #1: users, orders, and a debug commit
api (main) $ git switch -c orders
Switched to a new branch 'orders'
api (orders) $ mkdir src
api (orders) $ vim src/users.py
1 def users():
2 return ["ada", "grace"]
"src/users.py" [New] 2L, 41B written
api (orders) $ git add src/users.py
api (orders) $ git commit -q -m "Add users endpoint"A branch for the first pull request, orders, and the users endpoint.
api (orders) $ vim debug.env
1 DEBUG=1
2 API_TOKEN=dev-7f3a9c2e41
"debug.env" [New] 2L, 33B written
api (orders) $ git add debug.env
api (orders) $ git commit -q -m "WIP: local debug settings (do not ship)"To test locally, I commit my debug settings, with a token in them. The plan is to drop this commit before the review.
api (orders) $ vim src/orders.py
1 def orders():
2 return []
"src/orders.py" [New] 2L, 28B written
api (orders) $ git add src/orders.py
api (orders) $ git commit -q -m "Add orders endpoint"
api (orders) $ git log --oneline
d3d34e3 (HEAD -> orders) Add orders endpoint
cc34dbb WIP: local debug settings (do not ship)
83c12c6 Add users endpoint
dae88c9 (origin/main, main) Initial commitThen the orders endpoint. Now the token sits in the middle of the branch: cc34dbb.
Step 4: Pull request #2, stacked on top of #1: billing
api (orders) $ git switch -c billing
Switched to a new branch 'billing'
api (billing) $ vim src/billing.py
1 def invoice(order):
2 return {"order": order, "total": 0}
"src/billing.py" [New] 2L, 60B written
api (billing) $ git add src/billing.py
api (billing) $ git commit -q -m "Add billing endpoint"The second pull request, billing, starts where orders ends.
api (billing) $ git push -u origin orders billing
To …/api.git
* [new branch] orders -> orders
* [new branch] billing -> billing
branch 'orders' set up to track 'origin/orders'.
branch 'billing' set up to track 'origin/billing'.
api (billing) $ git log --oneline --graph --decorate --all
* df16dd5 (HEAD -> billing, origin/billing) Add billing endpoint
* d3d34e3 (origin/orders, orders) Add orders endpoint
* cc34dbb WIP: local debug settings (do not ship)
* 83c12c6 Add users endpoint
* dae88c9 (origin/main, main) Initial commitBoth branches go to the server. The graph shows the stack: billing on top of orders, and the WIP commit in both of them.
Step 5: Oops: the token is in pull request #1
api (billing) $ git show --stat --oneline ":/WIP"
cc34dbb WIP: local debug settings (do not ship)
debug.env | 2 ++
1 file changed, 2 insertions(+)
api (billing) $ git branch --contains ":/WIP"
* billing
orders
api (billing) $ WIP=$(git rev-parse --short ":/WIP")A reviewer spots it. :/WIP finds the youngest commit whose message matches WIP, and both branches contain it. I save its ID in WIP, because I need it later.
Step 6: The old way: git rebase -i
api (billing) $ git rebase -i main
1 pick 83c12c6 # Add users endpoint
2 drop cc34dbb # WIP: local debug settings (do not ship)
3 pick d3d34e3 # Add orders endpoint
4 pick df16dd5 # Add billing endpoint
5
6 # Rebase dae88c9..df16dd5 onto dae88c9 (4 commands)
7 #
8 # Commands:
9 # p, pick <commit> = use commit
10 # r, reword <commit> = use commit, but edit the commit message
11 # e, edit <commit> = use commit, but stop for amending
12 # s, squash <commit> = use commit, but meld into previous commit
13 # f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
14 # commit's log message, unless -C is used, in which case
15 # keep only this commit's message; -c is same as -C but
16 # opens the editor
17 # x, exec <command> = run command (the rest of the line) using shell
18 # b, break = stop here (continue rebase later with 'git rebase --continue')
19 # d, drop <commit> = remove commit
20 # l, label <label> = label current HEAD with a name
21 # t, reset <label> = reset HEAD to a label
22 # m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
23 # create a merge commit using the original merge commit's
24 # message (or the oneline, if no original merge commit was
25 # specified); use -c <commit> to reword the commit message
26 # u, update-ref <ref> = track a placeholder for the <ref> to be updated
27 # to this position in the new commits. The <ref> is
28 # updated at the end of the rebase
29 #
30 # These lines can be re-ordered; they are executed from top to bottom.
31 #
32 # If you remove a line here THAT COMMIT WILL BE LOST.
33 #
34 # However, if you remove everything, the rebase will be aborted.
35 #
".git/rebase-merge/git-rebase-todo" 35L, 1641B written
Successfully rebased and updated refs/heads/billing.vim opens the todo list. I find the WIP line, change pick to drop and save.
api (billing) $ git log --oneline --graph --decorate --all
* 4a7ec41 (HEAD -> billing) Add billing endpoint
* 9e3e4c6 Add orders endpoint
| * df16dd5 (origin/billing) Add billing endpoint
| * d3d34e3 (origin/orders, orders) Add orders endpoint
| * cc34dbb WIP: local debug settings (do not ship)
|/
* 83c12c6 Add users endpoint
* dae88c9 (origin/main, main) Initial commitbilling is clean now, but orders still has the token: the rebase moved only the branch that I had checked out. git rebase -i --update-refs main would have moved orders too, but only if I remember the option, and only because billing is the top of the stack.
Step 7: Undo the rebase
api (billing) $ git reset --hard ORIG_HEAD
HEAD is now at df16dd5 Add billing endpoint
api (billing) $ git log --oneline --graph --decorate --all
* df16dd5 (HEAD -> billing, origin/billing) Add billing endpoint
* d3d34e3 (origin/orders, orders) Add orders endpoint
* cc34dbb WIP: local debug settings (do not ship)
* 83c12c6 Add users endpoint
* dae88c9 (origin/main, main) Initial commitORIG_HEAD brings the old history back, so I can try the new command on it.
Step 8: Preview with --dry-run
api (billing) $ git history drop --dry-run ':/WIP' >../plan.txt
api (billing) $ cut -d" " -f1,2 ../plan.txt
update refs/heads/orders
update refs/heads/billingNo todo list and no editor. --dry-run prints what would change and moves nothing. Each line is update <ref> <new> <old> with full commit IDs, in the format of git update-ref --stdin. I save them to a file and show only the first two fields: both branches would move.
api (billing) $ git log --oneline main..$(grep billing ../plan.txt | cut -d" " -f3)
0faf08a Add billing endpoint
0d52114 Add orders endpoint
83c12c6 Add users endpointThe new commits already exist, so I can look at the result before anything moves. The WIP commit is gone.
Step 9: Drop the commit
api (billing) $ git history drop ':/WIP'
api (billing) $ git log --oneline --graph --decorate --all
* 185bf61 (HEAD -> billing) Add billing endpoint
* ae17b16 (orders) Add orders endpoint
| * df16dd5 (origin/billing) Add billing endpoint
| * d3d34e3 (origin/orders) Add orders endpoint
| * cc34dbb WIP: local debug settings (do not ship)
|/
* 83c12c6 Add users endpoint
* dae88c9 (origin/main, main) Initial commitOne command, and both branches moved. orders doesn’t have the token anymore, and I never checked it out. The origin/ branches still show the old history: they change only when I push. The new IDs are different from the preview (185bf61, not 0faf08a), because the real run makes the commits again, with a new committer date.
api (billing) $ ls
README.md src
api (billing) $ git status
On branch billing
Your branch and 'origin/billing' have diverged,
and have 2 and 3 different commits each, respectively.
(use "git pull" if you want to integrate the remote branch with yours)
nothing to commit, working tree cleandebug.env is gone from the working tree, and the tree is clean. My branch and the server’s have diverged, as after every rewrite.
Step 10: The reflog remembers the old tips
api (billing) $ git reflog -2 billing
185bf61 (HEAD -> billing) billing@{0}: drop: dropping :/WIP
df16dd5 (origin/billing) billing@{1}: reset: moving to ORIG_HEAD
api (billing) $ git reflog -2 orders
ae17b16 (orders) orders@{0}: drop: dropping :/WIP
d3d34e3 (origin/orders) orders@{1}: commit: Add orders endpointThe reflog shows the drop on both branches. To undo it: git reset --hard billing@{1} and git branch -f orders orders@{1}.
Step 11: Push the fixed branches, and rotate the token
api (billing) $ git push origin orders billing
To …/api.git
! [rejected] billing -> billing (non-fast-forward)
! [rejected] orders -> orders (non-fast-forward)
error: failed to push some refs to '…/api.git'
hint: Updates were rejected because the tip of your current branch is behind
hint: its remote counterpart. If you want to integrate the remote changes,
hint: use 'git pull' before pushing again.
hint: See the 'Note about fast-forwards' in 'git push --help' for details.Both branches were rewritten, so a plain push is rejected.
api (billing) $ git push --force-with-lease origin orders billing
To …/api.git
+ df16dd5...185bf61 billing -> billing (forced update)
+ d3d34e3...ae17b16 orders -> orders (forced update)--force-with-lease pushes them, but only if the server still has what I saw last.
api (billing) $ git -C ../api.git show $WIP:debug.env
DEBUG=1
API_TOKEN=dev-7f3a9c2e41The pull requests are clean now. But the server still has the old commit, and so do forks, CI caches and every clone. If the token were real, I would rotate it anyway.
Step 12: A branch you don’t have checked out
On a new branch, experiment, I try three ideas for the invoice layout, one commit each. If you follow along:
git switch -c experiment
mkdir templates
echo '<table>' > templates/invoice-a.html
git add templates && git commit -qm "Try invoice layout A"
echo '<div>' > templates/invoice-b.html
git add templates && git commit -qm "Try invoice layout B"
echo '<pre>' > templates/invoice-c.html
git add templates && git commit -qm "Try invoice layout C"
git switch billing
api (billing) $ git switch -c experiment
Switched to a new branch 'experiment'
api (experiment) $ git switch billing
Switched to branch 'billing'
Your branch is up to date with 'origin/billing'.
api (billing) $ git log --oneline billing..experiment
849c291 (experiment) Try invoice layout C
47806d4 Try invoice layout B
a4ef615 Try invoice layout ABack on billing. experiment has three commits that billing doesn’t have.
api (billing) $ git history drop ':/layout B'
api (billing) $ git log --oneline billing..experiment
7916703 (experiment) Try invoice layout C
a4ef615 Try invoice layout A
api (billing) $ git status
On branch billing
Your branch is up to date with 'origin/billing'.
nothing to commit, working tree cleanLayout B is gone from experiment, and I never left billing. The replay happens in memory, so HEAD and the working tree are not touched.
Step 13: A backup branch is rewritten too, unless you use --update-refs=head
Two more commits on billing: a scratch commit, and real work on top of it.
echo 'RATE = 0.2 # scratch: hard-coded' > src/tax.py
git add src/tax.py && git commit -qm "Scratch: hard-code the tax rate"
echo 'def next_number(): ...' > src/numbers.py
git add src/numbers.py && git commit -qm "Add invoice numbers"
api (billing) $ git log --oneline -2
c804d17 (HEAD -> billing) Add invoice numbers
4facffc Scratch: hard-code the tax rate
api (billing) $ git branch billing-backupBefore I clean up, I make a backup branch, just in case.
api (billing) $ git history drop --dry-run ':/Scratch' | cut -d' ' -f1,2
update refs/heads/billing-backup
update refs/heads/billingBy default, every branch that contains the commit is rewritten, and that includes my backup.
api (billing) $ git history drop --update-refs=head ':/Scratch'
api (billing) $ git log --oneline -2 billing
881990d (HEAD -> billing) Add invoice numbers
185bf61 (origin/billing) Add billing endpoint
api (billing) $ git log --oneline -2 billing-backup
c804d17 (billing-backup) Add invoice numbers
4facffc Scratch: hard-code the tax rateWith --update-refs=head, only the current branch changes. The backup keeps the scratch commit.
Step 14: A commit and its revert
api (billing) $ vim src/billing.py
1 def invoice(order):
2 print("invoice", order)
3 return {"order": order, "total": 0}
"src/billing.py" 3L, 88B written
api (billing) $ git commit -q -am "Log every invoice"
api (billing) $ git revert --no-edit HEAD
[billing 325ea81] Revert "Log every invoice"
Date: Thu Jan 1 10:58:00 2026 +0000
1 file changed, 1 deletion(-)
api (billing) $ git log --oneline -3
325ea81 (HEAD -> billing) Revert "Log every invoice"
2e4567c Log every invoice
881990d Add invoice numbersA log line in src/billing.py, a commit, and then a revert of it. A revert removes the change, not the commit.
api (billing) $ git history drop --empty=abort ':/^Log every invoice'
error: commit 325ea81c28e26183f64905f40654a99ebf6926e9 became empty after replay
error: failed replaying descendantsWithout the original commit, the revert has nothing left to do: it becomes empty. --empty=abort stops here. Note the ^ in :/^Log every invoice: without it, :/ would find the revert, because Revert "Log every invoice" matches too, and it’s the youngest commit.
api (billing) $ git history drop ':/^Log every invoice'
api (billing) $ git log --oneline -2
881990d (HEAD -> billing) Add invoice numbers
185bf61 (origin/billing) Add billing endpointThe default, --empty=drop, removes the empty revert as well. Both commits are gone.
Step 15: Safety rails: the root commit and conflicts
Two commits that change the same line of config.ini: first 100, then 500.
printf '[api]\nrate_limit = 100\n' > config.ini
git add config.ini && git commit -qm "Set the rate limit to 100"
printf '[api]\nrate_limit = 500\n' > config.ini
git commit -qam "Raise the rate limit to 500"
api (billing) $ git history drop main
error: cannot drop root commit main: it has no parent to replay ontoThe root commit has no parent to replay onto.
api (billing) $ git log --oneline -2
35f724a (HEAD -> billing) Raise the rate limit to 500
1d6b5e2 Set the rate limit to 100
api (billing) $ git history drop ':/to 100'
error: failed replaying descendants
api (billing) $ git status
On branch billing
Your branch is ahead of 'origin/billing' by 3 commits.
(use "git push" to publish your local commits)
nothing to commit, working tree cleanDropping the first commit would make the second one conflict. Git stops, and nothing is rewritten: no half-done rebase, and the tree is clean.
Step 16: Safety rails: local changes and merges
api (billing) $ vim config.ini
1 [api]
2 rate_limit = 1000
"config.ini" 2L, 24B written
api (billing) $ git history drop HEAD
error: Your local changes to the following files would be overwritten by checkout:
config.ini
Please commit your changes or stash them before you switch branches.
Aborting
error: dropping this commit would overwrite local changes; abortingAn uncommitted change in config.ini. Dropping HEAD would overwrite it, so Git refuses before it moves anything.
api (billing) $ git restore config.ini
api (billing) $ git merge -q --no-ff --no-edit experiment
api (billing) $ git history drop HEAD
error: cannot drop merge commit: HEAD
api (billing) $ git history drop HEAD^1
error: replaying merge commits is not supported yet!Histories with merges are not supported yet: neither a merge commit itself, nor a commit below a merge.
What to remember
git history drop <commit>removes one commit and replays the rest onto its parent. No todo list, no editor.- Every local branch that contains the commit follows, checked out or not.
--update-refs=headchanges only the current branch, so a backup branch stays as it is. --dry-runshows which branches would move.- A commit and its revert go together: the revert becomes empty and is dropped too (
--empty=dropis the default). - It refuses the root commit, merges, conflicts and local changes that would be lost. In all these cases nothing changes, and the exit code is 255.
:/textfinds the youngest commit whose message matches. Check it withgit show --stat --oneline ':/text'first, or use the commit ID.- For several commits at once, for reordering, or when you need to resolve a conflict,
git rebase -iis still the tool. - If the commit had a real secret in it, rotate the secret. It has already left your machine.
Conclusion
In summary, git history drop does one thing, and it does it for the whole stack of branches. For one commit, I don’t need the todo list anymore, and I don’t need to remember --update-refs. git history is still marked as experimental, so options and output may change. But for a single commit that shouldn’t be there, it’s much simpler than an interactive rebase.
Links: