diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a668858a..db3650105 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). * `jj op log` gained an option to include operation diffs. +* A new global flag `--no-commit-transaction` lets you run a command without + impacting the repo state or the working copy. + ### Fixed bugs * Fixed panic when parsing invalid conflict markers of a particular form. diff --git a/cli/src/cli_util.rs b/cli/src/cli_util.rs index 9e10424d5..4d49cfcf0 100644 --- a/cli/src/cli_util.rs +++ b/cli/src/cli_util.rs @@ -346,6 +346,23 @@ impl CommandHelper { )?) } + pub fn should_commit_transaction(&self) -> bool { + !self.global_args().no_commit_transaction + } + + pub fn maybe_commit_transaction( + &self, + tx: Transaction, + description: impl Into, + ) -> Arc { + let unpublished_op = tx.write(description); + if self.should_commit_transaction() { + unpublished_op.publish() + } else { + unpublished_op.leave_unpublished() + } + } + pub fn workspace_loader(&self) -> Result<&dyn WorkspaceLoader, CommandError> { self.data .maybe_workspace_loader @@ -839,7 +856,11 @@ impl WorkspaceCommandHelper { // state to it without updating working copy files. locked_ws.locked_wc().reset(&new_git_head_commit)?; tx.repo_mut().rebase_descendants(command.settings())?; - self.user_repo = ReadonlyUserRepo::new(tx.commit("import git head")); + self.user_repo = ReadonlyUserRepo::new( + self.env + .command + .maybe_commit_transaction(tx, "import git head"), + ); locked_ws.finish(self.user_repo.repo.op_id().clone())?; if old_git_head.is_present() { writeln!( @@ -1489,9 +1510,15 @@ See https://martinvonz.github.io/jj/latest/working-copy/#stale-working-copy \ print_failed_git_export(ui, &refs)?; } - self.user_repo = ReadonlyUserRepo::new(tx.commit("snapshot working copy")); + self.user_repo = ReadonlyUserRepo::new( + self.env + .command + .maybe_commit_transaction(tx, "snapshot working copy"), + ); + } + if self.env.command.should_commit_transaction() { + locked_ws.finish(self.user_repo.repo.op_id().clone())?; } - locked_ws.finish(self.user_repo.repo.op_id().clone())?; Ok(()) } @@ -1606,10 +1633,11 @@ See https://martinvonz.github.io/jj/latest/working-copy/#stale-working-copy \ print_failed_git_export(ui, &refs)?; } - self.user_repo = ReadonlyUserRepo::new(tx.commit(description)); + self.user_repo = + ReadonlyUserRepo::new(self.env.command.maybe_commit_transaction(tx, description)); self.report_repo_changes(ui, &old_repo)?; - if self.may_update_working_copy { + if self.may_update_working_copy && self.env.command.should_commit_transaction() { if let Some(new_commit) = &maybe_new_wc_commit { self.update_working_copy(ui, maybe_old_wc_commit.as_ref(), new_commit)?; } else { @@ -1738,6 +1766,14 @@ See https://martinvonz.github.io/jj/latest/working-copy/#stale-working-copy \ )?; } + if !self.env.command.should_commit_transaction() { + writeln!( + fmt, + "Operation left uncommitted because --no-commit-transaction was requested: {}", + short_operation_hash(self.repo().op_id()) + )?; + } + Ok(()) } @@ -2610,6 +2646,21 @@ pub struct GlobalArgs { /// implies `--ignore-working-copy`. #[arg(long, global = true)] pub ignore_working_copy: bool, + /// Run the command as usual but don't commit any transactions + /// + /// When this option is given, the operations will still be created as + /// usual but they will not be committed/promoted to the operation log. The + /// working copy will also not be updated. + /// + /// The command will print the resulting operation id. You can pass that to + /// e.g. `jj --at-op` to inspect the resulting repo state, or you can pass + /// it to `jj op restore` to restore the repo to that state. + /// + /// Note that this does *not* prevent side effects outside the repo. For + /// example, `jj git push --no-commit-transaction` will still perform + /// the push. + #[arg(long, global = true)] + pub no_commit_transaction: bool, /// Allow rewriting immutable commits /// /// By default, Jujutsu prevents rewriting commits in the configured set of diff --git a/cli/src/commands/file/track.rs b/cli/src/commands/file/track.rs index d6110ecb1..e26b0df1b 100644 --- a/cli/src/commands/file/track.rs +++ b/cli/src/commands/file/track.rs @@ -62,7 +62,9 @@ pub(crate) fn cmd_file_track( if num_rebased > 0 { writeln!(ui.status(), "Rebased {num_rebased} descendant commits")?; } - let repo = tx.commit("track paths"); - locked_ws.finish(repo.op_id().clone())?; + if command.should_commit_transaction() { + let repo = tx.commit("track paths"); + locked_ws.finish(repo.op_id().clone())?; + } Ok(()) } diff --git a/cli/src/commands/file/untrack.rs b/cli/src/commands/file/untrack.rs index cb720126a..b2812868d 100644 --- a/cli/src/commands/file/untrack.rs +++ b/cli/src/commands/file/untrack.rs @@ -107,7 +107,9 @@ Make sure they're ignored, then try again.", if num_rebased > 0 { writeln!(ui.status(), "Rebased {num_rebased} descendant commits")?; } - let repo = tx.commit("untrack paths"); - locked_ws.finish(repo.op_id().clone())?; + if command.should_commit_transaction() { + let repo = tx.commit("untrack paths"); + locked_ws.finish(repo.op_id().clone())?; + } Ok(()) } diff --git a/cli/src/commands/git/init.rs b/cli/src/commands/git/init.rs index 1d5735752..8f0cf82ee 100644 --- a/cli/src/commands/git/init.rs +++ b/cli/src/commands/git/init.rs @@ -83,6 +83,9 @@ pub fn cmd_git_init( command: &CommandHelper, args: &GitInitArgs, ) -> Result<(), CommandError> { + if command.global_args().no_commit_transaction { + return Err(cli_error("--no-commit-transaction is not respected")); + } if command.global_args().ignore_working_copy { return Err(cli_error("--ignore-working-copy is not respected")); } diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap index f8ef40f15..b22c57636 100644 --- a/cli/tests/cli-reference@.md.snap +++ b/cli/tests/cli-reference@.md.snap @@ -159,6 +159,13 @@ To get started, see the tutorial at https://martinvonz.github.io/jj/latest/tutor By default, Jujutsu snapshots the working copy at the beginning of every command. The working copy is also updated at the end of the command, if the command modified the working-copy commit (`@`). If you want to avoid snapshotting the working copy and instead see a possibly stale working copy commit, you can use `--ignore-working-copy`. This may be useful e.g. in a command prompt, especially if you have another process that commits the working copy. Loading the repository at a specific operation with `--at-operation` implies `--ignore-working-copy`. +* `--no-commit-transaction` — Run the command as usual but don't commit any transactions + + When this option is given, the operations will still be created as usual but they will not be committed/promoted to the operation log. The working copy will also not be updated. + + The command will print the resulting operation id. You can pass that to e.g. `jj --at-op` to inspect the resulting repo state, or you can pass it to `jj op restore` to restore the repo to that state. + + Note that this does *not* prevent side effects outside the repo. For example, `jj git push --no-commit-transaction` will still perform the push. * `--ignore-immutable` — Allow rewriting immutable commits By default, Jujutsu prevents rewriting commits in the configured set of immutable commits. This option disables that check and lets you rewrite any commit but the root commit. diff --git a/cli/tests/test_git_init.rs b/cli/tests/test_git_init.rs index 0ed6d7e94..8e7bf9bde 100644 --- a/cli/tests/test_git_init.rs +++ b/cli/tests/test_git_init.rs @@ -96,6 +96,19 @@ fn test_git_init_internal() { assert_eq!(read_git_target(&workspace_root), "git"); } +#[test] +fn test_git_init_internal_no_commit_transaction() { + let test_env = TestEnvironment::default(); + let workspace_root = test_env.env_root().join("repo"); + std::fs::create_dir(&workspace_root).unwrap(); + + let stderr = + test_env.jj_cmd_cli_error(&workspace_root, &["git", "init", "--no-commit-transaction"]); + insta::assert_snapshot!(stderr, @r###" + Error: --no-commit-transaction is not respected + "###); +} + #[test] fn test_git_init_internal_ignore_working_copy() { let test_env = TestEnvironment::default(); diff --git a/cli/tests/test_global_opts.rs b/cli/tests/test_global_opts.rs index adddf2104..26dd50b5f 100644 --- a/cli/tests/test_global_opts.rs +++ b/cli/tests/test_global_opts.rs @@ -147,6 +147,68 @@ fn test_ignore_working_copy() { "###); } +#[test] +fn test_no_commit_transaction() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_ok(test_env.env_root(), &["git", "init", "repo"]); + + let repo_path = test_env.env_root().join("repo"); + + std::fs::write(repo_path.join("file1"), "initial").unwrap(); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m=initial"]); + let op_log_stdout = test_env.jj_cmd_success(&repo_path, &["op", "log"]); + let working_copy_stdout = test_env.jj_cmd_success(&repo_path, &["debug", "working-copy"]); + + // Modify the working copy and run a mutating operation. With + // --no-commit-transaction, the working copy gets snapshotted and the operation + // gets created, but there's no new operation in the operation log, and the + // working copy state is not updated. + std::fs::write(repo_path.join("file2"), "initial").unwrap(); + let (stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["squash", "--no-commit-transaction"]); + insta::assert_snapshot!(stdout, @""); + insta::assert_snapshot!(stderr, @r###" + Operation left uncommitted because --no-commit-transaction was requested: 3fa0e7ceb0e4 + "###); + let first_line = stderr.split('\n').next().unwrap(); + let op_id_hex = first_line[first_line.len() - 12..].to_string(); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "log", "--ignore-working-copy"]); + assert_eq!(stdout, op_log_stdout); + let stdout = test_env.jj_cmd_success(&repo_path, &["debug", "working-copy"]); + assert_eq!(stdout, working_copy_stdout); + + // We can see the resulting log and op log with --at-op + let stdout = test_env.jj_cmd_success(&repo_path, &["log", "-s", "--at-op", op_id_hex.as_str()]); + insta::assert_snapshot!(stdout, @r###" + @ mzvwutvl test.user@example.com 2001-02-03 08:05:11 e4e6953f + │ (empty) (no description set) + ○ qpvuntsm test.user@example.com 2001-02-03 08:05:11 a2280cba + │ initial + │ A file1 + │ A file2 + ◆ zzzzzzzz root() 00000000 + "###); + let stdout = test_env.jj_cmd_success(&repo_path, &["op", "log", "--at-op", op_id_hex.as_str()]); + insta::assert_snapshot!(stdout, @r###" + @ 3fa0e7ceb0e4 test-username@host.example.com 2001-02-03 04:05:11.000 +07:00 - 2001-02-03 04:05:11.000 +07:00 + │ squash commits into dc5f5c36813feca46c1e26ea80c9634ea2fdb9e6 + │ args: jj squash --no-commit-transaction + ○ 63a798419dc4 test-username@host.example.com 2001-02-03 04:05:11.000 +07:00 - 2001-02-03 04:05:11.000 +07:00 + │ snapshot working copy + │ args: jj squash --no-commit-transaction + ○ 04139fd4890a test-username@host.example.com 2001-02-03 04:05:08.000 +07:00 - 2001-02-03 04:05:08.000 +07:00 + │ commit a38d281287b19f33abe36fedb6c2df1370b90f54 + │ args: jj commit '-m=initial' + ○ 0db616e9e09a test-username@host.example.com 2001-02-03 04:05:08.000 +07:00 - 2001-02-03 04:05:08.000 +07:00 + │ snapshot working copy + │ args: jj commit '-m=initial' + ○ b51416386f26 test-username@host.example.com 2001-02-03 04:05:07.000 +07:00 - 2001-02-03 04:05:07.000 +07:00 + │ add workspace 'default' + ○ 9a7d829846af test-username@host.example.com 2001-02-03 04:05:07.000 +07:00 - 2001-02-03 04:05:07.000 +07:00 + │ initialize repo + ○ 000000000000 root() + "###); +} + #[test] fn test_repo_arg_with_init() { let test_env = TestEnvironment::default(); @@ -608,6 +670,7 @@ fn test_help() { Global Options: -R, --repository Path to repository to operate on --ignore-working-copy Don't snapshot the working copy, and don't update it + --no-commit-transaction Run the command as usual but don't commit any transactions --ignore-immutable Allow rewriting immutable commits --at-operation Operation to load the repo at [aliases: at-op] --debug Enable debug logging