diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c5bd45d6..2c61bf4c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `jj restore` without `--from` works correctly even if `@` is a merge commit. +* `jj rebase` now accepts multiple `-s` arguments. Revsets with multiple commits + are allowed with `--allow-large-revsets`. + ### Fixed bugs * Modify/delete conflicts now include context lines diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 1c6d8306c..bd856fd0e 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -733,9 +733,15 @@ struct RebaseArgs { /// Rebase the whole branch (relative to destination's ancestors) #[arg(long, short)] branch: Option, - /// Rebase this revision and its descendants + + /// Rebase specified revision(s) together their tree of descendants (can be + /// repeated) + /// + /// Each specified revision will become a direct child of the destination + /// revision(s), even if some of the source revisions are descendants + /// of others. #[arg(long, short)] - source: Option, + source: Vec, /// Rebase only this revision, rebasing descendants onto this revision's /// parent(s) #[arg(long, short)] @@ -2762,13 +2768,18 @@ fn cmd_rebase(ui: &mut Ui, command: &CommandHelper, args: &RebaseArgs) -> Result .collect_vec(); if let Some(rev_str) = &args.revision { rebase_revision(ui, command, &mut workspace_command, &new_parents, rev_str)?; - } else if let Some(source_str) = &args.source { + } else if !args.source.is_empty() { + let source_commits = resolve_mutliple_nonempty_revsets_flag_guarded( + &workspace_command, + &args.source, + args.allow_large_revsets, + )?; rebase_descendants( ui, command, &mut workspace_command, &new_parents, - source_str, + &source_commits, )?; } else { let branch_str = args.branch.as_deref().unwrap_or("@"); @@ -2825,17 +2836,27 @@ fn rebase_descendants( command: &CommandHelper, workspace_command: &mut WorkspaceCommandHelper, new_parents: &[Commit], - source_str: &str, + old_commits: &IndexSet, ) -> Result<(), CommandError> { - let old_commit = workspace_command.resolve_single_rev(source_str)?; - workspace_command.check_rewritable(&old_commit)?; - check_rebase_destinations(workspace_command.repo(), new_parents, &old_commit)?; - let mut tx = workspace_command.start_transaction(&format!( - "rebase commit {} and descendants", - old_commit.id().hex() - )); - rebase_commit(command.settings(), tx.mut_repo(), &old_commit, new_parents)?; - let num_rebased = tx.mut_repo().rebase_descendants(command.settings())? + 1; + for old_commit in old_commits.iter() { + workspace_command.check_rewritable(old_commit)?; + check_rebase_destinations(workspace_command.repo(), new_parents, old_commit)?; + } + let tx_message = if old_commits.len() == 1 { + format!( + "rebase commit {} and descendants", + old_commits.first().unwrap().id().hex() + ) + } else { + format!("rebase {} commits and their descendants", old_commits.len()) + }; + let mut tx = workspace_command.start_transaction(&tx_message); + // `rebase_descendants` takes care of sorting in reverse topological order, so + // no need to do it here. + for old_commit in old_commits { + rebase_commit(command.settings(), tx.mut_repo(), old_commit, new_parents)?; + } + let num_rebased = old_commits.len() + tx.mut_repo().rebase_descendants(command.settings())?; writeln!(ui, "Rebased {num_rebased} commits")?; tx.finish(ui)?; Ok(()) diff --git a/tests/test_rebase_command.rs b/tests/test_rebase_command.rs index 5adbd01ce..0f66ffeba 100644 --- a/tests/test_rebase_command.rs +++ b/tests/test_rebase_command.rs @@ -426,6 +426,82 @@ fn test_rebase_with_descendants() { o a o "###); + + // Rebase several subtrees at once. + test_env.jj_cmd_success(&repo_path, &["undo"]); + let stdout = test_env.jj_cmd_success(&repo_path, &["rebase", "-s=c", "-s=d", "-d=a"]); + insta::assert_snapshot!(stdout, @r###" + Rebased 2 commits + Working copy now at: 92c2bc9a8623 d + Added 0 files, modified 0 files, removed 2 files + "###); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + @ d + │ o c + ├─╯ + │ o b + o │ a + ├─╯ + o + "###); + + test_env.jj_cmd_success(&repo_path, &["undo"]); + // Reminder of the setup + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + @ d + o c + ├─╮ + o │ b + │ o a + ├─╯ + o + "###); + + // `d` was a descendant of `b`, and both are moved to be direct descendants of + // `a`. `c` remains a descendant of `b`. + let stdout = test_env.jj_cmd_success(&repo_path, &["rebase", "-s=b", "-s=d", "-d=a"]); + insta::assert_snapshot!(stdout, @r###" + Rebased 3 commits + Working copy now at: f1e71cb78a06 d + Added 0 files, modified 0 files, removed 2 files + "###); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + o c + │ @ d + o │ b + ├─╯ + o a + o + "###); + + // Same test as above, but with duplicate commits and multiple commits per + // argument + test_env.jj_cmd_success(&repo_path, &["undo"]); + let stderr = test_env.jj_cmd_failure(&repo_path, &["rebase", "-s=b|d", "-s=d", "-d=a"]); + insta::assert_snapshot!(stderr, @r###" + Error: Revset "b|d" resolved to more than one revision + Hint: The revset "b|d" resolved to these revisions: + df54a9fd85ae d + d370aee184ba b + If this was intentional, specify the `--allow-large-revsets` argument + "###); + let stdout = test_env.jj_cmd_success( + &repo_path, + &["rebase", "-s=b|d", "-s=d", "-d=a", "--allow-large-revsets"], + ); + insta::assert_snapshot!(stdout, @r###" + Rebased 3 commits + Working copy now at: d17539f7ea7c d + Added 0 files, modified 0 files, removed 2 files + "###); + insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###" + o c + o b + │ @ d + ├─╯ + o a + o + "###); } fn get_log_output(test_env: &TestEnvironment, repo_path: &Path) -> String {