From 5034666147d748ab6efb0bf94bb1695711a3864f Mon Sep 17 00:00:00 2001 From: David Rieber Date: Mon, 20 Jul 2026 17:43:43 -0700 Subject: [PATCH 1/3] lib: converge also uses timestamp to break ties when choosing producer commit Why this change? ---------------- When the heuristics used by converge_change cannot come up with a description, author and/or parents for the solution, it produces ConvergedAttribute::unsolved. The heuristics use dominator value algorithm to create a Merge --a merge of values, e.g. a Merge for descriptions or Merge> for parents. The heuristics fail when the Merge does not resolve trivially. ConvergedAttribute::unsolved includes the CommitId of the "base commit". The base commit is "a" commit in the evolution history that "has" the dominator value. In converge.rs we call this a "value producer". For example the description merge may be: ``` Merge(add: "bar", remove: "foo", add: "baz"). ``` That merge does not resolve trivially. The "base commit" is a commit in the truncated evolution graph that has description "foo". It is guaranteed that such a commit exists, but there could be more than one. The changes here affect how that choice is made. converge_change MUST choose deterministically, and should try to make a decent choice. Where is ConvergeAttribute::unsolved.base_commit used? A follow up change in this sequence introduces the `jj converge` command. That command uses the base_commit when asking the user to merge divergent commit descriptions: it presents the user with an editor with the conflicting descriptions with conflict markers and conflict labels. The conflict label marker for the base description is the conflict labels of the base commit. There is another place where the "base commit" is used: in conflict markers in the MergedTree of the solution commit. What is the change? ------------------- Previously commit timestamp was not considered at all when choosing a base commit among two or more value producers. Now commit timestamp is considered. A commit with a more recent timestamp is given preference over a commit with an older timestamp. Why not simply use change offset ordering? The algorithm DOES use change ordering when choosing the base commit, but on some backends not all commits for a given change-id have change offsets. For example at Google change offset is calculated only for some commits (I forget the details, I think it is only for visible commits or something like that). --- lib/src/converge.rs | 37 +++++++++++++++++++++++++++---------- 1 file changed, 27 insertions(+), 10 deletions(-) diff --git a/lib/src/converge.rs b/lib/src/converge.rs index 9155f121783..ba9f900abd7 100644 --- a/lib/src/converge.rs +++ b/lib/src/converge.rs @@ -648,8 +648,8 @@ where // 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. + // But some commits are not given as input, so we use commit timestamp and + // CommitId as additional sorting criteria. let resolved_change_targets = truncated_evolution_graph .repo() @@ -661,19 +661,36 @@ where .enumerate() .map(|(position, commit_id)| (commit_id, position)) .collect(); - let producer = producers - .iter() - .min_by_key(|commit_id: &&CommitId| { + + // The sorting key is (change_offset, input_position, negated millis since Unix + // epoch, commit_id). + type SortingKey = (usize, usize, i64, CommitId); + let producers: Vec<_> = try_join_all(producers.iter().map( + async |commit_id| -> Result { 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) + let commit = truncated_evolution_graph + .repo() + .store() + .get_commit_async(commit_id) + .await?; + // We take MillisSinceEpoch and negate it, so that more recent commits are + // before older ones. + let millis_since_unix_epoch = commit.committer().timestamp.timestamp.0; + Ok(( + change_offset, + input_position, + -millis_since_unix_epoch, + commit_id.clone(), + )) + }, + )) + .await?; + let (_, _, _, commit_id) = producers.iter().min().unwrap().clone(); + Ok(commit_id) } async fn rebase_tree_onto_solution_parents( From b10d0e1524db42fbd484799fa3519e0bc7bfd009 Mon Sep 17 00:00:00 2001 From: David Rieber Date: Wed, 22 Jul 2026 09:40:41 -0700 Subject: [PATCH 2/3] lib: find_divergent_changes returns commits in a definite order. The motivation is to make the return type more appropriate for use in the converge command. Before this commit: ``` CommitsByChangeId = HashMap> ``` After this commit: ``` CommitsByChangeId = BTreeMap> ``` The change-ids are now sorted. Also, the commits for a given (divergent) change-id are now sorted by revset-engine order. --- lib/src/converge.rs | 7 ++++--- lib/tests/test_converge.rs | 43 ++++++++++++++------------------------ 2 files changed, 20 insertions(+), 30 deletions(-) diff --git a/lib/src/converge.rs b/lib/src/converge.rs index ba9f900abd7..09c6c01d9af 100644 --- a/lib/src/converge.rs +++ b/lib/src/converge.rs @@ -16,6 +16,7 @@ //! //! for more details. +use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::hash::Hash; @@ -58,7 +59,7 @@ use jj_lib::store::Store; use thiserror::Error; /// Maps change-ids to commits with that change-id. -pub type CommitsByChangeId = HashMap>; +pub type CommitsByChangeId = BTreeMap>; /// The result of attempting to converge a particular attribute (description, /// author, parents, tree) of a set of divergent commits. @@ -124,7 +125,7 @@ pub enum ConvergeError { pub async fn find_divergent_changes( repo: &Arc, revset_expression: Arc, -) -> Result { +) -> 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? { @@ -132,7 +133,7 @@ pub async fn find_divergent_changes( result .entry(commit.change_id().clone()) .or_default() - .insert(commit.id().clone(), commit); + .push(commit); } // Remove entries that have only a single commit — we only care about // changes with multiple divergent commits. diff --git a/lib/tests/test_converge.rs b/lib/tests/test_converge.rs index aa0bda10384..dcdbc631a5b 100644 --- a/lib/tests/test_converge.rs +++ b/lib/tests/test_converge.rs @@ -12,6 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::collections::BTreeMap; use std::collections::HashMap; use std::collections::HashSet; use std::slice; @@ -135,7 +136,7 @@ fn assert_divergent_changes( repo: &Arc, expected: &[(&ChangeId, &[Commit])], ) -> TestResult { - let expected_divergent_commits: HashMap> = expected + let expected_divergent_commits: HashMap> = expected .iter() .map(|(change_id, commits)| { ( @@ -145,10 +146,10 @@ fn assert_divergent_changes( }) .collect(); let actual = find_divergent_changes(repo, RevsetExpression::all()).block_on()?; - let simplified: HashMap> = actual + let simplified: HashMap> = actual .clone() .into_iter() - .map(|(change_id, commits)| (change_id, commits.into_keys().collect::>())) + .map(|(change_id, commits)| (change_id, commits.iter().map(|c| c.id().clone()).collect())) .collect(); assert_eq!(simplified, expected_divergent_commits); Ok(actual) @@ -263,13 +264,7 @@ fn test_find_divergent_changes_exactly_one_found() -> TestResult { let repo = repo.reload_at_head().block_on()?; assert_eq!( find_divergent_changes(&repo, RevsetExpression::all()).block_on()?, - HashMap::from([( - change_aa.clone(), - HashMap::from([ - (commit_1.id().clone(), commit_1.clone()), - (commit_2.id().clone(), commit_2.clone()), - ]), - )]) + BTreeMap::from([(change_aa.clone(), vec![commit_2.clone(), commit_1.clone()])]) ); Ok(()) @@ -350,8 +345,8 @@ fn test_find_divergent_changes_two_found() -> TestResult { drop(assert_divergent_changes( &repo, &[ - (&change_aa, &[commit_1.clone(), commit_2.clone()]), - (&change_bb, &[commit_3.clone(), commit_4.clone()]), + (&change_aa, &[commit_2.clone(), commit_1.clone()]), + (&change_bb, &[commit_4.clone(), commit_3.clone()]), ], )?); Ok(()) @@ -545,13 +540,7 @@ fn test_manual_converge_description_concurrent_ops() -> TestResult { let change_id = commit1.change_id().clone(); assert_eq!( find_divergent_changes(&repo5, RevsetExpression::all()).block_on()?, - HashMap::from([( - change_id.clone(), - HashMap::from([ - (commit4.id().clone(), commit4.clone()), - (commit5.id().clone(), commit5.clone()), - ]), - )]) + BTreeMap::from([(change_id.clone(), vec![commit5.clone(), commit4.clone()])]) ); let divergent_commits = vec![commit4.clone(), commit5.clone()]; @@ -655,7 +644,7 @@ fn test_automatic_converge_description_and_parent() -> TestResult { let repo5 = tx.commit("test").block_on()?; let change_id = commit1.change_id().clone(); - let divergent_commits = vec![commit3.clone(), commit4.clone()]; + let divergent_commits = vec![commit4.clone(), commit3.clone()]; assert_divergent_changes(&repo5, &[(&change_id, &divergent_commits)])?; let truncated_evolution_graph = @@ -789,7 +778,7 @@ fn test_automatic_converge_description_parent_and_trees() -> TestResult { let repo5 = tx.commit("test").block_on()?; let change_id = commit1.change_id().clone(); - let divergent_commits = vec![commit3.clone(), commit4.clone()]; + let divergent_commits = vec![commit4.clone(), commit3.clone()]; let divergent_commit_ids = divergent_commits .iter() .map(|c| c.id().clone()) @@ -803,8 +792,8 @@ fn test_automatic_converge_description_parent_and_trees() -> TestResult { let expected_tree = create_merged_tree(vec![ ( - commit1.tree().clone(), - format!("converge base: {}", commit1.conflict_label()), + commit4.tree().clone(), + format!("divergent commit: {}", commit4.conflict_label()), ), ( commit1.tree().clone(), @@ -819,8 +808,8 @@ fn test_automatic_converge_description_parent_and_trees() -> TestResult { format!("converge base: {}", commit1.conflict_label()), ), ( - commit4.tree().clone(), - format!("divergent commit: {}", commit4.conflict_label()), + commit1.tree().clone(), + format!("converge base: {}", commit1.conflict_label()), ), ]); @@ -878,8 +867,8 @@ fn test_automatic_converge_description_parent_and_trees() -> TestResult { Merge::from_removes_adds( vec![get_merged_tree_value(&tree1, "file")?], vec![ - get_merged_tree_value(&tree3, "file")?, get_merged_tree_value(&tree4, "file")?, + get_merged_tree_value(&tree3, "file")?, ], ), ); @@ -996,7 +985,7 @@ fn test_automatic_converge_description_parent_and_trees_with_reparent() -> TestR let repo5 = tx.commit("test").block_on()?; let change_id = commit1.change_id().clone(); - let divergent_commits = vec![commit3.clone(), commit4.clone()]; + let divergent_commits = vec![commit4.clone(), commit3.clone()]; assert_divergent_changes(&repo5, &[(&change_id, &divergent_commits)])?; let divergent_commit_ids = divergent_commits .iter() From 59ac566797062863b4e94c112a044152d7fc3734 Mon Sep 17 00:00:00 2001 From: David Rieber Date: Sun, 9 Nov 2025 11:23:40 -0800 Subject: [PATCH 3/3] cli:converge: new jj converge command `jj converge` allows users to "fix" divergence. The command tries to create a new commit for the divergent change that rewrites all divergent commits. The command tries to do this automatically, but falls back to prompting the user for pieces of information as needed. The command uses the lib/converge.rs library to do most of the work. The command takes an optional --search_space revset (it looks for divergent commits matching that revset). If not specified the command uses a new `revsets.converge` system revset (mutable() & divergent()). If the command cannot automatically merge the descriptions, the user's text editor is invoked to let the user merge the divergent descriptions manually (as if they were conflicts on a "description" file). The command has a --interactive=true/false flag to allow users to invoke it without prompting the user. --- CHANGELOG.md | 6 + cli/src/command_error.rs | 24 +- cli/src/commands/converge.rs | 633 +++++++++++ cli/src/commands/mod.rs | 3 + cli/src/config/revsets.toml | 1 + cli/tests/cli-reference@.md.snap | 32 +- cli/tests/runner.rs | 1 + cli/tests/test_converge_command.rs | 1631 ++++++++++++++++++++++++++++ docs/config.md | 13 + 9 files changed, 2342 insertions(+), 2 deletions(-) create mode 100644 cli/src/commands/converge.rs create mode 100644 cli/tests/test_converge_command.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 95501921677..55003170263 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,12 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). jj workspace can have its own Git HEAD. Existing repositories are migrated automatically. +* The new `jj converge` command attempts to automatically resolve divergence by + creating a new commit that replaces the divergent commits. It applies + heuristics to try to automatically come up with a good solution, and falls + back to prompting the user if the heuristics are inconclusive. It can also run + in non-interactive mode, which aborts if prompting would be needed. + ### Fixed bugs * The default pager flags now include `-K` (`--quit-on-intr`), so pressing diff --git a/cli/src/command_error.rs b/cli/src/command_error.rs index 3f898b88104..dc177f41f70 100644 --- a/cli/src/command_error.rs +++ b/cli/src/command_error.rs @@ -28,6 +28,7 @@ use jj_lib::config::ConfigFileSaveError; use jj_lib::config::ConfigGetError; use jj_lib::config::ConfigLoadError; use jj_lib::config::ConfigMigrateError; +use jj_lib::converge::ConvergeError; use jj_lib::dsl_util::Diagnostics; use jj_lib::evolution::WalkPredecessorsError; use jj_lib::fileset::FilePatternParseError; @@ -427,6 +428,19 @@ impl From for CommandError { } } +impl From for CommandError { + fn from(err: ConvergeError) -> Self { + match err { + ConvergeError::Backend(err) => err.into(), + ConvergeError::Index(err) => err.into(), + ConvergeError::RevsetEvaluation(err) => err.into(), + ConvergeError::WalkPredecessors(err) => err.into(), + ConvergeError::IO(err) => err.into(), + ConvergeError::Other(err) => internal_error(err), + } + } +} + impl From for CommandError { fn from(err: DiffEditError) -> Self { user_error_with_message("Failed to edit diff", err) @@ -507,6 +521,12 @@ impl From for CommandError { } } +impl From for ConvergeError { + fn from(err: TempTextEditError) -> Self { + Self::Other(Box::new(err)) + } +} + impl From for CommandError { fn from(err: TrailerParseError) -> Self { user_error(err) @@ -936,7 +956,9 @@ fn revset_resolution_error_hints(err: &RevsetResolutionError) -> Vec { kind: _, symbol: _, targets, - } => vec![multiple_targets_hint(targets)], + } => { + vec![multiple_targets_hint(targets)] + } RevsetResolutionError::EmptyString | RevsetResolutionError::WorkspaceMissingWorkingCopy { .. } | RevsetResolutionError::AmbiguousCommitIdPrefix(_) diff --git a/cli/src/commands/converge.rs b/cli/src/commands/converge.rs new file mode 100644 index 00000000000..bda1df93fae --- /dev/null +++ b/cli/src/commands/converge.rs @@ -0,0 +1,633 @@ +// 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. + +use std::collections::HashSet; +use std::fmt::Write as _; +use std::hash::Hash; +use std::io; + +use clap_complete::ArgValueCompleter; +use indexmap::IndexMap; +use indoc::indoc; +use itertools::Itertools as _; +use jj_lib::backend::ChangeId; +use jj_lib::backend::CommitId; +use jj_lib::backend::Signature; +use jj_lib::commit::Commit; +use jj_lib::conflict_labels::ConflictLabels; +use jj_lib::conflicts::ConflictMarkerStyle; +use jj_lib::conflicts::ConflictMaterializeOptions; +use jj_lib::conflicts::materialize_merge_result_to_bytes; +use jj_lib::converge::CommitsByChangeId; +use jj_lib::converge::ConvergedAttribute; +use jj_lib::converge::TruncatedEvolutionGraph; +use jj_lib::converge::apply_solution; +use jj_lib::converge::converge_change; +use jj_lib::converge::find_divergent_changes; +use jj_lib::files::FileMergeHunkLevel; +use jj_lib::merge::MergeBuilder; +use jj_lib::merge::SameChange; +use jj_lib::repo::ReadonlyRepo; +use jj_lib::repo::Repo as _; +use jj_lib::tree_merge::MergeOptions; + +use crate::cli_util::CommandHelper; +use crate::cli_util::RevisionArg; +use crate::cli_util::WorkspaceCommandTransaction; +use crate::cli_util::short_change_hash; +use crate::cli_util::short_commit_hash; +use crate::command_error::CommandError; +use crate::command_error::internal_error; +use crate::command_error::user_error; +use crate::complete; +use crate::description_util::TextEditor; +use crate::formatter::Formatter; +use crate::templater::TemplateRenderer; +use crate::ui::Ui; + +/// Converge divergent changes +/// +/// Attempts to resolve divergence by replacing two or more visible revisions +/// for a given change with a single revision. `jj converge` evaluates the +/// revset(s) given by the `--revisions` arg (or the `revsets.converge` setting +/// if none are specified) and groups the resulting revisions by change ID. +/// Change IDs with more than one revision are divergent. +/// +/// If there is no divergence it returns successfully. If there is more than one +/// divergent change it prompts the user to choose one. The command then applies +/// heuristics to try to automatically come up with a good solution (i.e. a new +/// revision) that replaces the divergent revisions. If the heuristics are +/// inconclusive `jj converge` falls back to prompting the user. Use +/// `--no-interactive` to print a warning instead of prompting the user. +/// +/// The user may be prompted for any of the following: to merge revision +/// descriptions, to choose parents for the solution, and/or (very rarely) to +/// choose a revision author. +/// +/// When a solution is found, the new revision replaces the divergent revisions +/// of the specific change ID (but only those matching the revsets; if there are +/// other visible revisions for the same change ID outside the revset, those +/// will remain and you will still have divergence, though it will be reduced). +/// Descendants of the divergent revisions are rebased onto the solution, and +/// local bookmarks pointing to any divergent revision are updated to point to +/// the solution. +/// +/// Note that there may be file conflicts in the solution whether or not there +/// were conflicts to begin with. +/// +/// The modifications made by `jj converge` can be reviewed by `jj op show -p`. +/// You can inspect the change evolution with `jj evolog`. If not satisfied with +/// the result you can run `jj undo`. +#[derive(clap::Args, Clone, Debug)] +pub(crate) struct ConvergeArgs { + /// The search space to look for divergent revisions + /// + /// If no revisions are specified, this defaults to the `revsets.converge` + /// setting. + #[arg(long = "revision", short, value_name = "REVSETS", alias = "revisions")] + #[arg(add = ArgValueCompleter::new(complete::revset_expression_all))] + revisions: Vec, + + /// Do not prompt the user for help resolving divergence + /// + /// If the command cannot solve divergence automatically it will print a + /// warning and exit without making any changes (divergence will still be + /// present). + #[arg(long, conflicts_with = "_interactive")] + no_interactive: bool, + + /// No-op flag to pair with --no-interactive + #[arg(long, short, hide = true)] + _interactive: bool, +} + +// TODO: consider adding logic to deal with more than one divergent change-id in +// one invocation. Pick one, solve it, pick another one, solve it, etc. +// NOTE: currently we walk the operation history as far back as necessary when +// building the TruncatedEvolutionGraph. If this ever becomes a problem (because +// of a very deep fork in the op log), we could add a config setting to limit +// the walk and pretend that a "root" operation happened at that point. +pub(crate) async fn cmd_converge( + ui: &mut Ui, + command: &CommandHelper, + args: &ConvergeArgs, +) -> Result<(), CommandError> { + let mut workspace_command = command.workspace_helper(ui).await?; + let settings = workspace_command.settings(); + + let search_space = { + if args.revisions.is_empty() { + let revset_string = settings.get_string("revsets.converge")?; + workspace_command.parse_revset(ui, &RevisionArg::from(revset_string))? + } else { + workspace_command.parse_union_revsets(ui, &args.revisions)? + } + } + .resolve()?; + + workspace_command + .check_rewritable_expr(&search_space) + .await?; + + let interactive = !args.no_interactive; + + let tx = workspace_command.start_transaction(); + + // Find all divergent changes and choose one to converge. + let divergent_changes = find_divergent_changes(tx.base_repo(), search_space).await?; + if divergent_changes.is_empty() { + if args.revisions.is_empty() { + writeln!(ui.status(), "No divergent changes found.")?; + } else { + writeln!( + ui.status(), + "No divergence found among the specified revisions." + )?; + } + return Ok(()); + } + report_divergent_changes(ui, &divergent_changes, &tx.commit_summary_template())?; + let Some(change_id) = choose_change(ui, &divergent_changes, interactive)? else { + return Ok(()); + }; + + Converge::new(ui, tx, &divergent_changes, change_id.clone(), interactive) + .await? + .run() + .await +} + +struct Converge<'a> { + ui: &'a Ui, + tx: WorkspaceCommandTransaction<'a>, + divergent_changes: &'a CommitsByChangeId, + change_id: ChangeId, + truncated_evolution_graph: TruncatedEvolutionGraph, + interactive: bool, +} + +impl<'a> Converge<'a> { + async fn new( + ui: &'a Ui, + tx: WorkspaceCommandTransaction<'a>, + divergent_changes: &'a CommitsByChangeId, + change_id: ChangeId, + interactive: bool, + ) -> Result { + let divergent_commits = divergent_changes + .get(&change_id) + .expect("change_id is in divergent_changes") + .clone(); + let truncated_evolution_graph = + TruncatedEvolutionGraph::new(tx.base_repo().clone(), divergent_commits).await?; + Ok(Self { + ui, + tx, + divergent_changes, + change_id, + truncated_evolution_graph, + interactive, + }) + } + + fn repo(&self) -> &ReadonlyRepo { + self.truncated_evolution_graph.repo() + } + + fn text_editor(&self) -> Result { + Ok(self.tx.base_workspace_helper().text_editor()?) + } + + async fn run(mut self) -> Result<(), CommandError> { + writeln!( + self.ui.stderr_formatter(), + "Attempting to converge change {}...\n", + short_change_hash(&self.change_id) + )?; + + // Call the library function to attempt to converge the change automatically. + let automatic_converge_result = { + // Initially we start with zero knowledge about what the solution should look + // like. + let author = None; + let description = None; + let parents = None; + let tree = None; + converge_change( + &self.truncated_evolution_graph, + author, + description, + parents, + tree, + ) + .await? + }; + + // Now solve the author, description and parents, prompting the user for input + // if necessary. + let author = self.generic_solver(automatic_converge_result.author, Self::choose_author)?; + let description = self.generic_solver( + automatic_converge_result.description, + Self::merge_description, + )?; + let parents = + self.generic_solver(automatic_converge_result.parents, Self::choose_parents)?; + + let (Some(author), Some(description), Some(parents)) = (&author, &description, &parents) + else { + if author.is_none() { + writeln!(self.ui.status(), "Could not determine which author to use.")?; + } + if description.is_none() { + writeln!( + self.ui.status(), + "Could not determine which description to use." + )?; + } + if parents.is_none() { + writeln!( + self.ui.status(), + "Could not determine which parents to use." + )?; + } + return Err(user_error("Could not converge change")); + }; + + // If we do not have a tree yet, call the converge_change library function + // again, now that we have the author, description and parents. + let tree = match automatic_converge_result.tree { + Some(tree) => Ok(tree), + None => { + let converge_result = converge_change( + &self.truncated_evolution_graph, + Some(author.clone()), + Some(description.clone()), + Some(parents.clone()), + None, + ) + .await?; + match converge_result.tree { + Some(tree) => Ok(tree), + None => Err(user_error("Failed to converge tree")), + } + } + }?; + + let (solution_commit, num_rebased) = apply_solution( + author.clone(), + description.clone(), + parents.clone(), + tree, + self.change_id.clone(), + self.truncated_evolution_graph.divergent_commit_ids(), + self.tx.repo_mut(), + ) + .await?; + + let change_id = solution_commit.change_id(); + let short_solution_id = short_commit_hash(solution_commit.id()); + let short_change_id = short_change_hash(change_id); + let num_divergent_commits = self + .divergent_changes + .get(change_id) + .map(|m| m.len()) + .unwrap_or(0); + writeln!( + self.ui.status(), + "Successfully converged change: created commit {short_solution_id}." + )?; + if num_rebased > 0 { + writeln!(self.ui.status(), "Rebased {num_rebased} descendants")?; + } + if self.divergent_changes.len() > 1 { + writeln!( + self.ui.hint_default(), + "There are still {} divergent changes remaining in the specified revset, you can \ + run this command again to converge another one.", + self.divergent_changes.len() - 1 + )?; + } + + let transaction_description = + format!("converge {short_change_id} with {num_divergent_commits} predecessors"); + self.tx.finish(self.ui, transaction_description).await?; + Ok(()) + } + + fn generic_solver( + &self, + automatic_convergence: ConvergedAttribute, + interactive_converge: InteractiveConvergeFn, + ) -> Result, CommandError> + where + T: Eq + Hash + Clone, + InteractiveConvergeFn: Fn(&Self, CommitId, HashSet) -> Result, + { + match automatic_convergence { + ConvergedAttribute::Solved(value) => Ok(Some(value)), + ConvergedAttribute::Unsolved { + base_commit, + excluded_divergent_commits, + } => { + if !self.interactive { + Ok(None) + } else { + Ok(Some(interactive_converge( + self, + base_commit, + excluded_divergent_commits, + )?)) + } + } + } + } + + fn choose_author( + &self, + _base_commit: CommitId, + _excluded_divergent_commits: HashSet, + ) -> Result { + choose_helper( + self.ui, + self.truncated_evolution_graph.divergent_commits(), + "Could not determine automatically which author to use", + |commit| commit.author().clone(), + |commit, _formatter| { + Ok(format!( + "{} ({}, {})\n", + short_commit_hash(commit.id()), + commit.author().name, + commit.author().email + )) + }, + indoc! {" + Enter the index of the author you want to use"}, + ) + } + + fn choose_parents( + &self, + _base_commit: CommitId, + excluded_divergent_commits: HashSet, + ) -> Result, CommandError> { + let viable_commits = self + .truncated_evolution_graph + .divergent_commits() + .iter() + .filter(|commit| !excluded_divergent_commits.contains(commit.id())) + .cloned() + .collect_vec(); + + let value_fn = |commit: &Commit| commit.parent_ids().to_vec(); + + // A function that takes one of the divergent commits and returns a string that + // displays that commit's id and then its parents (one parent per line) + let display_fn = |commit: &Commit, _formatter: &mut dyn Formatter| { + let mut display_string = String::new(); + writeln!(display_string, "{}:", short_commit_hash(commit.id())) + .map_err(internal_error)?; + for parent in commit.parent_ids() { + let parent_summary = self + .tx + .format_commit_summary(&self.repo().store().get_commit(parent)?); + writeln!(display_string, " Parent: {parent_summary}") + .map_err(internal_error)?; + } + Ok(display_string) + }; + + choose_helper( + self.ui, + &viable_commits, + "Could not determine automatically which parents to use", + value_fn, + display_fn, + indoc! {" + Enter the index of one of the divergent commits whose parent(s) will be the parents of the solution"}, + ) + } + + // TODO: Run the user's configured merge tool. + fn merge_description( + &self, + base_commit: CommitId, + _excluded_divergent_commits: HashSet, + ) -> Result { + let distinct_values = { + // Add the values of the divergent commits to the map, deduplicating them as we + // go. + let mut distinct_values = IndexMap::new(); + for commit in self.truncated_evolution_graph.divergent_commits() { + distinct_values + .entry(commit.description()) + .or_insert(commit); + } + distinct_values + }; + if distinct_values.len() == 1 { + return Ok(distinct_values + .first() + .expect("values is not empty") + .0 + .to_string()); + } + + let candidate_commits = distinct_values + .iter() + .map(|(_description, commit)| commit) + .copied() + .collect_vec(); + + let base_commit = self.repo().store().get_commit(&base_commit)?; + let conflicted_description = + materialize_conflicted_description(&candidate_commits, &base_commit); + let merge_in_text_editor = self.ui.prompt_yes_no( + indoc! {" + There are divergent descriptions. You can choose to merge them now in a + text editor, or skip merging and use the conflicted description (with + conflict markers). Do you want to merge them now?"}, + Some(true), + )?; + writeln!(self.ui.status(), "\n")?; + let description = if merge_in_text_editor { + self.text_editor()? + .edit_str(conflicted_description, Some(".jj-converge-description")) + .map_err(|err| err.with_name("description"))? + } else { + conflicted_description + }; + Ok(description) + } +} + +/// Prompts the user to choose a change-id to converge, if there are multiple +/// divergent change-ids. +fn choose_change<'a>( + ui: &Ui, + divergent_changes: &'a CommitsByChangeId, + interactive: bool, +) -> Result, CommandError> { + assert!(!divergent_changes.is_empty()); + let mut formatter = ui.stderr_formatter(); + if divergent_changes.len() == 1 { + return Ok(Some(divergent_changes.keys().next().unwrap())); + } + // TODO: consider using heuristics to automatically choose a "good" change-id to + // converge, falling back to prompting the user only if the heuristics are + // inconclusive. This is specially important in non-interactive mode. + if !interactive { + return Err( + user_error("Cannot automatically choose which change to converge").hinted( + "Run `jj converge` in interactive mode, or specify a revset that resolves to only \ + one change ID", + ), + ); + } + writeln!( + formatter, + "Choose which change to converge (jj converge only converges one change at a time):", + )?; + + let mut choices: Vec = Default::default(); + let change_ids: Vec<&ChangeId> = divergent_changes.keys().collect(); + for (i, change_id) in change_ids.iter().enumerate() { + // TODO: is there a better way to display the change-id? perhaps with + // format_short_change_id? + writeln!(formatter, "{}: {}", i + 1, short_change_hash(change_id))?; + choices.push(format!("{}", i + 1)); + } + writeln!(formatter, "q: abort")?; + choices.push("q".to_string()); + drop(formatter); + let index = ui.prompt_choice("Enter the index of the change to converge", &choices, None)?; + writeln!(ui.status(), "\n")?; + if index >= change_ids.len() { + writeln!(ui.status(), "Aborting... nothing changed.")?; + Ok(None) + } else { + Ok(Some(change_ids[index])) + } +} + +fn choose_helper( + ui: &Ui, + divergent_commits: &[Commit], + introduction: &str, + value_fn: ValueFn, + display_fn: DisplayFn, + prompt: &str, +) -> Result +where + T: Eq + Hash + Clone, + ValueFn: Fn(&Commit) -> T, + DisplayFn: Fn(&Commit, &mut dyn Formatter) -> Result, +{ + assert!(!divergent_commits.is_empty()); + let distinct_values = { + // Add the values of the divergent commits to the map, deduplicating them as we + // go. + let mut distinct_values = IndexMap::new(); + for commit in divergent_commits { + distinct_values.entry(value_fn(commit)).or_insert(commit); + } + distinct_values + }; + if distinct_values.len() == 1 { + return Ok(distinct_values + .first() + .expect("values is not empty") + .0 + .clone()); + } + + writeln!(ui.stderr_formatter(), "{introduction}")?; + let mut choices: Vec = Default::default(); + for (index, (_value, commit)) in distinct_values.iter().enumerate() { + let display_string = display_fn(commit, ui.stderr_formatter().as_mut())?; + assert!(display_string.ends_with('\n')); + write!(ui.stderr_formatter(), "{}: {}", index + 1, display_string)?; + choices.push(format!("{}", index + 1)); + } + writeln!(ui.stderr_formatter(), "q: abort")?; + choices.push("q".to_string()); + let index = ui.prompt_choice(prompt, &choices, None)?; + writeln!(ui.status(), "\n")?; + if index >= distinct_values.len() { + Err(user_error("Aborting... nothing changed.")) + } else { + Ok(distinct_values.get_index(index).unwrap().0.clone()) + } +} + +fn materialize_conflicted_description( + divergent_commits: &[&Commit], + base_commit: &Commit, +) -> String { + if divergent_commits.is_empty() { + return String::new(); + } + let (description_merge, conflict_labels) = { + let base = base_commit.description(); + let base_label = base_commit.conflict_label(); + let mut merge_builder = MergeBuilder::default(); + let mut labels = vec![]; + merge_builder.extend([divergent_commits[0].description().to_string()]); + labels.push(divergent_commits[0].conflict_label()); + for commit in divergent_commits.iter().skip(1) { + merge_builder.extend([base.to_string(), commit.description().to_string()]); + labels.extend([base_label.clone(), commit.conflict_label()]); + } + (merge_builder.build(), ConflictLabels::from_vec(labels)) + }; + let options = ConflictMaterializeOptions { + marker_style: ConflictMarkerStyle::Diff, + marker_len: None, + merge: MergeOptions { + hunk_level: FileMergeHunkLevel::Line, + same_change: SameChange::Accept, + }, + }; + materialize_merge_result_to_bytes(&description_merge, &conflict_labels, &options).to_string() +} + +fn report_divergent_changes( + ui: &Ui, + divergent_changes: &CommitsByChangeId, + commit_summary_template: &TemplateRenderer, +) -> io::Result<()> { + let mut formatter = ui.stderr_formatter(); + let n = divergent_changes.len(); + writeln!( + formatter, + "Found {n} divergent change(s) in the specified revset:", + )?; + for (change_id, commits) in divergent_changes { + writeln!( + formatter, + "- Change: {} with {} commits:", + short_change_hash(change_id), + commits.len(), + )?; + for commit in commits.iter().take(10) { + write!(formatter, " ")?; + commit_summary_template.format(commit, formatter.as_mut())?; + writeln!(formatter)?; + } + if commits.len() > 10 { + write!(formatter, " ... and {} more", commits.len() - 10)?; + } + writeln!(formatter)?; + } + Ok(()) +} diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index db93b7714f2..e2a55b88789 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -21,6 +21,7 @@ mod bisect; mod bookmark; mod commit; mod config; +mod converge; mod debug; mod describe; mod diff; @@ -110,6 +111,7 @@ enum Command { Commit(commit::CommitArgs), #[command(subcommand)] Config(config::ConfigCommand), + Converge(converge::ConvergeArgs), #[command(subcommand)] Debug(debug::DebugCommand), Describe(describe::DescribeArgs), @@ -181,6 +183,7 @@ pub async fn run_command(ui: &mut Ui, command_helper: &CommandHelper) -> Result< Command::Bookmark(args) => bookmark::cmd_bookmark(ui, command_helper, args).await, Command::Commit(args) => commit::cmd_commit(ui, command_helper, args).await, Command::Config(args) => config::cmd_config(ui, command_helper, args).await, + Command::Converge(args) => converge::cmd_converge(ui, command_helper, args).await, Command::Debug(args) => debug::cmd_debug(ui, command_helper, args).await, Command::Describe(args) => describe::cmd_describe(ui, command_helper, args).await, Command::Diff(args) => diff::cmd_diff(ui, command_helper, args).await, diff --git a/cli/src/config/revsets.toml b/cli/src/config/revsets.toml index 8dc0f0deda0..5cdd1f2057d 100644 --- a/cli/src/config/revsets.toml +++ b/cli/src/config/revsets.toml @@ -3,6 +3,7 @@ [revsets] arrange = "reachable(@, mutable())" +converge = "mutable() & divergent()" fix = "reachable(@, mutable())" run = "reachable(@, mutable())" simplify-parents = "reachable(@, mutable())" diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index 32f7966967d..029964a00e1 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -2,7 +2,6 @@ source: cli/tests/test_generate_md_cli_help.rs description: "AUTO-GENERATED FILE, DO NOT EDIT. This cli reference is generated by a test as an `insta` snapshot. MkDocs includes this snapshot from docs/cli-reference.md." --- - # Command-Line Help for `jj` @@ -37,6 +36,7 @@ This document contains the help content for the `jj` command-line program. * [`jj config path`↴](#jj-config-path) * [`jj config set`↴](#jj-config-set) * [`jj config unset`↴](#jj-config-unset) +* [`jj converge`↴](#jj-converge) * [`jj describe`↴](#jj-describe) * [`jj diff`↴](#jj-diff) * [`jj diffedit`↴](#jj-diffedit) @@ -155,6 +155,7 @@ To get started, see the tutorial [`jj help -k tutorial`]. * `bookmark` — Manage bookmarks [default alias: b] * `commit` — Update the description and create a new change on top [default alias: ci] * `config` — Manage config options +* `converge` — Converge divergent changes * `describe` — Update the change description or other metadata [default alias: desc] * `diff` — Compare file contents between two revisions * `diffedit` — Touch up the content changes in a revision with a diff editor @@ -923,6 +924,35 @@ Update a config file to unset the given option +## `jj converge` + +Converge divergent changes + +Attempts to resolve divergence by replacing two or more visible revisions for a given change with a single revision. `jj converge` evaluates the revset(s) given by the `--revisions` arg (or the `revsets.converge` setting if none are specified) and groups the resulting revisions by change ID. Change IDs with more than one revision are divergent. + +If there is no divergence it returns successfully. If there is more than one divergent change it prompts the user to choose one. The command then applies heuristics to try to automatically come up with a good solution (i.e. a new revision) that replaces the divergent revisions. If the heuristics are inconclusive `jj converge` falls back to prompting the user. Use `--no-interactive` to print a warning instead of prompting the user. + +The user may be prompted for any of the following: to merge revision descriptions, to choose parents for the solution, and/or (very rarely) to choose a revision author. + +When a solution is found, the new revision replaces the divergent revisions of the specific change ID (but only those matching the revsets; if there are other visible revisions for the same change ID outside the revset, those will remain and you will still have divergence, though it will be reduced). Descendants of the divergent revisions are rebased onto the solution, and local bookmarks pointing to any divergent revision are updated to point to the solution. + +Note that there may be file conflicts in the solution whether or not there were conflicts to begin with. + +The modifications made by `jj converge` can be reviewed by `jj op show -p`. You can inspect the change evolution with `jj evolog`. If not satisfied with the result you can run `jj undo`. + +**Usage:** `jj converge [OPTIONS]` + +###### **Options:** + +* `-r`, `--revision ` — The search space to look for divergent revisions + + If no revisions are specified, this defaults to the `revsets.converge` setting. +* `--no-interactive` — Do not prompt the user for help resolving divergence + + If the command cannot solve divergence automatically it will print a warning and exit without making any changes (divergence will still be present). + + + ## `jj describe` Update the change description or other metadata [default alias: desc] diff --git a/cli/tests/runner.rs b/cli/tests/runner.rs index 5682b7c476f..b790cc57d49 100644 --- a/cli/tests/runner.rs +++ b/cli/tests/runner.rs @@ -23,6 +23,7 @@ mod test_completion; mod test_concurrent_operations; mod test_config_command; mod test_config_schema; +mod test_converge_command; mod test_copy_detection; mod test_debug_command; mod test_debug_init_simple_command; diff --git a/cli/tests/test_converge_command.rs b/cli/tests/test_converge_command.rs new file mode 100644 index 00000000000..3e2268fefa4 --- /dev/null +++ b/cli/tests/test_converge_command.rs @@ -0,0 +1,1631 @@ +// 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. + +use test_case::test_case; +use testutils::TestResult; + +use crate::common::CommandOutput; +use crate::common::TestEnvironment; +use crate::common::TestWorkDir; +use crate::common::create_commit_with_files; +use crate::common::force_interactive; + +// `jj converge` must runs successfully when there are no divergent changes and +// prints a message to stderr. +#[test] +fn test_converge_no_divergence() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + // Set up commit graph (without divergent changes) + create_commit_with_files(&work_dir, "a", &[], &[("file1", "a")]); + create_commit_with_files(&work_dir, "b", &["a"], &[("file2", "b")]); + create_commit_with_files(&work_dir, "c", &["a"], &[("file3", "c")]); + + // Test the setup + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ c royxmykx 78dcec21 - description: c + │ ○ b zsuskuln 056564da - description: b + ├─╯ + ○ a rlvkpnrz 3b93fc14 - description: a + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Run `jj converge` command and check the output. + let output = work_dir.run_jj(["converge"]).success(); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + No divergent changes found. + [EOF] + "); +} + +// A simple `jj converge` scenario where there is a single divergent change with +// two visible commits. In this setup no user input is required. +#[test] +fn test_converge_simple() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + // Set up commit graph with one divergent change (with two visible commits). + create_commit_with_files(&work_dir, "a", &[], &[("file1", "1")]); + create_commit_with_files(&work_dir, "b2", &["a"], &[("file2", "2")]); + create_commit_with_files(&work_dir, "c", &["a"], &[("file3", "3")]); + work_dir.run_jj(["rebase", "-r", "b2", "-o", "c"]).success(); + work_dir + .run_jj(["bookmark", "create", "b1", "-r", "at_operation(@-, b2)"]) + .success(); + create_commit_with_files(&work_dir, "d", &["b1"], &[("file4", "4")]); + + // Test the setup: look at the commit graph, commit B is duplicated + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ d znkkpsqq bf5126ef - description: d + ○ b1 zsuskuln/1 59a77004 - description: b2 + │ ○ b2 zsuskuln/0 2c2bd25d - description: b2 + │ ○ c royxmykx 4343fc61 - description: c + ├─╯ + ○ a rlvkpnrz e9a731d9 - description: a + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Test the setup: look at the evolog + insta::assert_snapshot!(get_evolog(&work_dir, "b2"), @r" + ○ zsuskuln/0 2c2bd25d (divergent) b2 + ○ zsuskuln/1 59a77004 (divergent) b2 + ○ zsuskuln/2 b2852eb2 (hidden) (empty) b2 + [EOF] + "); + + // Run `jj converge` command and check the output. In this case no user input is + // needed. + let output = work_dir.run_jj(["converge"]).success(); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Found 1 divergent change(s) in the specified revset: + - Change: zsuskulnrvyr with 2 commits: + zsuskuln/0 2c2bd25d b2 | (divergent) b2 + zsuskuln/1 59a77004 b1 | (divergent) b2 + + Attempting to converge change zsuskulnrvyr... + + Successfully converged change: created commit 6b1ce5bc4cbe. + Rebased 1 descendants + Working copy (@) now at: znkkpsqq 696cf5e0 d | d + Parent commit (@-) : zsuskuln 6b1ce5bc b1 b2 | b2 + Added 1 files, modified 0 files, removed 0 files + [EOF] + "); + + // Verify the commit graph after converge + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ d znkkpsqq 696cf5e0 - description: d + ○ b1 b2 zsuskuln 6b1ce5bc - description: b2 + ○ c royxmykx 4343fc61 - description: c + ○ a rlvkpnrz e9a731d9 - description: a + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Verify the evolution history after converge + insta::assert_snapshot!(get_evolog(&work_dir, "b2"), @r" + ○ zsuskuln 6b1ce5bc b2 + ├─╮ + ○ │ zsuskuln/1 2c2bd25d (hidden) b2 + ├─╯ + ○ zsuskuln/2 59a77004 (hidden) b2 + ○ zsuskuln/3 b2852eb2 (hidden) (empty) b2 + [EOF] + "); +} + +// When there are multiple divergent changes, the command must prompt the user +// to select one of them. When running in non-interactive mode (jj converge +// --no-interactive) this is not possible. +#[test] +fn test_converge_two_divergent_changes_in_non_interactive_mode() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + // Set up: first create a base commit + create_commit_with_files(&work_dir, "a", &[], &[("file1", "1")]); + + // Set up: create commit graph with two divergent changes + // First divergent change: + create_commit_with_files(&work_dir, "b2", &["a"], &[("file2", "2")]); + create_commit_with_files(&work_dir, "c", &["a"], &[("file3", "3")]); + work_dir.run_jj(["rebase", "-r", "b2", "-o", "c"]).success(); + work_dir + .run_jj(["bookmark", "create", "b1", "-r", "at_operation(@-, b2)"]) + .success(); + create_commit_with_files(&work_dir, "d", &["b1"], &[("file4", "4")]); + + // Second divergent change: + create_commit_with_files(&work_dir, "e2", &["a"], &[("file5", "5")]); + create_commit_with_files(&work_dir, "f", &["a"], &[("file6", "6")]); + work_dir.run_jj(["rebase", "-r", "e2", "-o", "f"]).success(); + work_dir + .run_jj(["bookmark", "create", "e1", "-r", "at_operation(@-, e2)"]) + .success(); + create_commit_with_files(&work_dir, "g", &["e1"], &[("file7", "7")]); + + // Test the setup: look at the commit graph (commit B is duplicated and commit E + // is duplicated) + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ g xznxytkn 46658cae - description: g + ○ e1 kmkuslsw/1 15962bae - description: e2 + │ ○ e2 kmkuslsw/0 b54f15d8 - description: e2 + │ ○ f lylxulpl d50e2761 - description: f + ├─╯ + │ ○ b2 zsuskuln/0 2c2bd25d - description: b2 + │ ○ c royxmykx 4343fc61 - description: c + ├─╯ + │ ○ d znkkpsqq bf5126ef - description: d + │ ○ b1 zsuskuln/1 59a77004 - description: b2 + ├─╯ + ○ a rlvkpnrz e9a731d9 - description: a + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Pass --non-interactive to jj converge command. + let output = + work_dir.run_jj_with(|cmd| force_interactive(cmd).args(["converge", "--no-interactive"])); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Found 2 divergent change(s) in the specified revset: + - Change: zsuskulnrvyr with 2 commits: + zsuskuln/0 2c2bd25d b2 | (divergent) b2 + zsuskuln/1 59a77004 b1 | (divergent) b2 + + - Change: kmkuslswpqwq with 2 commits: + kmkuslsw/0 b54f15d8 e2 | (divergent) e2 + kmkuslsw/1 15962bae e1 | (divergent) e2 + + Error: Cannot automatically choose which change to converge + Hint: Run `jj converge` in interactive mode, or specify a revset that resolves to only one change ID + [EOF] + [exit status: 1] + "); + + // Note: in the test environment jj commands run in non-interactive (quiet) mode + // by default, so the following also fails but for a different reason: it + // cannot prompt the user + let output = work_dir.run_jj(["converge"]); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Found 2 divergent change(s) in the specified revset: + - Change: zsuskulnrvyr with 2 commits: + zsuskuln/0 2c2bd25d b2 | (divergent) b2 + zsuskuln/1 59a77004 b1 | (divergent) b2 + + - Change: kmkuslswpqwq with 2 commits: + kmkuslsw/0 b54f15d8 e2 | (divergent) e2 + kmkuslsw/1 15962bae e1 | (divergent) e2 + + Choose which change to converge (jj converge only converges one change at a time): + 1: zsuskulnrvyr + 2: kmkuslswpqwq + q: abort + Error: Cannot prompt for input since the output is not connected to a terminal + [EOF] + [exit status: 1] + "); + + // Note: the invocation also fails if stdin is not connected to a terminal + let output = work_dir.run_jj_with(|cmd| force_interactive(cmd).args(["converge"])); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Found 2 divergent change(s) in the specified revset: + - Change: zsuskulnrvyr with 2 commits: + zsuskuln/0 2c2bd25d b2 | (divergent) b2 + zsuskuln/1 59a77004 b1 | (divergent) b2 + + - Change: kmkuslswpqwq with 2 commits: + kmkuslsw/0 b54f15d8 e2 | (divergent) e2 + kmkuslsw/1 15962bae e1 | (divergent) e2 + + Choose which change to converge (jj converge only converges one change at a time): + 1: zsuskulnrvyr + 2: kmkuslswpqwq + q: abort + Enter the index of the change to converge: Error: Prompt canceled by EOF + [EOF] + [exit status: 1] + "); +} + +// This tests scenarios where there are two divergent changes. The command +// prompts the user to choose which change to converge. +#[test] +fn test_converge_two_divergent_changes() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + // Set up: first create a base commit + create_commit_with_files(&work_dir, "a", &[], &[("file1", "1")]); + + // Set up: create commit graph with two divergent changes + // First divergent change: + create_commit_with_files(&work_dir, "b2", &["a"], &[("file2", "2")]); + create_commit_with_files(&work_dir, "c", &["a"], &[("file3", "3")]); + work_dir.run_jj(["rebase", "-r", "b2", "-o", "c"]).success(); + work_dir + .run_jj(["bookmark", "create", "b1", "-r", "at_operation(@-, b2)"]) + .success(); + create_commit_with_files(&work_dir, "d", &["b1"], &[("file4", "4")]); + + // Second divergent change: + create_commit_with_files(&work_dir, "e2", &["a"], &[("file5", "5")]); + create_commit_with_files(&work_dir, "f", &["a"], &[("file6", "6")]); + work_dir.run_jj(["rebase", "-r", "e2", "-o", "f"]).success(); + work_dir + .run_jj(["bookmark", "create", "e1", "-r", "at_operation(@-, e2)"]) + .success(); + create_commit_with_files(&work_dir, "g", &["e1"], &[("file7", "7")]); + + // Test the setup: look at the commit graph (commit B is duplicated and commit E + // is duplicated) + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ g xznxytkn 46658cae - description: g + ○ e1 kmkuslsw/1 15962bae - description: e2 + │ ○ e2 kmkuslsw/0 b54f15d8 - description: e2 + │ ○ f lylxulpl d50e2761 - description: f + ├─╯ + │ ○ b2 zsuskuln/0 2c2bd25d - description: b2 + │ ○ c royxmykx 4343fc61 - description: c + ├─╯ + │ ○ d znkkpsqq bf5126ef - description: d + │ ○ b1 zsuskuln/1 59a77004 - description: b2 + ├─╯ + ○ a rlvkpnrz e9a731d9 - description: a + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Test the setup: look at the evolog + insta::assert_snapshot!(get_evolog(&work_dir, "b2"), @r" + ○ zsuskuln/0 2c2bd25d (divergent) b2 + ○ zsuskuln/1 59a77004 (divergent) b2 + ○ zsuskuln/2 b2852eb2 (hidden) (empty) b2 + [EOF] + "); + + // Test the setup: look at the evolog + insta::assert_snapshot!(get_evolog(&work_dir, "e2"), @r" + ○ kmkuslsw/0 b54f15d8 (divergent) e2 + ○ kmkuslsw/1 15962bae (divergent) e2 + ○ kmkuslsw/2 843de29d (hidden) (empty) e2 + [EOF] + "); + + // If the user chooses to abort the converge operation nothing changes. + let output = work_dir + .run_jj_with(|cmd| force_interactive(cmd).args(["converge"]).write_stdin("q\n")) + .success(); + + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Found 2 divergent change(s) in the specified revset: + - Change: zsuskulnrvyr with 2 commits: + zsuskuln/0 2c2bd25d b2 | (divergent) b2 + zsuskuln/1 59a77004 b1 | (divergent) b2 + + - Change: kmkuslswpqwq with 2 commits: + kmkuslsw/0 b54f15d8 e2 | (divergent) e2 + kmkuslsw/1 15962bae e1 | (divergent) e2 + + Choose which change to converge (jj converge only converges one change at a time): + 1: zsuskulnrvyr + 2: kmkuslswpqwq + q: abort + Enter the index of the change to converge: + + Aborting... nothing changed. + [EOF] + "); + + // Run the command again, this time the user chooses the first divergent change. + // This invocation succeeds to automatically converge that change. A hint is + // printed to inform the user that there is still one divergent change + // remaining. + let output = work_dir + .run_jj_with(|cmd| force_interactive(cmd).args(["converge"]).write_stdin("1\n")) + .success(); + + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Found 2 divergent change(s) in the specified revset: + - Change: zsuskulnrvyr with 2 commits: + zsuskuln/0 2c2bd25d b2 | (divergent) b2 + zsuskuln/1 59a77004 b1 | (divergent) b2 + + - Change: kmkuslswpqwq with 2 commits: + kmkuslsw/0 b54f15d8 e2 | (divergent) e2 + kmkuslsw/1 15962bae e1 | (divergent) e2 + + Choose which change to converge (jj converge only converges one change at a time): + 1: zsuskulnrvyr + 2: kmkuslswpqwq + q: abort + Enter the index of the change to converge: + + Attempting to converge change zsuskulnrvyr... + + Successfully converged change: created commit ba447f020ee6. + Rebased 1 descendants + Hint: There are still 1 divergent changes remaining in the specified revset, you can run this command again to converge another one. + [EOF] + "); + + // Verify the commit graph after converging the first divergent change + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ g xznxytkn 46658cae - description: g + ○ e1 kmkuslsw/1 15962bae - description: e2 + │ ○ e2 kmkuslsw/0 b54f15d8 - description: e2 + │ ○ f lylxulpl d50e2761 - description: f + ├─╯ + │ ○ d znkkpsqq b30d892b - description: d + │ ○ b1 b2 zsuskuln ba447f02 - description: b2 + │ ○ c royxmykx 4343fc61 - description: c + ├─╯ + ○ a rlvkpnrz e9a731d9 - description: a + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Verify the evolution history after converging the first divergent change + insta::assert_snapshot!(get_evolog(&work_dir, "b2"), @r" + ○ zsuskuln ba447f02 b2 + ├─╮ + ○ │ zsuskuln/1 2c2bd25d (hidden) b2 + ├─╯ + ○ zsuskuln/2 59a77004 (hidden) b2 + ○ zsuskuln/3 b2852eb2 (hidden) (empty) b2 + [EOF] + "); + + // Run converge a second time to converge the other divergent change + let output = work_dir.run_jj(["converge"]).success(); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Found 1 divergent change(s) in the specified revset: + - Change: kmkuslswpqwq with 2 commits: + kmkuslsw/0 b54f15d8 e2 | (divergent) e2 + kmkuslsw/1 15962bae e1 | (divergent) e2 + + Attempting to converge change kmkuslswpqwq... + + Successfully converged change: created commit 3f08d00a88c4. + Rebased 1 descendants + Working copy (@) now at: xznxytkn f9fd4e7e g | g + Parent commit (@-) : kmkuslsw 3f08d00a e1 e2 | e2 + Added 1 files, modified 0 files, removed 0 files + [EOF] + "); + + // Verify the commit graph after converging the second divergent change + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ g xznxytkn f9fd4e7e - description: g + ○ e1 e2 kmkuslsw 3f08d00a - description: e2 + ○ f lylxulpl d50e2761 - description: f + │ ○ d znkkpsqq b30d892b - description: d + │ ○ b1 b2 zsuskuln ba447f02 - description: b2 + │ ○ c royxmykx 4343fc61 - description: c + ├─╯ + ○ a rlvkpnrz e9a731d9 - description: a + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Verify the evolution history after converging the second divergent change + insta::assert_snapshot!(get_evolog(&work_dir, "e2"), @r" + ○ kmkuslsw 3f08d00a e2 + ├─╮ + ○ │ kmkuslsw/1 b54f15d8 (hidden) e2 + ├─╯ + ○ kmkuslsw/2 15962bae (hidden) e2 + ○ kmkuslsw/3 843de29d (hidden) (empty) e2 + [EOF] + "); + + // There are no more divergent changes now + let output = work_dir.run_jj(["converge"]).success(); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + No divergent changes found. + [EOF] + "); +} + +// This tests scenarios where the user specifies revisions to converge. More +// precisely, the user specifies a revset that is used as the search space for +// divergent commits. +#[test] +fn test_converge_simple_with_revisions_arg() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + // Set up commit graph with divergent changes + create_commit_with_files(&work_dir, "a", &[], &[("file1", "1")]); + create_commit_with_files(&work_dir, "b2", &["a"], &[("file2", "2")]); + create_commit_with_files(&work_dir, "c", &["a"], &[("file3", "3")]); + work_dir.run_jj(["rebase", "-r", "b2", "-o", "c"]).success(); + work_dir + .run_jj(["bookmark", "create", "b1", "-r", "at_operation(@-, b2)"]) + .success(); + create_commit_with_files(&work_dir, "d", &["b1"], &[("file4", "4")]); + + // Test the setup (commit B is duplicated) + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ d znkkpsqq bf5126ef - description: d + ○ b1 zsuskuln/1 59a77004 - description: b2 + │ ○ b2 zsuskuln/0 2c2bd25d - description: b2 + │ ○ c royxmykx 4343fc61 - description: c + ├─╯ + ○ a rlvkpnrz e9a731d9 - description: a + ◆ zzzzzzzz 00000000 + [EOF] + "); + + insta::assert_snapshot!(get_evolog(&work_dir, "b2"), @r" + ○ zsuskuln/0 2c2bd25d (divergent) b2 + ○ zsuskuln/1 59a77004 (divergent) b2 + ○ zsuskuln/2 b2852eb2 (hidden) (empty) b2 + [EOF] + "); + + // `-r a::d` resolves to {a, b1, d}. b1 IS a divergent commit, but in that + // revset there are no other commits with that change-id, so by design the + // command does nothing (we could change that in the future). + let output = work_dir.run_jj(["converge", "-r", "a::d"]).success(); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + No divergence found among the specified revisions. + [EOF] + "); + + // `-r a::` resolves to {a, b1, b2, c, d}. Now the command "sees" two commits + // with the same change-id and converges them. + let output = work_dir.run_jj(["converge", "-r", "a::"]).success(); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Found 1 divergent change(s) in the specified revset: + - Change: zsuskulnrvyr with 2 commits: + zsuskuln/0 2c2bd25d b2 | (divergent) b2 + zsuskuln/1 59a77004 b1 | (divergent) b2 + + Attempting to converge change zsuskulnrvyr... + + Successfully converged change: created commit 5b9d32498e06. + Rebased 1 descendants + Working copy (@) now at: znkkpsqq 4080edbe d | d + Parent commit (@-) : zsuskuln 5b9d3249 b1 b2 | b2 + Added 1 files, modified 0 files, removed 0 files + [EOF] + "); + + // Verify the commit graph after converge + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ d znkkpsqq 4080edbe - description: d + ○ b1 b2 zsuskuln 5b9d3249 - description: b2 + ○ c royxmykx 4343fc61 - description: c + ○ a rlvkpnrz e9a731d9 - description: a + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Verify the evolution history after converge + insta::assert_snapshot!(get_evolog(&work_dir, "b2"), @r" + ○ zsuskuln 5b9d3249 b2 + ├─╮ + ○ │ zsuskuln/1 2c2bd25d (hidden) b2 + ├─╯ + ○ zsuskuln/2 59a77004 (hidden) b2 + ○ zsuskuln/3 b2852eb2 (hidden) (empty) b2 + [EOF] + "); +} + +// This tests scenarios where the user specifies revisions to converge. More +// precisely, the user specifies a revset that is used as the search space for +// divergent commits. This is a variation of +// test_converge_simple_with_revisions_arg: in that test there was a single +// divergent change, here there are two. +#[test] +fn test_converge_simple_with_revisions_arg_and_two_divergent_changes() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + // Set up: first create a base commit + create_commit_with_files(&work_dir, "a", &[], &[("file1", "1")]); + + // Set up: create commit graph with two divergent changes + // First divergent change: + create_commit_with_files(&work_dir, "b2", &["a"], &[("file2", "2")]); + create_commit_with_files(&work_dir, "c", &["a"], &[("file3", "3")]); + work_dir.run_jj(["rebase", "-r", "b2", "-o", "c"]).success(); + work_dir + .run_jj(["bookmark", "create", "b1", "-r", "at_operation(@-, b2)"]) + .success(); + create_commit_with_files(&work_dir, "d", &["b1"], &[("file4", "4")]); + + // Second divergent change: + create_commit_with_files(&work_dir, "e3", &["a"], &[("file5", "5")]); + create_commit_with_files(&work_dir, "f", &["a"], &[("file6", "6")]); + work_dir.run_jj(["rebase", "-r", "e3", "-o", "f"]).success(); + work_dir + .run_jj(["bookmark", "create", "e2", "-r", "at_operation(@-, e3)"]) + .success(); + work_dir + .run_jj(["describe", "-r", "e2", "-m", "blah blah blah"]) + .success(); + work_dir + .run_jj(["bookmark", "create", "e1", "-r", "at_operation(@-, e2)"]) + .success(); + create_commit_with_files(&work_dir, "g", &["e2"], &[("file7", "7")]); + + // Test the setup: look at the commit graph (commit B is duplicated and commit E + // is duplicated) + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ g nmzmmopx 7e4fac7e - description: g + ○ e2 kmkuslsw/0 c8976369 - description: blah blah blah + │ ○ e3 kmkuslsw/1 d34ec64c - description: e3 + │ ○ f lylxulpl d50e2761 - description: f + ├─╯ + │ ○ e1 kmkuslsw/2 faebbd68 - description: e3 + ├─╯ + │ ○ b2 zsuskuln/0 2c2bd25d - description: b2 + │ ○ c royxmykx 4343fc61 - description: c + ├─╯ + │ ○ d znkkpsqq bf5126ef - description: d + │ ○ b1 zsuskuln/1 59a77004 - description: b2 + ├─╯ + ○ a rlvkpnrz e9a731d9 - description: a + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Test the setup: look at the evolog + insta::assert_snapshot!(get_evolog(&work_dir, "b2"), @r" + ○ zsuskuln/0 2c2bd25d (divergent) b2 + ○ zsuskuln/1 59a77004 (divergent) b2 + ○ zsuskuln/2 b2852eb2 (hidden) (empty) b2 + [EOF] + "); + + // Test the setup: look at the evolog + insta::assert_snapshot!(get_evolog(&work_dir, "e3"), @r" + ○ kmkuslsw/1 d34ec64c (divergent) e3 + ○ kmkuslsw/2 faebbd68 (divergent) e3 + ○ kmkuslsw/3 8f5eb314 (hidden) (empty) e3 + [EOF] + "); + + // `-r a::d` resolves to {a, b1, d}. b1 IS a divergent commit, but in that + // revset there are no other commits with that change-id, so by design the + // command does nothing (we could change that in the future). + let output = work_dir.run_jj(["converge", "-r", "a::d"]).success(); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + No divergence found among the specified revisions. + [EOF] + "); + + // `-r a::` does resolve to both divergent changes. In this test we simulate the + // user aborts at the prompt. + let output = work_dir + .run_jj_with(|cmd| { + force_interactive(cmd) + .args(["converge", "-r", "a::"]) + .write_stdin("q\n") + }) + .success(); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Found 2 divergent change(s) in the specified revset: + - Change: zsuskulnrvyr with 2 commits: + zsuskuln/0 2c2bd25d b2 | (divergent) b2 + zsuskuln/1 59a77004 b1 | (divergent) b2 + + - Change: kmkuslswpqwq with 3 commits: + kmkuslsw/0 c8976369 e2 | (divergent) blah blah blah + kmkuslsw/1 d34ec64c e3 | (divergent) e3 + kmkuslsw/2 faebbd68 e1 | (divergent) e3 + + Choose which change to converge (jj converge only converges one change at a time): + 1: zsuskulnrvyr + 2: kmkuslswpqwq + q: abort + Enter the index of the change to converge: + + Aborting... nothing changed. + [EOF] + "); + + // `-r b1|e3` resolve to those two commits. Both ARE divergent commits, but in + // the search space there are no other commits with either change-id so the + // command does nothing. + let output = work_dir + .run_jj_with(|cmd| { + force_interactive(cmd) + .args(["converge", "-r", "b1|e3"]) + .write_stdin("q\n") + }) + .success(); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + No divergence found among the specified revisions. + [EOF] + "); + + // Specifying `-r b1|b2` resolves to that divergent change and only that one. + // There should not be any prompt. + let output = work_dir + .run_jj_with(|cmd| { + force_interactive(cmd) + .args(["converge", "-r", "b1|b2"]) + .write_stdin("q\n") + }) + .success(); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Found 1 divergent change(s) in the specified revset: + - Change: zsuskulnrvyr with 2 commits: + zsuskuln/0 2c2bd25d b2 | (divergent) b2 + zsuskuln/1 59a77004 b1 | (divergent) b2 + + Attempting to converge change zsuskulnrvyr... + + Successfully converged change: created commit 32d4597c081c. + Rebased 1 descendants + [EOF] + "); + + // Look at the resulting commit graph + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ g nmzmmopx 7e4fac7e - description: g + ○ e2 kmkuslsw/0 c8976369 - description: blah blah blah + │ ○ e3 kmkuslsw/1 d34ec64c - description: e3 + │ ○ f lylxulpl d50e2761 - description: f + ├─╯ + │ ○ e1 kmkuslsw/2 faebbd68 - description: e3 + ├─╯ + │ ○ d znkkpsqq 5aecbdd4 - description: d + │ ○ b1 b2 zsuskuln 32d4597c - description: b2 + │ ○ c royxmykx 4343fc61 - description: c + ├─╯ + ○ a rlvkpnrz e9a731d9 - description: a + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Verify the evolution history after converge + insta::assert_snapshot!(get_evolog(&work_dir, "b2"), @r" + ○ zsuskuln 32d4597c b2 + ├─╮ + ○ │ zsuskuln/1 2c2bd25d (hidden) b2 + ├─╯ + ○ zsuskuln/2 59a77004 (hidden) b2 + ○ zsuskuln/3 b2852eb2 (hidden) (empty) b2 + [EOF] + "); + + // Lets undo the previous converge operation to try a different scenario. + work_dir.run_jj(["undo"]).success(); + + // The next invocation shows that specifying `-r e1|e3` converges those two + // divergent commits, but leaves e1 around (by design). + let output = work_dir + .run_jj_with(|cmd| { + force_interactive(cmd) + .args(["converge", "-r", "e1|e3"]) + .write_stdin("q\n") + }) + .success(); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Found 1 divergent change(s) in the specified revset: + - Change: kmkuslswpqwq with 2 commits: + kmkuslsw/1 d34ec64c e3 | (divergent) e3 + kmkuslsw/2 faebbd68 e1 | (divergent) e3 + + Attempting to converge change kmkuslswpqwq... + + Successfully converged change: created commit b5ce73ed2a0d. + [EOF] + "); + + // Look at the resulting commit graph + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ g nmzmmopx 7e4fac7e - description: g + ○ e2 kmkuslsw/1 c8976369 - description: blah blah blah + │ ○ e1 e3 kmkuslsw/0 b5ce73ed - description: e3 + │ ○ f lylxulpl d50e2761 - description: f + ├─╯ + │ ○ b2 zsuskuln/1 2c2bd25d - description: b2 + │ ○ c royxmykx 4343fc61 - description: c + ├─╯ + │ ○ d znkkpsqq bf5126ef - description: d + │ ○ b1 zsuskuln/2 59a77004 - description: b2 + ├─╯ + ○ a rlvkpnrz e9a731d9 - description: a + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Verify the evolution history after converge + insta::assert_snapshot!(get_evolog(&work_dir, "e3"), @r" + ○ kmkuslsw/0 b5ce73ed (divergent) e3 + ├─╮ + ○ │ kmkuslsw/2 d34ec64c (hidden) e3 + ├─╯ + ○ kmkuslsw/3 faebbd68 (hidden) e3 + ○ kmkuslsw/4 8f5eb314 (hidden) (empty) e3 + [EOF] + "); +} + +// In this scenario there are two divergent commits. One side changed the +// description, the other side was rebased. In such simple cases `jj converge` +// should be able to automatically combine the new description with the new +// parents. +#[test] +fn test_converge_one_side_rebased_one_side_description_changed() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + // Set up commit graph with divergent changes + create_commit_with_files(&work_dir, "a", &[], &[("file1", "1")]); + create_commit_with_files(&work_dir, "b2", &["a"], &[("file2", "2")]); + create_commit_with_files(&work_dir, "c", &["a"], &[("file3", "3")]); + work_dir.run_jj(["rebase", "-r", "b2", "-o", "c"]).success(); + work_dir + .run_jj(["bookmark", "create", "b1", "-r", "at_operation(@-, b2)"]) + .success(); + work_dir + .run_jj(["describe", "-r", "b1", "-m", "blah blah blah"]) + .success(); + create_commit_with_files(&work_dir, "d", &["b1"], &[("file4", "4")]); + + // Test the setup (commit B is duplicated) + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ d kpqxywon 16d29671 - description: d + ○ b1 zsuskuln/0 d471c689 - description: blah blah blah + │ ○ b2 zsuskuln/1 2c2bd25d - description: b2 + │ ○ c royxmykx 4343fc61 - description: c + ├─╯ + ○ a rlvkpnrz e9a731d9 - description: a + ◆ zzzzzzzz 00000000 + [EOF] + "); + + insta::assert_snapshot!(get_evolog(&work_dir, "b2"), @r" + ○ zsuskuln/1 2c2bd25d (divergent) b2 + ○ zsuskuln/2 59a77004 (hidden) b2 + ○ zsuskuln/3 b2852eb2 (hidden) (empty) b2 + [EOF] + "); + + let output = work_dir.run_jj(["converge"]).success(); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Found 1 divergent change(s) in the specified revset: + - Change: zsuskulnrvyr with 2 commits: + zsuskuln/0 d471c689 b1 | (divergent) blah blah blah + zsuskuln/1 2c2bd25d b2 | (divergent) b2 + + Attempting to converge change zsuskulnrvyr... + + Successfully converged change: created commit 65226e3f7378. + Rebased 1 descendants + Working copy (@) now at: kpqxywon 405941e7 d | d + Parent commit (@-) : zsuskuln 65226e3f b1 b2 | blah blah blah + Added 1 files, modified 0 files, removed 0 files + [EOF] + "); + + // Verify the commit graph after converge + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ d kpqxywon 405941e7 - description: d + ○ b1 b2 zsuskuln 65226e3f - description: blah blah blah + ○ c royxmykx 4343fc61 - description: c + ○ a rlvkpnrz e9a731d9 - description: a + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Verify the evolution history after converge + insta::assert_snapshot!(get_evolog(&work_dir, "b2"), @r" + ○ zsuskuln 65226e3f blah blah blah + ├─╮ + │ ○ zsuskuln/2 2c2bd25d (hidden) b2 + ○ │ zsuskuln/1 d471c689 (hidden) blah blah blah + ├─╯ + ○ zsuskuln/3 59a77004 (hidden) b2 + ○ zsuskuln/4 b2852eb2 (hidden) (empty) b2 + [EOF] + "); +} + +#[test_case(false; "dont_invoke_text_editor")] +#[test_case(true; "invoke_text_editor")] +fn test_converge_description_changed_inconsistently(invoke_text_editor: bool) -> TestResult { + let mut test_env = TestEnvironment::default(); + let edit_script = test_env.set_up_fake_editor(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + // Set up commit graph with divergent changes + create_commit_with_files(&work_dir, "a", &[], &[("file1", "1")]); + create_commit_with_files(&work_dir, "b2", &["a"], &[("file2", "2")]); + work_dir + .run_jj(["describe", "-r", "b2", "-m", "foo"]) + .success(); + work_dir + .run_jj(["bookmark", "create", "b1", "-r", "at_operation(@-, b2)"]) + .success(); + work_dir + .run_jj(["describe", "-r", "b1", "-m", "bar"]) + .success(); + create_commit_with_files(&work_dir, "d", &["b1"], &[("file3", "3")]); + + // Test the setup (commit B is duplicated) + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ d yostqsxw a906f67a - description: d + ○ b1 zsuskuln/0 0ec69b7a - description: bar + │ ○ b2 zsuskuln/1 08117b18 - description: foo + ├─╯ + ○ a rlvkpnrz e9a731d9 - description: a + ◆ zzzzzzzz 00000000 + [EOF] + "); + + insta::assert_snapshot!(get_evolog(&work_dir, "b2"), @r" + ○ zsuskuln/1 08117b18 (divergent) foo + ○ zsuskuln/2 59a77004 (hidden) b2 + ○ zsuskuln/3 b2852eb2 (hidden) (empty) b2 + [EOF] + "); + + // First check behavior in non-interactive mode. + let output = + work_dir.run_jj_with(|cmd| force_interactive(cmd).args(["converge", "--no-interactive"])); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Found 1 divergent change(s) in the specified revset: + - Change: zsuskulnrvyr with 2 commits: + zsuskuln/0 0ec69b7a b1 | (divergent) bar + zsuskuln/1 08117b18 b2 | (divergent) foo + + Attempting to converge change zsuskulnrvyr... + + Could not determine which description to use. + Error: Could not converge change + [EOF] + [exit status: 1] + "); + + // Now check behavior in interactive mode. + if invoke_text_editor { + std::fs::write( + &edit_script, + ["dump editor0", "write\nmy-merged-description"].join("\0"), + )?; + let output = work_dir + .run_jj_with(|cmd| force_interactive(cmd).args(["converge"]).write_stdin("y\n")) + .success(); + insta::assert_snapshot!( + std::fs::read_to_string(test_env.env_root().join("editor0"))?, @r#" + <<<<<<< conflict 1 of 1 + %%%%%%% diff from: zsuskuln 59a77004 "b2" + \\\\\\\ to: zsuskuln 0ec69b7a "bar" + -b2 + +bar + +++++++ zsuskuln 08117b18 "foo" + foo + >>>>>>> conflict 1 of 1 ends + "#); + insta::assert_snapshot!(output.stdout.normalized(), @""); + insta::assert_snapshot!(output.stderr.normalized(), @r" + Found 1 divergent change(s) in the specified revset: + - Change: zsuskulnrvyr with 2 commits: + zsuskuln/0 0ec69b7a b1 | (divergent) bar + zsuskuln/1 08117b18 b2 | (divergent) foo + + Attempting to converge change zsuskulnrvyr... + + There are divergent descriptions. You can choose to merge them now in a + text editor, or skip merging and use the conflicted description (with + conflict markers). Do you want to merge them now? (Yn): + + Successfully converged change: created commit a393891bef3b. + Rebased 1 descendants + Working copy (@) now at: yostqsxw a9dd817f d | d + Parent commit (@-) : zsuskuln a393891b b1 b2 | my-merged-description + "); + + // Verify the commit graph after converge + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ d yostqsxw a9dd817f - description: d + ○ b1 b2 zsuskuln a393891b - description: my-merged-des... + ○ a rlvkpnrz e9a731d9 - description: a + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Verify the evolution history after converge + insta::assert_snapshot!(get_evolog(&work_dir, "b2"), @r" + ○ zsuskuln a393891b my-merged-description + ├─╮ + │ ○ zsuskuln/2 08117b18 (hidden) foo + ○ │ zsuskuln/1 0ec69b7a (hidden) bar + ├─╯ + ○ zsuskuln/3 59a77004 (hidden) b2 + ○ zsuskuln/4 b2852eb2 (hidden) (empty) b2 + [EOF] + "); + } else { + let output = work_dir + .run_jj_with(|cmd| force_interactive(cmd).args(["converge"]).write_stdin("n\n")) + .success(); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Found 1 divergent change(s) in the specified revset: + - Change: zsuskulnrvyr with 2 commits: + zsuskuln/0 0ec69b7a b1 | (divergent) bar + zsuskuln/1 08117b18 b2 | (divergent) foo + + Attempting to converge change zsuskulnrvyr... + + There are divergent descriptions. You can choose to merge them now in a + text editor, or skip merging and use the conflicted description (with + conflict markers). Do you want to merge them now? (Yn): + + Successfully converged change: created commit 6fdb1551f127. + Rebased 1 descendants + Working copy (@) now at: yostqsxw 6b108e95 d | d + Parent commit (@-) : zsuskuln 6fdb1551 b1 b2 | <<<<<<< conflict 1 of 1 + [EOF] + "); + + // Verify the commit graph after converge + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ d yostqsxw 6b108e95 - description: d + ○ b1 b2 zsuskuln 6fdb1551 - description: <<<<<<< confl... + ○ a rlvkpnrz e9a731d9 - description: a + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Verify the evolution history after converge + insta::assert_snapshot!(get_evolog(&work_dir, "b2"), @r" + ○ zsuskuln 6fdb1551 <<<<<<< conflict 1 of 1 + ├─╮ + │ ○ zsuskuln/2 08117b18 (hidden) foo + ○ │ zsuskuln/1 0ec69b7a (hidden) bar + ├─╯ + ○ zsuskuln/3 59a77004 (hidden) b2 + ○ zsuskuln/4 b2852eb2 (hidden) (empty) b2 + [EOF] + "); + + // Verify the description after converge (it should have conflict markers) + let output = work_dir.run_jj(["log", "-T", "description", "-r", "b1", "--no-graph"]); + insta::assert_snapshot!(output, @r#" + <<<<<<< conflict 1 of 1 + %%%%%%% diff from: zsuskuln 59a77004 "b2" + \\\\\\\ to: zsuskuln 0ec69b7a "bar" + -b2 + +bar + +++++++ zsuskuln 08117b18 "foo" + foo + >>>>>>> conflict 1 of 1 ends + [EOF] + "#); + } + Ok(()) +} + +// In this scenario there are two divergent commits. Each side rebased their +// common predecessor on top of different parents. In this case `jj converge` +// cannot automatically determine which parents to use, so it should prompt the +// user. +#[test] +fn test_converge_with_inconsistent_parents() { + let test_env = TestEnvironment::default(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + // Set up commit graph with divergent changes + create_commit_with_files(&work_dir, "a", &[], &[("file1", "1")]); + create_commit_with_files(&work_dir, "b", &[], &[("file2", "2")]); + create_commit_with_files(&work_dir, "c", &[], &[("file3", "3")]); + create_commit_with_files(&work_dir, "d2", &["a"], &[("file4", "4")]); + work_dir.run_jj(["rebase", "-r", "d2", "-o", "b"]).success(); + work_dir + .run_jj(["bookmark", "create", "d1", "-r", "at_operation(@-, d2)"]) + .success(); + work_dir.run_jj(["rebase", "-r", "d1", "-o", "c"]).success(); + + // Test the setup (commit D is duplicated) + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ d2 vruxwmqv/1 c3de3020 - description: d2 + ○ b zsuskuln 38bded60 - description: b + │ ○ d1 vruxwmqv/0 4bcd1134 - description: d2 + │ ○ c royxmykx b616a3ce - description: c + ├─╯ + │ ○ a rlvkpnrz e9a731d9 - description: a + ├─╯ + ◆ zzzzzzzz 00000000 + [EOF] + "); + + insta::assert_snapshot!(get_evolog(&work_dir, "d1"), @r" + ○ vruxwmqv/0 4bcd1134 (divergent) d2 + ○ vruxwmqv/2 459038a5 (hidden) d2 + ○ vruxwmqv/3 b31c58cf (hidden) (empty) d2 + [EOF] + "); + + insta::assert_snapshot!(get_evolog(&work_dir, "d2"), @r" + @ vruxwmqv/1 c3de3020 (divergent) d2 + ○ vruxwmqv/2 459038a5 (hidden) d2 + ○ vruxwmqv/3 b31c58cf (hidden) (empty) d2 + [EOF] + "); + + // First check behavior in non-interactive mode. The command cannot determine + // which parents to use, so it should fail. + let output = + work_dir.run_jj_with(|cmd| force_interactive(cmd).args(["converge", "--no-interactive"])); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Found 1 divergent change(s) in the specified revset: + - Change: vruxwmqvtpmx with 2 commits: + vruxwmqv/0 4bcd1134 d1 | (divergent) d2 + vruxwmqv/1 c3de3020 d2 | (divergent) d2 + + Attempting to converge change vruxwmqvtpmx... + + Could not determine which parents to use. + Error: Could not converge change + [EOF] + [exit status: 1] + "); + + // Run the command again, but this time choose to abort at the prompt. + let output = + work_dir.run_jj_with(|cmd| force_interactive(cmd).args(["converge"]).write_stdin("q\n")); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Found 1 divergent change(s) in the specified revset: + - Change: vruxwmqvtpmx with 2 commits: + vruxwmqv/0 4bcd1134 d1 | (divergent) d2 + vruxwmqv/1 c3de3020 d2 | (divergent) d2 + + Attempting to converge change vruxwmqvtpmx... + + Could not determine automatically which parents to use + 1: 4bcd1134ba57: + Parent: royxmykx b616a3ce c | c + 2: c3de3020707a: + Parent: zsuskuln 38bded60 b | b + q: abort + Enter the index of one of the divergent commits whose parent(s) will be the parents of the solution: + + Error: Aborting... nothing changed. + [EOF] + [exit status: 1] + "); + + // Run the command one more time, this time the user chooses parents. + let output = + work_dir.run_jj_with(|cmd| force_interactive(cmd).args(["converge"]).write_stdin("2\n")); + insta::assert_snapshot!(output, @r" + ------- stderr ------- + Found 1 divergent change(s) in the specified revset: + - Change: vruxwmqvtpmx with 2 commits: + vruxwmqv/0 4bcd1134 d1 | (divergent) d2 + vruxwmqv/1 c3de3020 d2 | (divergent) d2 + + Attempting to converge change vruxwmqvtpmx... + + Could not determine automatically which parents to use + 1: 4bcd1134ba57: + Parent: royxmykx b616a3ce c | c + 2: c3de3020707a: + Parent: zsuskuln 38bded60 b | b + q: abort + Enter the index of one of the divergent commits whose parent(s) will be the parents of the solution: + + Successfully converged change: created commit 5a4258f7aa61. + Working copy (@) now at: vruxwmqv 5a4258f7 d1 d2 | d2 + Parent commit (@-) : zsuskuln 38bded60 b | b + [EOF] + "); + + // Verify the commit graph after converge + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ d1 d2 vruxwmqv 5a4258f7 - description: d2 + ○ b zsuskuln 38bded60 - description: b + │ ○ c royxmykx b616a3ce - description: c + ├─╯ + │ ○ a rlvkpnrz e9a731d9 - description: a + ├─╯ + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Verify the evolution history after converge + insta::assert_snapshot!(get_evolog(&work_dir, "d2"), @r" + @ vruxwmqv 5a4258f7 d2 + ├─╮ + │ ○ vruxwmqv/2 c3de3020 (hidden) d2 + ○ │ vruxwmqv/1 4bcd1134 (hidden) d2 + ├─╯ + ○ vruxwmqv/3 459038a5 (hidden) d2 + ○ vruxwmqv/4 b31c58cf (hidden) (empty) d2 + [EOF] + "); +} + +// It is possible that a divergent commit is a child of another divergent commit +// (with the same change-id or a different one). Consider the case where both +// parent and child have the same change-id. When converging that change-id the +// algorithm --or the user-- must choose the parent commit(s) of the solution. +// To be concrete, say commit A is the parent of commit B, both with the same +// change-id. +// +// Whether automatically or by user choice, `jj converge` is (currently) +// designed such that the parent(s) of the solution is the parent(s) of one of +// the divergent commits. BUT `jj converge` is "smart" and only considers +// divergent commits that are not descendants of other divergent commits during +// this parent selection process. Please see the implementation for more +// details. +// +// This test is to ensure that the above behavior is correct. +#[test] +fn test_converge_one_divergent_commit_is_a_descendant_of_another_divergent_commit() -> TestResult { + let mut test_env = TestEnvironment::default(); + let edit_script = test_env.set_up_fake_editor(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + // Start by setting description to "message 1", then simulate two concurrent + // operations, one changing the description to "message 2" and the other + // changing it to "message 3" (--at-op=@- is what allows us to pretend these two + // operations happen concurrently). At this point we have two divergent commits. + // Then we set up bookmarks b2 and b3 to point to the commits. Finally we rebase + // b2 onto b3. This sets the stage for this test's scenario. + work_dir.run_jj(["describe", "-m", "message 1"]).success(); + work_dir.run_jj(["describe", "-m", "message 2"]).success(); + work_dir + .run_jj(["describe", "-m", "message 3", "--at-op", "@-"]) + .success(); + work_dir + .run_jj([ + "bookmark", + "create", + "-r", + "description('message 2*')", + "b2", + ]) + .success(); + work_dir + .run_jj([ + "bookmark", + "create", + "-r", + "description('message 3*')", + "b3", + ]) + .success(); + work_dir + .run_jj(["rebase", "-r", "b2", "-d", "b3", "--keep-divergent"]) + .success(); + + // Test the setup: look at the operation log. + insta::assert_snapshot!(get_op_log_output(&work_dir), @r" + @ rebase commit 59df0df7968367d456d4438cc68ebe6a316ef8ce + │ args: jj rebase -r b2 -d b3 --keep-divergent + ○ create bookmark b3 pointing to commit 4734557e78fe9ccdb827e03e71602c685bfe8b53 + │ args: jj bookmark create -r 'description(\'message 3*\')' b3 + ○ create bookmark b2 pointing to commit 59df0df7968367d456d4438cc68ebe6a316ef8ce + │ args: jj bookmark create -r 'description(\'message 2*\')' b2 + ○ reconcile divergent operations + ├─╮ args: jj bookmark create -r 'description(\'message 2*\')' b2 + ○ │ describe commit a289638d100c5af526559dfafb99f062631771c4 + │ │ args: jj describe -m 'message 2' + │ ○ describe commit a289638d100c5af526559dfafb99f062631771c4 + ├─╯ args: jj describe -m 'message 3' --at-op @- + ○ describe commit e8849ae12c709f2321908879bc724fdb2ab8a781 + │ args: jj describe -m 'message 1' + ○ add workspace 'default' + ○ + [EOF] + "); + + // Test the setup: look at the commit graph (commit B is duplicated and commit E + // is duplicated) + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ b2 qpvuntsm/0 cca75b59 - description: message 2 + ○ b3 qpvuntsm/1 4734557e - description: message 3 + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Test the setup: look at the evolog + insta::assert_snapshot!(get_evolog(&work_dir, "b2"), @r" + @ qpvuntsm/0 cca75b59 (divergent) (empty) message 2 + ○ qpvuntsm/2 59df0df7 (hidden) (empty) message 2 + ○ qpvuntsm/3 a289638d (hidden) (empty) message 1 + ○ qpvuntsm/4 e8849ae1 (hidden) (empty) (no description set) + [EOF] + "); + + // Test the setup: look at the evolog + insta::assert_snapshot!(get_evolog(&work_dir, "b3"), @r" + ○ qpvuntsm/1 4734557e (divergent) (empty) message 3 + ○ qpvuntsm/3 a289638d (hidden) (empty) message 1 + ○ qpvuntsm/4 e8849ae1 (hidden) (empty) (no description set) + [EOF] + "); + + // Run `jj converge` command and check the output. In this case the user must + // merge the descriptions in a text editor. However, the parents are chosen + // automatically (b2 is ignored because it is a descendant of b3). + std::fs::write( + &edit_script, + ["dump editor0", "write\nmy-merged-description"].join("\0"), + )?; + let output = work_dir + .run_jj_with(|cmd| force_interactive(cmd).args(["converge"]).write_stdin("y\n")) + .success(); + insta::assert_snapshot!( + std::fs::read_to_string(test_env.env_root().join("editor0"))?, @r#" + <<<<<<< conflict 1 of 1 + %%%%%%% diff from: qpvuntsm a289638d "message 1" + \\\\\\\ to: qpvuntsm cca75b59 "message 2" + -message 1 + +message 2 + +++++++ qpvuntsm 4734557e "message 3" + message 3 + >>>>>>> conflict 1 of 1 ends + "#); + insta::assert_snapshot!(output.stdout.normalized(), @""); + insta::assert_snapshot!(output.stderr.normalized(), @r" + Found 1 divergent change(s) in the specified revset: + - Change: qpvuntsmwlqt with 2 commits: + qpvuntsm/0 cca75b59 b2 | (divergent) (empty) message 2 + qpvuntsm/1 4734557e b3 | (divergent) (empty) message 3 + + Attempting to converge change qpvuntsmwlqt... + + There are divergent descriptions. You can choose to merge them now in a + text editor, or skip merging and use the conflicted description (with + conflict markers). Do you want to merge them now? (Yn): + + Successfully converged change: created commit ae6fe4a3240c. + Working copy (@) now at: qpvuntsm ae6fe4a3 b2 b3 | (empty) my-merged-description + Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set) + "); + + // Verify the commit graph after converge + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ b2 b3 qpvuntsm ae6fe4a3 - description: my-merged-des... + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Verify the operation log after converge + insta::assert_snapshot!(get_op_log_output(&work_dir), @r" + @ converge qpvuntsmwlqt with 2 predecessors + │ args: jj converge + ○ rebase commit 59df0df7968367d456d4438cc68ebe6a316ef8ce + │ args: jj rebase -r b2 -d b3 --keep-divergent + ○ create bookmark b3 pointing to commit 4734557e78fe9ccdb827e03e71602c685bfe8b53 + │ args: jj bookmark create -r 'description(\'message 3*\')' b3 + ○ create bookmark b2 pointing to commit 59df0df7968367d456d4438cc68ebe6a316ef8ce + │ args: jj bookmark create -r 'description(\'message 2*\')' b2 + ○ reconcile divergent operations + ├─╮ args: jj bookmark create -r 'description(\'message 2*\')' b2 + ○ │ describe commit a289638d100c5af526559dfafb99f062631771c4 + │ │ args: jj describe -m 'message 2' + │ ○ describe commit a289638d100c5af526559dfafb99f062631771c4 + ├─╯ args: jj describe -m 'message 3' --at-op @- + ○ describe commit e8849ae12c709f2321908879bc724fdb2ab8a781 + │ args: jj describe -m 'message 1' + ○ add workspace 'default' + ○ + [EOF] + "); + + // Verify the evolution history after converge + insta::assert_snapshot!(get_evolog(&work_dir, "b2"), @r" + @ qpvuntsm ae6fe4a3 (empty) my-merged-description + ├─╮ + │ ○ qpvuntsm/2 4734557e (hidden) (empty) message 3 + ○ │ qpvuntsm/1 cca75b59 (hidden) (empty) message 2 + ○ │ qpvuntsm/3 59df0df7 (hidden) (empty) message 2 + ├─╯ + ○ qpvuntsm/4 a289638d (hidden) (empty) message 1 + ○ qpvuntsm/5 e8849ae1 (hidden) (empty) (no description set) + [EOF] + "); + + Ok(()) +} + +// Similar to +// test_converge_one_divergent_commit_is_a_descendant_of_another_divergent_commit, +// but with a few variations: +// * There is an related commit "in between" the two divergent commits +// * There are other unrelated commits +#[test] +fn test_converge_two_divergent_commits_with_unrelated_commit_in_between() -> TestResult { + let mut test_env = TestEnvironment::default(); + let edit_script = test_env.set_up_fake_editor(); + test_env.run_jj_in(".", ["git", "init", "repo"]).success(); + let work_dir = test_env.work_dir("repo"); + + // Start by setting description to "message 1", then simulate two concurrent + // operations, one changing the description to "message 2" and the other + // changing it to "message 3" (--at-op=@- is what allows us to pretend these two + // operations happen concurrently). At this point we have two divergent commits. + // Then we set up bookmarks b2 and b3 to point to the commits. After that we + // create foo as a child of b3, then we rebase b2 onto foo. This sets the + // stage for this test's scenario. We create two other commits (bar and baz) + // to observe how descendants are rebased. + work_dir.run_jj(["describe", "-m", "message 1"]).success(); + work_dir.run_jj(["describe", "-m", "message 2"]).success(); + work_dir + .run_jj(["describe", "-m", "message 3", "--at-op", "@-"]) + .success(); + work_dir + .run_jj([ + "bookmark", + "create", + "-r", + "description('message 2*')", + "b2", + ]) + .success(); + work_dir + .run_jj([ + "bookmark", + "create", + "-r", + "description('message 3*')", + "b3", + ]) + .success(); + work_dir.run_jj(["new", "-r", "b3", "-m", "foo"]).success(); + work_dir + .run_jj(["rebase", "-r", "b2", "-d", "@", "--keep-divergent"]) + .success(); + work_dir.run_jj(["new", "-r", "b2", "-m", "bar"]).success(); + work_dir.run_jj(["new", "-r", "b3", "-m", "baz"]).success(); + + // Test the setup: look at the operation log. + insta::assert_snapshot!(get_op_log_output(&work_dir), @r" + @ new empty commit + │ args: jj new -r b3 -m baz + ○ new empty commit + │ args: jj new -r b2 -m bar + ○ rebase commit 59df0df7968367d456d4438cc68ebe6a316ef8ce + │ args: jj rebase -r b2 -d @ --keep-divergent + ○ new empty commit + │ args: jj new -r b3 -m foo + ○ create bookmark b3 pointing to commit 4734557e78fe9ccdb827e03e71602c685bfe8b53 + │ args: jj bookmark create -r 'description(\'message 3*\')' b3 + ○ create bookmark b2 pointing to commit 59df0df7968367d456d4438cc68ebe6a316ef8ce + │ args: jj bookmark create -r 'description(\'message 2*\')' b2 + ○ reconcile divergent operations + ├─╮ args: jj bookmark create -r 'description(\'message 2*\')' b2 + ○ │ describe commit a289638d100c5af526559dfafb99f062631771c4 + │ │ args: jj describe -m 'message 2' + │ ○ describe commit a289638d100c5af526559dfafb99f062631771c4 + ├─╯ args: jj describe -m 'message 3' --at-op @- + ○ describe commit e8849ae12c709f2321908879bc724fdb2ab8a781 + │ args: jj describe -m 'message 1' + ○ add workspace 'default' + ○ + [EOF] + "); + + // Test the setup: look at the commit graph (commit B is duplicated and commit E + // is duplicated) + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ znkkpsqq aac9c864 - description: baz + │ ○ yostqsxw 38a29791 - description: bar + │ ○ b2 qpvuntsm/0 2a258b0d - description: message 2 + │ ○ yqosqzyt f658f253 - description: foo + ├─╯ + ○ b3 qpvuntsm/1 4734557e - description: message 3 + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Test the setup: look at the evolog + insta::assert_snapshot!(get_evolog(&work_dir, "b2"), @r" + ○ qpvuntsm/0 2a258b0d (divergent) (empty) message 2 + ○ qpvuntsm/2 59df0df7 (hidden) (empty) message 2 + ○ qpvuntsm/3 a289638d (hidden) (empty) message 1 + ○ qpvuntsm/4 e8849ae1 (hidden) (empty) (no description set) + [EOF] + "); + + // Test the setup: look at the evolog + insta::assert_snapshot!(get_evolog(&work_dir, "b3"), @r" + ○ qpvuntsm/1 4734557e (divergent) (empty) message 3 + ○ qpvuntsm/3 a289638d (hidden) (empty) message 1 + ○ qpvuntsm/4 e8849ae1 (hidden) (empty) (no description set) + [EOF] + "); + + // Run `jj converge` command and check the output. In this case the user must + // merge the descriptions in a text editor. However, the parents are chosen + // automatically (b2 is ignored because it is a descendant of b3). + std::fs::write( + &edit_script, + ["dump editor0", "write\nmy-merged-description"].join("\0"), + )?; + let output = work_dir + .run_jj_with(|cmd| force_interactive(cmd).args(["converge"]).write_stdin("y\n")) + .success(); + insta::assert_snapshot!( + std::fs::read_to_string(test_env.env_root().join("editor0"))?, @r#" + <<<<<<< conflict 1 of 1 + %%%%%%% diff from: qpvuntsm a289638d "message 1" + \\\\\\\ to: qpvuntsm 2a258b0d "message 2" + -message 1 + +message 2 + +++++++ qpvuntsm 4734557e "message 3" + message 3 + >>>>>>> conflict 1 of 1 ends + "#); + insta::assert_snapshot!(output.stdout.normalized(), @""); + insta::assert_snapshot!(output.stderr.normalized(), @r" + Found 1 divergent change(s) in the specified revset: + - Change: qpvuntsmwlqt with 2 commits: + qpvuntsm/0 2a258b0d b2 | (divergent) (empty) message 2 + qpvuntsm/1 4734557e b3 | (divergent) (empty) message 3 + + Attempting to converge change qpvuntsmwlqt... + + There are divergent descriptions. You can choose to merge them now in a + text editor, or skip merging and use the conflicted description (with + conflict markers). Do you want to merge them now? (Yn): + + Successfully converged change: created commit 605808281071. + Rebased 3 descendants + Working copy (@) now at: znkkpsqq 3ea044e0 (empty) baz + Parent commit (@-) : qpvuntsm 60580828 b2 b3 | (empty) my-merged-description + "); + + insta::assert_snapshot!(work_dir.run_jj(["op", "show"]).success(), @r" + 23ae9388893c test-username@host.example.com default@ 2001-02-03 04:05:21.000 +07:00 - 2001-02-03 04:05:21.000 +07:00 + converge qpvuntsmwlqt with 2 predecessors + args: jj converge + + Changed commits: + ○ + znkkpsqq 3ea044e0 (empty) baz + │ - znkkpsqq/1 aac9c864 (hidden) (empty) baz + │ ○ + yostqsxw a4dd040e (empty) bar + ├─╯ - yostqsxw/1 38a29791 (hidden) (empty) bar + │ ○ + yqosqzyt e85707a5 (empty) foo + ├─╯ - yqosqzyt/1 f658f253 (hidden) (empty) foo + ○ + qpvuntsm 60580828 b2 b3 | (empty) my-merged-description + - qpvuntsm/1 2a258b0d (hidden) (empty) message 2 + - qpvuntsm/2 4734557e (hidden) (empty) message 3 + + Changed working copy default@: + + znkkpsqq 3ea044e0 (empty) baz + - znkkpsqq/1 aac9c864 (hidden) (empty) baz + + Changed local bookmarks: + b2: + + qpvuntsm 60580828 b2 b3 | (empty) my-merged-description + - qpvuntsm/1 2a258b0d (hidden) (empty) message 2 + b3: + + qpvuntsm 60580828 b2 b3 | (empty) my-merged-description + - qpvuntsm/2 4734557e (hidden) (empty) message 3 + [EOF] + "); + + // Verify the commit graph after converge; notice the commit that was + // "sandwiched" between b2 and b3 (foo) is now a child of the solution commit. + insta::assert_snapshot!(get_long_log_output(&work_dir), @r" + @ znkkpsqq 3ea044e0 - description: baz + │ ○ yostqsxw a4dd040e - description: bar + ├─╯ + │ ○ yqosqzyt e85707a5 - description: foo + ├─╯ + ○ b2 b3 qpvuntsm 60580828 - description: my-merged-des... + ◆ zzzzzzzz 00000000 + [EOF] + "); + + // Verify the operation log after converge + insta::assert_snapshot!(get_op_log_output(&work_dir), @r" + @ converge qpvuntsmwlqt with 2 predecessors + │ args: jj converge + ○ new empty commit + │ args: jj new -r b3 -m baz + ○ new empty commit + │ args: jj new -r b2 -m bar + ○ rebase commit 59df0df7968367d456d4438cc68ebe6a316ef8ce + │ args: jj rebase -r b2 -d @ --keep-divergent + ○ new empty commit + │ args: jj new -r b3 -m foo + ○ create bookmark b3 pointing to commit 4734557e78fe9ccdb827e03e71602c685bfe8b53 + │ args: jj bookmark create -r 'description(\'message 3*\')' b3 + ○ create bookmark b2 pointing to commit 59df0df7968367d456d4438cc68ebe6a316ef8ce + │ args: jj bookmark create -r 'description(\'message 2*\')' b2 + ○ reconcile divergent operations + ├─╮ args: jj bookmark create -r 'description(\'message 2*\')' b2 + ○ │ describe commit a289638d100c5af526559dfafb99f062631771c4 + │ │ args: jj describe -m 'message 2' + │ ○ describe commit a289638d100c5af526559dfafb99f062631771c4 + ├─╯ args: jj describe -m 'message 3' --at-op @- + ○ describe commit e8849ae12c709f2321908879bc724fdb2ab8a781 + │ args: jj describe -m 'message 1' + ○ add workspace 'default' + ○ + [EOF] + "); + + // Verify the evolution history after converge + insta::assert_snapshot!(get_evolog(&work_dir, "b2"), @r" + ○ qpvuntsm 60580828 (empty) my-merged-description + ├─╮ + │ ○ qpvuntsm/2 4734557e (hidden) (empty) message 3 + ○ │ qpvuntsm/1 2a258b0d (hidden) (empty) message 2 + ○ │ qpvuntsm/3 59df0df7 (hidden) (empty) message 2 + ├─╯ + ○ qpvuntsm/4 a289638d (hidden) (empty) message 1 + ○ qpvuntsm/5 e8849ae1 (hidden) (empty) (no description set) + [EOF] + "); + + Ok(()) +} + +#[must_use] +fn get_long_log_output(work_dir: &TestWorkDir) -> CommandOutput { + let template = "bookmarks ++ ' ' ++ format_short_change_id_with_change_offset(self) ++ ' ' \ + ++ commit_id.shortest(8) ++ surround(' - description: ', '', truncate_end(16, \ + description.first_line(), '...'))"; + work_dir.run_jj(["log", "-T", template]) +} + +#[must_use] +fn get_op_log_output(work_dir: &TestWorkDir) -> CommandOutput { + work_dir.run_jj(["op", "log", "-T", "description ++ '\n' ++ attributes"]) +} + +#[must_use] +fn get_evolog>(work_dir: &TestWorkDir, revision: S) -> CommandOutput { + let template = "format_commit_summary_with_refs(commit, '')"; + work_dir.run_jj(["evolog", "-r", revision.as_ref(), "-T", template]) +} diff --git a/docs/config.md b/docs/config.md index a3b6e733331..8f2f9d53e88 100644 --- a/docs/config.md +++ b/docs/config.md @@ -2029,6 +2029,19 @@ different results depending on the order of merging. To turn it off, set same-change = "accept" ``` +## Converge settings + +The `jj converge` command attempts to resolve divergence by replacing two or +more divergent commits with a single commit. The command accepts a +`--revisions REVSET` argument, and looks for divergence within the commits that +match that revset. The `--revisions` argument is optional. By default +`jj converge` uses the `revsets.converge` revset: + +```toml +[revsets] +converge = "mutable() & divergent()" +``` + ## Filesystem monitor In large repositories, it may be beneficial to use a "filesystem monitor" to