From 9dbc1178e7f7fab8a0695b624ed3744ab1a8bf02 Mon Sep 17 00:00:00 2001 From: Florian BRUNIAUX Date: Sat, 31 Jan 2026 23:34:54 +0100 Subject: [PATCH 1/5] feat: shared infrastructure for new commands - Make compact_diff pub(crate) in git.rs for cross-module use - Extract filter_json_string() from json_cmd.rs for reuse - Add ok_confirmation() to utils.rs for write operation confirmations - Add detect_package_manager() and package_manager_exec() to utils.rs Co-Authored-By: Claude Opus 4.5 --- src/git.rs | 2 +- src/json_cmd.rs | 26 ++++++++++---- src/utils.rs | 93 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 8 deletions(-) diff --git a/src/git.rs b/src/git.rs index 89274ca7f..780cd45aa 100644 --- a/src/git.rs +++ b/src/git.rs @@ -90,7 +90,7 @@ fn run_diff(args: &[String], max_lines: Option, verbose: u8) -> Result<() Ok(()) } -fn compact_diff(diff: &str, max_lines: usize) -> String { +pub(crate) fn compact_diff(diff: &str, max_lines: usize) -> String { let mut result = Vec::new(); let mut current_file = String::new(); let mut added = 0; diff --git a/src/json_cmd.rs b/src/json_cmd.rs index b546c604a..406b85abe 100644 --- a/src/json_cmd.rs +++ b/src/json_cmd.rs @@ -1,8 +1,8 @@ +use crate::tracking; use anyhow::{Context, Result}; use serde_json::Value; use std::fs; use std::path::Path; -use crate::tracking; /// Show JSON structure without values pub fn run(file: &Path, max_depth: usize, verbose: u8) -> Result<()> { @@ -13,15 +13,24 @@ pub fn run(file: &Path, max_depth: usize, verbose: u8) -> Result<()> { let content = fs::read_to_string(file) .with_context(|| format!("Failed to read file: {}", file.display()))?; - let value: Value = serde_json::from_str(&content) - .with_context(|| format!("Failed to parse JSON: {}", file.display()))?; - - let schema = extract_schema(&value, 0, max_depth); + let schema = filter_json_string(&content, max_depth)?; println!("{}", schema); - tracking::track(&format!("cat {}", file.display()), "rtk json", &content, &schema); + tracking::track( + &format!("cat {}", file.display()), + "rtk json", + &content, + &schema, + ); Ok(()) } +/// Parse a JSON string and return its schema representation. +/// Useful for piping JSON from other commands (e.g., `gh api`, `curl`). +pub fn filter_json_string(json_str: &str, max_depth: usize) -> Result { + let value: Value = serde_json::from_str(json_str).context("Failed to parse JSON")?; + Ok(extract_schema(&value, 0, max_depth)) +} + fn extract_schema(value: &Value, depth: usize, max_depth: usize) -> String { let indent = " ".repeat(depth); @@ -82,7 +91,10 @@ fn extract_schema(value: &Value, depth: usize, max_depth: usize) -> String { let val_trimmed = val_schema.trim(); // Inline simple types - let is_simple = matches!(val, Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_)); + let is_simple = matches!( + val, + Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) + ); if is_simple { if i < keys.len() - 1 { diff --git a/src/utils.rs b/src/utils.rs index 1551b8c52..b7061cbd2 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -131,6 +131,77 @@ pub fn format_usd(amount: f64) -> String { } } +/// Format a confirmation message: "ok " +/// Used for write operations (merge, create, comment, edit, etc.) +/// +/// # Examples +/// ``` +/// use rtk::utils::ok_confirmation; +/// assert_eq!(ok_confirmation("merged", "#42"), "ok merged #42"); +/// assert_eq!(ok_confirmation("created", "PR #5 https://..."), "ok created PR #5 https://..."); +/// ``` +pub fn ok_confirmation(action: &str, detail: &str) -> String { + if detail.is_empty() { + format!("ok {}", action) + } else { + format!("ok {} {}", action, detail) + } +} + +/// Detect the package manager used in the current directory. +/// Returns "pnpm", "yarn", or "npm" based on lockfile presence. +/// +/// # Examples +/// ```no_run +/// use rtk::utils::detect_package_manager; +/// let pm = detect_package_manager(); +/// // Returns "pnpm" if pnpm-lock.yaml exists, "yarn" if yarn.lock, else "npm" +/// ``` +#[allow(dead_code)] +pub fn detect_package_manager() -> &'static str { + if std::path::Path::new("pnpm-lock.yaml").exists() { + "pnpm" + } else if std::path::Path::new("yarn.lock").exists() { + "yarn" + } else { + "npm" + } +} + +/// Build a Command using the detected package manager's exec mechanism. +/// Returns a Command ready to have tool-specific args appended. +#[allow(dead_code)] +pub fn package_manager_exec(tool: &str) -> Command { + let tool_exists = Command::new("which") + .arg(tool) + .output() + .map(|o| o.status.success()) + .unwrap_or(false); + + if tool_exists { + Command::new(tool) + } else { + let pm = detect_package_manager(); + match pm { + "pnpm" => { + let mut c = Command::new("pnpm"); + c.arg("exec").arg("--").arg(tool); + c + } + "yarn" => { + let mut c = Command::new("yarn"); + c.arg("exec").arg("--").arg(tool); + c + } + _ => { + let mut c = Command::new("npx"); + c.arg("--no-install").arg("--").arg(tool); + c + } + } + } +} + #[cfg(test)] mod tests { use super::*; @@ -240,4 +311,26 @@ mod tests { assert_eq!(format_usd(0.01), "$0.01"); assert_eq!(format_usd(0.009), "$0.0090"); } + + #[test] + fn test_ok_confirmation_with_detail() { + assert_eq!(ok_confirmation("merged", "#42"), "ok merged #42"); + assert_eq!( + ok_confirmation("created", "PR #5 https://github.com/foo/bar/pull/5"), + "ok created PR #5 https://github.com/foo/bar/pull/5" + ); + } + + #[test] + fn test_ok_confirmation_no_detail() { + assert_eq!(ok_confirmation("commented", ""), "ok commented"); + } + + #[test] + fn test_detect_package_manager_default() { + // In the test environment (rtk repo), there's no JS lockfile + // so it should default to "npm" + let pm = detect_package_manager(); + assert!(["pnpm", "yarn", "npm"].contains(&pm)); + } } From bfd5646f4eac32b46dbec05f923352a3e50c19ef Mon Sep 17 00:00:00 2001 From: Florian BRUNIAUX Date: Sat, 31 Jan 2026 23:35:59 +0100 Subject: [PATCH 2/5] feat: cargo build/test/clippy with compact output - cargo build: strip Compiling/Downloading lines, show errors + summary - cargo test: show failures only + summary line - cargo clippy: group warnings by lint rule with locations New module: src/cargo_cmd.rs with 6 unit tests Co-Authored-By: Claude Opus 4.5 --- src/cargo_cmd.rs | 503 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.rs | 41 ++++ 2 files changed, 544 insertions(+) create mode 100644 src/cargo_cmd.rs diff --git a/src/cargo_cmd.rs b/src/cargo_cmd.rs new file mode 100644 index 000000000..2c885c56e --- /dev/null +++ b/src/cargo_cmd.rs @@ -0,0 +1,503 @@ +use crate::tracking; +use crate::utils::truncate; +use anyhow::{Context, Result}; +use std::collections::HashMap; +use std::process::Command; + +#[derive(Debug, Clone)] +pub enum CargoCommand { + Build, + Test, + Clippy, +} + +pub fn run(cmd: CargoCommand, args: &[String], verbose: u8) -> Result<()> { + match cmd { + CargoCommand::Build => run_build(args, verbose), + CargoCommand::Test => run_test(args, verbose), + CargoCommand::Clippy => run_clippy(args, verbose), + } +} + +fn run_build(args: &[String], verbose: u8) -> Result<()> { + let mut cmd = Command::new("cargo"); + cmd.arg("build"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: cargo build {}", args.join(" ")); + } + + let output = cmd.output().context("Failed to run cargo build")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + let filtered = filter_cargo_build(&raw); + println!("{}", filtered); + + tracking::track( + &format!("cargo build {}", args.join(" ")), + &format!("rtk cargo build {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +fn run_test(args: &[String], verbose: u8) -> Result<()> { + let mut cmd = Command::new("cargo"); + cmd.arg("test"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: cargo test {}", args.join(" ")); + } + + let output = cmd.output().context("Failed to run cargo test")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + let filtered = filter_cargo_test(&raw); + println!("{}", filtered); + + tracking::track( + &format!("cargo test {}", args.join(" ")), + &format!("rtk cargo test {}", args.join(" ")), + &raw, + &filtered, + ); + + std::process::exit(output.status.code().unwrap_or(1)); +} + +fn run_clippy(args: &[String], verbose: u8) -> Result<()> { + let mut cmd = Command::new("cargo"); + cmd.arg("clippy"); + for arg in args { + cmd.arg(arg); + } + + if verbose > 0 { + eprintln!("Running: cargo clippy {}", args.join(" ")); + } + + let output = cmd.output().context("Failed to run cargo clippy")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + let filtered = filter_cargo_clippy(&raw); + println!("{}", filtered); + + tracking::track( + &format!("cargo clippy {}", args.join(" ")), + &format!("rtk cargo clippy {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +/// Filter cargo build output - strip "Compiling" lines, keep errors + summary +fn filter_cargo_build(output: &str) -> String { + let mut errors: Vec = Vec::new(); + let mut warnings = 0; + let mut error_count = 0; + let mut compiled = 0; + let mut in_error = false; + let mut current_error = Vec::new(); + + for line in output.lines() { + if line.trim_start().starts_with("Compiling") { + compiled += 1; + continue; + } + if line.trim_start().starts_with("Downloading") + || line.trim_start().starts_with("Downloaded") + { + continue; + } + if line.trim_start().starts_with("Finished") { + continue; + } + + // Detect error/warning blocks + if line.starts_with("error[") || line.starts_with("error:") { + // Skip "error: aborting due to" summary lines + if line.contains("aborting due to") || line.contains("could not compile") { + continue; + } + if in_error && !current_error.is_empty() { + errors.push(current_error.join("\n")); + current_error.clear(); + } + error_count += 1; + in_error = true; + current_error.push(line.to_string()); + } else if line.starts_with("warning:") + && line.contains("generated") + && line.contains("warning") + { + // "warning: `crate` generated N warnings" summary line + continue; + } else if line.starts_with("warning:") || line.starts_with("warning[") { + if in_error && !current_error.is_empty() { + errors.push(current_error.join("\n")); + current_error.clear(); + } + warnings += 1; + in_error = true; + current_error.push(line.to_string()); + } else if in_error { + if line.trim().is_empty() && current_error.len() > 3 { + errors.push(current_error.join("\n")); + current_error.clear(); + in_error = false; + } else { + current_error.push(line.to_string()); + } + } + } + + if !current_error.is_empty() { + errors.push(current_error.join("\n")); + } + + if error_count == 0 && warnings == 0 { + return format!("✓ cargo build ({} crates compiled)", compiled); + } + + let mut result = String::new(); + result.push_str(&format!( + "cargo build: {} errors, {} warnings ({} crates)\n", + error_count, warnings, compiled + )); + result.push_str("═══════════════════════════════════════\n"); + + for (i, err) in errors.iter().enumerate().take(15) { + result.push_str(err); + result.push('\n'); + if i < errors.len() - 1 { + result.push('\n'); + } + } + + if errors.len() > 15 { + result.push_str(&format!("\n... +{} more issues\n", errors.len() - 15)); + } + + result.trim().to_string() +} + +/// Filter cargo test output - show failures + summary only +fn filter_cargo_test(output: &str) -> String { + let mut failures: Vec = Vec::new(); + let mut summary_lines: Vec = Vec::new(); + let mut in_failure_section = false; + let mut current_failure = Vec::new(); + + for line in output.lines() { + // Skip compilation lines + if line.trim_start().starts_with("Compiling") + || line.trim_start().starts_with("Downloading") + || line.trim_start().starts_with("Downloaded") + || line.trim_start().starts_with("Finished") + { + continue; + } + + // Skip "running N tests" and individual "test ... ok" lines + if line.starts_with("running ") || (line.starts_with("test ") && line.ends_with("... ok")) { + continue; + } + + // Detect failures section + if line == "failures:" { + in_failure_section = true; + continue; + } + + if in_failure_section { + if line.starts_with("test result:") { + in_failure_section = false; + summary_lines.push(line.to_string()); + } else if line.starts_with(" ") || line.starts_with("---- ") { + current_failure.push(line.to_string()); + } else if line.trim().is_empty() && !current_failure.is_empty() { + failures.push(current_failure.join("\n")); + current_failure.clear(); + } else if !line.trim().is_empty() { + current_failure.push(line.to_string()); + } + } + + // Capture test result summary + if !in_failure_section && line.starts_with("test result:") { + summary_lines.push(line.to_string()); + } + } + + if !current_failure.is_empty() { + failures.push(current_failure.join("\n")); + } + + let mut result = String::new(); + + if failures.is_empty() && !summary_lines.is_empty() { + // All passed + for line in &summary_lines { + result.push_str(&format!("✓ {}\n", line)); + } + return result.trim().to_string(); + } + + if !failures.is_empty() { + result.push_str(&format!("FAILURES ({}):\n", failures.len())); + result.push_str("═══════════════════════════════════════\n"); + for (i, failure) in failures.iter().enumerate().take(10) { + result.push_str(&format!("{}. {}\n", i + 1, truncate(failure, 200))); + } + if failures.len() > 10 { + result.push_str(&format!("\n... +{} more failures\n", failures.len() - 10)); + } + result.push('\n'); + } + + for line in &summary_lines { + result.push_str(&format!("{}\n", line)); + } + + if result.trim().is_empty() { + // Fallback: show last meaningful lines + let meaningful: Vec<&str> = output + .lines() + .filter(|l| !l.trim().is_empty() && !l.trim_start().starts_with("Compiling")) + .collect(); + for line in meaningful.iter().rev().take(5).rev() { + result.push_str(&format!("{}\n", line)); + } + } + + result.trim().to_string() +} + +/// Filter cargo clippy output - group warnings by lint rule +fn filter_cargo_clippy(output: &str) -> String { + let mut by_rule: HashMap> = HashMap::new(); + let mut error_count = 0; + let mut warning_count = 0; + + // Parse clippy output lines + // Format: "warning: description\n --> file:line:col\n |\n | code\n" + let mut current_rule = String::new(); + + for line in output.lines() { + // Skip compilation lines + if line.trim_start().starts_with("Compiling") + || line.trim_start().starts_with("Checking") + || line.trim_start().starts_with("Downloading") + || line.trim_start().starts_with("Downloaded") + || line.trim_start().starts_with("Finished") + { + continue; + } + + // "warning: unused variable [unused_variables]" or "warning: description [clippy::rule_name]" + if (line.starts_with("warning:") || line.starts_with("warning[")) + || (line.starts_with("error:") || line.starts_with("error[")) + { + // Skip summary lines: "warning: `rtk` (bin) generated 5 warnings" + if line.contains("generated") && line.contains("warning") { + continue; + } + // Skip "error: aborting" / "error: could not compile" + if line.contains("aborting due to") || line.contains("could not compile") { + continue; + } + + let is_error = line.starts_with("error"); + if is_error { + error_count += 1; + } else { + warning_count += 1; + } + + // Extract rule name from brackets + current_rule = if let Some(bracket_start) = line.rfind('[') { + if let Some(bracket_end) = line.rfind(']') { + line[bracket_start + 1..bracket_end].to_string() + } else { + line.to_string() + } + } else { + // No bracket: use the message itself as the rule + let prefix = if is_error { "error: " } else { "warning: " }; + line.strip_prefix(prefix).unwrap_or(line).to_string() + }; + } else if line.trim_start().starts_with("--> ") { + let location = line.trim_start().trim_start_matches("--> ").to_string(); + if !current_rule.is_empty() { + by_rule + .entry(current_rule.clone()) + .or_default() + .push(location); + } + } + } + + if error_count == 0 && warning_count == 0 { + return "✓ cargo clippy: No issues found".to_string(); + } + + let mut result = String::new(); + result.push_str(&format!( + "cargo clippy: {} errors, {} warnings\n", + error_count, warning_count + )); + result.push_str("═══════════════════════════════════════\n"); + + // Sort rules by frequency + let mut rule_counts: Vec<_> = by_rule.iter().collect(); + rule_counts.sort_by(|a, b| b.1.len().cmp(&a.1.len())); + + for (rule, locations) in rule_counts.iter().take(15) { + result.push_str(&format!(" {} ({}x)\n", rule, locations.len())); + for loc in locations.iter().take(3) { + result.push_str(&format!(" {}\n", loc)); + } + if locations.len() > 3 { + result.push_str(&format!(" ... +{} more\n", locations.len() - 3)); + } + } + + if by_rule.len() > 15 { + result.push_str(&format!("\n... +{} more rules\n", by_rule.len() - 15)); + } + + result.trim().to_string() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_filter_cargo_build_success() { + let output = r#" Compiling libc v0.2.153 + Compiling cfg-if v1.0.0 + Compiling rtk v0.5.0 + Finished dev [unoptimized + debuginfo] target(s) in 15.23s +"#; + let result = filter_cargo_build(output); + assert!(result.contains("✓ cargo build")); + assert!(result.contains("3 crates compiled")); + } + + #[test] + fn test_filter_cargo_build_errors() { + let output = r#" Compiling rtk v0.5.0 +error[E0308]: mismatched types + --> src/main.rs:10:5 + | +10| "hello" + | ^^^^^^^ expected `i32`, found `&str` + +error: aborting due to 1 previous error +"#; + let result = filter_cargo_build(output); + assert!(result.contains("1 errors")); + assert!(result.contains("E0308")); + assert!(result.contains("mismatched types")); + } + + #[test] + fn test_filter_cargo_test_all_pass() { + let output = r#" Compiling rtk v0.5.0 + Finished test [unoptimized + debuginfo] target(s) in 2.53s + Running target/debug/deps/rtk-abc123 + +running 15 tests +test utils::tests::test_truncate_short_string ... ok +test utils::tests::test_truncate_long_string ... ok +test utils::tests::test_strip_ansi_simple ... ok + +test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.01s +"#; + let result = filter_cargo_test(output); + assert!(result.contains("✓ test result: ok. 15 passed")); + assert!(!result.contains("Compiling")); + assert!(!result.contains("test utils")); + } + + #[test] + fn test_filter_cargo_test_failures() { + let output = r#"running 5 tests +test foo::test_a ... ok +test foo::test_b ... FAILED +test foo::test_c ... ok + +failures: + +---- foo::test_b stdout ---- +thread 'foo::test_b' panicked at 'assert_eq!(1, 2)' + +failures: + foo::test_b + +test result: FAILED. 4 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out +"#; + let result = filter_cargo_test(output); + assert!(result.contains("FAILURES")); + assert!(result.contains("test_b")); + assert!(result.contains("test result:")); + } + + #[test] + fn test_filter_cargo_clippy_clean() { + let output = r#" Checking rtk v0.5.0 + Finished dev [unoptimized + debuginfo] target(s) in 1.53s +"#; + let result = filter_cargo_clippy(output); + assert!(result.contains("✓ cargo clippy: No issues found")); + } + + #[test] + fn test_filter_cargo_clippy_warnings() { + let output = r#" Checking rtk v0.5.0 +warning: unused variable: `x` [unused_variables] + --> src/main.rs:10:9 + | +10| let x = 5; + | ^ help: if this is intentional, prefix it with an underscore: `_x` + +warning: this function has too many arguments [clippy::too_many_arguments] + --> src/git.rs:16:1 + | +16| pub fn run(a: i32, b: i32, c: i32, d: i32, e: i32, f: i32, g: i32, h: i32) {} + | + +warning: `rtk` (bin) generated 2 warnings + Finished dev [unoptimized + debuginfo] target(s) in 1.53s +"#; + let result = filter_cargo_clippy(output); + assert!(result.contains("0 errors, 2 warnings")); + assert!(result.contains("unused_variables")); + assert!(result.contains("clippy::too_many_arguments")); + } +} diff --git a/src/main.rs b/src/main.rs index e8c73019f..036df6b03 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod cargo_cmd; mod cc_economics; mod ccusage; mod config; @@ -357,6 +358,12 @@ enum Commands { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, + + /// Cargo commands with compact output + Cargo { + #[command(subcommand)] + command: CargoCommands, + }, } #[derive(Subcommand)] @@ -512,6 +519,28 @@ enum PrismaMigrateCommands { }, } +#[derive(Subcommand)] +enum CargoCommands { + /// Build with compact output (strip Compiling lines, keep errors) + Build { + /// Additional cargo build arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Test with failures-only output + Test { + /// Additional cargo test arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Clippy with warnings grouped by lint rule + Clippy { + /// Additional cargo clippy arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, +} + fn main() -> Result<()> { let cli = Cli::parse(); @@ -823,6 +852,18 @@ fn main() -> Result<()> { Commands::Playwright { args } => { playwright_cmd::run(&args, cli.verbose)?; } + + Commands::Cargo { command } => match command { + CargoCommands::Build { args } => { + cargo_cmd::run(cargo_cmd::CargoCommand::Build, &args, cli.verbose)?; + } + CargoCommands::Test { args } => { + cargo_cmd::run(cargo_cmd::CargoCommand::Test, &args, cli.verbose)?; + } + CargoCommands::Clippy { args } => { + cargo_cmd::run(cargo_cmd::CargoCommand::Clippy, &args, cli.verbose)?; + } + }, } Ok(()) From bc31da8ad9d9e91eee8af8020e5bd7008da95dd2 Mon Sep 17 00:00:00 2001 From: Florian BRUNIAUX Date: Sat, 31 Jan 2026 23:36:44 +0100 Subject: [PATCH 3/5] feat: git branch, fetch, stash, worktree commands - git branch: compact listing (current/local/remote-only) - git fetch: "ok fetched (N new refs)" confirmation - git stash: list/show/pop/apply/drop with compact output - git worktree: compact listing with home dir abbreviation 4 new tests in git.rs Co-Authored-By: Claude Opus 4.5 --- src/git.rs | 432 +++++++++++++++++++++++++++++++++++++++++++++++++--- src/main.rs | 43 ++++++ 2 files changed, 457 insertions(+), 18 deletions(-) diff --git a/src/git.rs b/src/git.rs index 780cd45aa..7a8e1a514 100644 --- a/src/git.rs +++ b/src/git.rs @@ -1,6 +1,6 @@ +use crate::tracking; use anyhow::{Context, Result}; use std::process::Command; -use crate::tracking; #[derive(Debug, Clone)] pub enum GitCommand { @@ -11,6 +11,10 @@ pub enum GitCommand { Commit { message: String }, Push, Pull, + Branch, + Fetch, + Stash { subcommand: Option }, + Worktree, } pub fn run(cmd: GitCommand, args: &[String], max_lines: Option, verbose: u8) -> Result<()> { @@ -22,12 +26,18 @@ pub fn run(cmd: GitCommand, args: &[String], max_lines: Option, verbose: GitCommand::Commit { message } => run_commit(&message, verbose), GitCommand::Push => run_push(verbose), GitCommand::Pull => run_pull(verbose), + GitCommand::Branch => run_branch(args, verbose), + GitCommand::Fetch => run_fetch(args, verbose), + GitCommand::Stash { subcommand } => run_stash(subcommand.as_deref(), args, verbose), + GitCommand::Worktree => run_worktree(args, verbose), } } fn run_diff(args: &[String], max_lines: Option, verbose: u8) -> Result<()> { // Check if user wants stat output - let wants_stat = args.iter().any(|arg| arg == "--stat" || arg == "--numstat" || arg == "--shortstat"); + let wants_stat = args + .iter() + .any(|arg| arg == "--stat" || arg == "--numstat" || arg == "--shortstat"); // Check if user wants compact diff (default RTK behavior) let wants_compact = !args.iter().any(|arg| arg == "--no-compact"); @@ -105,11 +115,7 @@ pub(crate) fn compact_diff(diff: &str, max_lines: usize) -> String { if !current_file.is_empty() && (added > 0 || removed > 0) { result.push(format!(" +{} -{}", added, removed)); } - current_file = line - .split(" b/") - .nth(1) - .unwrap_or("unknown") - .to_string(); + current_file = line.split(" b/").nth(1).unwrap_or("unknown").to_string(); result.push(format!("\n📄 {}", current_file)); added = 0; removed = 0; @@ -166,13 +172,13 @@ fn run_log(args: &[String], _max_lines: Option, verbose: u8) -> Result<() // Check if user provided format flags let has_format_flag = args.iter().any(|arg| { - arg.starts_with("--oneline") - || arg.starts_with("--pretty") - || arg.starts_with("--format") + arg.starts_with("--oneline") || arg.starts_with("--pretty") || arg.starts_with("--format") }); // Check if user provided limit flag - let has_limit_flag = args.iter().any(|arg| arg.starts_with('-') && arg.chars().nth(1).map_or(false, |c| c.is_ascii_digit())); + let has_limit_flag = args.iter().any(|arg| { + arg.starts_with('-') && arg.chars().nth(1).map_or(false, |c| c.is_ascii_digit()) + }); // Apply RTK defaults only if user didn't specify them if !has_format_flag { @@ -184,7 +190,9 @@ fn run_log(args: &[String], _max_lines: Option, verbose: u8) -> Result<() } // Only add --no-merges if user didn't explicitly request merge commits - let wants_merges = args.iter().any(|arg| arg == "--merges" || arg == "--min-parents=2"); + let wants_merges = args + .iter() + .any(|arg| arg == "--merges" || arg == "--min-parents=2"); if !wants_merges { cmd.arg("--no-merges"); } @@ -232,7 +240,12 @@ fn run_status(_verbose: u8) -> Result<()> { if lines.is_empty() { println!("Clean working tree"); - tracking::track("git status", "rtk git status", &raw_output, "Clean working tree"); + tracking::track( + "git status", + "rtk git status", + &raw_output, + "Clean working tree", + ); return Ok(()); } @@ -320,7 +333,10 @@ fn run_status(_verbose: u8) -> Result<()> { } // Estimate output size for tracking - let rtk_output = format!("branch + {} staged + {} modified + {} untracked", staged, modified, untracked); + let rtk_output = format!( + "branch + {} staged + {} modified + {} untracked", + staged, modified, untracked + ); tracking::track("git status", "rtk git status", &raw_output, &rtk_output); Ok(()) @@ -443,7 +459,7 @@ fn run_push(verbose: u8) -> Result<()> { if line.contains("->") { let parts: Vec<&str> = line.split_whitespace().collect(); if parts.len() >= 3 { - let msg = format!("ok ✓ {}", parts[parts.len()-1]); + let msg = format!("ok ✓ {}", parts[parts.len() - 1]); println!("{}", msg); tracking::track("git push", "rtk git push", &raw, &msg); return Ok(()); @@ -494,11 +510,23 @@ fn run_pull(verbose: u8) -> Result<()> { for part in line.split(',') { let part = part.trim(); if part.contains("file") { - files = part.split_whitespace().next().and_then(|n| n.parse().ok()).unwrap_or(0); + files = part + .split_whitespace() + .next() + .and_then(|n| n.parse().ok()) + .unwrap_or(0); } else if part.contains("insertion") { - insertions = part.split_whitespace().next().and_then(|n| n.parse().ok()).unwrap_or(0); + insertions = part + .split_whitespace() + .next() + .and_then(|n| n.parse().ok()) + .unwrap_or(0); } else if part.contains("deletion") { - deletions = part.split_whitespace().next().and_then(|n| n.parse().ok()).unwrap_or(0); + deletions = part + .split_whitespace() + .next() + .and_then(|n| n.parse().ok()) + .unwrap_or(0); } } } @@ -523,6 +551,334 @@ fn run_pull(verbose: u8) -> Result<()> { Ok(()) } +fn run_branch(args: &[String], verbose: u8) -> Result<()> { + if verbose > 0 { + eprintln!("git branch"); + } + + let mut cmd = Command::new("git"); + cmd.arg("branch"); + + // If user passes flags like -d, -D, -m, pass through directly + let has_action_flag = args + .iter() + .any(|a| a == "-d" || a == "-D" || a == "-m" || a == "-M" || a == "-c" || a == "-C"); + + if has_action_flag { + for arg in args { + cmd.arg(arg); + } + let output = cmd.output().context("Failed to run git branch")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + if output.status.success() { + println!("ok ✓"); + } else { + eprintln!("FAILED: git branch"); + if !stderr.trim().is_empty() { + eprintln!("{}", stderr); + } + if !stdout.trim().is_empty() { + eprintln!("{}", stdout); + } + } + return Ok(()); + } + + // List mode: show compact branch list + cmd.arg("-a").arg("--no-color"); + for arg in args { + cmd.arg(arg); + } + + let output = cmd.output().context("Failed to run git branch")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let raw = stdout.to_string(); + + let filtered = filter_branch_output(&stdout); + println!("{}", filtered); + + tracking::track("git branch -a", "rtk git branch", &raw, &filtered); + + Ok(()) +} + +fn filter_branch_output(output: &str) -> String { + let mut current = String::new(); + let mut local: Vec = Vec::new(); + let mut remote: Vec = Vec::new(); + + for line in output.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + + if let Some(branch) = line.strip_prefix("* ") { + current = branch.to_string(); + } else if line.starts_with("remotes/origin/") { + let branch = line.strip_prefix("remotes/origin/").unwrap_or(line); + // Skip HEAD pointer + if branch.starts_with("HEAD ") { + continue; + } + remote.push(branch.to_string()); + } else { + local.push(line.to_string()); + } + } + + let mut result = Vec::new(); + result.push(format!("* {}", current)); + + if !local.is_empty() { + for b in &local { + result.push(format!(" {}", b)); + } + } + + if !remote.is_empty() { + // Filter out remotes that already exist locally + let remote_only: Vec<&String> = remote + .iter() + .filter(|r| *r != ¤t && !local.contains(r)) + .collect(); + if !remote_only.is_empty() { + result.push(format!(" remote-only ({}):", remote_only.len())); + for b in remote_only.iter().take(10) { + result.push(format!(" {}", b)); + } + if remote_only.len() > 10 { + result.push(format!(" ... +{} more", remote_only.len() - 10)); + } + } + } + + result.join("\n") +} + +fn run_fetch(args: &[String], verbose: u8) -> Result<()> { + if verbose > 0 { + eprintln!("git fetch"); + } + + let mut cmd = Command::new("git"); + cmd.arg("fetch"); + for arg in args { + cmd.arg(arg); + } + + let output = cmd.output().context("Failed to run git fetch")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}{}", stdout, stderr); + + if !output.status.success() { + eprintln!("FAILED: git fetch"); + if !stderr.trim().is_empty() { + eprintln!("{}", stderr); + } + return Ok(()); + } + + // Count new refs from stderr (git fetch outputs to stderr) + let new_refs: usize = stderr + .lines() + .filter(|l| l.contains("->") || l.contains("[new")) + .count(); + + let msg = if new_refs > 0 { + format!("ok fetched ({} new refs)", new_refs) + } else { + "ok fetched".to_string() + }; + + println!("{}", msg); + tracking::track("git fetch", "rtk git fetch", &raw, &msg); + + Ok(()) +} + +fn run_stash(subcommand: Option<&str>, args: &[String], verbose: u8) -> Result<()> { + if verbose > 0 { + eprintln!("git stash {:?}", subcommand); + } + + match subcommand { + Some("list") => { + let output = Command::new("git") + .args(["stash", "list"]) + .output() + .context("Failed to run git stash list")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let raw = stdout.to_string(); + + if stdout.trim().is_empty() { + let msg = "No stashes"; + println!("{}", msg); + tracking::track("git stash list", "rtk git stash list", &raw, msg); + return Ok(()); + } + + let filtered = filter_stash_list(&stdout); + println!("{}", filtered); + tracking::track("git stash list", "rtk git stash list", &raw, &filtered); + } + Some("show") => { + let mut cmd = Command::new("git"); + cmd.args(["stash", "show", "-p"]); + for arg in args { + cmd.arg(arg); + } + let output = cmd.output().context("Failed to run git stash show")?; + let stdout = String::from_utf8_lossy(&output.stdout); + + if stdout.trim().is_empty() { + println!("Empty stash"); + } else { + let compacted = compact_diff(&stdout, 100); + println!("{}", compacted); + } + } + Some("pop") | Some("apply") | Some("drop") | Some("push") => { + let sub = subcommand.unwrap(); + let mut cmd = Command::new("git"); + cmd.args(["stash", sub]); + for arg in args { + cmd.arg(arg); + } + let output = cmd.output().context("Failed to run git stash")?; + if output.status.success() { + println!("ok stash {}", sub); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("FAILED: git stash {}", sub); + if !stderr.trim().is_empty() { + eprintln!("{}", stderr); + } + } + } + _ => { + // Default: git stash (push) + let mut cmd = Command::new("git"); + cmd.arg("stash"); + for arg in args { + cmd.arg(arg); + } + let output = cmd.output().context("Failed to run git stash")?; + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + if stdout.contains("No local changes") { + println!("ok (nothing to stash)"); + } else { + println!("ok stashed"); + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("FAILED: git stash"); + if !stderr.trim().is_empty() { + eprintln!("{}", stderr); + } + } + } + } + + Ok(()) +} + +fn filter_stash_list(output: &str) -> String { + // Format: "stash@{0}: WIP on main: abc1234 commit message" + let mut result = Vec::new(); + for line in output.lines() { + if let Some(colon_pos) = line.find(": ") { + let index = &line[..colon_pos]; + let rest = &line[colon_pos + 2..]; + // Compact: strip "WIP on branch:" prefix if present + let message = if let Some(second_colon) = rest.find(": ") { + rest[second_colon + 2..].trim() + } else { + rest.trim() + }; + result.push(format!("{}: {}", index, message)); + } else { + result.push(line.to_string()); + } + } + result.join("\n") +} + +fn run_worktree(args: &[String], verbose: u8) -> Result<()> { + if verbose > 0 { + eprintln!("git worktree list"); + } + + // If args contain "add", "remove", "prune" etc., pass through + let has_action = args.iter().any(|a| { + a == "add" || a == "remove" || a == "prune" || a == "lock" || a == "unlock" || a == "move" + }); + + if has_action { + let mut cmd = Command::new("git"); + cmd.arg("worktree"); + for arg in args { + cmd.arg(arg); + } + let output = cmd.output().context("Failed to run git worktree")?; + if output.status.success() { + println!("ok ✓"); + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("FAILED: git worktree {}", args.join(" ")); + if !stderr.trim().is_empty() { + eprintln!("{}", stderr); + } + } + return Ok(()); + } + + // Default: list mode + let output = Command::new("git") + .args(["worktree", "list"]) + .output() + .context("Failed to run git worktree list")?; + + let stdout = String::from_utf8_lossy(&output.stdout); + let raw = stdout.to_string(); + + let filtered = filter_worktree_list(&stdout); + println!("{}", filtered); + tracking::track("git worktree list", "rtk git worktree", &raw, &filtered); + + Ok(()) +} + +fn filter_worktree_list(output: &str) -> String { + let home = dirs::home_dir() + .map(|h| h.to_string_lossy().to_string()) + .unwrap_or_default(); + + let mut result = Vec::new(); + for line in output.lines() { + if line.trim().is_empty() { + continue; + } + // Format: "/path/to/worktree abc1234 [branch]" + let parts: Vec<&str> = line.split_whitespace().collect(); + if parts.len() >= 3 { + let mut path = parts[0].to_string(); + if !home.is_empty() && path.starts_with(&home) { + path = format!("~{}", &path[home.len()..]); + } + let hash = parts[1]; + let branch = parts[2..].join(" "); + result.push(format!("{} {} {}", path, hash, branch)); + } else { + result.push(line.to_string()); + } + } + result.join("\n") +} + #[cfg(test)] mod tests { use super::*; @@ -541,4 +897,44 @@ mod tests { assert!(result.contains("foo.rs")); assert!(result.contains("+")); } + + #[test] + fn test_filter_branch_output() { + let output = "* main\n feature/auth\n fix/bug-123\n remotes/origin/HEAD -> origin/main\n remotes/origin/main\n remotes/origin/feature/auth\n remotes/origin/release/v2\n"; + let result = filter_branch_output(output); + assert!(result.contains("* main")); + assert!(result.contains("feature/auth")); + assert!(result.contains("fix/bug-123")); + // remote-only should show release/v2 but not main or feature/auth (already local) + assert!(result.contains("remote-only")); + assert!(result.contains("release/v2")); + } + + #[test] + fn test_filter_branch_no_remotes() { + let output = "* main\n develop\n"; + let result = filter_branch_output(output); + assert!(result.contains("* main")); + assert!(result.contains("develop")); + assert!(!result.contains("remote-only")); + } + + #[test] + fn test_filter_stash_list() { + let output = + "stash@{0}: WIP on main: abc1234 fix login\nstash@{1}: On feature: def5678 wip\n"; + let result = filter_stash_list(output); + assert!(result.contains("stash@{0}: abc1234 fix login")); + assert!(result.contains("stash@{1}: def5678 wip")); + } + + #[test] + fn test_filter_worktree_list() { + let output = + "/home/user/project abc1234 [main]\n/home/user/worktrees/feat def5678 [feature]\n"; + let result = filter_worktree_list(output); + assert!(result.contains("abc1234")); + assert!(result.contains("[main]")); + assert!(result.contains("[feature]")); + } } diff --git a/src/main.rs b/src/main.rs index 036df6b03..f3cd84a32 100644 --- a/src/main.rs +++ b/src/main.rs @@ -398,6 +398,32 @@ enum GitCommands { Push, /// Pull → "ok ✓ " Pull, + /// Compact branch listing (current/local/remote) + Branch { + /// Git branch arguments (supports -d, -D, -m, etc.) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Fetch → "ok fetched (N new refs)" + Fetch { + /// Git fetch arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Stash management (list, show, pop, apply, drop) + Stash { + /// Subcommand: list, show, pop, apply, drop, push + subcommand: Option, + /// Additional arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Compact worktree listing + Worktree { + /// Git worktree arguments (add, remove, prune, or empty for list) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, } #[derive(Subcommand)] @@ -593,6 +619,23 @@ fn main() -> Result<()> { GitCommands::Pull => { git::run(git::GitCommand::Pull, &[], None, cli.verbose)?; } + GitCommands::Branch { args } => { + git::run(git::GitCommand::Branch, &args, None, cli.verbose)?; + } + GitCommands::Fetch { args } => { + git::run(git::GitCommand::Fetch, &args, None, cli.verbose)?; + } + GitCommands::Stash { subcommand, args } => { + git::run( + git::GitCommand::Stash { subcommand }, + &args, + None, + cli.verbose, + )?; + } + GitCommands::Worktree { args } => { + git::run(git::GitCommand::Worktree, &args, None, cli.verbose)?; + } }, Commands::Gh { subcommand, args } => { From 517a93d0e4497414efe7486410c72afdad5f8a26 Mon Sep 17 00:00:00 2001 From: Florian BRUNIAUX Date: Sat, 31 Jan 2026 23:37:17 +0100 Subject: [PATCH 4/5] feat: gh pr create/merge/diff/comment/edit + gh api - gh pr create: capture URL + number, "ok created #N url" - gh pr merge: "ok merged #N" confirmation - gh pr diff: reuse compact_diff() for condensed output - gh pr comment/edit: generic "ok {action} #N" confirmations - gh api: auto-detect JSON, pipe through filter_json_string() 5 new tests in gh_cmd.rs Co-Authored-By: Claude Opus 4.5 --- src/gh_cmd.rs | 338 ++++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 300 insertions(+), 38 deletions(-) diff --git a/src/gh_cmd.rs b/src/gh_cmd.rs index 0e815bf22..abad7204c 100644 --- a/src/gh_cmd.rs +++ b/src/gh_cmd.rs @@ -3,6 +3,9 @@ //! Provides token-optimized alternatives to verbose `gh` commands. //! Focuses on extracting essential information from JSON outputs. +use crate::git; +use crate::json_cmd; +use crate::utils::ok_confirmation; use anyhow::{Context, Result}; use serde_json::Value; use std::process::Command; @@ -14,6 +17,7 @@ pub fn run(subcommand: &str, args: &[String], verbose: u8, ultra_compact: bool) "issue" => run_issue(args, verbose, ultra_compact), "run" => run_workflow(args, verbose, ultra_compact), "repo" => run_repo(args, verbose, ultra_compact), + "api" => run_api(args, verbose), _ => { // Unknown subcommand, pass through run_passthrough("gh", subcommand, args) @@ -31,13 +35,23 @@ fn run_pr(args: &[String], verbose: u8, ultra_compact: bool) -> Result<()> { "view" => view_pr(&args[1..], verbose, ultra_compact), "checks" => pr_checks(&args[1..], verbose, ultra_compact), "status" => pr_status(verbose, ultra_compact), + "create" => pr_create(&args[1..], verbose), + "merge" => pr_merge(&args[1..], verbose), + "diff" => pr_diff(&args[1..], verbose), + "comment" => pr_action("commented", &args[1..], verbose), + "edit" => pr_action("edited", &args[1..], verbose), _ => run_passthrough("gh", "pr", args), } } fn list_prs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { let mut cmd = Command::new("gh"); - cmd.args(["pr", "list", "--json", "number,title,state,author,updatedAt"]); + cmd.args([ + "pr", + "list", + "--json", + "number,title,state,author,updatedAt", + ]); // Pass through additional flags for arg in args { @@ -51,8 +65,8 @@ fn list_prs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { std::process::exit(output.status.code().unwrap_or(1)); } - let json: Value = serde_json::from_slice(&output.stdout) - .context("Failed to parse gh pr list output")?; + let json: Value = + serde_json::from_slice(&output.stdout).context("Failed to parse gh pr list output")?; if let Some(prs) = json.as_array() { if ultra_compact { @@ -83,7 +97,13 @@ fn list_prs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { } }; - println!(" {} #{} {} ({})", state_icon, number, truncate(title, 60), author); + println!( + " {} #{} {} ({})", + state_icon, + number, + truncate(title, 60), + author + ); } if prs.len() > 20 { @@ -103,8 +123,11 @@ fn view_pr(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { let mut cmd = Command::new("gh"); cmd.args([ - "pr", "view", pr_number, - "--json", "number,title,state,author,body,url,mergeable,reviews,statusCheckRollup" + "pr", + "view", + pr_number, + "--json", + "number,title,state,author,body,url,mergeable,reviews,statusCheckRollup", ]); let output = cmd.output().context("Failed to run gh pr view")?; @@ -114,8 +137,8 @@ fn view_pr(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { std::process::exit(output.status.code().unwrap_or(1)); } - let json: Value = serde_json::from_slice(&output.stdout) - .context("Failed to parse gh pr view output")?; + let json: Value = + serde_json::from_slice(&output.stdout).context("Failed to parse gh pr view output")?; // Extract essential info let number = json["number"].as_i64().unwrap_or(0); @@ -152,23 +175,40 @@ fn view_pr(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { // Show reviews summary if let Some(reviews) = json["reviews"]["nodes"].as_array() { - let approved = reviews.iter().filter(|r| r["state"].as_str() == Some("APPROVED")).count(); - let changes = reviews.iter().filter(|r| r["state"].as_str() == Some("CHANGES_REQUESTED")).count(); + let approved = reviews + .iter() + .filter(|r| r["state"].as_str() == Some("APPROVED")) + .count(); + let changes = reviews + .iter() + .filter(|r| r["state"].as_str() == Some("CHANGES_REQUESTED")) + .count(); if approved > 0 || changes > 0 { - println!(" Reviews: {} approved, {} changes requested", approved, changes); + println!( + " Reviews: {} approved, {} changes requested", + approved, changes + ); } } // Show checks summary if let Some(checks) = json["statusCheckRollup"].as_array() { let total = checks.len(); - let passed = checks.iter().filter(|c| { - c["conclusion"].as_str() == Some("SUCCESS") || c["state"].as_str() == Some("SUCCESS") - }).count(); - let failed = checks.iter().filter(|c| { - c["conclusion"].as_str() == Some("FAILURE") || c["state"].as_str() == Some("FAILURE") - }).count(); + let passed = checks + .iter() + .filter(|c| { + c["conclusion"].as_str() == Some("SUCCESS") + || c["state"].as_str() == Some("SUCCESS") + }) + .count(); + let failed = checks + .iter() + .filter(|c| { + c["conclusion"].as_str() == Some("FAILURE") + || c["state"].as_str() == Some("FAILURE") + }) + .count(); if ultra_compact { if failed > 0 { @@ -259,7 +299,12 @@ fn pr_checks(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> fn pr_status(_verbose: u8, _ultra_compact: bool) -> Result<()> { let mut cmd = Command::new("gh"); - cmd.args(["pr", "status", "--json", "currentBranch,createdBy,reviewDecision,statusCheckRollup"]); + cmd.args([ + "pr", + "status", + "--json", + "currentBranch,createdBy,reviewDecision,statusCheckRollup", + ]); let output = cmd.output().context("Failed to run gh pr status")?; @@ -268,8 +313,8 @@ fn pr_status(_verbose: u8, _ultra_compact: bool) -> Result<()> { std::process::exit(output.status.code().unwrap_or(1)); } - let json: Value = serde_json::from_slice(&output.stdout) - .context("Failed to parse gh pr status output")?; + let json: Value = + serde_json::from_slice(&output.stdout).context("Failed to parse gh pr status output")?; if let Some(created_by) = json["createdBy"].as_array() { println!("📝 Your PRs ({}):", created_by.len()); @@ -311,8 +356,8 @@ fn list_issues(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> std::process::exit(output.status.code().unwrap_or(1)); } - let json: Value = serde_json::from_slice(&output.stdout) - .context("Failed to parse gh issue list output")?; + let json: Value = + serde_json::from_slice(&output.stdout).context("Failed to parse gh issue list output")?; if let Some(issues) = json.as_array() { if ultra_compact { @@ -326,9 +371,17 @@ fn list_issues(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> let state = issue["state"].as_str().unwrap_or("???"); let icon = if ultra_compact { - if state == "OPEN" { "O" } else { "C" } + if state == "OPEN" { + "O" + } else { + "C" + } } else { - if state == "OPEN" { "🟢" } else { "🔴" } + if state == "OPEN" { + "🟢" + } else { + "🔴" + } }; println!(" {} #{} {}", icon, number, truncate(title, 60)); } @@ -349,7 +402,13 @@ fn view_issue(args: &[String], _verbose: u8) -> Result<()> { let issue_number = &args[0]; let mut cmd = Command::new("gh"); - cmd.args(["issue", "view", issue_number, "--json", "number,title,state,author,body,url"]); + cmd.args([ + "issue", + "view", + issue_number, + "--json", + "number,title,state,author,body,url", + ]); let output = cmd.output().context("Failed to run gh issue view")?; @@ -358,8 +417,8 @@ fn view_issue(args: &[String], _verbose: u8) -> Result<()> { std::process::exit(output.status.code().unwrap_or(1)); } - let json: Value = serde_json::from_slice(&output.stdout) - .context("Failed to parse gh issue view output")?; + let json: Value = + serde_json::from_slice(&output.stdout).context("Failed to parse gh issue view output")?; let number = json["number"].as_i64().unwrap_or(0); let title = json["title"].as_str().unwrap_or("???"); @@ -402,7 +461,12 @@ fn run_workflow(args: &[String], verbose: u8, ultra_compact: bool) -> Result<()> fn list_runs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { let mut cmd = Command::new("gh"); - cmd.args(["run", "list", "--json", "databaseId,name,status,conclusion,createdAt"]); + cmd.args([ + "run", + "list", + "--json", + "databaseId,name,status,conclusion,createdAt", + ]); cmd.arg("--limit").arg("10"); for arg in args { @@ -416,8 +480,8 @@ fn list_runs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { std::process::exit(output.status.code().unwrap_or(1)); } - let json: Value = serde_json::from_slice(&output.stdout) - .context("Failed to parse gh run list output")?; + let json: Value = + serde_json::from_slice(&output.stdout).context("Failed to parse gh run list output")?; if let Some(runs) = json.as_array() { if ultra_compact { @@ -436,14 +500,26 @@ fn list_runs(args: &[String], _verbose: u8, ultra_compact: bool) -> Result<()> { "success" => "✓", "failure" => "✗", "cancelled" => "X", - _ => if status == "in_progress" { "~" } else { "?" }, + _ => { + if status == "in_progress" { + "~" + } else { + "?" + } + } } } else { match conclusion { "success" => "✅", "failure" => "❌", "cancelled" => "🚫", - _ => if status == "in_progress" { "⏳" } else { "⚪" }, + _ => { + if status == "in_progress" { + "⏳" + } else { + "⚪" + } + } } }; @@ -501,7 +577,7 @@ fn view_run(args: &[String], _verbose: u8) -> Result<()> { fn run_repo(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> { // Parse subcommand (default to "view") let (subcommand, rest_args) = if args.is_empty() { - ("view", &args[..]) + ("view", args) } else { (args[0].as_str(), &args[1..]) }; @@ -517,7 +593,10 @@ fn run_repo(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> { cmd.arg(arg); } - cmd.args(["--json", "name,owner,description,url,stargazerCount,forkCount,isPrivate"]); + cmd.args([ + "--json", + "name,owner,description,url,stargazerCount,forkCount,isPrivate", + ]); let output = cmd.output().context("Failed to run gh repo view")?; @@ -526,8 +605,8 @@ fn run_repo(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> { std::process::exit(output.status.code().unwrap_or(1)); } - let json: Value = serde_json::from_slice(&output.stdout) - .context("Failed to parse gh repo view output")?; + let json: Value = + serde_json::from_slice(&output.stdout).context("Failed to parse gh repo view output")?; let name = json["name"].as_str().unwrap_or("???"); let owner = json["owner"]["login"].as_str().unwrap_or("???"); @@ -537,7 +616,11 @@ fn run_repo(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> { let forks = json["forkCount"].as_i64().unwrap_or(0); let private = json["isPrivate"].as_bool().unwrap_or(false); - let visibility = if private { "🔒 Private" } else { "🌐 Public" }; + let visibility = if private { + "🔒 Private" + } else { + "🌐 Public" + }; println!("📦 {}/{}", owner, name); println!(" {}", visibility); @@ -550,6 +633,157 @@ fn run_repo(args: &[String], _verbose: u8, _ultra_compact: bool) -> Result<()> { Ok(()) } +fn pr_create(args: &[String], _verbose: u8) -> Result<()> { + let mut cmd = Command::new("gh"); + cmd.args(["pr", "create"]); + for arg in args { + cmd.arg(arg); + } + + let output = cmd.output().context("Failed to run gh pr create")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + eprintln!("{}", stderr.trim()); + std::process::exit(output.status.code().unwrap_or(1)); + } + + // gh pr create outputs the URL on success + let url = stdout.trim(); + + // Try to extract PR number from URL (e.g., https://github.com/owner/repo/pull/42) + let pr_num = url.rsplit('/').next().unwrap_or(""); + + let detail = if !pr_num.is_empty() && pr_num.chars().all(|c| c.is_ascii_digit()) { + format!("#{} {}", pr_num, url) + } else { + url.to_string() + }; + + println!("{}", ok_confirmation("created", &detail)); + Ok(()) +} + +fn pr_merge(args: &[String], _verbose: u8) -> Result<()> { + let mut cmd = Command::new("gh"); + cmd.args(["pr", "merge"]); + for arg in args { + cmd.arg(arg); + } + + let output = cmd.output().context("Failed to run gh pr merge")?; + let stderr = String::from_utf8_lossy(&output.stderr); + + if !output.status.success() { + eprintln!("{}", stderr.trim()); + std::process::exit(output.status.code().unwrap_or(1)); + } + + // Extract PR number from args (first non-flag arg) + let pr_num = args + .iter() + .find(|a| !a.starts_with('-')) + .map(|s| s.as_str()) + .unwrap_or(""); + + let detail = if !pr_num.is_empty() { + format!("#{}", pr_num) + } else { + String::new() + }; + + println!("{}", ok_confirmation("merged", &detail)); + Ok(()) +} + +fn pr_diff(args: &[String], _verbose: u8) -> Result<()> { + let mut cmd = Command::new("gh"); + cmd.args(["pr", "diff"]); + for arg in args { + cmd.arg(arg); + } + + let output = cmd.output().context("Failed to run gh pr diff")?; + let stdout = String::from_utf8_lossy(&output.stdout); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("{}", stderr.trim()); + std::process::exit(output.status.code().unwrap_or(1)); + } + + if stdout.trim().is_empty() { + println!("No diff"); + } else { + let compacted = git::compact_diff(&stdout, 100); + println!("{}", compacted); + } + + Ok(()) +} + +/// Generic PR action handler for comment/edit +fn pr_action(action: &str, args: &[String], _verbose: u8) -> Result<()> { + let mut cmd = Command::new("gh"); + cmd.args(["pr", action]); + for arg in args { + cmd.arg(arg); + } + + let output = cmd + .output() + .context(format!("Failed to run gh pr {}", action))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("{}", stderr.trim()); + std::process::exit(output.status.code().unwrap_or(1)); + } + + // Extract PR number from args + let pr_num = args + .iter() + .find(|a| !a.starts_with('-')) + .map(|s| format!("#{}", s)) + .unwrap_or_default(); + + println!("{}", ok_confirmation(action, &pr_num)); + Ok(()) +} + +fn run_api(args: &[String], _verbose: u8) -> Result<()> { + let mut cmd = Command::new("gh"); + cmd.arg("api"); + for arg in args { + cmd.arg(arg); + } + + let output = cmd.output().context("Failed to run gh api")?; + let stdout = String::from_utf8_lossy(&output.stdout); + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + eprintln!("{}", stderr.trim()); + std::process::exit(output.status.code().unwrap_or(1)); + } + + // Try to parse as JSON and filter + match json_cmd::filter_json_string(&stdout, 5) { + Ok(schema) => println!("{}", schema), + Err(_) => { + // Not JSON, print truncated raw output + let lines: Vec<&str> = stdout.lines().take(20).collect(); + println!("{}", lines.join("\n")); + if stdout.lines().count() > 20 { + println!("... (truncated)"); + } + } + } + + Ok(()) +} + fn run_passthrough(cmd: &str, subcommand: &str, args: &[String]) -> Result<()> { let mut command = Command::new(cmd); command.arg(subcommand); @@ -579,6 +813,34 @@ mod tests { #[test] fn test_truncate() { assert_eq!(truncate("short", 10), "short"); - assert_eq!(truncate("this is a very long string", 15), "this is a ve..."); + assert_eq!( + truncate("this is a very long string", 15), + "this is a ve..." + ); + } + + #[test] + fn test_ok_confirmation_pr_create() { + let result = ok_confirmation("created", "#42 https://github.com/foo/bar/pull/42"); + assert!(result.contains("ok created")); + assert!(result.contains("#42")); + } + + #[test] + fn test_ok_confirmation_pr_merge() { + let result = ok_confirmation("merged", "#42"); + assert_eq!(result, "ok merged #42"); + } + + #[test] + fn test_ok_confirmation_pr_comment() { + let result = ok_confirmation("commented", "#42"); + assert_eq!(result, "ok commented #42"); + } + + #[test] + fn test_ok_confirmation_pr_edit() { + let result = ok_confirmation("edited", "#42"); + assert_eq!(result, "ok edited #42"); } } From 49b3cf293d856ff3001c46cff8fee9de9ef501c5 Mon Sep 17 00:00:00 2001 From: Florian BRUNIAUX Date: Sat, 31 Jan 2026 23:39:03 +0100 Subject: [PATCH 5/5] feat: npm/npx routing, pnpm build/typecheck, --skip-env flag - npm run: filter boilerplate (> script, npm WARN, progress) - npx: intelligent routing to specialized filters (tsc, eslint, prisma, next, prettier, playwright) - pnpm build: delegates to next_cmd filter - pnpm typecheck: delegates to tsc_cmd filter - --skip-env global flag: propagates SKIP_ENV_VALIDATION=1 New module: src/npm_cmd.rs with 2 unit tests Co-Authored-By: Claude Opus 4.5 --- src/main.rs | 109 +++++++++++++++++++++++++++++++++++++++++++++++- src/npm_cmd.rs | 107 +++++++++++++++++++++++++++++++++++++++++++++++ src/pnpm_cmd.rs | 45 ++++++++++++-------- 3 files changed, 242 insertions(+), 19 deletions(-) create mode 100644 src/npm_cmd.rs diff --git a/src/main.rs b/src/main.rs index f3cd84a32..bb45563db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,6 +20,7 @@ mod local_llm; mod log_cmd; mod ls; mod next_cmd; +mod npm_cmd; mod playwright_cmd; mod pnpm_cmd; mod prettier_cmd; @@ -33,7 +34,7 @@ mod utils; mod vitest_cmd; mod wget_cmd; -use anyhow::Result; +use anyhow::{Context, Result}; use clap::{Parser, Subcommand}; use std::path::PathBuf; @@ -55,6 +56,10 @@ struct Cli { /// Ultra-compact mode: ASCII icons, inline format (Level 2 optimizations) #[arg(short = 'u', long, global = true)] ultra_compact: bool, + + /// Set SKIP_ENV_VALIDATION=1 for child processes (Next.js, tsc, lint, prisma) + #[arg(long = "skip-env", global = true)] + skip_env: bool, } #[derive(Subcommand)] @@ -364,6 +369,20 @@ enum Commands { #[command(subcommand)] command: CargoCommands, }, + + /// npm run with filtered output (strip boilerplate) + Npm { + /// npm run arguments (script name + options) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + + /// npx with intelligent routing (tsc, eslint, prisma -> specialized filters) + Npx { + /// npx arguments (command + options) + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, } #[derive(Subcommand)] @@ -451,6 +470,18 @@ enum PnpmCommands { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, + /// Build (delegates to next build filter) + Build { + /// Additional build arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, + /// Typecheck (delegates to tsc filter) + Typecheck { + /// Additional typecheck arguments + #[arg(trailing_var_arg = true, allow_hyphen_values = true)] + args: Vec, + }, } #[derive(Subcommand)] @@ -656,6 +687,12 @@ fn main() -> Result<()> { cli.verbose, )?; } + PnpmCommands::Build { args } => { + next_cmd::run(&args, cli.verbose)?; + } + PnpmCommands::Typecheck { args } => { + tsc_cmd::run(&args, cli.verbose)?; + } }, Commands::Err { command } => { @@ -907,6 +944,76 @@ fn main() -> Result<()> { cargo_cmd::run(cargo_cmd::CargoCommand::Clippy, &args, cli.verbose)?; } }, + + Commands::Npm { args } => { + npm_cmd::run(&args, cli.verbose, cli.skip_env)?; + } + + Commands::Npx { args } => { + if args.is_empty() { + anyhow::bail!("npx requires a command argument"); + } + + // Intelligent routing: delegate to specialized filters + match args[0].as_str() { + "tsc" | "typescript" => { + tsc_cmd::run(&args[1..], cli.verbose)?; + } + "eslint" => { + lint_cmd::run(&args[1..], cli.verbose)?; + } + "prisma" => { + // Route to prisma_cmd based on subcommand + if args.len() > 1 { + let prisma_args: Vec = args[2..].to_vec(); + match args[1].as_str() { + "generate" => { + prisma_cmd::run( + prisma_cmd::PrismaCommand::Generate, + &prisma_args, + cli.verbose, + )?; + } + "db" if args.len() > 2 && args[2] == "push" => { + prisma_cmd::run( + prisma_cmd::PrismaCommand::DbPush, + &args[3..], + cli.verbose, + )?; + } + _ => { + // Passthrough other prisma subcommands + let mut cmd = std::process::Command::new("npx"); + for arg in &args { + cmd.arg(arg); + } + let status = cmd.status().context("Failed to run npx prisma")?; + std::process::exit(status.code().unwrap_or(1)); + } + } + } else { + let status = std::process::Command::new("npx") + .arg("prisma") + .status() + .context("Failed to run npx prisma")?; + std::process::exit(status.code().unwrap_or(1)); + } + } + "next" => { + next_cmd::run(&args[1..], cli.verbose)?; + } + "prettier" => { + prettier_cmd::run(&args[1..], cli.verbose)?; + } + "playwright" => { + playwright_cmd::run(&args[1..], cli.verbose)?; + } + _ => { + // Generic passthrough with npm boilerplate filter + npm_cmd::run(&args, cli.verbose, cli.skip_env)?; + } + } + } } Ok(()) diff --git a/src/npm_cmd.rs b/src/npm_cmd.rs new file mode 100644 index 000000000..99e7dd0aa --- /dev/null +++ b/src/npm_cmd.rs @@ -0,0 +1,107 @@ +use crate::tracking; +use anyhow::{Context, Result}; +use std::process::Command; + +pub fn run(args: &[String], verbose: u8, skip_env: bool) -> Result<()> { + let mut cmd = Command::new("npm"); + cmd.arg("run"); + + for arg in args { + cmd.arg(arg); + } + + if skip_env { + cmd.env("SKIP_ENV_VALIDATION", "1"); + } + + if verbose > 0 { + eprintln!("Running: npm run {}", args.join(" ")); + } + + let output = cmd.output().context("Failed to run npm run")?; + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let raw = format!("{}\n{}", stdout, stderr); + + let filtered = filter_npm_output(&raw); + println!("{}", filtered); + + tracking::track( + &format!("npm run {}", args.join(" ")), + &format!("rtk npm run {}", args.join(" ")), + &raw, + &filtered, + ); + + if !output.status.success() { + std::process::exit(output.status.code().unwrap_or(1)); + } + + Ok(()) +} + +/// Filter npm run output - strip boilerplate, progress bars, npm WARN +fn filter_npm_output(output: &str) -> String { + let mut result = Vec::new(); + + for line in output.lines() { + // Skip npm boilerplate + if line.starts_with('>') && line.contains('@') { + continue; + } + // Skip npm lifecycle scripts + if line.trim_start().starts_with("npm WARN") { + continue; + } + if line.trim_start().starts_with("npm notice") { + continue; + } + // Skip progress indicators + if line.contains("⸩") || line.contains("⸨") || line.contains("...") && line.len() < 10 { + continue; + } + // Skip empty lines + if line.trim().is_empty() { + continue; + } + + result.push(line.to_string()); + } + + if result.is_empty() { + "ok ✓".to_string() + } else { + result.join("\n") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_filter_npm_output() { + let output = r#" +> project@1.0.0 build +> next build + +npm WARN deprecated inflight@1.0.6: This module is not supported +npm notice + + Creating an optimized production build... + ✓ Build completed +"#; + let result = filter_npm_output(output); + assert!(!result.contains("npm WARN")); + assert!(!result.contains("npm notice")); + assert!(!result.contains("> project@")); + assert!(result.contains("Build completed")); + } + + #[test] + fn test_filter_npm_output_empty() { + let output = "\n\n\n"; + let result = filter_npm_output(output); + assert_eq!(result, "ok ✓"); + } +} diff --git a/src/pnpm_cmd.rs b/src/pnpm_cmd.rs index ab0fbbd84..f421b9334 100644 --- a/src/pnpm_cmd.rs +++ b/src/pnpm_cmd.rs @@ -1,6 +1,6 @@ +use crate::tracking; use anyhow::{Context, Result}; use std::process::Command; -use crate::tracking; /// Validates npm package name according to official rules /// https://docs.npmjs.com/cli/v9/configuring-npm/package-json#name @@ -17,9 +17,8 @@ fn is_valid_package_name(name: &str) -> bool { } // Only safe characters - name.chars().all(|c| { - c.is_alphanumeric() || matches!(c, '@' | '/' | '-' | '_' | '.') - }) + name.chars() + .all(|c| c.is_alphanumeric() || matches!(c, '@' | '/' | '-' | '_' | '.')) } #[derive(Debug, Clone)] @@ -99,12 +98,7 @@ fn run_outdated(args: &[String], verbose: u8) -> Result<()> { println!("{}", filtered); } - tracking::track( - "pnpm outdated", - "rtk pnpm outdated", - &combined, - &filtered, - ); + tracking::track("pnpm outdated", "rtk pnpm outdated", &combined, &filtered); Ok(()) } @@ -113,7 +107,10 @@ fn run_install(packages: &[String], args: &[String], verbose: u8) -> Result<()> // Validate package names to prevent command injection for pkg in packages { if !is_valid_package_name(pkg) { - anyhow::bail!("Invalid package name: '{}' (contains unsafe characters)", pkg); + anyhow::bail!( + "Invalid package name: '{}' (contains unsafe characters)", + pkg + ); } } @@ -161,7 +158,12 @@ fn filter_pnpm_list(output: &str) -> String { for line in output.lines() { // Skip box-drawing characters - if line.contains("│") || line.contains("├") || line.contains("└") || line.contains("┌") || line.contains("┐") { + if line.contains("│") + || line.contains("├") + || line.contains("└") + || line.contains("┌") + || line.contains("┐") + { continue; } @@ -187,14 +189,18 @@ fn filter_pnpm_outdated(output: &str) -> String { for line in output.lines() { // Skip box-drawing characters - if line.contains("│") || line.contains("├") || line.contains("└") - || line.contains("┌") || line.contains("┐") || line.contains("─") { + if line.contains("│") + || line.contains("├") + || line.contains("└") + || line.contains("┌") + || line.contains("┐") + || line.contains("─") + { continue; } // Skip headers and legend - if line.starts_with("Legend:") || line.starts_with("Package") - || line.trim().is_empty() { + if line.starts_with("Legend:") || line.starts_with("Package") || line.trim().is_empty() { continue; } @@ -239,8 +245,11 @@ fn filter_pnpm_install(output: &str) -> String { } // Keep summary lines - if line.contains("packages in") || line.contains("dependencies") - || line.starts_with('+') || line.starts_with('-') { + if line.contains("packages in") + || line.contains("dependencies") + || line.starts_with('+') + || line.starts_with('-') + { result.push(line.trim().to_string()); } }