diff --git a/CHANGELOG.md b/CHANGELOG.md index 2870323e9..c9b98d187 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * Graph node symbols are now configurable via `ui.graph.default_node` and `ui.graph.elided_node`. +* `jj squash` now accepts `--from` and `--into` (mutually exclusive with `-r`). + It can thereby be for all use cases where `jj move` can be used. + ### Fixed bugs ## [0.15.1] - 2024-03-06 diff --git a/cli/src/commands/squash.rs b/cli/src/commands/squash.rs index b9f0e0587..938d5e017 100644 --- a/cli/src/commands/squash.rs +++ b/cli/src/commands/squash.rs @@ -26,12 +26,20 @@ use crate::command_error::{user_error, CommandError}; use crate::description_util::{combine_messages, join_message_paragraphs}; use crate::ui::Ui; -/// Move changes from a revision into its parent +/// Move changes from a revision into another revision /// -/// After moving the changes into the parent, the child revision will have the -/// same content state as before. If that means that the change is now empty -/// compared to its parent, it will be abandoned. -/// Without `--interactive`, the child change will always be empty. +/// With the `-r` option, moves the changes from the specified revision to the +/// parent revision. Fails if there are several parent revisions (i.e., the +/// given revision is a merge). +/// +/// With the `--from` and/or `--into` options, moves changes from/to the given +/// revisions. If either is left out, it defaults to the working-copy commit. +/// For example, `jj squash --into @--` moves changes from the working-copy +/// commit to the grandparent. +/// +/// If, after moving changes out, the source revision is empty compared to its +/// parent(s), it will be abandoned. Without `--interactive`, the source +/// revision will always be empty. /// /// If the source became empty and both the source and destination had a /// non-empty description, you will be asked for the combined description. If @@ -44,6 +52,12 @@ pub(crate) struct SquashArgs { /// Revision to squash into its parent (default: @) #[arg(long, short)] revision: Option, + /// Revision to squash from (default: @) + #[arg(long, conflicts_with = "revision")] + from: Option, + /// Revision to squash into (default: @) + #[arg(long, conflicts_with = "revision")] + into: Option, /// The description to use for squashed revision (don't open editor) #[arg(long = "message", short, value_name = "MESSAGE")] message_paragraphs: Vec, @@ -65,12 +79,24 @@ pub(crate) fn cmd_squash( args: &SquashArgs, ) -> Result<(), CommandError> { let mut workspace_command = command.workspace_helper(ui)?; - let source = workspace_command.resolve_single_rev(args.revision.as_deref().unwrap_or("@"))?; - let mut parents = source.parents(); - if parents.len() != 1 { - return Err(user_error("Cannot squash merge commits")); + + let source; + let destination; + if args.from.is_some() || args.into.is_some() { + source = workspace_command.resolve_single_rev(args.from.as_deref().unwrap_or("@"))?; + destination = workspace_command.resolve_single_rev(args.into.as_deref().unwrap_or("@"))?; + if source.id() == destination.id() { + return Err(user_error("Source and destination cannot be the same")); + } + } else { + source = workspace_command.resolve_single_rev(args.revision.as_deref().unwrap_or("@"))?; + let mut parents = source.parents(); + if parents.len() != 1 { + return Err(user_error("Cannot squash merge commits")); + } + destination = parents.pop().unwrap(); } - let destination = parents.pop().unwrap(); + let matcher = workspace_command.matcher_from_values(&args.paths)?; let diff_selector = workspace_command.diff_selector(ui, args.tool.as_deref(), args.interactive)?; @@ -87,7 +113,7 @@ pub(crate) fn cmd_squash( matcher.as_ref(), &diff_selector, description, - args.revision.is_none(), + args.revision.is_none() && args.from.is_none() && args.into.is_none(), &args.paths, )?; tx.finish(ui, tx_description)?; diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index c634110b0..cd816acc3 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -133,7 +133,7 @@ repository. * `show` — Show commit description and changes in a revision * `sparse` — Manage which paths from the working-copy commit are present in the working copy * `split` — Split a revision in two -* `squash` — Move changes from a revision into its parent +* `squash` — Move changes from a revision into another revision * `status` — Show high-level repo status * `tag` — Manage tags * `util` — Infrequently used commands such as for generating shell completions @@ -1684,9 +1684,13 @@ If the change you split had a description, you will be asked to enter a change d ## `jj squash` -Move changes from a revision into its parent +Move changes from a revision into another revision -After moving the changes into the parent, the child revision will have the same content state as before. If that means that the change is now empty compared to its parent, it will be abandoned. Without `--interactive`, the child change will always be empty. +With the `-r` option, moves the changes from the specified revision to the parent revision. Fails if there are several parent revisions (i.e., the given revision is a merge). + +With the `--from` and/or `--into` options, moves changes from/to the given revisions. If either is left out, it defaults to the working-copy commit. For example, `jj squash --into @--` moves changes from the working-copy commit to the grandparent. + +If, after moving changes out, the source revision is empty compared to its parent(s), it will be abandoned. Without `--interactive`, the source revision will always be empty. If the source became empty and both the source and destination had a non-empty description, you will be asked for the combined description. If either was empty, then the other one will be used. @@ -1701,6 +1705,8 @@ If a working-copy commit gets abandoned, it will be given a new, empty commit. T ###### **Options:** * `-r`, `--revision ` — Revision to squash into its parent (default: @) +* `--from ` — Revision to squash from (default: @) +* `--into ` — Revision to squash into (default: @) * `-m`, `--message ` — The description to use for squashed revision (don't open editor) * `-i`, `--interactive` — Interactively choose which parts to squash diff --git a/cli/tests/test_squash_command.rs b/cli/tests/test_squash_command.rs index e8ed1b89e..f32dd3e8a 100644 --- a/cli/tests/test_squash_command.rs +++ b/cli/tests/test_squash_command.rs @@ -264,6 +264,324 @@ fn test_squash_partial() { insta::assert_snapshot!(stdout, @""); } +#[test] +fn test_squash_from_to() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + + // Create history like this: + // F + // | + // E C + // | | + // D B + // |/ + // A + // + // When moving changes between e.g. C and F, we should not get unrelated changes + // from B and D. + test_env.jj_cmd_ok(&repo_path, &["branch", "create", "a"]); + std::fs::write(repo_path.join("file1"), "a\n").unwrap(); + std::fs::write(repo_path.join("file2"), "a\n").unwrap(); + std::fs::write(repo_path.join("file3"), "a\n").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["new"]); + test_env.jj_cmd_ok(&repo_path, &["branch", "create", "b"]); + std::fs::write(repo_path.join("file3"), "b\n").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["new"]); + test_env.jj_cmd_ok(&repo_path, &["branch", "create", "c"]); + std::fs::write(repo_path.join("file1"), "c\n").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["edit", "a"]); + test_env.jj_cmd_ok(&repo_path, &["new"]); + test_env.jj_cmd_ok(&repo_path, &["branch", "create", "d"]); + std::fs::write(repo_path.join("file3"), "d\n").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["new"]); + test_env.jj_cmd_ok(&repo_path, &["branch", "create", "e"]); + std::fs::write(repo_path.join("file2"), "e\n").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["new"]); + test_env.jj_cmd_ok(&repo_path, &["branch", "create", "f"]); + std::fs::write(repo_path.join("file2"), "f\n").unwrap(); + // Test the setup + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + @ 0d7353584003 f + ◉ e9515f21068c e + ◉ bdd835cae844 d + │ ◉ caa4d0b23201 c + │ ◉ 55171e33db26 b + ├─╯ + ◉ 3db0a2f5b535 a + ◉ 000000000000 + "###); + + // Errors out if source and destination are the same + let stderr = test_env.jj_cmd_failure(&repo_path, &["squash", "--into", "@"]); + insta::assert_snapshot!(stderr, @r###" + Error: Source and destination cannot be the same + "###); + + // Can squash from sibling, which results in the source being abandoned + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["squash", "--from", "c"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Working copy now at: kmkuslsw 5337fca9 f | (no description set) + Parent commit : znkkpsqq e9515f21 e | (no description set) + Added 0 files, modified 1 files, removed 0 files + "###); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + @ 5337fca918e8 f + ◉ e9515f21068c e + ◉ bdd835cae844 d + │ ◉ 55171e33db26 b c + ├─╯ + ◉ 3db0a2f5b535 a + ◉ 000000000000 + "###); + // The change from the source has been applied + let stdout = test_env.jj_cmd_success(&repo_path, &["print", "file1"]); + insta::assert_snapshot!(stdout, @r###" + c + "###); + // File `file2`, which was not changed in source, is unchanged + let stdout = test_env.jj_cmd_success(&repo_path, &["print", "file2"]); + insta::assert_snapshot!(stdout, @r###" + f + "###); + + // Can squash from ancestor + test_env.jj_cmd_ok(&repo_path, &["undo"]); + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["squash", "--from", "@--"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Working copy now at: kmkuslsw 66ff309f f | (no description set) + Parent commit : znkkpsqq 16f4e7c4 e | (no description set) + "###); + // The change has been removed from the source (the change pointed to by 'd' + // became empty and was abandoned) + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + @ 66ff309f65e8 f + ◉ 16f4e7c4886f e + │ ◉ caa4d0b23201 c + │ ◉ 55171e33db26 b + ├─╯ + ◉ 3db0a2f5b535 a d + ◉ 000000000000 + "###); + // The change from the source has been applied (the file contents were already + // "f", as is typically the case when moving changes from an ancestor) + let stdout = test_env.jj_cmd_success(&repo_path, &["print", "file2"]); + insta::assert_snapshot!(stdout, @r###" + f + "###); + + // Can squash from descendant + test_env.jj_cmd_ok(&repo_path, &["undo"]); + let (stdout, stderr) = + test_env.jj_cmd_ok(&repo_path, &["squash", "--from", "e", "--into", "d"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Rebased 1 descendant commits + Working copy now at: kmkuslsw b4f8051d f | (no description set) + Parent commit : vruxwmqv f74c102f d e | (no description set) + "###); + // The change has been removed from the source (the change pointed to by 'e' + // became empty and was abandoned) + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + @ b4f8051d8466 f + ◉ f74c102ff29a d e + │ ◉ caa4d0b23201 c + │ ◉ 55171e33db26 b + ├─╯ + ◉ 3db0a2f5b535 a + ◉ 000000000000 + "###); + // The change from the source has been applied + let stdout = test_env.jj_cmd_success(&repo_path, &["print", "file2", "-r", "d"]); + insta::assert_snapshot!(stdout, @r###" + e + "###); +} + +#[test] +fn test_squash_from_to_partial() { + let mut test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + + // Create history like this: + // C + // | + // D B + // |/ + // A + test_env.jj_cmd_ok(&repo_path, &["branch", "create", "a"]); + std::fs::write(repo_path.join("file1"), "a\n").unwrap(); + std::fs::write(repo_path.join("file2"), "a\n").unwrap(); + std::fs::write(repo_path.join("file3"), "a\n").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["new"]); + test_env.jj_cmd_ok(&repo_path, &["branch", "create", "b"]); + std::fs::write(repo_path.join("file3"), "b\n").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["new"]); + test_env.jj_cmd_ok(&repo_path, &["branch", "create", "c"]); + std::fs::write(repo_path.join("file1"), "c\n").unwrap(); + std::fs::write(repo_path.join("file2"), "c\n").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["edit", "a"]); + test_env.jj_cmd_ok(&repo_path, &["new"]); + test_env.jj_cmd_ok(&repo_path, &["branch", "create", "d"]); + std::fs::write(repo_path.join("file3"), "d\n").unwrap(); + // Test the setup + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + @ bdd835cae844 d + │ ◉ 5028db694b6b c + │ ◉ 55171e33db26 b + ├─╯ + ◉ 3db0a2f5b535 a + ◉ 000000000000 + "###); + + let edit_script = test_env.set_up_fake_diff_editor(); + + // If we don't make any changes in the diff-editor, the whole change is moved + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["squash", "-i", "--from", "c"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Working copy now at: vruxwmqv 71b69e43 d | (no description set) + Parent commit : qpvuntsm 3db0a2f5 a | (no description set) + Added 0 files, modified 2 files, removed 0 files + "###); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + @ 71b69e433fbc d + │ ◉ 55171e33db26 b c + ├─╯ + ◉ 3db0a2f5b535 a + ◉ 000000000000 + "###); + // The changes from the source has been applied + let stdout = test_env.jj_cmd_success(&repo_path, &["print", "file1"]); + insta::assert_snapshot!(stdout, @r###" + c + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["print", "file2"]); + insta::assert_snapshot!(stdout, @r###" + c + "###); + // File `file3`, which was not changed in source, is unchanged + let stdout = test_env.jj_cmd_success(&repo_path, &["print", "file3"]); + insta::assert_snapshot!(stdout, @r###" + d + "###); + + // Can squash only part of the change in interactive mode + test_env.jj_cmd_ok(&repo_path, &["undo"]); + std::fs::write(&edit_script, "reset file2").unwrap(); + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["squash", "-i", "--from", "c"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Working copy now at: vruxwmqv 63f1a6e9 d | (no description set) + Parent commit : qpvuntsm 3db0a2f5 a | (no description set) + Added 0 files, modified 1 files, removed 0 files + "###); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + @ 63f1a6e96edb d + │ ◉ d027c6e3e6bc c + │ ◉ 55171e33db26 b + ├─╯ + ◉ 3db0a2f5b535 a + ◉ 000000000000 + "###); + // The selected change from the source has been applied + let stdout = test_env.jj_cmd_success(&repo_path, &["print", "file1"]); + insta::assert_snapshot!(stdout, @r###" + c + "###); + // The unselected change from the source has not been applied + let stdout = test_env.jj_cmd_success(&repo_path, &["print", "file2"]); + insta::assert_snapshot!(stdout, @r###" + a + "###); + // File `file3`, which was changed in source's parent, is unchanged + let stdout = test_env.jj_cmd_success(&repo_path, &["print", "file3"]); + insta::assert_snapshot!(stdout, @r###" + d + "###); + + // Can squash only part of the change from a sibling in non-interactive mode + test_env.jj_cmd_ok(&repo_path, &["undo"]); + // Clear the script so we know it won't be used + std::fs::write(&edit_script, "").unwrap(); + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["squash", "--from", "c", "file1"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Working copy now at: vruxwmqv 17c2e663 d | (no description set) + Parent commit : qpvuntsm 3db0a2f5 a | (no description set) + Added 0 files, modified 1 files, removed 0 files + "###); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + @ 17c2e6632cc5 d + │ ◉ 6a3ae047a03e c + │ ◉ 55171e33db26 b + ├─╯ + ◉ 3db0a2f5b535 a + ◉ 000000000000 + "###); + // The selected change from the source has been applied + let stdout = test_env.jj_cmd_success(&repo_path, &["print", "file1"]); + insta::assert_snapshot!(stdout, @r###" + c + "###); + // The unselected change from the source has not been applied + let stdout = test_env.jj_cmd_success(&repo_path, &["print", "file2"]); + insta::assert_snapshot!(stdout, @r###" + a + "###); + // File `file3`, which was changed in source's parent, is unchanged + let stdout = test_env.jj_cmd_success(&repo_path, &["print", "file3"]); + insta::assert_snapshot!(stdout, @r###" + d + "###); + + // Can squash only part of the change from a descendant in non-interactive mode + test_env.jj_cmd_ok(&repo_path, &["undo"]); + // Clear the script so we know it won't be used + std::fs::write(&edit_script, "").unwrap(); + let (stdout, stderr) = test_env.jj_cmd_ok( + &repo_path, + &["squash", "--from", "c", "--into", "b", "file1"], + ); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Rebased 1 descendant commits + "###); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + ◉ 21253406d416 c + ◉ e1cf08aae711 b + │ @ bdd835cae844 d + ├─╯ + ◉ 3db0a2f5b535 a + ◉ 000000000000 + "###); + // The selected change from the source has been applied + let stdout = test_env.jj_cmd_success(&repo_path, &["print", "file1", "-r", "b"]); + insta::assert_snapshot!(stdout, @r###" + c + "###); + // The unselected change from the source has not been applied + let stdout = test_env.jj_cmd_success(&repo_path, &["print", "file2", "-r", "b"]); + insta::assert_snapshot!(stdout, @r###" + a + "###); + + // If we specify only a non-existent file, then the move still succeeds and + // creates unchanged commits. + test_env.jj_cmd_ok(&repo_path, &["undo"]); + let (stdout, stderr) = + test_env.jj_cmd_ok(&repo_path, &["squash", "--from", "c", "nonexistent"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Working copy now at: vruxwmqv b670567d d | (no description set) + Parent commit : qpvuntsm 3db0a2f5 a | (no description set) + "###); +} + fn get_log_output(test_env: &TestEnvironment, repo_path: &Path) -> String { let template = r#"commit_id.short() ++ " " ++ branches"#; test_env.jj_cmd_success(repo_path, &["log", "-T", template])