diff --git a/CHANGELOG.md b/CHANGELOG.md index 76e298a554..404d3fa107 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 external program. For configuration, see [the documentation](docs/config.md). [#1886](https://github.com/martinvonz/jj/issues/1886) +* `jj next` and `jj prev` are added, these allow you to traverse the history + in a linear style, see [#NNN](https://github.com/martinvonz/jj/issues/NNN) + for further pending improvements. + + ### Fixed bugs * SSH authentication could hang when ssh-agent couldn't be reached diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs index 920d9a4896..67b97b0ec2 100644 --- a/cli/src/commands/mod.rs +++ b/cli/src/commands/mod.rs @@ -110,10 +110,12 @@ enum Commands { Merge(NewArgs), Move(MoveArgs), New(NewArgs), + Next(NextArgs), Obslog(ObslogArgs), #[command(subcommand)] #[command(visible_alias = "op")] Operation(operation::OperationCommands), + Prev(PrevArgs), Rebase(RebaseArgs), Resolve(ResolveArgs), Restore(RestoreArgs), @@ -542,6 +544,82 @@ struct NewArgs { insert_before: bool, } +/// Move the current working copy commit to the next child revision in the +/// repository. +/// +/// +/// The command moves you to the next child in a linear fashion. +/// +/// +/// D D @ +/// | | / +/// C @ => C +/// | / | +/// B B +/// +/// +/// If `--edit` is passed, it will move you directly to the child +/// revision. +/// +/// +/// D D +/// | | +/// C C +/// | | +/// B @ => @ +/// | / | +/// A A +// TODO(#NNN): Handle multiple child revisions properly. +#[derive(clap::Args, Clone, Debug)] +#[command(verbatim_doc_comment)] +struct NextArgs { + /// How many revisions to move forward. By default advances to the next + /// child. + #[arg(default_value = "1")] + amount: u64, + /// Instead of creating a new working-copy commit on top of the target + /// commit (like `jj new`), edit the target commit directly (like `jj + /// edit`). + #[arg(long)] + edit: bool, +} + +/// Move the working copy commit to the parent of the current revision. +/// +/// +/// The command moves you to the parent in a linear fashion. +/// +/// +/// D @ D +/// |/ | +/// A => A @ +/// | | / +/// B B +/// +/// +/// If `--edit` is passed, it will move the working copy commit +/// directly to the parent. +/// +/// +/// D @ D +/// |/ | +/// C => @ +/// | | +/// B B +/// | | +/// A A +// TODO(#NNN): Handle multiple parents, e.g merges. +#[derive(clap::Args, Clone, Debug)] +#[command(verbatim_doc_comment)] +struct PrevArgs { + /// How many revisions to move backward. By default moves to the parent. + #[arg(default_value = "1")] + amount: u64, + /// Edit the parent directly, instead of moving the working-copy commit. + #[arg(long)] + edit: bool, +} + /// Move changes from one revision into another /// /// Use `--interactive` to move only part of the source revision into the @@ -2376,6 +2454,127 @@ Please use `jj new 'all:x|y'` instead of `jj new --allow-large-revsets x y`.", Ok(()) } +fn cmd_next(ui: &mut Ui, command: &CommandHelper, args: &NextArgs) -> Result<(), CommandError> { + let mut workspace_command = command.workspace_helper(ui)?; + let edit = args.edit; + let amount = args.amount; + let current_wc_id = workspace_command + .get_wc_commit_id() + .ok_or_else(|| user_error("This command requires a working copy"))?; + let current_wc = workspace_command.repo().store().get_commit(current_wc_id)?; + let current_short = short_commit_hash(current_wc.id()); + // If we're editing, start at the working-copy commit. + // Everything else starts from our direct parent. + let start_id = if edit { + current_wc_id + } else { + match current_wc.parent_ids() { + [parent_id] => parent_id, + _ => return Err(user_error("Cannot run `jj next` on a merge commit")), + } + }; + let target_expression = RevsetExpression::commit(start_id.clone()) + .descendants_at(amount) + // Negate the current working copy. + .minus(&RevsetExpression::commit(current_wc_id.clone())); + let targets: Vec = target_expression + .resolve(workspace_command.repo().as_ref())? + .evaluate(workspace_command.repo().as_ref())? + .iter() + .commits(workspace_command.repo().store()) + .take(2) + .try_collect()?; + let target = match targets.as_slice() { + [target] => target, + [] => { + // TODO: How to handle the next onto new commit part? + // + // See below. + // If the parent is the last commit in the repository and we're not editing, + // just move the working-copy commit to the end. This is just a wrapped `jj + // new`. + return Err(user_error("No target_commit")); + } + _ => { + // TODO(#NNN) We currently cannot deal with multiple children, which result + // from branches. Prompt the user for resolution. + return Err(user_error("Ambiguous target commit")); + } + }; + let target_short = short_commit_hash(target.id()); + // If we're editing, the target must be rewritable. + if edit { + workspace_command.check_rewritable(target)?; + } + let mut tx = workspace_command.start_transaction(""); + // We're editing, just move to the target commit. + if edit { + tx.set_description(&format!("next: {current_short} -> editing {target_short}")); + tx.edit(target)?; + tx.finish(ui)?; + return Ok(()); + } + tx.set_description(&format!("next: {current_short} -> {target_short}")); + // Move the working-copy commit to the new parent. + tx.check_out(target)?; + Ok(()) +} + +fn cmd_prev(ui: &mut Ui, command: &CommandHelper, args: &PrevArgs) -> Result<(), CommandError> { + let mut workspace_command = command.workspace_helper(ui)?; + let edit = args.edit; + let amount = args.amount; + let current_wc_id = workspace_command + .get_wc_commit_id() + .ok_or_else(|| user_error("This command requires a working copy".to_string()))?; + let current_wc = workspace_command.repo().store().get_commit(current_wc_id)?; + let current_short = short_commit_hash(current_wc.id()); + let start_id = if edit { + current_wc_id + } else { + match current_wc.parent_ids() { + [parent_id] => parent_id, + _ => return Err(user_error("cannot run `jj prev` on a branching parent")), + } + }; + let target_revset = RevsetExpression::commit(start_id.clone()) + .ancestors_at(amount) + .minus(&RevsetExpression::commit(current_wc_id.clone())); + let targets: Vec<_> = target_revset + .resolve(workspace_command.repo().as_ref())? + .evaluate(workspace_command.repo().as_ref())? + .iter() + .commits(workspace_command.repo().store()) + .try_collect()?; + let target = match targets.as_slice() { + [target] => target, + [] => { + return Err(user_error(&format!( + "No parent found {amount} commits back" + ))) + } + _ => return Err(user_error("Ambiguous target")), + }; + // The target must be rewritable if we're editing. + if edit { + workspace_command.check_rewritable(target)?; + } + let mut tx = workspace_command.start_transaction(""); + // Generate a shot commit hash, to make it readable in the op log. + let target_short = short_commit_hash(target.id()); + // If we're editing, just move to the revision directly. + if edit { + tx.set_description(&format!("prev: {current_short} -> editing {target_short}")); + tx.edit(target)?; + tx.finish(ui)?; + return Ok(()); + } + tx.set_description(&format!("prev: {current_short} -> {target_short}",)); + tx.check_out(target)?; + tx.finish(ui)?; + Ok(()) +} + fn combine_messages( repo: &ReadonlyRepo, source: &Commit, @@ -3758,6 +3957,8 @@ pub fn run_command(ui: &mut Ui, command_helper: &CommandHelper) -> Result<(), Co Commands::Duplicate(sub_args) => cmd_duplicate(ui, command_helper, sub_args), Commands::Abandon(sub_args) => cmd_abandon(ui, command_helper, sub_args), Commands::Edit(sub_args) => cmd_edit(ui, command_helper, sub_args), + Commands::Next(sub_args) => cmd_next(ui, command_helper, sub_args), + Commands::Prev(sub_args) => cmd_prev(ui, command_helper, sub_args), Commands::New(sub_args) => cmd_new(ui, command_helper, sub_args), Commands::Move(sub_args) => cmd_move(ui, command_helper, sub_args), Commands::Squash(sub_args) => cmd_squash(ui, command_helper, sub_args), diff --git a/cli/tests/test_next_prev_commands.rs b/cli/tests/test_next_prev_commands.rs new file mode 100644 index 0000000000..638e8bd099 --- /dev/null +++ b/cli/tests/test_next_prev_commands.rs @@ -0,0 +1,223 @@ +// Copyright 2023 The Jujutsu Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// + +use crate::common::TestEnvironment; + +pub mod common; + +#[test] +fn test_next_simple() { + // Move from first => second. + // first + // | + // second + // | + // third + // + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + // Create a simple linear history, which we'll traverse. + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "first"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "second"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "third"]); + // Move to `first` + test_env.jj_cmd_success(&repo_path, &["edit", "-r", "@--"]); + let stdout = test_env.jj_cmd_success(&repo_path, &["next"]); + insta::assert_snapshot!( + stdout, + @r###" + Working copy now at: 5647d685026f (no description set) + Parent commit : 5c52832c3483 second + "### + ); +} + +#[test] +fn test_next_multiple() { + // Move from first => fourth. + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "first"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "second"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "third"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "fourth"]); + test_env.jj_cmd_success(&repo_path, &["edit", "@--"]); + let stdout = test_env.jj_cmd_success(&repo_path, &["next", "2"]); + // We should now be the child of the fourth commit. + insta::assert_snapshot!( + stdout, + @r###""" + """### + ); +} + +#[test] +fn test_prev_simple() { + // Move from third => second. + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "first"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "second"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "third"]); + let stdout = test_env.jj_cmd_success(&repo_path, &["prev"]); + // The working copy commit is now a child of "second". + insta::assert_snapshot!( + stdout, + @r###" + Working copy now at: 973e9c36d3e4 (no description set) + Parent commit : 3fa8931e7b89 third + "### + ); +} + +#[test] +fn test_prev_multiple_without_root() { + // Move from fourth => second. + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "first"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "second"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "third"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "fourth"]); + let stdout = test_env.jj_cmd_success(&repo_path, &["prev", "2"]); + insta::assert_snapshot!( + stdout, + @r###" + Working copy now at: 5647d685026f (no description set) + Parent commit : 5c52832c3483 second + "### + ); +} + +#[test] +fn test_next_creates_new_commit() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "first"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "second"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "third"]); + // Calling `next` now, should create a new commit as it's the last commit + // in the repositories history. + let stdout = test_env.jj_cmd_success(&repo_path, &["next"]); + insta::assert_snapshot!(stdout, @r#""#); +} + +#[test] +fn test_next_exceeding_history() { + // + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "first"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "second"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "third"]); + test_env.jj_cmd_success(&repo_path, &["edit", "-r", "@--"]); + let stderr = test_env.jj_cmd_cli_error(&repo_path, &["next 2"]); + // `jj next` beyond existing history with no `new` possibility. + insta::assert_snapshot!(stderr, @r#""#); +} + +#[test] +fn test_next_fails_on_branching_children() { + // TODO(#NNN): Fix this behavior + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "first"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "second"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "third"]); + // Create a branching child. + test_env.jj_cmd_success(&repo_path, &["branch", "c", "into-the-future"]); + test_env.jj_cmd_success(&repo_path, &["co", "into-the-future"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "42"]); + test_env.jj_cmd_success(&repo_path, &["co", "main"]); + // Try to advance the working copy commit. + let stderr = test_env.jj_cmd_cli_error(&repo_path, &["next"]); + insta::assert_snapshot!(stderr,@r#""#); +} + +#[test] +fn test_prev_fails_on_multiple_parents() { + // TODO(#NNN): Fix this behavior + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "first"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "second"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "third"]); + + // Try to access a parent commit. + let stderr = test_env.jj_cmd_cli_error(&repo_path, &["prev"]); + insta::assert_snapshot!(stderr,@r#""#); +} + +#[test] +fn test_prev_onto_root_fails() { + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "first"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "second"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "third"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "fourth"]); + // The root commit is before "first". + let stderr = test_env.jj_cmd_cli_error(&repo_path, &["prev", "4"]); + insta::assert_snapshot!(stderr,@r#""#); +} + +#[test] +fn test_prev_editing() { + // Edit the third commit. + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "first"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "second"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "third"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "fourth"]); + let stdout = test_env.jj_cmd_success(&repo_path, &["prev", "--edit"]); + insta::assert_snapshot!( + stdout, + @r###" + Working copy now at: 009f88bf7141 fourth + Parent commit : 3fa8931e7b89 third + "### + ); +} + +#[test] +fn test_next_editing() { + // Edit the second commit. + let test_env = TestEnvironment::default(); + test_env.jj_cmd_success(test_env.env_root(), &["init", "repo", "--git"]); + let repo_path = test_env.env_root().join("repo"); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "first"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "second"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "third"]); + test_env.jj_cmd_success(&repo_path, &["commit", "-m", "fourth"]); + test_env.jj_cmd_success(&repo_path, &["edit", "@--"]); + let stdout = test_env.jj_cmd_success(&repo_path, &["next", "--edit"]); + insta::assert_snapshot!( + stdout, + @r###" + Nothing changed. + "### + ); +}