diff --git a/CHANGELOG.md b/CHANGELOG.md index d8247ed2cf..b3e8e0be2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * new function `working_copies()` for revsets to show the working copy commits of all workspaces. +* New command `jj parallelize` that rebases a chain of commits into siblings. + ### Fixed bugs ## [0.15.1] - 2024-03-06 diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index e307e96583..4007ca8259 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -39,6 +39,7 @@ mod new; mod next; mod obslog; mod operation; +mod parallelize; mod prev; mod rebase; mod resolve; @@ -114,6 +115,7 @@ enum Command { #[command(subcommand)] #[command(visible_alias = "op")] Operation(operation::OperationCommand), + Parallelize(parallelize::ParallelizeArgs), Prev(prev::PrevArgs), Rebase(rebase::RebaseArgs), Resolve(resolve::ResolveArgs), @@ -180,6 +182,9 @@ pub fn run_command(ui: &mut Ui, command_helper: &CommandHelper) -> Result<(), Co Command::Abandon(sub_args) => abandon::cmd_abandon(ui, command_helper, sub_args), Command::Edit(sub_args) => edit::cmd_edit(ui, command_helper, sub_args), Command::Next(sub_args) => next::cmd_next(ui, command_helper, sub_args), + Command::Parallelize(sub_args) => { + parallelize::cmd_parallelize(ui, command_helper, sub_args) + } Command::Prev(sub_args) => prev::cmd_prev(ui, command_helper, sub_args), Command::New(sub_args) => new::cmd_new(ui, command_helper, sub_args), Command::Move(sub_args) => r#move::cmd_move(ui, command_helper, sub_args), diff --git a/cli/src/commands/parallelize.rs b/cli/src/commands/parallelize.rs new file mode 100644 index 0000000000..173e946c31 --- /dev/null +++ b/cli/src/commands/parallelize.rs @@ -0,0 +1,188 @@ +// Copyright 2024 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::io::Write; +use std::rc::Rc; + +use indexmap::IndexSet; +use itertools::Itertools; +use jj_lib::backend::CommitId; +use jj_lib::commit::Commit; +use jj_lib::repo::Repo; +use jj_lib::revset::{RevsetExpression, RevsetIteratorExt}; +use tracing::instrument; + +use crate::cli_util::{short_commit_hash, CommandHelper, RevisionArg}; +use crate::command_error::{user_error, CommandError}; +use crate::commands::rebase::rebase_descendants; +use crate::ui::Ui; + +/// Parallelize revisions by making them siblings +/// +/// The set of target commits being parallelized must have a single head and +/// root commit (not to be confused with the repo root), otherwise the command +/// will fail. Each of the target commits is rebased onto the parents of the +/// root commit. The children of the head commit are rebased onto the target +/// commits. +/// +/// Example usage: +/// ```text +/// jj log +/// ◉ 3 +/// ◉ 2 +/// ◉ 1 +/// ◉ +/// jj parallelize 1::2 +/// jj log +/// ◉ 3 +/// ├─╮ +/// │ ◉ 2 +/// ◉ │ 1 +/// ├─╯ +/// ◉ +/// ``` +#[derive(clap::Args, Clone, Debug)] +#[command(verbatim_doc_comment)] +pub(crate) struct ParallelizeArgs { + revisions: Vec, +} + +#[instrument(skip_all)] +pub(crate) fn cmd_parallelize( + ui: &mut Ui, + command: &CommandHelper, + args: &ParallelizeArgs, +) -> Result<(), CommandError> { + let mut workspace_command = command.workspace_helper(ui)?; + // The target commits are the commits being parallelized. Ordered with parents + // before children. + let target_commits: Vec = workspace_command + .parse_union_revsets(&args.revisions)? + .evaluate_to_commit_ids()? + .collect(); + if target_commits.len() < 2 { + writeln!(ui.status(), "Nothing changed.")?; + return Ok(()); + } + workspace_command.check_rewritable(target_commits.iter())?; + let mut tx = workspace_command.start_transaction(); + let target_revset = RevsetExpression::commits(target_commits.clone()); + let target_head = get_head(&target_revset, tx.repo())?; + let target_root = get_root(&target_revset, tx.repo())?; + let connected_length = RevsetExpression::commits(vec![target_head, target_root.clone()]) + .connected() + .evaluate_programmatic(tx.repo())? + .iter() + .count(); + if connected_length != target_commits.len() { + return Err(user_error( + "Cannot parallelize since the target revisions are not connected.", + )); + } + + // Rebase the non-target children of each target commit onto its new + // parents. A child which had a target commit as an ancestor before + // parallelize ran will have the target commit as a parent afterward. + for (i, target_commit) in target_commits.iter().enumerate() { + // These parents are shared by all children of the target commit and + // include the target commit itself plus any of its ancestors which are + // being parallelized. The commits in `target_commits` form a linear + // chain ordered with parents before children. + let common_parents: IndexSet = target_commits[i..].iter().cloned().collect(); + // Children of the target commit, excluding other target commits. + let children: Vec = RevsetExpression::commit(target_commit.clone()) + .children() + .minus(&target_revset) + .evaluate_programmatic(tx.repo())? + .iter() + .commits(tx.repo().store()) + .try_collect()?; + for child in children { + let mut new_parents = common_parents.clone(); + new_parents.extend(child.parent_ids().iter().cloned()); + let parents: Vec = new_parents + .iter() + .map(|c| tx.repo().store().get_commit(c)) + .try_collect()?; + rebase_descendants( + &mut tx, + command.settings(), + &parents, + &[child], + Default::default(), + )?; + } + } + + // Rebase the target commits onto the parents of the root commit. + let new_parents = tx.repo().store().get_commit(&target_root)?.parents(); + // We need to reverse the iterator so that when we rebase the target commits + // they will appear in the same relative order in `jj log` that they were in + // before being parallelized. + let target_commits: Vec = target_commits + .iter() + // Children before parents. + .rev() + .map(|c| tx.repo().store().get_commit(c)) + .try_collect()?; + rebase_descendants( + &mut tx, + command.settings(), + &new_parents, + &target_commits, + Default::default(), + )?; + + tx.finish(ui, format!("parallelized {} commits", target_commits.len())) +} + +// Returns the head of the target revset or an error if the revset has multiple +// heads. +fn get_head( + target_revset: &Rc, + repo: &dyn Repo, +) -> Result { + let mut target_heads: Vec = target_revset + .heads() + .evaluate_programmatic(repo)? + .iter() + .collect(); + if target_heads.len() != 1 { + let heads = target_heads.iter().map(short_commit_hash).join(", "); + return Err(user_error(format!( + "Cannot parallelize a set of revisions with multiple heads. Heads: {heads}" + ))); + } + Ok(target_heads.pop().unwrap()) +} + +// Returns the root of the target revset or an error if the revset has multiple +// roots. +fn get_root( + target_revset: &Rc, + repo: &dyn Repo, +) -> Result { + let mut target_roots: Vec = target_revset + .roots() + .evaluate_programmatic(repo)? + .iter() + .collect(); + if target_roots.len() != 1 { + let roots = target_roots.iter().map(short_commit_hash).join(", "); + return Err(user_error(format!( + "Cannot parallelize a set of revisions with multiple roots. Roots: {roots}" + ))); + } + Ok(target_roots.pop().unwrap()) +} diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index 70a936cece..abdce3587d 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -61,6 +61,7 @@ This document contains the help content for the `jj` command-line program. * [`jj operation log`↴](#jj-operation-log) * [`jj operation undo`↴](#jj-operation-undo) * [`jj operation restore`↴](#jj-operation-restore) +* [`jj parallelize`↴](#jj-parallelize) * [`jj prev`↴](#jj-prev) * [`jj rebase`↴](#jj-rebase) * [`jj resolve`↴](#jj-resolve) @@ -126,6 +127,7 @@ To get started, see the tutorial at https://github.com/martinvonz/jj/blob/main/d * `next` — Move the working-copy commit to the child revision * `obslog` — Show how a change has evolved * `operation` — Commands for working with the operation log +* `parallelize` — Parallelize revisions by making them siblings * `prev` — Move the working-copy commit to the parent revision * `rebase` — Move revisions to different parent(s) * `resolve` — Resolve a conflicted file with an external merge tool @@ -1345,6 +1347,41 @@ This restores the repo to the state at the specified operation, effectively undo +## `jj parallelize` + +Parallelize revisions by making them siblings + +The set of target commits being parallelized must have a single head and +root commit (not to be confused with the repo root), otherwise the command +will fail. Each of the target commits is rebased onto the parents of the +root commit. The children of the head commit are rebased onto the target +commits. + +Example usage: +```text +jj log +◉ 3 +◉ 2 +◉ 1 +◉ +jj parallelize 1::2 +jj log +◉ 3 +├─╮ +│ ◉ 2 +◉ │ 1 +├─╯ +◉ +``` + +**Usage:** `jj parallelize [REVISIONS]...` + +###### **Arguments:** + +* `` + + + ## `jj prev` Move the working-copy commit to the parent revision diff --git a/cli/tests/runner.rs b/cli/tests/runner.rs index dcd71c016a..b0e3b5e61a 100644 --- a/cli/tests/runner.rs +++ b/cli/tests/runner.rs @@ -45,6 +45,7 @@ mod test_new_command; mod test_next_prev_commands; mod test_obslog_command; mod test_operations; +mod test_parallelize_command; mod test_rebase_command; mod test_repo_change_report; mod test_resolve_command; diff --git a/cli/tests/test_immutable_commits.rs b/cli/tests/test_immutable_commits.rs index 2d5e83b4a6..a3afb28f0d 100644 --- a/cli/tests/test_immutable_commits.rs +++ b/cli/tests/test_immutable_commits.rs @@ -184,6 +184,12 @@ fn test_rewrite_immutable_commands() { Error: Commit 3d14df18607e is immutable Hint: Configure the set of immutable commits via `revset-aliases.immutable_heads()`. "###); + // parallelize + let stderr = test_env.jj_cmd_failure(&repo_path, &["parallelize", "description(b)", "main"]); + insta::assert_snapshot!(stderr, @r###" + Error: Commit 3d14df18607e is immutable + Hint: Configure the set of immutable commits via `revset-aliases.immutable_heads()`. + "###); // rebase -s let stderr = test_env.jj_cmd_failure(&repo_path, &["rebase", "-s=main", "-d=@"]); insta::assert_snapshot!(stderr, @r###" diff --git a/cli/tests/test_parallelize_command.rs b/cli/tests/test_parallelize_command.rs new file mode 100644 index 0000000000..62941b6ab3 --- /dev/null +++ b/cli/tests/test_parallelize_command.rs @@ -0,0 +1,333 @@ +// Copyright 2024 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::path::Path; + +use crate::common::TestEnvironment; + +#[test] +fn test_parallelize_no_descendants() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["init", "repo", "--git"]); + let workspace_path = test_env.env_root().join("repo"); + + for n in 1..6 { + test_env.jj_cmd_ok(&workspace_path, &["commit", &format!("-m{n}")]); + } + test_env.jj_cmd_ok(&workspace_path, &["describe", "-m=6"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ b911505e443e 6 + ◉ 2e00cb15c7b6 5 + ◉ 9df3c87db1a2 4 + ◉ 9f5b59fa4622 3 + ◉ d826910d21fb 2 + ◉ dc0e5d6135ce 1 + ◉ 000000000000 + "###); + + test_env.jj_cmd_ok(&workspace_path, &["parallelize", "dc0::"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 6c7b60a45eb6 6 + │ ◉ 296f48966777 5 + ├─╯ + │ ◉ 524062469789 4 + ├─╯ + │ ◉ a9334ecaa379 3 + ├─╯ + │ ◉ 3a7b37ebe843 2 + ├─╯ + │ ◉ 761e67df44b7 1 + ├─╯ + ◉ 000000000000 + "###); +} + +// Only the head commit has descendants. +#[test] +fn test_parallelize_with_descendants_simple() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["init", "repo", "--git"]); + let workspace_path = test_env.env_root().join("repo"); + + for n in 1..6 { + test_env.jj_cmd_ok(&workspace_path, &["commit", &format!("-m{n}")]); + } + test_env.jj_cmd_ok(&workspace_path, &["describe", "-m=6"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ b911505e443e 6 + ◉ 2e00cb15c7b6 5 + ◉ 9df3c87db1a2 4 + ◉ 9f5b59fa4622 3 + ◉ d826910d21fb 2 + ◉ dc0e5d6135ce 1 + ◉ 000000000000 + "###); + + test_env.jj_cmd_ok(&workspace_path, &["parallelize", "dc0::9df"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ f28f986c7134 6 + ◉ 21e9963ac5ff 5 + ├─┬─┬─╮ + │ │ │ ◉ 524062469789 4 + │ │ ◉ │ a9334ecaa379 3 + │ │ ├─╯ + │ ◉ │ 3a7b37ebe843 2 + │ ├─╯ + ◉ │ 761e67df44b7 1 + ├─╯ + ◉ 000000000000 + "###); +} + +// One of the commits being parallelized has a child that isn't being +// parallelized. That child will become a merge of any ancestors which are being +// parallelized. +#[test] +fn test_parallelize_with_descendants_complex() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["init", "repo", "--git"]); + let workspace_path = test_env.env_root().join("repo"); + + for n in 1..6 { + test_env.jj_cmd_ok(&workspace_path, &["commit", &format!("-m{n}")]); + } + test_env.jj_cmd_ok(&workspace_path, &["new", "d82", "-m=child-of-2"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "2e0", "-m=6"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ d27ee705f7a9 6 + ◉ 2e00cb15c7b6 5 + ◉ 9df3c87db1a2 4 + ◉ 9f5b59fa4622 3 + │ ◉ 60c93e41d09c child-of-2 + ├─╯ + ◉ d826910d21fb 2 + ◉ dc0e5d6135ce 1 + ◉ 000000000000 + "###); + + test_env.jj_cmd_ok(&workspace_path, &["parallelize", "dc0::9df"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 9f1bec0d6c46 6 + ◉ 7dd2f5648395 5 + ├─┬─┬─╮ + │ │ │ ◉ b8f977c12383 4 + │ │ ◉ │ 7be8374575b9 3 + │ │ ├─╯ + │ │ │ ◉ 3b27a65c2742 child-of-2 + ╭─┬───╯ + │ ◉ │ 96ce11389312 2 + │ ├─╯ + ◉ │ 2bfe3fe3e472 1 + ├─╯ + ◉ 000000000000 + "###); +} + +// One of the commits being parallelized has a child that is a merge commit. +#[test] +fn test_parallelize_with_merge_commit_child() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["init", "repo", "--git"]); + let workspace_path = test_env.env_root().join("repo"); + + test_env.jj_cmd_ok(&workspace_path, &["branch", "create", "1"]); + for n in 2..4 { + test_env.jj_cmd_ok(&workspace_path, &["new"]); + test_env.jj_cmd_ok(&workspace_path, &["branch", "create", &n.to_string()]); + } + test_env.jj_cmd_ok(&workspace_path, &["new", "-r", "root()"]); + test_env.jj_cmd_ok(&workspace_path, &["branch", "create", "a"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "2", "a"]); + test_env.jj_cmd_ok(&workspace_path, &["branch", "create", "child-2a"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "3"]); + test_env.jj_cmd_ok(&workspace_path, &["branch", "create", "4"]); + + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 20e497a5a92d 4 + ◉ 93b247c05ba5 3 + │ ◉ ac18af02edec child-2a + ╭─┤ + │ ◉ 5b36783cd11c a + ◉ │ 4db490c88528 2 + ◉ │ 230dd059e1b0 1 + ├─╯ + ◉ 000000000000 + "###); + + // After this finishes, child-2a will have three parents: "1", "2", and "a". + test_env.jj_cmd_ok(&workspace_path, &["parallelize", "1::3"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 025b566b2069 4 + ├─┬─╮ + │ │ ◉ 48ecaa663297 3 + │ │ │ ◉ 9591d4874ae3 child-2a + ╭─┬───┤ + │ │ │ ◉ 5b36783cd11c a + │ │ ├─╯ + │ ◉ │ e12f80116b44 2 + │ ├─╯ + ◉ │ 7086b0bcbc06 1 + ├─╯ + ◉ 000000000000 + "###); +} + +#[test] +fn test_parallelize_failure_disjoint_target_commits() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["init", "repo", "--git"]); + let workspace_path = test_env.env_root().join("repo"); + + for n in 1..6 { + test_env.jj_cmd_ok(&workspace_path, &["commit", &format!("-m{n}")]); + } + test_env.jj_cmd_ok(&workspace_path, &["describe", "-m=6"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ b911505e443e 6 + ◉ 2e00cb15c7b6 5 + ◉ 9df3c87db1a2 4 + ◉ 9f5b59fa4622 3 + ◉ d826910d21fb 2 + ◉ dc0e5d6135ce 1 + ◉ 000000000000 + "###); + + insta::assert_snapshot!(test_env.jj_cmd_failure(&workspace_path, &["parallelize", "dc0", "9f5"]),@r###" + Error: Cannot parallelize since the target revisions are not connected. + "###); +} + +#[test] +fn test_parallelize_head_is_a_merge() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["init", "repo", "--git"]); + let workspace_path = test_env.env_root().join("repo"); + test_env.jj_cmd_ok(&workspace_path, &["commit", "-m=1"]); + test_env.jj_cmd_ok(&workspace_path, &["commit", "-m=2"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "root()"]); + test_env.jj_cmd_ok(&workspace_path, &["commit", "-m=a"]); + test_env.jj_cmd_ok(&workspace_path, &["commit", "-m=b"]); + test_env.jj_cmd_ok( + &workspace_path, + &["new", "description(2)", "description(b)", "-m=merged-head"], + ); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 1a8db14a8cf0 merged-head + ├─╮ + │ ◉ 401e43e9461f b + │ ◉ 66ea2ab19a70 a + ◉ │ d826910d21fb 2 + ◉ │ dc0e5d6135ce 1 + ├─╯ + ◉ 000000000000 + "###); + + test_env.jj_cmd_ok(&workspace_path, &["parallelize", "description(1)::"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), + @r###" + @ c76816784f6e merged-head + │ ◉ 3a7b37ebe843 2 + ├─╯ + │ ◉ 761e67df44b7 1 + ├─╯ + │ ◉ 401e43e9461f b + │ ◉ 66ea2ab19a70 a + ├─╯ + ◉ 000000000000 + "###); + panic!("This log output is incorrect."); +} + +#[test] +fn test_parallelize_interior_target_is_a_merge() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["init", "repo", "--git"]); + let workspace_path = test_env.env_root().join("repo"); + test_env.jj_cmd_ok(&workspace_path, &["describe", "-m=1"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "root()", "-m=a"]); + test_env.jj_cmd_ok( + &workspace_path, + &["new", "description(1)", "description(a)", "-m=2"], + ); + test_env.jj_cmd_ok(&workspace_path, &["new", "-m=3"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 299099c22761 3 + ◉ 0c4da981fc0a 2 + ├─╮ + │ ◉ 6d37472c632c a + ◉ │ dc0e5d6135ce 1 + ├─╯ + ◉ 000000000000 + "###); + + test_env.jj_cmd_ok(&workspace_path, &["parallelize", "description(1)::"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 3c90598481cd 3 + │ ◉ b96aa55582e5 2 + ├─╯ + │ ◉ 1d9a0895e7d6 1 + ├─╯ + │ ◉ 6d37472c632c a + ├─╯ + ◉ 000000000000 + "###); + panic!("This log output is incorrect."); +} + +#[test] +fn test_parallelize_failure_multiple_heads() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["init", "repo", "--git"]); + let workspace_path = test_env.env_root().join("repo"); + test_env.jj_cmd_ok(&workspace_path, &["describe", "-m=1"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "root()", "-m=2"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 9922fc972ef6 2 + │ ◉ dc0e5d6135ce 1 + ├─╯ + ◉ 000000000000 + "###); + + insta::assert_snapshot!(test_env.jj_cmd_failure(&workspace_path, &["parallelize", "dc0", "992"]),@r###" + Error: Cannot parallelize a set of revisions with multiple heads. Heads: 9922fc972ef6, dc0e5d6135ce + "###); +} + +#[test] +fn test_parallelize_failure_multiple_roots() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["init", "repo", "--git"]); + let workspace_path = test_env.env_root().join("repo"); + test_env.jj_cmd_ok(&workspace_path, &["describe", "-m=1"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "root()", "-m=2"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "all:root()..", "-m=3"]); + + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 42080ac7cc83 3 + ├─╮ + │ ◉ dc0e5d6135ce 1 + ◉ │ 9922fc972ef6 2 + ├─╯ + ◉ 000000000000 + "###); + + insta::assert_snapshot!(test_env.jj_cmd_failure(&workspace_path, &["parallelize", "..@"]),@r###" + Error: Cannot parallelize a set of revisions with multiple roots. Roots: 9922fc972ef6, dc0e5d6135ce + "###); +} + +fn get_log_output(test_env: &TestEnvironment, cwd: &Path) -> String { + let template = r#"separate(" ", commit_id.short(), local_branches, description)"#; + test_env.jj_cmd_success(cwd, &["log", "-T", template]) +}