rev-list: use reduce_heads() for --maximal-only

The 'git rev-list --maximal-only' option filters the output to only independent commits. A commit is independent if it is not reachable from other listed commits. Currently this is implemented by doing a full revision walk and marking parents with CHILD_VISITED to skip non-maximal commits. The 'git merge-base --independent' command computes the same result using reduce_heads(), which uses the more efficient remove_redundant() algorithm. This is significantly faster because it avoids walking the entire commit graph. Add a fast path in rev-list that detects when --maximal-only is the only interesting option and all input commits are positive (no revision ranges). In this case, use reduce_heads() directly instead of doing a full revision walk. In order to preserve the rest of the output filtering, this computation is done opportunistically in a new prepare_maximal_independent() method when possible. If successful, it populates revs->commits with the list of independent commits and set revs->no_walk to prevent any other walk from occurring. This allows us to have any custom output be handled using the existing output code hidden inside traverse_commit_list_filtered(). A new test is added to demonstrate that this output is preserved. The fast path is only used when no other flags complicate the walk or output format: no UNINTERESTING commits, no limiting options (max-count, age filters, path filters, grep filters), no output formatting beyond plain OIDs, and no object listing flags. Running the p6011 performance test for my copy of git.git, I see the following improvement with this change: Test HEAD~1 HEAD ------------------------------------------------------------ 6011.2: merge-base --independent 0.03 0.03 +0.0% 6011.3: rev-list --maximal-only 0.06 0.03 -50.0% 6011.4: rev-list --maximal-only --since 0.06 0.06 +0.0% And for a fresh clone of the Linux kernel repository, I see: Test HEAD~1 HEAD ------------------------------------------------------------ 6011.2: merge-base --independent 0.00 0.00 = 6011.3: rev-list --maximal-only 0.70 0.00 -100.0% 6011.4: rev-list --maximal-only --since 0.70 0.70 +0.0% In both cases, the performance is indeed matching the behavior of 'git merge-base --independent', as expected. Signed-off-by: Derrick Stolee <stolee@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Derrick Stolee committed Apr 6, 2026 at 13:27 UTC b0ba57daa86dfba0a6551613452d1cb2301266a2
2 files changed +90
builtin/rev-list.c
+59
@@ -25,6 +25,7 @@
25 #include "oidset.h"
26 #include "oidmap.h"
27 #include "packfile.h"
28 +#include "commit-reach.h"
29 #include "quote.h"
30 #include "strbuf.h"
31
@@ -633,6 +634,61 @@ static int try_bitmap_disk_usage(struct rev_info *revs,
634 return 0;
635 }
636
637 +/*
638 + * If revs->maximal_only is set and no other walk modifiers are provided,
639 + * run a faster computation to filter the independent commits and prepare
640 + * them for output. Set revs->no_walk to prevent later walking.
641 + *
642 + * If this algorithm doesn't apply, then no changes are made to revs.
643 + */
644 +static void prepare_maximal_independent(struct rev_info *revs)
645 +{
646 + struct commit_list *c;
647 +
648 + if (!revs->maximal_only)
649 + return;
650 +
651 + for (c = revs->commits; c; c = c->next) {
652 + if (c->item->object.flags & UNINTERESTING)
653 + return;
654 + }
655 +
656 + if (revs->limited ||
657 + revs->topo_order ||
658 + revs->first_parent_only ||
659 + revs->reverse ||
660 + revs->max_count >= 0 ||
661 + revs->skip_count >= 0 ||
662 + revs->min_age != (timestamp_t)-1 ||
663 + revs->max_age != (timestamp_t)-1 ||
664 + revs->min_parents > 0 ||
665 + revs->max_parents >= 0 ||
666 + revs->prune_data.nr ||
667 + revs->count ||
668 + revs->left_right ||
669 + revs->boundary ||
670 + revs->tag_objects ||
671 + revs->tree_objects ||
672 + revs->blob_objects ||
673 + revs->filter.choice ||
674 + revs->reflog_info ||
675 + revs->diff ||
676 + revs->grep_filter.pattern_list ||
677 + revs->grep_filter.header_list ||
678 + revs->verbose_header ||
679 + revs->print_parents ||
680 + revs->edge_hint ||
681 + revs->unpacked ||
682 + revs->no_kept_objects ||
683 + revs->line_level_traverse)
684 + return;
685 +
686 + reduce_heads_replace(&revs->commits);
687 +
688 + /* Modify 'revs' to only output this commit list. */
689 + revs->no_walk = 1;
690 +}
691 +
692 int cmd_rev_list(int argc,
693 const char **argv,
694 const char *prefix,
@@ -875,6 +931,9 @@ int cmd_rev_list(int argc,
931
932 if (prepare_revision_walk(&revs))
933 die("revision walk setup failed");
934 +
935 + prepare_maximal_independent(&revs);
936 +
937 if (revs.tree_objects)
938 mark_edges_uninteresting(&revs, show_edge, 0);
939
t/t6000-rev-list-misc.sh
+31
@@ -263,4 +263,35 @@ test_expect_success 'rev-list --boundary incompatible with --maximal-only' '
263 test_grep "cannot be used together" err
264 '
265
266 +test_expect_success 'rev-list --maximal-only and --pretty' '
267 + test_when_finished rm -rf repo &&
268 +
269 + git init repo &&
270 + test_commit -C repo 1 &&
271 + oid1=$(git -C repo rev-parse HEAD) &&
272 + test_commit -C repo 2 &&
273 + oid2=$(git -C repo rev-parse HEAD) &&
274 + git -C repo checkout --detach HEAD~1 &&
275 + test_commit -C repo 3 &&
276 + oid3=$(git -C repo rev-parse HEAD) &&
277 +
278 + cat >expect <<-EOF &&
279 + commit $oid3
280 + $oid3
281 + commit $oid2
282 + $oid2
283 + EOF
284 +
285 + git -C repo rev-list --pretty="%H" --maximal-only $oid1 $oid2 $oid3 >out &&
286 + test_cmp expect out &&
287 +
288 + cat >expect <<-EOF &&
289 + $oid3
290 + $oid2
291 + EOF
292 +
293 + git -C repo log --pretty="%H" --maximal-only $oid1 $oid2 $oid3 >out &&
294 + test_cmp expect out
295 +'
296 +
297 test_done