cli: create mutable working-copy commit only when needed

This will probably work better when the immutable set is defined by the current
working copy.

Closes #7751
Closes #9338
This commit is contained in:
Yuya Nishihara
2026-06-02 16:31:20 +09:00
parent 8f71ca8e99
commit 36f4f09be4
8 changed files with 137 additions and 144 deletions

View File

@@ -18,6 +18,12 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
### Fixed bugs
* `jj` now creates a new working-copy revision during snapshotting if the
working copy was immutable. Previously, the new revision was created
immediately after the working copy became immutable.
[#7751](https://github.com/jj-vcs/jj/issues/7751)
[#9338](https://github.com/jj-vcs/jj/issues/9338)
## [0.42.0] - 2026-06-04
### Release highlights

View File

@@ -2030,15 +2030,43 @@ to the current parents may contain changes from multiple commits.
self.env.command.string_args(),
);
tx.set_is_snapshot(true);
let mut_repo = tx.repo_mut();
let commit = mut_repo
.rewrite_commit(&wc_commit)
.set_tree(new_tree.clone())
.write()
.await
let immutable_expr = self
.env
.resolve_immutable_expression(tx.repo())
.map_err(snapshot_command_error)?;
let wc_immutable = immutable_expr
.intersection(&RevsetExpression::commit(wc_commit.id().clone()))
.evaluate(tx.repo())
.map_err(snapshot_command_error)?
.stream()
.try_next()
.await
.map_err(snapshot_command_error)?
.is_some();
let mut_repo = tx.repo_mut();
let new_wc_commit;
if wc_immutable {
new_wc_commit = mut_repo
.new_commit(vec![wc_commit.id().clone()], new_tree.clone())
.write()
.await
.map_err(snapshot_command_error)?;
writeln!(
ui.warning_default(),
"The working-copy commit is immutable; a new commit has been created on top \
of it.",
)
.map_err(snapshot_command_error)?;
} else {
new_wc_commit = mut_repo
.rewrite_commit(&wc_commit)
.set_tree(new_tree.clone())
.write()
.await
.map_err(snapshot_command_error)?;
}
mut_repo
.set_wc_commit(workspace_name, commit.id().clone())
.set_wc_commit(workspace_name, new_wc_commit.id().clone())
.map_err(snapshot_command_error)?;
// Rebase descendants
@@ -2057,7 +2085,7 @@ to the current parents may contain changes from multiple commits.
#[cfg(feature = "git")]
if self.working_copy_shared_with_git && self.env.command.should_commit_transaction() {
let old_tree = wc_commit.tree();
let new_tree = commit.tree();
let new_tree = new_wc_commit.tree();
export_working_copy_changes_to_git(ui, mut_repo, &old_tree, &new_tree)
.await
.map_err(snapshot_command_error)?;
@@ -2189,49 +2217,13 @@ to the current parents may contain changes from multiple commits.
writeln!(ui.status(), "Rebased {num_rebased} descendant commits")?;
}
// This can fail if trunk() bookmark gets deleted or conflicted. If the
// unresolvable trunk() issue gets addressed differently, it should be
// okay to propagate the error.
let immutable_expr = match self.env.resolve_immutable_expression(tx.repo()) {
Ok(expr) => expr,
Err(CommandError { error, .. }) => {
writeln!(
ui.warning_default(),
"Failed to check mutability of the new working-copy revision."
)?;
print_error_sources(ui, Some(&error))?;
RevsetExpression::root()
}
};
for (name, wc_commit_id) in &tx.repo().view().wc_commit_ids().clone() {
let is_immutable = immutable_expr
.intersection(&RevsetExpression::commit(wc_commit_id.clone()))
.evaluate(tx.repo())?
.stream()
.try_next()
.await?
.is_some();
if is_immutable {
let wc_commit = tx.repo().store().get_commit_async(wc_commit_id).await?;
tx.repo_mut().check_out(name.clone(), &wc_commit).await?;
writeln!(
ui.warning_default(),
"The working-copy commit in workspace '{name}' became immutable, so a new \
commit has been created on top of it.",
name = name.as_symbol()
)?;
}
}
if let Err(err) =
revset_util::try_resolve_trunk_alias(tx.repo(), &self.env.revset_parse_context())
{
// The warning would be printed above if working copies exist.
if tx.repo().view().wc_commit_ids().is_empty() {
writeln!(
ui.warning_default(),
"Failed to resolve `revset-aliases.trunk()`: {err}"
)?;
}
writeln!(
ui.warning_default(),
"Failed to resolve `revset-aliases.trunk()`: {err}"
)?;
writeln!(
ui.hint_default(),
"Use `jj config edit --repo` to adjust the `trunk()` alias."

View File

@@ -1082,9 +1082,6 @@ fn test_bookmark_rename_colocated() {
------- stderr -------
Warning: Tracking of remote bookmark bpushed@origin was dropped.
Hint: Use `jj bookmark track` to re-track if needed.
Warning: The working-copy commit in workspace 'default' became immutable, so a new commit has been created on top of it.
Working copy (@) now at: znkkpsqq cf8db4ba (empty) (no description set)
Parent commit (@-) : royxmykx b6e46c10 bpushed@origin | (empty) commit-1
[EOF]
");
let output = work_dir.run_jj(["bookmark", "list", "--all", "bpushed"]);

View File

@@ -976,10 +976,7 @@ fn test_git_clone_trunk_deleted() {
------- stderr -------
Forgot 1 local bookmarks.
Forgot 1 remote bookmarks.
Warning: Failed to check mutability of the new working-copy revision.
Caused by:
1: Invalid `revset-aliases.immutable_heads()`
2: Revision `main@origin` doesn't exist
Warning: Failed to resolve `revset-aliases.trunk()`: Revision `main@origin` doesn't exist
Hint: Use `jj config edit --repo` to adjust the `trunk()` alias.
[EOF]
");
@@ -1130,26 +1127,14 @@ fn test_git_clone_invalid_immutable_heads() {
// Suppress lengthy warnings in commit summary template
test_env.add_config("revsets.short-prefixes = ''");
// The error shouldn't be counted as an immutable working-copy commit. It
// should be reported.
// Even if there were an error about the invalid immutable_heads(), the
// error shouldn't be counted as an immutable working-copy commit.
let output = root_dir.run_jj(["git", "clone", "source", "clone"]);
insta::assert_snapshot!(output, @r#"
------- stderr -------
Warning: Failed to check mutability of the new working-copy revision.
Caused by:
1: Invalid `revset-aliases.immutable_heads()`
2: Revision `unknown` doesn't exist
Fetching into new repo in "$TEST_ENV/clone"
bookmark: main@origin [new] tracked
Warning: Failed to check mutability of the new working-copy revision.
Caused by:
1: Invalid `revset-aliases.immutable_heads()`
2: Revision `unknown` doesn't exist
Setting the revset alias `trunk()` to `main@origin`
Warning: Failed to check mutability of the new working-copy revision.
Caused by:
1: Invalid `revset-aliases.immutable_heads()`
2: Revision `unknown` doesn't exist
Working copy (@) now at: sqpuoqvx 1ca44815 (empty) (no description set)
Parent commit (@-) : qomsplrm ebeb70d8 main | message
Added 1 files, modified 0 files, removed 0 files

View File

@@ -205,9 +205,6 @@ fn test_git_private_commits_can_be_overridden() {
------- stderr -------
Changes to push to origin:
bookmark: main [move forward from 95cc152cd086 to 7f665ca27d4e]
Warning: The working-copy commit in workspace 'default' became immutable, so a new commit has been created on top of it.
Working copy (@) now at: znkkpsqq 8227d51b (empty) (no description set)
Parent commit (@-) : yqosqzyt 7f665ca2 main | (empty) private 1
[EOF]
");
}
@@ -230,9 +227,6 @@ fn test_git_private_commits_are_not_checked_if_immutable() {
------- stderr -------
Changes to push to origin:
bookmark: main [move forward from 95cc152cd086 to 7f665ca27d4e]
Warning: The working-copy commit in workspace 'default' became immutable, so a new commit has been created on top of it.
Working copy (@) now at: yostqsxw 17947f20 (empty) (no description set)
Parent commit (@-) : yqosqzyt 7f665ca2 main | (empty) private 1
[EOF]
");
}
@@ -322,9 +316,6 @@ fn test_git_private_commits_already_on_the_remote_do_not_block_push() {
Changes to push to origin:
bookmark: bookmark1 [add to 95cc152cd086]
bookmark: main [move forward from 95cc152cd086 to 03bc2bf271e0]
Warning: The working-copy commit in workspace 'default' became immutable, so a new commit has been created on top of it.
Working copy (@) now at: kpqxywon 5308110d (empty) (no description set)
Parent commit (@-) : yostqsxw 03bc2bf2 main | (empty) public 3
[EOF]
");

View File

@@ -567,14 +567,11 @@ fn test_git_remote_with_preset_config() {
"#);
// Preset repo-level config should be updated automatically
// TODO: suppress warning about unresolvable immutable_heads()
// TODO: suppress warning about unresolvable trunk()
let output = local_dir.run_jj(["git", "remote", "rename", "origin", "foo"]);
insta::assert_snapshot!(output, @"
------- stderr -------
Warning: Failed to check mutability of the new working-copy revision.
Caused by:
1: Invalid `revset-aliases.immutable_heads()`
2: Revision `main@origin` doesn't exist
Warning: Failed to resolve `revset-aliases.trunk()`: Revision `main@origin` doesn't exist
Hint: Use `jj config edit --repo` to adjust the `trunk()` alias.
Updating the revset alias `trunk()` to `main@foo`.
[EOF]
@@ -594,14 +591,11 @@ fn test_git_remote_with_preset_config() {
"#);
// Preset repo-level config should be removed automatically
// TODO: suppress warning about unresolvable immutable_heads()
// TODO: suppress warning about unresolvable trunk()
let output = local_dir.run_jj(["git", "remote", "remove", "foo"]);
insta::assert_snapshot!(output, @"
------- stderr -------
Warning: Failed to check mutability of the new working-copy revision.
Caused by:
1: Invalid `revset-aliases.immutable_heads()`
2: Revision `main@foo` doesn't exist
Warning: Failed to resolve `revset-aliases.trunk()`: Revision `main@foo` doesn't exist
Hint: Use `jj config edit --repo` to adjust the `trunk()` alias.
Resetting the revset alias `trunk()` to default value.
[EOF]

View File

@@ -79,22 +79,18 @@ fn test_rewrite_immutable_generic() {
[exit status: 1]
");
// Unresolvable immutable_heads() is warned. This can be an error, but we
// need to somehow deal with unresolvable `trunk() = <name>@<remote>`.
// Unresolvable immutable_heads()
test_env.add_config(r#"revset-aliases."immutable_heads()" = "bookmark_that_does_not_exist""#);
// Suppress warning in the commit summary template
test_env.add_config("template-aliases.'format_short_id(id)' = 'id.short(8)'");
let output = work_dir.run_jj(["new", "main"]);
let output = work_dir.run_jj(["edit", "main"]);
insta::assert_snapshot!(output, @"
------- stderr -------
Warning: Failed to check mutability of the new working-copy revision.
Caused by:
1: Invalid `revset-aliases.immutable_heads()`
2: Revision `bookmark_that_does_not_exist` doesn't exist
Working copy (@) now at: znkkpsqq 4bd62ce9 (empty) (no description set)
Parent commit (@-) : kkmpptxz 9d190342 main | b
Added 0 files, modified 1 files, removed 0 files
Config error: Invalid `revset-aliases.immutable_heads()`
Caused by: Revision `bookmark_that_does_not_exist` doesn't exist
For help, see https://docs.jj-vcs.dev/latest/config/ or use `jj help -k config`.
[EOF]
[exit status: 1]
");
// Can use --ignore-immutable to override
@@ -104,6 +100,7 @@ fn test_rewrite_immutable_generic() {
------- stderr -------
Working copy (@) now at: kkmpptxz 9d190342 main | b
Parent commit (@-) : qpvuntsm c8c8515a a
Added 0 files, modified 1 files, removed 0 files
[EOF]
");
// ... but not the root commit
@@ -152,9 +149,21 @@ fn test_new_wc_commit_when_wc_immutable() {
insta::assert_snapshot!(output, @"
------- stderr -------
Moved 1 bookmarks to kkmpptxz e1cb4cf3 main | (empty) a
Warning: The working-copy commit in workspace 'default' became immutable, so a new commit has been created on top of it.
Working copy (@) now at: zsuskuln 19a353fe (empty) (no description set)
Parent commit (@-) : kkmpptxz e1cb4cf3 main | (empty) a
[EOF]
");
work_dir.write_file("file", "a");
let output = work_dir.run_jj(["log", "-r.."]);
insta::assert_snapshot!(output, @"
@ mzvwutvl test.user@example.com 2001-02-03 08:05:11 49ad4c46
│ (no description set)
◆ kkmpptxz test.user@example.com 2001-02-03 08:05:09 main e1cb4cf3
│ (empty) a
◆ qpvuntsm test.user@example.com 2001-02-03 08:05:07 e8849ae1
│ (empty) (no description set)
~
[EOF]
------- stderr -------
Warning: The working-copy commit is immutable; a new commit has been created on top of it.
[EOF]
");
}
@@ -171,9 +180,23 @@ fn test_immutable_heads_set_to_working_copy() {
let output = work_dir.run_jj(["new", "-m=a"]);
insta::assert_snapshot!(output, @"
------- stderr -------
Warning: The working-copy commit in workspace 'default' became immutable, so a new commit has been created on top of it.
Working copy (@) now at: pmmvwywv 08e27304 (empty) (no description set)
Parent commit (@-) : kkmpptxz e1cb4cf3 (empty) a
Working copy (@) now at: kkmpptxz e1cb4cf3 (empty) a
Parent commit (@-) : qpvuntsm e8849ae1 main | (empty) (no description set)
[EOF]
");
work_dir.write_file("file", "a");
let output = work_dir.run_jj(["log", "-r.."]);
insta::assert_snapshot!(output, @"
@ zsuskuln test.user@example.com 2001-02-03 08:05:10 aa4d78a2
│ (no description set)
◆ kkmpptxz test.user@example.com 2001-02-03 08:05:09 e1cb4cf3
│ (empty) a
◆ qpvuntsm test.user@example.com 2001-02-03 08:05:07 main e8849ae1
│ (empty) (no description set)
~
[EOF]
------- stderr -------
Warning: The working-copy commit is immutable; a new commit has been created on top of it.
[EOF]
");
}
@@ -193,28 +216,44 @@ fn test_new_wc_commit_when_wc_immutable_multi_workspace() {
.success();
let workspace1_dir = test_env.work_dir("workspace1");
workspace1_dir.run_jj(["edit", "default@"]).success();
let output = work_dir.run_jj(["bookmark", "set", "main", "-r@"]);
insta::assert_snapshot!(output, @"
------- stderr -------
Moved 1 bookmarks to kkmpptxz e1cb4cf3 main | (empty) a
Warning: The working-copy commit in workspace 'default' became immutable, so a new commit has been created on top of it.
Warning: The working-copy commit in workspace 'workspace1' became immutable, so a new commit has been created on top of it.
Working copy (@) now at: royxmykx cec19492 (empty) (no description set)
Parent commit (@-) : kkmpptxz e1cb4cf3 main | (empty) a
[EOF]
");
workspace1_dir
.run_jj(["workspace", "update-stale"])
.success();
let output = workspace1_dir.run_jj(["log", "--no-graph"]);
work_dir.write_file("file", "a");
let output = work_dir.run_jj(["log", "-r.."]);
insta::assert_snapshot!(output, @"
nppvrztz test.user@example.com 2001-02-03 08:05:12 workspace1@ e89ed162
(empty) (no description set)
royxmykx test.user@example.com 2001-02-03 08:05:12 default@ cec19492
(empty) (no description set)
kkmpptxz test.user@example.com 2001-02-03 08:05:09 main e1cb4cf3
(empty) a
zzzzzzzz root() 00000000
@ yqosqzyt test.user@example.com 2001-02-03 08:05:13 default@ 3386b5c7
(no description set)
◆ kkmpptxz test.user@example.com 2001-02-03 08:05:09 main workspace1@ e1cb4cf3
(empty) a
◆ qpvuntsm test.user@example.com 2001-02-03 08:05:07 e8849ae1
(empty) (no description set)
~
[EOF]
------- stderr -------
Warning: The working-copy commit is immutable; a new commit has been created on top of it.
[EOF]
");
workspace1_dir.write_file("file", "a");
let output = workspace1_dir.run_jj(["log", "-r.."]);
insta::assert_snapshot!(output, @"
@ vruxwmqv test.user@example.com 2001-02-03 08:05:14 workspace1@ bbc55980
│ (no description set)
│ ○ yqosqzyt test.user@example.com 2001-02-03 08:05:13 default@ 3386b5c7
├─╯ (no description set)
◆ kkmpptxz test.user@example.com 2001-02-03 08:05:09 main e1cb4cf3
│ (empty) a
◆ qpvuntsm test.user@example.com 2001-02-03 08:05:07 e8849ae1
│ (empty) (no description set)
~
[EOF]
------- stderr -------
Warning: The working-copy commit is immutable; a new commit has been created on top of it.
[EOF]
");
}
@@ -230,21 +269,18 @@ fn test_new_wc_commit_when_wc_immutable_multi_workspace_already_immutable() {
let output = work_dir
.run_jj(["workspace", "add", "../workspace1"])
.success();
// TODO: The current workspace is immutable from the new workspace's
// perspective, but we should not create a new commit for it.
// The current workspace is immutable from the new workspace's perspective,
// but we should not create a new commit for it.
insta::assert_snapshot!(output, @r#"
------- stderr -------
Created workspace in "../workspace1"
Warning: The working-copy commit in workspace 'default' became immutable, so a new commit has been created on top of it.
Working copy (@) now at: pmmvwywv 1cd27236 (empty) (no description set)
Parent commit (@-) : qpvuntsm e8849ae1 (empty) (no description set)
[EOF]
"#);
let output = work_dir.run_jj(["log", "-r=::"]);
insta::assert_snapshot!(output, @r"
@ yxszmlut test.user@example.com 2001-02-03 08:05:09 default@ 88a6c421
│ (empty) (no description set)
○ rlvkpnrz test.user@example.com 2001-02-03 08:05:08 167b8dbf
insta::assert_snapshot!(output, @"
@ rlvkpnrz test.user@example.com 2001-02-03 08:05:08 default@ 167b8dbf
│ (empty) a
│ ◆ pmmvwywv test.user@example.com 2001-02-03 08:05:09 workspace1@ 1cd27236
├─╯ (empty) (no description set)
@@ -253,14 +289,13 @@ fn test_new_wc_commit_when_wc_immutable_multi_workspace_already_immutable() {
◆ zzzzzzzz root() 00000000
[EOF]
");
// TODO: The other workspace was already immutable from the current workspace's
// The other workspace was already immutable from the current workspace's
// perspective, so we don't create a new commit for it.
let output = work_dir.run_jj(["new"]);
insta::assert_snapshot!(output, @r"
insta::assert_snapshot!(output, @"
------- stderr -------
Warning: The working-copy commit in workspace 'workspace1' became immutable, so a new commit has been created on top of it.
Working copy (@) now at: mzvwutvl c460fde3 (empty) (no description set)
Parent commit (@-) : yxszmlut 88a6c421 (empty) (no description set)
Working copy (@) now at: mzvwutvl b6d970c6 (empty) (no description set)
Parent commit (@-) : rlvkpnrz 167b8dbf (empty) a
[EOF]
");
}

View File

@@ -57,14 +57,10 @@ fn test_tag_set_delete() {
Warning: Target revision is empty.
Created 1 tags pointing to rlvkpnrz bbc74930 (empty) (no description set)
Moved 1 tags to rlvkpnrz bbc74930 (empty) (no description set)
Warning: The working-copy commit in workspace 'default' became immutable, so a new commit has been created on top of it.
Working copy (@) now at: yqosqzyt 13cbd515 (empty) (no description set)
Parent commit (@-) : rlvkpnrz bbc74930 (empty) (no description set)
[EOF]
");
insta::assert_snapshot!(get_log_output(&work_dir), @"
@ 13cbd51558a6
◆ bbc749308d7f baz foo
@ bbc749308d7f baz foo
◆ b876c5f49546 bar
◆ 000000000000
[EOF]
@@ -77,14 +73,13 @@ fn test_tag_set_delete() {
[EOF]
");
insta::assert_snapshot!(get_log_output(&work_dir), @"
@ 13cbd51558a6
◆ bbc749308d7f baz
@ bbc749308d7f baz
◆ b876c5f49546 bar
◆ 000000000000
[EOF]
");
let output = work_dir.run_jj(["tag", "set", "--allow-move", "-r@-", "baz"]);
let output = work_dir.run_jj(["tag", "set", "--allow-move", "-r@", "baz"]);
insta::assert_snapshot!(output, @"
------- stderr -------
Warning: Target revision is empty.
@@ -92,8 +87,7 @@ fn test_tag_set_delete() {
[EOF]
");
insta::assert_snapshot!(get_log_output(&work_dir), @"
@ 13cbd51558a6
◆ bbc749308d7f baz
@ bbc749308d7f baz
◆ b876c5f49546 bar
◆ 000000000000
[EOF]
@@ -106,8 +100,7 @@ fn test_tag_set_delete() {
[EOF]
");
insta::assert_snapshot!(get_log_output(&work_dir), @"
@ 13cbd51558a6
○ bbc749308d7f
@ bbc749308d7f
○ b876c5f49546
◆ 000000000000
[EOF]
@@ -280,7 +273,7 @@ fn test_tag_list() {
conflicted_tag (conflicted):
- rlvkpnrz 893e67dc (empty) commit1
+ zsuskuln 76abdd20 (empty) commit2
+ royxmykx 13c4e819 (empty) commit3
+ royxmykx 13c4e819 (empty) commit3
test_tag: rlvkpnrz 893e67dc (empty) commit1
test_tag2: zsuskuln 76abdd20 (empty) commit2
[EOF]