diff --git a/CHANGELOG.md b/CHANGELOG.md index bcdfe0174ed..41b54c580e8 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 * `jj branch` has gained a new `rename` subcommand that allows changing a branch name atomically. `jj branch help rename` for details. +* New `jj op abandon` command is added to clean up the operation history. If GC + is implemented, Git refs and commit objects can be compacted. + ### Fixed bugs * Command aliases can now be loaded from repository config relative to the diff --git a/cli/src/commands/operation.rs b/cli/src/commands/operation.rs index bc1915482d7..ee0de7e1845 100644 --- a/cli/src/commands/operation.rs +++ b/cli/src/commands/operation.rs @@ -12,12 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::io::Write as _; +use std::slice; + use clap::Subcommand; use jj_lib::backend::ObjectId; +use jj_lib::op_store::OperationId; use jj_lib::op_walk; use jj_lib::repo::Repo; -use crate::cli_util::{user_error, CommandError, CommandHelper, LogContentFormat}; +use crate::cli_util::{ + user_error, user_error_with_hint, CommandError, CommandHelper, LogContentFormat, +}; use crate::graphlog::{get_graphlog, Edge}; use crate::operation_templater; use crate::templater::Template as _; @@ -29,6 +35,7 @@ use crate::ui::Ui; /// https://github.com/martinvonz/jj/blob/main/docs/operation-log.md. #[derive(Subcommand, Clone, Debug)] pub enum OperationCommand { + Abandon(OperationAbandonArgs), Log(OperationLogArgs), Undo(OperationUndoArgs), Restore(OperationRestoreArgs), @@ -89,6 +96,24 @@ pub struct OperationUndoArgs { what: Vec, } +/// Abandon operation history +/// +/// To discard old operation history, use `jj op abandon ..`. It +/// will abandon the specified operation and its all ancestors. The descendants +/// will be reparented onto the root operation. +/// +/// To discard recent operations, run `jj op restore ` first to +/// restore the repository to the desired state. If the repository looks good, +/// use `jj op abandon ..` to remove the operations. +/// +/// The abandoned operations, commits, and other unreachable objects can be +/// later garbage collected by using `jj util gc` command. +#[derive(clap::Args, Clone, Debug)] +pub struct OperationAbandonArgs { + /// The operation range to abandon + operation: String, +} + #[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)] enum UndoWhatToRestore { /// The jj repo state and local branches @@ -249,12 +274,115 @@ fn cmd_op_restore( Ok(()) } +fn cmd_op_abandon( + ui: &mut Ui, + command: &CommandHelper, + args: &OperationAbandonArgs, +) -> Result<(), CommandError> { + let mut workspace_command = command.workspace_helper(ui)?; + let repo = workspace_command.repo().clone(); + let current_head_op = repo.operation(); + let Some((root_op_str, head_op_str)) = args.operation.split_once("..") else { + return Err(user_error_with_hint( + format!("Unsupported expression: {}", args.operation), + "Specify operation range as ..head_id or root_id..", + )); + }; + let abandon_root_op = if root_op_str.is_empty() { + // TODO: Introduce a virtual root operation and use it instead. + op_walk::walk_ancestors(slice::from_ref(current_head_op)) + .last() + .unwrap()? + } else { + workspace_command.resolve_single_op(root_op_str)? + }; + let abandon_head_op = if head_op_str.is_empty() { + current_head_op.clone() + } else { + workspace_command.resolve_single_op(head_op_str)? + }; + + if abandon_head_op == *current_head_op { + // The minimum requirement here is that the working copy tree is + // identical so the operation id can be remapped, but let's force user + // to do "op restore"/"undo" first. There's no "op-heads undo" command + // to recover from bad "op abandon". + let new_view = abandon_root_op.view()?; + let jj_lib::op_store::View { + head_ids: cur_head_ids, + public_head_ids: cur_public_head_ids, + local_branches: cur_local_branches, + tags: cur_tags, + remote_views: cur_remote_views, + git_refs: _, + git_head: _, + wc_commit_ids: cur_wc_commit_ids, + } = repo.view().store_view(); + let jj_lib::op_store::View { + head_ids: new_head_ids, + public_head_ids: new_public_head_ids, + local_branches: new_local_branches, + tags: new_tags, + remote_views: new_remote_views, + git_refs: _, + git_head: _, + wc_commit_ids: new_wc_commit_ids, + } = new_view.store_view(); + if cur_head_ids != new_head_ids + || cur_public_head_ids != new_public_head_ids + || cur_local_branches != new_local_branches + || cur_tags != new_tags + || cur_remote_views != new_remote_views + || cur_wc_commit_ids != new_wc_commit_ids + { + return Err(user_error_with_hint( + "Cannot roll back to unrelated repository state", + "Run `jj op restore` first to move to the desired state", + )); + } + // TODO: Maybe the current git_refs should be copied to the new view? + } + + // Acquire working copy lock early as we'll need to remap the operation id. + // This also prevents "op abandon --at-op" where @ is not the op heads. + let (locked_ws, _) = workspace_command.start_working_copy_mutation()?; + // Reparent descendants, count the number of abandoned operations. + let stats = op_walk::reparent_range( + repo.op_store().as_ref(), + slice::from_ref(&abandon_head_op), + slice::from_ref(current_head_op), + &abandon_root_op, + )?; + if stats.rewritten_count == 0 { + writeln!( + ui.stderr(), + "Abandoned {} operations.", + stats.unreachable_count, + )?; + } else { + writeln!( + ui.stderr(), + "Abandoned {} operations and reparented {} descendant operations.", + stats.unreachable_count, + stats.rewritten_count, + )?; + } + let [new_head_id]: [OperationId; 1] = stats.new_head_ids.try_into().unwrap(); + if current_head_op.id() != &new_head_id { + repo.op_heads_store() + .update_op_heads(slice::from_ref(current_head_op.id()), &new_head_id); + } + locked_ws.finish(new_head_id)?; + Ok(()) +} + pub fn cmd_operation( ui: &mut Ui, command: &CommandHelper, subcommand: &OperationCommand, ) -> Result<(), CommandError> { match subcommand { + OperationCommand::Abandon(args) => cmd_op_abandon(ui, command, args), OperationCommand::Log(args) => cmd_op_log(ui, command, args), OperationCommand::Restore(args) => cmd_op_restore(ui, command, args), OperationCommand::Undo(args) => cmd_op_undo(ui, command, args), diff --git a/cli/tests/test_operations.rs b/cli/tests/test_operations.rs index fb6b6437454..f841c3e0ea9 100644 --- a/cli/tests/test_operations.rs +++ b/cli/tests/test_operations.rs @@ -279,6 +279,130 @@ fn test_op_log_configurable() { assert!(stdout.contains("my-username@my-hostname")); } +#[test] +fn test_op_abandon_ancestors() { + 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"); + + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "commit 1"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "commit 2"]); + insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["op", "log"]), @r###" + @ bacc8030a969 test-username@host.example.com 2001-02-03 04:05:09.000 +07:00 - 2001-02-03 04:05:09.000 +07:00 + │ commit a8ac27b29a157ae7dabc0deb524df68823505730 + │ args: jj commit -m 'commit 2' + ◉ bb26fe31d66f test-username@host.example.com 2001-02-03 04:05:08.000 +07:00 - 2001-02-03 04:05:08.000 +07:00 + │ commit 230dd059e1b059aefc0da06a2e5a7dbf22362f22 + │ args: jj commit -m 'commit 1' + ◉ 19b8089fc78b 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' + ◉ f1c462c494be 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 + "###); + + // Abandon old operations. The working-copy operation id should be updated. + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["op", "abandon", "..@-"]); + insta::assert_snapshot!(stderr, @r###" + Abandoned 2 operations and reparented 1 descendant operations. + "###); + insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["debug", "workingcopy"]), @r###" + Current operation: OperationId("fb5252a68411468f5e3cf480a75b8b54d8ca9231406a3d0ddc4dfb31d851839a855aca5615ba4b09018fe45d11a04e1c051817a98de1c1ef5dd75cb6c2c09ba8") + Current tree: Merge(Resolved(TreeId("4b825dc642cb6eb9a060e54bf8d69288fbee4904"))) + "###); + insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["op", "log"]), @r###" + @ fb5252a68411 test-username@host.example.com 2001-02-03 04:05:09.000 +07:00 - 2001-02-03 04:05:09.000 +07:00 + │ commit a8ac27b29a157ae7dabc0deb524df68823505730 + │ args: jj commit -m 'commit 2' + ◉ f1c462c494be 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 + "###); + + // Abandon a certain operation. It wouldn't be useful, but works. + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "commit 3"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "commit 4"]); + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["op", "abandon", "@--..@-"]); + insta::assert_snapshot!(stderr, @r###" + Abandoned 1 operations and reparented 1 descendant operations. + "###); + insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["op", "log"]), @r###" + @ 50fc6d9ddaad test-username@host.example.com 2001-02-03 04:05:15.000 +07:00 - 2001-02-03 04:05:15.000 +07:00 + │ commit 7e8c543dd4b96597050139a02615492f84b29b0a + │ args: jj commit -m 'commit 4' + ◉ fb5252a68411 test-username@host.example.com 2001-02-03 04:05:09.000 +07:00 - 2001-02-03 04:05:09.000 +07:00 + │ commit a8ac27b29a157ae7dabc0deb524df68823505730 + │ args: jj commit -m 'commit 2' + ◉ f1c462c494be 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 + "###); + + // Can't abandon the whole operation history. + let stderr = test_env.jj_cmd_failure(&repo_path, &["op", "abandon", "..@"]); + insta::assert_snapshot!(stderr, @r###" + Error: Cannot roll back to unrelated repository state + Hint: Run `jj op restore` first to move to the desired state + "###); + + // Abandon empty range. + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["op", "abandon", "@..@"]); + insta::assert_snapshot!(stderr, @r###" + Abandoned 0 operations. + "###); + insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["op", "log", "-l1"]), @r###" + @ 50fc6d9ddaad test-username@host.example.com 2001-02-03 04:05:15.000 +07:00 - 2001-02-03 04:05:15.000 +07:00 + │ commit 7e8c543dd4b96597050139a02615492f84b29b0a + │ args: jj commit -m 'commit 4' + "###); +} + +#[test] +fn test_op_abandon_descendants() { + 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"); + + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "commit 1"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "commit 2"]); + insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["op", "log"]), @r###" + @ bacc8030a969 test-username@host.example.com 2001-02-03 04:05:09.000 +07:00 - 2001-02-03 04:05:09.000 +07:00 + │ commit a8ac27b29a157ae7dabc0deb524df68823505730 + │ args: jj commit -m 'commit 2' + ◉ bb26fe31d66f test-username@host.example.com 2001-02-03 04:05:08.000 +07:00 - 2001-02-03 04:05:08.000 +07:00 + │ commit 230dd059e1b059aefc0da06a2e5a7dbf22362f22 + │ args: jj commit -m 'commit 1' + ◉ 19b8089fc78b 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' + ◉ f1c462c494be 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 + "###); + + // For safety, can't abandon without restoring to the destination view + let stderr = test_env.jj_cmd_failure(&repo_path, &["op", "abandon", "@-.."]); + insta::assert_snapshot!(stderr, @r###" + Error: Cannot roll back to unrelated repository state + Hint: Run `jj op restore` first to move to the desired state + "###); + + // Abandon recent operations. The working-copy operation id should be updated. + test_env.jj_cmd_ok(&repo_path, &["op", "restore", "@-"]); + let (_stdout, stderr) = test_env.jj_cmd_ok(&repo_path, &["op", "abandon", "@--.."]); + insta::assert_snapshot!(stderr, @r###" + Abandoned 2 operations. + "###); + insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["debug", "workingcopy"]), @r###" + Current operation: OperationId("bb26fe31d66f320e6d2bda90b64a99ad95d54f1ea6ecb2e61af718fcdce91668a9edf4c5b2fe9142c43ce8037f05752b48b052fb2018d667c43d38bf76b098c2") + Current tree: Merge(Resolved(TreeId("4b825dc642cb6eb9a060e54bf8d69288fbee4904"))) + "###); + insta::assert_snapshot!(test_env.jj_cmd_success(&repo_path, &["op", "log"]), @r###" + @ bb26fe31d66f test-username@host.example.com 2001-02-03 04:05:08.000 +07:00 - 2001-02-03 04:05:08.000 +07:00 + │ commit 230dd059e1b059aefc0da06a2e5a7dbf22362f22 + │ args: jj commit -m 'commit 1' + ◉ 19b8089fc78b 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' + ◉ f1c462c494be 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 + "###); +} + fn get_log_output(test_env: &TestEnvironment, repo_path: &Path, op_id: &str) -> String { test_env.jj_cmd_success( repo_path,