Skip to content
Open
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
151 changes: 150 additions & 1 deletion src/cmds/git/git.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -24,6 +26,7 @@ pub enum GitCommand {
Pull,
Branch,
Fetch,
Grep,
Stash { subcommand: Option<String> },
Worktree,
}
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -1486,6 +1490,137 @@ fn run_fetch(args: &[String], verbose: u8, global_args: &[String]) -> Result<i32
Ok(0)
}

fn git_grep_passthrough_flag(args: &[String]) -> 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<String> {
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<String, Vec<(usize, String)>> = 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::<usize>() 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<i32> {
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 <subcommand>" format
Expand Down Expand Up @@ -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) <author>\n\n---END---\ndef5678 Another commit (1 week ago) <other>\n\n---END---\n";
Expand Down
16 changes: 16 additions & 0 deletions src/discover/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(
Expand Down
8 changes: 6 additions & 2 deletions src/discover/rules.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,22 @@ 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",
savings_pct: 70.0,
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)",
Expand Down
13 changes: 13 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -854,6 +854,12 @@ enum GitCommands {
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Compact git grep output
Grep {
/// Git grep arguments
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
args: Vec<String>,
},
/// Stash management (list, show, pop, apply, drop)
Stash {
/// Subcommand: list, show, pop, apply, drop, push
Expand Down Expand Up @@ -1651,6 +1657,13 @@ fn run_cli() -> Result<i32> {
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,
Expand Down
56 changes: 56 additions & 0 deletions tests/git_grep_test.rs
Original file line number Diff line number Diff line change
@@ -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"));
}
Loading