Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Documentation/config/rerere.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,13 @@ rerere.enabled::
enabled if there is an `rr-cache` directory under the

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Patrick Steinhardt wrote on the Git mailing list (how to reply to this email):

On Mon, Sep 14, 2026 at 08:04:20AM +0000, Thomas Bachem via GitGitGadget wrote:
> diff --git a/Documentation/git-rerere.adoc b/Documentation/git-rerere.adoc
> index 4e6ab9a27c..4df653367e 100644
> --- a/Documentation/git-rerere.adoc
> +++ b/Documentation/git-rerere.adoc
> @@ -70,7 +70,9 @@ occurred a long time ago.  By default, unresolved conflicts older
>  than 15 days and resolved conflicts older than 60
>  days are pruned.  These defaults are controlled via the
>  `gc.rerereUnresolved` and `gc.rerereResolved` configuration
> -variables respectively.
> +variables respectively.  If another process holds the rerere lock,
> +for example a merge or rebase that is recording a conflict, `gc`
> +does nothing and says so.

Do we maybe want to drop these examples? I don't feel like they add any
value.

> diff --git a/rerere.c b/rerere.c
> index 3d3bd0db16..7d44f3937c 100644
> --- a/rerere.c
> +++ b/rerere.c
> @@ -33,6 +33,9 @@ static int rerere_enabled = -1;
>  /* automatically update cleanly resolved paths to the index */
>  static int rerere_autoupdate;
>  
> +/* how long to wait for MERGE_RR.lock, in milliseconds */
> +static int rerere_lock_timeout_ms = 1000;
> +
>  #define RR_HAS_POSTIMAGE 1
>  #define RR_HAS_PREIMAGE 2
>  struct rerere_dir {
> @@ -850,6 +853,8 @@ static void git_rerere_config(void)
>  {
>  	repo_config_get_bool(the_repository, "rerere.enabled", &rerere_enabled);
>  	repo_config_get_bool(the_repository, "rerere.autoupdate", &rerere_autoupdate);
> +	repo_config_get_int(the_repository, "rerere.locktimeout",
> +			    &rerere_lock_timeout_ms);
>  	repo_config(the_repository, git_default_config, NULL);
>  }
>  
> @@ -882,12 +887,34 @@ int setup_rerere(struct repository *r, struct string_list *merge_rr, int flags)
>  
>  	if (flags & (RERERE_AUTOUPDATE|RERERE_NOAUTOUPDATE))
>  		rerere_autoupdate = !!(flags & RERERE_AUTOUPDATE);
> -	if (flags & RERERE_READONLY)
> +	if ((flags & RERERE_READONLY) && (flags & RERERE_NOWAIT))
> +		BUG("RERERE_READONLY takes no lock, so RERERE_NOWAIT does not apply");
> +	if (flags & RERERE_READONLY) {
>  		fd = 0;
> -	else
> -		fd = repo_hold_lock_file_for_update(r, &write_lock,
> -						    git_path_merge_rr(r),
> -						    LOCK_DIE_ON_ERROR);
> +	} else {
> +		const char *path = git_path_merge_rr(r);
> +		int lock_flags = LOCK_DIE_ON_ERROR;
> +		long timeout_ms = rerere_lock_timeout_ms;

Here you're using a `long` whereas `rerere_lock_timeout_ms` is an `int`.
Of course we'd ideally use a `long` consistently as that's also what
`repo_hold_lock_file_for_update_timeout()` accepts. But I guess the
reason you didn't is that we don't have `repo_config_get_long()`. So I
guess this is good enough for now.

> @@ -1211,7 +1238,7 @@ void rerere_gc(struct repository *r, struct string_list *rr)
>  	timestamp_t cutoff_resolve = now - 60 * 86400;
>  	struct strbuf buf = STRBUF_INIT;
>  
> -	if (setup_rerere(r, rr, 0) < 0)
> +	if (setup_rerere(r, rr, RERERE_NOWAIT) < 0)
>  		return;
>  
>  	repo_config_get_expiry_in_days(the_repository, "gc.rerereresolved",

I'm not a 100% sold on this change. There's two different scenarios
under which we want to perform garbage collection:

  - As part of auto-maintenance, triggered by Git automatically. Here
    I'm fully aligned that it makes sense to just silently ignore the
    case where we couldn't acquire the lock, as auto-maintenance is done
    on a best-effort basis anyway.

  - As part of `git rerere gc`, which is invoked manually by the user.
    Here I'm less so, as the user has explicitly asked us to garbage
    collect. Sure, we print a warning now, but the exit code does not
    signal that we failed garbage collecting.

So I'd argue that we should discern those two use cases. I think that in
the second use case, we'd probably want to use a timeout and if we fail
to acquire the lock, we should make `git rerere gc` fail with a non-zero
exit code.

Patrick

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Junio C Hamano wrote on the Git mailing list (how to reply to this email):

"Thomas Bachem via GitGitGadget" <gitgitgadget@gmail.com> writes:

> diff --git a/Documentation/config/rerere.adoc b/Documentation/config/rerere.adoc
> index 3a78b5ebb1..30e827f32b 100644
> --- a/Documentation/config/rerere.adoc
> +++ b/Documentation/config/rerere.adoc
> @@ -10,3 +10,11 @@ rerere.enabled::
>  	enabled if there is an `rr-cache` directory under the
>  	`$GIT_DIR`, e.g. if "rerere" was previously used in the
>  	repository.
> +
> +rerere.lockTimeout::

$ git grep -A4 -i '^[^ ]*timeout[a-z]*:' Documentation/

tells me that all "timeout" configuration variables are measured in
milliseconds, which justifies the choice of milliseconds as the unit
for this new variable too.

    Side note.  Only one of the other ones has unit in its name
    (i.e., credentialStore.lockTimeoutMS).  We probably want to give
    it a synonym without MS suffix to make everything uniform.
    #leftoverbits

> +	The length of time, in milliseconds, to wait for the rerere
> +	lock when another process holds it, typically a background
> +	`git rerere gc`.  Value 0 means not to wait at all; -1 means
> +	to wait indefinitely.  Default is 1000 (i.e., wait for 1
> +	second).

OK.

> +     When the time is up, the command fails as it does
> +	for any other lock it cannot take.

Is it necessary to say this?  If we invent a new lock on 'foo' whose
behavior is to wait for N milliseconds and then proceed anyway,
ignoring the lock after the timer expires, we should name such a
setting differently from a simple 'fooLockTimeout'.  This would make
it easier for users to tell the difference, perhaps using
'fooLockBreakTimeout' or something similar.

In any case, we should ensure that we do not have to single out
'rerere.lockTimeout' and describe what happens after the timer
expires.  The timeout behavior on locks should be consistent.  That
may be slightly outside the scope of this topic, but since none of
the configuration variables whose names end with 'timeout' say the
above, leaving it out of this would be a good first step.  We can
leave a '#leftoverbits' task to describe the overall rule for
timeout settings for locks (i.e., "if you still cannot take the lock
after the timeout expires, you will give up and fail") in some
central place to make it clear that the same rule applies to
everyone.


> @@ -882,12 +887,19 @@ int setup_rerere(struct repository *r, struct string_list *merge_rr, int flags)
>  
>  	if (flags & (RERERE_AUTOUPDATE|RERERE_NOAUTOUPDATE))
>  		rerere_autoupdate = !!(flags & RERERE_AUTOUPDATE);
> -	if (flags & RERERE_READONLY)
> +	if (flags & RERERE_READONLY) {
>  		fd = 0;
> -	else
> -		fd = repo_hold_lock_file_for_update(r, &write_lock,
> -						    git_path_merge_rr(r),
> -						    LOCK_DIE_ON_ERROR);
> +	} else {
> +		/*
> +		 * Another process may hold the lock for a while, e.g.
> +		 * "git rerere gc" while it prunes rr-cache, so wait for
> +		 * it instead of dying right away.
> +		 */
> +		fd = repo_hold_lock_file_for_update_timeout(r, &write_lock,
> +							    git_path_merge_rr(r),
> +							    LOCK_DIE_ON_ERROR,
> +							    rerere_lock_timeout_ms);
> +	}
>  	read_rr(r, merge_rr);
>  	return fd;
>  }

OK.  Very straight-forward.

> diff --git a/t/t4200-rerere.sh b/t/t4200-rerere.sh
> index 7bb601e117..7bd92235dc 100755
> --- a/t/t4200-rerere.sh
> +++ b/t/t4200-rerere.sh
> @@ -242,6 +242,59 @@ test_expect_success 'old records rest in peace' '
>  	test_path_is_missing $rr2/preimage
>  '
>  
> +test_expect_success 'a held lock is waited out within rerere.lockTimeout' '
> +	git reset --hard &&
> +	rm -rf $rr &&
> +	test_when_finished "rm -f .git/MERGE_RR.lock" &&
> +	>.git/MERGE_RR.lock &&
> +	{
> +		( sleep 1 && rm -f .git/MERGE_RR.lock ) &
> +	} &&
> +	test_must_fail git -c rerere.lockTimeout=5000 merge first 2>err &&

The backgrounded unlocker sleeps for a second.  Is the idea that,
even in a heavily loaded CI environment, the backgrounded unlocker
will have a sufficient chance to sleep for a second and unlock while
the 5000-millisecond timeout waits for it?

> +	wait &&

Is the idea behind this 'wait' that the 'rerere.locktimeout'
implementation might break in the future and we could reach this
point before the background unlocker has finished sleeping for a
full second?  And we want to ensure it has exited before proceeding
by waiting for it ourselves.  If that is the case, perhaps a comment
is warranted after '&&', such as:

        wait && # just in case the background unlocker is still active

or something similar.

> +	test_grep ! "MERGE_RR" err &&

It is a bit unclear what error message this is looking for.
repo_hold_lock_file_for_update_timeout() is fed the path to
MERGE_RR, and eventually calls unable_to_lock_message() to format
the error message, which starts with "Unable to create '...'" to
state the path.  Is the idea that this message will contain MERGE_RR
as part of that path and we will catch it if we failed to acquire
the lock?

This deserves a short comment to clarify that we are looking for the
lack of "unable to lock" comment, if that is indeed what is
happening.  Or make the string a bit more specific, such as:

	test_grep ! "Unable to create.*MERGE_RR\.lock" err &&

or something along those line.

Should we ensure that there is no leftover pid file by removing it
before creating .git/MERGE_RR.lock, by the way?

> +	test_grep "^=======\$" $rr/preimage

The merge still has to fail (which is ensured by test_must_fail in
the earlier step) and leave the preimage of the conflicted state,
which makes sense.

I'll stop here, but you can grasp the principles used in reviewing
this test and apply them to the remaining tests to ensure they are
clearly written.

Thanks.


> +
> +test_expect_success 'merge fails once rerere.lockTimeout is up' '
> +	git reset --hard &&
> +	rm -rf $rr &&
> +	test_when_finished "rm -f .git/MERGE_RR.lock" &&
> +	>.git/MERGE_RR.lock &&
> +	test_must_fail git -c rerere.lockTimeout=0 merge first 2>err &&
> +	test_grep "Unable to create" err &&
> +	test_grep "^=======\$" a1 &&
> +	test_path_is_missing $rr/preimage
> +'
> +
> +test_expect_success 'rerere, forget, clear and gc fail on a lock they cannot take' '
> +	test_when_finished "rm -f .git/MERGE_RR.lock" &&
> +	>.git/MERGE_RR.lock &&
> +	test_must_fail git -c rerere.lockTimeout=0 rerere 2>err &&
> +	test_grep "Unable to create" err &&
> +	test_must_fail git -c rerere.lockTimeout=0 rerere forget a1 2>err &&
> +	test_grep "Unable to create" err &&
> +	test_must_fail git -c rerere.lockTimeout=0 rerere clear 2>err &&
> +	test_grep "Unable to create" err &&
> +	test_must_fail git -c rerere.lockTimeout=0 rerere gc 2>err &&
> +	test_grep "Unable to create" err
> +'
> +
> +test_expect_success 'rebase --abort fails on a lock it cannot take' '
> +	git reset --hard &&
> +	git checkout -b lock-held-abort third &&
> +	test_when_finished "git checkout third && git branch -D lock-held-abort" &&
> +	test_must_fail git rebase first &&
> +	test_when_finished "rm -f .git/MERGE_RR.lock" &&
> +	>.git/MERGE_RR.lock &&
> +	test_must_fail git -c rerere.lockTimeout=0 rebase --abort 2>err &&
> +	test_grep "Unable to create" err &&
> +	test_path_is_dir .git/rebase-merge &&
> +	rm .git/MERGE_RR.lock &&
> +	git rebase --abort &&
> +	test_path_is_missing .git/rebase-merge
> +'
> +
>  rerere_gc_custom_expiry_test () {
>  	five_days="$1" right_now="$2"
>  	test_expect_success "rerere gc with custom expiry ($five_days, $right_now)" '

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thomas Bachem wrote on the Git mailing list (how to reply to this email):

Hi Junio,

Thanks for the careful review.

On 11/10/2026 02:39, Junio C Hamano wrote:
>> +     When the time is up, the command fails as it does
>> +     for any other lock it cannot take.
>
> Is it necessary to say this?

No, I'll drop it.

> The backgrounded unlocker sleeps for a second.  Is the idea that,
> even in a heavily loaded CI environment, the backgrounded unlocker
> will have a sufficient chance to sleep for a second and unlock while
> the 5000-millisecond timeout waits for it?

Yes, the unlocker can be up to four seconds late. t0610 and t1300 use
the same numbers.

> Is the idea behind this 'wait' that the 'rerere.locktimeout'
> implementation might break in the future and we could reach this
> point before the background unlocker has finished sleeping for a
> full second?  And we want to ensure it has exited before proceeding
> by waiting for it ourselves.  If that is the case, perhaps a comment
> is warranted after '&&', such as:
>
>         wait && # just in case the background unlocker is still active

It is. Instead of the comment, I'll move the wait into
test_when_finished, as pack-refs-tests.sh does, so it runs however
the test ends.

> Or make the string a bit more specific, such as:
>
>         test_grep ! "Unable to create.*MERGE_RR\.lock" err &&

I'll use your pattern there and in the other tests.

> Should we ensure that there is no leftover pid file by removing it
> before creating .git/MERGE_RR.lock, by the way?

I don't think we need to. Git only writes MERGE_RR~pid.lock with
core.lockfilePid set, and t4200 never sets it. Even a stale one would
only change the text after the "Unable to create" line, not whether
git gets the lock.

> I'll stop here, but you can grasp the principles used in reviewing
> this test and apply them to the remaining tests to ensure they are
> clearly written.

Will do.

Thanks,
Thomas

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Junio C Hamano wrote on the Git mailing list (how to reply to this email):

Thomas Bachem <mail@thomasbachem.com> writes:

>>         wait && # just in case the background unlocker is still active
>
> It is. Instead of the comment, I'll move the wait into
> test_when_finished, as pack-refs-tests.sh does, so it runs however
> the test ends.

Ah, that sounds quite sensible.

Thanks.

`$GIT_DIR`, e.g. if "rerere" was previously used in the
repository.

rerere.lockTimeout::
The length of time, in milliseconds, to wait for the rerere
lock when another process holds it, typically a background

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Patrick Steinhardt wrote on the Git mailing list (how to reply to this email):

On Mon, Sep 14, 2026 at 08:04:21AM +0000, Thomas Bachem via GitGitGadget wrote:
> diff --git a/rerere.c b/rerere.c
> index 7d44f3937c..a996d39159 100644
> --- a/rerere.c
> +++ b/rerere.c
> @@ -900,18 +902,26 @@ int setup_rerere(struct repository *r, struct string_list *merge_rr, int flags)
>  		 * Another process may hold the lock for a while, e.g.
>  		 * "git rerere gc" while it prunes rr-cache, so wait for
>  		 * it instead of dying right away.  The gc itself never
> -		 * waits: skipping one of its runs costs nothing.
> +		 * waits: skipping one of its runs costs nothing.  A
> +		 * command that stops at a conflict must not die here
> +		 * either, so it warns and goes on without rerere.
>  		 */
>  		if (flags & RERERE_NOWAIT) {
>  			lock_flags = 0;
>  			timeout_ms = 0;
>  		}
> +		if (flags & RERERE_SKIP_LOCKED)
> +			lock_flags = 0;
>  		fd = repo_hold_lock_file_for_update_timeout(r, &write_lock,
>  							    path, lock_flags,
>  							    timeout_ms);
>  		if (fd < 0) {
>  			warning_errno(_("skipping rerere, "
>  					"unable to create '%s.lock'"), path);
> +			if (flags & RERERE_SKIP_LOCKED)
> +				advise(_("run \"git rerere\" before resolving "
> +					 "the conflict to record or replay "
> +					 "its resolution"));
>  			return -1;
>  		}
>  	}

Should this use `advise_if_enabled()`?

Patrick

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Patrick Steinhardt wrote on the Git mailing list (how to reply to this email):

On Fri, Oct 02, 2026 at 11:11:32AM +0000, Thomas Bachem via GitGitGadget wrote:
> From: Thomas Bachem <mail@thomasbachem.com>
> 
> When a merge, rebase, cherry-pick, revert, am, stash or apply stops
> at a conflict, it runs rerere right before it returns to the user.
> If MERGE_RR.lock is still held when rerere.lockTimeout runs out, the
> command dies there. In a rebase, the sequencer has not yet written the
> state that "git rebase --continue" needs. A later
> "git rebase --continue" fails, and the "git commit --amend" that its
> message offers first folds the conflicted pick into the previous
> commit.
> 
> So warn and go on without rerere. The conflict is still in place, and
> a hint tells the user to run "git rerere" before resolving it. That
> records the preimage or replays a known resolution, as the command
> would have. The hint is under advice.mergeConflict like other hints
> printed at a conflict stop.
> 
> Everything else that waits for the lock is left as it is and still
> fails if the wait times out. That includes "git commit" and
> "git am --continue", which run rerere after a resolution. When they
> fail, the rebase or am can still be continued.

Is this a commit that we maybe want to defer to a later point in time?
I'm not yet convinced that it's really necessary with the other changes
that you've done, and it feels fishy to me to just skip some operations.
So I'd propose that we drop the commit for now, but keep the option open
to reintroduce it at a later point in time in case where we have users
actually hit the issue in the wild.

Patrick

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thomas Bachem wrote on the Git mailing list (how to reply to this email):

Hi Patrick,

On 09/10/2026 11:06, Patrick Steinhardt wrote:
> Is this a commit that we maybe want to defer to a later point in time?
> I'm not yet convinced that it's really necessary with the other changes
> that you've done, and it feels fishy to me to just skip some operations.
> So I'd propose that we drop the commit for now, but keep the option open
> to reintroduce it at a later point in time in case where we have users
> actually hit the issue in the wild.

Agreed, I'll drop it in v7. With the wait, a rebase only dies at a
conflict when another process holds the lock for longer than
rerere.lockTimeout. And since tb/rerere-lock-grace, a rebase's own
commits no longer start auto maintenance. If users do hit it, I'll
bring the patch back.

Thanks,
Thomas

`git rerere gc`. Value 0 means not to wait at all; -1 means

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Patrick Steinhardt wrote on the Git mailing list (how to reply to this email):

On Mon, Sep 28, 2026 at 11:58:21AM +0000, Thomas Bachem via GitGitGadget wrote:
> diff --git a/Documentation/git-rerere.adoc b/Documentation/git-rerere.adoc
> index 4e6ab9a27c..da7a1d093e 100644
> --- a/Documentation/git-rerere.adoc
> +++ b/Documentation/git-rerere.adoc
> @@ -63,14 +63,17 @@ Print paths with conflicts that have not been autoresolved by rerere.
>  This includes paths whose resolutions cannot be tracked by rerere,
>  such as conflicting submodules.
>  
> -'gc'::
> +'gc' [--auto]::
>  
>  Prune records of conflicted merges that
>  occurred a long time ago.  By default, unresolved conflicts older
>  than 15 days and resolved conflicts older than 60
>  days are pruned.  These defaults are controlled via the
>  `gc.rerereUnresolved` and `gc.rerereResolved` configuration
> -variables respectively.
> +variables respectively.  With `--auto`, which `git maintenance run
> +--auto` and `git gc --auto` pass, `gc` does nothing while another
> +process holds the rerere lock.  Without it, `gc` waits for the lock
> +as long as `rerere.lockTimeout` allows and then fails.

It's a bit weird to have git-rerere(1) document who calls it. We may
want to document why specifically this is useful though.

> diff --git a/builtin/rerere.c b/builtin/rerere.c
> index a056cb791b..2a8871df41 100644
> --- a/builtin/rerere.c
> +++ b/builtin/rerere.c
> @@ -56,16 +57,21 @@ int cmd_rerere(int argc,
>  	       struct repository *repo UNUSED)
>  {
>  	struct string_list merge_rr = STRING_LIST_INIT_DUP;
> -	int autoupdate = -1, flags = 0;
> +	int autoupdate = -1, auto_flag = 0, flags = 0;
>  
>  	struct option options[] = {
>  		OPT_SET_INT(0, "rerere-autoupdate", &autoupdate,
>  			N_("register clean resolutions in index"), 1),
> +		OPT_BOOL(0, "auto", &auto_flag,
> +			 N_("skip gc while another process holds the lock")),
>  		OPT_END(),
>  	};

Thinking about this a bit... I know it was my suggestion, but I wonder
whether "auto" is misnamed. We don't let any heuristics kick in like we
typically do for other commands like `git pack-refs --auto`, we only
know to skip garbage collection if the lock is taken. So there is a bit
of a mismatch here.

How about we instead call this "--skip-locked"? We could even mark it as
a hidden option and not even document it, as it feels very specific to
how git-maintenance(1) wants to invoke it. If so, we could maybe remove
it again at a later point.

An alternative could be to instead call `rerere_gc()` directly, and if
so we wouldn't have to add this flag at all. But that may result in some
bigger changes, so I'll leave it up to you to decide.

>  	argc = parse_options(argc, argv, prefix, options, rerere_usage, 0);
>  
> +	if (auto_flag && (argc < 1 || strcmp(argv[0], "gc")))
> +		die(_("the option '%s' requires '%s'"), "--auto", "gc");
> +
>  	repo_config(the_repository, git_xmerge_config, NULL);
>  
>  	if (autoupdate == 1)

Oh dear, this is a mess. The file could really use a refactoring to use
proper subcommands.

But anyway, that's certainly outside the scope of this patch series.

Patrick

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thomas Bachem wrote on the Git mailing list (how to reply to this email):

Hi Patrick,

On 30/09/2026 17:00, Patrick Steinhardt wrote:
> It's a bit weird to have git-rerere(1) document who calls it. We may
> want to document why specifically this is useful though.

I'll take that out of git-rerere(1) again. I'd keep the last sentence
of the rerere.lockTimeout entry, since that is where I say what each
command does when the time is up, but name the two commands there
instead of the option:

"A `git rerere gc` run by `git maintenance run --auto` or
`git gc --auto` does not wait and does nothing while the lock is held."

> How about we instead call this "--skip-locked"? We could even mark it as
> a hidden option and not even document it, as it feels very specific to
> how git-maintenance(1) wants to invoke it. If so, we could maybe remove
> it again at a later point.

I'll take both, the name and hiding it.

Patch 3 has a RERERE_SKIP_LOCKED flag for the conflict-time callers.
I'll rename that one to RERERE_WARN_LOCKED so it doesn't look like the
option's flag, which stays RERERE_NOWAIT.

> An alternative could be to instead call `rerere_gc()` directly, and if
> so we wouldn't have to add this flag at all. But that may result in some
> bigger changes, so I'll leave it up to you to decide.

I tried it. It is six lines in builtin/gc.c, but rerere_gc() dies when
it can't take the lock. A manual or scheduled "git maintenance run"
then dies with the lockfile's message and exit code 128, where it now
reports "task 'rerere-gc' failed" and exits with 1. The rerere-gc
tests in t7900 fail too, since their helper looks for the
"git rerere gc" child. So I'd keep the option for this series. Say if
you'd rather have the direct call.

I'll wait a day or two for other comments before I send v6.

Thanks,
Thomas

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Patrick Steinhardt wrote on the Git mailing list (how to reply to this email):

On Thu, Oct 01, 2026 at 10:08:16AM +0200, Thomas Bachem wrote:
> Hi Patrick,
> 
> On 30/09/2026 17:00, Patrick Steinhardt wrote:
> > It's a bit weird to have git-rerere(1) document who calls it. We may
> > want to document why specifically this is useful though.
> 
> I'll take that out of git-rerere(1) again. I'd keep the last sentence
> of the rerere.lockTimeout entry, since that is where I say what each
> command does when the time is up, but name the two commands there
> instead of the option:
> 
> "A `git rerere gc` run by `git maintenance run --auto` or
> `git gc --auto` does not wait and does nothing while the lock is held."
> 
> > How about we instead call this "--skip-locked"? We could even mark it as
> > a hidden option and not even document it, as it feels very specific to
> > how git-maintenance(1) wants to invoke it. If so, we could maybe remove
> > it again at a later point.
> 
> I'll take both, the name and hiding it.
> 
> Patch 3 has a RERERE_SKIP_LOCKED flag for the conflict-time callers.
> I'll rename that one to RERERE_WARN_LOCKED so it doesn't look like the
> option's flag, which stays RERERE_NOWAIT.
> 
> > An alternative could be to instead call `rerere_gc()` directly, and if
> > so we wouldn't have to add this flag at all. But that may result in some
> > bigger changes, so I'll leave it up to you to decide.
> 
> I tried it. It is six lines in builtin/gc.c, but rerere_gc() dies when
> it can't take the lock. A manual or scheduled "git maintenance run"
> then dies with the lockfile's message and exit code 128, where it now
> reports "task 'rerere-gc' failed" and exits with 1. The rerere-gc
> tests in t7900 fail too, since their helper looks for the
> "git rerere gc" child. So I'd keep the option for this series. Say if
> you'd rather have the direct call.

Ah, right, that makes sense. Let's keep the hidden option in that case.
Thanks!

Patrick

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Patrick Steinhardt wrote on the Git mailing list (how to reply to this email):

On Fri, Oct 02, 2026 at 11:11:31AM +0000, Thomas Bachem via GitGitGadget wrote:
> From: Thomas Bachem <mail@thomasbachem.com>
> 
> Since the previous commit, "git rerere gc" waits for MERGE_RR.lock
> like every other command that takes it, and fails only if the wait
> times out. That suits a user who runs it by hand and wants to know
> when nothing was pruned.

Two nits:

  - We already took the lock before the preceding commit, the only
    difference is that we now have a timeout. Your message sounds as if
    the whole lock were new.

  - The user don't necessarily care that nothing was pruned, but they do
    care that pruning has failed.

> But the user did not ask for the gc that auto
> maintenance starts after a commit, and the next commit starts another
> one.

And this reads quite awkward, too. How about:

  Starting with the preceding commit, processes that want to acquire
  the rerere cache's MERGE_RR.lock by default know to wait up to one
  second until that lock has been released. This is a sensible default
  for many commands that happen to write rerere entries, as we would
  otherwise die immediately when the lock is taken by another process.

  But for repository maintenance it's a bit more complicated, as there
  are two cases that we have to care about. When the user explicitly
  asks us to garbage collect rerere entries via `git rerere gc` they
  probably want us to try our best to perform this operation. It's thus
  sensible to wait for the lock and then die if we weren't able to
  acquire it.

  But we also prune rerere entries as part of auto-maintenance, which is
  only executed on a best-effort basis anyway. Delaying the whole
  operation to acquire the lock is somewhat heavy-handed, and neither
  does it make sense to die in case we haven't been able to garbage
  collect rerere entries as that would impede other housekeeping tasks.
  Furthermore, it's totally fine to skip the operation when the rerere
  cache is locked already, as we will retry during the next run anyway.

  But we do not have an easy way to tell `git rerere gc` to skip the
  operation in case the cache is locked already. Add a new
  "--skip-locked" flag to plug that gap and have auto-maintenance pass
  that flag.

> diff --git a/builtin/gc.c b/builtin/gc.c
> index 57a3520263..7ad3987b71 100644
> --- a/builtin/gc.c
> +++ b/builtin/gc.c
> @@ -385,12 +385,14 @@ out:
>  	return should_prune;
>  }
>  
> -static int maintenance_task_rerere_gc(struct maintenance_run_opts *opts UNUSED,
> +static int maintenance_task_rerere_gc(struct maintenance_run_opts *opts,
>  				      struct gc_config *cfg UNUSED)
>  {
>  	struct child_process rerere_cmd = CHILD_PROCESS_INIT;
>  	rerere_cmd.git_cmd = 1;
>  	strvec_pushl(&rerere_cmd.args, "rerere", "gc", NULL);
> +	if (opts->auto_flag)
> +		strvec_push(&rerere_cmd.args, "--skip-locked");
>  	return run_command(&rerere_cmd);
>  }

Makes sense, as this is what drives both `git gc --auto` and `git
maintenance run --auto`.

> diff --git a/rerere.c b/rerere.c
> index 64fac07c71..43c8eb04db 100644
> --- a/rerere.c
> +++ b/rerere.c
> @@ -887,18 +887,30 @@ int setup_rerere(struct repository *r, struct string_list *merge_rr, int flags)
>  
>  	if (flags & (RERERE_AUTOUPDATE|RERERE_NOAUTOUPDATE))
>  		rerere_autoupdate = !!(flags & RERERE_AUTOUPDATE);
> +	if ((flags & RERERE_READONLY) && (flags & RERERE_NOWAIT))
> +		BUG("RERERE_NOWAIT does not apply with RERERE_READONLY");
>  	if (flags & RERERE_READONLY) {
>  		fd = 0;
>  	} else {
> +		int lock_flags = LOCK_DIE_ON_ERROR;
> +		int timeout_ms = rerere_lock_timeout_ms;
> +
>  		/*
>  		 * Another process may hold the lock for a while, e.g.
>  		 * "git rerere gc" while it prunes rr-cache, so wait for
> -		 * it instead of dying right away.
> +		 * it instead of dying right away.  The gc of an automatic
> +		 * maintenance run does not wait, since skipping one of
> +		 * its runs costs nothing.
>  		 */

This comment is basically a layering violation, as you now assume who
passes `RERERE_NOWAIT`. It's a generic mechanism though, so I'd just
drop that part.

> +		if (flags & RERERE_NOWAIT) {
> +			lock_flags = 0;
> +			timeout_ms = 0;
> +		}
>  		fd = repo_hold_lock_file_for_update_timeout(r, &write_lock,
>  							    git_path_merge_rr(r),
> -							    LOCK_DIE_ON_ERROR,
> -							    rerere_lock_timeout_ms);
> +							    lock_flags, timeout_ms);
> +		if (fd < 0)
> +			return -1;

It's a tiny bit fishy that we return an error in the case where we have
been asked to skip locking and we indeed weren't able to acquire the
lock. To me it doesn't really indicate an error, as it matches the
intent of the caller. But I guess that's debatable.

> diff --git a/rerere.h b/rerere.h
> index feeb0e2c9f..d54c53d0d4 100644
> --- a/rerere.h
> +++ b/rerere.h
> @@ -10,6 +10,8 @@ struct repository;
>  #define RERERE_AUTOUPDATE   01
>  #define RERERE_NOAUTOUPDATE 02
>  #define RERERE_READONLY     04
> +/* Take MERGE_RR.lock only if it is free, and return quietly otherwise */
> +#define RERERE_NOWAIT       010

"free" is a bit unusual for a term for a lock.

> @@ -37,7 +39,7 @@ const char *rerere_path(struct strbuf *buf, const struct rerere_id *,
>  int rerere_forget(struct repository *, struct pathspec *);
>  int rerere_remaining(struct repository *, struct string_list *);
>  void rerere_clear(struct repository *, struct string_list *);
> -void rerere_gc(struct repository *, struct string_list *);
> +void rerere_gc(struct repository *, struct string_list *, int);

Given that these flags are new now, and given that none of the other
flags apply to `rerere_gc`, shouldn't we instead have a separate list of
flags specific to this function?

    enum rerere_gc_flags {
        /* Skip the operation in case the MERGE_RR.lock is already taken. */
        RERERE_GC_NOWAIT = (1 << 0),
    };

    void rerere_gc(struct repository *, struct string_list *,
                   enum rerere_gc_flags flags);

> diff --git a/t/t4200-rerere.sh b/t/t4200-rerere.sh
> index 7bd92235dc..28152bf456 100755
> --- a/t/t4200-rerere.sh
> +++ b/t/t4200-rerere.sh
> @@ -242,6 +242,27 @@ test_expect_success 'old records rest in peace' '
>  	test_path_is_missing $rr2/preimage
>  '
>  
> +test_expect_success 'gc --skip-locked does nothing while MERGE_RR is locked' '
> +	mkdir -p $rr2 &&
> +	echo Hello >$rr2/preimage &&
> +	test-tool chmtime =$just_over_15_days_ago $rr2/preimage &&
> +
> +	test_when_finished "rm -f .git/MERGE_RR.lock" &&
> +	>.git/MERGE_RR.lock &&
> +	git rerere gc --skip-locked 2>err &&
> +	test_must_be_empty err &&
> +	test_path_is_file $rr2/preimage &&
> +
> +	rm .git/MERGE_RR.lock &&
> +	git rerere gc --skip-locked &&
> +	test_path_is_missing $rr2/preimage
> +'
> +
> +test_expect_success '--skip-locked is only accepted by gc' '
> +	test_must_fail git rerere --skip-locked clear 2>err &&
> +	test_grep "option .--skip-locked. requires .gc." err
> +'

You verify that --skip-locked skips when locked, but you don't verify
that it doesn't skip when unlocked.

Patrick

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thomas Bachem wrote on the Git mailing list (how to reply to this email):

Hi Patrick,

On 09/10/2026 11:06, Patrick Steinhardt wrote:
> And this reads quite awkward, too. How about:

I'll take your message as it is, thanks. I'd only add a last
paragraph on why the flag is hidden:

  Only auto-maintenance needs that flag, so hide it, like the
  "--skip-foreground-tasks" flag that `git maintenance run` passes to
  `git gc`.

> This comment is basically a layering violation, as you now assume who
> passes `RERERE_NOWAIT`. It's a generic mechanism though, so I'd just
> drop that part.

Right, I'll drop it.

> It's a tiny bit fishy that we return an error in the case where we have
> been asked to skip locking and we indeed weren't able to acquire the
> lock. To me it doesn't really indicate an error, as it matches the
> intent of the caller. But I guess that's debatable.

setup_rerere() already returns -1 when rerere is disabled, and every
caller takes that as nothing to do rather than as an error. So I'd
keep the -1 and say so in the comment on RERERE_NOWAIT.

> "free" is a bit unusual for a term for a lock.

That's the comment I'd change anyway, so it would read:

  /* If MERGE_RR.lock is taken, return -1 as if rerere were disabled */

> Given that these flags are new now, and given that none of the other
> flags apply to `rerere_gc`, shouldn't we instead have a separate list of
> flags specific to this function?

Yes, I'll add enum rerere_gc_flags as you wrote it, and have
rerere_gc() pass RERERE_NOWAIT to setup_rerere() for it.

> You verify that --skip-locked skips when locked, but you don't verify
> that it doesn't skip when unlocked.

The second half of that test does: it removes the lock, runs
"git rerere gc --skip-locked" again and checks that the preimage is
gone. That's easy to miss, so I'll make it a test of its own.

Thanks,
Thomas

to wait indefinitely. Default is 1000 (i.e., wait for 1
second). When the time is up, the command fails as it does
for any other lock it cannot take. A `git rerere gc` run by
`git maintenance run --auto` or `git gc --auto` does not wait
and does nothing while the lock is held.
4 changes: 3 additions & 1 deletion builtin/gc.c
Original file line number Diff line number Diff line change
Expand Up @@ -385,12 +385,14 @@ static int worktree_prune_condition(struct gc_config *cfg)
return should_prune;
}

static int maintenance_task_rerere_gc(struct maintenance_run_opts *opts UNUSED,
static int maintenance_task_rerere_gc(struct maintenance_run_opts *opts,
struct gc_config *cfg UNUSED)
{
struct child_process rerere_cmd = CHILD_PROCESS_INIT;
rerere_cmd.git_cmd = 1;
strvec_pushl(&rerere_cmd.args, "rerere", "gc", NULL);
if (opts->auto_flag)
strvec_push(&rerere_cmd.args, "--skip-locked");
return run_command(&rerere_cmd);
}

Expand Down
10 changes: 8 additions & 2 deletions builtin/rerere.c
Original file line number Diff line number Diff line change
Expand Up @@ -56,16 +56,21 @@ int cmd_rerere(int argc,
struct repository *repo UNUSED)
{
struct string_list merge_rr = STRING_LIST_INIT_DUP;
int autoupdate = -1, flags = 0;
int autoupdate = -1, skip_locked = 0, flags = 0;

struct option options[] = {
OPT_SET_INT(0, "rerere-autoupdate", &autoupdate,
N_("register clean resolutions in index"), 1),
OPT_HIDDEN_BOOL(0, "skip-locked", &skip_locked,
N_("skip gc while another process holds the lock")),
OPT_END(),
};

argc = parse_options(argc, argv, prefix, options, rerere_usage, 0);

if (skip_locked && (argc < 1 || strcmp(argv[0], "gc")))
die(_("the option '%s' requires '%s'"), "--skip-locked", "gc");

repo_config(the_repository, git_xmerge_config, NULL);

if (autoupdate == 1)
Expand Down Expand Up @@ -94,7 +99,8 @@ int cmd_rerere(int argc,
if (!strcmp(argv[0], "clear")) {
rerere_clear(the_repository, &merge_rr);
} else if (!strcmp(argv[0], "gc"))
rerere_gc(the_repository, &merge_rr);
rerere_gc(the_repository, &merge_rr,
skip_locked ? RERERE_GC_NOWAIT : 0);
else if (!strcmp(argv[0], "status")) {
if (setup_rerere(the_repository, &merge_rr,
flags | RERERE_READONLY) < 0)
Expand Down
38 changes: 31 additions & 7 deletions rerere.c
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ static int rerere_enabled = -1;
/* automatically update cleanly resolved paths to the index */
static int rerere_autoupdate;

/* how long to wait for MERGE_RR.lock, in milliseconds */
static int rerere_lock_timeout_ms = 1000;

#define RR_HAS_POSTIMAGE 1
#define RR_HAS_PREIMAGE 2
struct rerere_dir {
Expand Down Expand Up @@ -850,6 +853,8 @@ static void git_rerere_config(void)
{
repo_config_get_bool(the_repository, "rerere.enabled", &rerere_enabled);
repo_config_get_bool(the_repository, "rerere.autoupdate", &rerere_autoupdate);
repo_config_get_int(the_repository, "rerere.locktimeout",
&rerere_lock_timeout_ms);
repo_config(the_repository, git_default_config, NULL);
}

Expand Down Expand Up @@ -882,12 +887,29 @@ int setup_rerere(struct repository *r, struct string_list *merge_rr, int flags)

if (flags & (RERERE_AUTOUPDATE|RERERE_NOAUTOUPDATE))
rerere_autoupdate = !!(flags & RERERE_AUTOUPDATE);
if (flags & RERERE_READONLY)
if ((flags & RERERE_READONLY) && (flags & RERERE_NOWAIT))
BUG("RERERE_NOWAIT does not apply with RERERE_READONLY");
if (flags & RERERE_READONLY) {
fd = 0;
else
fd = repo_hold_lock_file_for_update(r, &write_lock,
git_path_merge_rr(r),
LOCK_DIE_ON_ERROR);
} else {
int lock_flags = LOCK_DIE_ON_ERROR;
int timeout_ms = rerere_lock_timeout_ms;

/*
* Another process may hold the lock for a while, e.g.
* "git rerere gc" while it prunes rr-cache, so wait for
* it instead of dying right away.
*/
if (flags & RERERE_NOWAIT) {
lock_flags = 0;
timeout_ms = 0;
}
fd = repo_hold_lock_file_for_update_timeout(r, &write_lock,
git_path_merge_rr(r),
lock_flags, timeout_ms);
if (fd < 0)
return -1;
}
read_rr(r, merge_rr);
return fd;
}
Expand Down Expand Up @@ -1272,7 +1294,8 @@ bool rerere_gc_needed(struct repository *r, size_t limit)
return needed;
}

void rerere_gc(struct repository *r, struct string_list *rr)
void rerere_gc(struct repository *r, struct string_list *rr,
enum rerere_gc_flags flags)
{
struct string_list to_remove = STRING_LIST_INIT_DUP;
DIR *dir;
Expand All @@ -1282,7 +1305,8 @@ void rerere_gc(struct repository *r, struct string_list *rr)
timestamp_t cutoff_resolve;
struct strbuf buf = STRBUF_INIT;

if (setup_rerere(r, rr, 0) < 0)
if (setup_rerere(r, rr,
(flags & RERERE_GC_NOWAIT) ? RERERE_NOWAIT : 0) < 0)
return;

rerere_gc_cutoffs(r, &cutoff_resolve, &cutoff_noresolve);
Expand Down
11 changes: 10 additions & 1 deletion rerere.h
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ struct repository;
#define RERERE_AUTOUPDATE 01
#define RERERE_NOAUTOUPDATE 02
#define RERERE_READONLY 04
/* If MERGE_RR.lock is taken, return -1 as if rerere were disabled */
#define RERERE_NOWAIT 010

/*
* Marks paths that have been hand-resolved and added to the
Expand Down Expand Up @@ -37,7 +39,14 @@ const char *rerere_path(struct strbuf *buf, const struct rerere_id *,
int rerere_forget(struct repository *, struct pathspec *);
int rerere_remaining(struct repository *, struct string_list *);
void rerere_clear(struct repository *, struct string_list *);
void rerere_gc(struct repository *, struct string_list *);

enum rerere_gc_flags {
/* Skip the operation in case the MERGE_RR.lock is already taken. */
RERERE_GC_NOWAIT = (1 << 0),
};

void rerere_gc(struct repository *, struct string_list *,
enum rerere_gc_flags flags);

/*
* Check whether garbage collection for rerere entries is needed, which is
Expand Down
79 changes: 79 additions & 0 deletions t/t4200-rerere.sh
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,85 @@ test_expect_success 'old records rest in peace' '
test_path_is_missing $rr2/preimage
'

test_expect_success 'gc --skip-locked does nothing while MERGE_RR is locked' '
mkdir -p $rr2 &&
echo Hello >$rr2/preimage &&
test-tool chmtime =$just_over_15_days_ago $rr2/preimage &&

test_when_finished "rm -f .git/MERGE_RR.lock" &&
>.git/MERGE_RR.lock &&
git rerere gc --skip-locked 2>err &&
test_must_be_empty err &&
test_path_is_file $rr2/preimage
'

test_expect_success 'gc --skip-locked prunes while MERGE_RR is not locked' '
mkdir -p $rr2 &&
echo Hello >$rr2/preimage &&
test-tool chmtime =$just_over_15_days_ago $rr2/preimage &&

git rerere gc --skip-locked &&
test_path_is_missing $rr2/preimage
'

test_expect_success '--skip-locked is only accepted by gc' '
test_must_fail git rerere --skip-locked clear 2>err &&
test_grep "option .--skip-locked. requires .gc." err
'

test_expect_success 'a held lock is waited out within rerere.lockTimeout' '
git reset --hard &&
rm -rf $rr &&
test_when_finished "rm -f .git/MERGE_RR.lock" &&
>.git/MERGE_RR.lock &&
{
( sleep 1 && rm -f .git/MERGE_RR.lock ) &
} &&
test_must_fail git -c rerere.lockTimeout=5000 merge first 2>err &&
wait &&
test_grep ! "MERGE_RR" err &&
test_grep "^=======\$" $rr/preimage
'

test_expect_success 'merge fails once rerere.lockTimeout is up' '
git reset --hard &&
rm -rf $rr &&
test_when_finished "rm -f .git/MERGE_RR.lock" &&
>.git/MERGE_RR.lock &&
test_must_fail git -c rerere.lockTimeout=0 merge first 2>err &&
test_grep "Unable to create" err &&
test_grep "^=======\$" a1 &&
test_path_is_missing $rr/preimage
'

test_expect_success 'rerere, forget, clear and gc fail on a lock they cannot take' '
test_when_finished "rm -f .git/MERGE_RR.lock" &&
>.git/MERGE_RR.lock &&
test_must_fail git -c rerere.lockTimeout=0 rerere 2>err &&
test_grep "Unable to create" err &&
test_must_fail git -c rerere.lockTimeout=0 rerere forget a1 2>err &&
test_grep "Unable to create" err &&
test_must_fail git -c rerere.lockTimeout=0 rerere clear 2>err &&
test_grep "Unable to create" err &&
test_must_fail git -c rerere.lockTimeout=0 rerere gc 2>err &&
test_grep "Unable to create" err
'

test_expect_success 'rebase --abort fails on a lock it cannot take' '
git reset --hard &&
git checkout -b lock-held-abort third &&
test_when_finished "git checkout third && git branch -D lock-held-abort" &&
test_must_fail git rebase first &&
test_when_finished "rm -f .git/MERGE_RR.lock" &&
>.git/MERGE_RR.lock &&
test_must_fail git -c rerere.lockTimeout=0 rebase --abort 2>err &&
test_grep "Unable to create" err &&
test_path_is_dir .git/rebase-merge &&
rm .git/MERGE_RR.lock &&
git rebase --abort &&
test_path_is_missing .git/rebase-merge
'

rerere_gc_custom_expiry_test () {
five_days="$1" right_now="$2"
test_expect_success "rerere gc with custom expiry ($five_days, $right_now)" '
Expand Down
25 changes: 24 additions & 1 deletion t/t7900-maintenance.sh
Original file line number Diff line number Diff line change
Expand Up @@ -1007,9 +1007,17 @@ test_expect_rerere_gc () {
shift
fi

# An automatic run passes --skip-locked to "git rerere gc".
skip_locked=
case " $* " in
*" --auto "*)
skip_locked=--skip-locked
;;
esac

rm -f "rerere-gc.txt" &&
GIT_TRACE2_EVENT="$(pwd)/rerere-gc.txt" "$@" &&
test_subcommand $negate git rerere gc <rerere-gc.txt
test_subcommand $negate git rerere gc $skip_locked <rerere-gc.txt
}

test_expect_success 'rerere-gc task without --auto always collects garbage' '
Expand Down Expand Up @@ -1084,6 +1092,21 @@ test_expect_success 'rerere-gc task with --auto honors maintenance.rerere-gc.aut
test_expect_rerere_gc ! git -c maintenance.rerere-gc.auto=0 maintenance run --auto --task=rerere-gc
'

test_expect_success 'rerere-gc task with --auto succeeds while MERGE_RR is locked' '
test_when_finished "rm -rf .git/rr-cache .git/MERGE_RR.lock" &&
mkdir .git/rr-cache &&
>.git/MERGE_RR.lock &&
test_expect_rerere_gc git -c maintenance.rerere-gc.auto=-1 maintenance run --auto --task=rerere-gc
'

test_expect_success 'rerere-gc task without --auto fails while MERGE_RR is locked' '
test_when_finished "rm -rf .git/rr-cache .git/MERGE_RR.lock" &&
mkdir .git/rr-cache &&
>.git/MERGE_RR.lock &&
test_must_fail git -c rerere.lockTimeout=0 maintenance run --task=rerere-gc 2>err &&
test_grep "Unable to create" err
'

test_expect_success '--auto and --schedule incompatible' '
test_must_fail git maintenance run --auto --schedule=daily 2>err &&
test_grep "cannot be used together" err
Expand Down
Loading