Volume XXII, number 279Tuesday, October 6, 2026Latest message 1 hour ago

The Git List

News and archive of git@vger.kernel.org, since April 2005

patch, 2 partscheckout -m: recreate conflict labels

18 messages between Sep 30, 2026 and Oct 5, 2026, from Phillip Wood, Junio C Hamano, Johannes Sixt.

Plain Markdown or JSON for tools and agents. Diffs are folded; open one to read it.

Phillip WoodSep 30, 2026, 09:48 UTC on lore

When "git checkout -m <path>" recreates a merge conflict, it uses the labels "base", "ours", "theirs", rather than the labels used by the original merge. This short series teaches the ort machinery to write the labels to ".git/MERGE_LABELS" when it switches to a merge result containing conflicts, so that "git checkout -m" can then read that file and use the same labels.

As "git checkout -m" is recreating the original conflict I wonder if we should remember the conflict style as well so that

    git -c merge.conflictStyle=diff3 git merge topic
    git checkout -m <unmerged-path>

would recreate diff3 style conflicts, instead of using the default config. I cannot decide if that would be convenient or confusing and am interested to hear what others think.

base-commit: 3bc0341126508f78f5869cbfc0005e987efdf0c7
Published-As: https://github.com/phillipwood/git/releases/tag/pw%2Fconflict-labels%2Fv1
View-Changes-At: https://github.com/phillipwood/git/compare/3bc034112...fdaf3da99
Fetch-It-Via: git fetch https://github.com/phillipwood/git pw/conflict-labels/v1
Phillip Wood (2):
  remove_branch_state: convert boolean argument to flags
  merge: remember conflict labels
 branch.c           | 17 +++++++++----
 branch.h           |  4 ++-
 builtin/checkout.c | 30 ++++++++++++++++++----
 builtin/commit.c   |  1 +
 merge-ort.c        | 19 ++++++++++++++
 merge.c            | 63 ++++++++++++++++++++++++++++++++++++++++++++++
 merge.h            |  4 +++
 path.c             |  1 +
 path.h             |  1 +
 repository.c       |  1 +
 repository.h       |  1 +
 sequencer.c        |  1 +
 t/t7201-co.sh      | 21 ++++++++++++++++
 13 files changed, 153 insertions(+), 11 deletions(-)
-- 
2.56.0.rc2.84.gaf8b4f0d381
Phillip WoodSep 30, 2026, 09:48 UTC in reply to Phillip Wood on lore

[PATCH 1/2] remove_branch_state: convert boolean argument to flags

From: Phillip Wood <phillip.wood@dunelm.org.uk>

Convert the "verbose" boolean argument to a flag so that we can add more flags in a future commit.

Signed-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>
---
 branch.c           | 4 ++--
 branch.h           | 3 ++-
 builtin/checkout.c | 6 +++++-
 3 files changed, 9 insertions(+), 4 deletions(-)
Show changes to 3 files +9 −4

branch.c, branch.h, builtin/checkout.c

diff --git a/branch.c b/branch.c
index 22f4f46b96..8bc7a395a7 100644
--- a/branch.c
+++ b/branch.c
@@ -871,9 +871,9 @@ void remove_merge_branch_state(struct repository *r)
 	save_autostash_ref(r, "MERGE_AUTOSTASH");
 }
 
-void remove_branch_state(struct repository *r, int verbose)
+void remove_branch_state(struct repository *r, unsigned flags)
 {
-	sequencer_post_commit_cleanup(r, verbose);
+	sequencer_post_commit_cleanup(r, flags & REMOVE_BRANCH_STATE_VERBOSE);
 	unlink(git_path_squash_msg(r));
 	remove_merge_branch_state(r);
 }
diff --git a/branch.h b/branch.h
index e9b1f7b37d..42d1b12918 100644
--- a/branch.h
+++ b/branch.h
@@ -127,6 +127,7 @@ int validate_branchname(const char *name, struct strbuf *ref);
  */
 int validate_new_branchname(const char *name, struct strbuf *ref, int force);
 
+#define REMOVE_BRANCH_STATE_VERBOSE (1u << 0)
 /*
  * Remove information about the merge state on the current
  * branch. (E.g., MERGE_HEAD)
@@ -137,7 +138,7 @@ void remove_merge_branch_state(struct repository *r);
  * Remove information about the state of working on the current
  * branch. (E.g., MERGE_HEAD)
  */
