diff --git a/Cargo.lock b/Cargo.lock index fa365d1..ccd3bbc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,6 +248,18 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "console" +version = "0.16.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e96a4956774c13c126a8b5af4daa79384f4d826534c95a02d76afb39e2ab64e3" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -278,6 +290,17 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "fuzzy-matcher", + "shell-words", +] + [[package]] name = "diffs" version = "0.7.0" @@ -286,6 +309,8 @@ dependencies = [ "axum", "chrono", "clap", + "console", + "dialoguer", "futures-util", "git2", "hex", @@ -328,6 +353,12 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "equivalent" version = "1.0.2" @@ -425,6 +456,15 @@ dependencies = [ "slab", ] +[[package]] +name = "fuzzy-matcher" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54614a3312934d066701a80f20f15fa3b56d67ac7722b39eea5b4c9dd1d66c94" +dependencies = [ + "thread_local", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -1201,6 +1241,12 @@ dependencies = [ "digest", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -1323,6 +1369,15 @@ dependencies = [ "syn 3.0.6", ] +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + [[package]] name = "tinystr" version = "0.8.4" @@ -1515,6 +1570,12 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "url" version = "2.5.8" diff --git a/Cargo.toml b/Cargo.toml index 2188e7f..cbe97f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,6 +10,8 @@ anyhow = "1" axum = { version = "0.8", features = ["macros"] } chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive", "env"] } +console = { version = "0.16", default-features = false } +dialoguer = { version = "0.12.0", default-features = false, features = ["fuzzy-select"] } futures-util = "0.3" git2 = { version = "0.20", default-features = false, features = ["vendored-libgit2"] } hex = "0.4" diff --git a/README.md b/README.md index eb2c836..c9707c4 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,8 @@ Review a branch against a base locally (the current branch unless a head is give diffs branch # infers the base diffs branch main # explicit base diffs branch main feature # compare two branches without checking out either +diffs branch -i # pick the base from a filterable list +diffs branch main -i # pick the head to compare against main diffs branch --include-dirty # include staged, unstaged, and untracked changes ``` diff --git a/src/cli.rs b/src/cli.rs index f2419ed..7d42319 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -78,6 +78,12 @@ Examples: # Compare two branches without checking either out diffs branch main feature/login + # Pick the base from a filterable list (type to filter, Enter to select) + diffs branch -i + + # Pick the head to compare against main + diffs branch main -i + # Include uncommitted changes diffs branch --include-dirty "; @@ -172,6 +178,9 @@ enum Command { /// Include staged, unstaged, and untracked changes #[arg(long, conflicts_with = "head")] include_dirty: bool, + /// Pick the base (or, when a base is given, the head) from a filterable list + #[arg(short, long, conflicts_with = "head")] + interactive: bool, #[command(flatten)] serve: ServeFlags, }, @@ -265,12 +274,18 @@ pub async fn run(started: Instant) -> anyhow::Result<()> { base, head, include_dirty, + interactive, serve, }) => { // Fail with the formatted git help before inferring a base, so a // non-repo gives the same UX as the local command (not a confusing // "could not infer base ref"). resolve_repo_root_or_help(&cli.dir)?; + let (base, head) = if interactive { + pick_branch_refs(&cli.dir, base).await? + } else { + (base, head) + }; let base = resolve_branch_base(base, &cli.dir).await?; let head = head .map(|value| value.trim().to_string()) @@ -601,13 +616,19 @@ fn browser_url(addr: SocketAddr, target_path: &str) -> String { format!("http://{host}:{}{}", addr.port(), target_path) } -async fn resolve_branch_base(base: Option, dir: &PathBuf) -> anyhow::Result { +async fn resolve_branch_base(base: Option, dir: &Path) -> anyhow::Result { if let Some(base) = base .map(|value| value.trim().to_string()) .filter(|value| !value.is_empty()) { return Ok(base); } + infer_branch_base(dir).await +} + +/// Infers a base ref from the PR base branch, the repository's default branch, +/// or finally main/master, preferring a local branch over its remote-tracking ref. +async fn infer_branch_base(dir: &Path) -> anyhow::Result { for args in [ ["pr", "view", "--json", "baseRefName", "-q", ".baseRefName"], [ @@ -633,6 +654,88 @@ async fn resolve_branch_base(base: Option, dir: &PathBuf) -> anyhow::Res bail!("could not infer base ref; pass one explicitly, e.g. `diffs branch main`") } +/// `--interactive`: fills the missing positional from a fuzzy-filterable list of +/// local and remote-tracking branches. With no base the picker chooses the base +/// (the checked-out branch is left out, the inferred base is preselected); with a +/// base it chooses the head instead (the base is left out, the checked-out branch +/// is preselected). Esc, q, or Ctrl-C exits 1 without printing anything. +async fn pick_branch_refs( + dir: &Path, + base: Option, +) -> anyhow::Result<(Option, Option)> { + if !io::stdin().is_terminal() || !io::stderr().is_terminal() { + bail!("--interactive needs a terminal; pass the base explicitly, e.g. `diffs branch main`"); + } + let base = base + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + let branches = git::list_branches(dir).context("could not list branches")?; + let current = git::branch(dir); + let current = (!current.is_empty()).then_some(current); + match base { + None => { + let inferred = infer_branch_base(dir).await.ok(); + let list = picker_list(branches, current.as_deref(), inferred.as_deref()); + let base = pick_from_list("Base ref", &list)?; + Ok((Some(base), None)) + } + Some(base) => { + let list = picker_list(branches, Some(&base), current.as_deref()); + let head = pick_from_list("Head ref", &list)?; + Ok((Some(base), Some(head))) + } + } +} + +struct PickerList { + items: Vec, + default: usize, +} + +/// Picker entries in `list_branches` order minus `exclude` (comparing a ref +/// against itself is never useful), with `preferred` highlighted when present. +fn picker_list( + branches: Vec, + exclude: Option<&str>, + preferred: Option<&str>, +) -> PickerList { + let items: Vec = branches + .into_iter() + .filter(|name| Some(name.as_str()) != exclude) + .collect(); + let default = preferred + .and_then(|preferred| items.iter().position(|name| name == preferred)) + .unwrap_or(0); + PickerList { items, default } +} + +fn pick_from_list(prompt: &str, list: &PickerList) -> anyhow::Result { + if list.items.is_empty() { + bail!("no branches to pick from"); + } + if !colors_enabled(true) { + console::set_colors_enabled(false); + } + let theme = dialoguer::theme::ColorfulTheme::default(); + let selection = dialoguer::FuzzySelect::with_theme(&theme) + .with_prompt(prompt) + .items(&list.items) + .default(list.default) + .highlight_matches(true) + .max_length(12) + .interact_opt(); + match selection { + Ok(Some(index)) => Ok(list.items[index].clone()), + // Esc or q; dialoguer already cleared the prompt. + Ok(None) => Err(QuietExit.into()), + // Ctrl-C surfaces as an interrupted read rather than a signal. + Err(dialoguer::Error::IO(err)) if err.kind() == io::ErrorKind::Interrupted => { + Err(QuietExit.into()) + } + Err(err) => Err(err).context("branch picker failed"), + } +} + fn branch_target(base: &str, head: Option<&str>, include_dirty: bool) -> String { let mut params = url::form_urlencoded::Serializer::new(String::new()); params.append_pair("base", base); @@ -818,6 +921,20 @@ mod tests { assert!(status.success(), "git {args:?} failed"); } + #[test] + fn picker_list_excludes_ref_and_preselects_preferred() { + let branches = || vec!["feature".to_string(), "main".into(), "origin/main".into()]; + let list = picker_list(branches(), Some("feature"), Some("origin/main")); + assert_eq!(list.items, ["main", "origin/main"]); + assert_eq!(list.default, 1); + // A preferred ref that isn't listed falls back to the first entry. + let list = picker_list(branches(), None, Some("missing")); + assert_eq!(list.items, ["feature", "main", "origin/main"]); + assert_eq!(list.default, 0); + let list = picker_list(branches(), None, None); + assert_eq!(list.default, 0); + } + #[test] fn branch_target_encodes_base_head_and_dirty() { assert_eq!(