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 history is an experimental command for simple history edits without a rebase. Git 2.55 already had fixup, reword and split. drop is new in 2.56; Git 2.55 answers error: unknown subcommand: `drop'.
  • By default (--update-refs=branches), every local branch that contains the commit is rewritten. --update-refs=head changes only the current branch. Remote-tracking branches like origin/main are never touched.
  • --dry-run creates the new commits, but only prints which branches would move.
  • --empty decides what happens to a commit that becomes empty, like a revert of the dropped commit: drop (the default) removes it, keep keeps it, abort stops.
  • 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 written

The 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 -> main

The 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 commit

Then 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 commit

Both 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 commit

billing 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 commit

ORIG_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/billing

No 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 endpoint

The 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 commit

One 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 clean

debug.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 endpoint

The 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-7f3a9c2e41

The 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 A

Back 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 clean

Layout 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-backup

Before 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/billing

By 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 rate

With --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 numbers

A 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 descendants

Without 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 endpoint

The 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 onto

The 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 clean

Dropping 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; aborting

An 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=head changes only the current branch, so a backup branch stays as it is.
  • --dry-run shows which branches would move.
  • A commit and its revert go together: the revert becomes empty and is dropped too (--empty=drop is 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.
  • :/text finds the youngest commit whose message matches. Check it with git 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 -i is 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: