merge-ort: add a new mergeability_only option

Git Forges may be interested in whether two branches can be merged while not being interested in what the resulting merge tree is nor which files conflicted. For such cases, add a new mergeability_only option. This option allows the merge machinery to, in the "outer layer" of the merge: * exit upon first[-ish] conflict * avoid (not prevent) writing merged blobs/trees to the object store I have a number of qualifiers there, so let me explain each: "outer layer": Note that since the recursive merge of merge bases (corresponding to call_depth > 0) can conflict without the outer final merge (corresponding to call_depth == 0) conflicting, we can't short-circuit nor avoid writing merged blobs/trees to the object store during those inner merges. "first-ish conflict": The current patch only exits early from process_entries() on the first conflict it detects, but conflicts could have been detected in a previous function call, namely detect_and_process_renames(). However: * conflicts detected by detect_and_process_renames() are quite rare conflict types * the detection would still come after regular rename detection (which is the expensive part of detect_and_process_renames()), so it is not saving us much in computation time given that process_entries() directly follows detect_and_process_renames() * [this overlaps with the next bullet point] process_entries() is the place where virtually all object writing occurs (object writing is sometimes more of a concern for Forges than computation time), so exiting early here isn't saving us much in object writes either * the code changes needed to handle an earlier exit are slightly more invasive in detect_and_process_renames() than for process_entries(). Given the rareness of the even earlier conflicts, the limited savings we'd get from exiting even earlier, and in an attempt to keep this patch simpler, we don't guarantee that we actually exit on the first conflict detected. We can always revisit this decision later if we decide that a further micro-optimization to exit slightly earlier in rare cases is worthwhile. "avoid (not prevent) writing objects": The detect_and_process_renames() call can also write objects to the object store, when rename/rename conflicts involve one (or more) files that have also been modified on both sides. Because of this alternate call path leading to handle_content_merges(), our "early exit" does not prevent writing objects entirely, even within the "outer layer" (i.e. even within call_depth == 0). I figure that's fine though, since we're already writing objects for the inner merges (i.e. for call_depth > 0), which are likely going to represent vastly more objects than files involved in rename/rename+modify/modify cases in the outer merge, on average. Signed-off-by: Elijah Newren <newren@gmail.com> Signed-off-by: Junio C Hamano <gitster@pobox.com>

