# [BUG] `rerere remaining` skips consecutive conflicted paths

2 messages from 2026-09-17 to 2026-09-18. Participants: Mikko Rantalainen, Junio C Hamano.
Thread: https://gitlist.dev/t/66339

## Mikko Rantalainen, 2026-09-17 08:56

Subject: [BUG] `rerere remaining` skips consecutive conflicted paths
Message-ID: <32062ff9-6dfc-4452-b8f3-66881c3957cd@peda.net>

```
Hi,

I found what appears to be a bug in `git rerere remaining` which can
also cause `git mergetool` to exit successfully while unresolved
conflicts still remain.

I originally encountered this during a large rebase. Some conflicts were
reported by `git mergetool` like this:

ˋˋˋ
Deleted merge conflict for 'some/path':
   {local}: deleted
   {remote}: deleted
Use (m)odified or (d)eleted file, or (a)bort?
ˋˋˋ

Choosing `d` resolved that path, but `git mergetool` then exited
successfully even though additional unresolved paths remained. Running
`git mergetool` again presented the next such path.

`git mergetool -- .` processes all of them in one invocation, which
led me to `git rerere remaining`.

It appears that `git rerere remaining` skips consecutive conflicted
paths when each path has only a stage-1 index entry.

For example, if the unmerged index contains:

ˋˋˋ
100644 <object> 1	a
100644 <object> 1	b
ˋˋˋ

then:

ˋˋˋ
git diff --name-only --diff-filter=U
ˋˋˋ

reports:

ˋˋˋ
a
b
ˋˋˋ

but:

ˋˋˋ
git rerere remaining
ˋˋˋ

reports only:

ˋˋˋ
a
ˋˋˋ

After resolving `a`, invoking `git rerere remaining` again reports `b`.

I then used ChatGPT Sol High to look for possible causes...

The issue is probably  caused by `check_one_conflict()` in `rerere.c.
There is currently a loop of the form:

ˋˋˋ
*type = PUNTED;
while (i < istate->cache_nr && ce_stage(istate->cache[i]) == 1)
         i++;
ˋˋˋ

According to ChatGPT, this is probably intended to skip multiple stage-1
entries belonging to the same conflicted pathname, but it also skips a
stage-1 entry belonging to the next pathname.

The loop may need an additional same-path check, maybe
something like:

ˋˋˋ
while (i < istate->cache_nr &&
        ce_stage(istate->cache[i]) == 1 &&
        ce_same_name(e, istate->cache[i]))
         i++;
ˋˋˋ

I have not checked whether `ce_same_name()` is necessarily the
preferred helper here, so this is only a possible fix rather than
a proposed patch.

The effect becomes visible through `git mergetool` because, when rerere
state exists and no explicit pathspec is supplied, `git mergetool`
obtains the paths to process from:

ˋˋˋ
git rerere remaining
ˋˋˋ

Thus only the first of a sequence of these conflicts is given to the
mergetool. It resolves that path and exits with status 0, although
other unmerged index entries still exist.

Giving an explicit pathspec avoids that path-selection logic:

ˋˋˋ
git mergetool -- .
ˋˋˋ

This was an effective workaround for the actual rebase I had to do.


Here is a minimized reproducer. It uses a rebase with two files
renamed to different destinations on the two histories. After resolving
the destination-side conflicts, the two original source paths are left
as consecutive stage-1-only conflicts.

It reproduces the problem on Ubuntu 24.04 LTS using git version 2.43.0.

Run this in an empty directory with bash:


ˋˋˋ
#!/bin/bash
set -eu

test ! -e .git || {
     echo "ERROR: .git already exists" >&2
     exit 1
}

git init -q -b main

git config user.name "Bug Reproducer"
git config user.email "reproducer@example.invalid"

git config rerere.enabled true

# Avoid trying to start a graphical merge tool. This command should not
# actually be invoked for the delete/delete conflicts below.
git config merge.tool dummy
git config mergetool.dummy.cmd true
git config mergetool.dummy.trustExitCode true

printf 'file a\n' > a
printf 'file b\n' > b
git add a b
git commit -qm 'base'

git branch topic

mkdir z-main
git mv a z-main/a
git mv b z-main/b
git commit -qm 'main: move files'

git switch -q topic

mkdir z-topic
git mv a z-topic/a
git mv b z-topic/b
git commit -qm 'topic: move files differently'

set +e
git rebase main >/dev/null 2>&1
rebase_rc=$?
set -e

if test "$rebase_rc" -eq 0; then
     echo "ERROR: rebase unexpectedly succeeded" >&2
     exit 1
fi

# Resolve the destination paths while leaving the original source paths
# unresolved.
git add z-main/a z-main/b z-topic/a z-topic/b

echo
echo "=== Unmerged index entries ==="
git ls-files -u

echo
echo "Expected: two stage-1-only entries:"
echo "  ... 1 a"
echo "  ... 1 b"

echo
echo "=== All unresolved paths according to git diff ==="
git diff --name-only --diff-filter=U

echo
echo "Expected:"
echo "  a"
echo "  b"

echo
echo "=== Paths according to 'git rerere remaining' ==="
git rerere remaining

echo
echo "BUG: on affected versions this incorrectly prints only:"
echo "  a"

echo
echo "=== Running plain 'git mergetool' and answering d twice ==="

set +e
printf 'd\nd\n' | git mergetool
mergetool_rc=$?
set -e

echo
echo "git mergetool exit status: $mergetool_rc"

echo
echo "=== Unresolved paths after git mergetool ==="
remaining="$(git diff --name-only --diff-filter=U)"
printf '%s\n' "$remaining"

echo
if test "$mergetool_rc" -eq 0 && test "$remaining" = "b"; then
     echo "BUG REPRODUCED:"
     echo "  git mergetool exited successfully after resolving only 'a',"
     echo "  while unresolved path 'b' remains."
     exit 0
