From 782439ebfbb29db0649d07d40fed917d29f16bd6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=86=A0=E8=BE=B0?= Date: Wed, 24 Jun 2026 19:56:09 +0800 Subject: [PATCH] feat(git): compact git grep output --- src/cmds/git/git.rs | 151 ++++++++++++++++++++++++++++++++++++++- src/discover/registry.rs | 16 +++++ src/discover/rules.rs | 8 ++- src/main.rs | 13 ++++ tests/git_grep_test.rs | 56 +++++++++++++++ 5 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 tests/git_grep_test.rs diff --git a/src/cmds/git/git.rs b/src/cmds/git/git.rs index 29fd3b73a..8a2f281bc 100644 --- a/src/cmds/git/git.rs +++ b/src/cmds/git/git.rs @@ -1,13 +1,15 @@ //! Filters git output — log, status, diff, and more — keeping just the essential info. use crate::core::args_utils; +use crate::core::config; use crate::core::stream::{ self, exec_capture, CaptureResult, FilterMode, LineHandler, LineStreamFilter, StdinMode, }; use crate::core::tracking; use crate::core::truncate::CAP_WARNINGS; -use crate::core::utils::{exit_code_from_output, exit_code_from_status, resolved_command}; +use crate::core::utils::{exit_code_from_output, exit_code_from_status, resolved_command, truncate}; use anyhow::{Context, Result}; +use std::collections::HashMap; use std::ffi::OsString; use std::process::Command; use std::process::Stdio; @@ -24,6 +26,7 @@ pub enum GitCommand { Pull, Branch, Fetch, + Grep, Stash { subcommand: Option }, Worktree, } @@ -96,6 +99,7 @@ pub fn run( GitCommand::Pull => run_pull(args, verbose, global_args), GitCommand::Branch => run_branch(args, verbose, global_args), GitCommand::Fetch => run_fetch(args, verbose, global_args), + GitCommand::Grep => run_grep(args, verbose, global_args), GitCommand::Stash { subcommand } => { run_stash(subcommand.as_deref(), args, verbose, global_args) } @@ -1486,6 +1490,137 @@ fn run_fetch(args: &[String], verbose: u8, global_args: &[String]) -> Result bool { + args.iter().any(|arg| { + if arg.starts_with("--format") { + return true; + } + if let Some(flags) = arg.strip_prefix('-').filter(|s| !s.starts_with('-')) { + return flags.chars().any(|ch| matches!(ch, 'c' | 'h' | 'l' | 'L' | 'o' | 'z' | 'Z')); + } + matches!( + arg.as_str(), + "-c" | "--count" + | "-h" + | "--no-filename" + | "--name-only" + | "-l" + | "--files-with-matches" + | "-L" + | "--files-without-match" + | "-o" + | "--only-matching" + | "-z" + | "-Z" + | "--null" + | "--heading" + ) + }) +} + +fn format_git_grep_output(output: &str) -> Option { + let max_results = config::limits().grep_max_results; + let per_file = config::limits().grep_max_per_file; + let total_matches = output.lines().count(); + let mut by_file: HashMap> = HashMap::new(); + + for line in output.lines() { + let parts: Vec<&str> = line.splitn(3, ':').collect(); + if parts.len() != 3 { + continue; + } + let Ok(line_num) = parts[1].parse::() else { + continue; + }; + by_file + .entry(parts[0].to_string()) + .or_default() + .push((line_num, truncate(parts[2].trim(), 80))); + } + + if by_file.is_empty() { + return None; + } + + let mut rtk_output = String::new(); + rtk_output.push_str(&format!( + "{} matches in {} files:\n\n", + total_matches, + by_file.len() + )); + + let mut shown = 0; + let mut files: Vec<_> = by_file.iter().collect(); + files.sort_by_key(|(file, _)| *file); + + for (file, matches) in files { + if shown >= max_results { + break; + } + for (line_num, content) in matches.iter().take(per_file) { + if shown >= max_results { + break; + } + rtk_output.push_str(&format!("{}:{}:{}\n", file, line_num, content)); + shown += 1; + } + } + + if total_matches > shown { + rtk_output.push_str(&format!("[+{} more]\n", total_matches - shown)); + } + + Some(rtk_output) +} + +fn run_grep(args: &[String], verbose: u8, global_args: &[String]) -> Result { + let timer = tracking::TimedExecution::start(); + + if verbose > 0 { + eprintln!("git grep"); + } + + if git_grep_passthrough_flag(args) { + let mut cmd = git_cmd(global_args); + cmd.arg("grep").args(args); + let status = cmd.status().context("Failed to run git grep")?; + let args_str = args.join(" "); + timer.track_passthrough( + &format!("git grep {}", args_str), + &format!("rtk git grep {} (passthrough)", args_str), + ); + return Ok(exit_code_from_status(&status, "git grep")); + } + + let mut cmd = git_cmd(global_args); + cmd.arg("grep").arg("-n").arg("--no-color").args(args); + let result = exec_capture(&mut cmd).context("Failed to run git grep")?; + let raw = result.combined(); + + if !result.success() { + if !result.stderr.trim().is_empty() { + eprintln!("{}", result.stderr.trim()); + } + return Ok(result.exit_code); + } + + let Some(filtered) = format_git_grep_output(&result.stdout) else { + print!("{}", result.stdout); + timer.track_passthrough("git grep", "rtk git grep (unparsed passthrough)"); + return Ok(result.exit_code); + }; + + print!("{}", filtered); + timer.track( + &format!("git grep {}", args.join(" ")), + "rtk git grep", + &raw, + &filtered, + ); + + Ok(result.exit_code) +} + /// Format status message for stash operations. /// - For create operations (push/save): checks for "No local changes" /// - For other operations: uses "ok stash " format @@ -2171,6 +2306,20 @@ A added.rs // Compile-time verification that the function exists with correct signature } + #[test] + fn test_git_grep_passthrough_flag_detects_machine_output() { + assert!(git_grep_passthrough_flag(&["-l".to_string()])); + assert!(git_grep_passthrough_flag(&["-lz".to_string()])); + assert!(git_grep_passthrough_flag(&["--format=%(path)".to_string()])); + assert!(git_grep_passthrough_flag(&["--name-only".to_string()])); + assert!(git_grep_passthrough_flag(&["--null".to_string()])); + assert!(!git_grep_passthrough_flag(&[ + "-n".to_string(), + "-i".to_string(), + "alpha".to_string() + ])); + } + #[test] fn test_filter_log_output() { let output = "abc1234 This is a commit message (2 days ago) \n\n---END---\ndef5678 Another commit (1 week ago) \n\n---END---\n"; diff --git a/src/discover/registry.rs b/src/discover/registry.rs index fc18b6be0..bbad1e159 100644 --- a/src/discover/registry.rs +++ b/src/discover/registry.rs @@ -897,6 +897,22 @@ mod tests { ); } + #[test] + fn test_rewrite_git_grep_and_passthrough_git_readers() { + assert_eq!( + rewrite_command_no_prefixes("git grep alpha", &[]), + Some("rtk git grep alpha".to_string()) + ); + assert_eq!( + rewrite_command_no_prefixes("git rev-parse --show-toplevel", &[]), + Some("rtk git rev-parse --show-toplevel".to_string()) + ); + assert_eq!( + rewrite_command_no_prefixes("git ls-files src", &[]), + Some("rtk git ls-files src".to_string()) + ); + } + #[test] fn test_classify_yadm_status() { assert_eq!( diff --git a/src/discover/rules.rs b/src/discover/rules.rs index e9bf1b1f3..dd39ef2fa 100644 --- a/src/discover/rules.rs +++ b/src/discover/rules.rs @@ -12,7 +12,7 @@ pub struct RtkRule { pub const RULES: &[RtkRule] = &[ RtkRule { - pattern: r"^(?:git|yadm)\s+(?:-[Cc]\s+\S+\s+)*(status|log|diff|show|add|commit|push|pull|branch|fetch|stash|worktree)", + pattern: r"^(?:git|yadm)\s+(?:-[Cc]\s+\S+\s+)*(status|log|diff|show|add|commit|push|pull|branch|fetch|stash|worktree|grep|rev-parse|ls-files)", rtk_cmd: "rtk git", rewrite_prefixes: &["git", "yadm"], category: "Git", @@ -20,10 +20,14 @@ pub const RULES: &[RtkRule] = &[ subcmd_savings: &[ ("diff", 80.0), ("show", 80.0), + ("grep", 75.0), ("add", 59.0), ("commit", 59.0), ], - subcmd_status: &[], + subcmd_status: &[ + ("rev-parse", RtkStatus::Passthrough), + ("ls-files", RtkStatus::Passthrough), + ], }, RtkRule { pattern: r"^gh\s+(pr|issue|run|repo|api|release)", diff --git a/src/main.rs b/src/main.rs index c55751dfc..b41c591b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -854,6 +854,12 @@ enum GitCommands { #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, }, + /// Compact git grep output + Grep { + /// Git grep 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 @@ -1651,6 +1657,13 @@ fn run_cli() -> Result { cli.verbose, &global_args, )?, + GitCommands::Grep { args } => git::run( + git::GitCommand::Grep, + &args, + None, + cli.verbose, + &global_args, + )?, GitCommands::Stash { subcommand, args } => git::run( git::GitCommand::Stash { subcommand }, &args, diff --git a/tests/git_grep_test.rs b/tests/git_grep_test.rs new file mode 100644 index 000000000..176be221f --- /dev/null +++ b/tests/git_grep_test.rs @@ -0,0 +1,56 @@ +use std::process::{Command, Output}; + +fn run_rtk(args: &[&str], cwd: &std::path::Path) -> Output { + Command::new(env!("CARGO_BIN_EXE_rtk")) + .args(args) + .current_dir(cwd) + .env("RTK_TELEMETRY_DISABLED", "1") + .output() + .expect("run rtk") +} + +fn git(args: &[&str], cwd: &std::path::Path) { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {:?} failed: {}{}", + args, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); +} + +fn repo_with_file() -> tempfile::TempDir { + let dir = tempfile::tempdir().expect("tempdir"); + std::fs::write(dir.path().join("file.txt"), "alpha one\nbeta\nalpha two\n").unwrap(); + git(&["init"], dir.path()); + git(&["add", "file.txt"], dir.path()); + dir +} + +#[test] +fn git_grep_is_compacted_with_line_numbers() { + let dir = repo_with_file(); + let output = run_rtk(&["git", "grep", "alpha"], dir.path()); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("2 matches in 1 files:"), "{stdout}"); + assert!(stdout.contains("file.txt:1:alpha one"), "{stdout}"); + assert!(stdout.contains("file.txt:3:alpha two"), "{stdout}"); +} + +#[test] +fn git_grep_machine_output_flags_stay_raw() { + let dir = repo_with_file(); + let output = run_rtk(&["git", "grep", "-l", "alpha"], dir.path()); + assert!(output.status.success()); + + let stdout = String::from_utf8_lossy(&output.stdout); + assert_eq!(stdout, "file.txt\n"); + assert!(!stdout.contains("matches in")); +}