//! Per-repo code search with `git grep` at one commit. No index: each query runs git once, //! streams its output, and stops (killing git) after `max_matches` hits. The commit is a validated //! full object id; the query goes after `-e`; the optional glob goes after `--`. use std::path::Path; use std::process::Stdio; use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader}; use crate::command::GitEnv; use crate::error::{Error, Result}; use crate::history::{git, validate_oid}; /// Longest accepted query, in bytes. pub const MAX_QUERY_BYTES: usize = 200; /// Longest accepted path glob, in bytes. const MAX_GLOB_BYTES: usize = 512; /// Longest line text kept in a hit, in characters. const MAX_LINE_CHARS: usize = 500; #[derive(Debug, Clone, PartialEq, Eq)] pub struct GrepOptions { /// Match the query as plain text (`-F`) rather than as an extended regex (`-E`). pub fixed: bool, pub ignore_case: bool, /// Limits the search to paths matching this git pathspec glob. pub path_glob: Option, pub max_matches: usize, pub timeout: Duration, } impl Default for GrepOptions { fn default() -> Self { Self { fixed: true, ignore_case: false, path_glob: None, max_matches: 200, timeout: Duration::from_secs(10), } } } #[derive(Debug, Clone, PartialEq, Eq)] pub struct GrepHit { pub path: String, pub line_no: u32, /// The matching line, cut to 500 characters. pub line: String, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct GrepResult { pub hits: Vec, /// True when more matches existed than `max_matches`. pub truncated: bool, } fn validate_query(query: &str) -> Result<()> { if query.is_empty() || query.len() > MAX_QUERY_BYTES { return Err(Error::InvalidInput(format!( "query must be 1 to {MAX_QUERY_BYTES} bytes" ))); } if query.contains(['\0', '\n', '\r']) { return Err(Error::InvalidInput( "query contains NUL or a newline".into(), )); } Ok(()) } fn validate_glob(glob: &str) -> Result<()> { if glob.is_empty() || glob.len() > MAX_GLOB_BYTES { return Err(Error::InvalidInput(format!( "path glob must be 1 to {MAX_GLOB_BYTES} bytes" ))); } if glob.starts_with('-') || glob.contains("..") { return Err(Error::InvalidInput(format!("unsafe path glob: {glob:?}"))); } if glob.contains(['\0', '\n', '\r']) { return Err(Error::InvalidInput( "path glob contains NUL or a newline".into(), )); } Ok(()) } /// Parses one `--null` record, `:\0\0`, without its trailing newline. fn parse_record(record: &[u8], commit: &str) -> Option { let mut parts = record.splitn(3, |&b| b == 0); let header = parts.next()?; let line_bytes = parts.next()?; let text = parts.next().unwrap_or_default(); let prefix = format!("{commit}:"); let path = header.strip_prefix(prefix.as_bytes())?; let line_no = std::str::from_utf8(line_bytes).ok()?.parse::().ok()?; let text = String::from_utf8_lossy(text); let text = text.strip_suffix('\r').unwrap_or(&text); Some(GrepHit { path: String::from_utf8_lossy(path).into_owned(), line_no, line: text.chars().take(MAX_LINE_CHARS).collect(), }) } /// Searches the tree of `commit` for `query`. Exit code 1 from git (no match) is an empty result. pub async fn grep( env: &GitEnv, repo: &Path, commit: &str, query: &str, opts: GrepOptions, ) -> Result { validate_oid(commit)?; validate_query(query)?; if let Some(glob) = &opts.path_glob { validate_glob(glob)?; } let globs: Vec<&str> = opts.path_glob.as_deref().into_iter().collect(); let mut cmd = git(env, "grep") .git_dir(repo) .arg("-n") .arg("-I") .arg("--no-color") .arg("--null") .arg("--max-count=20") .arg(if opts.fixed { "-F" } else { "-E" }); if opts.ignore_case { cmd = cmd.arg("-i"); } let cmd = cmd.user_pattern(query)?.user_arg(commit)?.paths(&globs); let mut command = cmd.into_tokio(); command.stdout(Stdio::piped()).stderr(Stdio::piped()); let mut child = command.spawn()?; let stdout = child .stdout .take() .ok_or_else(|| Error::Failed("git stdout missing".into()))?; let stderr_task = child.stderr.take().map(|mut e| { tokio::spawn(async move { let mut v = Vec::new(); let _ = e.read_to_end(&mut v).await; v }) }); let max = opts.max_matches; let mut hits = Vec::new(); let mut truncated = false; let reading = async { let mut lines = BufReader::new(stdout); let mut buf = Vec::new(); loop { buf.clear(); if lines.read_until(b'\n', &mut buf).await? == 0 { break; } let record = buf.strip_suffix(b"\n").unwrap_or(&buf); let Some(hit) = parse_record(record, commit) else { continue; }; if hits.len() == max { truncated = true; break; } hits.push(hit); } Ok::<(), Error>(()) }; match tokio::time::timeout(opts.timeout, reading).await { Ok(read) => read?, Err(_) => { let _ = child.start_kill(); let _ = child.wait().await; return Err(Error::Failed(format!( "code search timed out after {} seconds", opts.timeout.as_secs() ))); } } if truncated { let _ = child.start_kill(); } let status = child.wait().await?; let stderr = match stderr_task { Some(task) => task.await.unwrap_or_default(), None => Vec::new(), }; // Git exits 1 when nothing matched. A killed child (truncated) has no meaningful code. if !truncated && !status.success() && status.code() != Some(1) { return Err(Error::Failed(format!( "git grep exited with {status}: {}", String::from_utf8_lossy(&stderr).trim() ))); } Ok(GrepResult { hits, truncated }) } #[cfg(test)] mod tests { use super::*; use std::process::Command; fn env(dir: &Path) -> GitEnv { GitEnv { git_bin: "git".into(), data_dir: dir.to_path_buf(), } } fn git_in(dir: &Path, args: &[&str]) { let status = Command::new("git") .current_dir(dir) .env("GIT_CONFIG_NOSYSTEM", "1") .args([ "-c", "user.name=Test", "-c", "user.email=test@example.com", "-c", "commit.gpgsign=false", ]) .args(args) .status() .expect("git runs"); assert!(status.success(), "git {args:?} failed"); } /// A repo with `src/main.rs`, `docs/Guide.md` and `src/lib.rs`. Returns (git dir, commit oid). fn fixture(dir: &Path) -> (std::path::PathBuf, String) { let work = dir.join("work"); std::fs::create_dir_all(work.join("src")).unwrap(); std::fs::create_dir_all(work.join("docs")).unwrap(); std::fs::write( work.join("src/main.rs"), "fn main() {\n println!(\"Hello\");\n}\nlet a.b = 1;\n", ) .unwrap(); std::fs::write(work.join("src/lib.rs"), "pub fn hello() {}\n").unwrap(); std::fs::write(work.join("docs/Guide.md"), "say hello there\n").unwrap(); git_in(&work, &["init", "-q", "-b", "main"]); git_in(&work, &["add", "-A"]); git_in(&work, &["commit", "-q", "-m", "init"]); let out = Command::new("git") .current_dir(&work) .args(["rev-parse", "HEAD"]) .output() .expect("rev-parse"); let oid = String::from_utf8(out.stdout).unwrap().trim().to_owned(); (work.join(".git"), oid) } #[tokio::test] async fn fixed_search_finds_lines_across_files() { let dir = tempfile::tempdir().unwrap(); let (repo, oid) = fixture(dir.path()); let res = grep(&env(dir.path()), &repo, &oid, "a.b", GrepOptions::default()) .await .unwrap(); // Fixed mode: the dot is literal, so only `a.b` matches. assert_eq!(res.hits.len(), 1); assert_eq!(res.hits[0].path, "src/main.rs"); assert_eq!(res.hits[0].line_no, 4); assert_eq!(res.hits[0].line, "let a.b = 1;"); assert!(!res.truncated); } #[tokio::test] async fn ignore_case_and_regex_mode() { let dir = tempfile::tempdir().unwrap(); let (repo, oid) = fixture(dir.path()); let env = env(dir.path()); let sensitive = grep(&env, &repo, &oid, "hello", GrepOptions::default()) .await .unwrap(); assert_eq!( sensitive.hits.len(), 2, "lib.rs and Guide.md, case-sensitive" ); let opts = GrepOptions { ignore_case: true, ..GrepOptions::default() }; let insensitive = grep(&env, &repo, &oid, "HELLO", opts).await.unwrap(); assert_eq!(insensitive.hits.len(), 3); let regex = GrepOptions { fixed: false, ..GrepOptions::default() }; let res = grep(&env, &repo, &oid, "^pub fn \\w+", regex) .await .unwrap(); assert_eq!(res.hits.len(), 1); assert_eq!(res.hits[0].path, "src/lib.rs"); } #[tokio::test] async fn path_glob_limits_search() { let dir = tempfile::tempdir().unwrap(); let (repo, oid) = fixture(dir.path()); let opts = GrepOptions { path_glob: Some("*.md".into()), ..GrepOptions::default() }; let res = grep(&env(dir.path()), &repo, &oid, "hello", opts) .await .unwrap(); assert_eq!(res.hits.len(), 1); assert_eq!(res.hits[0].path, "docs/Guide.md"); } #[tokio::test] async fn truncates_at_max_matches() { let dir = tempfile::tempdir().unwrap(); let (repo, oid) = fixture(dir.path()); let opts = GrepOptions { ignore_case: true, max_matches: 2, ..GrepOptions::default() }; let res = grep(&env(dir.path()), &repo, &oid, "hello", opts) .await .unwrap(); assert_eq!(res.hits.len(), 2); assert!(res.truncated); } #[tokio::test] async fn no_match_is_empty_not_error() { let dir = tempfile::tempdir().unwrap(); let (repo, oid) = fixture(dir.path()); let res = grep( &env(dir.path()), &repo, &oid, "zzz-not-here", GrepOptions::default(), ) .await .unwrap(); assert!(res.hits.is_empty()); assert!(!res.truncated); } #[tokio::test] async fn query_starting_with_dash_is_a_pattern() { let dir = tempfile::tempdir().unwrap(); let (repo, oid) = fixture(dir.path()); std::fs::write(dir.path().join("work/flags.txt"), "--verbose\n").unwrap(); git_in(&dir.path().join("work"), &["add", "-A"]); git_in(&dir.path().join("work"), &["commit", "-q", "-m", "flags"]); let out = Command::new("git") .current_dir(dir.path().join("work")) .args(["rev-parse", "HEAD"]) .output() .unwrap(); let oid2 = String::from_utf8(out.stdout).unwrap().trim().to_owned(); assert_ne!(oid, oid2); let res = grep( &env(dir.path()), &repo, &oid2, "--verbose", GrepOptions::default(), ) .await .unwrap(); assert_eq!(res.hits.len(), 1); assert_eq!(res.hits[0].path, "flags.txt"); } #[tokio::test] async fn bad_inputs_are_rejected() { let dir = tempfile::tempdir().unwrap(); let (repo, oid) = fixture(dir.path()); let env = env(dir.path()); let opts = || GrepOptions::default(); let long = "x".repeat(MAX_QUERY_BYTES + 1); for query in ["", long.as_str(), "a\nb", "a\0b", "a\rb"] { assert!( grep(&env, &repo, &oid, query, opts()).await.is_err(), "query {query:?} should be rejected" ); } for glob in ["-x", "../etc", "src/../x", ""] { let o = GrepOptions { path_glob: Some(glob.into()), ..GrepOptions::default() }; assert!( grep(&env, &repo, &oid, "hello", o).await.is_err(), "glob {glob:?} should be rejected" ); } assert!( grep(&env, &repo, "not-an-oid", "hello", opts()) .await .is_err() ); // A bad regex is a git failure, not an empty result. let bad_regex = GrepOptions { fixed: false, ..GrepOptions::default() }; assert!(grep(&env, &repo, &oid, "(", bad_regex).await.is_err()); } #[test] fn parse_record_truncates_and_strips_cr() { let long = "y".repeat(600); let record = format!("{}:a/b.rs\x007\x00{long}\r", "c".repeat(40)); let hit = parse_record(record.as_bytes(), &"c".repeat(40)).unwrap(); assert_eq!(hit.path, "a/b.rs"); assert_eq!(hit.line_no, 7); assert_eq!(hit.line.chars().count(), MAX_LINE_CHARS); assert!(parse_record(b"other:x\x001\x00t", &"c".repeat(40)).is_none()); } }