diff --git a/CHANGELOG.md b/CHANGELOG.md index 78796b35230..6feac503162 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * A new config option `ui.always-allow-large-revsets` has been added to allow large revsets expressions in some commands, without the `all:` prefix. +* New command `jj parallelize` that rebases a set of revisions into siblings. + ### Fixed bugs ## [0.16.0] - 2024-04-03 diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index e307e96583a..4007ca8259e 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 00000000000..3759dbb4091 --- /dev/null +++ b/cli/src/commands/parallelize.rs @@ -0,0 +1,257 @@ +// 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::collections::HashSet; +use std::io::Write; +use std::rc::Rc; + +use indexmap::IndexSet; +use itertools::Itertools; +use jj_lib::backend::CommitId; +use jj_lib::commit::{Commit, CommitIteratorExt}; +use jj_lib::repo::Repo; +use jj_lib::revset::{RevsetExpression, RevsetIteratorExt}; +use tracing::instrument; + +use crate::cli_util::{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 +/// +/// Running `jj parallelize 1::2` will transform the history like this: +/// ```text +/// 3 +/// | 3 +/// 2 / \ +/// | -> 1 2 +/// 1 \ / +/// | 0 +/// 0 +/// ``` +/// +/// Each of the target revisions is rebased onto the parents of the root(s) of +/// the target revset (not to be confused with the repo root). The children of +/// the head(s) of the target revset are rebased onto the target revisions. +/// +/// The target revset is the union of the REVISIONS arguments and must satisfy +/// several conditions, otherwise the command will fail. +/// +/// 1. The heads of the target revset must have either the same children as the +/// other heads or none. +/// 2. The roots of the target revset have the same parents. +/// 3. The parents of all target revisions except the roots must also be +/// parallelized. This means that the target revisions must be connected. +#[derive(clap::Args, Clone, Debug)] +#[command(verbatim_doc_comment)] +pub(crate) struct ParallelizeArgs { + /// Revisions to parallelize + 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. They are ordered + // here with parents before children. + let target_commits: Vec = workspace_command + .parse_union_revsets(&args.revisions)? + .evaluate_to_commits()? + .try_collect()?; + if target_commits.len() < 2 { + writeln!(ui.status(), "Nothing changed.")?; + return Ok(()); + } + workspace_command.check_rewritable(target_commits.iter().ids())?; + let mut tx = workspace_command.start_transaction(); + let target_revset = + RevsetExpression::commits(target_commits.iter().ids().cloned().collect_vec()); + + let new_parents = + check_preconditions_and_get_new_parents(&target_revset, &target_commits, tx.repo())?; + + // 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 target_commit in target_commits.iter() { + // Children of the target commit, excluding other target commits. + let children: Vec = RevsetExpression::commit(target_commit.id().clone()) + .children() + .minus(&target_revset) + .evaluate_programmatic(tx.repo())? + .iter() + .commits(tx.repo().store()) + .try_collect()?; + // 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. + let common_parents: IndexSet = RevsetExpression::commit(target_commit.id().clone()) + .ancestors() + .intersection(&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.parents().into_iter()); + rebase_descendants( + &mut tx, + command.settings(), + &new_parents.into_iter().collect_vec(), + &[child], + Default::default(), + )?; + } + } + + // Rebase the target commits onto the parents of the root commit. + // We already checked that the roots have the same parents, so we can just + // use the first one. + let target_commits = target_commits + .into_iter() + // 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. After reversing, the commits + // are ordered with children before parents. + .rev() + .collect_vec(); + rebase_descendants( + &mut tx, + command.settings(), + &new_parents, + &target_commits, + Default::default(), + )?; + + tx.finish(ui, format!("parallelize {} commits", target_commits.len())) +} + +/// Returns the new parents of the parallelized commits or an error if any of +/// the following preconditions are not met: +/// +/// 1. If the heads of the target revset must not have different children. +/// 2. The roots of the target revset must not have different parents. +/// 3. The parents of all target revisions except the roots must also be +/// parallelized. This means that the target revisions must be connected. +/// +/// The `target_revset` must evaluate to the commits in `target_commits` when +/// the provided `repo` is used. +fn check_preconditions_and_get_new_parents( + target_revset: &Rc, + target_commits: &[Commit], + repo: &dyn Repo, +) -> Result, CommandError> { + check_target_heads(target_revset, repo)?; + let target_roots = check_target_roots(target_revset, repo)?; + check_target_commits_are_connected(&target_roots, target_commits)?; + + // We already verified that the roots have the same parents, so we can just + // use the first root. + Ok(target_roots[0].parents()) +} + +/// Returns an error if the heads of the target revset have children which are +/// different. +fn check_target_heads( + target_revset: &Rc, + repo: &dyn Repo, +) -> Result<(), CommandError> { + let target_heads = target_revset + .heads() + .evaluate_programmatic(repo)? + .iter() + .sorted() + .collect_vec(); + if target_heads.len() == 1 { + return Ok(()); + } + let all_children: Vec = target_revset + .heads() + .children() + .evaluate_programmatic(repo)? + .iter() + .commits(repo.store()) + .try_collect()?; + // Every child must have every target head as a parent, otherwise it means + // that the target heads have different children. + for child in all_children { + let parents = child.parent_ids().iter().sorted(); + if !parents.eq(target_heads.iter()) { + return Err(user_error( + "All heads of the target revisions must have the same children.", + )); + } + } + Ok(()) +} + +/// Returns the roots of the target revset or an error if their parents are +/// different. +fn check_target_roots( + target_revset: &Rc, + repo: &dyn Repo, +) -> Result, CommandError> { + let target_roots: Vec = target_revset + .roots() + .evaluate_programmatic(repo)? + .iter() + .commits(repo.store()) + .try_collect()?; + let expected_parents = target_roots[0].parent_ids().iter().sorted().collect_vec(); + for root in target_roots[1..].iter() { + let root_parents = root.parent_ids().iter().sorted(); + if !root_parents.eq(expected_parents.iter().copied()) { + return Err(user_error( + "All roots of the target revisions must have the same parents.", + )); + } + } + Ok(target_roots) +} + +/// The target commits must be connected. The parents of every target commit +/// except the root commit must also be target commits. Returns an error if this +/// requirement is not met. +fn check_target_commits_are_connected( + target_roots: &[Commit], + target_commits: &[Commit], +) -> Result<(), CommandError> { + let target_commit_ids: HashSet = target_commits.iter().ids().cloned().collect(); + for target_commit in target_commits.iter() { + if target_roots.iter().ids().contains(target_commit.id()) { + continue; + } + for parent in target_commit.parent_ids() { + if !target_commit_ids.contains(parent) { + // We check this condition to return a more useful error to the user. + if target_commit.parent_ids().len() == 1 { + return Err(user_error( + "Cannot parallelize since the target revisions are not connected.", + )); + } + return Err(user_error( + "Only the roots of the target revset are allowed to have parents which are \ + not being parallelized.", + )); + } + } + } + Ok(()) +} diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index 16493959381..39638eaa513 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,42 @@ This restores the repo to the state at the specified operation, effectively undo +## `jj parallelize` + +Parallelize revisions by making them siblings + +Running `jj parallelize 1::2` will transform the history like this: +```text +3 +| 3 +2 / \ +| -> 1 2 +1 \ / +| 0 +0 +``` + +Each of the target revisions is rebased onto the parents of the root(s) of +the target revset (not to be confused with the repo root). The children of +the head(s) of the target revset are rebased onto the target revisions. + +The target revset is the union of the REVISIONS arguments and must satisfy +several conditions, otherwise the command will fail. + +1. The heads of the target revset must have either the same children as the + other heads or none. +2. The roots of the target revset have the same parents. +3. The parents of all target revisions except the roots must also be + parallelized. This means that the target revisions must be connected. + +**Usage:** `jj parallelize [REVISIONS]...` + +###### **Arguments:** + +* `` — Revisions to parallelize + + + ## `jj prev` Move the working-copy commit to the parent revision diff --git a/cli/tests/runner.rs b/cli/tests/runner.rs index dcd71c016a5..b0e3b5e61ab 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 2d5e83b4a64..a3afb28f0d0 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 00000000000..1f941f761ed --- /dev/null +++ b/cli/tests/test_parallelize_command.rs @@ -0,0 +1,590 @@ +// 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", "description(1)::"]); + 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", "description(1)::description(4)"], + ); + 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_where_interior_has_non_target_children() { + 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", "description(2)", "-m=2c"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "description(5)", "-m=6"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ d27ee705f7a9 6 + ◉ 2e00cb15c7b6 5 + ◉ 9df3c87db1a2 4 + ◉ 9f5b59fa4622 3 + │ ◉ 9c8865930f3c 2c + ├─╯ + ◉ 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 + │ │ ├─╯ + │ │ │ ◉ 679fc870858c 2c + ╭─┬───╯ + │ ◉ │ 96ce11389312 2 + │ ├─╯ + ◉ │ 2bfe3fe3e472 1 + ├─╯ + ◉ 000000000000 + "###); +} + +#[test] +fn test_parallelize_where_root_has_non_target_children() { + 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..4 { + test_env.jj_cmd_ok(&workspace_path, &["commit", &format!("-m{n}")]); + } + test_env.jj_cmd_ok(&workspace_path, &["new", "description(1)", "-m=1c"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "description(3)", "-m=4"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 7636b3f489f4 4 + ◉ 9f5b59fa4622 3 + ◉ d826910d21fb 2 + │ ◉ 50e2ced81124 1c + ├─╯ + ◉ dc0e5d6135ce 1 + ◉ 000000000000 + "###); + test_env.jj_cmd_ok( + &workspace_path, + &["parallelize", "description(1)::description(3)"], + ); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + ◉ ad35c9caf4fb 1c + │ @ 6ee674074e23 4 + ╭─┼─╮ + │ │ ◉ 5bd049136a7c 3 + │ ◉ │ 60f737a5a4a7 2 + │ ├─╯ + ◉ │ 79ebcd81a1ee 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, &["commit", "-m", "1"]); + for n in 2..4 { + test_env.jj_cmd_ok(&workspace_path, &["commit", "-m", &n.to_string()]); + } + test_env.jj_cmd_ok(&workspace_path, &["new", "root()", "-m", "a"]); + test_env.jj_cmd_ok( + &workspace_path, + &["new", "description(2)", "description(a)", "-m", "2a-c"], + ); + test_env.jj_cmd_ok(&workspace_path, &["new", "description(3)", "-m", "4"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 90a65779e2ec 4 + ◉ 9f5b59fa4622 3 + │ ◉ a01c1fad8506 2a-c + ╭─┤ + │ ◉ 1eb902150bb9 a + ◉ │ d826910d21fb 2 + ◉ │ dc0e5d6135ce 1 + ├─╯ + ◉ 000000000000 + "###); + + // After this finishes, child-2a will have three parents: "1", "2", and "a". + test_env.jj_cmd_ok( + &workspace_path, + &["parallelize", "description(1)::description(3)"], + ); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 5a0dd49510d1 4 + ├─┬─╮ + │ │ ◉ a9334ecaa379 3 + │ │ │ ◉ 605371712469 2a-c + ╭─┬───┤ + │ │ │ ◉ 1eb902150bb9 a + │ │ ├─╯ + │ ◉ │ 3a7b37ebe843 2 + │ ├─╯ + ◉ │ 761e67df44b7 1 + ├─╯ + ◉ 000000000000 + "###); +} + +#[test] +fn test_parallelize_failure_disconnected_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..3 { + test_env.jj_cmd_ok(&workspace_path, &["commit", &format!("-m{n}")]); + } + test_env.jj_cmd_ok(&workspace_path, &["describe", "-m=3"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 9f5b59fa4622 3 + ◉ d826910d21fb 2 + ◉ dc0e5d6135ce 1 + ◉ 000000000000 + "###); + + insta::assert_snapshot!(test_env.jj_cmd_failure( + &workspace_path, &["parallelize", "description(1)", "description(3)"]),@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 + "###); + + insta::assert_snapshot!(test_env.jj_cmd_failure(&workspace_path,&["parallelize", "description(1)::"]), + @r###" + Error: Only the roots of the target revset are allowed to have parents which are not being parallelized. + "###); +} + +#[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 + "###); + + insta::assert_snapshot!(test_env.jj_cmd_failure(&workspace_path,&["parallelize", "description(1)::"]), + @r###" + Error: Only the roots of the target revset are allowed to have parents which are not being parallelized. + "###); +} + +#[test] +fn test_parallelize_root_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=y"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "root()", "-m=x"]); + test_env.jj_cmd_ok( + &workspace_path, + &["new", "description(y)", "description(x)", "-m=1"], + ); + test_env.jj_cmd_ok(&workspace_path, &["new", "-m=2"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "-m=3"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 9f66b50aa1f2 3 + ◉ dd995ce87f21 2 + ◉ 4b4941342e06 1 + ├─╮ + │ ◉ 4035b23c8f72 x + ◉ │ f3ec359cf9ff y + ├─╯ + ◉ 000000000000 + "###); + + test_env.jj_cmd_ok( + &workspace_path, + &["parallelize", "description(1)::description(2)"], + ); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 4e81469adb0d 3 + ├─╮ + │ ◉ 38945baf55f4 2 + │ ├─╮ + ◉ │ │ 9b1a1927720c 1 + ╰─┬─╮ + │ ◉ 4035b23c8f72 x + ◉ │ f3ec359cf9ff y + ├─╯ + ◉ 000000000000 + "###); +} + +#[test] +fn test_parallelize_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, &["commit", "-m=0"]); + test_env.jj_cmd_ok(&workspace_path, &["describe", "-m=1"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "description(0)", "-m=2"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 8314addde180 2 + │ ◉ a915696cf0ad 1 + ├─╯ + ◉ a56846756248 0 + ◉ 000000000000 + "###); + + test_env.jj_cmd_ok(&workspace_path, &["parallelize", "description(0)::"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ e84481c26195 2 + │ ◉ 2047527ade93 1 + ├─╯ + │ ◉ 9d0c0750973c 0 + ├─╯ + ◉ 000000000000 + "###); +} + +// All heads must have the same children as the other heads, but only if they +// have children. In this test only one head has children, so the command +// succeeds. +#[test] +fn test_parallelize_multiple_heads_with_and_without_children() { + 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=0"]); + test_env.jj_cmd_ok(&workspace_path, &["describe", "-m=1"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "description(0)", "-m=2"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 8314addde180 2 + │ ◉ a915696cf0ad 1 + ├─╯ + ◉ a56846756248 0 + ◉ 000000000000 + "###); + + test_env.jj_cmd_ok( + &workspace_path, + &["parallelize", "description(0)", "description(1)"], + ); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 49fe9e130d15 2 + ◉ 9d0c0750973c 0 + │ ◉ 2047527ade93 1 + ├─╯ + ◉ 000000000000 + "###); +} + +#[test] +fn test_parallelize_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=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 + "###); + + // Succeeds because the roots have the same parents. + test_env.jj_cmd_ok(&workspace_path, &["parallelize", "root().."]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 3c90598481cd 3 + │ ◉ b96aa55582e5 2 + ├─╯ + │ ◉ 3178394e33e7 a + ├─╯ + │ ◉ 1d9a0895e7d6 1 + ├─╯ + ◉ 000000000000 + "###); +} + +#[test] +fn test_parallelize_failure_multiple_heads_with_different_children() { + 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, &["commit", "-m=3"]); + 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, &["commit", "-m=c"]); + insta::assert_snapshot!(get_log_output(&test_env, &workspace_path), @r###" + @ 9b5fa4b364d4 + ◉ 7b095ae9b21f c + ◉ 5164ab888473 b + ◉ f16fe8ac5ce9 a + │ ◉ 9f5b59fa4622 3 + │ ◉ d826910d21fb 2 + │ ◉ dc0e5d6135ce 1 + ├─╯ + ◉ 000000000000 + "###); + + insta::assert_snapshot!( + test_env.jj_cmd_failure( + &workspace_path, + &[ + "parallelize", + "description(1)::description(2)", + "description(a)::description(b)", + ], + ),@r###" + Error: All heads of the target revisions must have the same children. + "###); +} + +#[test] +fn test_parallelize_failure_multiple_roots_with_different_parents() { + 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 + "###); + + insta::assert_snapshot!( + test_env.jj_cmd_failure( + &workspace_path, + &["parallelize", "description(2)::", "description(b)::"], + ),@r###" + Error: All roots of the target revisions must have the same parents. + "###); +} + +#[test] +fn test_parallelize_complex_nonlinear_target() { + 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, &["new", "-m=0", "root()"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "-m=1", "description(0)"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "-m=2", "description(0)"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "-m=3", "description(0)"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "-m=4", "all:heads(..)"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "-m=1c", "description(1)"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "-m=2c", "description(2)"]); + test_env.jj_cmd_ok(&workspace_path, &["new", "-m=3c", "description(3)"]); + insta::assert_snapshot!(get_log_output_with_parents(&test_env, &workspace_path), @r###" + @ b043eb81416c 3c parents: 3 + │ ◉ 48277ee9afe0 4 parents: 3 2 1 + ╭─┼─╮ + ◉ │ │ 944922f0c69f 3 parents: 0 + │ │ │ ◉ 9d28e8e38435 2c parents: 2 + │ ├───╯ + │ ◉ │ 97d7522f40e8 2 parents: 0 + ├─╯ │ + │ ◉ │ 6c82c22a5e35 1c parents: 1 + │ ├─╯ + │ ◉ 0c058af014a6 1 parents: 0 + ├─╯ + ◉ 745bea8029c1 0 parents: + ◉ 000000000000 parents: + "###); + + let (_stdout, stderr) = test_env.jj_cmd_ok( + &workspace_path, + &["parallelize", "description(0)::description(4)"], + ); + insta::assert_snapshot!(stderr, @r###" + Working copy now at: yostqsxw d193f3b7 (empty) 3c + Parent commit : rlvkpnrz cbb4e169 (empty) 0 + Parent commit : mzvwutvl cb944786 (empty) 3 + "###); + insta::assert_snapshot!(get_log_output_with_parents(&test_env, &workspace_path), @r###" + @ d193f3b72495 3c parents: 0 3 + ├─╮ + │ ◉ cb9447869bf0 3 parents: + │ │ ◉ 80fbafb56917 2c parents: 0 2 + ╭───┤ + │ │ ◉ 8f4b8ef68676 2 parents: + │ ├─╯ + │ │ ◉ 1985e0427139 1c parents: 0 1 + ╭───┤ + │ │ ◉ 82918d78c984 1 parents: + │ ├─╯ + ◉ │ cbb4e1692ef4 0 parents: + ├─╯ + │ ◉ 14ca4df576b3 4 parents: + ├─╯ + ◉ 000000000000 parents: + "###) +} + +fn get_log_output_with_parents(test_env: &TestEnvironment, cwd: &Path) -> String { + let template = r#" + separate(" ", + commit_id.short(), + description.first_line(), + "parents:", + parents.map(|c|c.description().first_line()) + )"#; + test_env.jj_cmd_success(cwd, &["log", "-T", template]) +} + +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]) +}