ok/jj
1
0
Fork 0
forked from mirrors/jj

squash: accept multiple --from revisions

Now you can do e.g. `jj squash --from 'foo+::' --into foo` to squash a
whole series into one commit. It doesn't need to be linear; you can
squash a bunch of siblings into another siblings, for example.
This commit is contained in:
Martin von Zweigbergk 2024-03-10 21:46:21 -07:00 committed by Martin von Zweigbergk
parent f3e35f1da4
commit 800f0f0347
4 changed files with 339 additions and 53 deletions

View file

@ -24,7 +24,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
elided.
* `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.
It can thereby be for all use cases where `jj move` can be used. The `--from`
argument accepts a revset that resolves to move than one revision.
### Fixed bugs

View file

@ -89,8 +89,8 @@ pub(crate) fn cmd_move(
ui,
&mut tx,
command.settings(),
source,
destination,
&[source],
&destination,
matcher.as_ref(),
&diff_selector,
None,

View file

@ -80,20 +80,26 @@ pub(crate) fn cmd_squash(
) -> Result<(), CommandError> {
let mut workspace_command = command.workspace_helper(ui)?;
let source;
let mut sources;
let destination;
if args.from.is_some() || args.into.is_some() {
source = workspace_command.resolve_single_rev(args.from.as_deref().unwrap_or("@"))?;
sources = workspace_command.resolve_revset(args.from.as_deref().unwrap_or("@"))?;
destination = workspace_command.resolve_single_rev(args.into.as_deref().unwrap_or("@"))?;
if source.id() == destination.id() {
if sources.iter().any(|source| source.id() == destination.id()) {
return Err(user_error("Source and destination cannot be the same"));
}
// Reverse the set so we apply the oldest commits first. It shouldn't affect the
// result, but it avoids creating transient conflicts and is therefore probably
// a little faster.
sources.reverse();
} else {
source = workspace_command.resolve_single_rev(args.revision.as_deref().unwrap_or("@"))?;
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"));
}
sources = vec![source];
destination = parents.pop().unwrap();
}
@ -101,15 +107,15 @@ pub(crate) fn cmd_squash(
let diff_selector =
workspace_command.diff_selector(ui, args.tool.as_deref(), args.interactive)?;
let mut tx = workspace_command.start_transaction();
let tx_description = format!("squash commit {}", source.id().hex());
let tx_description = format!("squash commits into {}", destination.id().hex());
let description = (!args.message_paragraphs.is_empty())
.then(|| join_message_paragraphs(&args.message_paragraphs));
move_diff(
ui,
&mut tx,
command.settings(),
source,
destination,
&sources,
&destination,
matcher.as_ref(),
&diff_selector,
description,
@ -125,8 +131,8 @@ pub fn move_diff(
ui: &mut Ui,
tx: &mut WorkspaceCommandTransaction,
settings: &UserSettings,
source: Commit,
mut destination: Commit,
sources: &[Commit],
destination: &Commit,
matcher: &dyn Matcher,
diff_selector: &DiffSelector,
description: Option<String>,
@ -134,11 +140,15 @@ pub fn move_diff(
path_arg: &[String],
) -> Result<(), CommandError> {
tx.base_workspace_helper()
.check_rewritable([&source, &destination])?;
let parent_tree = merge_commit_trees(tx.repo(), &source.parents())?;
let source_tree = source.tree()?;
let instructions = format!(
"\
.check_rewritable(sources.iter().chain(std::iter::once(destination)))?;
// Tree diffs to apply to the destination
let mut tree_diffs = vec![];
let mut abandoned_commits = vec![];
for source in sources {
let parent_tree = merge_commit_trees(tx.repo(), &source.parents())?;
let source_tree = source.tree()?;
let instructions = format!(
"\
You are moving changes from: {}
into commit: {}
@ -150,12 +160,32 @@ Adjust the right side until the diff shows the changes you want to move
to the destination. If you don't make any changes, then all the changes
from the source will be moved into the destination.
",
tx.format_commit_summary(&source),
tx.format_commit_summary(&destination)
);
let new_parent_tree_id =
diff_selector.select(&parent_tree, &source_tree, matcher, Some(&instructions))?;
if new_parent_tree_id == parent_tree.id() {
tx.format_commit_summary(source),
tx.format_commit_summary(destination)
);
let new_parent_tree_id =
diff_selector.select(&parent_tree, &source_tree, matcher, Some(&instructions))?;
let new_parent_tree = tx.repo().store().get_root_tree(&new_parent_tree_id)?;
// TODO: Do we want to optimize the case of moving to the parent commit (`jj
// squash -r`)? The source tree will be unchanged in that case.
// Apply the reverse of the selected changes onto the source
let new_source_tree = source_tree.merge(&new_parent_tree, &parent_tree)?;
let abandon_source = new_source_tree.id() == parent_tree.id();
if abandon_source {
abandoned_commits.push(source);
tx.mut_repo().record_abandoned_commit(source.id().clone());
} else {
tx.mut_repo()
.rewrite_commit(settings, source)
.set_tree_id(new_source_tree.id().clone())
.write()?;
}
if new_parent_tree_id != parent_tree.id() {
tree_diffs.push((parent_tree, new_parent_tree));
}
}
if tree_diffs.is_empty() {
if diff_selector.is_interactive() {
return Err(user_error("No changes selected"));
}
@ -176,47 +206,34 @@ from the source will be moved into the destination.
}
}
}
let new_parent_tree = tx.repo().store().get_root_tree(&new_parent_tree_id)?;
// TODO: Do we want to optimize the case of moving to the parent commit (`jj
// squash -r`)? The source tree will be unchanged in that case.
// Apply the reverse of the selected changes onto the source
let new_source_tree = source_tree.merge(&new_parent_tree, &parent_tree)?;
let abandon_source = new_source_tree.id() == parent_tree.id();
if abandon_source {
tx.mut_repo().record_abandoned_commit(source.id().clone());
} else {
tx.mut_repo()
.rewrite_commit(settings, &source)
.set_tree_id(new_source_tree.id().clone())
.write()?;
}
if tx.repo().index().is_ancestor(source.id(), destination.id()) {
let mut rewritten_destination = destination.clone();
if sources
.iter()
.any(|source| tx.repo().index().is_ancestor(source.id(), destination.id()))
{
// If we're moving changes to a descendant, first rebase descendants onto the
// rewritten source. Otherwise it will likely already have the content
// rewritten sources. Otherwise it will likely already have the content
// changes we're moving, so applying them will have no effect and the
// changes will disappear.
let rebase_map = tx.mut_repo().rebase_descendants_return_map(settings)?;
let rebased_destination_id = rebase_map.get(destination.id()).unwrap().clone();
destination = tx.mut_repo().store().get_commit(&rebased_destination_id)?;
rewritten_destination = tx.mut_repo().store().get_commit(&rebased_destination_id)?;
}
// Apply the selected changes onto the destination
let destination_tree = destination.tree()?;
let new_destination_tree = destination_tree.merge(&parent_tree, &new_parent_tree)?;
let mut destination_tree = rewritten_destination.tree()?;
for (tree1, tree2) in tree_diffs {
destination_tree = destination_tree.merge(&tree1, &tree2)?;
}
let description = match description {
Some(description) => description,
None => {
if abandon_source {
combine_messages(tx.base_repo(), &[&source], &destination, settings)?
} else {
destination.description().to_owned()
}
}
None => combine_messages(tx.base_repo(), &abandoned_commits, destination, settings)?,
};
let mut predecessors = vec![destination.id().clone()];
predecessors.extend(sources.iter().map(|source| source.id().clone()));
tx.mut_repo()
.rewrite_commit(settings, &destination)
.set_tree_id(new_destination_tree.id().clone())
.set_predecessors(vec![destination.id().clone(), source.id().clone()])
.rewrite_commit(settings, &rewritten_destination)
.set_tree_id(destination_tree.id().clone())
.set_predecessors(predecessors)
.set_description(description)
.write()?;
Ok(())

View file

@ -582,6 +582,274 @@ fn test_squash_from_to_partial() {
"###);
}
#[test]
fn test_squash_from_multiple() {
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
// /|\
// B C D
// \|/
// A
let file = repo_path.join("file");
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "a"]);
std::fs::write(&file, "a\n").unwrap();
test_env.jj_cmd_ok(&repo_path, &["new"]);
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "b"]);
std::fs::write(&file, "b\n").unwrap();
test_env.jj_cmd_ok(&repo_path, &["new", "@-"]);
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "c"]);
std::fs::write(&file, "c\n").unwrap();
test_env.jj_cmd_ok(&repo_path, &["new", "@-"]);
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "d"]);
std::fs::write(&file, "d\n").unwrap();
test_env.jj_cmd_ok(&repo_path, &["new", "all:visible_heads()"]);
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "e"]);
std::fs::write(&file, "e\n").unwrap();
test_env.jj_cmd_ok(&repo_path, &["new"]);
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "f"]);
std::fs::write(&file, "f\n").unwrap();
// Test the setup
insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###"
@ 7c982f87d244 f
90fb23310e1d e
512dff087306 b
5ee503da2262 c
cb214cffd91a d
37941ee54ace a
000000000000
"###);
// Squash a few commits sideways
let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["squash", "--from=b|c", "--into=d"]);
insta::assert_snapshot!(stdout, @"");
insta::assert_snapshot!(stderr, @r###"
Rebased 2 descendant commits
New conflicts appeared in these commits:
yqosqzyt d5401742 d | (conflict) (no description set)
To resolve the conflicts, start by updating to it:
jj new yqosqzytrlsw
Then use `jj resolve`, or edit the conflict markers in the file directly.
Once the conflicts are resolved, you may want inspect the result with `jj diff`.
Then run `jj squash` to move the resolution into the conflicted commit.
Working copy now at: kpqxywon cc9f4cad f | (no description set)
Parent commit : yostqsxw 9f25b62d e | (no description set)
"###);
insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###"
@ cc9f4cad1a29 f
9f25b62ddffc e
d54017421f3f d
37941ee54ace a b c
000000000000
"###);
// The changes from the sources have been applied
let stdout = test_env.jj_cmd_success(&repo_path, &["print", "-r=d", "file"]);
insta::assert_snapshot!(stdout, @r###"
<<<<<<<
%%%%%%%
-a
+d
%%%%%%%
-a
+b
+++++++
c
>>>>>>>
"###);
// Squash a few commits up an down
test_env.jj_cmd_ok(&repo_path, &["undo"]);
let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["squash", "--from=b|c|f", "--into=e"]);
insta::assert_snapshot!(stdout, @"");
insta::assert_snapshot!(stderr, @r###"
Rebased 1 descendant commits
Working copy now at: xznxytkn 59801ce3 (empty) (no description set)
Parent commit : yostqsxw b7bc1dda e f | (no description set)
"###);
insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###"
@ 59801ce3ff81
b7bc1dda247e e f
cb214cffd91a d
37941ee54ace a b c
000000000000
"###);
// The changes from the sources have been applied to the destination
let stdout = test_env.jj_cmd_success(&repo_path, &["print", "-r=e", "file"]);
insta::assert_snapshot!(stdout, @r###"
f
"###);
}
#[test]
fn test_squash_from_multiple_partial() {
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
// /|\
// B C D
// \|/
// A
let file1 = repo_path.join("file1");
let file2 = repo_path.join("file2");
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "a"]);
std::fs::write(&file1, "a\n").unwrap();
std::fs::write(&file2, "a\n").unwrap();
test_env.jj_cmd_ok(&repo_path, &["new"]);
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "b"]);
std::fs::write(&file1, "b\n").unwrap();
std::fs::write(&file2, "b\n").unwrap();
test_env.jj_cmd_ok(&repo_path, &["new", "@-"]);
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "c"]);
std::fs::write(&file1, "c\n").unwrap();
std::fs::write(&file2, "c\n").unwrap();
test_env.jj_cmd_ok(&repo_path, &["new", "@-"]);
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "d"]);
std::fs::write(&file1, "d\n").unwrap();
std::fs::write(&file2, "d\n").unwrap();
test_env.jj_cmd_ok(&repo_path, &["new", "all:visible_heads()"]);
test_env.jj_cmd_ok(&repo_path, &["branch", "create", "e"]);
std::fs::write(&file1, "e\n").unwrap();
std::fs::write(&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(&file1, "f\n").unwrap();
std::fs::write(&file2, "f\n").unwrap();
// Test the setup
insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###"
@ 5adc4b1fb0f9 f
8ba764396a28 e
2a2d19a3283f b
864a16169cef c
5def0e76dfaf d
47a1e795d146 a
000000000000
"###);
// Partially squash a few commits sideways
let (stdout, stderr) =
test_env.jj_cmd_ok(&repo_path, &["squash", "--from=b|c", "--into=d", "file1"]);
insta::assert_snapshot!(stdout, @"");
insta::assert_snapshot!(stderr, @r###"
Rebased 2 descendant commits
New conflicts appeared in these commits:
yqosqzyt 13468b54 d | (conflict) (no description set)
To resolve the conflicts, start by updating to it:
jj new yqosqzytrlsw
Then use `jj resolve`, or edit the conflict markers in the file directly.
Once the conflicts are resolved, you may want inspect the result with `jj diff`.
Then run `jj squash` to move the resolution into the conflicted commit.
Working copy now at: kpqxywon 8aaa7910 f | (no description set)
Parent commit : yostqsxw 5aad25ea e | (no description set)
"###);
insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###"
@ 8aaa79109163 f
5aad25eae5aa e
ba60ddff2d41 b
8ef5a315bf7d c
13468b546ba3 d
47a1e795d146 a
000000000000
"###);
// The selected changes have been removed from the sources
let stdout = test_env.jj_cmd_success(&repo_path, &["print", "-r=b", "file1"]);
insta::assert_snapshot!(stdout, @r###"
a
"###);
let stdout = test_env.jj_cmd_success(&repo_path, &["print", "-r=c", "file1"]);
insta::assert_snapshot!(stdout, @r###"
a
"###);
// The selected changes from the sources have been applied
let stdout = test_env.jj_cmd_success(&repo_path, &["print", "-r=d", "file1"]);
insta::assert_snapshot!(stdout, @r###"
<<<<<<<
%%%%%%%
-a
+d
%%%%%%%
-a
+b
+++++++
c
>>>>>>>
"###);
// The unselected change from the sources have not been applied to the
// destination
let stdout = test_env.jj_cmd_success(&repo_path, &["print", "-r=d", "file2"]);
insta::assert_snapshot!(stdout, @r###"
d
"###);
// Partially squash a few commits up an down
test_env.jj_cmd_ok(&repo_path, &["undo"]);
let (stdout, stderr) =
test_env.jj_cmd_ok(&repo_path, &["squash", "--from=b|c|f", "--into=e", "file1"]);
insta::assert_snapshot!(stdout, @"");
insta::assert_snapshot!(stderr, @r###"
Rebased 1 descendant commits
Working copy now at: kpqxywon 610a144d f | (no description set)
Parent commit : yostqsxw ac27a136 e | (no description set)
"###);
insta::assert_snapshot!(get_log_output(&test_env, &repo_path), @r###"
@ 610a144de39b f
ac27a1361b09 e
0c8eab864a32 b
ad1776ad0b1b c
5def0e76dfaf d
47a1e795d146 a
000000000000
"###);
// The selected changes have been removed from the sources
let stdout = test_env.jj_cmd_success(&repo_path, &["print", "-r=b", "file1"]);
insta::assert_snapshot!(stdout, @r###"
a
"###);
let stdout = test_env.jj_cmd_success(&repo_path, &["print", "-r=c", "file1"]);
insta::assert_snapshot!(stdout, @r###"
a
"###);
let stdout = test_env.jj_cmd_success(&repo_path, &["print", "-r=f", "file1"]);
insta::assert_snapshot!(stdout, @r###"
f
"###);
// The selected changes from the sources have been applied to the destination
let stdout = test_env.jj_cmd_success(&repo_path, &["print", "-r=e", "file1"]);
insta::assert_snapshot!(stdout, @r###"
f
"###);
// The unselected changes from the sources have not been applied
let stdout = test_env.jj_cmd_success(&repo_path, &["print", "-r=d", "file2"]);
insta::assert_snapshot!(stdout, @r###"
d
"###);
}
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])