Git 2.56's history drop command replays commits 40 to 77 times faster than rebase - DEV Community

DEV Community

Alex Georgiev
Alex Georgiev

Posted on AI-assisted

Git 2.56's history drop command replays commits 40 to 77 times faster than rebase

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
Enter fullscreen mode Exit fullscreen mode

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 add a linked worktree off the bare repo, run git rebase --onto <target>^ <target> main, remove the worktree.
  • New way: git history drop <target> --update-refs=branches directly 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Dropping a merge commit:

$ git history drop <merge-hash> --update-refs=branches
error: cannot drop merge commit: 292f3c6e56c3001b8ea93ccc69751f27b8fd8424
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)