diff --git a/Cargo.lock b/Cargo.lock index 083a1ea1c78..a7b7c19c122 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2465,6 +2465,7 @@ dependencies = [ "gix-revwalk 0.16.0", "gix-testtools", "gix-trace 0.1.11", + "permutohedron", "serde", "thiserror", ] @@ -3780,6 +3781,12 @@ version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" +[[package]] +name = "permutohedron" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b687ff7b5da449d39e418ad391e5e08da53ec334903ddbb921db208908fc372c" + [[package]] name = "pin-project" version = "1.1.5" diff --git a/crate-status.md b/crate-status.md index 24f179e2e8e..b0316d7bf5c 100644 --- a/crate-status.md +++ b/crate-status.md @@ -621,6 +621,8 @@ Make it the best-performing implementation and the most convenient one. ### gix-revision * [x] `describe()` (similar to `git name-rev`) +* [x] merge-base +* [x] merge-base octopus * parse specifications * [x] parsing and navigation * [x] revision ranges diff --git a/gix-revision/Cargo.toml b/gix-revision/Cargo.toml index dd869916b10..54003abfbb3 100644 --- a/gix-revision/Cargo.toml +++ b/gix-revision/Cargo.toml @@ -44,6 +44,7 @@ document-features = { version = "0.2.1", optional = true } [dev-dependencies] gix-odb = { path = "../gix-odb" } gix-testtools = { path = "../tests/tools" } +permutohedron = "0.2.4" [package.metadata.docs.rs] all-features = true diff --git a/gix-revision/src/merge_base.rs b/gix-revision/src/merge_base.rs deleted file mode 100644 index 39eab9c97dd..00000000000 --- a/gix-revision/src/merge_base.rs +++ /dev/null @@ -1,251 +0,0 @@ -bitflags::bitflags! { - /// The flags used in the graph for finding [merge bases](crate::merge_base()). - #[derive(Debug, Default, Copy, Clone, Eq, PartialEq)] - pub struct Flags: u8 { - /// The commit belongs to the graph reachable by the first commit - const COMMIT1 = 1 << 0; - /// The commit belongs to the graph reachable by all other commits. - const COMMIT2 = 1 << 1; - - /// Marks the commit as done, it's reachable by both COMMIT1 and COMMIT2. - const STALE = 1 << 2; - /// The commit was already put ontto the results list. - const RESULT = 1 << 3; - } -} - -/// The error returned by the [`merge_base()`][function::merge_base()] function. -#[derive(Debug, thiserror::Error)] -#[allow(missing_docs)] -pub enum Error { - #[error("A commit could not be inserted into the graph")] - InsertCommit(#[from] gix_revwalk::graph::get_or_insert_default::Error), -} - -pub(crate) mod function { - use super::Error; - use crate::{merge_base::Flags, Graph, PriorityQueue}; - use gix_hash::ObjectId; - use gix_revwalk::graph; - use std::cmp::Ordering; - - /// Given a commit at `first` id, traverse the commit `graph` and return all possible merge-base between it and `others`, - /// sorted from best to worst. Returns `None` if there is no merge-base as `first` and `others` don't share history. - /// If `others` is empty, `Some(first)` is returned. - /// - /// Note that this function doesn't do any work if `first` is contained in `others`, which is when `first` will be returned - /// as only merge-base right away. This is even the case if some commits of `others` are disjoint. - /// - /// # Performance - /// - /// For repeated calls, be sure to re-use `graph` as its content will be kept and reused for a great speed-up. The contained flags - /// will automatically be cleared. - pub fn merge_base( - first: ObjectId, - others: &[ObjectId], - graph: &mut Graph<'_, '_, graph::Commit>, - ) -> Result>, Error> { - let _span = gix_trace::coarse!("gix_revision::merge_base()", ?first, ?others); - if others.is_empty() || others.contains(&first) { - return Ok(Some(vec![first])); - } - - graph.clear_commit_data(|f| *f = Flags::empty()); - let bases = paint_down_to_common(first, others, graph)?; - - let bases = remove_redundant(&bases, graph)?; - Ok((!bases.is_empty()).then_some(bases)) - } - - /// Remove all those commits from `commits` if they are in the history of another commit in `commits`. - /// That way, we return only the topologically most recent commits in `commits`. - fn remove_redundant( - commits: &[(ObjectId, GenThenTime)], - graph: &mut Graph<'_, '_, graph::Commit>, - ) -> Result, Error> { - if commits.is_empty() { - return Ok(Vec::new()); - } - graph.clear_commit_data(|f| *f = Flags::empty()); - let _span = gix_trace::detail!("gix_revision::remove_redundant()", num_commits = %commits.len()); - let sorted_commits = { - let mut v = commits.to_vec(); - v.sort_by(|a, b| a.1.cmp(&b.1)); - v - }; - let mut min_gen_pos = 0; - let mut min_gen = sorted_commits[min_gen_pos].1.generation; - - let mut walk_start = Vec::with_capacity(commits.len()); - for (id, _) in commits { - let commit = graph.get_mut(id).expect("previously added"); - commit.data |= Flags::RESULT; - for parent_id in commit.parents.clone() { - graph.get_or_insert_full_commit(parent_id, |parent| { - // prevent double-addition - if !parent.data.contains(Flags::STALE) { - parent.data |= Flags::STALE; - walk_start.push((parent_id, GenThenTime::from(&*parent))); - } - })?; - } - } - walk_start.sort_by(|a, b| a.0.cmp(&b.0)); - // allow walking everything at first. - walk_start - .iter_mut() - .for_each(|(id, _)| graph.get_mut(id).expect("added previously").data.remove(Flags::STALE)); - let mut count_still_independent = commits.len(); - - let mut stack = Vec::new(); - while let Some((commit_id, commit_info)) = walk_start.pop().filter(|_| count_still_independent > 1) { - stack.clear(); - graph.get_mut(&commit_id).expect("added").data |= Flags::STALE; - stack.push((commit_id, commit_info)); - - while let Some((commit_id, commit_info)) = stack.last().copied() { - let commit = graph.get_mut(&commit_id).expect("all commits have been added"); - let commit_parents = commit.parents.clone(); - if commit.data.contains(Flags::RESULT) { - commit.data.remove(Flags::RESULT); - count_still_independent -= 1; - if count_still_independent <= 1 { - break; - } - if *commit_id == *sorted_commits[min_gen_pos].0 { - while min_gen_pos < commits.len() - 1 - && graph - .get(&sorted_commits[min_gen_pos].0) - .expect("already added") - .data - .contains(Flags::STALE) - { - min_gen_pos += 1; - } - min_gen = sorted_commits[min_gen_pos].1.generation; - } - } - - if commit_info.generation < min_gen { - stack.pop(); - continue; - } - - let previous_len = stack.len(); - for parent_id in &commit_parents { - if graph - .get_or_insert_full_commit(*parent_id, |parent| { - if !parent.data.contains(Flags::STALE) { - parent.data |= Flags::STALE; - stack.push((*parent_id, GenThenTime::from(&*parent))); - } - })? - .is_some() - { - break; - } - } - - if previous_len == stack.len() { - stack.pop(); - } - } - } - - Ok(commits - .iter() - .filter_map(|(id, _info)| { - graph - .get(id) - .filter(|commit| !commit.data.contains(Flags::STALE)) - .map(|_| *id) - }) - .collect()) - } - - fn paint_down_to_common( - first: ObjectId, - others: &[ObjectId], - graph: &mut Graph<'_, '_, graph::Commit>, - ) -> Result, Error> { - let mut queue = PriorityQueue::::new(); - graph.get_or_insert_full_commit(first, |commit| { - commit.data |= Flags::COMMIT1; - queue.insert(GenThenTime::from(&*commit), first); - })?; - - for other in others { - graph.get_or_insert_full_commit(*other, |commit| { - commit.data |= Flags::COMMIT2; - queue.insert(GenThenTime::from(&*commit), *other); - })?; - } - - let mut out = Vec::new(); - while queue.iter_unordered().any(|id| { - graph - .get(id) - .map_or(false, |commit| !commit.data.contains(Flags::STALE)) - }) { - let (info, commit_id) = queue.pop().expect("we have non-stale"); - let commit = graph.get_mut(&commit_id).expect("everything queued is in graph"); - let mut flags_without_result = commit.data & (Flags::COMMIT1 | Flags::COMMIT2 | Flags::STALE); - if flags_without_result == (Flags::COMMIT1 | Flags::COMMIT2) { - if !commit.data.contains(Flags::RESULT) { - commit.data |= Flags::RESULT; - out.push((commit_id, info)); - } - flags_without_result |= Flags::STALE; - } - - for parent_id in commit.parents.clone() { - graph.get_or_insert_full_commit(parent_id, |parent| { - if (parent.data & flags_without_result) != flags_without_result { - parent.data |= flags_without_result; - queue.insert(GenThenTime::from(&*parent), parent_id); - } - })?; - } - } - - Ok(out) - } - - // TODO(ST): Should this type be used for `describe` as well? - #[derive(Debug, Clone, Copy)] - struct GenThenTime { - /// Note that the special [`GENERATION_NUMBER_INFINITY`](gix_commitgraph::GENERATION_NUMBER_INFINITY) is used to indicate - /// that no commitgraph is available. - generation: gix_revwalk::graph::Generation, - time: gix_date::SecondsSinceUnixEpoch, - } - - impl From<&graph::Commit> for GenThenTime { - fn from(commit: &graph::Commit) -> Self { - GenThenTime { - generation: commit.generation.unwrap_or(gix_commitgraph::GENERATION_NUMBER_INFINITY), - time: commit.commit_time, - } - } - } - - impl Eq for GenThenTime {} - - impl PartialEq for GenThenTime { - fn eq(&self, other: &Self) -> bool { - self.cmp(other).is_eq() - } - } - - impl PartialOrd for GenThenTime { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) - } - } - - impl Ord for GenThenTime { - fn cmp(&self, other: &Self) -> Ordering { - self.generation.cmp(&other.generation).then(self.time.cmp(&other.time)) - } - } -} diff --git a/gix-revision/src/merge_base/function.rs b/gix-revision/src/merge_base/function.rs new file mode 100644 index 00000000000..95903efad46 --- /dev/null +++ b/gix-revision/src/merge_base/function.rs @@ -0,0 +1,230 @@ +use super::Error; +use crate::{merge_base::Flags, Graph, PriorityQueue}; +use gix_hash::ObjectId; +use gix_revwalk::graph; +use std::cmp::Ordering; + +/// Given a commit at `first` id, traverse the commit `graph` and return all possible merge-base between it and `others`, +/// sorted from best to worst. Returns `None` if there is no merge-base as `first` and `others` don't share history. +/// If `others` is empty, `Some(first)` is returned. +/// +/// Note that this function doesn't do any work if `first` is contained in `others`, which is when `first` will be returned +/// as only merge-base right away. This is even the case if some commits of `others` are disjoint. +/// +/// Additionally, this function isn't stable and results may differ dependeing on the order in which `first` and `others` are +/// provided due to its special rules. +/// +/// If a stable result is needed, use [`merge_base::octopus()`](crate::merge_base::octopus()). +/// +/// # Performance +/// +/// For repeated calls, be sure to re-use `graph` as its content will be kept and reused for a great speed-up. The contained flags +/// will automatically be cleared. +pub fn merge_base( + first: ObjectId, + others: &[ObjectId], + graph: &mut Graph<'_, '_, graph::Commit>, +) -> Result>, Error> { + let _span = gix_trace::coarse!("gix_revision::merge_base()", ?first, ?others); + if others.is_empty() || others.contains(&first) { + return Ok(Some(vec![first])); + } + + graph.clear_commit_data(|f| *f = Flags::empty()); + let bases = paint_down_to_common(first, others, graph)?; + + let bases = remove_redundant(&bases, graph)?; + Ok((!bases.is_empty()).then_some(bases)) +} + +/// Remove all those commits from `commits` if they are in the history of another commit in `commits`. +/// That way, we return only the topologically most recent commits in `commits`. +fn remove_redundant( + commits: &[(ObjectId, GenThenTime)], + graph: &mut Graph<'_, '_, graph::Commit>, +) -> Result, Error> { + if commits.is_empty() { + return Ok(Vec::new()); + } + graph.clear_commit_data(|f| *f = Flags::empty()); + let _span = gix_trace::detail!("gix_revision::remove_redundant()", num_commits = %commits.len()); + let sorted_commits = { + let mut v = commits.to_vec(); + v.sort_by(|a, b| a.1.cmp(&b.1)); + v + }; + let mut min_gen_pos = 0; + let mut min_gen = sorted_commits[min_gen_pos].1.generation; + + let mut walk_start = Vec::with_capacity(commits.len()); + for (id, _) in commits { + let commit = graph.get_mut(id).expect("previously added"); + commit.data |= Flags::RESULT; + for parent_id in commit.parents.clone() { + graph.get_or_insert_full_commit(parent_id, |parent| { + // prevent double-addition + if !parent.data.contains(Flags::STALE) { + parent.data |= Flags::STALE; + walk_start.push((parent_id, GenThenTime::from(&*parent))); + } + })?; + } + } + walk_start.sort_by(|a, b| a.0.cmp(&b.0)); + // allow walking everything at first. + walk_start + .iter_mut() + .for_each(|(id, _)| graph.get_mut(id).expect("added previously").data.remove(Flags::STALE)); + let mut count_still_independent = commits.len(); + + let mut stack = Vec::new(); + while let Some((commit_id, commit_info)) = walk_start.pop().filter(|_| count_still_independent > 1) { + stack.clear(); + graph.get_mut(&commit_id).expect("added").data |= Flags::STALE; + stack.push((commit_id, commit_info)); + + while let Some((commit_id, commit_info)) = stack.last().copied() { + let commit = graph.get_mut(&commit_id).expect("all commits have been added"); + let commit_parents = commit.parents.clone(); + if commit.data.contains(Flags::RESULT) { + commit.data.remove(Flags::RESULT); + count_still_independent -= 1; + if count_still_independent <= 1 { + break; + } + if *commit_id == *sorted_commits[min_gen_pos].0 { + while min_gen_pos < commits.len() - 1 + && graph + .get(&sorted_commits[min_gen_pos].0) + .expect("already added") + .data + .contains(Flags::STALE) + { + min_gen_pos += 1; + } + min_gen = sorted_commits[min_gen_pos].1.generation; + } + } + + if commit_info.generation < min_gen { + stack.pop(); + continue; + } + + let previous_len = stack.len(); + for parent_id in &commit_parents { + if graph + .get_or_insert_full_commit(*parent_id, |parent| { + if !parent.data.contains(Flags::STALE) { + parent.data |= Flags::STALE; + stack.push((*parent_id, GenThenTime::from(&*parent))); + } + })? + .is_some() + { + break; + } + } + + if previous_len == stack.len() { + stack.pop(); + } + } + } + + Ok(commits + .iter() + .filter_map(|(id, _info)| { + graph + .get(id) + .filter(|commit| !commit.data.contains(Flags::STALE)) + .map(|_| *id) + }) + .collect()) +} + +fn paint_down_to_common( + first: ObjectId, + others: &[ObjectId], + graph: &mut Graph<'_, '_, graph::Commit>, +) -> Result, Error> { + let mut queue = PriorityQueue::::new(); + graph.get_or_insert_full_commit(first, |commit| { + commit.data |= Flags::COMMIT1; + queue.insert(GenThenTime::from(&*commit), first); + })?; + + for other in others { + graph.get_or_insert_full_commit(*other, |commit| { + commit.data |= Flags::COMMIT2; + queue.insert(GenThenTime::from(&*commit), *other); + })?; + } + + let mut out = Vec::new(); + while queue.iter_unordered().any(|id| { + graph + .get(id) + .map_or(false, |commit| !commit.data.contains(Flags::STALE)) + }) { + let (info, commit_id) = queue.pop().expect("we have non-stale"); + let commit = graph.get_mut(&commit_id).expect("everything queued is in graph"); + let mut flags_without_result = commit.data & (Flags::COMMIT1 | Flags::COMMIT2 | Flags::STALE); + if flags_without_result == (Flags::COMMIT1 | Flags::COMMIT2) { + if !commit.data.contains(Flags::RESULT) { + commit.data |= Flags::RESULT; + out.push((commit_id, info)); + } + flags_without_result |= Flags::STALE; + } + + for parent_id in commit.parents.clone() { + graph.get_or_insert_full_commit(parent_id, |parent| { + if (parent.data & flags_without_result) != flags_without_result { + parent.data |= flags_without_result; + queue.insert(GenThenTime::from(&*parent), parent_id); + } + })?; + } + } + + Ok(out) +} + +// TODO(ST): Should this type be used for `describe` as well? +#[derive(Debug, Clone, Copy)] +struct GenThenTime { + /// Note that the special [`GENERATION_NUMBER_INFINITY`](gix_commitgraph::GENERATION_NUMBER_INFINITY) is used to indicate + /// that no commitgraph is available. + generation: gix_revwalk::graph::Generation, + time: gix_date::SecondsSinceUnixEpoch, +} + +impl From<&graph::Commit> for GenThenTime { + fn from(commit: &graph::Commit) -> Self { + GenThenTime { + generation: commit.generation.unwrap_or(gix_commitgraph::GENERATION_NUMBER_INFINITY), + time: commit.commit_time, + } + } +} + +impl Eq for GenThenTime {} + +impl PartialEq for GenThenTime { + fn eq(&self, other: &Self) -> bool { + self.cmp(other).is_eq() + } +} + +impl PartialOrd for GenThenTime { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for GenThenTime { + fn cmp(&self, other: &Self) -> Ordering { + self.generation.cmp(&other.generation).then(self.time.cmp(&other.time)) + } +} diff --git a/gix-revision/src/merge_base/mod.rs b/gix-revision/src/merge_base/mod.rs new file mode 100644 index 00000000000..d6e00e49915 --- /dev/null +++ b/gix-revision/src/merge_base/mod.rs @@ -0,0 +1,57 @@ +bitflags::bitflags! { + /// The flags used in the graph for finding [merge bases](crate::merge_base()). + #[derive(Debug, Default, Copy, Clone, Eq, PartialEq)] + pub struct Flags: u8 { + /// The commit belongs to the graph reachable by the first commit + const COMMIT1 = 1 << 0; + /// The commit belongs to the graph reachable by all other commits. + const COMMIT2 = 1 << 1; + + /// Marks the commit as done, it's reachable by both COMMIT1 and COMMIT2. + const STALE = 1 << 2; + /// The commit was already put ontto the results list. + const RESULT = 1 << 3; + } +} + +/// The error returned by the [`merge_base()`][function::merge_base()] function. +#[derive(Debug, thiserror::Error)] +#[allow(missing_docs)] +pub enum Error { + #[error("A commit could not be inserted into the graph")] + InsertCommit(#[from] gix_revwalk::graph::get_or_insert_default::Error), +} + +pub(crate) mod function; + +mod octopus { + use crate::merge_base::{Error, Flags}; + use gix_hash::ObjectId; + use gix_revwalk::{graph, Graph}; + + /// Given a commit at `first` id, traverse the commit `graph` and return *the best common ancestor* between it and `others`, + /// sorted from best to worst. Returns `None` if there is no common merge-base as `first` and `others` don't *all* share history. + /// If `others` is empty, `Some(first)` is returned. + /// + /// # Performance + /// + /// For repeated calls, be sure to re-use `graph` as its content will be kept and reused for a great speed-up. The contained flags + /// will automatically be cleared. + pub fn octopus( + mut first: ObjectId, + others: &[ObjectId], + graph: &mut Graph<'_, '_, graph::Commit>, + ) -> Result, Error> { + for other in others { + if let Some(next) = + crate::merge_base(first, std::slice::from_ref(other), graph)?.and_then(|bases| bases.into_iter().next()) + { + first = next; + } else { + return Ok(None); + } + } + Ok(Some(first)) + } +} +pub use octopus::octopus; diff --git a/gix-revision/tests/fixtures/generated-archives/merge_base_octopus_repos.tar b/gix-revision/tests/fixtures/generated-archives/merge_base_octopus_repos.tar new file mode 100644 index 00000000000..a7e3aed260e Binary files /dev/null and b/gix-revision/tests/fixtures/generated-archives/merge_base_octopus_repos.tar differ diff --git a/gix-revision/tests/fixtures/merge_base_octopus_repos.sh b/gix-revision/tests/fixtures/merge_base_octopus_repos.sh new file mode 100644 index 00000000000..94b6b73435b --- /dev/null +++ b/gix-revision/tests/fixtures/merge_base_octopus_repos.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -eu -o pipefail + +git init three-sequential-commits +(cd three-sequential-commits + git commit -m "A" --allow-empty + git commit -m "B" --allow-empty + git commit -m "C" --allow-empty +) + +git init three-parallel-commits +(cd three-parallel-commits + git commit -m "BASE" --allow-empty + git branch A + git branch B + git branch C + + git checkout A + git commit -m "A" --allow-empty + + git checkout B + git commit -m "B" --allow-empty + + git checkout C + git commit -m "C" --allow-empty +) + +git init three-forked-commits +(cd three-forked-commits + git commit -m "BASE" --allow-empty + git branch A + + git checkout -b C + git commit -m "C" --allow-empty + + git checkout A + git commit -m "A-1" --allow-empty + git branch B + git commit -m "A-2" --allow-empty + + git checkout B + git commit -m "B" --allow-empty +) diff --git a/gix-revision/tests/merge_base/mod.rs b/gix-revision/tests/merge_base/mod.rs deleted file mode 100644 index a4bb86224d8..00000000000 --- a/gix-revision/tests/merge_base/mod.rs +++ /dev/null @@ -1,90 +0,0 @@ -mod baseline { - use bstr::ByteSlice; - use gix_hash::ObjectId; - use gix_revision::merge_base; - use std::ffi::OsStr; - use std::path::{Path, PathBuf}; - - #[test] - fn validate() -> crate::Result { - let root = gix_testtools::scripted_fixture_read_only("make_merge_base_repos.sh")?; - let mut count = 0; - let odb = gix_odb::at(root.join(".git/objects"))?; - for baseline_path in expectation_paths(&root)? { - count += 1; - for use_commitgraph in [false, true] { - let cache = use_commitgraph - .then(|| gix_commitgraph::Graph::from_info_dir(&odb.store_ref().path().join("info")).unwrap()); - for expected in parse_expectations(&baseline_path)? { - let mut graph = gix_revision::Graph::new(&odb, cache.as_ref()); - let actual = merge_base(expected.first, &expected.others, &mut graph)?; - assert_eq!( - actual, - expected.bases, - "sample {file:?}:{input}", - file = baseline_path.with_extension("").file_name(), - input = expected.plain_input - ); - } - let mut graph = gix_revision::Graph::new(&odb, cache.as_ref()); - for expected in parse_expectations(&baseline_path)? { - let actual = merge_base(expected.first, &expected.others, &mut graph)?; - assert_eq!( - actual, - expected.bases, - "sample (reused graph) {file:?}:{input}", - file = baseline_path.with_extension("").file_name(), - input = expected.plain_input - ); - } - } - } - assert_ne!(count, 0, "there must be at least one baseline"); - Ok(()) - } - - /// The expectation as produced by Git itself - #[derive(Debug)] - struct Expectation { - plain_input: String, - first: ObjectId, - others: Vec, - bases: Option>, - } - - fn parse_expectations(baseline: &Path) -> std::io::Result> { - let lines = std::fs::read(baseline)?; - let mut lines = lines.lines(); - let mut out = Vec::new(); - while let Some(plain_input) = lines.next() { - let plain_input = plain_input.to_str_lossy().into_owned(); - let mut input = lines - .next() - .expect("second line is resolved input objects") - .split(|b| *b == b' '); - let first = ObjectId::from_hex(input.next().expect("at least one object")).unwrap(); - let others = input.map(|hex_id| ObjectId::from_hex(hex_id).unwrap()).collect(); - let bases: Vec<_> = lines - .by_ref() - .take_while(|l| !l.is_empty()) - .map(|hex_id| ObjectId::from_hex(hex_id).unwrap()) - .collect(); - out.push(Expectation { - plain_input, - first, - others, - bases: if bases.is_empty() { None } else { Some(bases) }, - }); - } - Ok(out) - } - - fn expectation_paths(root: &Path) -> std::io::Result> { - let mut out: Vec<_> = std::fs::read_dir(root)? - .map(Result::unwrap) - .filter_map(|e| (e.path().extension() == Some(OsStr::new("baseline"))).then(|| e.path())) - .collect(); - out.sort(); - Ok(out) - } -} diff --git a/gix-revision/tests/describe/format.rs b/gix-revision/tests/revision/describe/format.rs similarity index 100% rename from gix-revision/tests/describe/format.rs rename to gix-revision/tests/revision/describe/format.rs diff --git a/gix-revision/tests/describe/mod.rs b/gix-revision/tests/revision/describe/mod.rs similarity index 100% rename from gix-revision/tests/describe/mod.rs rename to gix-revision/tests/revision/describe/mod.rs diff --git a/gix-revision/tests/revision.rs b/gix-revision/tests/revision/main.rs similarity index 100% rename from gix-revision/tests/revision.rs rename to gix-revision/tests/revision/main.rs diff --git a/gix-revision/tests/revision/merge_base/mod.rs b/gix-revision/tests/revision/merge_base/mod.rs new file mode 100644 index 00000000000..565c6298447 --- /dev/null +++ b/gix-revision/tests/revision/merge_base/mod.rs @@ -0,0 +1,157 @@ +use gix_revision::merge_base; + +#[test] +fn validate() -> crate::Result { + let root = gix_testtools::scripted_fixture_read_only("make_merge_base_repos.sh")?; + let mut count = 0; + let odb = gix_odb::at(root.join(".git/objects"))?; + for baseline_path in baseline::expectation_paths(&root)? { + count += 1; + for use_commitgraph in [false, true] { + let cache = use_commitgraph + .then(|| gix_commitgraph::Graph::from_info_dir(&odb.store_ref().path().join("info")).unwrap()); + for expected in baseline::parse_expectations(&baseline_path)? { + let mut graph = gix_revision::Graph::new(&odb, cache.as_ref()); + let actual = merge_base(expected.first, &expected.others, &mut graph)?; + assert_eq!( + actual, + expected.bases, + "sample {file:?}:{input}", + file = baseline_path.with_extension("").file_name(), + input = expected.plain_input + ); + } + let mut graph = gix_revision::Graph::new(&odb, cache.as_ref()); + for expected in baseline::parse_expectations(&baseline_path)? { + let actual = merge_base(expected.first, &expected.others, &mut graph)?; + assert_eq!( + actual, + expected.bases, + "sample (reused graph) {file:?}:{input}", + file = baseline_path.with_extension("").file_name(), + input = expected.plain_input + ); + } + } + } + assert_ne!(count, 0, "there must be at least one baseline"); + Ok(()) +} + +mod octopus { + use crate::hex_to_id; + + #[test] + fn three_sequential_commits() -> crate::Result { + let odb = odb_at("three-sequential-commits")?; + let mut graph = gix_revision::Graph::new(&odb, None); + let first_commit = hex_to_id("e5d0542bd38431f105a8de8e982b3579647feb9f"); + let mut heads = vec![ + hex_to_id("4fbed377d3eab982d4a465cafaf34b64207da847"), + hex_to_id("8bc2f99c9aacf07568a2bbfe1269f6e543f22d6b"), + first_commit, + ]; + let mut heap = permutohedron::Heap::new(&mut heads); + while let Some(heads) = heap.next_permutation() { + let actual = gix_revision::merge_base::octopus(*heads.first().unwrap(), &heads[1..], &mut graph)? + .expect("a merge base"); + assert_eq!(actual, first_commit); + } + Ok(()) + } + + #[test] + fn three_parallel_commits() -> crate::Result { + let odb = odb_at("three-parallel-commits")?; + let mut graph = gix_revision::Graph::new(&odb, None); + let base = hex_to_id("3ca3e3dd12585fabbef311d524a5e54678090528"); + let mut heads = vec![ + hex_to_id("4ce66b336dff547fdeb6cd86e04c617c8d998ff5"), + hex_to_id("6291f6d7da04208dc4ccbbdf9fda98ac9ae67bc0"), + hex_to_id("c507d5413da00c32e5de1ea433030e8e4716bc60"), + ]; + let mut heap = permutohedron::Heap::new(&mut heads); + while let Some(heads) = heap.next_permutation() { + let actual = gix_revision::merge_base::octopus(*heads.first().unwrap(), &heads[1..], &mut graph)? + .expect("a merge base"); + assert_eq!(actual, base); + } + Ok(()) + } + + #[test] + fn three_forked_commits() -> crate::Result { + let odb = odb_at("three-forked-commits")?; + let mut graph = gix_revision::Graph::new(&odb, None); + let base = hex_to_id("3ca3e3dd12585fabbef311d524a5e54678090528"); + let mut heads = vec![ + hex_to_id("413d38a3fe7453c68cb7314739d7775f68ab89f5"), + hex_to_id("d4d01a9b6f6fcb23d57cd560229cd9680ec9bd6e"), + hex_to_id("c507d5413da00c32e5de1ea433030e8e4716bc60"), + ]; + let mut heap = permutohedron::Heap::new(&mut heads); + while let Some(heads) = heap.next_permutation() { + let actual = gix_revision::merge_base::octopus(*heads.first().unwrap(), &heads[1..], &mut graph)? + .expect("a merge base"); + assert_eq!(actual, base); + } + Ok(()) + } + + fn odb_at(name: &str) -> crate::Result { + let root = gix_testtools::scripted_fixture_read_only("merge_base_octopus_repos.sh")?; + Ok(gix_odb::at(root.join(name).join(".git/objects"))?) + } +} + +mod baseline { + use bstr::ByteSlice; + use gix_hash::ObjectId; + use std::ffi::OsStr; + use std::path::{Path, PathBuf}; + + /// The expectation as produced by Git itself + #[derive(Debug)] + pub struct Expectation { + pub plain_input: String, + pub first: ObjectId, + pub others: Vec, + pub bases: Option>, + } + + pub fn parse_expectations(baseline: &Path) -> std::io::Result> { + let lines = std::fs::read(baseline)?; + let mut lines = lines.lines(); + let mut out = Vec::new(); + while let Some(plain_input) = lines.next() { + let plain_input = plain_input.to_str_lossy().into_owned(); + let mut input = lines + .next() + .expect("second line is resolved input objects") + .split(|b| *b == b' '); + let first = ObjectId::from_hex(input.next().expect("at least one object")).unwrap(); + let others = input.map(|hex_id| ObjectId::from_hex(hex_id).unwrap()).collect(); + let bases: Vec<_> = lines + .by_ref() + .take_while(|l| !l.is_empty()) + .map(|hex_id| ObjectId::from_hex(hex_id).unwrap()) + .collect(); + out.push(Expectation { + plain_input, + first, + others, + bases: if bases.is_empty() { None } else { Some(bases) }, + }); + } + Ok(out) + } + + pub fn expectation_paths(root: &Path) -> std::io::Result> { + let mut out: Vec<_> = std::fs::read_dir(root)? + .map(Result::unwrap) + .filter_map(|e| (e.path().extension() == Some(OsStr::new("baseline"))).then(|| e.path())) + .collect(); + out.sort(); + Ok(out) + } +} diff --git a/gix-revision/tests/spec/display.rs b/gix-revision/tests/revision/spec/display.rs similarity index 100% rename from gix-revision/tests/spec/display.rs rename to gix-revision/tests/revision/spec/display.rs diff --git a/gix-revision/tests/spec/mod.rs b/gix-revision/tests/revision/spec/mod.rs similarity index 100% rename from gix-revision/tests/spec/mod.rs rename to gix-revision/tests/revision/spec/mod.rs diff --git a/gix-revision/tests/spec/parse/anchor/at_symbol.rs b/gix-revision/tests/revision/spec/parse/anchor/at_symbol.rs similarity index 100% rename from gix-revision/tests/spec/parse/anchor/at_symbol.rs rename to gix-revision/tests/revision/spec/parse/anchor/at_symbol.rs diff --git a/gix-revision/tests/spec/parse/anchor/colon_symbol.rs b/gix-revision/tests/revision/spec/parse/anchor/colon_symbol.rs similarity index 100% rename from gix-revision/tests/spec/parse/anchor/colon_symbol.rs rename to gix-revision/tests/revision/spec/parse/anchor/colon_symbol.rs diff --git a/gix-revision/tests/spec/parse/anchor/describe.rs b/gix-revision/tests/revision/spec/parse/anchor/describe.rs similarity index 100% rename from gix-revision/tests/spec/parse/anchor/describe.rs rename to gix-revision/tests/revision/spec/parse/anchor/describe.rs diff --git a/gix-revision/tests/spec/parse/anchor/hash.rs b/gix-revision/tests/revision/spec/parse/anchor/hash.rs similarity index 100% rename from gix-revision/tests/spec/parse/anchor/hash.rs rename to gix-revision/tests/revision/spec/parse/anchor/hash.rs diff --git a/gix-revision/tests/spec/parse/anchor/mod.rs b/gix-revision/tests/revision/spec/parse/anchor/mod.rs similarity index 100% rename from gix-revision/tests/spec/parse/anchor/mod.rs rename to gix-revision/tests/revision/spec/parse/anchor/mod.rs diff --git a/gix-revision/tests/spec/parse/anchor/refnames.rs b/gix-revision/tests/revision/spec/parse/anchor/refnames.rs similarity index 100% rename from gix-revision/tests/spec/parse/anchor/refnames.rs rename to gix-revision/tests/revision/spec/parse/anchor/refnames.rs diff --git a/gix-revision/tests/spec/parse/kind.rs b/gix-revision/tests/revision/spec/parse/kind.rs similarity index 100% rename from gix-revision/tests/spec/parse/kind.rs rename to gix-revision/tests/revision/spec/parse/kind.rs diff --git a/gix-revision/tests/spec/parse/mod.rs b/gix-revision/tests/revision/spec/parse/mod.rs similarity index 100% rename from gix-revision/tests/spec/parse/mod.rs rename to gix-revision/tests/revision/spec/parse/mod.rs diff --git a/gix-revision/tests/spec/parse/navigate/caret_symbol.rs b/gix-revision/tests/revision/spec/parse/navigate/caret_symbol.rs similarity index 100% rename from gix-revision/tests/spec/parse/navigate/caret_symbol.rs rename to gix-revision/tests/revision/spec/parse/navigate/caret_symbol.rs diff --git a/gix-revision/tests/spec/parse/navigate/colon_symbol.rs b/gix-revision/tests/revision/spec/parse/navigate/colon_symbol.rs similarity index 100% rename from gix-revision/tests/spec/parse/navigate/colon_symbol.rs rename to gix-revision/tests/revision/spec/parse/navigate/colon_symbol.rs diff --git a/gix-revision/tests/spec/parse/navigate/mod.rs b/gix-revision/tests/revision/spec/parse/navigate/mod.rs similarity index 100% rename from gix-revision/tests/spec/parse/navigate/mod.rs rename to gix-revision/tests/revision/spec/parse/navigate/mod.rs diff --git a/gix-revision/tests/spec/parse/navigate/tilde_symbol.rs b/gix-revision/tests/revision/spec/parse/navigate/tilde_symbol.rs similarity index 100% rename from gix-revision/tests/spec/parse/navigate/tilde_symbol.rs rename to gix-revision/tests/revision/spec/parse/navigate/tilde_symbol.rs diff --git a/gix/src/repository/merge.rs b/gix/src/repository/merge.rs index 72f38795bd1..d501359cacb 100644 --- a/gix/src/repository/merge.rs +++ b/gix/src/repository/merge.rs @@ -233,6 +233,8 @@ impl Repository { /// /// Note that most of `options` are overwritten to match the requirements of a merge-base merge, but they can be useful /// to control the diff algorithm or rewrite tracking, for example. + /// + /// This method is useful in conjunction with [`Self::merge_trees()`], as the ancestor tree can be produced here. // TODO: test pub fn virtual_merge_base( &self, diff --git a/gix/src/repository/mod.rs b/gix/src/repository/mod.rs index 069ce119b07..4d5ca4093fa 100644 --- a/gix/src/repository/mod.rs +++ b/gix/src/repository/mod.rs @@ -171,7 +171,7 @@ pub mod virtual_merge_base_with_graph { #[derive(Debug, thiserror::Error)] #[allow(missing_docs)] pub enum Error { - #[error("Not commit was provided as merge-base")] + #[error("No commit was provided as merge-base")] MissingCommit, #[error(transparent)] MergeResourceCache(#[from] super::merge_resource_cache::Error), @@ -186,6 +186,36 @@ pub mod virtual_merge_base_with_graph { } } +/// +#[cfg(feature = "revision")] +pub mod merge_base_octopus_with_graph { + /// The error returned by [Repository::merge_base_octopus_with_graph()](crate::Repository::merge_base_octopus_with_graph()). + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error("No commit was provided")] + MissingCommit, + #[error("No merge base was found between the given commits")] + NoMergeBase, + #[error(transparent)] + MergeBase(#[from] gix_revision::merge_base::Error), + } +} + +/// +#[cfg(feature = "revision")] +pub mod merge_base_octopus { + /// The error returned by [Repository::merge_base_octopus()](crate::Repository::merge_base_octopus()). + #[derive(Debug, thiserror::Error)] + #[allow(missing_docs)] + pub enum Error { + #[error(transparent)] + OpenCache(#[from] crate::repository::commit_graph_if_enabled::Error), + #[error(transparent)] + MergeBaseOctopus(#[from] super::merge_base_octopus_with_graph::Error), + } +} + /// #[cfg(feature = "merge")] pub mod tree_merge_options { diff --git a/gix/src/repository/revision.rs b/gix/src/repository/revision.rs index 4ea96c4850f..f00774572ca 100644 --- a/gix/src/repository/revision.rs +++ b/gix/src/repository/revision.rs @@ -104,6 +104,40 @@ impl crate::Repository { .collect()) } + /// Return the best merge-base among all `commits`, or fail if `commits` yields no commit or no merge-base was found. + /// + /// Use `graph` to speed up repeated calls. + #[cfg(feature = "revision")] + pub fn merge_base_octopus_with_graph( + &self, + commits: impl IntoIterator>, + graph: &mut gix_revwalk::Graph<'_, '_, gix_revwalk::graph::Commit>, + ) -> Result, crate::repository::merge_base_octopus_with_graph::Error> { + use crate::prelude::ObjectIdExt; + use crate::repository::merge_base_octopus_with_graph; + let commits: Vec<_> = commits.into_iter().map(Into::into).collect(); + let first = commits + .first() + .copied() + .ok_or(merge_base_octopus_with_graph::Error::MissingCommit)?; + gix_revision::merge_base::octopus(first, &commits[1..], graph)? + .ok_or(merge_base_octopus_with_graph::Error::NoMergeBase) + .map(|id| id.attach(self)) + } + + /// Return the best merge-base among all `commits`, or fail if `commits` yields no commit or no merge-base was found. + /// + /// For repeated calls, prefer [`Self::merge_base_octopus_with_graph()`] for cache-reuse. + #[cfg(feature = "revision")] + pub fn merge_base_octopus( + &self, + commits: impl IntoIterator>, + ) -> Result, crate::repository::merge_base_octopus::Error> { + let cache = self.commit_graph_if_enabled()?; + let mut graph = self.revision_graph(cache.as_ref()); + Ok(self.merge_base_octopus_with_graph(commits, &mut graph)?) + } + /// Create the baseline for a revision walk by initializing it with the `tips` to start iterating on. /// /// It can be configured further before starting the actual walk.