else
     echo "Bug was NOT reproduced in the expected form."
     exit 1
fi
ˋˋˋ


On an affected version, the important part of the output is:

ˋˋˋ
=== Unmerged index entries ===
100644 <object> 1	a
100644 <object> 1	b

=== All unresolved paths according to git diff ===
a
b

=== Paths according to 'git rerere remaining' ===
a
ˋˋˋ

(The 'git rerere remaining' should list both `a` and `b`.)

Plain `git mergetool` then processes only `a`, returns status 0, and
leaves `b` unresolved. If there were multiple files remaining,
running `git mergetool` again would resolve one additional file and
exit with 0 again. I originally had a rebase where I had about 50
files remaining and this was getting tedious fast.

I reproduced the original problem in my real rebase and also
reproduced it independently with the script above using git
version 2.43.0.

I haven't tried compiling the latest Git source to verify the issue
or reproducing script on tip. The checked `git blame` and related
code in git/rerere.c hasn't been changed during the last 8 years
so I would assume the exact same issue would happen in tip
version, too.

My interpretation is that the primary bug is in `rerere remaining`
missing conflicts. The `git mergetool` behavior is then just a
consequence of using the incomplete output of
`git rerere remaining` as its path list.

I don't consider any code in this mail as copyrightable because it
was mostly written by AI after my prompting but here's signed of
line just to be sure in case the code is worth using. Consider
this to cover the whole email too, in case somebody wants to use
any text in this mail for the commit that fixes the issue.

Signed-off-by: Mikko Rantalainen <mikko.rantalainen@peda.net>

-- 
Mikko

```

## Junio C Hamano, 2026-09-18 12:30

Subject: Re: [BUG] `rerere remaining` skips consecutive conflicted paths
Message-ID: <xmqqwlsioi6c.fsf@gitster.g>
In-Reply-To: <32062ff9-6dfc-4452-b8f3-66881c3957cd@peda.net>

```
Mikko Rantalainen <mikko.rantalainen@peda.net> writes:

> The issue is probably  caused by `check_one_conflict()` in `rerere.c.
> There is currently a loop of the form:
>
> ˋˋˋ
> *type = PUNTED;
> while (i < istate->cache_nr && ce_stage(istate->cache[i]) == 1)
>          i++;
> ˋˋˋ
>
> According to ChatGPT, this is probably intended to skip multiple stage-1
> entries belonging to the same conflicted pathname, but it also skips a
> stage-1 entry belonging to the next pathname.
>
> The loop may need an additional same-path check, maybe
> something like:
>
> ˋˋˋ
> while (i < istate->cache_nr &&
>         ce_stage(istate->cache[i]) == 1 &&
>         ce_same_name(e, istate->cache[i]))
>          i++;
> ˋˋˋ
>
> I have not checked whether `ce_same_name()` is necessarily the
> preferred helper here, so this is only a possible fix rather than
> a proposed patch.

Spot on, I would say, even though I find that it is a bit iffy for
the merge machinery to leave a "delete-delete" conflict in the first
place.

The idea of that function is to return for the current path if we
(1) don't need to do anything as it is cleanly resolved (RESOLVED),
(2) know it is conflicting but we cannot handle (PUNTED), or (3)
know it is conflicting and we are willing to handle (THREE_STAGED).

For (1), we only need to see that the current entry is resolved
(because in istate->cache[], resolved entry for a single path
appears only once) and return, telling the caller that we consumed
only one entry.  For THREE_STAGED, we would want to see a stage 2
(i.e., ours) entry followed by a stage 3 (i.e., theirs) entry, and
the way the code does so is to skip over stage 1 entries for the
same path, and we must see stage 2 and then stage 3 entries after
that.  Again in istate->cache[], by definition more than one stage 2
entries (i.e., "ours") cannot exist for a single path, so we check
if the first entry after skipping over the stage 1 entries (i.e.,
"common") is a stage 2 entry and immediately after that is a stage 3
entry, and the stage 3 entry has the same name as the first entry
we started looking at upon entry to the function.  And to conclude
one iteration, we skip the entries of the same name at the end.

And as you pointed out, the same "must be the same name" check must
be done also while we are skipping over stage 1 entries.  If you
have a sequence of stage 1 entries for different paths, all of them
would probably be skipped over at once.

Note that the low-level merge machinery and rerere machinery are
both prepared to see multiple stage #1 and stage #3 entries for a
same path, even though multiple stage #0 and stage #2 entries is a
sign of index corruption.  The "resolve" merge strategy will use
multiple stage #1 entries when dealing with a criss-cross merges,
where multiple merge-bases exist.  Being prepared for multiple stage
#3 entries is purely for philosophical consistency---an Octopus merge
ought to be representing more than one "their" branches as stage #3
entries, even though the current implementation of octopus merge of
N branches happens to do N pair-wise merges and do not require
multiple stage #3 entries.

 rerere.c | 6 +++++-
 1 file changed, 5 insertions(+), 1 deletion(-)

diff --git c/rerere.c w/rerere.c
index 1c3745d9e3..296f254c1e 100644
--- c/rerere.c
+++ w/rerere.c
@@ -499,7 +499,11 @@ static int check_one_conflict(struct index_state *istate, int i, int *type)
 	}
 
 	*type = PUNTED;
-	while (i < istate->cache_nr && ce_stage(istate->cache[i]) == 1)
+
+	/* First ignore stage #1 entries */
+	while (i < istate->cache_nr &&
+	       ce_same_name(e, istate->cache[i]) &&
+	       ce_stage(istate->cache[i]) == 1)
 		i++;
 
 	/* Only handle regular files with both stages #2 and #3 */

```
