{"thread":{"id":"65619","subject":"[PATCH] log: let --follow follow renames in merge commits","startedAt":"2026-05-12T07:21:32Z","lastAt":"2026-06-23T11:30:49Z","messageCount":18,"participants":["Miklos Vajna","Junio C Hamano","Jeff King"],"isPatch":true,"patchVersion":1,"patchTotal":null},"messages":[{"id":"543149","messageId":"agLU58gbG1y7KLz-@collabora.com","threadId":"65619","inReplyTo":null,"subject":"[PATCH] log: let --follow follow renames in merge commits","fromName":"Miklos Vajna","fromEmail":"vmiklos@collabora.com","sentAt":"2026-05-12T07:21:11Z","receivedAt":"2026-05-12T07:21:32Z","isPatch":true,"body":"Have a repo with a subtree merge, do a 'git log --follow prefix/test.c',\nthe output only contains history in the outer repo, not commits that\nwere merged via a subtree merge.\n\nThis is inconsistent, since doing a 'git blame prefix/test.c' does find\nthe original commits. This works because find_rename() in blame.c is\ninvoked for each parent, and there diff_tree_oid() is used, which uses\ntry_to_follow_renames(). This means that in case a rename happens as\npart of a merge commit, git blame can follow that rename.\n\nFix the problem in a similar way for the 'git log --follow' case: in\ncase log_tree_diff() finds a merge commit and it would return early,\nthen do some extra work in the follow_renames case first. Check each\nparent, use diff_tree_oid() and if found_follow is set, then work with\nthat parent instead of returning.\n\nThis means that users examining the history of a repo with subtree\nmerges can see all commits to a file with a single 'git log --follow'\ninvocation, instead of one invocation for the outer repo and one for the\nhistory before the subtree merge.\n\nSigned-off-by: Miklos Vajna <vmiklos@collabora.com>\n---\n\nHi Junio,\n\nI sent this out a week ago at\n<https://lore.kernel.org/git/afmfSa-p-9vuDL3E@collabora.com/T/#u>, I\ndidn't get any reply to it -- so I'm somewhat optimistic that the patch\nitself is a good idea, seeing no negative comments.\n\nSo this is a resend, this time to you, CC'ing the list, rather than the\nother way around.\n\nCould you please review this?\n\nThanks,\n\nMiklos\n\n log-tree.c                          | 20 ++++++++++++++++-\n t/meson.build                       |  1 +\n t/t4218-log-follow-subtree-merge.sh | 34 +++++++++++++++++++++++++++++\n 3 files changed, 54 insertions(+), 1 deletion(-)\n create mode 100755 t/t4218-log-follow-subtree-merge.sh\n\ndiff --git a/log-tree.c b/log-tree.c\nindex 7e048701d0..bce09c7dac 100644\n--- a/log-tree.c\n+++ b/log-tree.c\n@@ -1142,8 +1142,26 @@ static int log_tree_diff(struct rev_info *opt, struct commit *commit, struct log\n \t\t\t\t/* Show parent info for multiple diffs */\n \t\t\t\tlog->parent = parents->item;\n \t\t\t}\n-\t\t} else\n+\t\t} else {\n+\t\t\tif (opt->diffopt.flags.follow_renames) {\n+\t\t\t\t/*\n+\t\t\t\t * Detect a rename across one of the parents.\n+\t\t\t\t * Check each parent till we find a follow.\n+\t\t\t\t */\n+\t\t\t\tstruct commit_list *p;\n+\t\t\t\tfor (p = parents; p; p = p->next) {\n+\t\t\t\t\tparse_commit_or_die(p->item);\n+\t\t\t\t\tdiff_tree_oid(get_commit_tree_oid(p->item),\n+\t\t\t\t\t\t      oid, \"\", &opt->diffopt);\n+\t\t\t\t\tdiff_queue_clear(&diff_queued_diff);\n+\t\t\t\t\tif (opt->diffopt.found_follow) {\n+\t\t\t\t\t\topt->diffopt.found_follow = 0;\n+\t\t\t\t\t\tbreak;\n+\t\t\t\t\t}\n+\t\t\t\t}\n+\t\t\t}\n \t\t\treturn 0;\n+\t\t}\n \t}\n \n \tshowed_log = 0;\ndiff --git a/t/meson.build b/t/meson.build\nindex 7528e5cda5..b4ae8d76d8 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -574,6 +574,7 @@ integration_tests = [\n   't4215-log-skewed-merges.sh',\n   't4216-log-bloom.sh',\n   't4217-log-limit.sh',\n+  't4218-log-follow-subtree-merge.sh',\n   't4252-am-options.sh',\n   't4253-am-keep-cr-dos.sh',\n   't4254-am-corrupt.sh',\ndiff --git a/t/t4218-log-follow-subtree-merge.sh b/t/t4218-log-follow-subtree-merge.sh\nnew file mode 100755\nindex 0000000000..7ca607cbb8\n--- /dev/null\n+++ b/t/t4218-log-follow-subtree-merge.sh\n@@ -0,0 +1,34 @@\n+#!/bin/sh\n+\n+test_description='Test --follow follows renames across subtree merges'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=master\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup subtree-merged repository' '\n+\tgit init inner &&\n+\techo inner >inner/inner.txt &&\n+\tgit -C inner add inner.txt &&\n+\tgit -C inner commit -m \"inner init\" &&\n+\n+\tgit init outer &&\n+\techo outer >outer/outer.txt &&\n+\tgit -C outer add outer.txt &&\n+\tgit -C outer commit -m \"outer init\" &&\n+\n+\tgit -C outer fetch ../inner master &&\n+\tgit -C outer merge -s ours --no-commit --allow-unrelated-histories \\\n+\t\tFETCH_HEAD &&\n+\tgit -C outer read-tree --prefix=inner/ -u FETCH_HEAD &&\n+\tgit -C outer commit -m \"Merge inner repo into inner/ subdirectory\"\n+'\n+\n+test_expect_success '--follow finds the pre-merge commit through a subtree merge' '\n+\tgit -C outer log --follow --pretty=tformat:%s inner/inner.txt >actual &&\n+\techo \"inner init\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_done\n-- \n2.51.0\n\n"},{"id":"543592","messageId":"agwAkHzjrJQPVtCS@collabora.com","threadId":"65619","inReplyTo":"agLU58gbG1y7KLz-@collabora.com","subject":"Re: [PATCH] log: let --follow follow renames in merge commits","fromName":"Miklos Vajna","fromEmail":"vmiklos@collabora.com","sentAt":"2026-05-19T06:17:52Z","receivedAt":"2026-05-19T06:18:12Z","isPatch":true,"body":"Hi Junio,\n\nOn Tue, May 12, 2026 at 09:21:17AM +0200, Miklos Vajna <vmiklos@collabora.com> wrote:\n> I sent this out a week ago at\n> <https://lore.kernel.org/git/afmfSa-p-9vuDL3E@collabora.com/T/#u>, I\n> didn't get any reply to it -- so I'm somewhat optimistic that the patch\n> itself is a good idea, seeing no negative comments.\n> \n> So this is a resend, this time to you, CC'ing the list, rather than the\n> other way around.\n> \n> Could you please review this?\n\nI'm a bit confused regarding what can be a next step here. I\nunderstanding you were away for 3 weeks, so there is a lot to process.\n:-) Should I just wait more or should I resend this?\n\nThanks,\n\nMiklos\n"},{"id":"543595","messageId":"xmqqo6ib7vlp.fsf@gitster.g","threadId":"65619","inReplyTo":"agwAkHzjrJQPVtCS@collabora.com","subject":"Re: [PATCH] log: let --follow follow renames in merge commits","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-19T06:37:54Z","receivedAt":"2026-05-19T06:37:57Z","isPatch":true,"body":"Miklos Vajna <vmiklos@collabora.com> writes:\n\n> Hi Junio,\n>\n> On Tue, May 12, 2026 at 09:21:17AM +0200, Miklos Vajna <vmiklos@collabora.com> wrote:\n>> I sent this out a week ago at\n>> <https://lore.kernel.org/git/afmfSa-p-9vuDL3E@collabora.com/T/#u>, I\n>> didn't get any reply to it -- so I'm somewhat optimistic that the patch\n>> itself is a good idea, seeing no negative comments.\n\nThe patch collecting no comments is just that--nobody so far is\ninterested enough to drop other things they were doing to give\nsupporting code reviews---and \"no news\" does not mean a good news.\n\n>> So this is a resend, this time to you, CC'ing the list, rather than the\n>> other way around.\n>> \n>> Could you please review this?\n>\n> I'm a bit confused regarding what can be a next step here. I\n> understanding you were away for 3 weeks, so there is a lot to process.\n> :-) Should I just wait more or should I resend this?\n\nRather, ask other reviewers; when I do not comment on a patch, I\noften am not interested, or too busy and the change does not look\ninteresting enough to me to make me drop what I am doing.\n\n"},{"id":"543599","messageId":"xmqqjysz7r41.fsf@gitster.g","threadId":"65619","inReplyTo":"xmqqo6ib7vlp.fsf@gitster.g","subject":"Re: [PATCH] log: let --follow follow renames in merge commits","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-05-19T08:14:54Z","receivedAt":"2026-05-19T08:14:57Z","isPatch":true,"body":"Junio C Hamano <gitster@pobox.com> writes:\n\n>>> Could you please review this?\n>>\n>> I'm a bit confused regarding what can be a next step here. I\n>> understanding you were away for 3 weeks, so there is a lot to process.\n>> :-) Should I just wait more or should I resend this?\n>\n> Rather, ask other reviewers; when I do not comment on a patch, I\n> often am not interested, or too busy and the change does not look\n> interesting enough to me to make me drop what I am doing.\n\nAddendum.  As I said in\n\n    https://lore.kernel.org/git/xmqqqzni967o.fsf@gitster.g/\n\nand the subsequent discussion concluded, the \"==follow\" checkbox\nfeature is meant to work well only in a linear history, and that is\ninherent to the way it \"follows\" the single path.\n\nIt does not follow different pathname(s) while following a set of\ndifferent histories merged, e.g., in a history like this (as usual\ntime flows from left to right)\n\n    ----o----A----o\n                   \\\n                    M----o----o----o\n                   /\n    ----x----B----x\n\nyou may start following path F at the HEAD, and after crossing the\nmerge M, one history may find out that path F came from path G.\nThe traveral starts with \"F\" as the sole element in the pathspec,\nbut once the traversal hits that commit (say, A), the traversal\nswitches to use \"G\" as the sole element in the pathspec and follows\nthe history down.  Even if the other history (i.e., 'x' on the lower\nhistory) had path F all along, once the pathspec is swapped to\nfollow \"G\" on the upper lineage of the history, traversal of the\nlower lineage that happens after the traversal passes 'A\" _will_ try\nto follow \"G\" that may not exist at all.  Or 'x' may have done the\nsame rename from \"G\" to \"F\" at \"B\".  Depending on the order in which\n\"A\" and any of these commits on the lower history are visited, the\ncommit that is a child of \"B\" (which has the path at \"F\") may be\nvisited after \"A\", in which case the path in question \"F\" will not\nbe looked for in it.\n\nA minor \"tweak\" that does not solve this inherent design issue does\nnot interest me, so...\n"},{"id":"543742","messageId":"ag2265RJal-tJLoW@collabora.com","threadId":"65619","inReplyTo":"xmqqo6ib7vlp.fsf@gitster.g","subject":"Re: [PATCH] log: let --follow follow renames in merge commits","fromName":"Miklos Vajna","fromEmail":"vmiklos@collabora.com","sentAt":"2026-05-20T13:28:11Z","receivedAt":"2026-05-20T13:28:23Z","isPatch":true,"body":"Hi Elijah, Jeff,\n\nOn Tue, May 19, 2026 at 03:37:54PM +0900, Junio C Hamano <gitster@pobox.com> wrote:\n> > :-) Should I just wait more or should I resend this?\n> \n> Rather, ask other reviewers\n\nI did a small improvement to how 'git log --follow' works, as in if the\nrename happens inside the merge commit itself, then the rename was\ndetected \"vs the first parent\", but it wasn't detected \"vs other\nparents\", which is painful with a \"subtree\" merge commit.\n\nI'm not sure if it adds value, but I can append a one-paragraph summary\nof Junio's comment in this thread to the end the commit message, to be\nmore explicit that the inherent limitation of the current log follow\ndesign (single path, once a rename is detected, we only care about the\nnew path) is not changed with the patch, this is just a fix patch so\n'git log' works better, similar to how 'git blame' already does.\n\nMay I ask you to review the patch?\n\nThanks,\n\nMiklos\n"},{"id":"543883","messageId":"20260522054312.GD861761@coredump.intra.peff.net","threadId":"65619","inReplyTo":"ag2265RJal-tJLoW@collabora.com","subject":"Re: [PATCH] log: let --follow follow renames in merge commits","fromName":"Jeff King","fromEmail":"peff@peff.net","sentAt":"2026-05-22T05:43:12Z","receivedAt":"2026-05-22T05:43:14Z","isPatch":true,"body":"On Wed, May 20, 2026 at 03:28:11PM +0200, Miklos Vajna wrote:\n\n> Hi Elijah, Jeff,\n> \n> On Tue, May 19, 2026 at 03:37:54PM +0900, Junio C Hamano <gitster@pobox.com> wrote:\n> > > :-) Should I just wait more or should I resend this?\n> > \n> > Rather, ask other reviewers\n> \n> I did a small improvement to how 'git log --follow' works, as in if the\n> rename happens inside the merge commit itself, then the rename was\n> detected \"vs the first parent\", but it wasn't detected \"vs other\n> parents\", which is painful with a \"subtree\" merge commit.\n> \n> I'm not sure if it adds value, but I can append a one-paragraph summary\n> of Junio's comment in this thread to the end the commit message, to be\n> more explicit that the inherent limitation of the current log follow\n> design (single path, once a rename is detected, we only care about the\n> new path) is not changed with the patch, this is just a fix patch so\n> 'git log' works better, similar to how 'git blame' already does.\n> \n> May I ask you to review the patch?\n\nI saw Junio's comment. I was about to write something very similar\nbefore I saw that he had already done so. ;)\n\nI think we can probably all agree that both before and after your patch,\n--follow is never going to do the _right_ thing, which is to follow\npaths independently down both sides of history.\n\nI am OK conceptually with making the current broken behavior slightly\nmore useful if it is easy to do. But I am not sure if we are making\nthings more useful here or not. If we see a merge where the file \"bar\"\nwas previous \"foo\" on one side and \"bar\" on the other, our broken follow\nis going to either pick \"foo\" or \"bar\" to continue with as we traverse.\nBut which one is right? Whichever name we choose, we are potentially\nomitting results from the other side.\n\nRight now we pick the first-parent name always. But it does not seem\nmore correct to me to pick one from another parent. You'd be missing\nfurther commits using the original name along the first-parent track.\n\nThere might be a more useful rule like: if the path is untouched versus\nthe merge result in all parents but one (i.e., TREESAME), then choose\nthe parent where it was changed, including any --follow processing. But\nwe already do something like that for history simplification. Which\nmakes me wonder if you could get the results you want through some use\nof history-simplification flags.\n\nOr maybe we already do that TREESAME check. Simplification kicks in when\nthe traversal is limited by path, and --follow mode by definition has\nsuch a path. But I'm not sure if the --follow code would see the\nsimplified parent list or not.\n\nSo I dunno. Probably some experimenting could yield more analysis there,\nbut with the patch as-is I'm not convinced that it is not going to make\nsome cases worse.\n\n-Peff\n"},{"id":"543958","messageId":"ahFDgq4TAcs29zCA@collabora.com","threadId":"65619","inReplyTo":"20260522054312.GD861761@coredump.intra.peff.net","subject":"[PATCH] log: improve --follow following renames in merge commits","fromName":"Miklos Vajna","fromEmail":"vmiklos@collabora.com","sentAt":"2026-05-23T06:04:50Z","receivedAt":"2026-05-23T06:05:04Z","isPatch":true,"body":"Have a repo with a subtree merge, do a 'git log --follow prefix/test.c',\nthe output only contains history in the outer repo, not commits that\nwere merged via a subtree merge.\n\nThere is an inherent limitation of the current 'git log --follow'\ndesign, since it's limited to a single filename, and once 'git log' sees\na rename, it only tracks the new path, which only works with mostly\nlinear history.  Still, 'git blame prefix/test.c' does find the original\ncommits, so it's fair to expect 'git log --follow' can do the same.\n\nFix the problem by improving when to update the followed path in\nlog_tree_diff(). If the path is untouched versus the merge result in all\nparents but one, then choose the parent where it was changed, including\nany --follow processing.\n\nThis is almost the same as requiring that all but one parents are\nTREESAME, except we don't consider the addition of a file as\n\"interesting\". With this, the pre-merge history of subtree merge is\nvisible in git log, but the behavior is unchanged for other cases (e.g.\nwhen a file was previously named differently on multiple parents).\n\nSigned-off-by: Miklos Vajna <vmiklos@collabora.com>\n---\n\nHi Jeff,\n\nOn Fri, May 22, 2026 at 01:43:12AM -0400, Jeff King <peff@peff.net> wrote:\n> I think we can probably all agree that both before and after your patch,\n> --follow is never going to do the _right_ thing, which is to follow\n> paths independently down both sides of history.\n\nSure.\n\n> I am OK conceptually with making the current broken behavior slightly\n> more useful if it is easy to do. But I am not sure if we are making\n> things more useful here or not. If we see a merge where the file \"bar\"\n> was previous \"foo\" on one side and \"bar\" on the other, our broken follow\n> is going to either pick \"foo\" or \"bar\" to continue with as we traverse.\n> But which one is right? Whichever name we choose, we are potentially\n> omitting results from the other side.\n\nIndeed, I didn't consider this case.\n\n> There might be a more useful rule like: if the path is untouched versus\n> the merge result in all parents but one (i.e., TREESAME), then choose\n> the parent where it was changed, including any --follow processing.\n\nI like this idea: it keeps working with the subtree use-case I have in\nmind and goes back to not change behavior when the file has history on\nmultiple parents.\n\n> So I dunno. Probably some experimenting could yield more analysis there,\n\nI think requiring TREESAME for all but one parents is too strict, since\na subtree merge will look like an addition vs the first parent and will\nlook like a rename on the first parent. It seems to me that handling\naddition as TREESAME can be correct: if the file was just added, that\nsuggests it has no prior history.\n\nSo a slightly relaxed rule could be: if the path is untouched or just\nadded versus the merge result in all parents but one, then choose the\nparent where it was changed, including any --follow processing.\n\nHere is a patch that implements that idea. It works for the subtree\nmerge use-case I outlined and I also added a test to show that the\nbehavior is unchanged for the \"multiple parents have actual history for\nthis file\" case you mentioned.\n\nWhat do you think?\n\nThanks,\n\nMiklos\n\n log-tree.c                          | 55 ++++++++++++++++++++++++-\n t/meson.build                       |  1 +\n t/t4218-log-follow-subtree-merge.sh | 64 +++++++++++++++++++++++++++++\n 3 files changed, 119 insertions(+), 1 deletion(-)\n create mode 100755 t/t4218-log-follow-subtree-merge.sh\n\ndiff --git a/log-tree.c b/log-tree.c\nindex 7e048701d0..368144fafc 100644\n--- a/log-tree.c\n+++ b/log-tree.c\n@@ -1142,8 +1142,61 @@ static int log_tree_diff(struct rev_info *opt, struct commit *commit, struct log\n \t\t\t\t/* Show parent info for multiple diffs */\n \t\t\t\tlog->parent = parents->item;\n \t\t\t}\n-\t\t} else\n+\t\t} else {\n+\t\t\tif (opt->diffopt.flags.follow_renames) {\n+\t\t\t\t/*\n+\t\t\t\t * If the path is untouched in all parents but\n+\t\t\t\t * one, then choose the parent where it was\n+\t\t\t\t * changed.\n+\t\t\t\t */\n+\t\t\t\tstruct commit_list *p;\n+\t\t\t\tstruct commit *changed_parent = NULL;\n+\t\t\t\tint n_changed = 0;\n+\n+\t\t\t\tfor (p = parents; p; p = p->next) {\n+\t\t\t\t\tstruct diff_options diff_opts;\n+\t\t\t\t\tint interesting = 0;\n+\t\t\t\t\tint i;\n+\n+\t\t\t\t\tparse_commit_or_die(p->item);\n+\t\t\t\t\trepo_diff_setup(opt->diffopt.repo, &diff_opts);\n+\t\t\t\t\tcopy_pathspec(&diff_opts.pathspec,\n+\t\t\t\t\t\t      &opt->diffopt.pathspec);\n+\t\t\t\t\tdiff_opts.flags.recursive = 1;\n+\t\t\t\t\tdiff_opts.flags.follow_renames = 1;\n+\t\t\t\t\tdiff_opts.output_format = DIFF_FORMAT_NO_OUTPUT;\n+\t\t\t\t\tdiff_setup_done(&diff_opts);\n+\t\t\t\t\tdiff_tree_oid(get_commit_tree_oid(p->item),\n+\t\t\t\t\t\t      oid, \"\", &diff_opts);\n+\n+\t\t\t\t\tfor (i = 0; i < diff_queued_diff.nr; i++) {\n+\t\t\t\t\t\tstruct diff_filepair *pair = diff_queued_diff.queue[i];\n+\t\t\t\t\t\tif (DIFF_FILE_VALID(pair->one)) {\n+\t\t\t\t\t\t\tinteresting = 1;\n+\t\t\t\t\t\t\tbreak;\n+\t\t\t\t\t\t}\n+\t\t\t\t\t}\n+\n+\t\t\t\t\tdiff_queue_clear(&diff_queued_diff);\n+\t\t\t\t\tdiff_free(&diff_opts);\n+\n+\t\t\t\t\tif (interesting) {\n+\t\t\t\t\t\tn_changed++;\n+\t\t\t\t\t\tchanged_parent = p->item;\n+\t\t\t\t\t\tif (n_changed > 1)\n+\t\t\t\t\t\t\tbreak;\n+\t\t\t\t\t}\n+\t\t\t\t}\n+\n+\t\t\t\tif (n_changed == 1) {\n+\t\t\t\t\tdiff_tree_oid(get_commit_tree_oid(changed_parent),\n+\t\t\t\t\t\t      oid, \"\", &opt->diffopt);\n+\t\t\t\t\tdiff_queue_clear(&diff_queued_diff);\n+\t\t\t\t\topt->diffopt.found_follow = 0;\n+\t\t\t\t}\n+\t\t\t}\n \t\t\treturn 0;\n+\t\t}\n \t}\n \n \tshowed_log = 0;\ndiff --git a/t/meson.build b/t/meson.build\nindex 7528e5cda5..b4ae8d76d8 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -574,6 +574,7 @@ integration_tests = [\n   't4215-log-skewed-merges.sh',\n   't4216-log-bloom.sh',\n   't4217-log-limit.sh',\n+  't4218-log-follow-subtree-merge.sh',\n   't4252-am-options.sh',\n   't4253-am-keep-cr-dos.sh',\n   't4254-am-corrupt.sh',\ndiff --git a/t/t4218-log-follow-subtree-merge.sh b/t/t4218-log-follow-subtree-merge.sh\nnew file mode 100755\nindex 0000000000..fc846ebeb4\n--- /dev/null\n+++ b/t/t4218-log-follow-subtree-merge.sh\n@@ -0,0 +1,64 @@\n+#!/bin/sh\n+\n+test_description='Test --follow follows renames across subtree merges'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=master\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup subtree-merged repository' '\n+\tgit init inner &&\n+\techo inner >inner/inner.txt &&\n+\tgit -C inner add inner.txt &&\n+\tgit -C inner commit -m \"inner init\" &&\n+\n+\tgit init outer &&\n+\techo outer >outer/outer.txt &&\n+\tgit -C outer add outer.txt &&\n+\tgit -C outer commit -m \"outer init\" &&\n+\n+\tgit -C outer fetch ../inner master &&\n+\tgit -C outer merge -s ours --no-commit --allow-unrelated-histories \\\n+\t\tFETCH_HEAD &&\n+\tgit -C outer read-tree --prefix=inner/ -u FETCH_HEAD &&\n+\tgit -C outer commit -m \"Merge inner repo into inner/ subdirectory\"\n+'\n+\n+test_expect_success '--follow finds the pre-merge commit through a subtree merge' '\n+\tgit -C outer log --follow --pretty=tformat:%s inner/inner.txt >actual &&\n+\techo \"inner init\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'setup merge with rename sources on multiple parents' '\n+\tgit init left &&\n+\tprintf \"shared content\\n\" >left/a.txt &&\n+\tgit -C left add a.txt &&\n+\tgit -C left commit -m \"left: a.txt\" &&\n+\n+\tgit init right &&\n+\tprintf \"shared content\\n\" >right/b.txt &&\n+\tgit -C right add b.txt &&\n+\tgit -C right commit -m \"right: b.txt\" &&\n+\n+\tgit -C left fetch ../right master &&\n+\tgit -C left merge -s ours --no-commit --allow-unrelated-histories \\\n+\t\tFETCH_HEAD &&\n+\tgit -C left rm a.txt &&\n+\tprintf \"shared content\\n\" >left/c.txt &&\n+\tgit -C left add c.txt &&\n+\tgit -C left commit -m \"Merge: rename to c.txt\" &&\n+\n+\tprintf \"more content\\n\" >>left/c.txt &&\n+\tgit -C left add c.txt &&\n+\tgit -C left commit -m \"modify c.txt\"\n+'\n+\n+test_expect_success '--follow does not switch when multiple parents supply a rename source' '\n+\tgit -C left log --follow --pretty=tformat:%s c.txt >actual &&\n+\techo \"modify c.txt\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_done\n-- \n2.51.0\n\n"},{"id":"544294","messageId":"ahqDqSH7yfYVOOyE@collabora.com","threadId":"65619","inReplyTo":"ahFDgq4TAcs29zCA@collabora.com","subject":"Re: [PATCH] log: improve --follow following renames in merge commits","fromName":"Miklos Vajna","fromEmail":"vmiklos@collabora.com","sentAt":"2026-05-30T06:28:57Z","receivedAt":"2026-05-30T06:29:11Z","isPatch":true,"body":"Hi Jeff,\n\nOn Sat, May 23, 2026 at 08:04:50AM +0200, Miklos Vajna <vmiklos@collabora.com> wrote:\n> > There might be a more useful rule like: if the path is untouched versus\n> > the merge result in all parents but one (i.e., TREESAME), then choose\n> > the parent where it was changed, including any --follow processing.\n> \n> I like this idea: it keeps working with the subtree use-case I have in\n> mind and goes back to not change behavior when the file has history on\n> multiple parents.\n> \n> > So I dunno. Probably some experimenting could yield more analysis there,\n> \n> I think requiring TREESAME for all but one parents is too strict, since\n> a subtree merge will look like an addition vs the first parent and will\n> look like a rename on the first parent. It seems to me that handling\n> addition as TREESAME can be correct: if the file was just added, that\n> suggests it has no prior history.\n> \n> So a slightly relaxed rule could be: if the path is untouched or just\n> added versus the merge result in all parents but one, then choose the\n> parent where it was changed, including any --follow processing.\n\nCould you please comment on this, if this tweaked rule and its\nimplementation in the patch looks OK to you? Let me know if I should\njust wait some more.\n\nI would hope this addresses your concern where naively following an\nother parent just makes one use-case better and can be worse in other\ncases.\n\nThis also explains why the normal history simplification is not enough\nhere: the \"added vs parent\" is a change that is not interesting in this\ncase, but is more than TREESAME.\n\nFinally, because I forgot to react to that earlier: I'm not against the\nidea to attempt to improve --follow work better when visiting a tree of\ncommits in general, but sounds like a larger rework, so it would be nice\nto have a fix for the subtree use-case first.\n\nThanks,\n\nMiklos\n"},{"id":"544742","messageId":"aiFtpkk_xN1897IE@collabora.com","threadId":"65619","inReplyTo":"ahqDqSH7yfYVOOyE@collabora.com","subject":"Re: [PATCH] log: improve --follow following renames in merge commits","fromName":"Miklos Vajna","fromEmail":"vmiklos@collabora.com","sentAt":"2026-06-04T12:20:54Z","receivedAt":"2026-06-04T12:21:08Z","isPatch":true,"body":"Hi Jeff,\n\nOn Sat, May 30, 2026 at 08:29:03AM +0200, Miklos Vajna <vmiklos@collabora.com> wrote:\n> Could you please comment on this, if this tweaked rule and its\n> implementation in the patch looks OK to you? Let me know if I should\n> just wait some more.\n\nJust to come back to this, the idea was to make the --follow behavior\nslightly more useful by not always assuming we should follow a first\nparent in merge commits, but see if only one parent has effective\nchanges to the followed file, and if so, follow that one.\n\nI did this by doing a diff on the followed path in each parent, then\nmark the parent as \"interesting\" if DIFF_FILE_VALID() says so. This is\ntrue if the file is touched or the rename happens inside the merge\ncommit (vs that parent), but it's not true if the file is really not\ntouched or the file only shows up as an addition. And if we have only\nhave one interesting parent, then switch to this, even if it's not the\nfirst parent. With this rule, I think we address your worry case about\n\"making some other cases\" worse and this still works for the subtree\ncase, and this is relatively easy to do.\n\nWhat do you think?\n\nThanks,\n\nMiklos\n"},{"id":"544861","messageId":"aiZipugmA7z8oBcd@collabora.com","threadId":"65619","inReplyTo":"xmqqjysz7r41.fsf@gitster.g","subject":"[PATCH] log: improve --follow following renames for non-linear history","fromName":"Miklos Vajna","fromEmail":"vmiklos@collabora.com","sentAt":"2026-06-08T06:35:18Z","receivedAt":"2026-06-08T06:35:33Z","isPatch":true,"body":"Have a repo with a subtree merge, do a 'git log --follow prefix/test.c',\nthe output only contains history in the outer repo, not commits that\nwere merged via a subtree merge.\n\nWhat happened is that 'git log --follow' used to store the followed path\nonly in opt->diffopt.pathspec, so in case the commit history is\nnon-linear, and multiple parents had renames to the followed path, then\nthe end result wasn't really defined: the first commit that happened to\nbe visited in one of the parents updated opt->diffopt.pathspec, and from\nthat point, only that updated path was visited.\n\nFix the problem by introducing a commit -> path map\n(follow_pathspec_slab) that stores that will be path to follow when\nvisiting that parent. At the top of log_tree_commit(), if the slab has\nan entry for this commit, we replace opt->diffopt.pathspec with it, so\nthe correct path is followed, even if an unrelated sub-tree changed the\npath to be followed to something else. After log_tree_diff() runs, we\nrecord each parent's path in the slab: for a non-merge commit,\ntry_to_follow_renames() inside diff_tree_oid() has already updated\nopt->diffopt.pathspec to the parent's name, so we just record it. For a\nmerge, log_tree_diff() is a no-op; we run a separate\ndiff_tree_oid(parent, commit, ...) with follow_renames=1 for each parent\nand record the path it finds. As a result, the walk order doesn't\nmatter, which was exactly the source of problems previously.\n\nThis helps with subtree merges (rename happens inside the merge commit),\nbut also the general case when the rename happens in the history of\nparents, not in the merge commit itself.\n---\n\nHi Junio, Jeff,\n\nOn Tue, May 19, 2026 at 05:14:54PM +0900, Junio C Hamano <gitster@pobox.com> wrote:\n> A minor \"tweak\" that does not solve this inherent design issue does\n> not interest me, so...\n\nHere is a patch that attempts to actually solve the problem you point\nout here, by tracking what path should be followed for multiple branches\nof the history.\n\nIt obsoletes the previous two \"tweak\" patches in this thread.\n\nHopefully this one is more interesting. :-)\n\nThanks,\n\nMiklos\n\n Documentation/config/log.adoc |   3 +-\n log-tree.c                    | 116 ++++++++++++++++++++++++++++++++++\n log-tree.h                    |   1 +\n revision.c                    |   2 +\n revision.h                    |   4 ++\n t/meson.build                 |   1 +\n t/t4218-log-follow-merge.sh   |  80 +++++++++++++++++++++++\n 7 files changed, 205 insertions(+), 2 deletions(-)\n create mode 100755 t/t4218-log-follow-merge.sh\n\ndiff --git a/Documentation/config/log.adoc b/Documentation/config/log.adoc\nindex f20cc25cd7..757a7be196 100644\n--- a/Documentation/config/log.adoc\n+++ b/Documentation/config/log.adoc\n@@ -53,8 +53,7 @@ This is the same as the `--decorate` option of the `git log`.\n `log.follow`::\n \tIf `true`, `git log` will act as if the `--follow` option was used when\n \ta single <path> is given.  This has the same limitations as `--follow`,\n-\ti.e. it cannot be used to follow multiple files and does not work well\n-\ton non-linear history.\n+\ti.e. it cannot be used to follow multiple files.\n \n `log.graphColors`::\n \tA list of colors, separated by commas, that can be used to draw\ndiff --git a/log-tree.c b/log-tree.c\nindex 7e048701d0..e7f098e571 100644\n--- a/log-tree.c\n+++ b/log-tree.c\n@@ -3,6 +3,7 @@\n \n #include \"git-compat-util.h\"\n #include \"commit-reach.h\"\n+#include \"commit-slab.h\"\n #include \"config.h\"\n #include \"diff.h\"\n #include \"diffcore.h\"\n@@ -1089,6 +1090,96 @@ static int do_remerge_diff(struct rev_info *opt,\n \treturn !opt->loginfo;\n }\n \n+/* Per-commit pathspec storage for --follow across merges */\n+define_commit_slab(follow_pathspec_slab, char *);\n+\n+static const char *pathspec_single_path(const struct pathspec *ps)\n+{\n+\tif (ps->nr != 1)\n+\t\treturn NULL;\n+\treturn ps->items[0].match;\n+}\n+\n+static void set_pathspec_to_single_path(struct pathspec *ps, const char *path)\n+{\n+\tconst char *paths[2] = { path, NULL };\n+\n+\tclear_pathspec(ps);\n+\tparse_pathspec(ps,\n+\t\t       PATHSPEC_ALL_MAGIC & ~PATHSPEC_LITERAL,\n+\t\t       PATHSPEC_LITERAL_PATH, \"\", paths);\n+}\n+\n+static void remember_follow_pathspec(struct rev_info *opt,\n+\t\t\t\t     struct commit *c, const char *path)\n+{\n+\tchar **slot;\n+\n+\tif (!path)\n+\t\treturn;\n+\tif (!opt->follow_pathspec_slab) {\n+\t\topt->follow_pathspec_slab = xmalloc(sizeof(*opt->follow_pathspec_slab));\n+\t\tinit_follow_pathspec_slab(opt->follow_pathspec_slab);\n+\t}\n+\tslot = follow_pathspec_slab_at(opt->follow_pathspec_slab, c);\n+\tif (*slot && !strcmp(*slot, path))\n+\t\treturn;\n+\tfree(*slot);\n+\t*slot = xstrdup(path);\n+}\n+\n+static const char *recall_follow_pathspec(struct rev_info *opt,\n+\t\t\t\t\t  struct commit *c)\n+{\n+\tchar **slot;\n+\n+\tif (!opt->follow_pathspec_slab)\n+\t\treturn NULL;\n+\tslot = follow_pathspec_slab_peek(opt->follow_pathspec_slab, c);\n+\treturn slot ? *slot : NULL;\n+}\n+\n+static void free_follow_pathspec_slot(char **slot)\n+{\n+\tFREE_AND_NULL(*slot);\n+}\n+\n+void release_follow_pathspec_slab(struct rev_info *opt)\n+{\n+\tif (!opt->follow_pathspec_slab)\n+\t\treturn;\n+\tdeep_clear_follow_pathspec_slab(opt->follow_pathspec_slab,\n+\t\t\t\t\tfree_follow_pathspec_slot);\n+\tFREE_AND_NULL(opt->follow_pathspec_slab);\n+}\n+\n+/* Compute the followed pathspec that should apply to parent. */\n+static void propagate_follow_pathspec_to_parent(struct rev_info *opt,\n+\t\t\t\t\t\tstruct commit *commit,\n+\t\t\t\t\t\tstruct commit *parent)\n+{\n+\tstruct diff_options diff_opts;\n+\tconst char *path;\n+\n+\tparse_commit_or_die(parent);\n+\trepo_diff_setup(opt->diffopt.repo, &diff_opts);\n+\tcopy_pathspec(&diff_opts.pathspec, &opt->diffopt.pathspec);\n+\tdiff_opts.flags.recursive = 1;\n+\tdiff_opts.flags.follow_renames = 1;\n+\tdiff_opts.output_format = DIFF_FORMAT_NO_OUTPUT;\n+\tdiff_setup_done(&diff_opts);\n+\tdiff_tree_oid(get_commit_tree_oid(parent),\n+\t\t      get_commit_tree_oid(commit),\n+\t\t      \"\", &diff_opts);\n+\n+\tpath = pathspec_single_path(&diff_opts.pathspec);\n+\tif (path)\n+\t\tremember_follow_pathspec(opt, parent, path);\n+\n+\tdiff_queue_clear(&diff_queued_diff);\n+\tdiff_free(&diff_opts);\n+}\n+\n /*\n  * Show the diff of a commit.\n  *\n@@ -1179,6 +1270,16 @@ int log_tree_commit(struct rev_info *opt, struct commit *commit)\n \topt->loginfo = &log;\n \topt->diffopt.no_free = 1;\n \n+\t/* Any recorded pathspec for this commit? If so, restore it. */\n+\tif (opt->diffopt.flags.follow_renames) {\n+\t\tconst char *stored = recall_follow_pathspec(opt, commit);\n+\t\tif (stored) {\n+\t\t\tconst char *current = pathspec_single_path(&opt->diffopt.pathspec);\n+\t\t\tif (!current || strcmp(current, stored))\n+\t\t\t\tset_pathspec_to_single_path(&opt->diffopt.pathspec, stored);\n+\t\t}\n+\t}\n+\n \t/* NEEDSWORK: no restoring of no_free?  Why? */\n \tif (opt->line_level_traverse)\n \t\treturn line_log_print(opt, commit);\n@@ -1195,6 +1296,21 @@ int log_tree_commit(struct rev_info *opt, struct commit *commit)\n \t\tfprintf(opt->diffopt.file, \"\\n%s\\n\", opt->break_bar);\n \tif (shown)\n \t\tshow_diff_of_diff(opt);\n+\n+\t/* Record what pathspec each parent of this commit should use */\n+\tif (opt->diffopt.flags.follow_renames) {\n+\t\tstruct commit_list *parents = get_saved_parents(opt, commit);\n+\t\tif (parents && parents->next) {\n+\t\t\tstruct commit_list *p;\n+\t\t\tfor (p = parents; p; p = p->next)\n+\t\t\t\tpropagate_follow_pathspec_to_parent(opt, commit,\n+\t\t\t\t\t\t\t\t    p->item);\n+\t\t} else if (parents) {\n+\t\t\tremember_follow_pathspec(opt, parents->item,\n+\t\t\t\tpathspec_single_path(&opt->diffopt.pathspec));\n+\t\t}\n+\t}\n+\n \topt->loginfo = NULL;\n \tmaybe_flush_or_die(opt->diffopt.file, \"stdout\");\n \topt->diffopt.no_free = no_free;\ndiff --git a/log-tree.h b/log-tree.h\nindex 07924be8bc..e8679b6c4a 100644\n--- a/log-tree.h\n+++ b/log-tree.h\n@@ -26,6 +26,7 @@ struct decoration_options {\n int parse_decorate_color_config(const char *var, const char *slot_name, const char *value);\n int log_tree_diff_flush(struct rev_info *);\n int log_tree_commit(struct rev_info *, struct commit *);\n+void release_follow_pathspec_slab(struct rev_info *);\n void show_log(struct rev_info *opt);\n void format_decorations(struct strbuf *sb, const struct commit *commit,\n \t\t\tenum git_colorbool use_color, const struct decoration_options *opts);\ndiff --git a/revision.c b/revision.c\nindex 5693618be4..caa85fb4c6 100644\n--- a/revision.c\n+++ b/revision.c\n@@ -26,6 +26,7 @@\n #include \"decorate.h\"\n #include \"string-list.h\"\n #include \"line-log.h\"\n+#include \"log-tree.h\"\n #include \"mailmap.h\"\n #include \"commit-slab.h\"\n #include \"cache-tree.h\"\n@@ -3284,6 +3285,7 @@ void release_revisions(struct rev_info *revs)\n \tline_log_free(revs);\n \toidset_clear(&revs->missing_commits);\n \trelease_revisions_bloom_keyvecs(revs);\n+\trelease_follow_pathspec_slab(revs);\n }\n \n static void add_child(struct rev_info *revs, struct commit *parent, struct commit *child)\ndiff --git a/revision.h b/revision.h\nindex c9a11827cc..607113ca74 100644\n--- a/revision.h\n+++ b/revision.h\n@@ -65,6 +65,7 @@ struct repository;\n struct rev_info;\n struct string_list;\n struct saved_parents;\n+struct follow_pathspec_slab;\n struct bloom_keyvec;\n struct bloom_filter_settings;\n struct option;\n@@ -354,6 +355,9 @@ struct rev_info {\n \t/* copies of the parent lists, for --full-diff display */\n \tstruct saved_parents *saved_parents_slab;\n \n+\t/* per-commit pathspec for --follow across merges */\n+\tstruct follow_pathspec_slab *follow_pathspec_slab;\n+\n \tstruct commit_list *previous_parents;\n \tstruct commit_list *ancestry_path_bottoms;\n \tconst char *break_bar;\ndiff --git a/t/meson.build b/t/meson.build\nindex 2af8d01279..cd43e0609a 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -576,6 +576,7 @@ integration_tests = [\n   't4215-log-skewed-merges.sh',\n   't4216-log-bloom.sh',\n   't4217-log-limit.sh',\n+  't4218-log-follow-merge.sh',\n   't4252-am-options.sh',\n   't4253-am-keep-cr-dos.sh',\n   't4254-am-corrupt.sh',\ndiff --git a/t/t4218-log-follow-merge.sh b/t/t4218-log-follow-merge.sh\nnew file mode 100755\nindex 0000000000..7a1b6fcb84\n--- /dev/null\n+++ b/t/t4218-log-follow-merge.sh\n@@ -0,0 +1,80 @@\n+#!/bin/sh\n+\n+test_description='Test --follow follows renames across merges'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=master\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup subtree-merged repository' '\n+\tgit init inner &&\n+\techo inner >inner/inner.txt &&\n+\tgit -C inner add inner.txt &&\n+\tgit -C inner commit -m \"inner init\" &&\n+\n+\tgit init outer &&\n+\techo outer >outer/outer.txt &&\n+\tgit -C outer add outer.txt &&\n+\tgit -C outer commit -m \"outer init\" &&\n+\n+\tgit -C outer fetch ../inner master &&\n+\tgit -C outer merge -s ours --no-commit --allow-unrelated-histories \\\n+\t\tFETCH_HEAD &&\n+\tgit -C outer read-tree --prefix=inner/ -u FETCH_HEAD &&\n+\tgit -C outer commit -m \"Merge inner repo into inner/ subdirectory\"\n+'\n+\n+test_expect_success '--follow finds the pre-merge commit through a subtree merge' '\n+\tgit -C outer log --follow --pretty=tformat:%s inner/inner.txt >actual &&\n+\techo \"inner init\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'setup merge of two branches that both renamed a file to README' '\n+\tgit init foo &&\n+\tmkdir foo/foo &&\n+\techo \"foo readme\" >foo/foo/README &&\n+\tgit -C foo add foo/README &&\n+\tgit -C foo commit -m \"add foo README\" &&\n+\n+\tgit -C foo mv foo/README README &&\n+\tgit -C foo commit -m \"promote foo README to toplevel\" &&\n+\n+\techo \"foo c\" >foo/foo.c &&\n+\tgit -C foo add foo.c &&\n+\tgit -C foo commit -m \"add foo C impl\" &&\n+\n+\tgit init bar &&\n+\tmkdir bar/bar &&\n+\techo \"bar readme\" >bar/bar/README &&\n+\tgit -C bar add bar/README &&\n+\tgit -C bar commit -m \"add bar README\" &&\n+\n+\tgit -C bar mv bar/README README &&\n+\tgit -C bar commit -m \"promote bar README to toplevel\" &&\n+\n+\techo \"bar c\" >bar/bar.c &&\n+\tgit -C bar add bar.c &&\n+\tgit -C bar commit -m \"add bar C impl\" &&\n+\n+\tgit -C foo fetch ../bar master &&\n+\tgit -C foo merge -s ours --no-commit --allow-unrelated-histories \\\n+\t\tFETCH_HEAD &&\n+\tgit -C foo checkout FETCH_HEAD -- bar.c &&\n+\tgit -C foo commit -m \"merge bar into foo\"\n+'\n+\n+test_expect_success '--follow follows renames across both sides of a merge' '\n+\tgit -C foo log --follow --pretty=tformat:%s README >actual &&\n+\tsort actual >actual.sorted &&\n+\tcat >expect <<-\\EOF &&\n+\tadd bar README\n+\tadd foo README\n+\tpromote bar README to toplevel\n+\tpromote foo README to toplevel\n+\tEOF\n+\ttest_cmp expect actual.sorted\n+'\n+\n+test_done\n-- \n2.51.0\n\n"},{"id":"544937","messageId":"xmqqpl21vzj2.fsf@gitster.g","threadId":"65619","inReplyTo":"aiZipugmA7z8oBcd@collabora.com","subject":"Re: [PATCH] log: improve --follow following renames for non-linear history","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-08T15:10:25Z","receivedAt":"2026-06-08T15:10:28Z","isPatch":true,"body":"Miklos Vajna <vmiklos@collabora.com> writes:\n\n> Have a repo with a subtree merge, do a 'git log --follow prefix/test.c',\n> the output only contains history in the outer repo, not commits that\n> were merged via a subtree merge.\n>\n> What happened is that 'git log --follow' used to store the followed path\n> only in opt->diffopt.pathspec, so in case the commit history is\n> non-linear, and multiple parents had renames to the followed path, then\n> the end result wasn't really defined: the first commit that happened to\n> be visited in one of the parents updated opt->diffopt.pathspec, and from\n> that point, only that updated path was visited.\n\nWhen describing a problematic symptom you are trying to improve, you\nshould talk about the current state of the system in the present\ntense.  \"used to store\" makes it sound like in ancient times back\nwhen Linus wrote the first version of this feature it was so, but a\nfew years ago that changed, but that is not what you want to say, is\nit?\n\nThe above may sound picky, but using the consistent style of\ndescription makes it easier to follow the thought process,\nespecially when you need to read many commits to understand what is\ngoing on.\n\n> Fix the problem by introducing a commit -> path map\n> (follow_pathspec_slab) that stores that will be path to follow when\n> visiting that parent. At the top of log_tree_commit(), if the slab has\n> an entry for this commit, we replace opt->diffopt.pathspec with it, so\n> the correct path is followed, even if an unrelated sub-tree changed the\n> path to be followed to something else.\n\nCan a \"map\" cut it?\n\nIf a history forked at commit A, with two children commit B and\ncommit C, and you started traversing the history from a much later\ndescendant M that merges these two lines of history (i.e., M^1\ncontains B, M^2 contains C, and A==B^1==C^1), while traversing down\nfrom M to B you may find that you need to follow path1 and similarly\nsomewhere between M down to C the path you are following may be\npath2.  And the traversal meets at A.  The slab records path1 for B\nand path2 for C.  Wouldn't you need to be able to store both path1\nand path2 for commit A?  What path do you need to pay attention to\nwhen traversing past A to its ancestors?\n"},{"id":"545229","messageId":"aipTOsH8LKTSwglj@collabora.com","threadId":"65619","inReplyTo":"xmqqpl21vzj2.fsf@gitster.g","subject":"[PATCH v2] log: improve --follow following renames for non-linear history","fromName":"Miklos Vajna","fromEmail":"vmiklos@collabora.com","sentAt":"2026-06-11T06:18:34Z","receivedAt":"2026-06-11T06:18:51Z","isPatch":true,"body":"Have a repo with a subtree merge, do a 'git log --follow prefix/test.c',\nthe output only contains history in the outer repo, not commits that\nwere merged via a subtree merge.\n\nWhat happens is that 'git log --follow' stores the followed path only in\nopt->diffopt.pathspec, so in case the commit history is non-linear, and\nmultiple parents have renames to the followed path, then the end result\nisn't really defined: the first commit that happens to be visited in one\nof the parents update opt->diffopt.pathspec, and from that point, only\nthat updated path is visited.\n\nFix the problem by introducing a commit -> paths map\n(follow_pathspec_slab) that stores what will be paths to follow when\nvisiting that parent. At the top of log_tree_commit(), if the slab has\nan entry for this commit, we replace opt->diffopt.pathspec with paths\nfrom this entry, so the correct paths are followed, even if an unrelated\nsub-tree changed the paths to be followed to something else. After\nlog_tree_diff() runs, we record each parent's paths in the slab. As a\nresult, the walk order doesn't matter, which was exactly the source of\nproblems previously.\n\nThis helps with subtree merges (rename happens inside the merge commit),\nbut also fixes the general case when the rename happens in the history\nof parents, not in the merge commit itself. This does not remove the\nlimitation that only a single path can be specified on the command-line,\nbut we now do follow multiple paths instead of a \"last write wins\"\nsituation when determining what path to follow for a specific commit\nwith multiple previously visited children.\n---\n\nHi Junio,\n\nOn Mon, Jun 08, 2026 at 08:10:25AM -0700, Junio C Hamano <gitster@pobox.com> wrote:\n> When describing a problematic symptom you are trying to improve, you\n> should talk about the current state of the system in the present\n> tense.  \"used to store\" makes it sound like in ancient times back\n> when Linus wrote the first version of this feature it was so, but a\n> few years ago that changed, but that is not what you want to say, is\n> it?\n> \n> The above may sound picky, but using the consistent style of\n> description makes it easier to follow the thought process,\n> especially when you need to read many commits to understand what is\n> going on.\n\nMakes sense, I now fixed this.\n\n> Can a \"map\" cut it?\n> \n> If a history forked at commit A, with two children commit B and\n> commit C, and you started traversing the history from a much later\n> descendant M that merges these two lines of history (i.e., M^1\n> contains B, M^2 contains C, and A==B^1==C^1), while traversing down\n> from M to B you may find that you need to follow path1 and similarly\n> somewhere between M down to C the path you are following may be\n> path2.  And the traversal meets at A.  The slab records path1 for B\n> and path2 for C.  Wouldn't you need to be able to store both path1\n> and path2 for commit A?  What path do you need to pay attention to\n> when traversing past A to its ancestors?\n\nIndeed, I focused on merge commits and their parents and I did not \nconsider that slab[A] may be set to path1 when visiting one parent and \nthen slab[A] may be set to path2 when visiting an other parent -- even \nif \"A\" itself is just a plain commit with no renames and is not a merge.\n\nHere is an updated version, where I changed the value of \nfollow_pathspec_slab to be a string_list, and appended a new test that \nshows we now handle this case.\n\nThanks,\n\nMiklos\n\n Documentation/config/log.adoc |   3 +-\n log-tree.c                    | 133 ++++++++++++++++++++++++++++++++++\n log-tree.h                    |   1 +\n revision.c                    |   2 +\n revision.h                    |   4 +\n t/meson.build                 |   1 +\n t/t4218-log-follow-merge.sh   | 119 ++++++++++++++++++++++++++++++\n 7 files changed, 261 insertions(+), 2 deletions(-)\n create mode 100755 t/t4218-log-follow-merge.sh\n\ndiff --git a/Documentation/config/log.adoc b/Documentation/config/log.adoc\nindex f20cc25cd7..757a7be196 100644\n--- a/Documentation/config/log.adoc\n+++ b/Documentation/config/log.adoc\n@@ -53,8 +53,7 @@ This is the same as the `--decorate` option of the `git log`.\n `log.follow`::\n \tIf `true`, `git log` will act as if the `--follow` option was used when\n \ta single <path> is given.  This has the same limitations as `--follow`,\n-\ti.e. it cannot be used to follow multiple files and does not work well\n-\ton non-linear history.\n+\ti.e. it cannot be used to follow multiple files.\n \n `log.graphColors`::\n \tA list of colors, separated by commas, that can be used to draw\ndiff --git a/log-tree.c b/log-tree.c\nindex 7e048701d0..f6f396be22 100644\n--- a/log-tree.c\n+++ b/log-tree.c\n@@ -3,6 +3,7 @@\n \n #include \"git-compat-util.h\"\n #include \"commit-reach.h\"\n+#include \"commit-slab.h\"\n #include \"config.h\"\n #include \"diff.h\"\n #include \"diffcore.h\"\n@@ -1089,6 +1090,104 @@ static int do_remerge_diff(struct rev_info *opt,\n \treturn !opt->loginfo;\n }\n \n+/* Per-commit paths storage for --follow across merges */\n+define_commit_slab(follow_pathspec_slab, struct string_list);\n+\n+static const char *pathspec_single_path(const struct pathspec *ps)\n+{\n+\tif (ps->nr != 1)\n+\t\treturn NULL;\n+\treturn ps->items[0].match;\n+}\n+\n+static void set_pathspec_to_paths(struct pathspec *ps,\n+\t\t\t\t  const struct string_list *paths)\n+{\n+\tconst char **argv;\n+\tstruct string_list_item *item;\n+\tint i = 0;\n+\n+\tclear_pathspec(ps);\n+\tif (!paths->nr)\n+\t\treturn;\n+\tALLOC_ARRAY(argv, paths->nr + 1);\n+\tfor_each_string_list_item(item, paths)\n+\t\targv[i++] = item->string;\n+\targv[i] = NULL;\n+\tparse_pathspec(ps,\n+\t\t       PATHSPEC_ALL_MAGIC & ~PATHSPEC_LITERAL,\n+\t\t       PATHSPEC_LITERAL_PATH, \"\", argv);\n+\tfree(argv);\n+}\n+\n+static struct string_list *get_follow_pathspec_at(struct rev_info *opt,\n+\t\t\t\t\t\t  struct commit *c)\n+{\n+\tstruct string_list *list;\n+\n+\tif (!opt->follow_pathspec_slab) {\n+\t\topt->follow_pathspec_slab = xmalloc(sizeof(*opt->follow_pathspec_slab));\n+\t\tinit_follow_pathspec_slab(opt->follow_pathspec_slab);\n+\t}\n+\tlist = follow_pathspec_slab_at(opt->follow_pathspec_slab, c);\n+\tif (!list->strdup_strings)\n+\t\tlist->strdup_strings = 1;\n+\treturn list;\n+}\n+\n+static void remember_follow_pathspec(struct rev_info *opt,\n+\t\t\t\t     struct commit *c, const char *path)\n+{\n+\tif (!path)\n+\t\treturn;\n+\tstring_list_insert(get_follow_pathspec_at(opt, c), path);\n+}\n+\n+static void free_follow_pathspec_slot(struct string_list *slot)\n+{\n+\tstring_list_clear(slot, 0);\n+}\n+\n+void release_follow_pathspec_slab(struct rev_info *opt)\n+{\n+\tif (!opt->follow_pathspec_slab)\n+\t\treturn;\n+\tdeep_clear_follow_pathspec_slab(opt->follow_pathspec_slab,\n+\t\t\t\t\tfree_follow_pathspec_slot);\n+\tFREE_AND_NULL(opt->follow_pathspec_slab);\n+}\n+\n+/* Compute a path to follow in parent, if there is one */\n+static void propagate_follow_pathspec_to_parent(struct rev_info *opt,\n+\t\t\t\t\t\tconst char *path,\n+\t\t\t\t\t\tstruct commit *commit,\n+\t\t\t\t\t\tstruct commit *parent)\n+{\n+\tstruct diff_options diff_opts;\n+\tconst char *paths[2] = { path, NULL };\n+\tconst char *out_path;\n+\n+\tparse_commit_or_die(parent);\n+\trepo_diff_setup(opt->diffopt.repo, &diff_opts);\n+\tparse_pathspec(&diff_opts.pathspec,\n+\t\t       PATHSPEC_ALL_MAGIC & ~PATHSPEC_LITERAL,\n+\t\t       PATHSPEC_LITERAL_PATH, \"\", paths);\n+\tdiff_opts.flags.recursive = 1;\n+\tdiff_opts.flags.follow_renames = 1;\n+\tdiff_opts.output_format = DIFF_FORMAT_NO_OUTPUT;\n+\tdiff_setup_done(&diff_opts);\n+\tdiff_tree_oid(get_commit_tree_oid(parent),\n+\t\t      get_commit_tree_oid(commit),\n+\t\t      \"\", &diff_opts);\n+\n+\tout_path = pathspec_single_path(&diff_opts.pathspec);\n+\tif (out_path)\n+\t\tremember_follow_pathspec(opt, parent, out_path);\n+\n+\tdiff_queue_clear(&diff_queued_diff);\n+\tdiff_free(&diff_opts);\n+}\n+\n /*\n  * Show the diff of a commit.\n  *\n@@ -1173,12 +1272,30 @@ int log_tree_commit(struct rev_info *opt, struct commit *commit)\n \tint shown;\n \t/* maybe called by e.g. cmd_log_walk(), maybe stand-alone */\n \tint no_free = opt->diffopt.no_free;\n+\tint saved_follow_renames = 0;\n+\tstruct string_list *paths = NULL;\n \n \tlog.commit = commit;\n \tlog.parent = NULL;\n \topt->loginfo = &log;\n \topt->diffopt.no_free = 1;\n \n+\t/* Any recorded paths for this commit? If so, restore it */\n+\tif (opt->diffopt.flags.follow_renames) {\n+\t\tpaths = get_follow_pathspec_at(opt, commit);\n+\t\tif (!paths->nr) {\n+\t\t\tconst char *path = pathspec_single_path(&opt->diffopt.pathspec);\n+\t\t\tif (path)\n+\t\t\t\tstring_list_insert(paths, path);\n+\t\t}\n+\t\tset_pathspec_to_paths(&opt->diffopt.pathspec, paths);\n+\t\tif (paths->nr > 1) {\n+\t\t\t/* diff_check_follow_pathspec() doesn't handle multiple paths */\n+\t\t\tsaved_follow_renames = opt->diffopt.flags.follow_renames;\n+\t\t\topt->diffopt.flags.follow_renames = 0;\n+\t\t}\n+\t}\n+\n \t/* NEEDSWORK: no restoring of no_free?  Why? */\n \tif (opt->line_level_traverse)\n \t\treturn line_log_print(opt, commit);\n@@ -1195,6 +1312,22 @@ int log_tree_commit(struct rev_info *opt, struct commit *commit)\n \t\tfprintf(opt->diffopt.file, \"\\n%s\\n\", opt->break_bar);\n \tif (shown)\n \t\tshow_diff_of_diff(opt);\n+\n+\tif (saved_follow_renames)\n+\t\topt->diffopt.flags.follow_renames = saved_follow_renames;\n+\n+\t/* Record what paths each parent of this commit should use */\n+\tif (opt->diffopt.flags.follow_renames && paths) {\n+\t\tstruct commit_list *parents = get_saved_parents(opt, commit);\n+\t\tstruct commit_list *p;\n+\t\tstruct string_list_item *item;\n+\t\tfor (p = parents; p; p = p->next) {\n+\t\t\tfor_each_string_list_item(item, paths)\n+\t\t\t\tpropagate_follow_pathspec_to_parent(opt,\n+\t\t\t\t\titem->string, commit, p->item);\n+\t\t}\n+\t}\n+\n \topt->loginfo = NULL;\n \tmaybe_flush_or_die(opt->diffopt.file, \"stdout\");\n \topt->diffopt.no_free = no_free;\ndiff --git a/log-tree.h b/log-tree.h\nindex 07924be8bc..e8679b6c4a 100644\n--- a/log-tree.h\n+++ b/log-tree.h\n@@ -26,6 +26,7 @@ struct decoration_options {\n int parse_decorate_color_config(const char *var, const char *slot_name, const char *value);\n int log_tree_diff_flush(struct rev_info *);\n int log_tree_commit(struct rev_info *, struct commit *);\n+void release_follow_pathspec_slab(struct rev_info *);\n void show_log(struct rev_info *opt);\n void format_decorations(struct strbuf *sb, const struct commit *commit,\n \t\t\tenum git_colorbool use_color, const struct decoration_options *opts);\ndiff --git a/revision.c b/revision.c\nindex 5693618be4..caa85fb4c6 100644\n--- a/revision.c\n+++ b/revision.c\n@@ -26,6 +26,7 @@\n #include \"decorate.h\"\n #include \"string-list.h\"\n #include \"line-log.h\"\n+#include \"log-tree.h\"\n #include \"mailmap.h\"\n #include \"commit-slab.h\"\n #include \"cache-tree.h\"\n@@ -3284,6 +3285,7 @@ void release_revisions(struct rev_info *revs)\n \tline_log_free(revs);\n \toidset_clear(&revs->missing_commits);\n \trelease_revisions_bloom_keyvecs(revs);\n+\trelease_follow_pathspec_slab(revs);\n }\n \n static void add_child(struct rev_info *revs, struct commit *parent, struct commit *child)\ndiff --git a/revision.h b/revision.h\nindex c9a11827cc..607113ca74 100644\n--- a/revision.h\n+++ b/revision.h\n@@ -65,6 +65,7 @@ struct repository;\n struct rev_info;\n struct string_list;\n struct saved_parents;\n+struct follow_pathspec_slab;\n struct bloom_keyvec;\n struct bloom_filter_settings;\n struct option;\n@@ -354,6 +355,9 @@ struct rev_info {\n \t/* copies of the parent lists, for --full-diff display */\n \tstruct saved_parents *saved_parents_slab;\n \n+\t/* per-commit pathspec for --follow across merges */\n+\tstruct follow_pathspec_slab *follow_pathspec_slab;\n+\n \tstruct commit_list *previous_parents;\n \tstruct commit_list *ancestry_path_bottoms;\n \tconst char *break_bar;\ndiff --git a/t/meson.build b/t/meson.build\nindex c5832fee05..faecae2b50 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -576,6 +576,7 @@ integration_tests = [\n   't4215-log-skewed-merges.sh',\n   't4216-log-bloom.sh',\n   't4217-log-limit.sh',\n+  't4218-log-follow-merge.sh',\n   't4252-am-options.sh',\n   't4253-am-keep-cr-dos.sh',\n   't4254-am-corrupt.sh',\ndiff --git a/t/t4218-log-follow-merge.sh b/t/t4218-log-follow-merge.sh\nnew file mode 100755\nindex 0000000000..dcb0c937d7\n--- /dev/null\n+++ b/t/t4218-log-follow-merge.sh\n@@ -0,0 +1,119 @@\n+#!/bin/sh\n+\n+test_description='Test --follow follows renames across merges'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=master\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup subtree-merged repository' '\n+\tgit init inner &&\n+\techo inner >inner/inner.txt &&\n+\tgit -C inner add inner.txt &&\n+\tgit -C inner commit -m \"inner init\" &&\n+\n+\tgit init outer &&\n+\techo outer >outer/outer.txt &&\n+\tgit -C outer add outer.txt &&\n+\tgit -C outer commit -m \"outer init\" &&\n+\n+\tgit -C outer fetch ../inner master &&\n+\tgit -C outer merge -s ours --no-commit --allow-unrelated-histories \\\n+\t\tFETCH_HEAD &&\n+\tgit -C outer read-tree --prefix=inner/ -u FETCH_HEAD &&\n+\tgit -C outer commit -m \"Merge inner repo into inner/ subdirectory\"\n+'\n+\n+test_expect_success '--follow finds the pre-merge commit through a subtree merge' '\n+\tgit -C outer log --follow --pretty=tformat:%s inner/inner.txt >actual &&\n+\techo \"inner init\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'setup merge of two branches that both renamed a file to README' '\n+\tgit init foo &&\n+\tmkdir foo/foo &&\n+\techo \"foo readme\" >foo/foo/README &&\n+\tgit -C foo add foo/README &&\n+\tgit -C foo commit -m \"add foo README\" &&\n+\n+\tgit -C foo mv foo/README README &&\n+\tgit -C foo commit -m \"promote foo README to toplevel\" &&\n+\n+\techo \"foo c\" >foo/foo.c &&\n+\tgit -C foo add foo.c &&\n+\tgit -C foo commit -m \"add foo C impl\" &&\n+\n+\tgit init bar &&\n+\tmkdir bar/bar &&\n+\techo \"bar readme\" >bar/bar/README &&\n+\tgit -C bar add bar/README &&\n+\tgit -C bar commit -m \"add bar README\" &&\n+\n+\tgit -C bar mv bar/README README &&\n+\tgit -C bar commit -m \"promote bar README to toplevel\" &&\n+\n+\techo \"bar c\" >bar/bar.c &&\n+\tgit -C bar add bar.c &&\n+\tgit -C bar commit -m \"add bar C impl\" &&\n+\n+\tgit -C foo fetch ../bar master &&\n+\tgit -C foo merge -s ours --no-commit --allow-unrelated-histories \\\n+\t\tFETCH_HEAD &&\n+\tgit -C foo checkout FETCH_HEAD -- bar.c &&\n+\tgit -C foo commit -m \"merge bar into foo\"\n+'\n+\n+test_expect_success '--follow follows renames across both sides of a merge' '\n+\tgit -C foo log --follow --pretty=tformat:%s README >actual &&\n+\tsort actual >actual.sorted &&\n+\tcat >expect <<-\\EOF &&\n+\tadd bar README\n+\tadd foo README\n+\tpromote bar README to toplevel\n+\tpromote foo README to toplevel\n+\tEOF\n+\ttest_cmp expect actual.sorted\n+'\n+\n+# When two branches rename a different file to the same name and then meet again\n+# in a merge, log --follow needs to keep track both paths.\n+test_expect_success 'setup criss-cross merge where two paths converge in ancestor' '\n+\tgit init crisscross &&\n+\techo \"alpha content\" >crisscross/alpha.txt &&\n+\tgit -C crisscross add alpha.txt &&\n+\tgit -C crisscross commit -m \"root: add alpha.txt\" &&\n+\n+\techo \"beta content\" >crisscross/beta.txt &&\n+\tgit -C crisscross add beta.txt &&\n+\tgit -C crisscross commit -m \"fork: add beta.txt\" &&\n+\n+\tgit -C crisscross checkout -b branchB &&\n+\tgit -C crisscross mv alpha.txt combined.txt &&\n+\tgit -C crisscross rm beta.txt &&\n+\tgit -C crisscross commit -m \"B: rename alpha to combined\" &&\n+\n+\tgit -C crisscross checkout master &&\n+\tgit -C crisscross checkout -b branchC &&\n+\tgit -C crisscross mv beta.txt combined.txt &&\n+\tgit -C crisscross rm alpha.txt &&\n+\tgit -C crisscross commit -m \"C: rename beta to combined\" &&\n+\n+\tgit -C crisscross checkout branchB &&\n+\tgit -C crisscross merge -s ours -m \"merge C into B\" branchC\n+'\n+\n+test_expect_success '--follow follows two diverged paths past their common ancestor' '\n+\tgit -C crisscross log --follow --pretty=tformat:%s combined.txt >actual &&\n+\tsort actual >actual.sorted &&\n+\tcat >expect <<-\\EOF &&\n+\tB: rename alpha to combined\n+\tC: rename beta to combined\n+\tfork: add beta.txt\n+\troot: add alpha.txt\n+\tEOF\n+\ttest_cmp expect actual.sorted\n+'\n+\n+test_done\n-- \n2.51.0\n\n"},{"id":"545318","messageId":"xmqqo6hglncl.fsf@gitster.g","threadId":"65619","inReplyTo":"aipTOsH8LKTSwglj@collabora.com","subject":"Re: [PATCH v2] log: improve --follow following renames for non-linear history","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-11T22:32:42Z","receivedAt":"2026-06-11T22:32:44Z","isPatch":true,"body":"Miklos Vajna <vmiklos@collabora.com> writes:\n\n> situation when determining what path to follow for a specific commit\n> with multiple previously visited children.\n> ---\n\nMissing sign-off; omitting sign-off to say that this is primarily\nfor requesting comments and not ready for application (often we see\nRFC on the Subject line when this is done) is fine, though.\n\n>> Can a \"map\" cut it?\n>> \n>> If a history forked at commit A, with two children commit B and\n>> commit C, and you started traversing the history from a much later\n>> descendant M that merges these two lines of history (i.e., M^1\n>> contains B, M^2 contains C, and A==B^1==C^1), while traversing down\n>> from M to B you may find that you need to follow path1 and similarly\n>> somewhere between M down to C the path you are following may be\n>> path2.  And the traversal meets at A.  The slab records path1 for B\n>> and path2 for C.  Wouldn't you need to be able to store both path1\n>> and path2 for commit A?  What path do you need to pay attention to\n>> when traversing past A to its ancestors?\n>\n> Indeed, I focused on merge commits and their parents and I did not \n> consider that slab[A] may be set to path1 when visiting one parent and \n> then slab[A] may be set to path2 when visiting an other parent -- even \n> if \"A\" itself is just a plain commit with no renames and is not a merge.\n\n\"A\" in my example is a fork point.  One of A's children may arrive\nat A following path1 while another child may come to A following\npath2.  IOW, in the history below:\n\n      B---X---o---M---o---Z\n     /           /\n    A---C---Y---o\n\n * A has the original path at \"path0\"; so do B and C.\n * X renames \"path0\" to \"path1\"\n * Y renames \"path0\" to \"path2\"\n * M merges path1 coming from upper and path2 from lower history\n   and records the result at path \"path\".\n * Z has \"path\".\n\nYou run \"git log --follow Z -- path\".\n\nMy answer to my (rhetorical) question (Can a \"map\" cut it?) actually\nwas \"we probably can\", since our \"rename following\" code does not\nhandle cases where two paths in a parent is merged into a single\npath in a child, or a single path in a parent is split to form\nmultiple paths in a child.\n\nSo the \"what path are we following?\" slab would need to keep track\nof a single path.  From Z down to M, we follow \"path\".  \"path1\" is\nfollowed from M to X and \"path2\" is followed from M to Y.  And from\nX to A, and Y to A, we follow \"path0\".  IOW, I did not think we need\ntwo paths recorded for one commit.\n\nAre any of your test cases added by this patch behave differently\nwith this version (vs the \"single path assigned to each commit\"\nversion you had earlier)?  If so, then obviously there is some hole\nin my above discussion.  \n\nOne case that _could_ break down is if a rename on one track (say,\nat Y) is so huge that it is not recognised as a rename.  Then from Y\ndown to A we would probably try to track \"path2\" (because we fail to\nnotice that \"path2\" came from \"path0\") and declare that \"path2\"\nappeared at Y from nowhere.  But even then, we shouldn't propagate\n\"path2\" down to A, so A would get only \"path0\" which was what we\nfollow going from X down to A, I think.  Still no need for following\nmultiple paths at a fork point.\n\n> +\t/* Any recorded paths for this commit? If so, restore it */\n> +\tif (opt->diffopt.flags.follow_renames) {\n> +\t\tpaths = get_follow_pathspec_at(opt, commit);\n> +\t\tif (!paths->nr) {\n> +\t\t\tconst char *path = pathspec_single_path(&opt->diffopt.pathspec);\n> +\t\t\tif (path)\n> +\t\t\t\tstring_list_insert(paths, path);\n\nWe do not need to worry about deduplicating, as string_list_insert()\nwill automatically takes care of that for us, which is nice.\n\n> +\t\t}\n> +\t\tset_pathspec_to_paths(&opt->diffopt.pathspec, paths);\n> +\t\tif (paths->nr > 1) {\n> +\t\t\t/* diff_check_follow_pathspec() doesn't handle multiple paths */\n> +\t\t\tsaved_follow_renames = opt->diffopt.flags.follow_renames;\n> +\t\t\topt->diffopt.flags.follow_renames = 0;\n> +\t\t}\n> +\t}\n\nEek. That's a subtle workaround to break the built-in safety to\nensure there is only one pathspec element while following.\n\n"},{"id":"545419","messageId":"xmqqa4szh644.fsf@gitster.g","threadId":"65619","inReplyTo":"aipTOsH8LKTSwglj@collabora.com","subject":"Re: [PATCH v2] log: improve --follow following renames for non-linear history","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-12T20:10:51Z","receivedAt":"2026-06-12T20:10:54Z","isPatch":true,"body":"Miklos Vajna <vmiklos@collabora.com> writes:\n\n>  Documentation/config/log.adoc |   3 +-\n>  log-tree.c                    | 133 ++++++++++++++++++++++++++++++++++\n>  log-tree.h                    |   1 +\n>  revision.c                    |   2 +\n>  revision.h                    |   4 +\n>  t/meson.build                 |   1 +\n>  t/t4218-log-follow-merge.sh   | 119 ++++++++++++++++++++++++++++++\n\nt4218 seems to be taken by another topic in-flight, so this needs\nrenumbering.\n"},{"id":"545516","messageId":"ai-aE83w02xPRlPr@collabora.com","threadId":"65619","inReplyTo":"xmqqo6hglncl.fsf@gitster.g","subject":"[PATCH v3] log: improve --follow following renames for non-linear history","fromName":"Miklos Vajna","fromEmail":"vmiklos@collabora.com","sentAt":"2026-06-15T06:22:11Z","receivedAt":"2026-06-15T06:22:28Z","isPatch":true,"body":"Have a repo with a subtree merge, do a 'git log --follow prefix/test.c',\nthe output only contains history in the outer repo, not commits that\nwere merged via a subtree merge.\n\nWhat happens is that 'git log --follow' stores the followed path only in\nopt->diffopt.pathspec, so in case the commit history is non-linear, and\nmultiple parents have renames to the followed path, then the end result\nisn't really defined: the first commit that happens to be visited in one\nof the parents update opt->diffopt.pathspec, and from that point, only\nthat updated path is visited.\n\nFix the problem by introducing a commit -> path map\n(follow_pathspec_slab) that stores what will be a path to follow when\nvisiting that parent. At the top of log_tree_commit(), if the slab has\nan entry for this commit, we replace opt->diffopt.pathspec with a path\nfrom this entry, so the correct path is followed, even if an unrelated\nsub-tree changed the path to be followed to something else. After\nlog_tree_diff() runs, we record each parent's path in the slab. As a\nresult, the walk order doesn't matter, which was exactly the source of\nproblems previously.\n\nThis helps with subtree merges (rename happens inside the merge commit),\nbut also fixes the general case when the rename happens in the history\nof parents, not in the merge commit itself.\n\nSigned-off-by: Miklos Vajna <vmiklos@collabora.com>\n---\n\nHi Junio,\n\nOn Thu, Jun 11, 2026 at 03:32:42PM -0700, Junio C Hamano <gitster@pobox.com> wrote:\n> Missing sign-off; omitting sign-off to say that this is primarily\n> for requesting comments and not ready for application (often we see\n> RFC on the Subject line when this is done) is fine, though.\n\nI've fixed that, this is meant to be ready for application now.\n\n> My answer to my (rhetorical) question (Can a \"map\" cut it?) actually\n> was \"we probably can\", since our \"rename following\" code does not\n> handle cases where two paths in a parent is merged into a single\n> path in a child, or a single path in a parent is split to form\n> multiple paths in a child.\n\nThis is what confused me. Seeing that the \"rename following\" code\ndoesn't handle splits, I can indeed go back to just track one path per\ncommit, which makes the patch simpler, so I'm quite happy with that.\n\n> Are any of your test cases added by this patch behave differently\n> with this version (vs the \"single path assigned to each commit\"\n> version you had earlier)?  If so, then obviously there is some hole\n> in my above discussion.  \n\nIgnoring the setup ones, I had 3 tests in the patch:\n\n1) The original subtree merge use-case, with unrelated histories, rename\nhappening in the merge commit itself.\n\n2) Your unrelated histories use-case from\n\nhttps://lore.kernel.org/git/xmqqjysz7r41.fsf@gitster.g/\n\nwhich pointed out the design issue in the --follow feature.\n\n3) A last one, which tried to handle splits, in retrospect not really\nsuccessfully.\n\nSo I suggest let's forget about the 3rd case, and the first two behave\nthe same when storing just one path in the slab, so that validates your\ndiscussion.\n\nNow that you pointed out a 3rd use-case, with related histories, I also\nadded a test for that, with a history like this:\n\n  B---X\n /     \\\nA       M---Z\n \\     /\n  C---Y\n\nWhere:\n\n- A has path0\n- B (child of A) modifies path0\n- X (child of B) renames path0 to path1\n- C (child of A) modifies path0\n- Y (child of C) renames path0 to path2\n- M merges path1 and path2 to just path\n- Z modifies path\n\nand 'git log --follow path' finds all 6 non-merge commits. I turned this\ninto a (new) 3rd testcase in the patch, since related histories were not\ntested so far.\n\n> Eek. That's a subtle workaround to break the built-in safety to\n> ensure there is only one pathspec element while following.\n\nI now took that out, since the slab now just has one path for each\ncommit.\n\n> t4218 seems to be taken by another topic in-flight, so this needs\n> renumbering.\n\nOK, t4219 seems to be free in 'next', let me take that, then.\n\nThanks,\n\nMiklos\n\n Documentation/config/log.adoc |   3 +-\n log-tree.c                    | 116 ++++++++++++++++++++++++++++++\n log-tree.h                    |   1 +\n revision.c                    |   2 +\n revision.h                    |   4 ++\n t/meson.build                 |   1 +\n t/t4219-log-follow-merge.sh   | 129 ++++++++++++++++++++++++++++++++++\n 7 files changed, 254 insertions(+), 2 deletions(-)\n create mode 100755 t/t4219-log-follow-merge.sh\n\ndiff --git a/Documentation/config/log.adoc b/Documentation/config/log.adoc\nindex f20cc25cd7..757a7be196 100644\n--- a/Documentation/config/log.adoc\n+++ b/Documentation/config/log.adoc\n@@ -53,8 +53,7 @@ This is the same as the `--decorate` option of the `git log`.\n `log.follow`::\n \tIf `true`, `git log` will act as if the `--follow` option was used when\n \ta single <path> is given.  This has the same limitations as `--follow`,\n-\ti.e. it cannot be used to follow multiple files and does not work well\n-\ton non-linear history.\n+\ti.e. it cannot be used to follow multiple files.\n \n `log.graphColors`::\n \tA list of colors, separated by commas, that can be used to draw\ndiff --git a/log-tree.c b/log-tree.c\nindex 7e048701d0..90f933063e 100644\n--- a/log-tree.c\n+++ b/log-tree.c\n@@ -3,6 +3,7 @@\n \n #include \"git-compat-util.h\"\n #include \"commit-reach.h\"\n+#include \"commit-slab.h\"\n #include \"config.h\"\n #include \"diff.h\"\n #include \"diffcore.h\"\n@@ -1089,6 +1090,96 @@ static int do_remerge_diff(struct rev_info *opt,\n \treturn !opt->loginfo;\n }\n \n+/* Per-commit path storage for --follow across merges */\n+define_commit_slab(follow_pathspec_slab, char *);\n+\n+static const char *pathspec_single_path(const struct pathspec *ps)\n+{\n+\tif (ps->nr != 1)\n+\t\treturn NULL;\n+\treturn ps->items[0].match;\n+}\n+\n+static void set_pathspec_to_single_path(struct pathspec *ps, const char *path)\n+{\n+\tconst char *paths[2] = { path, NULL };\n+\n+\tclear_pathspec(ps);\n+\tparse_pathspec(ps,\n+\t\t       PATHSPEC_ALL_MAGIC & ~PATHSPEC_LITERAL,\n+\t\t       PATHSPEC_LITERAL_PATH, \"\", paths);\n+}\n+\n+static void remember_follow_pathspec(struct rev_info *opt,\n+\t\t\t\t     struct commit *c, const char *path)\n+{\n+\tchar **slot;\n+\n+\tif (!path)\n+\t\treturn;\n+\tif (!opt->follow_pathspec_slab) {\n+\t\topt->follow_pathspec_slab = xmalloc(sizeof(*opt->follow_pathspec_slab));\n+\t\tinit_follow_pathspec_slab(opt->follow_pathspec_slab);\n+\t}\n+\tslot = follow_pathspec_slab_at(opt->follow_pathspec_slab, c);\n+\tif (*slot && !strcmp(*slot, path))\n+\t\treturn;\n+\tfree(*slot);\n+\t*slot = xstrdup(path);\n+}\n+\n+static const char *recall_follow_pathspec(struct rev_info *opt,\n+\t\t\t\t\t  struct commit *c)\n+{\n+\tchar **slot;\n+\n+\tif (!opt->follow_pathspec_slab)\n+\t\treturn NULL;\n+\tslot = follow_pathspec_slab_peek(opt->follow_pathspec_slab, c);\n+\treturn slot ? *slot : NULL;\n+}\n+\n+static void free_follow_pathspec_slot(char **slot)\n+{\n+\tFREE_AND_NULL(*slot);\n+}\n+\n+void release_follow_pathspec_slab(struct rev_info *opt)\n+{\n+\tif (!opt->follow_pathspec_slab)\n+\t\treturn;\n+\tdeep_clear_follow_pathspec_slab(opt->follow_pathspec_slab,\n+\t\t\t\t\tfree_follow_pathspec_slot);\n+\tFREE_AND_NULL(opt->follow_pathspec_slab);\n+}\n+\n+/* Compute a path to follow in parent, if there is one */\n+static void propagate_follow_pathspec_to_parent(struct rev_info *opt,\n+\t\t\t\t\t\tstruct commit *commit,\n+\t\t\t\t\t\tstruct commit *parent)\n+{\n+\tstruct diff_options diff_opts;\n+\tconst char *path;\n+\n+\tparse_commit_or_die(parent);\n+\trepo_diff_setup(opt->diffopt.repo, &diff_opts);\n+\tcopy_pathspec(&diff_opts.pathspec, &opt->diffopt.pathspec);\n+\tdiff_opts.flags.recursive = 1;\n+\tdiff_opts.flags.follow_renames = 1;\n+\tdiff_opts.output_format = DIFF_FORMAT_NO_OUTPUT;\n+\tdiff_setup_done(&diff_opts);\n+\tdiff_tree_oid(get_commit_tree_oid(parent),\n+\t\t      get_commit_tree_oid(commit),\n+\t\t      \"\", &diff_opts);\n+\n+\tpath = pathspec_single_path(&diff_opts.pathspec);\n+\tif (path)\n+\t\tremember_follow_pathspec(opt, parent, path);\n+\n+\tdiff_queue_clear(&diff_queued_diff);\n+\tdiff_free(&diff_opts);\n+}\n+\n /*\n  * Show the diff of a commit.\n  *\n@@ -1179,6 +1270,16 @@ int log_tree_commit(struct rev_info *opt, struct commit *commit)\n \topt->loginfo = &log;\n \topt->diffopt.no_free = 1;\n \n+\t/* Any recorded path for this commit? If so, restore it */\n+\tif (opt->diffopt.flags.follow_renames) {\n+\t\tconst char *stored = recall_follow_pathspec(opt, commit);\n+\t\tif (stored) {\n+\t\t\tconst char *current = pathspec_single_path(&opt->diffopt.pathspec);\n+\t\t\tif (!current || strcmp(current, stored))\n+\t\t\t\tset_pathspec_to_single_path(&opt->diffopt.pathspec, stored);\n+\t\t}\n+\t}\n+\n \t/* NEEDSWORK: no restoring of no_free?  Why? */\n \tif (opt->line_level_traverse)\n \t\treturn line_log_print(opt, commit);\n@@ -1195,6 +1296,21 @@ int log_tree_commit(struct rev_info *opt, struct commit *commit)\n \t\tfprintf(opt->diffopt.file, \"\\n%s\\n\", opt->break_bar);\n \tif (shown)\n \t\tshow_diff_of_diff(opt);\n+\n+\t/* Record what path each parent of this commit should use */\n+\tif (opt->diffopt.flags.follow_renames) {\n+\t\tstruct commit_list *parents = get_saved_parents(opt, commit);\n+\t\tif (parents && parents->next) {\n+\t\t\tstruct commit_list *p;\n+\t\t\tfor (p = parents; p; p = p->next)\n+\t\t\t\tpropagate_follow_pathspec_to_parent(opt, commit,\n+\t\t\t\t\t\t\t\t    p->item);\n+\t\t} else if (parents) {\n+\t\t\tremember_follow_pathspec(opt, parents->item,\n+\t\t\t\tpathspec_single_path(&opt->diffopt.pathspec));\n+\t\t}\n+\t}\n+\n \topt->loginfo = NULL;\n \tmaybe_flush_or_die(opt->diffopt.file, \"stdout\");\n \topt->diffopt.no_free = no_free;\ndiff --git a/log-tree.h b/log-tree.h\nindex 07924be8bc..e8679b6c4a 100644\n--- a/log-tree.h\n+++ b/log-tree.h\n@@ -26,6 +26,7 @@ struct decoration_options {\n int parse_decorate_color_config(const char *var, const char *slot_name, const char *value);\n int log_tree_diff_flush(struct rev_info *);\n int log_tree_commit(struct rev_info *, struct commit *);\n+void release_follow_pathspec_slab(struct rev_info *);\n void show_log(struct rev_info *opt);\n void format_decorations(struct strbuf *sb, const struct commit *commit,\n \t\t\tenum git_colorbool use_color, const struct decoration_options *opts);\ndiff --git a/revision.c b/revision.c\nindex 5693618be4..caa85fb4c6 100644\n--- a/revision.c\n+++ b/revision.c\n@@ -26,6 +26,7 @@\n #include \"decorate.h\"\n #include \"string-list.h\"\n #include \"line-log.h\"\n+#include \"log-tree.h\"\n #include \"mailmap.h\"\n #include \"commit-slab.h\"\n #include \"cache-tree.h\"\n@@ -3284,6 +3285,7 @@ void release_revisions(struct rev_info *revs)\n \tline_log_free(revs);\n \toidset_clear(&revs->missing_commits);\n \trelease_revisions_bloom_keyvecs(revs);\n+\trelease_follow_pathspec_slab(revs);\n }\n \n static void add_child(struct rev_info *revs, struct commit *parent, struct commit *child)\ndiff --git a/revision.h b/revision.h\nindex c9a11827cc..607113ca74 100644\n--- a/revision.h\n+++ b/revision.h\n@@ -65,6 +65,7 @@ struct repository;\n struct rev_info;\n struct string_list;\n struct saved_parents;\n+struct follow_pathspec_slab;\n struct bloom_keyvec;\n struct bloom_filter_settings;\n struct option;\n@@ -354,6 +355,9 @@ struct rev_info {\n \t/* copies of the parent lists, for --full-diff display */\n \tstruct saved_parents *saved_parents_slab;\n \n+\t/* per-commit pathspec for --follow across merges */\n+\tstruct follow_pathspec_slab *follow_pathspec_slab;\n+\n \tstruct commit_list *previous_parents;\n \tstruct commit_list *ancestry_path_bottoms;\n \tconst char *break_bar;\ndiff --git a/t/meson.build b/t/meson.build\nindex c5832fee05..8c4636565b 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -576,6 +576,7 @@ integration_tests = [\n   't4215-log-skewed-merges.sh',\n   't4216-log-bloom.sh',\n   't4217-log-limit.sh',\n+  't4219-log-follow-merge.sh',\n   't4252-am-options.sh',\n   't4253-am-keep-cr-dos.sh',\n   't4254-am-corrupt.sh',\ndiff --git a/t/t4219-log-follow-merge.sh b/t/t4219-log-follow-merge.sh\nnew file mode 100755\nindex 0000000000..e370f82955\n--- /dev/null\n+++ b/t/t4219-log-follow-merge.sh\n@@ -0,0 +1,129 @@\n+#!/bin/sh\n+\n+test_description='Test --follow follows renames across merges'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=master\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup subtree-merged repository' '\n+\tgit init inner &&\n+\techo inner >inner/inner.txt &&\n+\tgit -C inner add inner.txt &&\n+\tgit -C inner commit -m \"inner init\" &&\n+\n+\tgit init outer &&\n+\techo outer >outer/outer.txt &&\n+\tgit -C outer add outer.txt &&\n+\tgit -C outer commit -m \"outer init\" &&\n+\n+\tgit -C outer fetch ../inner master &&\n+\tgit -C outer merge -s ours --no-commit --allow-unrelated-histories \\\n+\t\tFETCH_HEAD &&\n+\tgit -C outer read-tree --prefix=inner/ -u FETCH_HEAD &&\n+\tgit -C outer commit -m \"Merge inner repo into inner/ subdirectory\"\n+'\n+\n+test_expect_success '--follow finds the pre-merge commit through a subtree merge' '\n+\tgit -C outer log --follow --pretty=tformat:%s inner/inner.txt >actual &&\n+\techo \"inner init\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'setup merge of two branches that both renamed a file to README' '\n+\tgit init foo &&\n+\tmkdir foo/foo &&\n+\techo \"foo readme\" >foo/foo/README &&\n+\tgit -C foo add foo/README &&\n+\tgit -C foo commit -m \"add foo README\" &&\n+\n+\tgit -C foo mv foo/README README &&\n+\tgit -C foo commit -m \"promote foo README to toplevel\" &&\n+\n+\techo \"foo c\" >foo/foo.c &&\n+\tgit -C foo add foo.c &&\n+\tgit -C foo commit -m \"add foo C impl\" &&\n+\n+\tgit init bar &&\n+\tmkdir bar/bar &&\n+\techo \"bar readme\" >bar/bar/README &&\n+\tgit -C bar add bar/README &&\n+\tgit -C bar commit -m \"add bar README\" &&\n+\n+\tgit -C bar mv bar/README README &&\n+\tgit -C bar commit -m \"promote bar README to toplevel\" &&\n+\n+\techo \"bar c\" >bar/bar.c &&\n+\tgit -C bar add bar.c &&\n+\tgit -C bar commit -m \"add bar C impl\" &&\n+\n+\tgit -C foo fetch ../bar master &&\n+\tgit -C foo merge -s ours --no-commit --allow-unrelated-histories \\\n+\t\tFETCH_HEAD &&\n+\tgit -C foo checkout FETCH_HEAD -- bar.c &&\n+\tgit -C foo commit -m \"merge bar into foo\"\n+'\n+\n+test_expect_success '--follow follows renames across both sides of a merge' '\n+\tgit -C foo log --follow --pretty=tformat:%s README >actual &&\n+\tsort actual >actual.sorted &&\n+\tcat >expect <<-\\EOF &&\n+\tadd bar README\n+\tadd foo README\n+\tpromote bar README to toplevel\n+\tpromote foo README to toplevel\n+\tEOF\n+\ttest_cmp expect actual.sorted\n+'\n+\n+test_expect_success 'setup diamond with renames on both sides of a fork' '\n+\tgit init diamond &&\n+\ttest_lines=\"line 1\\nline 2\\nline 3\\nline 4\\nline 5\\n\" &&\n+\n+\tprintf \"$test_lines\" >diamond/path0 &&\n+\tgit -C diamond add path0 &&\n+\tgit -C diamond commit -m \"A: add path0\" &&\n+\n+\tgit -C diamond checkout -b upper &&\n+\tprintf \"line 1\\nline 2\\nline 3 modified by B\\nline 4\\nline 5\\n\" \\\n+\t\t>diamond/path0 &&\n+\tgit -C diamond commit -am \"B: modify path0 on upper\" &&\n+\tgit -C diamond mv path0 path1 &&\n+\tgit -C diamond commit -m \"X: rename path0 to path1\" &&\n+\n+\tgit -C diamond checkout -b lower master &&\n+\tprintf \"line 1\\nline 2\\nline 3 modified by C\\nline 4\\nline 5\\n\" \\\n+\t\t>diamond/path0 &&\n+\tgit -C diamond commit -am \"C: modify path0 on lower\" &&\n+\tgit -C diamond mv path0 path2 &&\n+\tgit -C diamond commit -m \"Y: rename path0 to path2\" &&\n+\n+\tgit -C diamond checkout upper &&\n+\tgit -C diamond merge -s ours --no-commit lower &&\n+\tgit -C diamond rm path1 &&\n+\tprintf \"line 1\\nline 2\\nline 3 merged\\nline 4\\nline 5\\n\" \\\n+\t\t>diamond/path &&\n+\tgit -C diamond add path &&\n+\tgit -C diamond commit -m \"M: merge with rename to path\" &&\n+\n+\tprintf \"line 1\\nline 2\\nline 3 merged again\\nline 4\\nline 5\\n\" \\\n+\t\t>diamond/path &&\n+\tgit -C diamond commit -am \"Z: modify path\"\n+'\n+\n+test_expect_success '--follow follows renames through a fork in a single history' '\n+\tgit -C diamond log --follow --pretty=tformat:%s path >actual &&\n+\tsort actual >actual.sorted &&\n+\tcat >expect <<-\\EOF &&\n+\tA: add path0\n+\tB: modify path0 on upper\n+\tC: modify path0 on lower\n+\tX: rename path0 to path1\n+\tY: rename path0 to path2\n+\tZ: modify path\n+\tEOF\n+\ttest_cmp expect actual.sorted\n+'\n+\n+test_done\n-- \n2.51.0\n\n"},{"id":"546107","messageId":"ajjU4w2B0NlZffw1@collabora.com","threadId":"65619","inReplyTo":"ai-aE83w02xPRlPr@collabora.com","subject":"[PATCH v4] log: improve --follow following renames for non-linear history","fromName":"Miklos Vajna","fromEmail":"vmiklos@collabora.com","sentAt":"2026-06-22T06:23:31Z","receivedAt":"2026-06-22T06:23:47Z","isPatch":true,"body":"Have a repo with a subtree merge, do a 'git log --follow prefix/test.c',\nthe output only contains history in the outer repo, not commits that\nwere merged via a subtree merge.\n\nWhat happens is that 'git log --follow' stores the followed path only in\nopt->diffopt.pathspec, so in case the commit history is non-linear, and\nmultiple parents have renames to the followed path, then the end result\nisn't really defined: the first commit that happens to be visited in one\nof the parents update opt->diffopt.pathspec, and from that point, only\nthat updated path is visited.\n\nFix the problem by introducing a commit -> path map\n(follow_pathspec_slab) that stores what will be a path to follow when\nvisiting that parent. At the top of log_tree_commit(), if the slab has\nan entry for this commit, we replace opt->diffopt.pathspec with a path\nfrom this entry, so the correct path is followed, even if an unrelated\nsub-tree changed the path to be followed to something else. After\nlog_tree_diff() runs, we record each parent's path in the slab. As a\nresult, the walk order doesn't matter, which was exactly the source of\nproblems previously.\n\nThis helps with subtree merges (rename happens inside the merge commit),\nbut also fixes the general case when the rename happens in the history\nof parents, not in the merge commit itself.\n\nSigned-off-by: Miklos Vajna <vmiklos@collabora.com>\n---\n\nHi Junio,\n\nI noticed that merging 'mv/log-follow-mergy' to 'master' results in a\n(simple) conflict since commit 42d960748e (line-log: integrate -L output\nwith the standard log-tree pipeline, 2026-05-28), this is an updated\nversion that resolves the conflict.\n\nPlease replace the content of that branch with this patch.\n\nThanks,\n\nMiklos\n\n Documentation/config/log.adoc |   3 +-\n log-tree.c                    | 116 ++++++++++++++++++++++++++++++\n log-tree.h                    |   1 +\n revision.c                    |   2 +\n revision.h                    |   4 ++\n t/meson.build                 |   1 +\n t/t4219-log-follow-merge.sh   | 129 ++++++++++++++++++++++++++++++++++\n 7 files changed, 254 insertions(+), 2 deletions(-)\n create mode 100755 t/t4219-log-follow-merge.sh\n\ndiff --git a/Documentation/config/log.adoc b/Documentation/config/log.adoc\nindex f20cc25cd7..757a7be196 100644\n--- a/Documentation/config/log.adoc\n+++ b/Documentation/config/log.adoc\n@@ -53,8 +53,7 @@ This is the same as the `--decorate` option of the `git log`.\n `log.follow`::\n \tIf `true`, `git log` will act as if the `--follow` option was used when\n \ta single <path> is given.  This has the same limitations as `--follow`,\n-\ti.e. it cannot be used to follow multiple files and does not work well\n-\ton non-linear history.\n+\ti.e. it cannot be used to follow multiple files.\n \n `log.graphColors`::\n \tA list of colors, separated by commas, that can be used to draw\ndiff --git a/log-tree.c b/log-tree.c\nindex 88b3019293..83a3c4bf9b 100644\n--- a/log-tree.c\n+++ b/log-tree.c\n@@ -3,6 +3,7 @@\n \n #include \"git-compat-util.h\"\n #include \"commit-reach.h\"\n+#include \"commit-slab.h\"\n #include \"config.h\"\n #include \"diff.h\"\n #include \"diffcore.h\"\n@@ -1089,6 +1090,96 @@ static int do_remerge_diff(struct rev_info *opt,\n \treturn !opt->loginfo;\n }\n \n+/* Per-commit path storage for --follow across merges */\n+define_commit_slab(follow_pathspec_slab, char *);\n+\n+static const char *pathspec_single_path(const struct pathspec *ps)\n+{\n+\tif (ps->nr != 1)\n+\t\treturn NULL;\n+\treturn ps->items[0].match;\n+}\n+\n+static void set_pathspec_to_single_path(struct pathspec *ps, const char *path)\n+{\n+\tconst char *paths[2] = { path, NULL };\n+\n+\tclear_pathspec(ps);\n+\tparse_pathspec(ps,\n+\t\t       PATHSPEC_ALL_MAGIC & ~PATHSPEC_LITERAL,\n+\t\t       PATHSPEC_LITERAL_PATH, \"\", paths);\n+}\n+\n+static void remember_follow_pathspec(struct rev_info *opt,\n+\t\t\t\t     struct commit *c, const char *path)\n+{\n+\tchar **slot;\n+\n+\tif (!path)\n+\t\treturn;\n+\tif (!opt->follow_pathspec_slab) {\n+\t\topt->follow_pathspec_slab = xmalloc(sizeof(*opt->follow_pathspec_slab));\n+\t\tinit_follow_pathspec_slab(opt->follow_pathspec_slab);\n+\t}\n+\tslot = follow_pathspec_slab_at(opt->follow_pathspec_slab, c);\n+\tif (*slot && !strcmp(*slot, path))\n+\t\treturn;\n+\tfree(*slot);\n+\t*slot = xstrdup(path);\n+}\n+\n+static const char *recall_follow_pathspec(struct rev_info *opt,\n+\t\t\t\t\t  struct commit *c)\n+{\n+\tchar **slot;\n+\n+\tif (!opt->follow_pathspec_slab)\n+\t\treturn NULL;\n+\tslot = follow_pathspec_slab_peek(opt->follow_pathspec_slab, c);\n+\treturn slot ? *slot : NULL;\n+}\n+\n+static void free_follow_pathspec_slot(char **slot)\n+{\n+\tFREE_AND_NULL(*slot);\n+}\n+\n+void release_follow_pathspec_slab(struct rev_info *opt)\n+{\n+\tif (!opt->follow_pathspec_slab)\n+\t\treturn;\n+\tdeep_clear_follow_pathspec_slab(opt->follow_pathspec_slab,\n+\t\t\t\t\tfree_follow_pathspec_slot);\n+\tFREE_AND_NULL(opt->follow_pathspec_slab);\n+}\n+\n+/* Compute a path to follow in parent, if there is one */\n+static void propagate_follow_pathspec_to_parent(struct rev_info *opt,\n+\t\t\t\t\t\tstruct commit *commit,\n+\t\t\t\t\t\tstruct commit *parent)\n+{\n+\tstruct diff_options diff_opts;\n+\tconst char *path;\n+\n+\tparse_commit_or_die(parent);\n+\trepo_diff_setup(opt->diffopt.repo, &diff_opts);\n+\tcopy_pathspec(&diff_opts.pathspec, &opt->diffopt.pathspec);\n+\tdiff_opts.flags.recursive = 1;\n+\tdiff_opts.flags.follow_renames = 1;\n+\tdiff_opts.output_format = DIFF_FORMAT_NO_OUTPUT;\n+\tdiff_setup_done(&diff_opts);\n+\tdiff_tree_oid(get_commit_tree_oid(parent),\n+\t\t      get_commit_tree_oid(commit),\n+\t\t      \"\", &diff_opts);\n+\n+\tpath = pathspec_single_path(&diff_opts.pathspec);\n+\tif (path)\n+\t\tremember_follow_pathspec(opt, parent, path);\n+\n+\tdiff_queue_clear(&diff_queued_diff);\n+\tdiff_free(&diff_opts);\n+}\n+\n /*\n  * Show the diff of a commit.\n  *\n@@ -1185,6 +1276,16 @@ int log_tree_commit(struct rev_info *opt, struct commit *commit)\n \topt->loginfo = &log;\n \topt->diffopt.no_free = 1;\n \n+\t/* Any recorded path for this commit? If so, restore it */\n+\tif (opt->diffopt.flags.follow_renames) {\n+\t\tconst char *stored = recall_follow_pathspec(opt, commit);\n+\t\tif (stored) {\n+\t\t\tconst char *current = pathspec_single_path(&opt->diffopt.pathspec);\n+\t\t\tif (!current || strcmp(current, stored))\n+\t\t\t\tset_pathspec_to_single_path(&opt->diffopt.pathspec, stored);\n+\t\t}\n+\t}\n+\n \tif (opt->track_linear && !opt->linear && !opt->reverse_output_stage)\n \t\tfprintf(opt->diffopt.file, \"\\n%s\\n\", opt->break_bar);\n \tshown = log_tree_diff(opt, commit, &log);\n@@ -1197,6 +1298,21 @@ int log_tree_commit(struct rev_info *opt, struct commit *commit)\n \t\tfprintf(opt->diffopt.file, \"\\n%s\\n\", opt->break_bar);\n \tif (shown)\n \t\tshow_diff_of_diff(opt);\n+\n+\t/* Record what path each parent of this commit should use */\n+\tif (opt->diffopt.flags.follow_renames) {\n+\t\tstruct commit_list *parents = get_saved_parents(opt, commit);\n+\t\tif (parents && parents->next) {\n+\t\t\tstruct commit_list *p;\n+\t\t\tfor (p = parents; p; p = p->next)\n+\t\t\t\tpropagate_follow_pathspec_to_parent(opt, commit,\n+\t\t\t\t\t\t\t\t    p->item);\n+\t\t} else if (parents) {\n+\t\t\tremember_follow_pathspec(opt, parents->item,\n+\t\t\t\tpathspec_single_path(&opt->diffopt.pathspec));\n+\t\t}\n+\t}\n+\n \topt->loginfo = NULL;\n \tmaybe_flush_or_die(opt->diffopt.file, \"stdout\");\n \topt->diffopt.no_free = no_free;\ndiff --git a/log-tree.h b/log-tree.h\nindex 07924be8bc..e8679b6c4a 100644\n--- a/log-tree.h\n+++ b/log-tree.h\n@@ -26,6 +26,7 @@ struct decoration_options {\n int parse_decorate_color_config(const char *var, const char *slot_name, const char *value);\n int log_tree_diff_flush(struct rev_info *);\n int log_tree_commit(struct rev_info *, struct commit *);\n+void release_follow_pathspec_slab(struct rev_info *);\n void show_log(struct rev_info *opt);\n void format_decorations(struct strbuf *sb, const struct commit *commit,\n \t\t\tenum git_colorbool use_color, const struct decoration_options *opts);\ndiff --git a/revision.c b/revision.c\nindex e91d7e1f11..0c95edef59 100644\n--- a/revision.c\n+++ b/revision.c\n@@ -26,6 +26,7 @@\n #include \"decorate.h\"\n #include \"string-list.h\"\n #include \"line-log.h\"\n+#include \"log-tree.h\"\n #include \"mailmap.h\"\n #include \"commit-slab.h\"\n #include \"cache-tree.h\"\n@@ -3304,6 +3305,7 @@ void release_revisions(struct rev_info *revs)\n \tline_log_free(revs);\n \toidset_clear(&revs->missing_commits);\n \trelease_revisions_bloom_keyvecs(revs);\n+\trelease_follow_pathspec_slab(revs);\n }\n \n static void add_child(struct rev_info *revs, struct commit *parent, struct commit *child)\ndiff --git a/revision.h b/revision.h\nindex 00c392be37..569b3fa1cb 100644\n--- a/revision.h\n+++ b/revision.h\n@@ -66,6 +66,7 @@ struct repository;\n struct rev_info;\n struct string_list;\n struct saved_parents;\n+struct follow_pathspec_slab;\n struct bloom_keyvec;\n struct bloom_filter_settings;\n struct option;\n@@ -363,6 +364,9 @@ struct rev_info {\n \t/* copies of the parent lists, for --full-diff display */\n \tstruct saved_parents *saved_parents_slab;\n \n+\t/* per-commit pathspec for --follow across merges */\n+\tstruct follow_pathspec_slab *follow_pathspec_slab;\n+\n \tstruct commit_list *previous_parents;\n \tstruct commit_list *ancestry_path_bottoms;\n \tconst char *break_bar;\ndiff --git a/t/meson.build b/t/meson.build\nindex 3219264fe7..b6ac49b443 100644\n--- a/t/meson.build\n+++ b/t/meson.build\n@@ -576,6 +576,7 @@ integration_tests = [\n   't4215-log-skewed-merges.sh',\n   't4216-log-bloom.sh',\n   't4217-log-limit.sh',\n+  't4219-log-follow-merge.sh',\n   't4252-am-options.sh',\n   't4253-am-keep-cr-dos.sh',\n   't4254-am-corrupt.sh',\ndiff --git a/t/t4219-log-follow-merge.sh b/t/t4219-log-follow-merge.sh\nnew file mode 100755\nindex 0000000000..e370f82955\n--- /dev/null\n+++ b/t/t4219-log-follow-merge.sh\n@@ -0,0 +1,129 @@\n+#!/bin/sh\n+\n+test_description='Test --follow follows renames across merges'\n+\n+GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME=master\n+export GIT_TEST_DEFAULT_INITIAL_BRANCH_NAME\n+\n+. ./test-lib.sh\n+\n+test_expect_success 'setup subtree-merged repository' '\n+\tgit init inner &&\n+\techo inner >inner/inner.txt &&\n+\tgit -C inner add inner.txt &&\n+\tgit -C inner commit -m \"inner init\" &&\n+\n+\tgit init outer &&\n+\techo outer >outer/outer.txt &&\n+\tgit -C outer add outer.txt &&\n+\tgit -C outer commit -m \"outer init\" &&\n+\n+\tgit -C outer fetch ../inner master &&\n+\tgit -C outer merge -s ours --no-commit --allow-unrelated-histories \\\n+\t\tFETCH_HEAD &&\n+\tgit -C outer read-tree --prefix=inner/ -u FETCH_HEAD &&\n+\tgit -C outer commit -m \"Merge inner repo into inner/ subdirectory\"\n+'\n+\n+test_expect_success '--follow finds the pre-merge commit through a subtree merge' '\n+\tgit -C outer log --follow --pretty=tformat:%s inner/inner.txt >actual &&\n+\techo \"inner init\" >expect &&\n+\ttest_cmp expect actual\n+'\n+\n+test_expect_success 'setup merge of two branches that both renamed a file to README' '\n+\tgit init foo &&\n+\tmkdir foo/foo &&\n+\techo \"foo readme\" >foo/foo/README &&\n+\tgit -C foo add foo/README &&\n+\tgit -C foo commit -m \"add foo README\" &&\n+\n+\tgit -C foo mv foo/README README &&\n+\tgit -C foo commit -m \"promote foo README to toplevel\" &&\n+\n+\techo \"foo c\" >foo/foo.c &&\n+\tgit -C foo add foo.c &&\n+\tgit -C foo commit -m \"add foo C impl\" &&\n+\n+\tgit init bar &&\n+\tmkdir bar/bar &&\n+\techo \"bar readme\" >bar/bar/README &&\n+\tgit -C bar add bar/README &&\n+\tgit -C bar commit -m \"add bar README\" &&\n+\n+\tgit -C bar mv bar/README README &&\n+\tgit -C bar commit -m \"promote bar README to toplevel\" &&\n+\n+\techo \"bar c\" >bar/bar.c &&\n+\tgit -C bar add bar.c &&\n+\tgit -C bar commit -m \"add bar C impl\" &&\n+\n+\tgit -C foo fetch ../bar master &&\n+\tgit -C foo merge -s ours --no-commit --allow-unrelated-histories \\\n+\t\tFETCH_HEAD &&\n+\tgit -C foo checkout FETCH_HEAD -- bar.c &&\n+\tgit -C foo commit -m \"merge bar into foo\"\n+'\n+\n+test_expect_success '--follow follows renames across both sides of a merge' '\n+\tgit -C foo log --follow --pretty=tformat:%s README >actual &&\n+\tsort actual >actual.sorted &&\n+\tcat >expect <<-\\EOF &&\n+\tadd bar README\n+\tadd foo README\n+\tpromote bar README to toplevel\n+\tpromote foo README to toplevel\n+\tEOF\n+\ttest_cmp expect actual.sorted\n+'\n+\n+test_expect_success 'setup diamond with renames on both sides of a fork' '\n+\tgit init diamond &&\n+\ttest_lines=\"line 1\\nline 2\\nline 3\\nline 4\\nline 5\\n\" &&\n+\n+\tprintf \"$test_lines\" >diamond/path0 &&\n+\tgit -C diamond add path0 &&\n+\tgit -C diamond commit -m \"A: add path0\" &&\n+\n+\tgit -C diamond checkout -b upper &&\n+\tprintf \"line 1\\nline 2\\nline 3 modified by B\\nline 4\\nline 5\\n\" \\\n+\t\t>diamond/path0 &&\n+\tgit -C diamond commit -am \"B: modify path0 on upper\" &&\n+\tgit -C diamond mv path0 path1 &&\n+\tgit -C diamond commit -m \"X: rename path0 to path1\" &&\n+\n+\tgit -C diamond checkout -b lower master &&\n+\tprintf \"line 1\\nline 2\\nline 3 modified by C\\nline 4\\nline 5\\n\" \\\n+\t\t>diamond/path0 &&\n+\tgit -C diamond commit -am \"C: modify path0 on lower\" &&\n+\tgit -C diamond mv path0 path2 &&\n+\tgit -C diamond commit -m \"Y: rename path0 to path2\" &&\n+\n+\tgit -C diamond checkout upper &&\n+\tgit -C diamond merge -s ours --no-commit lower &&\n+\tgit -C diamond rm path1 &&\n+\tprintf \"line 1\\nline 2\\nline 3 merged\\nline 4\\nline 5\\n\" \\\n+\t\t>diamond/path &&\n+\tgit -C diamond add path &&\n+\tgit -C diamond commit -m \"M: merge with rename to path\" &&\n+\n+\tprintf \"line 1\\nline 2\\nline 3 merged again\\nline 4\\nline 5\\n\" \\\n+\t\t>diamond/path &&\n+\tgit -C diamond commit -am \"Z: modify path\"\n+'\n+\n+test_expect_success '--follow follows renames through a fork in a single history' '\n+\tgit -C diamond log --follow --pretty=tformat:%s path >actual &&\n+\tsort actual >actual.sorted &&\n+\tcat >expect <<-\\EOF &&\n+\tA: add path0\n+\tB: modify path0 on upper\n+\tC: modify path0 on lower\n+\tX: rename path0 to path1\n+\tY: rename path0 to path2\n+\tZ: modify path\n+\tEOF\n+\ttest_cmp expect actual.sorted\n+'\n+\n+test_done\n-- \n2.51.0\n\n"},{"id":"546175","messageId":"xmqq1pdy4udg.fsf@gitster.g","threadId":"65619","inReplyTo":"ajjU4w2B0NlZffw1@collabora.com","subject":"Re: [PATCH v4] log: improve --follow following renames for non-linear history","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-06-22T12:44:43Z","receivedAt":"2026-06-22T12:44:45Z","isPatch":true,"body":"Miklos Vajna <vmiklos@collabora.com> writes:\n\n> I noticed that merging 'mv/log-follow-mergy' to 'master' results in a\n> (simple) conflict since commit 42d960748e (line-log: integrate -L output\n> with the standard log-tree pipeline, 2026-05-28), this is an updated\n> version that resolves the conflict.\n> ...\n> Please replace the content of that branch with this patch.\n\nIf there are changes of substance, polishing with new iterations is\nwelcome even without any code change (e.g., clarifying the proposed\nlog message or documentation to help future readers understand what\nwent on in this patch would count), but as long as the resolution\nthat is in my tree (as a part of 'seen') exactly matches what your\nupdate contains (meaning: rerere will do the same correct resolution\nwhen the topic gets merged to 'master' anyway) and the conflict is\ntrivial to resolve by hand for others, which seems to be the case\nhere,\n\n    $ git checkout --detach master\n    $ git -c rerere.autoupdate merge mv/log-follow-mergy\n    $ git commit --no-edit\n    $ HERE=$(git rev-parse HEAD)\n    $ git reset --hard HEAD^ ;# back at 'master'\n    $ git am $this_message\n    $ git diff $HERE ;# shows nothing\n    $ git range-diff master..$HERE master..HEAD ;# no change in the log message\n\nI'd prefer not to see such a reroll.\n\nThanks.\n"},{"id":"546228","messageId":"ajpuWSUiQ6CRV2Kv@collabora.com","threadId":"65619","inReplyTo":"xmqq1pdy4udg.fsf@gitster.g","subject":"Re: [PATCH v4] log: improve --follow following renames for non-linear history","fromName":"Miklos Vajna","fromEmail":"vmiklos@collabora.com","sentAt":"2026-06-23T11:30:33Z","receivedAt":"2026-06-23T11:30:49Z","isPatch":true,"body":"Hi Junio,\n\nOn Mon, Jun 22, 2026 at 05:44:43AM -0700, Junio C Hamano <gitster@pobox.com> wrote:\n> went on in this patch would count), but as long as the resolution\n> that is in my tree (as a part of 'seen') exactly matches what your\n> update contains (meaning: rerere will do the same correct resolution\n> when the topic gets merged to 'master' anyway) and the conflict is\n> trivial to resolve by hand for others\n\nI see, I'll keep that in mind for the future.\n\nThanks,\n\nMiklos\n"}]}