// Copyright 2026 The Jujutsu Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. //! Utility for solving divergence. See //! //! for more details. use std::collections::HashMap; use std::collections::HashSet; use std::hash::Hash; use std::ops::Deref as _; use std::rc::Rc; use std::sync::Arc; use std::sync::Mutex; use futures::StreamExt as _; use futures::TryStreamExt as _; use futures::executor::block_on_stream; use futures::future::try_join_all; use itertools::Itertools as _; use jj_lib::backend::BackendError; use jj_lib::backend::BackendResult; use jj_lib::backend::ChangeId; use jj_lib::backend::CommitId; use jj_lib::backend::Signature; use jj_lib::backend::TreeId; use jj_lib::commit::Commit; use jj_lib::conflict_labels::ConflictLabels; use jj_lib::evolution::WalkPredecessorsError; use jj_lib::evolution::walk_predecessors; use jj_lib::graph_dominators::FlowGraph; use jj_lib::graph_dominators::SimpleDirectedGraph; use jj_lib::graph_dominators::ValueCache; use jj_lib::index::IndexError; use jj_lib::merge::Merge; use jj_lib::merge::MergeBuilder; use jj_lib::merge::SameChange; use jj_lib::merged_tree::MergedTree; use jj_lib::repo::MutableRepo; use jj_lib::repo::ReadonlyRepo; use jj_lib::repo::Repo as _; use jj_lib::revset::ResolvedRevsetExpression; use jj_lib::revset::RevsetEvaluationError; use jj_lib::revset::RevsetExpression; use jj_lib::rewrite::merge_commit_trees_no_resolve; use jj_lib::store::Store; use thiserror::Error; /// Maps change-ids to commits with that change-id. pub type CommitsByChangeId = HashMap>; /// The result of attempting to converge a particular attribute (description, /// author, parents, tree) of a set of divergent commits. #[derive(Debug, PartialEq, Eq, Clone)] pub enum ConvergedAttribute { /// The attribute was successfully merged. Solved(T), /// The attribute could not be merged automatically. Unsolved { /// This is a hint to the caller: merge the attribute of the divergent /// commits (minus the excluded_divergent_commits), using this /// commit as the base. base_commit: CommitId, /// This is a hint to the caller: these divergent commits should be /// excluded from the merge. excluded_divergent_commits: HashSet, }, } /// The proposed solution for converging a change. #[derive(Debug, PartialEq, Eq, Clone)] pub struct ConvergeResult { /// The proposed author. pub author: ConvergedAttribute, /// The proposed description. pub description: ConvergedAttribute, /// The proposed parents. pub parents: ConvergedAttribute>, /// The proposed tree. Not set if we haven't converged the parents yet. pub tree: Option, } /// Errors that can occur during converge. #[derive(Debug, Error)] pub enum ConvergeError { /// A backend error occurred. #[error(transparent)] Backend(#[from] BackendError), /// An index error occurred. #[error(transparent)] Index(#[from] IndexError), /// An error occurred while evaluating the revset expression for finding /// divergent commits. #[error(transparent)] RevsetEvaluation(#[from] RevsetEvaluationError), /// An error occurred while traversing the evolution graph of the divergent /// commits. #[error(transparent)] WalkPredecessors(#[from] WalkPredecessorsError), /// An IO error occurred. #[error(transparent)] IO(#[from] std::io::Error), /// An unexpected error occurred. #[error(transparent)] Other(Box), } /// Evaluates the revset expression and returns those commits that are /// divergent, in the sense that the expression matches two or more commits in /// the result with the same change-id. /// /// The commits are keyed by their change-id. pub async fn find_divergent_changes( repo: &Arc, revset_expression: Arc, ) -> Result { let mut result = CommitsByChangeId::new(); let mut stream = revset_expression.evaluate(repo.as_ref())?.stream(); while let Some(commit_id) = stream.try_next().await? { let commit = repo.store().get_commit_async(&commit_id).await?; result .entry(commit.change_id().clone()) .or_default() .insert(commit.id().clone(), commit); } // Remove entries that have only a single commit — we only care about // changes with multiple divergent commits. result.retain(|_, commits| commits.len() > 1); Ok(result) } /// Attempts to solve divergence in the divergent commits given by the /// TruncatedEvolutionGraph. The caller can provide any subset of author, /// description, parents, and tree, to be used in the solution commit. An /// attempt is made to automatically produce a value for those attributes not /// given by the user. pub async fn converge_change( truncated_evolution_graph: &TruncatedEvolutionGraph, author: Option, description: Option, parents: Option>, tree: Option, ) -> Result { let author = if let Some(author) = author { ConvergedAttribute::Solved(author) } else { converge_author(truncated_evolution_graph).await? }; let description = if let Some(description) = description { ConvergedAttribute::Solved(description) } else { converge_description(truncated_evolution_graph).await? }; let parents = if let Some(parents) = parents { ConvergedAttribute::Solved(parents) } else { converge_parents(truncated_evolution_graph).await? }; let tree = if let Some(tree) = tree { Some(tree) } else if let ConvergedAttribute::Solved(parents) = &parents { let tree = converge_trees(truncated_evolution_graph, parents).await?; Some(TreeIdsAndLabels::new(tree)) } else { None }; Ok(ConvergeResult { author, description, parents, tree, }) } /// Adds a new commit for the proposed solution, as a successor of the divergent /// commits. pub async fn apply_solution( author: Signature, description: String, parents: Vec, tree: TreeIdsAndLabels, change_id: ChangeId, divergent_commit_ids: &Vec, repo_mut: &mut MutableRepo, ) -> Result<(Commit, usize), ConvergeError> { let merged_tree = tree.to_merged_tree(repo_mut.store()); let solution = repo_mut .new_commit(parents, merged_tree) .set_change_id(change_id.clone()) .set_description(description) .set_author(author) .set_predecessors(divergent_commit_ids.clone()) .write() .await?; for divergent_commit_id in divergent_commit_ids { repo_mut.set_rewritten_commit(divergent_commit_id.clone(), solution.id().clone()); } let num_rebased = repo_mut.rebase_descendants().await?; Ok((solution, num_rebased)) } /// The truncated evolution graph for a divergent change. /// /// This is similar to the evolog graph, but truncated in the sense that it only /// contains commits that are for the given change-id, and only goes as far as /// the closest common dominator of the divergent commits. pub struct TruncatedEvolutionGraph { // The repo. repo: Arc, // The commits to converge. divergent_commits: Vec, // The ids of the commits to converge. divergent_commit_ids: Vec, /// The evolution graph of the divergent commits, with edges X->Y if commit /// X is a predecessor of commit Y and both X and Y have the same /// divergent change-id. The graph is not necessarily a tree (commits /// may have multiple predecessors). The start node is the evolution /// fork point. pub flow_graph: FlowGraph, } impl TruncatedEvolutionGraph { /// Builds a truncated evolution graph for the given divergent commits, /// which are expected to all have the same change-id. pub async fn new( repo: Arc, divergent_commits: Vec, ) -> Result { validate( divergent_commits.len() > 1, &format!( "Expected multiple divergent commits, got {}", divergent_commits.len() ), )?; let divergent_commit_ids = divergent_commits .iter() .map(|c| c.id().clone()) .collect_vec(); // Ensure all provided divergent commits belong to the same change-id. // Note: divergent_commits is not empty, so it is ok to unwrap. let divergent_change_id = if divergent_commits.iter().map(|c| c.change_id()).all_equal() { divergent_commits.first().unwrap().change_id().clone() } else { return Err(ConvergeError::Other( "all divergent commits must have the same change-id".into(), )); }; // The list of edges, with commits pointing to their successors. let mut edges = vec![]; let mut seen = HashSet::new(); let mut to_visit = HashSet::with_capacity(divergent_commit_ids.len()); to_visit.extend(divergent_commit_ids.iter().cloned()); let evolution_nodes = block_on_stream( walk_predecessors(&repo, divergent_commit_ids.as_slice()).boxed_local(), ); // These are the commits in the graph that have no predecessors. Typically // there is exactly one entry in initial_nodes (the first commit for the // change-id). let mut initial_nodes = vec![]; for node in evolution_nodes { let entry = node?; let commit_id = entry.commit.id(); if *entry.commit.change_id() != divergent_change_id { // Skip commits with unrelated change ids. continue; } to_visit.remove(commit_id); if !seen.insert(commit_id.clone()) { // TODO: think about this some more. Can 2 different operations result in the // same commit? Maybe the key should be (commit-id, operation-id). // Note: currently walk_predecessors returns an error if the graph is cyclic, so // we shouldn't encounter the same commit twice. But in the future we could // allow cyclic evolution, and if we do there is no reason to disallow it here. // By continuing we future proof this. continue; } let predecessors = entry .predecessors() .await? .iter() .filter_map(|commit| { if *commit.change_id() == divergent_change_id { Some(commit.id().clone()) } else { None } }) .collect_vec(); for predecessor in &predecessors { edges.push((predecessor.clone(), commit_id.clone())); } if predecessors.is_empty() { initial_nodes.push(commit_id.clone()); if to_visit.is_empty() { break; } } else { to_visit.extend(predecessors); } } validate( !initial_nodes.is_empty(), "Unexpected error: initial_nodes should not be empty", )?; // By definition the flow graph must have a single initial node. let initial_node = if initial_nodes.len() == 1 { initial_nodes[0].clone() } else { // In graphs with multiple "real" initial nodes we introduce a virtual initial // node (the root commit) and pretend the two or more "real" initial nodes are // successors of the root commit. let root_commit_id = repo.store().root_commit_id().clone(); for initial_node in initial_nodes { edges.push((root_commit_id.clone(), initial_node)); } root_commit_id }; let flow_graph = FlowGraph::new(SimpleDirectedGraph::new(edges), initial_node); Ok(Self { repo, divergent_commits, divergent_commit_ids, flow_graph, }) } /// Returns the repo. pub fn repo(&self) -> &Arc { &self.repo } /// Returns the divergent commits. pub fn divergent_commits(&self) -> &Vec { &self.divergent_commits } /// Returns the commit ids of the divergent commits. pub fn divergent_commit_ids(&self) -> &Vec { &self.divergent_commit_ids } /// Returns the change-id of the divergent commits. All divergent commits /// are expected to have the same change-id. pub fn change_id(&self) -> &ChangeId { self.divergent_commits[0].change_id() } } async fn converge_author( graph: &TruncatedEvolutionGraph, ) -> Result, ConvergeError> { let value_fn = async |c: &Commit| Ok(c.author().clone()); let excluded_divergent_commits = HashSet::default(); let (value_merge, base_commit) = create_value_merge(graph, &excluded_divergent_commits, value_fn).await?; if let Some(value) = value_merge.resolve_trivial(SameChange::Accept) { Ok(ConvergedAttribute::Solved(value.clone())) } else { Ok(ConvergedAttribute::Unsolved { base_commit, excluded_divergent_commits: HashSet::default(), }) } } async fn converge_description( graph: &TruncatedEvolutionGraph, ) -> Result, ConvergeError> { let value_fn = async |c: &Commit| Ok(c.description().to_string()); let excluded_divergent_commits = HashSet::default(); let (value_merge, base_commit) = create_value_merge(graph, &excluded_divergent_commits, value_fn).await?; if let Some(value) = value_merge.resolve_trivial(SameChange::Accept) { Ok(ConvergedAttribute::Solved(value.clone())) } else { Ok(ConvergedAttribute::Unsolved { base_commit, excluded_divergent_commits: HashSet::default(), }) } } async fn converge_parents( graph: &TruncatedEvolutionGraph, ) -> Result>, ConvergeError> { // Filter out divergent commits that are descendants of other divergent commits // (we cannot use the parents of those commits because that would introduce // cycles when we rebase everything on top of the parents). let viable_commits = remove_descendants(graph.repo(), graph.divergent_commit_ids()).await?; let excluded_divergent_commits: HashSet = graph .divergent_commit_ids() .iter() .filter(|commit_id| !viable_commits.contains(commit_id)) .cloned() .collect(); let get_parents_fn = async |c: &Commit| Ok(c.parent_ids().to_vec()); let (value_merge, base_commit) = create_value_merge(graph, &excluded_divergent_commits, get_parents_fn).await?; if let Some(value) = value_merge.resolve_trivial(SameChange::Accept) { Ok(ConvergedAttribute::Solved(value.clone())) } else { Ok(ConvergedAttribute::Unsolved { base_commit, excluded_divergent_commits, }) } } /// A MergedTree, without the `Arc`. That allows us to derive Eq and Hash /// for it, which we need in some algorithms. #[derive(Eq, Hash, PartialEq, Clone, Debug)] pub struct TreeIdsAndLabels { /// The tree IDs of the merged tree. pub tree_ids: Merge, /// Conflict labels of the merged tree. pub labels: ConflictLabels, } impl TreeIdsAndLabels { /// Creates a new TreeIdsAndLabels. pub fn new(merged_tree: MergedTree) -> Self { let (tree_ids, labels) = merged_tree.into_tree_ids_and_labels(); Self { tree_ids, labels } } /// Converts the TreeIdsAndLabels into a MergedTree. pub fn to_merged_tree(&self, store: &Arc) -> MergedTree { MergedTree::new(store.clone(), self.tree_ids.clone(), self.labels.clone()) } } // Assume A, B, C are the divergent commits, P is the solution parents (i.e. the // parents chosen by converge_parents), and F is a commit chosen as a "good base // for converging trees" as explained below. // // Notation: // * MCTNR: merge_commit_trees_no_resolve // * F^: MCTNR(F.parents()), i.e. the unresolved MergedTree of the parents of F. // * F': the resolved MergedTree of F rebased on top of the tree of P // * A': the resolved MergedTree of A rebased on top of the tree of P // * B': the resolved MergedTree of B rebased on top of the tree of P // * C': the resolved MergedTree of C rebased on top of the tree of P // // Let X be an arbitrary commit. X' is given by: // X' = MergedTree::merge{ MCTNR(P) + (X.tree - X^) } = // = MergedTree::merge{ MCTNR(P) + (X.tree - MCTNR(X.parents())) } // // converge_trees returns: // Solution = MergedTree::merge{ F' + (A' - F') + (B' - F') + (C' - F') } // // What is F? What is a "good base for converging trees"? F is calculated as // follows: // 1. For each commit X in the truncated evolution graph, we calculate // X'.tree_ids() // 2. We build the "Value Transition Graph" of the values from step 1, with // edges between values corresponding to edges in the truncated evolution // graph: if commit X is a predecessor of commit Y, then the value transition // graph has an edge from X'.tree_ids() to Y'.tree_ids() // 3. We find the dominator value of this Value Transition Graph // 4. The dominator value is "produced" from one or more commits in the // truncated evolution graph // 5. F is any of those producer commits (we pick the first one) async fn converge_trees( truncated_evolution_graph: &TruncatedEvolutionGraph, parents: &[CommitId], ) -> Result { let repo = truncated_evolution_graph.repo(); let parent_commits: Vec = try_join_all(parents.iter().map(|id| repo.store().get_commit_async(id))).await?; let parents_merged_tree = merge_commit_trees_no_resolve(repo.as_ref(), &parent_commits).await?; let rebased_resolved_trees = Arc::new(Mutex::new(HashMap::::new())); // We first compute the dominator value of the trees (in the value history graph // of the trees), together with the commit(s) that produce that tree. Any // such commit is a good candidate to be used as the base of the merge. let value_fn = async |commit: &Commit| -> Result, ConvergeError> { let tree_ids_and_labels = TreeIdsAndLabels::new( rebase_tree_onto_solution_parents(commit, parents, &parents_merged_tree, repo).await?, ); rebased_resolved_trees .lock() .unwrap() .insert(commit.id().clone(), tree_ids_and_labels.clone()); // Note we only return the tree ids here, not the labels. We do that to increase // the chances of finding a common dominator value that is closer to the // divergent commits, ideally one that result in a simple merge of the trees // later on. Ok(tree_ids_and_labels.tree_ids.clone()) }; let mut value_cache = ValueCache::new(async |commit_id: &CommitId| { let commit = repo.store().get_commit_async(commit_id).await?; value_fn(&commit).await }); // Calculate the dominator value on the value flow graph, and record which // commits produce which values. let dominator_value = truncated_evolution_graph .flow_graph .find_dominator_value_with_value_cache( truncated_evolution_graph.divergent_commit_ids(), &mut value_cache, ) .await .map_err(|e| ConvergeError::Other(e.into()))?; let dominator_producer = get_value_producer(truncated_evolution_graph, &dominator_value, &value_cache)?; let base_commit = repo.store().get_commit_async(&dominator_producer).await?; let rebased_resolved_trees = Arc::try_unwrap(rebased_resolved_trees) .map_err(|_| ConvergeError::Other("Failed to unwrap rebased_resolved_trees Arc".into()))? .into_inner() .unwrap(); let mut terms: Vec<(MergedTree, String)> = Vec::new(); let base_term = get_term_for_tree_merge( &base_commit, parents, &rebased_resolved_trees, "converge base", ); // Add terms.push(base_term.clone()); for divergent_commit in truncated_evolution_graph.divergent_commits() { // Remove terms.push(base_term.clone()); // Add terms.push(get_term_for_tree_merge( divergent_commit, parents, &rebased_resolved_trees, "divergent commit", )); } Ok(MergedTree::merge(MergeBuilder::from_iter(terms).build()).await?) } fn get_term_for_tree_merge( commit: &Commit, parents: &[CommitId], rebased_resolved_trees: &HashMap, prefix: &str, ) -> (MergedTree, String) { let rebased_and_resolved_tree = rebased_resolved_trees .get(commit.id()) .unwrap() .to_merged_tree(commit.store()); let conflict_label = if commit.parent_ids() == parents { format!("{prefix}: {}", commit.conflict_label()) } else { format!( "{prefix}: tree of {} rebased onto parents", commit.conflict_label() ) }; (rebased_and_resolved_tree, conflict_label) } // Creates a merge of values, using as terms the values of the divergent // commits, and as base the dominator value. Returns the merge together with the // commit id of one of the commits that produces the dominator value. Commits in // excluded_divergent_commits are not used in the merge. async fn create_value_merge( graph: &TruncatedEvolutionGraph, excluded_divergent_commits: &HashSet, value_fn: VF, ) -> Result<(Merge, CommitId), ConvergeError> where T: Eq + Hash + Clone, VF: AsyncFn(&Commit) -> Result, { let mut value_cache = ValueCache::new(async |commit_id: &CommitId| { let commit = graph.repo().store().get_commit_async(commit_id).await?; value_fn(&commit).await }); let divergent_commits = graph .divergent_commit_ids() .iter() .filter(|id| !excluded_divergent_commits.contains(*id)); // Calculate the dominator value on the value flow graph, and record which // commits produce which values. let dominator_value = graph .flow_graph .find_dominator_value_with_value_cache(divergent_commits.clone(), &mut value_cache) .await .map_err(|e| ConvergeError::Other(e.into()))?; let dominator_producer = get_value_producer(graph, &dominator_value, &value_cache)?; let mut merge_builder = MergeBuilder::default(); // ADD merge_builder.extend([(*dominator_value).clone()]); for divergent_commit in divergent_commits { let commit_value = value_cache.get_value(divergent_commit).await?; // REMOVE, ADD merge_builder.extend([(*dominator_value).clone(), (*commit_value).clone()]); } Ok((merge_builder.build(), dominator_producer)) } /// Returns a commit that produces a given value (e.g. finds a commit that /// produces a given description). The value must be present in value_cache. fn get_value_producer( truncated_evolution_graph: &TruncatedEvolutionGraph, value: &Rc, value_cache: &ValueCache, ) -> Result where T: Eq + Hash, VF: AsyncFn(&CommitId) -> Result, { let producers = value_cache.get_nodes_for_value(value).unwrap(); match producers.len() { 0 => unreachable!(), // If it is present in ValueCache, it comes from some commit. 1 => return Ok(producers[0].clone()), _ => {} } // If there is more than one producer we choose the one of minimum rank, where // rank is defined as lowest change-offset. Because some backends may not // provide change-offsets for hidden commits, we consider those as having // maximum change-offset and use input-order as the secondary sorting criterion. // By input-order we refer to the order of commits passed to converge_change. // But some commits are not given as input, so we use CommitId as tertiary // sorting criterion. let resolved_change_targets = truncated_evolution_graph .repo() .resolve_change_id(truncated_evolution_graph.change_id())?; let input_position: HashMap<&CommitId, usize> = truncated_evolution_graph .divergent_commit_ids() .iter() .enumerate() .map(|(position, commit_id)| (commit_id, position)) .collect(); let producer = producers .iter() .min_by_key(|commit_id: &&CommitId| { let change_offset = match &resolved_change_targets { Some(change_targets) => change_targets.find_offset(commit_id).unwrap_or(usize::MAX), None => usize::MAX, }; let input_position = *input_position.get(commit_id).unwrap_or(&usize::MAX); (change_offset, input_position, *commit_id) }) .unwrap() .clone(); Ok(producer) } async fn rebase_tree_onto_solution_parents( c: &Commit, parents: &[CommitId], parents_merged_tree: &MergedTree, repo: &Arc, ) -> BackendResult { if c.parent_ids() == parents { return Ok(c.tree()); } let mut terms: Vec<(MergedTree, String)> = Vec::new(); // Add terms.push(( parents_merged_tree.clone(), "converge solution parent(s)".to_string(), )); // Remove terms.push(( c.parent_tree_no_resolve(repo.as_ref()).await?, c.parents_conflict_label().await?, )); // Add terms.push((c.tree(), c.conflict_label())); MergedTree::merge(MergeBuilder::from_iter(terms).build()).await } /// Returns those commits in commit_ids that are not descendants of any other /// commit in commit_ids. pub async fn remove_descendants( repo: &Arc, commit_ids: &[CommitId], ) -> Result, ConvergeError> { if commit_ids.is_empty() { return Ok(HashSet::default()); } let revset_expression = Arc::new(RevsetExpression::Commits(commit_ids.to_vec())).roots(); let mut result = HashSet::with_capacity(commit_ids.len()); let mut stream = revset_expression.evaluate(repo.deref())?.stream(); while let Some(commit_id) = stream.try_next().await? { result.insert(commit_id); } validate( !result.is_empty(), &format!("the result of remove_descendants should never be empty; commits: {commit_ids:?}"), )?; Ok(result) } fn validate(predicate: bool, msg: &str) -> Result<(), ConvergeError> { if !predicate { Err(ConvergeError::Other(msg.into())) } else { Ok(()) } }