-void remove_branch_state(struct repository *r, int verbose);
+void remove_branch_state(struct repository *r, unsigned flags);
 
 /*
  * Configure local branch "local" as downstream to branch "remote"
diff --git a/builtin/checkout.c b/builtin/checkout.c
index c0f0d2c700..bdd2d816b6 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -950,6 +950,8 @@ static void update_refs_for_switch(const struct checkout_opts *opts,
 {
 	struct strbuf msg = STRBUF_INIT;
 	const char *old_desc, *reflog_msg;
+	unsigned flags = 0;
+
 	if (opts->new_branch) {
 		if (opts->new_orphan_branch) {
 			enum log_refs_config log_all_ref_updates = LOG_REFS_UNSET;
@@ -1044,7 +1046,9 @@ static void update_refs_for_switch(const struct checkout_opts *opts,
 						   old_branch_info->path);
 		}
 	}
-	remove_branch_state(the_repository, !opts->quiet);
+	if (!opts->quiet)
+		flags |= REMOVE_BRANCH_STATE_VERBOSE;
+	remove_branch_state(the_repository, flags);
 	strbuf_release(&msg);
 	if (!opts->quiet &&
 	    !opts->force_detach &&
-- 
2.56.0.rc2.84.gaf8b4f0d381
Phillip WoodSep 30, 2026, 09:48 UTC in reply to Phillip Wood on lore

[PATCH 2/2] merge: remember conflict labels

From: Phillip Wood <phillip.wood@dunelm.org.uk>

When recreating merge conflicts with "git checkout -m <path>" the original conflict labels are lost. For commands like "git merge" and "git cherry-pick" we could use the presence of the related root ref (MERGE_HEAD and CHERRY_PICK_HEAD respectively) to recreate the labels. However, if the conflicts are from "git stash pop" or "git checkout -m <branch>", then there is no ref to deduce the labels from. To ensure the labels are always available, the merge machinery is updated to write ".git/MERGE_LABELS" when it updates the worktree and there are conflicts. The labels are then read from that file by "git checkout -m <path>" when recreating the conflicts.

As "git checkout -m <branch>" calls remove_branch_state() which ordinarily removes the labels file, we need to pass a flag down to optionally prevent that so that the labels are available for any subsequent "git checkout -m <path>". Note that merge_switch_to_result() we assign "result->priv" to "opt->priv" and later clear "opt->priv" in order to get a pointer to the private struct as result->priv is void*.

Signed-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>
---
 branch.c           | 11 ++++++--
 branch.h           |  1 +
 builtin/checkout.c | 24 +++++++++++++++---
 builtin/commit.c   |  1 +
 merge-ort.c        | 19 ++++++++++++++
 merge.c            | 63 ++++++++++++++++++++++++++++++++++++++++++++++
 merge.h            |  4 +++
 path.c             |  1 +
 path.h             |  1 +
 repository.c       |  1 +
 repository.h       |  1 +
 sequencer.c        |  1 +
 t/t7201-co.sh      | 21 ++++++++++++++++
 13 files changed, 143 insertions(+), 6 deletions(-)
Show changes to 13 files +143 −6

branch.c, branch.h, builtin/checkout.c, builtin/commit.c, merge-ort.c, merge.c, merge.h, path.c, path.h, repository.c, repository.h, sequencer.c, t/t7201-co.sh

diff --git a/branch.c b/branch.c
index 8bc7a395a7..5bb1c28915 100644
--- a/branch.c
+++ b/branch.c
@@ -860,9 +860,11 @@ void create_branches_recursively(struct repository *r, const char *name,
 	free(branch_point);
 }
 
-void remove_merge_branch_state(struct repository *r)
+static void do_remove_merge_branch_state(struct repository *r, unsigned flags)
 {
 	unlink(git_path_merge_head(r));
+	if (!(flags & REMOVE_BRANCH_STATE_PRESERVE_CONFLICT_LABELS))
+		unlink(git_path_merge_labels(r));
 	unlink(git_path_merge_rr(r));
 	unlink(git_path_merge_msg(r));
 	unlink(git_path_merge_mode(r));
@@ -871,11 +873,16 @@ void remove_merge_branch_state(struct repository *r)
 	save_autostash_ref(r, "MERGE_AUTOSTASH");
 }
 
+void remove_merge_branch_state(struct repository *r)
+{
+	do_remove_merge_branch_state(r, 0);
+}
+
 void remove_branch_state(struct repository *r, unsigned flags)
 {
 	sequencer_post_commit_cleanup(r, flags & REMOVE_BRANCH_STATE_VERBOSE);
 	unlink(git_path_squash_msg(r));
-	remove_merge_branch_state(r);
+	do_remove_merge_branch_state(r, flags);
 }
 
 void die_if_checked_out(const char *branch, int ignore_current_worktree)
diff --git a/branch.h b/branch.h
index 42d1b12918..95b2431f24 100644
--- a/branch.h
+++ b/branch.h
@@ -128,6 +128,7 @@ int validate_branchname(const char *name, struct strbuf *ref);
 int validate_new_branchname(const char *name, struct strbuf *ref, int force);
 
 #define REMOVE_BRANCH_STATE_VERBOSE (1u << 0)
+#define REMOVE_BRANCH_STATE_PRESERVE_CONFLICT_LABELS (1u << 1)
 /*
  * Remove information about the merge state on the current
  * branch. (E.g., MERGE_HEAD)
diff --git a/builtin/checkout.c b/builtin/checkout.c
index bdd2d816b6..295fe0e9fa 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -15,6 +15,7 @@
 #include "hex.h"
 #include "hook.h"
 #include "merge-ll.h"
+#include "merge.h"
 #include "lockfile.h"
 #include "mem-pool.h"
 #include "object-file.h"
@@ -317,6 +318,7 @@ static int checkout_merged(int pos, const struct checkout *state,
 	struct cache_entry *ce = the_repository->index->cache[pos];
 	const char *path = ce->name;
 	mmfile_t ancestor, ours, theirs;
+	char *base_label, *ours_label, *theirs_label;
 	enum ll_merge_result merge_status;
 	int status;
 	struct object_id oid;
@@ -347,10 +349,19 @@ static int checkout_merged(int pos, const struct checkout *state,
 
 	repo_config_get_bool(the_repository, "merge.renormalize", &renormalize);
 	ll_opts.renormalize = renormalize;
+	if (read_merge_labels(the_repository, &base_label, &ours_label,
+			      &theirs_label)) {
+		base_label = xstrdup("base");
+		ours_label = xstrdup("ours");
+		theirs_label = xstrdup("theirs");
+	}
 	ll_opts.conflict_style = conflict_style;
-	merge_status = ll_merge(&result_buf, path, &ancestor, "base",
-				&ours, "ours", &theirs, "theirs",
+	merge_status = ll_merge(&result_buf, path, &ancestor, base_label,
+				&ours, ours_label, &theirs, theirs_label,
 				state->istate, &ll_opts);
+	free(base_label);
+	free(ours_label);
+	free(theirs_label);
 	free(ancestor.ptr);
 	free(ours.ptr);
 	free(theirs.ptr);
@@ -946,7 +957,8 @@ static void report_tracking(struct branch_info *new_branch_info)
 
 static void update_refs_for_switch(const struct checkout_opts *opts,
 				   struct branch_info *old_branch_info,
-				   struct branch_info *new_branch_info)
+				   struct branch_info *new_branch_info,
+				   bool merge_conflicts)
 {
 	struct strbuf msg = STRBUF_INIT;
 	const char *old_desc, *reflog_msg;
@@ -1048,6 +1060,8 @@ static void update_refs_for_switch(const struct checkout_opts *opts,
 	}
 	if (!opts->quiet)
 		flags |= REMOVE_BRANCH_STATE_VERBOSE;
+	if (merge_conflicts)
+		flags |= REMOVE_BRANCH_STATE_PRESERVE_CONFLICT_LABELS;
 	remove_branch_state(the_repository, flags);
 	strbuf_release(&msg);
 	if (!opts->quiet &&
@@ -1262,7 +1276,9 @@ static int switch_branches(const struct checkout_opts *opts,
 
 	if (autostash_res == STASH_APPLY_CONFLICT && !opts->quiet)
 		fputc('\n', stderr);
-	update_refs_for_switch(opts, &old_branch_info, new_branch_info);
+
+	update_refs_for_switch(opts, &old_branch_info, new_branch_info,
+			       autostash_res == STASH_APPLY_CONFLICT);
 
 	if (created_autostash) {
 		discard_index(the_repository->index);
diff --git a/builtin/commit.c b/builtin/commit.c
index 205fbd57e3..c374d5e0d5 100644
--- a/builtin/commit.c
+++ b/builtin/commit.c
@@ -1977,6 +1977,7 @@ int cmd_commit(int argc,
 
 	sequencer_post_commit_cleanup(the_repository, 0);
 	unlink(git_path_merge_head(the_repository));
+	unlink(git_path_merge_labels(the_repository));
 	unlink(git_path_merge_msg(the_repository));
 	unlink(git_path_merge_mode(the_repository));
 	unlink(git_path_squash_msg(the_repository));
diff --git a/merge-ort.c b/merge-ort.c
index c410a5d353..783e74c6e3 100644
--- a/merge-ort.c
+++ b/merge-ort.c
@@ -35,6 +35,7 @@
 #include "hex.h"
 #include "entry.h"
 #include "merge-ll.h"
+#include "merge.h"
 #include "match-trees.h"
 #include "mem-pool.h"
 #include "object-file.h"
@@ -418,6 +419,9 @@ struct merge_options_internal {
 
 	/* field that holds submodule conflict information */
 	struct string_list conflicted_submodules;
+
+	/* Copies of the labels used for conflict markers */
+	char *labels[3];
 };
 
 struct conflicted_submodule_item {
@@ -4969,6 +4973,13 @@ void merge_switch_to_result(struct merge_options *opt,
 			return;
 		}
 		trace2_region_leave("merge", "write_auto_merge", opt->repo);
+
+		trace2_region_enter("merge", "write_merge_labels", opt->repo);
+		opt->priv = result->priv;
+		write_merge_labels(opt->repo, opt->priv->labels[0], opt->priv->labels[1],
+				   opt->priv->labels[2]);
+		opt->priv = NULL;
+		trace2_region_leave("merge", "write_merge_labels", opt->repo);
 	}
 	if (display_update_msgs)
 		merge_display_update_messages(opt, /* detailed */ 0, result);
@@ -5234,6 +5245,14 @@ static void move_opt_priv_to_result_priv(struct merge_options *opt,
 	 * to move it.
 	 */
 	assert(opt->priv && !result->priv);
+	if (!result->clean) {
+		opt->priv->labels[0] =
+			mem_pool_strdup(&opt->priv->pool, opt->ancestor);
+		opt->priv->labels[1] =
+			mem_pool_strdup(&opt->priv->pool, opt->branch1);
+		opt->priv->labels[2] =
+			mem_pool_strdup(&opt->priv->pool, opt->branch2);
+	}
 	result->priv = opt->priv;
 	result->_properly_initialized = RESULT_INITIALIZED;
 	opt->priv = NULL;
diff --git a/merge.c b/merge.c
index 0f5e823e63..95495ae1ba 100644
--- a/merge.c
+++ b/merge.c
@@ -8,6 +8,7 @@
 #include "merge.h"
 #include "commit.h"
 #include "repository.h"
+#include "path.h"
 #include "run-command.h"
 #include "resolve-undo.h"
 #include "tree.h"
@@ -111,3 +112,65 @@ int checkout_fast_forward(struct repository *r,
 		return error(_("unable to write new index file"));
 	return 0;
 }
+
+int write_merge_labels(struct repository *r, const char *base,
+			  const char *ours, const char *theirs)
+{
+	FILE *f = fopen_or_warn(git_path_merge_labels(r), "w");
+
+	if (!f)
+		return -1;
+
+	fprintf(f, "%s\n%s\n%s\n", base, ours, theirs);
+	if (fclose(f))
+		return error_errno("could not write '%s'",
+				   git_path_merge_labels(r));
+
+	return 0;
+}
+
+static int parse_merge_label_line(const char **p, char **line)
+{
+	const char *eol = strchr(*p, '\n');
+
+	if (!eol)
+		return -1;
+
+	*line = xmemdupz(*p, eol - *p);
+	*p = eol + 1;
+
+	return 0;
+}
+
+int read_merge_labels(struct repository *r,
+		      char **pbase, char** pours, char** ptheirs)
+{
+	struct strbuf buf = STRBUF_INIT;
+	const char *p;
+	char *base = NULL, *ours = NULL, *theirs = NULL;
+	int ret = -1;
+
+	if (strbuf_read_file(&buf, git_path_merge_labels(r), 0) < 0)
+		return -1;
+
+	p = buf.buf;
+	if (parse_merge_label_line(&p, &base))
+		goto out;
+	if (parse_merge_label_line(&p, &ours))
+		goto out;
+	if (parse_merge_label_line(&p, &theirs))
+		goto out;
+	ret = 0;
+	*pbase = base;
+	*pours = ours;
+	*ptheirs = theirs;
+out:
+	if (ret) {
+		free(base);
+		free(ours);
+		free(theirs);
+	}
+	strbuf_release(&buf);
+
+	return ret;
+}
diff --git a/merge.h b/merge.h
index 21ac7ef2f1..0772737a87 100644
--- a/merge.h
+++ b/merge.h
@@ -13,5 +13,9 @@ int checkout_fast_forward(struct repository *r,
 			  const struct object_id *from,
 			  const struct object_id *to,
 			  int overwrite_ignore);
+int write_merge_labels(struct repository *r,
+		       const char *base, const char *ours, const char *theirs);
+int read_merge_labels(struct repository *r,
+		      char **base, char **ours, char **theirs);
 
 #endif /* MERGE_H */
diff --git a/path.c b/path.c
index c3a709a928..7965762602 100644
--- a/path.c
+++ b/path.c
@@ -1655,3 +1655,4 @@ REPO_GIT_PATH_FUNC(merge_mode, "MERGE_MODE")
 REPO_GIT_PATH_FUNC(merge_head, "MERGE_HEAD")
 REPO_GIT_PATH_FUNC(fetch_head, "FETCH_HEAD")
 REPO_GIT_PATH_FUNC(shallow, "shallow")
+REPO_GIT_PATH_FUNC(merge_labels, "MERGE_LABELS")
diff --git a/path.h b/path.h
index 7e7408dd05..8cd12ccfde 100644
--- a/path.h
+++ b/path.h
@@ -142,6 +142,7 @@ const char *git_path_merge_mode(struct repository *r);
 const char *git_path_merge_head(struct repository *r);
 const char *git_path_fetch_head(struct repository *r);
 const char *git_path_shallow(struct repository *r);
+const char *git_path_merge_labels(struct repository *r);
 
 int ends_with_path_components(const char *path, const char *components);
 
diff --git a/repository.c b/repository.c
index b857e1c580..210fb819b0 100644
--- a/repository.c
+++ b/repository.c
@@ -367,6 +367,7 @@ static void repo_clear_path_cache(struct repo_path_cache *cache)
 	FREE_AND_NULL(cache->merge_head);
 	FREE_AND_NULL(cache->fetch_head);
 	FREE_AND_NULL(cache->shallow);
+	FREE_AND_NULL(cache->merge_labels);
 }
 
 void repo_clear(struct repository *repo)
