last-modified: fix bug when some paths remain unhandled

The recently introduced new subcommand git-last-modified(1) runs into an error in some scenarios. It then would exit with the message: BUG: paths remaining beyond boundary in last-modified This seems to happens for example when criss-cross merges are involved. In that scenario, the function diff_tree_combined() gets called. The function diff_tree_combined() copies the `struct diff_options` from the input `struct rev_info` to override some flags. One flag is `recursive`, which is always set to 1. This has been the case since the inception of this function in af3feefa1d (diff-tree -c: show a merge commit a bit more sensibly., 2006-01-24). This behavior is incompatible with git-last-modified(1), when called non-recursive (which is the default). The last-modified machinery uses a hashmap for all the paths it wants to get the last-modified commit for. Through log_tree_commit() the callback mark_path() is called. The diff machinery uses diff_tree_combined() internally, and due to it's recursive behavior the callback receives entries inside subtrees, but not the subtree entries themselves. So a directory is never expelled from the hashmap, and the BUG() statement gets hit. Because there are many callers calling into diff_tree_combined(), both directly and indirectly, we cannot simply change it's behavior. Instead, add a flag `no_recursive_diff_tree_combined` which supresses the behavior of diff_tree_combined() to override `recursive` and set this flag in builtin/last-modified.c. Signed-off-by: Toon Claes <toon@iotcl.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Toon Claes committed Sep 18, 2025 at 10:00 UTC e6c06e87a255995d2e7ead2b8e49e46e29a724fb
4 files changed +26 -1
builtin/last-modified.c
+1
@@ -265,6 +265,7 @@ static int last_modified_init(struct last_modified *lm, struct repository *r,
265 lm->rev.boundary = 1;
266 lm->rev.no_commit_id = 1;
267 lm->rev.diff = 1;
268 + lm->rev.diffopt.flags.no_recursive_diff_tree_combined = 1;
269 lm->rev.diffopt.flags.recursive = lm->recursive;
270 lm->rev.diffopt.flags.tree_in_recursive = lm->show_trees;
271
combine-diff.c
+2 -1
@@ -1515,8 +1515,9 @@ void diff_tree_combined(const struct object_id *oid,
1515
1516 diffopts = *opt;
1517 copy_pathspec(&diffopts.pathspec, &opt->pathspec);
1518 - diffopts.flags.recursive = 1;
1518 diffopts.flags.allow_external = 0;
1519 + if (!opt->flags.no_recursive_diff_tree_combined)
1520 + diffopts.flags.recursive = 1;
1521
1522 /* find set of paths that everybody touches
1523 *
diff.h
+7
@@ -126,6 +126,13 @@ struct diff_flags {
126 unsigned recursive;
127 unsigned tree_in_recursive;
128
129 + /*
130 + * Historically diff_tree_combined() overrides recursive to 1. To
131 + * suppress this behavior, set the flag below.
132 + * It has no effect if recursive is already set to 1.
133 + */
134 + unsigned no_recursive_diff_tree_combined;
135 +
136 /* Affects the way how a file that is seemingly binary is treated. */
137 unsigned binary;
138 unsigned text;
t/t8020-last-modified.sh
+16
@@ -128,6 +128,22 @@ test_expect_success 'only last-modified files in the current tree' '
128 EOF
129 '
130
131 +test_expect_success 'last-modified with subdir and criss-cross merge' '
132 + git checkout -b branch-k1 1 &&
133 + mkdir -p a k &&
134 + test_commit k1 a/file2 &&
135 + git checkout -b branch-k2 &&
136 + test_commit k2 k/file2 &&
137 + git checkout branch-k1 &&
138 + test_merge km2 branch-k2 &&
139 + test_merge km3 3 &&
140 + check_last_modified <<-\EOF
141 + km3 a
142 + k2 k
143 + 1 file
144 + EOF
145 +'
146 +
147 test_expect_success 'cross merge boundaries in blaming' '
148 git checkout HEAD^0 &&
149 git rm -rf . &&