diff --git a/CHANGELOG.md b/CHANGELOG.md index 207516947e..5eb0b0ca3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 * `jj tag list` command prints imported git tags. +* `jj next` and `jj prev` now prompt in the event of the next/previous commit + being ambiguous, instead of failing outright. + ### Fixed bugs diff --git a/cli/src/commands/next.rs b/cli/src/commands/next.rs index b05ebeceb5..ef6613dad5 100644 --- a/cli/src/commands/next.rs +++ b/cli/src/commands/next.rs @@ -12,12 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. +use std::io::Write; + use itertools::Itertools; use jj_lib::commit::Commit; use jj_lib::repo::Repo; use jj_lib::revset::{RevsetExpression, RevsetIteratorExt}; -use crate::cli_util::{short_commit_hash, user_error, CommandError, CommandHelper}; +use crate::cli_util::{ + short_commit_hash, user_error, CommandError, CommandHelper, WorkspaceCommandHelper, +}; use crate::ui::Ui; /// Move the current working copy commit to the next child revision in the @@ -45,7 +49,6 @@ use crate::ui::Ui; /// B => @ /// | | /// @ A -// TODO(#2126): Handle multiple child revisions properly. #[derive(clap::Args, Clone, Debug)] #[command(verbatim_doc_comment)] pub(crate) struct NextArgs { @@ -60,6 +63,38 @@ pub(crate) struct NextArgs { edit: bool, } +pub fn choose_commit<'a>( + ui: &mut Ui, + workspace_command: &WorkspaceCommandHelper, + cmd: &str, + commits: &'a [Commit], +) -> Result<&'a Commit, CommandError> { + writeln!(ui.stdout(), "ambiguous {cmd} commit, choose one to target:")?; + let mut choices: Vec = Default::default(); + for (i, commit) in commits.iter().enumerate() { + writeln!( + ui.stdout(), + "{}: {}", + i + 1, + workspace_command.format_commit_summary(commit) + )?; + choices.push(format!("{}", i + 1)); + } + writeln!(ui.stdout(), "q: quit the prompt")?; + choices.push("q".to_string()); + + let choice = ui.prompt_choice( + "enter the index of the commit you want to target", + &choices, + None, + )?; + if choice == "q" { + return Err(user_error("ambiguous target commit")); + } + + Ok(&commits[choice.parse::().unwrap() - 1]) +} + pub(crate) fn cmd_next( ui: &mut Ui, command: &CommandHelper, @@ -104,11 +139,7 @@ pub(crate) fn cmd_next( if amount > 1 { "s" } else { "" } ))); } - _ => { - // TODO(#2126) We currently cannot deal with multiple children, which result - // from branches. Prompt the user for resolution. - return Err(user_error("Ambiguous target commit")); - } + commits => choose_commit(ui, &workspace_command, "next", commits)?, }; let target_short = short_commit_hash(target.id()); // We're editing, just move to the target commit. diff --git a/cli/src/commands/prev.rs b/cli/src/commands/prev.rs index 8056edefeb..7a8527516c 100644 --- a/cli/src/commands/prev.rs +++ b/cli/src/commands/prev.rs @@ -17,6 +17,7 @@ use jj_lib::repo::Repo; use jj_lib::revset::{RevsetExpression, RevsetIteratorExt}; use crate::cli_util::{short_commit_hash, user_error, CommandError, CommandHelper}; +use crate::commands::next::choose_commit; use crate::ui::Ui; /// Move the working copy commit to the parent of the current revision. @@ -101,7 +102,7 @@ pub(crate) fn cmd_prev( if amount > 1 { "s" } else { "" } ))) } - _ => return Err(user_error("Ambiguous target commit")), + commits => choose_commit(ui, &workspace_command, "prev", commits)?, }; // Generate a short commit hash, to make it readable in the op log. let target_short = short_commit_hash(target.id()); diff --git a/cli/src/ui.rs b/cli/src/ui.rs index 9cffad161f..9beced9cd3 100644 --- a/cli/src/ui.rs +++ b/cli/src/ui.rs @@ -329,8 +329,15 @@ impl Ui { } } + pub fn can_prompt() -> bool { + io::stdout().is_terminal() + || env::var("JJ_INTERACTIVE") + .map(|v| v == "1") + .unwrap_or(false) + } + pub fn prompt(&mut self, prompt: &str) -> io::Result { - if !io::stdout().is_terminal() { + if !Self::can_prompt() { return Err(io::Error::new( io::ErrorKind::Unsupported, "Cannot prompt for input since the output is not connected to a terminal", @@ -340,9 +347,66 @@ impl Ui { self.stdout().flush()?; let mut buf = String::new(); io::stdin().read_line(&mut buf)?; + + if let Some(trimmed) = buf.strip_suffix('\n') { + buf = trimmed.to_owned(); + } else if buf.is_empty() { + return Err(io::Error::new( + io::ErrorKind::UnexpectedEof, + "Prompt cancelled by EOF", + )); + } + Ok(buf) } + /// Repeat the given prompt until the input is one of the specified choices. + pub fn prompt_choice( + &mut self, + prompt: &str, + choices: &[impl AsRef], + default: Option<&str>, + ) -> io::Result { + if !Self::can_prompt() { + if let Some(default) = default { + // Choose the default automatically without waiting. + writeln!(self.stdout(), "{prompt}: {default}")?; + return Ok(default.to_owned()); + } + } + + loop { + let choice = self.prompt(prompt)?.trim().to_owned(); + if choice.is_empty() { + if let Some(default) = default { + return Ok(default.to_owned()); + } + } + if choices.iter().any(|c| choice == c.as_ref()) { + return Ok(choice); + } + + writeln!(self.warning(), "unrecognized response")?; + } + } + + /// Prompts for a yes-or-no response, with yes = true and no = false. + pub fn prompt_yes_no(&mut self, prompt: &str, default: Option) -> io::Result { + let default_str = match &default { + Some(true) => "(Yn)", + Some(false) => "(yN)", + None => "(yn)", + }; + let default_choice = default.map(|c| if c { "Y" } else { "N" }); + + let choice = self.prompt_choice( + &format!("{} {}", prompt, default_str), + &["y", "n", "yes", "no", "Yes", "No", "YES", "NO"], + default_choice, + )?; + Ok(choice.to_lowercase().starts_with(['y', 'Y'])) + } + pub fn prompt_password(&mut self, prompt: &str) -> io::Result { if !io::stdout().is_terminal() { return Err(io::Error::new( diff --git a/cli/tests/common/mod.rs b/cli/tests/common/mod.rs index fc77250b7c..c9bad7ea6c 100644 --- a/cli/tests/common/mod.rs +++ b/cli/tests/common/mod.rs @@ -141,15 +141,45 @@ impl TestEnvironment { cmd } - /// Run a `jj` command, check that it was successful, and return its - /// `(stdout, stderr)`. - pub fn jj_cmd_ok(&self, current_dir: &Path, args: &[&str]) -> (String, String) { - let assert = self.jj_cmd(current_dir, args).assert().success(); + pub fn write_stdin(&self, cmd: &mut assert_cmd::Command, stdin: &str) { + cmd.env("JJ_INTERACTIVE", "1"); + cmd.write_stdin(stdin); + } + + pub fn jj_cmd_stdin( + &self, + current_dir: &Path, + args: &[&str], + stdin: &str, + ) -> assert_cmd::Command { + let mut cmd = self.jj_cmd(current_dir, args); + self.write_stdin(&mut cmd, stdin); + + cmd + } + + fn get_ok(&self, mut cmd: assert_cmd::Command) -> (String, String) { + let assert = cmd.assert().success(); let stdout = self.normalize_output(&get_stdout_string(&assert)); let stderr = self.normalize_output(&get_stderr_string(&assert)); (stdout, stderr) } + /// Run a `jj` command, check that it was successful, and return its + /// `(stdout, stderr)`. + pub fn jj_cmd_ok(&self, current_dir: &Path, args: &[&str]) -> (String, String) { + self.get_ok(self.jj_cmd(current_dir, args)) + } + + pub fn jj_cmd_stdin_ok( + &self, + current_dir: &Path, + args: &[&str], + stdin: &str, + ) -> (String, String) { + self.get_ok(self.jj_cmd_stdin(current_dir, args, stdin)) + } + /// Run a `jj` command, check that it was successful, and return its stdout pub fn jj_cmd_success(&self, current_dir: &Path, args: &[&str]) -> String { if self.debug_allow_stderr { diff --git a/cli/tests/test_next_prev_commands.rs b/cli/tests/test_next_prev_commands.rs index ec58196cf6..3059cf6c79 100644 --- a/cli/tests/test_next_prev_commands.rs +++ b/cli/tests/test_next_prev_commands.rs @@ -13,7 +13,7 @@ // limitations under the License. // -use crate::common::TestEnvironment; +use crate::common::{get_stderr_string, get_stdout_string, TestEnvironment}; pub mod common; @@ -117,23 +117,16 @@ fn test_next_exceeding_history() { } #[test] -fn test_next_fails_on_branching_children() { - // TODO(#2126): Fix this behavior +fn test_next_fails_on_merge_commit() { 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"); - // Create a main branch for this test - test_env.jj_cmd_ok(&repo_path, &["branch", "create", "main"]); + test_env.jj_cmd_ok(&repo_path, &["branch", "c", "left"]); test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "first"]); + test_env.jj_cmd_ok(&repo_path, &["co", "@--"]); + test_env.jj_cmd_ok(&repo_path, &["branch", "c", "right"]); test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "second"]); - test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "third"]); - // Create a branching child. - test_env.jj_cmd_ok(&repo_path, &["branch", "c", "into-the-future"]); - test_env.jj_cmd_ok(&repo_path, &["co", "into-the-future"]); - test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "42"]); - test_env.jj_cmd_ok(&repo_path, &["co", "main"]); - // Make the default branch have two possible children. - test_env.jj_cmd_ok(&repo_path, &["new", "into-the-future", "@-"]); + test_env.jj_cmd_ok(&repo_path, &["new", "left", "right"]); // Try to advance the working copy commit. let stderr = test_env.jj_cmd_failure(&repo_path, &["next"]); insta::assert_snapshot!(stderr,@r###" @@ -142,26 +135,125 @@ fn test_next_fails_on_branching_children() { } #[test] -fn test_prev_fails_on_multiple_parents() { - // TODO(#2126): Fix this behavior +fn test_next_fails_on_branching_children_no_stdin() { 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", "first"]); test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "second"]); + test_env.jj_cmd_ok(&repo_path, &["co", "@--"]); test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "third"]); - test_env.jj_cmd_ok(&repo_path, &["branch", "c", "all-about"]); - test_env.jj_cmd_ok(&repo_path, &["co", "all-about"]); - test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "soname"]); - // Create a merge commit, which has two parents. - test_env.jj_cmd_ok(&repo_path, &["new", "@-", "@--"]); - // We have more than one parent, prev fails. + test_env.jj_cmd_ok(&repo_path, &["co", "@--"]); + + // Try to advance the working copy commit. + let assert = test_env.jj_cmd(&repo_path, &["next"]).assert().code(255); + let stderr = test_env.normalize_output(&get_stderr_string(&assert)); + insta::assert_snapshot!(stderr,@r###" + Internal error: I/O error: Cannot prompt for input since the output is not connected to a terminal + "###); +} + +#[test] +fn test_next_fails_on_branching_children_quit_prompt() { + 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", "first"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "second"]); + test_env.jj_cmd_ok(&repo_path, &["co", "@--"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "third"]); + test_env.jj_cmd_ok(&repo_path, &["co", "@--"]); + + // Try to advance the working copy commit. + let assert = test_env + .jj_cmd_stdin(&repo_path, &["next"], "q\n") + .assert() + .code(1); + let stdout = test_env.normalize_output(&get_stdout_string(&assert)); + let stderr = test_env.normalize_output(&get_stderr_string(&assert)); + insta::assert_snapshot!(stdout,@r###" + ambiguous next commit, choose one to target: + 1: zsuskuln 40a959a0 (empty) third + 2: rlvkpnrz 5c52832c (empty) second + q: quit the prompt + enter the index of the commit you want to target: + "###); + insta::assert_snapshot!(stderr,@r###" + Error: ambiguous target commit + "###); +} + +#[test] +fn test_next_choose_branching_child() { + 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", "first"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "second"]); + test_env.jj_cmd_ok(&repo_path, &["co", "@--"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "third"]); + test_env.jj_cmd_ok(&repo_path, &["co", "@--"]); + // Advance the working copy commit. + let (stdout, stderr) = test_env.jj_cmd_stdin_ok(&repo_path, &["next"], "1\n"); + insta::assert_snapshot!(stdout,@r###" + ambiguous next commit, choose one to target: + 1: zsuskuln 40a959a0 (empty) third + 2: rlvkpnrz 5c52832c (empty) second + q: quit the prompt + enter the index of the commit you want to target: + "###); + insta::assert_snapshot!(stderr,@r###" + Working copy now at: yqosqzyt f9f7101a (empty) (no description set) + Parent commit : zsuskuln 40a959a0 (empty) third + "###); +} + +#[test] +fn test_prev_fails_on_merge_commit() { + 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, &["branch", "c", "left"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "first"]); + test_env.jj_cmd_ok(&repo_path, &["co", "@--"]); + test_env.jj_cmd_ok(&repo_path, &["branch", "c", "right"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "second"]); + test_env.jj_cmd_ok(&repo_path, &["new", "left", "right"]); + // Try to advance the working copy commit. let stderr = test_env.jj_cmd_failure(&repo_path, &["prev"]); insta::assert_snapshot!(stderr,@r###" Error: Cannot run `jj prev` on a merge commit "###); } +#[test] +fn test_prev_fails_on_multiple_parents() { + 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, &["branch", "c", "left"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "first"]); + test_env.jj_cmd_ok(&repo_path, &["co", "@--"]); + test_env.jj_cmd_ok(&repo_path, &["branch", "c", "right"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "second"]); + // Create a merge commit, which has two parents. + test_env.jj_cmd_ok(&repo_path, &["new", "left", "right"]); + test_env.jj_cmd_ok(&repo_path, &["commit", "-m", "merge"]); + // Advance the working copy commit. + let (stdout, stderr) = test_env.jj_cmd_stdin_ok(&repo_path, &["prev"], "2\n"); + insta::assert_snapshot!(stdout,@r###" + ambiguous prev commit, choose one to target: + 1: zsuskuln edad76e9 right | (empty) second + 2: qpvuntsm 5ae1a6a5 left | (empty) first + q: quit the prompt + enter the index of the commit you want to target: + "###); + insta::assert_snapshot!(stderr,@r###" + Working copy now at: yostqsxw a9de0711 (empty) (no description set) + Parent commit : qpvuntsm 5ae1a6a5 left | (empty) first + "###); +} + #[test] fn test_prev_onto_root_fails() { let test_env = TestEnvironment::default();