diff --git a/repository.h b/repository.h
index 11f5c2ed10..91b1f57db7 100644
--- a/repository.h
+++ b/repository.h
@@ -36,6 +36,7 @@ struct repo_path_cache {
 	char *merge_head;
 	char *fetch_head;
 	char *shallow;
+	char *merge_labels;
 };
 
 struct repository {
diff --git a/sequencer.c b/sequencer.c
index e25ef5eb61..0710aa6400 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -5145,6 +5145,7 @@ static int pick_commits(struct repository *r,
 	unlink(rebase_path_stopped_sha());
 	unlink(rebase_path_amend());
 	unlink(rebase_path_patch());
+	unlink(git_path_merge_labels(r));
 
 	while (todo_list->current < todo_list->nr) {
 		struct todo_item *item = todo_list->items + todo_list->current;
diff --git a/t/t7201-co.sh b/t/t7201-co.sh
index 9ea9462914..7d0dcf8c8b 100755
--- a/t/t7201-co.sh
+++ b/t/t7201-co.sh
@@ -183,6 +183,27 @@ test_expect_success 'format of merge conflict from checkout -m' '
 	d
 	>>>>>>> local
 	EOF
+	test_cmp expect two &&
+
+	test_path_is_file .git/MERGE_LABELS &&
+
+	git checkout --conflict=diff3 two &&
+	cat >expect <<-\EOF &&
+	<<<<<<< simple
+	a
+	c
+	e
+	||||||| main
+	a
+	b
+	c
+	d
+	e
+	=======
+	b
+	d
+	>>>>>>> local
+	EOF
 	test_cmp expect two
 '
 
-- 
2.56.0.rc2.84.gaf8b4f0d381
Junio C HamanoSep 30, 2026, 16:42 UTC in reply to Phillip Wood on lore

Re: [PATCH 2/2] merge: remember conflict labels

Phillip Wood <phillip.wood123@gmail.com> writes:
Show 36 quoted lines
> From: Phillip Wood <phillip.wood@dunelm.org.uk>
>
> When recreating merge conflicts with "git checkout -m <path>" the
> original conflict labels are lost. For commands like "git merge" and
> "git cherry-pick" we could use the presence of the related root
> ref (MERGE_HEAD and CHERRY_PICK_HEAD respectively) to recreate the
> labels. However, if the conflicts are from "git stash pop" or "git
> checkout -m <branch>", then there is no ref to deduce the labels from. To
> ensure the labels are always available, the merge machinery is updated to
> write ".git/MERGE_LABELS" when it updates the worktree and
> there are conflicts. The labels are then read from that file by "git
> checkout -m <path>" when recreating the conflicts.
>
> As "git checkout -m <branch>" calls remove_branch_state() which
> ordinarily removes the labels file, we need to pass a flag down
> to optionally prevent that so that the labels are available for any
> subsequent "git checkout -m <path>". Note that merge_switch_to_result()
> we assign "result->priv" to "opt->priv" and later clear "opt->priv" in
> order to get a pointer to the private struct as result->priv is void*.
>
> Signed-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>
> ---
>  branch.c           | 11 ++++++--
>  branch.h           |  1 +
>  builtin/checkout.c | 24 +++++++++++++++---
>  builtin/commit.c   |  1 +
>  merge-ort.c        | 19 ++++++++++++++
>  merge.c            | 63 ++++++++++++++++++++++++++++++++++++++++++++++
>  merge.h            |  4 +++
>  path.c             |  1 +
>  path.h             |  1 +
>  repository.c       |  1 +
>  repository.h       |  1 +
>  sequencer.c        |  1 +
>  t/t7201-co.sh      | 21 ++++++++++++++++
>  13 files changed, 143 insertions(+), 6 deletions(-)

Where do we talk about MERGE_HEAD and CHERRY_PICK_HEAD in the current documentation set? Do we want to mention MERGE_LABELS alongside them?

Show 17 quoted lines
> +
> +int write_merge_labels(struct repository *r, const char *base,
> +			  const char *ours, const char *theirs)
> +{
> +	FILE *f = fopen_or_warn(git_path_merge_labels(r), "w");
> +
> +	if (!f)
> +		return -1;
> +
> +	fprintf(f, "%s\n%s\n%s\n", base, ours, theirs);
> +	if (fclose(f))
> +		return error_errno("could not write '%s'",
> +				   git_path_merge_labels(r));
> +
> +	return 0;
> +}
> +

We write three items, one per line, delimited by LF. As this goes through stdio, wouldn't Windows write CRLF-delimited lines? I guess if we read this back through stdio, that will cancel out and we get the LF-delimited lines back?

Wait. Do we want to read this file via stdio, one line at a time, using three calls to fgets()? No, we do not give a strict upper limit to the length of these labels. So if we read with strbuf_read_line() or something, we would be safe, I guess, but alas there is no such helper function X-<.

Show 12 quoted lines
> +static int parse_merge_label_line(const char **p, char **line)
> +{
> +	const char *eol = strchr(*p, '\n');
> +
> +	if (!eol)
> +		return -1;
> +
> +	*line = xmemdupz(*p, eol - *p);
> +	*p = eol + 1;
> +
> +	return 0;
> +}

OK, this reads one line at a time from the file contents already fully read by strbuf_read_file(), as seen below.

Which means that the CRLF fprintf() may have written in write_merge_labels() will come back to this function, and our 'ours' may become 'ours\015' after stripping only the LF at the end?

Show 10 quoted lines
> +int read_merge_labels(struct repository *r,
> +		      char **pbase, char** pours, char** ptheirs)
> +{
> +	struct strbuf buf = STRBUF_INIT;
> +	const char *p;
> +	char *base = NULL, *ours = NULL, *theirs = NULL;
> +	int ret = -1;
> +
> +	if (strbuf_read_file(&buf, git_path_merge_labels(r), 0) < 0)
> +		return -1;

Can strbuf_read_file() fill '.buf' halfway and return a failure, or does it ensure that it frees '.buf' before returning failure? Just double-checking.

    ... goes and checks ...

strbuf_read_file() calls strbuf_read(), which calls read_in_full() to fill a sufficiently large buffer, and a failure from there results in strbuf_release() or strbuf_setlen() resetting back to the '.len' before strbuf_read() was called (i.e., 0 in this case), so we do not leak anything on the error path and this code is safe, I think.

Show 8 quoted lines
> +
> +	p = buf.buf;
> +	if (parse_merge_label_line(&p, &base))
> +		goto out;
> +	if (parse_merge_label_line(&p, &ours))
> +		goto out;
> +	if (parse_merge_label_line(&p, &theirs))
> +		goto out;
OK, we read three things.
Show 11 quoted lines
> +	ret = 0;
> +	*pbase = base;
> +	*pours = ours;
> +	*ptheirs = theirs;
> +out:
> +	if (ret) {
> +		free(base);
> +		free(ours);
> +		free(theirs);
> +	}
> +	strbuf_release(&buf);

OK, so the contract is that we will not touch p{base,ours,theirs} if we return failure, and we will not leak anything when doing so.

Which is very sensible.
> +	return ret;
> +}

Looking good so far, modulo a small worry about writing via stdio and reading back while bypassing stdio. But perhaps CRLF is so annoying that the compat/mingw layer takes care of all of the above worries by passing the 'binary' bit down to the msvcrt/ucrt layer, in which case we should not have to worry about it. I dunno.

Thanks for working on these patches.
Johannes SixtSep 30, 2026, 20:24 UTC in reply to Phillip Wood on lore

Re: [PATCH 0/2] checkout -m: recreate conflict labels

Am 30.09.26 um 11:48 schrieb Phillip Wood:
Show 6 quoted lines
> When "git checkout -m <path>" recreates a merge conflict, it uses
> the labels "base", "ours", "theirs", rather than the labels used by
> the original merge. This short series teaches the ort machinery to
> write the labels to ".git/MERGE_LABELS" when it switches to a merge
> result containing conflicts, so that "git checkout -m" can then read
> that file and use the same labels.

Would an index extension not be a better place to store auxiliary information about merges?

-- Hannes
Junio C HamanoSep 30, 2026, 20:40 UTC in reply to Johannes Sixt on lore

Re: [PATCH 0/2] checkout -m: recreate conflict labels

Johannes Sixt <j6t@kdbg.org> writes:
Show 10 quoted lines
> Am 30.09.26 um 11:48 schrieb Phillip Wood:
>> When "git checkout -m <path>" recreates a merge conflict, it uses
>> the labels "base", "ours", "theirs", rather than the labels used by
>> the original merge. This short series teaches the ort machinery to
>> write the labels to ".git/MERGE_LABELS" when it switches to a merge
>> result containing conflicts, so that "git checkout -m" can then read
>> that file and use the same labels.
>
> Would an index extension not be a better place to store auxiliary
> information about merges?

Wow. MERGE_HEAD, CHERRY_PICK_HEAD, and all others replaced with index extensions? That would unclutter $GIT_DIR/ quite a lot (for some reason, I find ORIG_HEAD is a bit of eyesore). It makes the information less accessible, so I am not sure how I feel about the proposal, but it is an interesting thought.

Thanks.
Johannes SixtSep 30, 2026, 21:20 UTC in reply to Junio C Hamano on lore

Re: [PATCH 0/2] checkout -m: recreate conflict labels

Am 30.09.26 um 22:40 schrieb Junio C Hamano:
Show 15 quoted lines
> Johannes Sixt <j6t@kdbg.org> writes:
> 
>> Am 30.09.26 um 11:48 schrieb Phillip Wood:
>>> When "git checkout -m <path>" recreates a merge conflict, it uses
>>> the labels "base", "ours", "theirs", rather than the labels used by
>>> the original merge. This short series teaches the ort machinery to
>>> write the labels to ".git/MERGE_LABELS" when it switches to a merge
>>> result containing conflicts, so that "git checkout -m" can then read
>>> that file and use the same labels.
>>
>> Would an index extension not be a better place to store auxiliary
>> information about merges?
> 
> Wow.  MERGE_HEAD, CHERRY_PICK_HEAD, and all others replaced with
> index extensions?

Absolutely not. IIUC, MERGE_LABELS should not be a pseudo ref, but a file carrying auxiliary information.

>  That would unclutter $GIT_DIR/ quite a lot (for
> some reason, I find ORIG_HEAD is a bit of eyesore).  It makes the
> information less accessible, so I am not sure how I feel about the
> proposal, but it is an interesting thought.

I don't know how accessible data in an index extension is. But if it's prohibitively difficult to access, then the idea is dead on arrival.

-- Hannes
Phillip WoodOct 1, 2026, 08:45 UTC in reply to Johannes Sixt on lore

Re: [PATCH 0/2] checkout -m: recreate conflict labels

On 30/09/2026 21:24, Johannes Sixt wrote:
Show 10 quoted lines
> Am 30.09.26 um 11:48 schrieb Phillip Wood:
>> When "git checkout -m <path>" recreates a merge conflict, it uses
>> the labels "base", "ours", "theirs", rather than the labels used by
>> the original merge. This short series teaches the ort machinery to
>> write the labels to ".git/MERGE_LABELS" when it switches to a merge
>> result containing conflicts, so that "git checkout -m" can then read
>> that file and use the same labels.
> 
> Would an index extension not be a better place to store auxiliary
> information about merges?

I did briefly consider that, but it makes it much harder for other merge strategies such as git-merge-octopus (which I should probably update to write MERGE_LABELS) to store the labels. We already have MERGE_MODE, MERGE_RR and MERGE_MSG storing various bits of merge-related information so this series just follows existing practice.

Thanks
Phillip
Phillip WoodOct 1, 2026, 08:54 UTC in reply to Junio C Hamano on lore

Re: [PATCH 2/2] merge: remember conflict labels

Hi Junio
On 30/09/2026 17:42, Junio C Hamano wrote:
Show 42 quoted lines
> Phillip Wood <phillip.wood123@gmail.com> writes:
> 
>> From: Phillip Wood <phillip.wood@dunelm.org.uk>
>>
>> When recreating merge conflicts with "git checkout -m <path>" the
>> original conflict labels are lost. For commands like "git merge" and
>> "git cherry-pick" we could use the presence of the related root
>> ref (MERGE_HEAD and CHERRY_PICK_HEAD respectively) to recreate the
>> labels. However, if the conflicts are from "git stash pop" or "git
>> checkout -m <branch>", then there is no ref to deduce the labels from. To
>> ensure the labels are always available, the merge machinery is updated to
>> write ".git/MERGE_LABELS" when it updates the worktree and
>> there are conflicts. The labels are then read from that file by "git
>> checkout -m <path>" when recreating the conflicts.
>>
>> As "git checkout -m <branch>" calls remove_branch_state() which
>> ordinarily removes the labels file, we need to pass a flag down
>> to optionally prevent that so that the labels are available for any
>> subsequent "git checkout -m <path>". Note that merge_switch_to_result()
>> we assign "result->priv" to "opt->priv" and later clear "opt->priv" in
>> order to get a pointer to the private struct as result->priv is void*.
>>
>> Signed-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>
>> ---
>>   branch.c           | 11 ++++++--
>>   branch.h           |  1 +
>>   builtin/checkout.c | 24 +++++++++++++++---
>>   builtin/commit.c   |  1 +
>>   merge-ort.c        | 19 ++++++++++++++
>>   merge.c            | 63 ++++++++++++++++++++++++++++++++++++++++++++++
>>   merge.h            |  4 +++
>>   path.c             |  1 +
>>   path.h             |  1 +
>>   repository.c       |  1 +
>>   repository.h       |  1 +
>>   sequencer.c        |  1 +
>>   t/t7201-co.sh      | 21 ++++++++++++++++
>>   13 files changed, 143 insertions(+), 6 deletions(-)
> 
> Where do we talk about MERGE_HEAD and CHERRY_PICK_HEAD in the
> current documentation set?  Do we want to mention MERGE_LABELS
> alongside them?

We talk about those in gitrevisions, the "refs" section of gitglossary and in the merge documentation. As this is not a ref I don't think it fits with MERGE_HEAD, it is more like MERGE_MSG, or MERGE_MODE. The merge man page mentions MERGE_MSG in passing but never explicitly says what it contains and MERGE_MODE is undocumented as far as I can see. We would perhaps benefit from documenting the common files like COMMIT_EDITMSG, MERGE_MSG, SQUASH_MSG, MERGE_HEAD, FETCH_HEAD and MERGE_LABELS somewhere in gitrepository briefly explaining what they contain and how they are used as a separate series.

Show 20 quoted lines
>> +static int parse_merge_label_line(const char **p, char **line)
>> +{
>> +	const char *eol = strchr(*p, '\n');
>> +
>> +	if (!eol)
>> +		return -1;
>> +
>> +	*line = xmemdupz(*p, eol - *p);
>> +	*p = eol + 1;
>> +
>> +	return 0;
>> +}
> 
> 
> OK, this reads one line at a time from the file contents already
> fully read by strbuf_read_file(), as seen below.
> 
> Which means that the CRLF fprintf() may have written in
> write_merge_labels() will come back to this function, and our 'ours'
> may become 'ours\015' after stripping only the LF at the end?
That's a good point, I've changed it to use strbuf_getline() instead.
> Thanks for working on these patches.
Thanks for reviewing them, I'll send a re-roll in a couple of days
Phillip
Junio C HamanoOct 1, 2026, 17:14 UTC in reply to Phillip Wood on lore

Re: [PATCH 2/2] merge: remember conflict labels

Phillip Wood <phillip.wood123@gmail.com> writes:
Show 5 quoted lines
> We
> would perhaps benefit from documenting the common files like 
> COMMIT_EDITMSG, MERGE_MSG, SQUASH_MSG, MERGE_HEAD, FETCH_HEAD and 
> MERGE_LABELS somewhere in gitrepository briefly explaining what they 
> contain and how they are used as a separate series.
Sounds good.
Thanks.
Phillip WoodOct 5, 2026, 13:24 UTC in reply to Phillip Wood on lore

[PATCH v2 1/2] remove_branch_state: convert boolean argument to flags

From: Phillip Wood <phillip.wood@dunelm.org.uk>

Convert the "verbose" boolean argument to a flag so that we can add more flags in a future commit.

Signed-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>
---
 branch.c           | 4 ++--
 branch.h           | 3 ++-
 builtin/checkout.c | 6 +++++-
 3 files changed, 9 insertions(+), 4 deletions(-)
Show changes to 3 files +9 −4

branch.c, branch.h, builtin/checkout.c

diff --git a/branch.c b/branch.c
index 22f4f46b96..8bc7a395a7 100644
--- a/branch.c
+++ b/branch.c
@@ -871,9 +871,9 @@ void remove_merge_branch_state(struct repository *r)
 	save_autostash_ref(r, "MERGE_AUTOSTASH");
 }
 
-void remove_branch_state(struct repository *r, int verbose)
+void remove_branch_state(struct repository *r, unsigned flags)
 {
-	sequencer_post_commit_cleanup(r, verbose);
+	sequencer_post_commit_cleanup(r, flags & REMOVE_BRANCH_STATE_VERBOSE);
 	unlink(git_path_squash_msg(r));
 	remove_merge_branch_state(r);
 }
diff --git a/branch.h b/branch.h
index e9b1f7b37d..42d1b12918 100644
--- a/branch.h
+++ b/branch.h
@@ -127,6 +127,7 @@ int validate_branchname(const char *name, struct strbuf *ref);
  */
 int validate_new_branchname(const char *name, struct strbuf *ref, int force);
 
+#define REMOVE_BRANCH_STATE_VERBOSE (1u << 0)
 /*
  * Remove information about the merge state on the current
  * branch. (E.g., MERGE_HEAD)
@@ -137,7 +138,7 @@ void remove_merge_branch_state(struct repository *r);
  * Remove information about the state of working on the current
  * branch. (E.g., MERGE_HEAD)
  */
-void remove_branch_state(struct repository *r, int verbose);
+void remove_branch_state(struct repository *r, unsigned flags);
 
 /*
  * Configure local branch "local" as downstream to branch "remote"
diff --git a/builtin/checkout.c b/builtin/checkout.c
index c0f0d2c700..bdd2d816b6 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -950,6 +950,8 @@ static void update_refs_for_switch(const struct checkout_opts *opts,
 {
 	struct strbuf msg = STRBUF_INIT;
 	const char *old_desc, *reflog_msg;
+	unsigned flags = 0;
+
 	if (opts->new_branch) {
 		if (opts->new_orphan_branch) {
 			enum log_refs_config log_all_ref_updates = LOG_REFS_UNSET;
@@ -1044,7 +1046,9 @@ static void update_refs_for_switch(const struct checkout_opts *opts,
 						   old_branch_info->path);
 		}
 	}
-	remove_branch_state(the_repository, !opts->quiet);
+	if (!opts->quiet)
+		flags |= REMOVE_BRANCH_STATE_VERBOSE;
+	remove_branch_state(the_repository, flags);
 	strbuf_release(&msg);
 	if (!opts->quiet &&
 	    !opts->force_detach &&
-- 
2.56.0.134.g299a3c16181
Phillip WoodOct 5, 2026, 13:24 UTC in reply to Phillip Wood on lore

[PATCH v2 0/2] checkout -m: recreate conflict labels

When "git checkout -m <path>" recreates a merge conflict, it uses the labels "base", "ours", "theirs", rather than the labels used by the original merge. This short series teaches the ort machinery to write the labels to ".git/MERGE_LABELS" when it switches to a merge result containing conflicts, so that "git checkout -m" can then read that file and use the same labels.

As "git checkout -m" is recreating the original conflict I wonder if we should remember the conflict style as well so that

    git -c merge.conflictStyle=diff3 git merge topic
    git checkout -m <unmerged-path>

would recreate diff3 style conflicts, instead of using the default config. I cannot decide if that would be convenient or confusing and am interested to hear what others think.

Changes since V1:
 - use strbuf_getline() rather than strbuf_read_file() to read labels
   so that the newline handling of the reading and writing sides match.

NB ".git/MERGE_LABELS" is still undocumented - I'm hoping to find time to add some documentation for all the MERGE_* files in a future series. Johannes suggested using an index extension to store the labels, but as we already have MERGE_MODE, MERGE_RR and MERGE_MSG I think it is easier just to add another file.

base-commit: 3bc0341126508f78f5869cbfc0005e987efdf0c7
Published-As: https://github.com/phillipwood/git/releases/tag/pw%2Fconflict-labels%2Fv2
View-Changes-At: https://github.com/phillipwood/git/compare/3bc034112...18bdf7df4
Fetch-It-Via: git fetch https://github.com/phillipwood/git pw/conflict-labels/v2
Phillip Wood (2):
  remove_branch_state: convert boolean argument to flags
  merge: remember conflict labels
 branch.c           | 17 ++++++++----
 branch.h           |  4 ++-
 builtin/checkout.c | 30 ++++++++++++++++++----
 builtin/commit.c   |  1 +
 merge-ort.c        | 19 ++++++++++++++
 merge.c            | 64 ++++++++++++++++++++++++++++++++++++++++++++++
 merge.h            |  4 +++
 path.c             |  1 +
 path.h             |  1 +
 repository.c       |  1 +
 repository.h       |  1 +
 sequencer.c        |  1 +
 t/t7201-co.sh      | 21 +++++++++++++++
 13 files changed, 154 insertions(+), 11 deletions(-)
Range-diff against v1:
1:  86ef0f848a = 1:  86ef0f848a remove_branch_state: convert boolean argument to flags
2:  fdaf3da993 ! 2:  18bdf7df49 merge: remember conflict labels
    @@ merge.c: int checkout_fast_forward(struct repository *r,
     +	return 0;
     +}
     +
    -+static int parse_merge_label_line(const char **p, char **line)
    ++static char *parse_merge_label_line(struct strbuf *buf, FILE *fp)
     +{
    -+	const char *eol = strchr(*p, '\n');
    -+
    -+	if (!eol)
    -+		return -1;
    -+
    -+	*line = xmemdupz(*p, eol - *p);
    -+	*p = eol + 1;
    -+
    -+	return 0;
    ++	if (strbuf_getline(buf, fp) == EOF)
    ++		return NULL;
    ++
    ++	return xmemdupz(buf->buf, buf->len);
     +}
     +
     +int read_merge_labels(struct repository *r,
     +		      char **pbase, char** pours, char** ptheirs)
     +{
     +	struct strbuf buf = STRBUF_INIT;
    -+	const char *p;
     +	char *base = NULL, *ours = NULL, *theirs = NULL;
     +	int ret = -1;
    ++	FILE *fp = fopen(git_path_merge_labels(r), "r");
     +
    -+	if (strbuf_read_file(&buf, git_path_merge_labels(r), 0) < 0)
    ++	if (!fp)
     +		return -1;
     +
    -+	p = buf.buf;
    -+	if (parse_merge_label_line(&p, &base))
    -+		goto out;
    -+	if (parse_merge_label_line(&p, &ours))
    -+		goto out;
    -+	if (parse_merge_label_line(&p, &theirs))
    -+		goto out;
    ++	base = parse_merge_label_line(&buf, fp);
    ++	if (!base)
    ++		goto out;
    ++
    ++	ours = parse_merge_label_line(&buf, fp);
    ++	if (!ours)
    ++		goto out;
    ++
    ++	theirs = parse_merge_label_line(&buf, fp);
    ++	if (!theirs)
    ++		goto out;
    ++
     +	ret = 0;
     +	*pbase = base;
     +	*pours = ours;
    @@ merge.c: int checkout_fast_forward(struct repository *r,
     +		free(ours);
     +		free(theirs);
     +	}
    ++	fclose(fp);
     +	strbuf_release(&buf);
     +
     +	return ret;
-- 
2.56.0.134.g299a3c16181
Phillip WoodOct 5, 2026, 13:24 UTC in reply to Phillip Wood on lore

[PATCH v2 2/2] merge: remember conflict labels

From: Phillip Wood <phillip.wood@dunelm.org.uk>

When recreating merge conflicts with "git checkout -m <path>" the original conflict labels are lost. For commands like "git merge" and "git cherry-pick" we could use the presence of the related root ref (MERGE_HEAD and CHERRY_PICK_HEAD respectively) to recreate the labels. However, if the conflicts are from "git stash pop" or "git checkout -m <branch>", then there is no ref to deduce the labels from. To ensure the labels are always available, the merge machinery is updated to write ".git/MERGE_LABELS" when it updates the worktree and there are conflicts. The labels are then read from that file by "git checkout -m <path>" when recreating the conflicts.

As "git checkout -m <branch>" calls remove_branch_state() which ordinarily removes the labels file, we need to pass a flag down to optionally prevent that so that the labels are available for any subsequent "git checkout -m <path>". Note that merge_switch_to_result() we assign "result->priv" to "opt->priv" and later clear "opt->priv" in order to get a pointer to the private struct as result->priv is void*.

Signed-off-by: Phillip Wood <phillip.wood@dunelm.org.uk>
---
 branch.c           | 11 ++++++--
 branch.h           |  1 +
 builtin/checkout.c | 24 ++++++++++++++---
 builtin/commit.c   |  1 +
 merge-ort.c        | 19 ++++++++++++++
 merge.c            | 64 ++++++++++++++++++++++++++++++++++++++++++++++
 merge.h            |  4 +++
 path.c             |  1 +
 path.h             |  1 +
 repository.c       |  1 +
 repository.h       |  1 +
 sequencer.c        |  1 +
 t/t7201-co.sh      | 21 +++++++++++++++
 13 files changed, 144 insertions(+), 6 deletions(-)
Show changes to 13 files +144 −6

branch.c, branch.h, builtin/checkout.c, builtin/commit.c, merge-ort.c, merge.c, merge.h, path.c, path.h, repository.c, repository.h, sequencer.c, t/t7201-co.sh

diff --git a/branch.c b/branch.c
index 8bc7a395a7..5bb1c28915 100644
--- a/branch.c
+++ b/branch.c
@@ -860,9 +860,11 @@ void create_branches_recursively(struct repository *r, const char *name,
 	free(branch_point);
 }
 
-void remove_merge_branch_state(struct repository *r)
+static void do_remove_merge_branch_state(struct repository *r, unsigned flags)
 {
 	unlink(git_path_merge_head(r));
+	if (!(flags & REMOVE_BRANCH_STATE_PRESERVE_CONFLICT_LABELS))
+		unlink(git_path_merge_labels(r));
 	unlink(git_path_merge_rr(r));
 	unlink(git_path_merge_msg(r));
 	unlink(git_path_merge_mode(r));
@@ -871,11 +873,16 @@ void remove_merge_branch_state(struct repository *r)
 	save_autostash_ref(r, "MERGE_AUTOSTASH");
 }
 
+void remove_merge_branch_state(struct repository *r)
+{
+	do_remove_merge_branch_state(r, 0);
+}
+
 void remove_branch_state(struct repository *r, unsigned flags)
 {
 	sequencer_post_commit_cleanup(r, flags & REMOVE_BRANCH_STATE_VERBOSE);
 	unlink(git_path_squash_msg(r));
-	remove_merge_branch_state(r);
+	do_remove_merge_branch_state(r, flags);
 }
 
 void die_if_checked_out(const char *branch, int ignore_current_worktree)
diff --git a/branch.h b/branch.h
index 42d1b12918..95b2431f24 100644
--- a/branch.h
+++ b/branch.h
@@ -128,6 +128,7 @@ int validate_branchname(const char *name, struct strbuf *ref);
 int validate_new_branchname(const char *name, struct strbuf *ref, int force);
 
 #define REMOVE_BRANCH_STATE_VERBOSE (1u << 0)
+#define REMOVE_BRANCH_STATE_PRESERVE_CONFLICT_LABELS (1u << 1)
 /*
  * Remove information about the merge state on the current
  * branch. (E.g., MERGE_HEAD)
diff --git a/builtin/checkout.c b/builtin/checkout.c
index bdd2d816b6..295fe0e9fa 100644
--- a/builtin/checkout.c
+++ b/builtin/checkout.c
@@ -15,6 +15,7 @@
 #include "hex.h"
 #include "hook.h"
 #include "merge-ll.h"
+#include "merge.h"
 #include "lockfile.h"
 #include "mem-pool.h"
 #include "object-file.h"
@@ -317,6 +318,7 @@ static int checkout_merged(int pos, const struct checkout *state,
 	struct cache_entry *ce = the_repository->index->cache[pos];
 	const char *path = ce->name;
 	mmfile_t ancestor, ours, theirs;
+	char *base_label, *ours_label, *theirs_label;
 	enum ll_merge_result merge_status;
 	int status;
 	struct object_id oid;
@@ -347,10 +349,19 @@ static int checkout_merged(int pos, const struct checkout *state,
 
 	repo_config_get_bool(the_repository, "merge.renormalize", &renormalize);
 	ll_opts.renormalize = renormalize;
+	if (read_merge_labels(the_repository, &base_label, &ours_label,
+			      &theirs_label)) {
+		base_label = xstrdup("base");
+		ours_label = xstrdup("ours");
+		theirs_label = xstrdup("theirs");
+	}
 	ll_opts.conflict_style = conflict_style;
-	merge_status = ll_merge(&result_buf, path, &ancestor, "base",
-				&ours, "ours", &theirs, "theirs",
+	merge_status = ll_merge(&result_buf, path, &ancestor, base_label,
+				&ours, ours_label, &theirs, theirs_label,
 				state->istate, &ll_opts);
+	free(base_label);
+	free(ours_label);
+	free(theirs_label);
 	free(ancestor.ptr);
 	free(ours.ptr);
 	free(theirs.ptr);
@@ -946,7 +957,8 @@ static void report_tracking(struct branch_info *new_branch_info)
 
 static void update_refs_for_switch(const struct checkout_opts *opts,
 				   struct branch_info *old_branch_info,
-				   struct branch_info *new_branch_info)
+				   struct branch_info *new_branch_info,
+				   bool merge_conflicts)
 {
 	struct strbuf msg = STRBUF_INIT;
 	const char *old_desc, *reflog_msg;
@@ -1048,6 +1060,8 @@ static void update_refs_for_switch(const struct checkout_opts *opts,
 	}
 	if (!opts->quiet)
 		flags |= REMOVE_BRANCH_STATE_VERBOSE;
+	if (merge_conflicts)
+		flags |= REMOVE_BRANCH_STATE_PRESERVE_CONFLICT_LABELS;
 	remove_branch_state(the_repository, flags);
 	strbuf_release(&msg);
 	if (!opts->quiet &&
@@ -1262,7 +1276,9 @@ static int switch_branches(const struct checkout_opts *opts,
 
 	if (autostash_res == STASH_APPLY_CONFLICT && !opts->quiet)
 		fputc('\n', stderr);
-	update_refs_for_switch(opts, &old_branch_info, new_branch_info);
+
+	update_refs_for_switch(opts, &old_branch_info, new_branch_info,
+			       autostash_res == STASH_APPLY_CONFLICT);
 
 	if (created_autostash) {
 		discard_index(the_repository->index);
diff --git a/builtin/commit.c b/builtin/commit.c
index 205fbd57e3..c374d5e0d5 100644
--- a/builtin/commit.c
+++ b/builtin/commit.c
@@ -1977,6 +1977,7 @@ int cmd_commit(int argc,
 
 	sequencer_post_commit_cleanup(the_repository, 0);
 	unlink(git_path_merge_head(the_repository));
+	unlink(git_path_merge_labels(the_repository));
 	unlink(git_path_merge_msg(the_repository));
 	unlink(git_path_merge_mode(the_repository));
 	unlink(git_path_squash_msg(the_repository));
diff --git a/merge-ort.c b/merge-ort.c
index c410a5d353..783e74c6e3 100644
--- a/merge-ort.c
+++ b/merge-ort.c
@@ -35,6 +35,7 @@
 #include "hex.h"
 #include "entry.h"
 #include "merge-ll.h"
+#include "merge.h"
 #include "match-trees.h"
 #include "mem-pool.h"
 #include "object-file.h"
@@ -418,6 +419,9 @@ struct merge_options_internal {
 
 	/* field that holds submodule conflict information */
 	struct string_list conflicted_submodules;
+
+	/* Copies of the labels used for conflict markers */
+	char *labels[3];
 };
 
 struct conflicted_submodule_item {
@@ -4969,6 +4973,13 @@ void merge_switch_to_result(struct merge_options *opt,
 			return;
 		}
 		trace2_region_leave("merge", "write_auto_merge", opt->repo);
+
+		trace2_region_enter("merge", "write_merge_labels", opt->repo);
+		opt->priv = result->priv;
+		write_merge_labels(opt->repo, opt->priv->labels[0], opt->priv->labels[1],
+				   opt->priv->labels[2]);
+		opt->priv = NULL;
+		trace2_region_leave("merge", "write_merge_labels", opt->repo);
 	}
 	if (display_update_msgs)
 		merge_display_update_messages(opt, /* detailed */ 0, result);
@@ -5234,6 +5245,14 @@ static void move_opt_priv_to_result_priv(struct merge_options *opt,
 	 * to move it.
 	 */
 	assert(opt->priv && !result->priv);
+	if (!result->clean) {
+		opt->priv->labels[0] =
+			mem_pool_strdup(&opt->priv->pool, opt->ancestor);
+		opt->priv->labels[1] =
+			mem_pool_strdup(&opt->priv->pool, opt->branch1);
+		opt->priv->labels[2] =
+			mem_pool_strdup(&opt->priv->pool, opt->branch2);
+	}
 	result->priv = opt->priv;
 	result->_properly_initialized = RESULT_INITIALIZED;
 	opt->priv = NULL;
diff --git a/merge.c b/merge.c
index 0f5e823e63..892a78e0c9 100644
--- a/merge.c
+++ b/merge.c
@@ -8,6 +8,7 @@
 #include "merge.h"
 #include "commit.h"
 #include "repository.h"
+#include "path.h"
 #include "run-command.h"
 #include "resolve-undo.h"
 #include "tree.h"
@@ -111,3 +112,66 @@ int checkout_fast_forward(struct repository *r,
 		return error(_("unable to write new index file"));
 	return 0;
 }
+
+int write_merge_labels(struct repository *r, const char *base,
+			  const char *ours, const char *theirs)
+{
+	FILE *f = fopen_or_warn(git_path_merge_labels(r), "w");
+
+	if (!f)
+		return -1;
+
+	fprintf(f, "%s\n%s\n%s\n", base, ours, theirs);
+	if (fclose(f))
+		return error_errno("could not write '%s'",
+				   git_path_merge_labels(r));
+
+	return 0;
+}
+
+static char *parse_merge_label_line(struct strbuf *buf, FILE *fp)
+{
+	if (strbuf_getline(buf, fp) == EOF)
+		return NULL;
+
+	return xmemdupz(buf->buf, buf->len);
+}
+
+int read_merge_labels(struct repository *r,
+		      char **pbase, char** pours, char** ptheirs)
+{
+	struct strbuf buf = STRBUF_INIT;
+	char *base = NULL, *ours = NULL, *theirs = NULL;
+	int ret = -1;
+	FILE *fp = fopen(git_path_merge_labels(r), "r");
+
+	if (!fp)
+		return -1;
+
+	base = parse_merge_label_line(&buf, fp);
+	if (!base)
+		goto out;
+
+	ours = parse_merge_label_line(&buf, fp);
+	if (!ours)
+		goto out;
+
+	theirs = parse_merge_label_line(&buf, fp);
+	if (!theirs)
+		goto out;
+
+	ret = 0;
+	*pbase = base;
+	*pours = ours;
+	*ptheirs = theirs;
+out:
+	if (ret) {
+		free(base);
+		free(ours);
+		free(theirs);
+	}
+	fclose(fp);
+	strbuf_release(&buf);
+
+	return ret;
+}
diff --git a/merge.h b/merge.h
index 21ac7ef2f1..0772737a87 100644
--- a/merge.h
+++ b/merge.h
@@ -13,5 +13,9 @@ int checkout_fast_forward(struct repository *r,
 			  const struct object_id *from,
 			  const struct object_id *to,
 			  int overwrite_ignore);
+int write_merge_labels(struct repository *r,
+		       const char *base, const char *ours, const char *theirs);
+int read_merge_labels(struct repository *r,
+		      char **base, char **ours, char **theirs);
 
 #endif /* MERGE_H */
diff --git a/path.c b/path.c
index c3a709a928..7965762602 100644
--- a/path.c
+++ b/path.c
@@ -1655,3 +1655,4 @@ REPO_GIT_PATH_FUNC(merge_mode, "MERGE_MODE")
 REPO_GIT_PATH_FUNC(merge_head, "MERGE_HEAD")
 REPO_GIT_PATH_FUNC(fetch_head, "FETCH_HEAD")
 REPO_GIT_PATH_FUNC(shallow, "shallow")
+REPO_GIT_PATH_FUNC(merge_labels, "MERGE_LABELS")
diff --git a/path.h b/path.h
index 7e7408dd05..8cd12ccfde 100644
--- a/path.h
+++ b/path.h
@@ -142,6 +142,7 @@ const char *git_path_merge_mode(struct repository *r);
 const char *git_path_merge_head(struct repository *r);
 const char *git_path_fetch_head(struct repository *r);
 const char *git_path_shallow(struct repository *r);
+const char *git_path_merge_labels(struct repository *r);
 
 int ends_with_path_components(const char *path, const char *components);
 
diff --git a/repository.c b/repository.c
index b857e1c580..210fb819b0 100644
--- a/repository.c
+++ b/repository.c
@@ -367,6 +367,7 @@ static void repo_clear_path_cache(struct repo_path_cache *cache)
 	FREE_AND_NULL(cache->merge_head);
 	FREE_AND_NULL(cache->fetch_head);
 	FREE_AND_NULL(cache->shallow);
+	FREE_AND_NULL(cache->merge_labels);
 }
 
 void repo_clear(struct repository *repo)
diff --git a/repository.h b/repository.h
index 11f5c2ed10..91b1f57db7 100644
--- a/repository.h
+++ b/repository.h
@@ -36,6 +36,7 @@ struct repo_path_cache {
 	char *merge_head;
 	char *fetch_head;
 	char *shallow;
+	char *merge_labels;
 };
 
 struct repository {
diff --git a/sequencer.c b/sequencer.c
index e25ef5eb61..0710aa6400 100644
--- a/sequencer.c
+++ b/sequencer.c
@@ -5145,6 +5145,7 @@ static int pick_commits(struct repository *r,
 	unlink(rebase_path_stopped_sha());
 	unlink(rebase_path_amend());
 	unlink(rebase_path_patch());
+	unlink(git_path_merge_labels(r));
 
 	while (todo_list->current < todo_list->nr) {
 		struct todo_item *item = todo_list->items + todo_list->current;
diff --git a/t/t7201-co.sh b/t/t7201-co.sh
index 9ea9462914..7d0dcf8c8b 100755
--- a/t/t7201-co.sh
+++ b/t/t7201-co.sh
@@ -183,6 +183,27 @@ test_expect_success 'format of merge conflict from checkout -m' '
 	d
 	>>>>>>> local
 	EOF
+	test_cmp expect two &&
+
+	test_path_is_file .git/MERGE_LABELS &&
+
+	git checkout --conflict=diff3 two &&
+	cat >expect <<-\EOF &&
+	<<<<<<< simple
+	a
+	c
+	e
+	||||||| main
+	a
+	b
+	c
+	d
+	e
+	=======
+	b
+	d
+	>>>>>>> local
+	EOF
 	test_cmp expect two
 '
 
-- 
2.56.0.134.g299a3c16181
Johannes SixtOct 5, 2026, 14:48 UTC in reply to Phillip Wood on lore

Re: [PATCH v2 0/2] checkout -m: recreate conflict labels

Am 05.10.26 um 15:24 schrieb Phillip Wood:
Show 9 quoted lines
> As "git checkout -m" is recreating the original conflict I wonder
> if we should remember the conflict style as well so that
> 
>     git -c merge.conflictStyle=diff3 git merge topic
>     git checkout -m <unmerged-path>
> 
> would recreate diff3 style conflicts, instead of using the default
> config. I cannot decide if that would be convenient or confusing and
> am interested to hear what others think.

I think it hurts more than it helps. For example, I usually get away with the regular conflict markers, but at times I might decide to see the diff3 version. Then I could change the conflict marker style with

   git checkout --conflict=diff3 -m <unmerged-path>
It would be disappointing if this were not possible.
-- Hannes
Phillip WoodOct 5, 2026, 15:09 UTC in reply to Johannes Sixt on lore

Re: [PATCH v2 0/2] checkout -m: recreate conflict labels

On 05/10/2026 15:48, Johannes Sixt wrote:
Show 15 quoted lines
> Am 05.10.26 um 15:24 schrieb Phillip Wood:
>> As "git checkout -m" is recreating the original conflict I wonder
>> if we should remember the conflict style as well so that
>>
>>      git -c merge.conflictStyle=diff3 git merge topic
>>      git checkout -m <unmerged-path>
>>
>> would recreate diff3 style conflicts, instead of using the default
>> config. I cannot decide if that would be convenient or confusing and
>> am interested to hear what others think.
> > I think it hurts more than it helps. For example, I usually get away
> with the regular conflict markers, but at times I might decide to see
> the diff3 version. Then I could change the conflict marker style with
> >     git checkout --conflict=diff3 -m <unmerged-path>
> > It would be disappointing if this were not possible.
I do the same thing. What I'm talking about above is "git checkout -m" using the same conflict style as the command that created the conflicts when the user does not specify a conflict style for the checkout.
    git checkout --conflict=<style> ...
and
    git -c merge.conflictStyle=<style> checkout -m ...
would continue to work as they do now.
Thanks
Phillip
Junio C HamanoOct 5, 2026, 15:53 UTC in reply to Phillip Wood on lore

Re: [PATCH v2 0/2] checkout -m: recreate conflict labels

Phillip Wood <phillip.wood123@gmail.com> writes:
Show 16 quoted lines
> When "git checkout -m <path>" recreates a merge conflict, it uses
> the labels "base", "ours", "theirs", rather than the labels used by
> the original merge. This short series teaches the ort machinery to
> write the labels to ".git/MERGE_LABELS" when it switches to a merge
> result containing conflicts, so that "git checkout -m" can then read
> that file and use the same labels.
>
> As "git checkout -m" is recreating the original conflict I wonder
> if we should remember the conflict style as well so that
>
>     git -c merge.conflictStyle=diff3 git merge topic
>     git checkout -m <unmerged-path>
>
> would recreate diff3 style conflicts, instead of using the default
> config. I cannot decide if that would be convenient or confusing and
> am interested to hear what others think.

It has been quite a while since I invented and last looked at the code paths for "checkout -m", but we should use the usual mechanism to decide what conflict style to use, so the only scenario that it makes difference between recording and not recording is the case you showed, i.e., the original merge was made with one-shot custom conflict style that is different from usual.

As "git checkout -m" can be used twice, after the above sequence, you can

    $ git -c merge.conflictStyle=diff3 checkout -m <path>

to recover without losing any work. If your regular style is "merge", then the following sequence might be more commonly useful:

    $ git merge topic
    $ git diff
    ... stare at the diff output, feeling lost trying to
    ... figure out what the correct resolution would be.
    $ git -c merge.conflictStyle=diff3 checkout -m \*
    $ git diff
    ... now with the common ancestor version, you understand
    ... what both sides wanted to do better.
Junio C HamanoOct 5, 2026, 16:19 UTC in reply to Phillip Wood on lore

Re: [PATCH v2 2/2] merge: remember conflict labels

Phillip Wood <phillip.wood123@gmail.com> writes:
Show 5 quoted lines
> @@ -128,6 +128,7 @@ int validate_branchname(const char *name, struct strbuf *ref);
>  int validate_new_branchname(const char *name, struct strbuf *ref, int force);
>  
>  #define REMOVE_BRANCH_STATE_VERBOSE (1u << 0)
> +#define REMOVE_BRANCH_STATE_PRESERVE_CONFLICT_LABELS (1u << 1)

Not complaining and I have no improvement suggestions, but this phrasing made me imagine that we would be passing this flag bit in code paths where we want to write the extra file out.

But that does not match the reality. merge_switch_to_result() calls write_merge_labels() unconditionally. The bit controls if the file written survives the clean-up after the operation.

Show 16 quoted lines
> @@ -946,7 +957,8 @@ static void report_tracking(struct branch_info *new_branch_info)
>  
>  static void update_refs_for_switch(const struct checkout_opts *opts,
>  				   struct branch_info *old_branch_info,
> -				   struct branch_info *new_branch_info)
> +				   struct branch_info *new_branch_info,
> +				   bool merge_conflicts)
>  {
>  	struct strbuf msg = STRBUF_INIT;
>  	const char *old_desc, *reflog_msg;
> @@ -1048,6 +1060,8 @@ static void update_refs_for_switch(const struct checkout_opts *opts,
>  	}
>  	if (!opts->quiet)
>  		flags |= REMOVE_BRANCH_STATE_VERBOSE;
> +	if (merge_conflicts)
> +		flags |= REMOVE_BRANCH_STATE_PRESERVE_CONFLICT_LABELS;
OK.
Show 11 quoted lines
>  	remove_branch_state(the_repository, flags);
>  	strbuf_release(&msg);
>  	if (!opts->quiet &&
> @@ -1262,7 +1276,9 @@ static int switch_branches(const struct checkout_opts *opts,
>  
>  	if (autostash_res == STASH_APPLY_CONFLICT && !opts->quiet)
>  		fputc('\n', stderr);
> -	update_refs_for_switch(opts, &old_branch_info, new_branch_info);
> +
> +	update_refs_for_switch(opts, &old_branch_info, new_branch_info,
> +			       autostash_res == STASH_APPLY_CONFLICT);

OK, so here we assume STASH_APPLY_CONFLICT result means we called write_merge_labels() and left the file. If not, we did not call it and the file should not be there.

But then can't we just unconditionally leave the file, instead of not removing what we wouldn't have created?

Show 12 quoted lines
> diff --git a/builtin/commit.c b/builtin/commit.c
> index 205fbd57e3..c374d5e0d5 100644
> --- a/builtin/commit.c
> +++ b/builtin/commit.c
> @@ -1977,6 +1977,7 @@ int cmd_commit(int argc,
>  
>  	sequencer_post_commit_cleanup(the_repository, 0);
>  	unlink(git_path_merge_head(the_repository));
> +	unlink(git_path_merge_labels(the_repository));
>  	unlink(git_path_merge_msg(the_repository));
>  	unlink(git_path_merge_mode(the_repository));
>  	unlink(git_path_squash_msg(the_repository));

Here we clean it up unconditionally after we are about to successfully finish "git commit".

Show 14 quoted lines
> @@ -4969,6 +4973,13 @@ void merge_switch_to_result(struct merge_options *opt,
>  			return;
>  		}
>  		trace2_region_leave("merge", "write_auto_merge", opt->repo);
> +
> +		trace2_region_enter("merge", "write_merge_labels", opt->repo);
> +		opt->priv = result->priv;
> +		write_merge_labels(opt->repo, opt->priv->labels[0], opt->priv->labels[1],
> +				   opt->priv->labels[2]);
> +		opt->priv = NULL;
> +		trace2_region_leave("merge", "write_merge_labels", opt->repo);
>  	}
>  	if (display_update_msgs)
>  		merge_display_update_messages(opt, /* detailed */ 0, result);
Show 12 quoted lines
> @@ -5234,6 +5245,14 @@ static void move_opt_priv_to_result_priv(struct merge_options *opt,
>  	 * to move it.
>  	 */
>  	assert(opt->priv && !result->priv);
> +	if (!result->clean) {
> +		opt->priv->labels[0] =
> +			mem_pool_strdup(&opt->priv->pool, opt->ancestor);
> +		opt->priv->labels[1] =
> +			mem_pool_strdup(&opt->priv->pool, opt->branch1);
> +		opt->priv->labels[2] =
> +			mem_pool_strdup(&opt->priv->pool, opt->branch2);
> +	}

OK, merge_switch_to_result() is the only thing that consumes these, and it will never happen after we call merge_finalize() where we destroy the mempool, so this allocation should be safe.

Show 7 quoted lines
> +static char *parse_merge_label_line(struct strbuf *buf, FILE *fp)
> +{
> +	if (strbuf_getline(buf, fp) == EOF)
> +		return NULL;
> +
> +	return xmemdupz(buf->buf, buf->len);
> +}
Wouldn't strbuf_detach() be more intuitive?
> +int read_merge_labels(struct repository *r,
> +		      char **pbase, char** pours, char** ptheirs)
Be consistent.  Asterisk sticks to variables, not types.
Show 20 quoted lines
> +{
> +	struct strbuf buf = STRBUF_INIT;
> +	char *base = NULL, *ours = NULL, *theirs = NULL;
> +	int ret = -1;
> +	FILE *fp = fopen(git_path_merge_labels(r), "r");
> +
> +	if (!fp)
> +		return -1;
> +
> +	base = parse_merge_label_line(&buf, fp);
> +	if (!base)
> +		goto out;
> +
> +	ours = parse_merge_label_line(&buf, fp);
> +	if (!ours)
> +		goto out;
> +
> +	theirs = parse_merge_label_line(&buf, fp);
> +	if (!theirs)
> +		goto out;
The repetitions are a bit annoying, but it does not get much better:
	int i;
	char bot[3] = {0}; /* base, ours, theirs */
	for (i = 0; i < ARRAY_SIZE(bot); i++)
        	if (!(bot[i] = parse_merge_label_line(&buf, fp)))
			goto out;
so I am OK with what was posted.

It may be helpful to future developers to leave a comment that we deliberately ignore cruft after these three lines in the file and why, instead of diagnosing it as an error.

Show 15 quoted lines
> +	ret = 0;
> +	*pbase = base;
> +	*pours = ours;
> +	*ptheirs = theirs;
> +out:
> +	if (ret) {
> +		free(base);
> +		free(ours);
> +		free(theirs);
> +	}
> +	fclose(fp);
> +	strbuf_release(&buf);
> +
> +	return ret;
> +}
Junio C HamanoOct 5, 2026, 16:31 UTC in reply to Phillip Wood on lore

Re: [PATCH v2 2/2] merge: remember conflict labels

Phillip Wood <phillip.wood123@gmail.com> writes:
> Note that merge_switch_to_result()
> we assign "result->priv" to "opt->priv" and later clear "opt->priv" in
> order to get a pointer to the private struct as result->priv is void*.
I missed this part.
Show 8 quoted lines
>  		trace2_region_leave("merge", "write_auto_merge", opt->repo);
> +
> +		trace2_region_enter("merge", "write_merge_labels", opt->repo);
> +		opt->priv = result->priv;
> +		write_merge_labels(opt->repo, opt->priv->labels[0], opt->priv->labels[1],
> +				   opt->priv->labels[2]);
> +		opt->priv = NULL;
> +		trace2_region_leave("merge", "write_merge_labels", opt->repo);
Would it be better to do it this way instead?
	struct merge_options_internal *priv = result->priv;
	write_merge_labels(opt->repo,
			   priv->labels[0], priv->labels[1], priv->labels[2]);

Also, with the way merge labels are prepared and passed around, I wonder if we should just tighten its function signature and take

	write_merge_labels(struct repository *repo, const char *labels[3])
so that this calling site becomes[*]
	struct merge_options_internal *priv = result->priv;
	write_merge_labels(opt->repo, priv->labels);
[Footnote]
 * Here, I deviate from the usual naming convention to call an array
   of things in singular (so the second label would become
   label[2]), because from the point of view of the API consumer,
   "labels" as a unit is what they pass around, and call it in
   plural.

Back to recent threads

[PATCH 0/2] checkout -m: recreate conflict labels | The Git List