I ran the same command on the same 40 files twice. The only thing I changed was which 40 files. The first run took 861 milliseconds. The second took 6.4 seconds, and lost to the thing it's supposed to replace.
Git 2.52.0 added git last-modified, a command that walks history once and reports, for every path you give it, the closest commit that touched it. It exists because the obvious alternative — running git log -1 -- <path> once per file — is what every repo browser's file listing has always done, and it's wasteful: each call re-walks history from scratch for one path. git last-modified is meant to do that walk once and answer for all the paths at the same time. It's still marked EXPERIMENTAL in its own manual page, and it picked up real bug fixes as late as 2.54, so I wanted to know what the current stable release, 2.56.0 (released 27 September 2026), actually delivers.
I tested against the git/git repository itself, which is also where the original performance discussion happened. I cloned it with --filter=blob:none (full commit and tree history, no file contents, since last-modified never needs blob bytes) and built Git 2.56.0 from source, because no package repository I could reach ships it yet. 82,542 commits, 4,857 tracked files, on a 4-core VM.
The headline number
The realistic case for this command is a repo browser listing one directory: here are these 40 files, show me who touched each one last. I ran it against git.git's top-level listing, three times each, fastest run reported:
| Method | Time (40 files, one directory) |
|---|---|
git last-modified -- <40 files> |
861ms |
per-file git log -1 loop |
2,981ms |
That's 3.5x faster, and I checked the actual output against git log -1 for five of the files by hand — every commit hash matched. It isn't fast because it's wrong.
The opposite reading: same file count, different shape, different winner
The 40 files above all live in one directory, which means a lot of their history overlaps — the same release-prep commits, the same large refactors, touch several of them together. That overlap is exactly what lets a single history walk answer for all of them at once. So I built a second set of 40 files, picked at random from anywhere in the 4,857 tracked files, with no shared directory and mostly unrelated histories, and ran the identical comparison:
| Method | Time (40 files, scattered across the tree) |
|---|---|
git last-modified -- <40 scattered files> |
6,402ms |
per-file git log -1 loop |
5,793ms |
Same number of files, same repository, same machine. last-modified is now the slower option. I pushed the scattered case further to see whether that gap was a fluke:
| Scattered file count | last-modified |
log loop |
|---|---|---|
| 40 | 6.40s | 5.79s |
| 50 | 7.48s | 6.59s |
| 100 | 25.7s | 12.2s |
Going from 50 to 100 scattered files should roughly double the loop's time, and it does (6.59s to 12.2s). last-modified more than tripled over the same step (7.48s to 25.7s). I killed a 500-file run of the same shape after it had burned over four CPU-minutes without finishing — more evidence this isn't linear once the paths stop sharing history, not a timing fluke. Whatever bookkeeping the command does per tracked path seems to compound badly when the paths don't overlap, and the maintainer's own "about 6x faster" claim, which I could reproduce for a real directory listing, doesn't hold at all for a scattered set of the same size.
The practical rule: this command is for "show me the directory," not "show me these files I picked from across the repo." A GitHub- or Forgejo-style file browser is exactly the first case. A dashboard that pulls together files from unrelated parts of a codebase is the second, and the per-file loop will beat it there.
It also loses outright on a single file
The whole appeal of last-modified is spreading one history walk across many paths. Hand it exactly one path and there's nothing to spread, so the comparison just measures whatever setup the command does before it starts answering at all.
| Method | Time (1 file, COPYING) |
|---|---|
git last-modified -- COPYING |
684ms (min of 5), up to 1,582ms |
git log -1 -- COPYING |
431ms (min of 5), up to 744ms |
Five runs each, and last-modified never once beat git log -1 for this single file, even on its fastest attempt. Its spread was wider too, up to 1.58 seconds on a slow run against git log's worst of 744 milliseconds, which looks like the setup cost itself is a little unpredictable rather than just slower on average. If you're fetching the last-touch commit for one file, plain git log -1 is still the right tool, because there's no second path for the new command to amortize anything against.
A pathspec order bug that isn't in any changelog
The manual says the default (--max-depth=0) "shows all paths matching the pathspec without descending into subtrees." I gave it a directory and one of its own children as two separate pathspecs, in both orders:
$ git last-modified -- contrib
a9bd346d8b6570a950724d4196224a493b038243 contrib
$ git last-modified -- contrib/completion
1c1256682a54d6738880e11ed2edf4b36757ef6c contrib/completion
$ git last-modified -- contrib contrib/completion
1c1256682a54d6738880e11ed2edf4b36757ef6c contrib/completion
30cc6c91b618a26a355558d6ee6aadbe7a0d5d85 contrib/buildsystems
b837e5f4bc1577f267edfec5459babafc156d7ed contrib/contacts
[... 15 more rows, one per immediate child of contrib ...]
$ git last-modified -- contrib/completion contrib
a9bd346d8b6570a950724d4196224a493b038243 contrib
Asked alone, each path gives exactly one row, matching git log -1 for the same path. Asked together with the parent listed first, contrib silently expands into all 18 of its immediate children and its own aggregate row disappears entirely — nothing told me it was recursing, and --max-depth=0 didn't stop it, because I'd already passed that explicitly. Reverse the order, child first, and it collapses correctly back to the single contrib row. Nothing in the 2.52 through 2.56 release notes mentions this, and I couldn't find it described anywhere. For a tool meant to back a file browser, where the pathspec list is built from whatever the user clicked on, this is the kind of bug that would show up as an intermittent, hard-to-reproduce wrong listing, because it depends on argument order.
One bug that is actually fixed
The 2.54 release notes claimed "giving git last-modified a tree (not a commit-ish) died an uncontrolled death, which has been corrected." I checked:
$ git last-modified "$(git rev-parse HEAD^{tree})" -- COPYING
error: revision argument '006b28c5d4599d10965d9e5bbe22450db8c24eca' is a tree, not a commit-ish
A clean, named error, not a crash. That one holds up.
What it costs, and how it behaves under load
Peak memory for both approaches landed close together — about 190MB for last-modified against 50 paths, and roughly the same for the single heaviest git log process in the loop. Both numbers are dominated by loading this repository's commit-graph and pack index, not by anything last-modified itself allocates, so there's no memory story here worth claiming either way.
Running four copies of the 40-file directory listing at once, simulating four people loading the same repo page simultaneously, finished in 1.3 seconds wall-clock on my 4-core box, against 3.64 seconds run one after another. It's a read-only command with nothing to lock, so it parallelises about as well as the core count allows, with no contention I could detect.
What I got wrong on the way
My first build failed outright: make died on http.c unable to find curl/curl.h, because I'd skipped libcurl4-openssl-dev on the assumption that a locally-run, locally-cloned binary wouldn't need network transport code at all. Git's Makefile doesn't separate "can clone over https" from "can do anything," so leaving NO_CURL unset means it always tries to compile the curl-backed transport. Adding NO_CURL=1 NO_EXPAT=1 to the build fixed it in under two minutes, and the resulting binary works fine for everything in this post, since none of it touches a remote.
Run it yourself
This needs Git built from source, since 2.56 isn't in most distributions' repositories yet:
git clone --filter=blob:none https://github.com/git/git.git git-source
cd git-source && git checkout v2.56.0
make -j"$(nproc)" NO_GETTEXT=1 NO_TCLTK=1 NO_PERL=1 NO_CURL=1 NO_EXPAT=1
./git --version # git version 2.56.0
Then the comparison itself, against any repo with real history (git/git works well because it's large and public):
import subprocess, sys, time
GITBIN, REPO = "./git", "./git-source"
def run(cmd, reps=3):
times = []
for _ in range(reps):
t0 = time.perf_counter()
subprocess.run(cmd, cwd=REPO, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
times.append(time.perf_counter() - t0)
return times
paths = sys.argv[1:]
print("last-modified:", run([GITBIN, "last-modified", "--"] + paths))
print("log loop:", run(["sh", "-c",
"for p in " + " ".join(paths) + "; do git log -1 --format=%H -- \"$p\"; done"]))
Run it with a real directory's file list first (git ls-tree HEAD --name-only inside some subdirectory), then with the same number of paths picked from across the tree, and compare. The gap flips.
If you're building a file browser or a dashboard on top of this command, test it against your own directory shapes before trusting the "faster" claim, and don't pass a parent directory alongside one of its own children until the ordering bug above is fixed — list each directory's contents as its own call instead.
Top comments (5)
The pathspec order bug is a great find. It is exactly the kind of behavior that only shows up when someone tests the unhappy path instead of the happy one. The practical rule of thumb, one directory listing yes, scattered paths no, is something I will actually remember next time I reach for this command.
Thanks, yeah the order one was a total accident, I only tried both orders cause I was double checking something else lol.
Small tweak on the rule of thumb though, "scattered paths no" is a bit harsher than the numbers. Scattered was 6.4s vs 5.8s for the plain git log loop, so it's more "no real win" than "don't". It's the single directory case where it actually pays off.
And whichever way you use it, don't pass a directory and one of its own children in the same call. That's the case that quietly drops a row.
Thanks for the correction — the 6.4s vs 5.8s numbers and the single-directory sweet spot make the guidance much clearer.
The scattered-paths run is the most useful part here, since benchmarks usually stop at the happy path of a single directory listing. My guess is it can prune whole subtrees when every path shares a parent, while scattered paths force it to diff trees across most of the repo on each commit. Does the dropped row from the parent-then-child pathspec order go away if you sort the paths first? Seems worth a report to the Git mailing list while the command is still marked experimental.
Yeah the subtree pruning guess sounds right to me too. A shared parent lets it skip whole trees that didn't change, scattered paths mean almost every commit touches something it cares about. The 50 to 100 jump going 3x instead of 2x fits that, though I didn't dig into the code to confirm it.
On sorting, funny thing, a normal sort would actually make it worse. "contrib" sorts before "contrib/completion", so sorting puts the parent first every time, which is exactly the order that breaks. Reverse sort would give you child first, but I haven't tested that, and I wouldn't want a file browser depending on argument order to get the right answer anyway. Easier to just not pass a directory together with its own children.
And yeah, the mailing list is the right place while it's still experimental. I should actually write that up instead of just talking about it here.