[PATCH v4 6/6] log: fix --follow following an edit on a side the merge did not take
flat view
From: Miklos Vajna <hidden>
Date: 2026-10-30 20:10:29
Subsystem:
the rest · Maintainer:
Linus Torvalds
Take a history where F is renamed to G on master, F is edited on a side branch, and the side branch is merged with '-s ours', so the merge keeps G and drops F. Here 'git log --follow G' doesn't show the edit of F on the side branch, while it did before the previous commit. The problem is that follow_needs_renames() only searches for renames in a parent without the path if the merge base still has the path, which covers the case when the parent renamed the path. Here the other side of the merge did the rename, so the merge base only has the old name. The parent is then told to follow G, which it doesn't have, and the history of F on its side is lost. Searching for renames in each parent that lacks the path would fix this, but that is the expensive search that the previous commit avoids, e.g. 148 merges hit this case for include/linux/rsc_table.h in linux.git. Fix the problem by also searching for renames when the merge base doesn't have the path, but the parent changed a file since the merge base that the merge no longer has: only such a file can be the old name of the path that still needs to be followed in the parent. This check is cheap, since it only diffs the changes of the side branch, and it rules out 146 of the 148 merges in linux.git. Then do a plain rename detection from the parent to the merge, which only looks at the files deleted by the merge, not at all files of the parent like --follow does. Reported-by: Patrick Steinhardt <redacted> Signed-off-by: Miklos Vajna <redacted> --- log-tree.c | 96 +++++++++++++++++++++++++++++++++---- t/t4219-log-follow-merge.sh | 31 ++++++++++++ 2 files changed, 117 insertions(+), 10 deletions(-)
diff --git a/log-tree.c b/log-tree.c
index 0dc71be61e..c2c3473f25 100644
--- a/log-tree.c
+++ b/log-tree.c@@ -1111,15 +1111,20 @@ static void set_pathspec_to_single_path(struct pathspec *ps, const char *path) PATHSPEC_LITERAL_PATH, "", paths); } -static int commit_has_path(struct rev_info *opt, struct commit *c, - const char *path) +static int tree_has_path(struct rev_info *opt, struct object_id *tree, + const char *path) { struct object_id oid; unsigned short mode; + return !get_tree_entry(opt->diffopt.repo, tree, path, &oid, &mode); +} + +static int commit_has_path(struct rev_info *opt, struct commit *c, + const char *path) +{ parse_commit_or_die(c); - return !get_tree_entry(opt->diffopt.repo, get_commit_tree_oid(c), path, - &oid, &mode); + return tree_has_path(opt, get_commit_tree_oid(c), path); } static void remember_follow_pathspec(struct rev_info *opt,
@@ -1169,26 +1174,97 @@ void release_follow_pathspec_slab(struct rev_info *opt) FREE_AND_NULL(opt->follow_pathspec_slab); } +/* + * Did 'parent' change a file since 'base' that the merge (with tree 'oid') no + * longer has? Only such a file can be the source of a rename on the other side + * of the merge that still needs to be followed in 'parent'. + */ +static int follow_parent_changed_dropped_file(struct rev_info *opt, + struct commit *base, + struct commit *parent, + struct object_id *oid) +{ + struct diff_options diff_opts; + int i, ret = 0; + + repo_diff_setup(opt->diffopt.repo, &diff_opts); + diff_opts.flags.recursive = 1; + diff_opts.output_format = DIFF_FORMAT_NO_OUTPUT; + diff_setup_done(&diff_opts); + diff_tree_oid(get_commit_tree_oid(base), get_commit_tree_oid(parent), + "", &diff_opts); + for (i = 0; i < diff_queued_diff.nr && !ret; i++) { + struct diff_filepair *p = diff_queued_diff.queue[i]; + + ret = DIFF_FILE_VALID(p->two) && + !tree_has_path(opt, oid, p->two->path); + } + diff_queue_clear(&diff_queued_diff); + diff_free(&diff_opts); + return ret; +} + +/* + * Is 'path' in the merge (with tree 'oid') renamed from a file of 'parent'? + * Only look at the files that the merge deleted, not at all files of 'parent' + * like --follow does. + */ +static int follow_has_rename_source(struct rev_info *opt, + struct commit *parent, + struct object_id *oid, const char *path) +{ + struct diff_options diff_opts; + int i, ret = 0; + + repo_diff_setup(opt->diffopt.repo, &diff_opts); + diff_opts.flags.recursive = 1; + diff_opts.detect_rename = DIFF_DETECT_RENAME; + diff_opts.rename_score = opt->diffopt.rename_score; + diff_opts.single_follow = path; + diff_opts.output_format = DIFF_FORMAT_NO_OUTPUT; + diff_setup_done(&diff_opts); + diff_tree_oid(get_commit_tree_oid(parent), oid, "", &diff_opts); + diffcore_std(&diff_opts); + for (i = 0; i < diff_queued_diff.nr && !ret; i++) { + struct diff_filepair *p = diff_queued_diff.queue[i]; + + ret = p->status == DIFF_STATUS_RENAMED && + !strcmp(p->two->path, path); + } + diff_queue_clear(&diff_queued_diff); + diff_free(&diff_opts); + return ret; +} + /* * When the path in a merge is the same as in 'same', does the path still need * renames to be followed in 'parent'? Only if the merge has the path, 'parent' - * doesn't have it, but its fork point with 'same' had it, e.g. 'parent' - * renamed the path. + * doesn't have it, and either: + * + * - its fork point with 'same' had it, e.g. 'parent' renamed the path, or + * - 'same' renamed the path from a file that 'parent' changed. */ static int follow_needs_renames(struct rev_info *opt, struct commit *parent, - struct commit *same, const char *path) + struct commit *same, struct object_id *oid, + const char *path) { struct commit_list *bases = NULL, *base; - int ret = 0; + int ret = 0, changed_dropped = 0; if (commit_has_path(opt, parent, path) || !commit_has_path(opt, same, path)) return 0; if (repo_get_merge_bases(opt->diffopt.repo, parent, same, &bases) < 0) exit(128); - for (base = bases; base && !ret; base = base->next) + for (base = bases; base && !ret; base = base->next) { ret = commit_has_path(opt, base->item, path); + if (!ret && !changed_dropped) + changed_dropped = follow_parent_changed_dropped_file( + opt, base->item, parent, oid); + } commit_list_free(bases); + if (!ret && changed_dropped) + ret = follow_has_rename_source(opt, parent, oid, path); return ret; }
@@ -1313,7 +1389,7 @@ static int log_tree_diff(struct rev_info *opt, struct commit *commit, struct log parents = parents->next) { if (parents->item != parent && follow_needs_renames(opt, parents->item, - parent, path)) + parent, oid, path)) tail = &commit_list_insert(parents->item, tail)->next; else
diff --git a/t/t4219-log-follow-merge.sh b/t/t4219-log-follow-merge.sh
index 36204ae2bf..a85fb78ca2 100755
--- a/t/t4219-log-follow-merge.sh
+++ b/t/t4219-log-follow-merge.sh@@ -266,4 +266,35 @@ test_expect_success '--follow with --diff-merges=first-parent follows all parent test_cmp expect actual ' +test_expect_success 'setup merge keeping the renamed path, other side edited the old one' ' + git init keep-renamed && + printf "line 1\nline 2\nline 3\nline 4\nline 5\n" >keep-renamed/F && + git -C keep-renamed add F && + test_tick && + git -C keep-renamed commit -m "O: add F" && + git -C keep-renamed branch B && + git -C keep-renamed mv F G && + test_tick && + git -C keep-renamed commit -m "A1: rename F to G on master" && + + git -C keep-renamed checkout B && + printf "line 1\nline 2\nline 3\nline 4 T\nline 5\n" >keep-renamed/F && + test_tick && + git -C keep-renamed commit -am "B2: update F on B" && + + git -C keep-renamed checkout master && + test_tick && + git -C keep-renamed merge -s ours -m "M: merge B, keep G, drop F" B +' + +test_expect_success '--follow follows an edit on the side the merge did not take' ' + git -C keep-renamed log --follow --pretty=tformat:%s G >actual && + cat >expect <<-\EOF && + B2: update F on B + A1: rename F to G on master + O: add F + EOF + test_cmp expect actual +' + test_done
--
2.56.0