diff --git a/knot2/crates/knot-xrpc/src/reads.rs b/knot2/crates/knot-xrpc/src/reads.rs --- a/knot2/crates/knot-xrpc/src/reads.rs +++ b/knot2/crates/knot-xrpc/src/reads.rs @@ -929,6 +929,8 @@ #[derive(Serialize)] struct CompareOut { rev1: String, rev2: String, + #[serde(skip_serializing_if = "Option::is_none")] + merge_base: Option, #[serde(skip_serializing_if = "Vec::is_empty")] format_patch: Vec, #[serde(rename = "patch", skip_serializing_if = "String::is_empty")] @@ -1079,31 +1081,28 @@ }) .collect::, _>>() .map_err(compare_error)?; let patch_raw: String = entries.iter().map(|(_, raw)| format!("{raw}\n")).collect(); - let (combined_patch, combined_patch_raw) = match entries.len() >= 2 { - true => repo - .merge_base(base, head) + let merge_base = repo.merge_base(base, head).ok().flatten(); + let (combined_patch, combined_patch_raw) = match (entries.len() >= 2, merge_base) { + (true, Some(merge_base)) => repo + .commit_patches(knot_git::PatchRange { + base: Some(merge_base), + head, + }) .ok() - .flatten() - .and_then(|merge_base| { - repo.commit_patches(knot_git::PatchRange { - base: Some(merge_base), - head, - }) - .ok() - .map(|patches| { - ( - Some(patches.iter().map(FileWire::of).collect::>()), - Some(render_patches(&patches)), - ) - }) + .map(|patches| { + ( + Some(patches.iter().map(FileWire::of).collect::>()), + Some(render_patches(&patches)), + ) }) .unwrap_or((None, None)), - false => (None, None), + _ => (None, None), }; json( CompareOut { rev1: base.to_hex(), rev2: head.to_hex(), + merge_base: merge_base.map(|oid| oid.to_hex()), format_patch: entries.into_iter().map(|(entry, _)| entry).collect(), patch_raw, combined_patch, diff --git a/knot2/crates/knot-xrpc/tests/reads.rs b/knot2/crates/knot-xrpc/tests/reads.rs --- a/knot2/crates/knot-xrpc/tests/reads.rs +++ b/knot2/crates/knot-xrpc/tests/reads.rs @@ -413,6 +413,51 @@ ); } #[tokio::test] +async fn compare_reports_the_merge_base_of_diverged_branches() { + let world = World::new(); + let (did, work) = seeded(&world, "woofie"); + let work = work.path(); + let bare = world.layout.repo_path(&did).unwrap(); + let merge_base = sh_git(work, &["rev-parse", "HEAD"]); + + sh_git(work, &["checkout", "-q", "-b", "feature"]); + commit_file( + work, + "feature.txt", + b"feature\n", + "feature moved", + "2026-08-11T12:40:00+02:00", + ); + sh_git( + work, + &[ + "push", + "-q", + bare.to_str().unwrap(), + "HEAD:refs/heads/feature", + ], + ); + + sh_git(work, &["checkout", "-q", "main"]); + commit_file( + work, + "main.txt", + b"main\n", + "main moved", + "2026-08-11T12:41:00+02:00", + ); + sh_git(work, &["push", "-q", bare.to_str().unwrap(), "main"]); + + let value = get_json( + &world, + &format!("/xrpc/sh.tangled.repo.compare?repo={did}&rev1=feature&rev2=main"), + ) + .await; + + assert_eq!(value["merge_base"], merge_base.as_str()); +} + +#[tokio::test] async fn diff_reports_structured_fragments_and_stats() { let world = World::new(); let (did, work) = seeded(&world, "whelk"); diff --git a/knotserver/xrpc/repo_compare.go b/knotserver/xrpc/repo_compare.go --- a/knotserver/xrpc/repo_compare.go +++ b/knotserver/xrpc/repo_compare.go @@ -74,12 +74,13 @@ } var combinedPatch []*gitdiff.File var combinedPatchRaw string - // we need the combined patch - if len(formatPatch) >= 2 { - mergeBaseCommit, err := gr.MergeBase(commit1, commit2) - if err != nil { - x.Logger.Error("error comparing revisions", "msg", err.Error()) - } else { + var mergeBase string + if mergeBaseCommit, err := gr.MergeBase(commit1, commit2); err != nil { + x.Logger.Error("error finding merge base", "msg", err.Error()) + } else { + mergeBase = mergeBaseCommit.Hash.String() + // nothing to combine with fewer than two patches + if len(formatPatch) >= 2 { diffTree, err := gr.DiffTree(mergeBaseCommit, commit2) if err != nil { x.Logger.Error("error comparing revisions", "msg", err.Error()) @@ -93,6 +94,7 @@ response := types.RepoFormatPatchResponse{ Rev1: commit1.Hash.String(), Rev2: commit2.Hash.String(), + MergeBase: mergeBase, FormatPatch: formatPatch, FormatPatchRaw: rawPatch, CombinedPatch: combinedPatch, diff --git a/types/repo.go b/types/repo.go --- a/types/repo.go +++ b/types/repo.go @@ -34,6 +34,7 @@ type RepoFormatPatchResponse struct { Rev1 string `json:"rev1,omitempty"` Rev2 string `json:"rev2,omitempty"` + MergeBase string `json:"merge_base,omitempty"` FormatPatch []FormatPatch `json:"format_patch,omitempty"` FormatPatchRaw string `json:"patch,omitempty"` CombinedPatch []*gitdiff.File `json:"combined_patch,omitempty"`