From ed0cf1bd2ae8415313c1512c2d635e58e011d5f1 Mon Sep 17 00:00:00 2001 From: Philip Metzger Date: Sun, 22 Jan 2023 00:07:42 +0100 Subject: [PATCH] commands: Implement `next` and `prev` This is a naive implementation, which cannot deal with multiple children or parents stemming from merges. Note: I currently gave each command separate a separate argument struct for extensibility. Fixes #878 --- CHANGELOG.md | 4 + src/commands/mod.rs | 290 +++++++++++++++++++++++++++++++ tests/test_next_prev_commands.rs | 98 +++++++++++ 3 files changed, 392 insertions(+) create mode 100644 tests/test_next_prev_commands.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 808df81a27..6d6d65fda5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -94,6 +94,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 now shorter within the default log revset. You can override the default by setting the `revsets.short-prefixes` config to a different revset. +* `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 * Modify/delete conflicts now include context lines diff --git a/src/commands/mod.rs b/src/commands/mod.rs index b523a7fe8f..bee7feba65 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -104,10 +104,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), @@ -515,6 +517,70 @@ 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. +/// +/// F F @ +/// | | / +/// C @ => C +/// | / | +/// B B +/// +/// If `edit` is passed as an argument, it will move you directly to the child +/// revision. +/// +/// F F +/// | | +/// C C +/// | | +/// B @ => @ +/// | / | +/// A A +// TODO(#NNN): Handle multiple child revisions properly. +#[derive(clap::Args, Clone, Debug)] +struct NextArgs { + /// How many revisions to move forward. By default advances to the next + /// child. + #[arg(default_value = "1")] + amount: usize, + /// Instead of moving the empty commit from `jj new`, edit the child + /// revision directly. This mirrors the behavior of Mercurial and + /// Sapling. + #[arg(long)] + edit: Option, +} + +/// Move the working copy commit to the parent of the current revision. +/// The command moves you to the parent in a linear fashion. +/// +/// F @ F +/// |/ | +/// A => A @ +/// | | / +/// B B +/// +/// If `edit` is passed as an argument, it will move the working copy commit +/// directly to the parent. +/// +/// F @ F +/// |/ | +/// C => C +/// | | +/// B @ +/// | | +/// A A +// TODO(#NNN): Handle multiple parents, e.g merges. +#[derive(clap::Args, Clone, Debug)] +struct PrevArgs { + /// How many revisions to move backward. By default moves to the parent. + #[arg(default_value = "1")] + amount: usize, + /// Edit the parent directly, instead of moving the empty revision. + /// This mirrors the behavior of Mercurial and Sapling. + #[arg(long)] + edit: Option, +} + /// Move changes from one revision into another /// /// Use `--interactive` to move only part of the source revision into the @@ -2189,6 +2255,228 @@ fn cmd_new(ui: &mut Ui, command: &CommandHelper, args: &NewArgs) -> Result<(), C Ok(()) } +fn cmd_next(ui: &mut Ui, command: &CommandHelper, args: &NextArgs) -> Result<(), CommandError> { + let mut workspace_command = command.workspace_helper(ui)?; + let edit = if args.edit.is_some() { true } else { false }; + let amount = args.amount; + assert!(amount == 1 || amount > 1); + let current_wc_id = workspace_command + .get_wc_commit_id() + .ok_or_else(|| user_error(format!("This command requires a working copy")))?; + let current_wc = workspace_command + .repo() + .store() + .get_commit(¤t_wc_id)?; + // TODO(#NNN): This currently depends on order in which the parents are + // returned, make it configurable. + let parents = current_wc.parents(); + let parent = parents + .first() + .ok_or_else(|| user_error("unable to determine parent"))?; + let parent_commit = RevsetExpression::commit(parent.id().clone()); + // Collect all descendants of our parent. + // TODO(#NNN) We currently cannot deal with multiple children, which result + // from branches. Fix it when --interactive is implemented. + let children: Vec = RevsetExpression::descendants(&parent_commit) + .resolve(workspace_command.repo().as_ref())? + .evaluate(workspace_command.repo().as_ref())? + .iter() + .commits(workspace_command.repo().store()) + .try_collect()?; + + let current_id = current_wc_id.hex(); + // Handle the simple `jj next` call. + if amount == 1 { + // If the parent is the last commmit in the repository and we're not editing, + // just move the working-copy commit to the end. This is just a wrapped `jj + // new`. + if children.is_empty() { + let mut tx = + workspace_command.start_transaction(&format!("next: {current_id} -> new commit")); + // As stated above, we can only move the working-copy commit if we're not + // editing. + if edit { + return Err(user_error(format!( + "next cannot edit the next commit at the end of the history" + ))); + } + let merged_tree = merge_commit_trees(tx.repo(), &[parent.clone()]); + // Move the working-copy commit. + let new_wc_revision = tx + .mut_repo() + .new_commit( + command.settings(), + vec![parent.id().clone()], + merged_tree.id().clone(), + ) + .write()?; + tx.edit(&new_wc_revision).unwrap(); + tx.finish(ui)?; + return Ok(()); + } + + // The target is the first child. + let target = children.first().unwrap(); + workspace_command.check_rewritable(&target)?; + let target_id = target.id().hex(); + + let message = if !edit { + format!("next: {current_id} -> {target_id}") + } else { + format!("next: {current_id} -> editing {target_id}") + }; + let mut tx = workspace_command.start_transaction(&message); + + // Move to the revision. + if edit { + tx.edit(&target).unwrap(); + tx.finish(ui)?; + return Ok(()); + } + let merged_tree = merge_commit_trees(tx.repo(), &[target.clone()]); + let new_wc_revision = tx + .mut_repo() + .new_commit( + command.settings(), + vec![target.id().clone()], + merged_tree.id().clone(), + ) + .write()?; + tx.edit(&new_wc_revision).unwrap(); + tx.finish(ui)?; + return Ok(()); + } + assert!(amount > 1, "Expected to descend to further children"); + let target = children.iter().nth(amount - 1).ok_or_else(|| { + user_error(format!( + "unable to find target as {amount} is larger than all following commits" + )) + })?; + let target_id = target.id().hex(); + let message = if !edit { + format!("next: {current_id} -> {target_id}") + } else { + format!("next: {current_id} -> editing {target_id}") + }; + let mut tx = workspace_command.start_transaction(&message); + + // Move to the target. + if edit { + tx.edit(&target).unwrap(); + tx.finish(ui)?; + return Ok(()); + } + // Make the target the parent of the new working-copy commit. + let merged_tree = merge_commit_trees(tx.repo(), &[target.clone()]); + let new_wc_revision = tx + .mut_repo() + .new_commit( + command.settings(), + vec![target.id().clone()], + merged_tree.id().clone(), + ) + .write()?; + tx.edit(&new_wc_revision).unwrap(); + tx.finish(ui)?; + Ok(()) +} + +fn cmd_prev(ui: &mut Ui, command: &CommandHelper, args: &PrevArgs) -> Result<(), CommandError> { + let mut workspace_command = command.workspace_helper(ui)?; + let edit = if args.edit.is_some() { true } else { false }; + let amount = args.amount; + assert!(amount == 1 || amount > 1); + let current_wc_id = workspace_command + .get_wc_commit_id() + .ok_or_else(|| user_error(format!("This command requires a working copy")))?; + let current_wc = workspace_command + .repo() + .store() + .get_commit(¤t_wc_id)?; + let current_id = current_wc.id().hex(); + let parents = RevsetExpression::commit(current_wc_id.clone()).parents(); + // Collect all ancestors up until the root commit. + let all_ancestors: Vec = parents + .ancestors() + .resolve(workspace_command.repo().as_ref())? + .evaluate(workspace_command.repo().as_ref())? + .iter() + .commits(workspace_command.repo().store()) + .try_collect()?; + // Handle the simple case of a basic `prev` call. + if amount == 1 { + // The direct parent is the first ancestor. + // TODO(#NNN): Handle multiple parents correctly, e.g prompt if we're + // interactive. + let parent = all_ancestors.first().unwrap(); + workspace_command.check_rewritable(&parent)?; + let parent_id = parent.id(); + // Omit the "moved N commits" from the message. + let mut tx = workspace_command.start_transaction(&format!( + "prev: {current_id} -> {parent_id}", + parent_id = parent_id.hex() + )); + let root_commit = tx.base_repo().store().root_commit(); + // If we're editing, just move to the revision directly. + if edit { + if parent_id == root_commit.id() { + return Err(user_error("Editing the root commit is not allowed.")); + } + tx.edit(&parent).unwrap(); + tx.finish(ui)?; + return Ok(()); + } + let merged_tree = merge_commit_trees(tx.repo(), &[parent.clone()]); + // Make the workspace commit a descendant of the parent. + let new_wc_revision = tx + .mut_repo() + .new_commit( + command.settings(), + vec![parent_id.clone()], + merged_tree.id().clone(), + ) + .write()?; + tx.edit(&new_wc_revision).unwrap(); + tx.finish(ui)?; + return Ok(()); + } + assert!(amount > 1, "Expected more parents to traverse"); + let target = all_ancestors.iter().nth(amount - 1).unwrap(); + let target_id = target.id().hex(); + let message = if !edit { + format!("prev: moved {amount} commits {current_id} -> {target_id}") + } else { + format!("prev: moved {amount} commits editing {current_id} -> {target_id}") + }; + let mut tx = workspace_command.start_transaction(&message); + let root_commit = tx.base_repo().store().root_commit(); + // You still cannot edit the root commit. + if *target == root_commit { + return Err(user_error("you cannot edit the root commit")); + } + // We're editing, just move to the commit. + if edit { + tx.edit(&target).unwrap(); + tx.finish(ui)?; + return Ok(()); + } + // Create a child revision for our new working-copy commit. + let merged_tree = merge_commit_trees(tx.repo(), &[target.clone()]); + let target_id = target.id(); + // Make the working-copy commit a descendant of the target. + let new_wc_revision = tx + .mut_repo() + .new_commit( + command.settings(), + vec![target_id.clone()], + merged_tree.id().clone(), + ) + .write()?; + tx.edit(&new_wc_revision).unwrap(); + tx.finish(ui)?; + Ok(()) +} + fn combine_messages( repo: &ReadonlyRepo, source: &Commit, @@ -3564,6 +3852,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/tests/test_next_prev_commands.rs b/tests/test_next_prev_commands.rs new file mode 100644 index 0000000000..4d2cea6ada --- /dev/null +++ b/tests/test_next_prev_commands.rs @@ -0,0 +1,98 @@ +// 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. +// +// TODO: Finish tests for `prev` and `next` + +use crate::common::{get_stderr_string, TestEnvironment}; + +pub mod common; + +#[test] +fn test_next_simple() { + 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(test_env.env_root(), &["commit", "-m", "first"]); + test_env.jj_cmd_success(test_env.env_root(), &["commit", "-m", "second"]); + test_env.jj_cmd_success(test_env.env_root(), &["commit", "-m", "third"]); + test_env.jj_cmd_success(test_env.env_root(), &["edit", ""]); + test_env.jj_cmd_success(test_env.env_root(), &["next"]); + insta::assert_snapshot!() +} + +#[test] +fn test_next_multiple_without_root() { + 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"); + insta::assert_snapshot!() +} + +#[test] +fn test_prev_simple() { + 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(test_env.env_root(), &["commit", "-m", "first"]); + test_env.jj_cmd_success(test_env.env_root(), &["commit", "-m", "second"]); + test_env.jj_cmd_success(test_env.env_root(), &["commit", "-m", "third"]); + test_env.jj_cmd_success(test_env.env_root(), &["prev"]); + // The working copy commit is now a child of "second". + insta::assert_snapshot!() +} + +#[test] +fn test_prev_multiple_without_root() { + 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] +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] +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] +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] +fn test_prev_editing() { + 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] +fn test_next_editing() { + 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"); +}