diff --git a/CHANGELOG.md b/CHANGELOG.md index 950c5d194..595fbd8be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 operations ("origin" by default) using the `git.fetch` and `git.push` configuration entries. +* `jj duplicate` can now duplicate multiple changes in one go. This preserves + any parent-child relationships between them. For example, the entire tree of + descendants of `abc` can be duplicated with `jj duplicate abc:`. + ### Fixed bugs * When sharing the working copy with a Git repo, we used to forget to export diff --git a/Cargo.lock b/Cargo.lock index b4afb2b9f..81c0b1ac7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -877,9 +877,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "1.9.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10a35a97730320ffe8e2d410b5d3b69279b98d2c14bdb8b70ea89ecf7888d41e" +checksum = "1885e79c1fc4b10f0e172c475f458b7f7b93061064d98c3293e98c5ba0c8b399" dependencies = [ "autocfg", "hashbrown", @@ -989,6 +989,7 @@ dependencies = [ "git2", "glob", "hex", + "indexmap", "insta", "itertools", "jujutsu-lib", diff --git a/Cargo.toml b/Cargo.toml index 2a0e3aa30..4b8f01d4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ timeago = { version = "0.4.0", default-features = false } thiserror = "1.0.38" tracing = "0.1.37" tracing-subscriber = { version = "0.3.16", default-features = false, features = ["std", "ansi", "env-filter", "fmt"] } +indexmap = "1.9.2" [target.'cfg(unix)'.dependencies] libc = { version = "0.2.139" } diff --git a/src/commands.rs b/src/commands.rs index c570049ab..4b9c7e8b5 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -25,6 +25,7 @@ use std::{fs, io}; use clap::builder::NonEmptyStringValueParser; use clap::{ArgGroup, ArgMatches, CommandFactory, FromArgMatches, Subcommand}; use config::Source; +use indexmap::{IndexMap, IndexSet}; use itertools::Itertools; use jujutsu_lib::backend::{CommitId, ObjectId, TreeValue}; use jujutsu_lib::commit::Commit; @@ -397,9 +398,9 @@ struct CommitArgs { /// Create a new change with the same content as an existing one #[derive(clap::Args, Clone, Debug)] struct DuplicateArgs { - /// The revision to duplicate + /// The revision(s) to duplicate #[arg(default_value = "@")] - revision: RevisionArg, + revisions: Vec, /// Ignored (but lets you pass `-r` for consistency with other commands) #[arg(short = 'r', hide = true)] unused_revision: bool, @@ -2034,30 +2035,80 @@ fn cmd_commit(ui: &mut Ui, command: &CommandHelper, args: &CommitArgs) -> Result Ok(()) } +fn resolve_multiple_rewriteable_revsets( + revision_args: &[RevisionArg], + workspace_command: &WorkspaceCommandHelper, +) -> Result, CommandError> { + let mut acc = Vec::new(); + for revset in revision_args { + let revisions = workspace_command.resolve_revset(revset)?; + workspace_command.check_non_empty(&revisions)?; + for commit in &revisions { + workspace_command.check_rewriteable(commit)?; + } + acc.extend(revisions); + } + Ok(acc) +} + fn cmd_duplicate( ui: &mut Ui, command: &CommandHelper, args: &DuplicateArgs, ) -> Result<(), CommandError> { let mut workspace_command = command.workspace_helper(ui)?; - let predecessor = workspace_command.resolve_single_rev(&args.revision)?; - workspace_command.check_rewriteable(&predecessor)?; + let to_duplicate: IndexSet = + resolve_multiple_rewriteable_revsets(&args.revisions, &workspace_command)? + .into_iter() + .collect(); + let mut duplicated_old_to_new: IndexMap = IndexMap::new(); + let mut tx = workspace_command - .start_transaction(&format!("duplicate commit {}", predecessor.id().hex())); + .start_transaction(&format!("duplicating {} commit(s)", to_duplicate.len())); + let index = tx.base_repo().index().clone(); + let store = tx.base_repo().store().clone(); let mut_repo = tx.mut_repo(); - let new_commit = mut_repo - .rewrite_commit(command.settings(), &predecessor) - .generate_new_change_id() - .write()?; - ui.write("Created: ")?; - write_commit_summary( - ui.stdout_formatter().as_mut(), - mut_repo.as_repo_ref(), - &workspace_command.workspace_id(), - &new_commit, - command.settings(), - )?; - ui.write("\n")?; + + for original_commit_id in index + .topo_order(to_duplicate.iter().map(|c| c.id())) + .into_iter() + .map(|index_entry| index_entry.commit_id()) + { + // Topological order ensures that any parents of `original_commit` are + // either not in `to_duplicate` or were already duplicated. + let original_commit = store.get_commit(&original_commit_id).unwrap(); + let new_parents = original_commit + .parents() + .iter() + .map(|parent| { + if let Some(duplicated_parent) = duplicated_old_to_new.get(parent) { + duplicated_parent + } else { + parent + } + .id() + .clone() + }) + .collect(); + let new_commit = mut_repo + .rewrite_commit(command.settings(), &original_commit) + .generate_new_change_id() + .set_parents(new_parents) + .write()?; + duplicated_old_to_new.insert(original_commit, new_commit); + } + + for (old, new) in duplicated_old_to_new.iter() { + ui.write(&format!("Duplicated {} as ", short_commit_hash(old.id())))?; + write_commit_summary( + ui.stdout_formatter().as_mut(), + mut_repo.as_repo_ref(), + &workspace_command.workspace_id(), + new, + command.settings(), + )?; + ui.write("\n")?; + } workspace_command.finish_transaction(ui, tx)?; Ok(()) } @@ -2068,18 +2119,7 @@ fn cmd_abandon( args: &AbandonArgs, ) -> Result<(), CommandError> { let mut workspace_command = command.workspace_helper(ui)?; - let to_abandon = { - let mut acc = Vec::new(); - for revset in &args.revisions { - let revisions = workspace_command.resolve_revset(revset)?; - workspace_command.check_non_empty(&revisions)?; - for commit in &revisions { - workspace_command.check_rewriteable(commit)?; - } - acc.extend(revisions); - } - acc - }; + let to_abandon = resolve_multiple_rewriteable_revsets(&args.revisions, &workspace_command)?; let transaction_description = if to_abandon.len() == 1 { format!("abandon commit {}", to_abandon[0].id().hex()) } else { diff --git a/tests/test_duplicate_command.rs b/tests/test_duplicate_command.rs index fba27a2b7..30e95cf27 100644 --- a/tests/test_duplicate_command.rs +++ b/tests/test_duplicate_command.rs @@ -56,7 +56,7 @@ fn test_duplicate() { let stdout = test_env.jj_cmd_success(&repo_path, &["duplicate", "a"]); insta::assert_snapshot!(stdout, @r###" - Created: 2f6dc5a1ffc2 a + Duplicated 2443ea76b0b1 as 2f6dc5a1ffc2 a "###); insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" o 2f6dc5a1ffc2 a @@ -72,7 +72,7 @@ fn test_duplicate() { insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["undo"]), @""); let stdout = test_env.jj_cmd_success(&repo_path, &["duplicate" /* duplicates `c` */]); insta::assert_snapshot!(stdout, @r###" - Created: 1dd099ea963c c + Duplicated 17a00fc21654 as 1dd099ea963c c "###); insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" o 1dd099ea963c c @@ -89,6 +89,163 @@ fn test_duplicate() { "###); } +#[test] +fn test_duplicate_many() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + + create_commit(&test_env, &repo_path, "a", &[]); + create_commit(&test_env, &repo_path, "b", &["a"]); + create_commit(&test_env, &repo_path, "c", &["a"]); + create_commit(&test_env, &repo_path, "d", &["c"]); + create_commit(&test_env, &repo_path, "e", &["b", "d"]); + // Test the setup + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + @ 921dde6e55c0 e + |\ + o | ebd06dba20ec d + o | c0cb3a0b73e7 c + | o 1394f625cbbd b + |/ + o 2443ea76b0b1 a + o 000000000000 (no description set) + "###); + + let stdout = test_env.jj_cmd_success(&repo_path, &["duplicate", "b:"]); + insta::assert_snapshot!(stdout, @r###" + Duplicated 1394f625cbbd as 3b74d9691015 b + Duplicated 921dde6e55c0 as 8348ddcec733 e + "###); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + o 8348ddcec733 e + |\ + o | 3b74d9691015 b + | | @ 921dde6e55c0 e + | | |\ + | |/ / + | o | ebd06dba20ec d + | o | c0cb3a0b73e7 c + |/ / + | o 1394f625cbbd b + |/ + o 2443ea76b0b1 a + o 000000000000 (no description set) + "###); + + // Try specifying the same commit twice directly + test_env.jj_cmd_success(&repo_path, &["undo"]); + let stdout = test_env.jj_cmd_success(&repo_path, &["duplicate", "b", "b"]); + insta::assert_snapshot!(stdout, @r###" + Duplicated 1394f625cbbd as 0276d3d7c24d b + "###); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + o 0276d3d7c24d b + | @ 921dde6e55c0 e + | |\ + | o | ebd06dba20ec d + | o | c0cb3a0b73e7 c + |/ / + | o 1394f625cbbd b + |/ + o 2443ea76b0b1 a + o 000000000000 (no description set) + "###); + + // Try specifying the same commit twice indirectly + test_env.jj_cmd_success(&repo_path, &["undo"]); + let stdout = test_env.jj_cmd_success(&repo_path, &["duplicate", "b:", "d:"]); + insta::assert_snapshot!(stdout, @r###" + Duplicated 1394f625cbbd as fa167d18a83a b + Duplicated ebd06dba20ec as 2181781b4f81 d + Duplicated 921dde6e55c0 as 0f7430f2727a e + "###); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + o 0f7430f2727a e + |\ + o | 2181781b4f81 d + | o fa167d18a83a b + | | @ 921dde6e55c0 e + | | |\ + | | o | ebd06dba20ec d + | |/ / + |/| | + o | | c0cb3a0b73e7 c + |/ / + | o 1394f625cbbd b + |/ + o 2443ea76b0b1 a + o 000000000000 (no description set) + "###); + + test_env.jj_cmd_success(&repo_path, &["undo"]); + // Reminder of the setup + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + @ 921dde6e55c0 e + |\ + o | ebd06dba20ec d + o | c0cb3a0b73e7 c + | o 1394f625cbbd b + |/ + o 2443ea76b0b1 a + o 000000000000 (no description set) + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["duplicate", "d:", "a"]); + insta::assert_snapshot!(stdout, @r###" + Duplicated 2443ea76b0b1 as c6f7f8c4512e a + Duplicated ebd06dba20ec as d94e4c55a68b d + Duplicated 921dde6e55c0 as 9bd4389f5d47 e + "###); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + o 9bd4389f5d47 e + |\ + o | d94e4c55a68b d + | | o c6f7f8c4512e a + | | | @ 921dde6e55c0 e + | | | |\ + | | |_|/ + | |/| | + | | | o ebd06dba20ec d + | |_|/ + |/| | + o | | c0cb3a0b73e7 c + | o | 1394f625cbbd b + |/ / + o | 2443ea76b0b1 a + |/ + o 000000000000 (no description set) + "###); + + // Check for BUG -- makes too many 'a'-s, etc. + test_env.jj_cmd_success(&repo_path, &["undo"]); + let stdout = test_env.jj_cmd_success(&repo_path, &["duplicate", "a:"]); + insta::assert_snapshot!(stdout, @r###" + Duplicated 2443ea76b0b1 as 0fe67a05989e a + Duplicated 1394f625cbbd as e13ac0adabdf b + Duplicated c0cb3a0b73e7 as df53fa589286 c + Duplicated ebd06dba20ec as 2f2442db08eb d + Duplicated 921dde6e55c0 as ee8fe64ed254 e + "###); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + o ee8fe64ed254 e + |\ + o | 2f2442db08eb d + o | df53fa589286 c + | o e13ac0adabdf b + |/ + o 0fe67a05989e a + | @ 921dde6e55c0 e + | |\ + | o | ebd06dba20ec d + | o | c0cb3a0b73e7 c + | | o 1394f625cbbd b + | |/ + | o 2443ea76b0b1 a + |/ + o 000000000000 (no description set) + "###); +} + // https://github.com/martinvonz/jj/issues/1050 #[test] fn test_undo_after_duplicate() { @@ -104,7 +261,7 @@ fn test_undo_after_duplicate() { let stdout = test_env.jj_cmd_success(&repo_path, &["duplicate", "a"]); insta::assert_snapshot!(stdout, @r###" - Created: f5cefcbb65a4 a + Duplicated 2443ea76b0b1 as f5cefcbb65a4 a "###); insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" o f5cefcbb65a4 a @@ -138,11 +295,11 @@ fn test_rebase_duplicates() { let stdout = test_env.jj_cmd_success(&repo_path, &["duplicate", "b"]); insta::assert_snapshot!(stdout, @r###" - Created: fdaaf3950f07 b + Duplicated 1394f625cbbd as fdaaf3950f07 b "###); let stdout = test_env.jj_cmd_success(&repo_path, &["duplicate", "b"]); insta::assert_snapshot!(stdout, @r###" - Created: 870cf438ccbb b + Duplicated 1394f625cbbd as 870cf438ccbb b "###); insta::assert_snapshot!(get_log_output_with_ts(&test_env, &repo_path), @r###" o 870cf438ccbb b @ 2001-02-03 04:05:14.000 +07:00