ls-files: filter pathspec before lstat

In --deleted and --modified modes, show_files() calls lstat() for each index entry before show_ce() applies the pathspec. prune_index() avoids most of these calls for pathspecs with a common directory prefix, but not for a top-level name or leading wildcard. Match before lstat() to avoid accessing the worktree for entries that cannot be shown. Treat this as a prefilter: do not update ps_matched, and retain the match in show_ce() so --error-unmatch is satisfied only by entries that the selected modes actually show. Prefilter only a single pathspec item, bounding the added work for each index entry. Applying match_pathspec() to multiple arguments can cost more than the lstat() calls it avoids. In a synthetic repository with 10,000 clean files, passing every path to ls-files --modified increased runtime from 112.5 ms to 494.1 ms when the prefilter was unconditional. With $parent and $this exported as paths to binaries built from the parent and this commit, on a repository with 881,290 index entries: hyperfine --warmup 0 --runs 3 \ --command-name parent \ '$parent -c core.fsmonitor=false ls-files --deleted -- README.md >/dev/null' \ --command-name this-commit \ '$this -c core.fsmonitor=false ls-files --deleted -- README.md >/dev/null' reported means of 65.790 seconds for the parent and 4.987 seconds for this commit. Link: https://lore.kernel.org/r/xmqqfr2tnfk0.fsf@gitster.g Helped-by: Jeff King <peff@peff.net> Signed-off-by: Tamir Duberstein <tamird@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Tamir Duberstein committed Jun 11, 2026 at 21:31 UTC 3f5203eeb46e6e01c7848acde4bf5a62d0dd7d1b
4 files changed +61
builtin/ls-files.c
+11
@@ -453,6 +453,17 @@ static void show_files(struct repository *repo, struct dir_struct *dir)
453 continue;
454 if (ce_skip_worktree(ce))
455 continue;
456 + /*
457 + * match_pathspec() is linear in pathspec.nr, so prefilter only
458 + * the single-pathspec case. Only entries shown by show_ce()
459 + * satisfy --error-unmatch.
460 + */
461 + if (pathspec.nr == 1 &&
462 + !match_pathspec(repo->index, &pathspec, fullname.buf,
463 + fullname.len, max_prefix_len, NULL,
464 + S_ISDIR(ce->ce_mode) ||
465 + S_ISGITLINK(ce->ce_mode)))
466 + continue;
467 stat_err = lstat(fullname.buf, &st);
468 if (stat_err && (errno != ENOENT && errno != ENOTDIR))
469 error_errno("cannot lstat '%s'", fullname.buf);
t/meson.build
+1
@@ -1141,6 +1141,7 @@ benchmarks = [
1141 'perf/p1500-graph-walks.sh',
1142 'perf/p1501-rev-parse-oneline.sh',
1143 'perf/p2000-sparse-operations.sh',
1144 + 'perf/p3010-ls-files.sh',
1145 'perf/p3400-rebase.sh',
1146 'perf/p3404-rebase-interactive.sh',
1147 'perf/p4000-diff-algorithms.sh',
t/perf/p3010-ls-files.sh new
+31
@@ -0,0 +1,31 @@
1 +#!/bin/sh
2 +
3 +test_description='Tests ls-files worktree performance'
4 +
5 +. ./perf-lib.sh
6 +
7 +test_perf_large_repo
8 +test_checkout_worktree
9 +
10 +test_expect_success 'select a zero-prefix pathspec' '
11 + tracked_file=$(git ls-files | sed -n 1p) &&
12 + test -n "$tracked_file" &&
13 + pathspec="?${tracked_file#?}" &&
14 + test_export pathspec
15 +'
16 +
17 +test_perf 'ls-files --deleted with pathspec' '
18 + git -c core.fsmonitor=false ls-files --deleted \
19 + -- "$pathspec" >/dev/null
20 +'
21 +
22 +test_perf 'ls-files --deleted with all-matching pathspec' '
23 + git -c core.fsmonitor=false ls-files --deleted -- "*" >/dev/null
24 +'
25 +
26 +test_perf 'ls-files --modified with pathspec' '
27 + git -c core.fsmonitor=false ls-files --modified \
28 + -- "$pathspec" >/dev/null
29 +'
30 +
31 +test_done
t/t3010-ls-files-killed-modified.sh
+18
@@ -124,4 +124,22 @@ test_expect_success 'validate git ls-files -m output.' '
124 test_cmp .expected .output
125 '
126
127 +test_expect_success 'worktree modes honor wildcard pathspecs' '
128 + cat >.expected <<-\EOF &&
129 + path2/file2
130 + path3/file3
131 + EOF
132 + git ls-files --deleted -- "path?/file?" >.output &&
133 + test_cmp .expected .output &&
134 +
135 + cat >.expected <<-\EOF &&
136 + path7
137 + path8
138 + EOF
139 + git ls-files --modified --error-unmatch -- "path[78]" >.output &&
140 + test_cmp .expected .output &&
141 +
142 + test_must_fail git ls-files --modified --error-unmatch -- path10
143 +'
144 +
145 test_done