From 1804287820c6d3a4111d27b6ab56b75d8dba41a9 Mon Sep 17 00:00:00 2001 From: Evan Mesterhazy Date: Sun, 31 Mar 2024 08:46:55 -0400 Subject: [PATCH] Implement jj parallelize #1079 --- cli/src/commands/mod.rs | 5 + cli/src/commands/parallelize.rs | 163 +++++++++++++++++++++++ cli/tests/cli-reference@.md.snap | 37 ++++++ cli/tests/runner.rs | 1 + cli/tests/test_parallelize_command.rs | 181 ++++++++++++++++++++++++++ 5 files changed, 387 insertions(+) create mode 100644 cli/src/commands/parallelize.rs create mode 100644 cli/tests/test_parallelize_command.rs 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..b5140807ef --- /dev/null +++ b/cli/src/commands/parallelize.rs @@ -0,0 +1,163 @@ +// 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 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 { + // #[arg(long, short)] + 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)?; + // Parse and validate the input revset(s). + // Find the parents of each commit. The parent(s) which are ancestors of + // each revision are the new parents of the input revisions. + // Find the children of each commit. The children which are descendants + // of each revision are the new children of the input revisions. + // If one or both of these sets cannot be found, then parallelize cannot + // proceed. + // let target_commits = workspace_command.parse_union_revsets(&args.revisions)?. + let mut tx = workspace_command.start_transaction(); + let target_revset = tx + .base_workspace_helper() + .parse_union_revsets(&args.revisions)?; + let target_commits: Vec = tx + .base_workspace_helper() + .evaluate_revset(target_revset.clone())? + .iter() + // We want parents before children, so we need to reverse the order. + .reversed() + .commits(tx.base_repo().store()) + .try_collect::<_, Vec, _>()?; + if target_commits.len() < 2 { + return Ok(writeln!(ui.stderr(), "Nothing to do.")?); + } + let target_heads: Vec = tx + .base_workspace_helper() + .evaluate_revset(target_revset.clone().heads())? + .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}" + ))); + } + let target_roots: Vec = tx + .base_workspace_helper() + .evaluate_revset(target_revset.roots())? + .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}" + ))); + } + + // Rebase the children of the head commit onto the target commits. + let new_children: Vec = RevsetExpression::commit(target_heads[0].clone()) + .children() + .evaluate_programmatic(tx.base_repo().as_ref())? + .iter() + .commits(tx.base_repo().store()) + .try_collect()?; + for child in new_children { + // The ordering here is intentional. We iterate over the target commits first so + // that when `jj log` prints the graph they will be printed in their original + // order. After that, we add any existing parents which aren't being + // parallelized. + let get_id = |c: &Commit| c.id().clone(); + let new_parents_for_child: Vec = target_commits + .iter() + .map(get_id) + .chain(child.parents().iter().map(get_id)) + .collect::>() + .iter() + .map(|c| tx.mut_repo().store().get_commit(c)) + .collect::, _>>()?; + rebase_descendants( + &mut tx, + command.settings(), + &new_parents_for_child, + &[child], + Default::default(), + )?; + } + + // Rebase the target commits onto the parents of the root commit. + let new_parents: Vec = RevsetExpression::commit(target_roots[0].clone()) + .parents() + .evaluate_programmatic(tx.base_repo().as_ref())? + .iter() + .commits(tx.base_repo().store()) + .try_collect()?; + rebase_descendants( + &mut tx, + command.settings(), + &new_parents, + &target_commits, + Default::default(), + )?; + + tx.finish( + ui, + format!("Parallelized {} commits.", target_commits.len()), + ) +} diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index 05d6746903..bb48f4fe22 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 @@ -1341,6 +1343,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_parallelize_command.rs b/cli/tests/test_parallelize_command.rs new file mode 100644 index 0000000000..656c7c7b54 --- /dev/null +++ b/cli/tests/test_parallelize_command.rs @@ -0,0 +1,181 @@ +// 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 + "###); +} + +// More than one commit being parallelized has descendants, but only the +// descendants of the head commit will be rebased onto the target commits. +#[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 + │ │ ├─╯ + ◉ │ │ 2bfe3fe3e472 1 + ├───╯ + │ │ ◉ bf7eef1f1dc6 child-of-2 + │ ├─╯ + │ ◉ 96ce11389312 2 + ├─╯ + ◉ 000000000000 + "###); +} + +#[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#"commit_id.short() ++ " " ++ description"#; + test_env.jj_cmd_success(cwd, &["log", "-T", template]) +}