I wanted to remove one commit from the middle of a bare repository without checking anything out. The usual answer is: you can't, not directly. You add a worktree, rebase, then throw the worktree away. Git 2.56, released on 28 September 2026, ships an experimental command that skips all of that: git history drop. I built it from source and measured what it actually saves.
Short version: on a bare repo with 400 commits stacked after the one I wanted gone, the classic worktree-and-rebase approach took 26.5 seconds. git history drop took 0.36 seconds. That's not a rounding difference.
Setting it up
Git 2.56.0 isn't in Ubuntu's apt repositories yet, and I don't have a release binary for it, so I built it from the tagged source:
$ git clone --depth 1 --branch v2.56.0 https://github.com/git/git.git git-src $ cd git-src $ make -j4 NO_GETTEXT=YesPlease NO_TCLTK=YesPlease NO_PERL=YesPlease \ NO_PYTHON=YesPlease NO_CURL=YesPlease NO_EXPAT=YesPlease $ ./git --version git version 2.56.0 git history is explicitly marked experimental in its own man page: "THIS COMMAND IS EXPERIMENTAL. THE BEHAVIOR MAY CHANGE." It currently has four subcommands: drop, fixup, reword, split. I focused on drop, which removes a commit and replays everything after it onto the commit's parent.
The benchmark
For each test size, I built a fresh non-bare repo with a root commit, a target commit to drop, and N descendant commits, each touching a shared file and a unique file so the replay has to do real diff work. I then cloned it --bare twice, so the two methods started from byte-identical history:
- Old way:
git worktree adda linked worktree off the bare repo, rungit rebase --onto <target>^ <target> main, remove the worktree. - New way:
git history drop <target> --update-refs=branchesdirectly against the bare repo, no worktree at all.
Three trials per size, wall-clock date +%s.%N around each run:
| Descendant commits | Old way (rebase), avg | range | New way (history drop), avg | range | Ratio |
|---|---|---|---|---|---|
| 20 | 1.418s | 1.364–1.473s | 0.035s | 0.034–0.037s | 40.7× |
| 100 | 7.121s | 7.014–7.183s | 0.092s | 0.084–0.097s | 77.5× |
| 400 | 26.525s | 25.504–27.296s | 0.362s | 0.287–0.414s | 73.2× |
Both methods produced the same resulting tree. I checked the tree hash of main after each run on a smaller repo and they matched exactly, even though the individual commit hashes differed (new commit timestamps, naturally):
$ git -C old.git rev-parse main^{tree} fadfb2c2c2f23eb52db063920d6e686fb90ea5f2 $ git -C new.git rev-parse main^{tree} fadfb2c2c2f23eb52db063920d6e686fb90ea5f2 So this isn't a case of the new command cutting corners. It gets to the same place, just much faster.
Where the 70× actually comes from
My first guess was that the saving was mostly in skipping git worktree add and the later git worktree remove. I timed those in isolation on the 400-commit case:
$ time git -C old.git worktree add -q /tmp/wt main real 0m0.017s $ time git -C old.git worktree remove /tmp/wt --force real 0m0.009s Together that's 26 milliseconds out of a 25-second run. The worktree setup isn't the story. I then timed the rebase itself, in isolation:
$ time (cd /tmp/wt && git rebase --quiet --onto "$target^" "$target" main) real 0m25.199s user 0m5.844s sys 0m6.047s Six seconds of system time to replay 400 commits is the giveaway. git rebase --onto cherry-picks each commit one at a time, and each cherry-pick updates the index and the worktree, even though nothing outside the final result is ever going to be looked at. git history drop never touches a working tree for drop, reword, or split — the docs say so directly, and the timing backs it up. It operates purely on objects in .git, so the cost scales with the actual diff work, not with filesystem writes per commit.
What else it does that rebase doesn't
Two differences surprised me enough to check directly, with exact output.
It updates every branch that descends from the dropped commit, not just the one you're on. I created main and a feature branch both pointing past a shared TARGET commit, then ran one git history drop:
$ git log --oneline main feature # before: main at d2, feature at d1, both past TARGET $ git history drop <target-hash> --update-refs=branches $ git log --oneline main 52b8e52 d2 1ef9a67 d1 dab8608 root $ git log --oneline feature 1ef9a67 d1 dab8608 root Both branches moved. A plain rebase only ever rewrites the branch you're currently on; cleaning up a second branch would be a second, separate rebase.
It does not run hooks. I put an executable post-rewrite hook in a test repo that appends to a log file. git history drop never triggered it. The same repo, same hook, run through git rebase --onto instead, fired it immediately:
$ git history drop <target-hash> --update-refs=head $ cat hook.log cat: hook.log: No such file or directory $ git rebase --quiet --onto HEAD~2 HEAD~1 main $ cat hook.log POST-REWRITE FIRED If your team relies on post-rewrite for anything — notifying a bot, updating a cache, re-running a linter — switching a script from rebase to git history drop will silently stop firing it. The man page does say hooks aren't run "at the current point in time," with a note that this may change, so don't treat this as permanent, but it's true today.
What it refuses
I tried the three things the docs say are off-limits, to see the actual errors rather than take the documentation's word for it.
Dropping the root commit:
$ git history drop <root-hash> --update-refs=branches error: cannot drop root commit 7b93b486e52f50f22330e79a04f5da05e7457734: it has no parent to replay onto Dropping a merge commit:
$ git history drop <merge-hash> --update-refs=branches error: cannot drop merge commit: 292f3c6e56c3001b8ea93ccc69751f27b8fd8424 Both refusals are immediate and exit non-zero (255 in my build). Neither one leaves anything behind to clean up.
The more interesting case is a drop that would cause a conflict during replay. I built a commit chain where the target commit changes a line and a later commit changes the same line again, which is exactly what would conflict:
$ git history drop <target-hash> --update-refs=branches error: failed replaying descendants Then I checked the repo state:
$ git status --short $ ls .git | grep -i rebase (no rebase state directory) $ git log --oneline e6e5ca7 d1 bdbfea2 TARGET 276c6de root Nothing changed. No conflict markers in any file, no rebase-merge directory, no half-finished state. History is exactly as it was before I ran the command. That's the actual payoff of "history rewrites are not intended to be stateful operations," which the man page states as a design limitation rather than a feature — but from where I'm sitting, a command that fails all-or-nothing is a feature. Compare that to an interactive rebase hitting the same conflict: it stops mid-sequence with the working tree dirty, and you either resolve it by hand or run git rebase --abort. git history drop skips that step entirely by refusing to start down a road it can't finish.
The dry-run output is genuinely a diff you can apply
--dry-run doesn't just print a preview; it prints git update-ref --stdin syntax:
$ git history drop <target-hash> --dry-run --update-refs=branches update refs/heads/main f5f696ff8266f3709b306457024cecbedb25275b ab0b41479b899b357f0cdbd462e0ea9041aba9c9 I piped that straight into update-ref and it worked exactly as the real run would have:
$ git history drop <target-hash> --dry-run --update-refs=branches | git update-ref --stdin $ git log --oneline main b2f5b62 d1 523c749 root That's a genuinely useful property if you want to review a rewrite before committing to it — generate the objects, inspect the proposed ref moves, then apply them with a separate, auditable command.
What I got wrong on the way
My first pass at the benchmark used git history drop --quiet to suppress output, matching the --quiet I was already passing to git rebase. git history drop doesn't have a --quiet flag, so every "new way" run failed immediately with error: unknown option 'quiet' and a usage message. Because I was piping stderr into a log file and only checking the elapsed time, the failed runs looked like spectacular successes — 0.003 seconds to drop 100 commits. I didn't believe that number, which is the only reason I caught it: I diffed the resulting commit hash against the pre-run hash and found they were identical, meaning nothing had happened at all. After that I stopped suppressing stderr in any timing run and let failures show up as failures.
Run it yourself
This needs Git built from the v2.56.0 tag, as above. The full benchmark script:
#!/usr/bin/env bash set -euo pipefail export PATH=/path/to/git-src:$PATH WORK=$(mktemp -d) make_src_repo () { local n=$1 dir=$2 git init -q -b main "$dir" (cd "$dir" git config user.email a@b.c; git config user.name test echo root > root.txt && git add root.txt && git commit -q -m root echo t > target.txt && git add target.txt && git commit -q -m TARGET git rev-parse HEAD > /tmp/target_hash.txt echo start > shared.txt for i in $(seq 1 "$n"); do echo "line$i" >> shared.txt; echo "f$i" > "f$i.txt" git add shared.txt "f$i.txt"; git commit -q -m "d$i" done) } for n in 20 100 400; do make_src_repo "$n" "$WORK/src" target=$(cat /tmp/target_hash.txt) git clone -q --bare "$WORK/src" "$WORK/old.git" wt="$WORK/wt" git -C "$WORK/old.git" worktree add -q "$wt" main t0=$(date +%s.%N) (cd "$wt" && git rebase --quiet --onto "${target}^" "$target" main) t1=$(date +%s.%N) git -C "$WORK/old.git" worktree remove "$wt" --force echo "n=$n old=$(echo "$t1 - $t0" | bc)s" git clone -q --bare "$WORK/src" "$WORK/new.git" t0=$(date +%s.%N) git -C "$WORK/new.git" history drop "$target" --update-refs=branches t1=$(date +%s.%N) echo "n=$n new=$(echo "$t1 - $t0" | bc)s" rm -rf "$WORK/src" "$WORK/old.git" "$WORK/new.git" done I ran this three times per size on an otherwise idle machine; your absolute numbers will depend on disk and CPU, but the ratio between the two methods should hold, since the gap comes from per-commit working-tree writes, not from anything specific to my hardware.
What to do with this
If you ever script history rewrites against a bare repo — cleaning up a leaked secret from a shared repository, dropping a bad commit from a long-lived integration branch, anything that currently involves a throwaway clone or worktree — try git history drop instead, but build it from source first, since it isn't in a stable release that's reached your distribution yet. Don't move anything that depends on post-rewrite firing until you've checked for that dependency, and don't reach for it on a history that contains merge commits: it will refuse, today, with no workaround beyond git rebase --rebase-merges. Treat the command as what its own man page calls it — experimental — and keep git rebase as your fallback for anything it refuses.