Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve prompt support in JJ #2852

Merged
merged 6 commits into from
Jan 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
45 changes: 38 additions & 7 deletions cli/src/commands/next.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand All @@ -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<String> = 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::<usize>().unwrap() - 1])
}

pub(crate) fn cmd_next(
ui: &mut Ui,
command: &CommandHelper,
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion cli/src/commands/prev.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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());
Expand Down
66 changes: 65 additions & 1 deletion cli/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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",
Expand All @@ -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",
));
torquestomp marked this conversation as resolved.
Show resolved Hide resolved
}

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<str>],
default: Option<&str>,
) -> io::Result<String> {
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();
torquestomp marked this conversation as resolved.
Show resolved Hide resolved
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<bool>) -> io::Result<bool> {
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<String> {
if !io::stdout().is_terminal() {
return Err(io::Error::new(
Expand Down
38 changes: 34 additions & 4 deletions cli/tests/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading