{"thread":{"id":"66339","subject":"[BUG] `rerere remaining` skips consecutive conflicted paths","startedAt":"2026-09-17T08:56:58Z","lastAt":"2026-09-18T12:30:54Z","messageCount":2,"participants":["Mikko Rantalainen","Junio C Hamano"],"isPatch":false,"patchVersion":null,"patchTotal":null},"messages":[{"id":"552807","messageId":"32062ff9-6dfc-4452-b8f3-66881c3957cd@peda.net","threadId":"66339","inReplyTo":null,"subject":"[BUG] `rerere remaining` skips consecutive conflicted paths","fromName":"Mikko Rantalainen","fromEmail":"mikko.rantalainen@peda.net","sentAt":"2026-09-17T08:56:49Z","receivedAt":"2026-09-17T08:56:58Z","isPatch":false,"body":"Hi,\n\nI found what appears to be a bug in `git rerere remaining` which can\nalso cause `git mergetool` to exit successfully while unresolved\nconflicts still remain.\n\nI originally encountered this during a large rebase. Some conflicts were\nreported by `git mergetool` like this:\n\n```\nDeleted merge conflict for 'some/path':\n   {local}: deleted\n   {remote}: deleted\nUse (m)odified or (d)eleted file, or (a)bort?\n```\n\nChoosing `d` resolved that path, but `git mergetool` then exited\nsuccessfully even though additional unresolved paths remained. Running\n`git mergetool` again presented the next such path.\n\n`git mergetool -- .` processes all of them in one invocation, which\nled me to `git rerere remaining`.\n\nIt appears that `git rerere remaining` skips consecutive conflicted\npaths when each path has only a stage-1 index entry.\n\nFor example, if the unmerged index contains:\n\n```\n100644 <object> 1\ta\n100644 <object> 1\tb\n```\n\nthen:\n\n```\ngit diff --name-only --diff-filter=U\n```\n\nreports:\n\n```\na\nb\n```\n\nbut:\n\n```\ngit rerere remaining\n```\n\nreports only:\n\n```\na\n```\n\nAfter resolving `a`, invoking `git rerere remaining` again reports `b`.\n\nI then used ChatGPT Sol High to look for possible causes...\n\nThe issue is probably  caused by `check_one_conflict()` in `rerere.c.\nThere is currently a loop of the form:\n\n```\n*type = PUNTED;\nwhile (i < istate->cache_nr && ce_stage(istate->cache[i]) == 1)\n         i++;\n```\n\nAccording to ChatGPT, this is probably intended to skip multiple stage-1\nentries belonging to the same conflicted pathname, but it also skips a\nstage-1 entry belonging to the next pathname.\n\nThe loop may need an additional same-path check, maybe\nsomething like:\n\n```\nwhile (i < istate->cache_nr &&\n        ce_stage(istate->cache[i]) == 1 &&\n        ce_same_name(e, istate->cache[i]))\n         i++;\n```\n\nI have not checked whether `ce_same_name()` is necessarily the\npreferred helper here, so this is only a possible fix rather than\na proposed patch.\n\nThe effect becomes visible through `git mergetool` because, when rerere\nstate exists and no explicit pathspec is supplied, `git mergetool`\nobtains the paths to process from:\n\n```\ngit rerere remaining\n```\n\nThus only the first of a sequence of these conflicts is given to the\nmergetool. It resolves that path and exits with status 0, although\nother unmerged index entries still exist.\n\nGiving an explicit pathspec avoids that path-selection logic:\n\n```\ngit mergetool -- .\n```\n\nThis was an effective workaround for the actual rebase I had to do.\n\n\nHere is a minimized reproducer. It uses a rebase with two files\nrenamed to different destinations on the two histories. After resolving\nthe destination-side conflicts, the two original source paths are left\nas consecutive stage-1-only conflicts.\n\nIt reproduces the problem on Ubuntu 24.04 LTS using git version 2.43.0.\n\nRun this in an empty directory with bash:\n\n\n```\n#!/bin/bash\nset -eu\n\ntest ! -e .git || {\n     echo \"ERROR: .git already exists\" >&2\n     exit 1\n}\n\ngit init -q -b main\n\ngit config user.name \"Bug Reproducer\"\ngit config user.email \"reproducer@example.invalid\"\n\ngit config rerere.enabled true\n\n# Avoid trying to start a graphical merge tool. This command should not\n# actually be invoked for the delete/delete conflicts below.\ngit config merge.tool dummy\ngit config mergetool.dummy.cmd true\ngit config mergetool.dummy.trustExitCode true\n\nprintf 'file a\\n' > a\nprintf 'file b\\n' > b\ngit add a b\ngit commit -qm 'base'\n\ngit branch topic\n\nmkdir z-main\ngit mv a z-main/a\ngit mv b z-main/b\ngit commit -qm 'main: move files'\n\ngit switch -q topic\n\nmkdir z-topic\ngit mv a z-topic/a\ngit mv b z-topic/b\ngit commit -qm 'topic: move files differently'\n\nset +e\ngit rebase main >/dev/null 2>&1\nrebase_rc=$?\nset -e\n\nif test \"$rebase_rc\" -eq 0; then\n     echo \"ERROR: rebase unexpectedly succeeded\" >&2\n     exit 1\nfi\n\n# Resolve the destination paths while leaving the original source paths\n# unresolved.\ngit add z-main/a z-main/b z-topic/a z-topic/b\n\necho\necho \"=== Unmerged index entries ===\"\ngit ls-files -u\n\necho\necho \"Expected: two stage-1-only entries:\"\necho \"  ... 1 a\"\necho \"  ... 1 b\"\n\necho\necho \"=== All unresolved paths according to git diff ===\"\ngit diff --name-only --diff-filter=U\n\necho\necho \"Expected:\"\necho \"  a\"\necho \"  b\"\n\necho\necho \"=== Paths according to 'git rerere remaining' ===\"\ngit rerere remaining\n\necho\necho \"BUG: on affected versions this incorrectly prints only:\"\necho \"  a\"\n\necho\necho \"=== Running plain 'git mergetool' and answering d twice ===\"\n\nset +e\nprintf 'd\\nd\\n' | git mergetool\nmergetool_rc=$?\nset -e\n\necho\necho \"git mergetool exit status: $mergetool_rc\"\n\necho\necho \"=== Unresolved paths after git mergetool ===\"\nremaining=\"$(git diff --name-only --diff-filter=U)\"\nprintf '%s\\n' \"$remaining\"\n\necho\nif test \"$mergetool_rc\" -eq 0 && test \"$remaining\" = \"b\"; then\n     echo \"BUG REPRODUCED:\"\n     echo \"  git mergetool exited successfully after resolving only 'a',\"\n     echo \"  while unresolved path 'b' remains.\"\n     exit 0\nelse\n     echo \"Bug was NOT reproduced in the expected form.\"\n     exit 1\nfi\n```\n\n\nOn an affected version, the important part of the output is:\n\n```\n=== Unmerged index entries ===\n100644 <object> 1\ta\n100644 <object> 1\tb\n\n=== All unresolved paths according to git diff ===\na\nb\n\n=== Paths according to 'git rerere remaining' ===\na\n```\n\n(The 'git rerere remaining' should list both `a` and `b`.)\n\nPlain `git mergetool` then processes only `a`, returns status 0, and\nleaves `b` unresolved. If there were multiple files remaining,\nrunning `git mergetool` again would resolve one additional file and\nexit with 0 again. I originally had a rebase where I had about 50\nfiles remaining and this was getting tedious fast.\n\nI reproduced the original problem in my real rebase and also\nreproduced it independently with the script above using git\nversion 2.43.0.\n\nI haven't tried compiling the latest Git source to verify the issue\nor reproducing script on tip. The checked `git blame` and related\ncode in git/rerere.c hasn't been changed during the last 8 years\nso I would assume the exact same issue would happen in tip\nversion, too.\n\nMy interpretation is that the primary bug is in `rerere remaining`\nmissing conflicts. The `git mergetool` behavior is then just a\nconsequence of using the incomplete output of\n`git rerere remaining` as its path list.\n\nI don't consider any code in this mail as copyrightable because it\nwas mostly written by AI after my prompting but here's signed of\nline just to be sure in case the code is worth using. Consider\nthis to cover the whole email too, in case somebody wants to use\nany text in this mail for the commit that fixes the issue.\n\nSigned-off-by: Mikko Rantalainen <mikko.rantalainen@peda.net>\n\n-- \nMikko\n"},{"id":"552856","messageId":"xmqqwlsioi6c.fsf@gitster.g","threadId":"66339","inReplyTo":"32062ff9-6dfc-4452-b8f3-66881c3957cd@peda.net","subject":"Re: [BUG] `rerere remaining` skips consecutive conflicted paths","fromName":"Junio C Hamano","fromEmail":"gitster@pobox.com","sentAt":"2026-09-18T12:30:51Z","receivedAt":"2026-09-18T12:30:54Z","isPatch":false,"body":"Mikko Rantalainen <mikko.rantalainen@peda.net> writes:\n\n> The issue is probably  caused by `check_one_conflict()` in `rerere.c.\n> There is currently a loop of the form:\n>\n> ```\n> *type = PUNTED;\n> while (i < istate->cache_nr && ce_stage(istate->cache[i]) == 1)\n>          i++;\n> ```\n>\n> According to ChatGPT, this is probably intended to skip multiple stage-1\n> entries belonging to the same conflicted pathname, but it also skips a\n> stage-1 entry belonging to the next pathname.\n>\n> The loop may need an additional same-path check, maybe\n> something like:\n>\n> ```\n> while (i < istate->cache_nr &&\n>         ce_stage(istate->cache[i]) == 1 &&\n>         ce_same_name(e, istate->cache[i]))\n>          i++;\n> ```\n>\n> I have not checked whether `ce_same_name()` is necessarily the\n> preferred helper here, so this is only a possible fix rather than\n> a proposed patch.\n\nSpot on, I would say, even though I find that it is a bit iffy for\nthe merge machinery to leave a \"delete-delete\" conflict in the first\nplace.\n\nThe idea of that function is to return for the current path if we\n(1) don't need to do anything as it is cleanly resolved (RESOLVED),\n(2) know it is conflicting but we cannot handle (PUNTED), or (3)\nknow it is conflicting and we are willing to handle (THREE_STAGED).\n\nFor (1), we only need to see that the current entry is resolved\n(because in istate->cache[], resolved entry for a single path\nappears only once) and return, telling the caller that we consumed\nonly one entry.  For THREE_STAGED, we would want to see a stage 2\n(i.e., ours) entry followed by a stage 3 (i.e., theirs) entry, and\nthe way the code does so is to skip over stage 1 entries for the\nsame path, and we must see stage 2 and then stage 3 entries after\nthat.  Again in istate->cache[], by definition more than one stage 2\nentries (i.e., \"ours\") cannot exist for a single path, so we check\nif the first entry after skipping over the stage 1 entries (i.e.,\n\"common\") is a stage 2 entry and immediately after that is a stage 3\nentry, and the stage 3 entry has the same name as the first entry\nwe started looking at upon entry to the function.  And to conclude\none iteration, we skip the entries of the same name at the end.\n\nAnd as you pointed out, the same \"must be the same name\" check must\nbe done also while we are skipping over stage 1 entries.  If you\nhave a sequence of stage 1 entries for different paths, all of them\nwould probably be skipped over at once.\n\nNote that the low-level merge machinery and rerere machinery are\nboth prepared to see multiple stage #1 and stage #3 entries for a\nsame path, even though multiple stage #0 and stage #2 entries is a\nsign of index corruption.  The \"resolve\" merge strategy will use\nmultiple stage #1 entries when dealing with a criss-cross merges,\nwhere multiple merge-bases exist.  Being prepared for multiple stage\n#3 entries is purely for philosophical consistency---an Octopus merge\nought to be representing more than one \"their\" branches as stage #3\nentries, even though the current implementation of octopus merge of\nN branches happens to do N pair-wise merges and do not require\nmultiple stage #3 entries.\n\n rerere.c | 6 +++++-\n 1 file changed, 5 insertions(+), 1 deletion(-)\n\ndiff --git c/rerere.c w/rerere.c\nindex 1c3745d9e3..296f254c1e 100644\n--- c/rerere.c\n+++ w/rerere.c\n@@ -499,7 +499,11 @@ static int check_one_conflict(struct index_state *istate, int i, int *type)\n \t}\n \n \t*type = PUNTED;\n-\twhile (i < istate->cache_nr && ce_stage(istate->cache[i]) == 1)\n+\n+\t/* First ignore stage #1 entries */\n+\twhile (i < istate->cache_nr &&\n+\t       ce_same_name(e, istate->cache[i]) &&\n+\t       ce_stage(istate->cache[i]) == 1)\n \t\ti++;\n \n \t/* Only handle regular files with both stages #2 and #3 */\n"}]}