Elijah Newren committed May 16, 2025 at 20:04 UTC c6d5ca10e3dcee91bcbefaf87753e93e9c1fa7a7
2 files changed +32 -7
merge-ort.c
+31 -7
@@ -2127,6 +2127,7 @@ static int handle_content_merge(struct merge_options *opt,
2127 const struct version_info *b,
2128 const char *pathnames[3],
2129 const int extra_marker_size,
2130 + const int record_object,
2131 struct version_info *result)
2132 {
2133 /*
@@ -2214,7 +2215,7 @@ static int handle_content_merge(struct merge_options *opt,
2215 ret = -1;
2216 }
2217
2217 - if (!ret &&
2218 + if (!ret && record_object &&
2219 write_object_file(result_buf.ptr, result_buf.size,
2220 OBJ_BLOB, &result->oid)) {
2221 path_msg(opt, ERROR_OBJECT_WRITE_FAILED, 0,
@@ -2897,6 +2898,7 @@ static int process_renames(struct merge_options *opt,
2898 struct version_info merged;
2899 struct conflict_info *base, *side1, *side2;
2900 unsigned was_binary_blob = 0;
2901 + const int record_object = true;
2902
2903 pathnames[0] = oldpath;
2904 pathnames[1] = newpath;
@@ -2947,6 +2949,7 @@ static int process_renames(struct merge_options *opt,
2949 &side2->stages[2],
2950 pathnames,
2951 1 + 2 * opt->priv->call_depth,
2952 + record_object,
2953 &merged);
2954 if (clean_merge < 0)
2955 return -1;
@@ -3061,6 +3064,7 @@ static int process_renames(struct merge_options *opt,
3064
3065 struct conflict_info *base, *side1, *side2;
3066 int clean;
3067 + const int record_object = true;
3068
3069 pathnames[0] = oldpath;
3070 pathnames[other_source_index] = oldpath;
@@ -3080,6 +3084,7 @@ static int process_renames(struct merge_options *opt,
3084 &side2->stages[2],
3085 pathnames,
3086 1 + 2 * opt->priv->call_depth,
3087 + record_object,
3088 &merged);
3089 if (clean < 0)
3090 return -1;
@@ -3931,9 +3936,12 @@ static int write_completed_directory(struct merge_options *opt,
3936 * Write out the tree to the git object directory, and also
3937 * record the mode and oid in dir_info->result.
3938 */
3939 + int record_tree = (!opt->mergeability_only ||
3940 + opt->priv->call_depth);
3941 dir_info->is_null = 0;
3942 dir_info->result.mode = S_IFDIR;
3936 - if (write_tree(&dir_info->result.oid, &info->versions, offset,
3943 + if (record_tree &&
3944 + write_tree(&dir_info->result.oid, &info->versions, offset,
3945 opt->repo->hash_algo->rawsz) < 0)
3946 ret = -1;
3947 }
@@ -4231,10 +4239,13 @@ static int process_entry(struct merge_options *opt,
4239 struct version_info *o = &ci->stages[0];
4240 struct version_info *a = &ci->stages[1];
4241 struct version_info *b = &ci->stages[2];
4242 + int record_object = (!opt->mergeability_only ||
4243 + opt->priv->call_depth);
4244
4245 clean_merge = handle_content_merge(opt, path, o, a, b,
4246 ci->pathnames,
4247 opt->priv->call_depth * 2,
4248 + record_object,
4249 &merged_file);
4250 if (clean_merge < 0)
4251 return -1;
@@ -4395,6 +4406,8 @@ static int process_entries(struct merge_options *opt,
4406 STRING_LIST_INIT_NODUP,
4407 NULL, 0 };
4408 int ret = 0;
4409 + const int record_tree = (!opt->mergeability_only ||
4410 + opt->priv->call_depth);
4411
4412 trace2_region_enter("merge", "process_entries setup", opt->repo);
4413 if (strmap_empty(&opt->priv->paths)) {
@@ -4454,6 +4467,12 @@ static int process_entries(struct merge_options *opt,
4467 ret = -1;
4468 goto cleanup;
4469 };
4470 + if (!ci->merged.clean && opt->mergeability_only &&
4471 + !opt->priv->call_depth) {
4472 + ret = 0;
4473 + goto cleanup;
4474 + }
4475 +
4476 }
4477 }
4478 trace2_region_leave("merge", "processing", opt->repo);
@@ -4468,7 +4487,8 @@ static int process_entries(struct merge_options *opt,
4487 fflush(stdout);
4488 BUG("dir_metadata accounting completely off; shouldn't happen");
4489 }
4471 - if (write_tree(result_oid, &dir_metadata.versions, 0,
4490 + if (record_tree &&
4491 + write_tree(result_oid, &dir_metadata.versions, 0,
4492 opt->repo->hash_algo->rawsz) < 0)
4493 ret = -1;
4494 cleanup:
@@ -4715,6 +4735,8 @@ void merge_display_update_messages(struct merge_options *opt,
4735
4736 if (opt->record_conflict_msgs_as_headers)
4737 BUG("Either display conflict messages or record them as headers, not both");
4738 + if (opt->mergeability_only)
4739 + BUG("Displaying conflict messages incompatible with mergeability-only checks");
4740
4741 trace2_region_enter("merge", "display messages", opt->repo);
4742
@@ -5171,10 +5193,12 @@ redo:
5193 result->path_messages = &opt->priv->conflicts;
5194
5195 if (result->clean >= 0) {
5174 - result->tree = parse_tree_indirect(&working_tree_oid);
5175 - if (!result->tree)
5176 - die(_("unable to read tree (%s)"),
5177 - oid_to_hex(&working_tree_oid));
5196 + if (!opt->mergeability_only) {
5197 + result->tree = parse_tree_indirect(&working_tree_oid);
5198 + if (!result->tree)
5199 + die(_("unable to read tree (%s)"),
5200 + oid_to_hex(&working_tree_oid));
5201 + }
5202 /* existence of conflicted entries implies unclean */
5203 result->clean &= strmap_empty(&opt->priv->conflicted);
5204 }
merge-ort.h
+1
@@ -83,6 +83,7 @@ struct merge_options {
83 /* miscellaneous control options */
84 const char *subtree_shift;
85 unsigned renormalize : 1;
86 + unsigned mergeability_only : 1; /* exit early, write fewer objects */
87 unsigned record_conflict_msgs_as_headers : 1;
88 const char *msg_header_prefix;
89