forge
ExploreLog in

Tahinli/forge

public
0 watching
★ 0
13.6 KiB
1//! Per-repo code search with `git grep` at one commit. No index: each query runs git once,
2//! streams its output, and stops (killing git) after `max_matches` hits. The commit is a validated
3//! full object id; the query goes after `-e`; the optional glob goes after `--`.
4
5use std::path::Path;
6use std::process::Stdio;
7use std::time::Duration;
8
9use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
10
11use crate::command::GitEnv;
12use crate::error::{Error, Result};
13use crate::history::{git, validate_oid};
14
15/// Longest accepted query, in bytes.
16pub const MAX_QUERY_BYTES: usize = 200;
17/// Longest accepted path glob, in bytes.
18const MAX_GLOB_BYTES: usize = 512;
19/// Longest line text kept in a hit, in characters.
20const MAX_LINE_CHARS: usize = 500;
21
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct GrepOptions {
24 /// Match the query as plain text (`-F`) rather than as an extended regex (`-E`).
25 pub fixed: bool,
26 pub ignore_case: bool,
27 /// Limits the search to paths matching this git pathspec glob.
28 pub path_glob: Option<String>,
29 pub max_matches: usize,
30 pub timeout: Duration,
31}
32
33impl Default for GrepOptions {
34 fn default() -> Self {
35 Self {
36 fixed: true,
37 ignore_case: false,
38 path_glob: None,
39 max_matches: 200,
40 timeout: Duration::from_secs(10),
41 }
42 }
43}
44
45#[derive(Debug, Clone, PartialEq, Eq)]
46pub struct GrepHit {
47 pub path: String,
48 pub line_no: u32,
49 /// The matching line, cut to 500 characters.
50 pub line: String,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct GrepResult {
55 pub hits: Vec<GrepHit>,
56 /// True when more matches existed than `max_matches`.
57 pub truncated: bool,
58}
59
60fn validate_query(query: &str) -> Result<()> {
61 if query.is_empty() || query.len() > MAX_QUERY_BYTES {
62 return Err(Error::InvalidInput(format!(
63 "query must be 1 to {MAX_QUERY_BYTES} bytes"
64 )));
65 }
66 if query.contains(['\0', '\n', '\r']) {
67 return Err(Error::InvalidInput(
68 "query contains NUL or a newline".into(),
69 ));
70 }
71 Ok(())
72}
73
74fn validate_glob(glob: &str) -> Result<()> {
75 if glob.is_empty() || glob.len() > MAX_GLOB_BYTES {
76 return Err(Error::InvalidInput(format!(
77 "path glob must be 1 to {MAX_GLOB_BYTES} bytes"
78 )));
79 }
80 if glob.starts_with('-') || glob.contains("..") {
81 return Err(Error::InvalidInput(format!("unsafe path glob: {glob:?}")));
82 }
83 if glob.contains(['\0', '\n', '\r']) {
84 return Err(Error::InvalidInput(
85 "path glob contains NUL or a newline".into(),
86 ));
87 }
88 Ok(())
89}
90
91/// Parses one `--null` record, `<commit>:<path>\0<line>\0<text>`, without its trailing newline.
92fn parse_record(record: &[u8], commit: &str) -> Option<GrepHit> {
93 let mut parts = record.splitn(3, |&b| b == 0);
94 let header = parts.next()?;
95 let line_bytes = parts.next()?;
96 let text = parts.next().unwrap_or_default();
97
98 let prefix = format!("{commit}:");
99 let path = header.strip_prefix(prefix.as_bytes())?;
100 let line_no = std::str::from_utf8(line_bytes).ok()?.parse::<u32>().ok()?;
101 let text = String::from_utf8_lossy(text);
102 let text = text.strip_suffix('\r').unwrap_or(&text);
103 Some(GrepHit {
104 path: String::from_utf8_lossy(path).into_owned(),
105 line_no,
106 line: text.chars().take(MAX_LINE_CHARS).collect(),
107 })
108}
109
110/// Searches the tree of `commit` for `query`. Exit code 1 from git (no match) is an empty result.
111pub async fn grep(
112 env: &GitEnv,
113 repo: &Path,
114 commit: &str,
115 query: &str,
116 opts: GrepOptions,
117) -> Result<GrepResult> {
118 validate_oid(commit)?;
119 validate_query(query)?;
120 if let Some(glob) = &opts.path_glob {
121 validate_glob(glob)?;
122 }
123 let globs: Vec<&str> = opts.path_glob.as_deref().into_iter().collect();
124
125 let mut cmd = git(env, "grep")
126 .git_dir(repo)
127 .arg("-n")
128 .arg("-I")
129 .arg("--no-color")
130 .arg("--null")
131 .arg("--max-count=20")
132 .arg(if opts.fixed { "-F" } else { "-E" });
133 if opts.ignore_case {
134 cmd = cmd.arg("-i");
135 }
136 let cmd = cmd.user_pattern(query)?.user_arg(commit)?.paths(&globs);
137
138 let mut command = cmd.into_tokio();
139 command.stdout(Stdio::piped()).stderr(Stdio::piped());
140 let mut child = command.spawn()?;
141 let stdout = child
142 .stdout
143 .take()
144 .ok_or_else(|| Error::Failed("git stdout missing".into()))?;
145 let stderr_task = child.stderr.take().map(|mut e| {
146 tokio::spawn(async move {
147 let mut v = Vec::new();
148 let _ = e.read_to_end(&mut v).await;
149 v
150 })
151 });
152
153 let max = opts.max_matches;
154 let mut hits = Vec::new();
155 let mut truncated = false;
156 let reading = async {
157 let mut lines = BufReader::new(stdout);
158 let mut buf = Vec::new();
159 loop {
160 buf.clear();
161 if lines.read_until(b'\n', &mut buf).await? == 0 {
162 break;
163 }
164 let record = buf.strip_suffix(b"\n").unwrap_or(&buf);
165 let Some(hit) = parse_record(record, commit) else {
166 continue;
167 };
168 if hits.len() == max {
169 truncated = true;
170 break;
171 }
172 hits.push(hit);
173 }
174 Ok::<(), Error>(())
175 };
176
177 match tokio::time::timeout(opts.timeout, reading).await {
178 Ok(read) => read?,
179 Err(_) => {
180 let _ = child.start_kill();
181 let _ = child.wait().await;
182 return Err(Error::Failed(format!(
183 "code search timed out after {} seconds",
184 opts.timeout.as_secs()
185 )));
186 }
187 }
188
189 if truncated {
190 let _ = child.start_kill();
191 }
192 let status = child.wait().await?;
193 let stderr = match stderr_task {
194 Some(task) => task.await.unwrap_or_default(),
195 None => Vec::new(),
196 };
197 // Git exits 1 when nothing matched. A killed child (truncated) has no meaningful code.
198 if !truncated && !status.success() && status.code() != Some(1) {
199 return Err(Error::Failed(format!(
200 "git grep exited with {status}: {}",
201 String::from_utf8_lossy(&stderr).trim()
202 )));
203 }
204 Ok(GrepResult { hits, truncated })
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use std::process::Command;
211
212 fn env(dir: &Path) -> GitEnv {
213 GitEnv {
214 git_bin: "git".into(),
215 data_dir: dir.to_path_buf(),
216 }
217 }
218
219 fn git_in(dir: &Path, args: &[&str]) {
220 let status = Command::new("git")
221 .current_dir(dir)
222 .env("GIT_CONFIG_NOSYSTEM", "1")
223 .args([
224 "-c",
225 "user.name=Test",
226 "-c",
227 "user.email=test@example.com",
228 "-c",
229 "commit.gpgsign=false",
230 ])
231 .args(args)
232 .status()
233 .expect("git runs");
234 assert!(status.success(), "git {args:?} failed");
235 }
236
237 /// A repo with `src/main.rs`, `docs/Guide.md` and `src/lib.rs`. Returns (git dir, commit oid).
238 fn fixture(dir: &Path) -> (std::path::PathBuf, String) {
239 let work = dir.join("work");
240 std::fs::create_dir_all(work.join("src")).unwrap();
241 std::fs::create_dir_all(work.join("docs")).unwrap();
242 std::fs::write(
243 work.join("src/main.rs"),
244 "fn main() {\n println!(\"Hello\");\n}\nlet a.b = 1;\n",
245 )
246 .unwrap();
247 std::fs::write(work.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
248 std::fs::write(work.join("docs/Guide.md"), "say hello there\n").unwrap();
249 git_in(&work, &["init", "-q", "-b", "main"]);
250 git_in(&work, &["add", "-A"]);
251 git_in(&work, &["commit", "-q", "-m", "init"]);
252 let out = Command::new("git")
253 .current_dir(&work)
254 .args(["rev-parse", "HEAD"])
255 .output()
256 .expect("rev-parse");
257 let oid = String::from_utf8(out.stdout).unwrap().trim().to_owned();
258 (work.join(".git"), oid)
259 }
260
261 #[tokio::test]
262 async fn fixed_search_finds_lines_across_files() {
263 let dir = tempfile::tempdir().unwrap();
264 let (repo, oid) = fixture(dir.path());
265 let res = grep(&env(dir.path()), &repo, &oid, "a.b", GrepOptions::default())
266 .await
267 .unwrap();
268 // Fixed mode: the dot is literal, so only `a.b` matches.
269 assert_eq!(res.hits.len(), 1);
270 assert_eq!(res.hits[0].path, "src/main.rs");
271 assert_eq!(res.hits[0].line_no, 4);
272 assert_eq!(res.hits[0].line, "let a.b = 1;");
273 assert!(!res.truncated);
274 }
275
276 #[tokio::test]
277 async fn ignore_case_and_regex_mode() {
278 let dir = tempfile::tempdir().unwrap();
279 let (repo, oid) = fixture(dir.path());
280 let env = env(dir.path());
281
282 let sensitive = grep(&env, &repo, &oid, "hello", GrepOptions::default())
283 .await
284 .unwrap();
285 assert_eq!(
286 sensitive.hits.len(),
287 2,
288 "lib.rs and Guide.md, case-sensitive"
289 );
290
291 let opts = GrepOptions {
292 ignore_case: true,
293 ..GrepOptions::default()
294 };
295 let insensitive = grep(&env, &repo, &oid, "HELLO", opts).await.unwrap();
296 assert_eq!(insensitive.hits.len(), 3);
297
298 let regex = GrepOptions {
299 fixed: false,
300 ..GrepOptions::default()
301 };
302 let res = grep(&env, &repo, &oid, "^pub fn \\w+", regex)
303 .await
304 .unwrap();
305 assert_eq!(res.hits.len(), 1);
306 assert_eq!(res.hits[0].path, "src/lib.rs");
307 }
308
309 #[tokio::test]
310 async fn path_glob_limits_search() {
311 let dir = tempfile::tempdir().unwrap();
312 let (repo, oid) = fixture(dir.path());
313 let opts = GrepOptions {
314 path_glob: Some("*.md".into()),
315 ..GrepOptions::default()
316 };
317 let res = grep(&env(dir.path()), &repo, &oid, "hello", opts)
318 .await
319 .unwrap();
320 assert_eq!(res.hits.len(), 1);
321 assert_eq!(res.hits[0].path, "docs/Guide.md");
322 }
323
324 #[tokio::test]
325 async fn truncates_at_max_matches() {
326 let dir = tempfile::tempdir().unwrap();
327 let (repo, oid) = fixture(dir.path());
328 let opts = GrepOptions {
329 ignore_case: true,
330 max_matches: 2,
331 ..GrepOptions::default()
332 };
333 let res = grep(&env(dir.path()), &repo, &oid, "hello", opts)
334 .await
335 .unwrap();
336 assert_eq!(res.hits.len(), 2);
337 assert!(res.truncated);
338 }
339
340 #[tokio::test]
341 async fn no_match_is_empty_not_error() {
342 let dir = tempfile::tempdir().unwrap();
343 let (repo, oid) = fixture(dir.path());
344 let res = grep(
345 &env(dir.path()),
346 &repo,
347 &oid,
348 "zzz-not-here",
349 GrepOptions::default(),
350 )
351 .await
352 .unwrap();
353 assert!(res.hits.is_empty());
354 assert!(!res.truncated);
355 }
356
357 #[tokio::test]
358 async fn query_starting_with_dash_is_a_pattern() {
359 let dir = tempfile::tempdir().unwrap();
360 let (repo, oid) = fixture(dir.path());
361 std::fs::write(dir.path().join("work/flags.txt"), "--verbose\n").unwrap();
362 git_in(&dir.path().join("work"), &["add", "-A"]);
363 git_in(&dir.path().join("work"), &["commit", "-q", "-m", "flags"]);
364 let out = Command::new("git")
365 .current_dir(dir.path().join("work"))
366 .args(["rev-parse", "HEAD"])
367 .output()
368 .unwrap();
369 let oid2 = String::from_utf8(out.stdout).unwrap().trim().to_owned();
370 assert_ne!(oid, oid2);
371 let res = grep(
372 &env(dir.path()),
373 &repo,
374 &oid2,
375 "--verbose",
376 GrepOptions::default(),
377 )
378 .await
379 .unwrap();
380 assert_eq!(res.hits.len(), 1);
381 assert_eq!(res.hits[0].path, "flags.txt");
382 }
383
384 #[tokio::test]
385 async fn bad_inputs_are_rejected() {
386 let dir = tempfile::tempdir().unwrap();
387 let (repo, oid) = fixture(dir.path());
388 let env = env(dir.path());
389 let opts = || GrepOptions::default();
390 let long = "x".repeat(MAX_QUERY_BYTES + 1);
391 for query in ["", long.as_str(), "a\nb", "a\0b", "a\rb"] {
392 assert!(
393 grep(&env, &repo, &oid, query, opts()).await.is_err(),
394 "query {query:?} should be rejected"
395 );
396 }
397 for glob in ["-x", "../etc", "src/../x", ""] {
398 let o = GrepOptions {
399 path_glob: Some(glob.into()),
400 ..GrepOptions::default()
401 };
402 assert!(
403 grep(&env, &repo, &oid, "hello", o).await.is_err(),
404 "glob {glob:?} should be rejected"
405 );
406 }
407 assert!(
408 grep(&env, &repo, "not-an-oid", "hello", opts())
409 .await
410 .is_err()
411 );
412 // A bad regex is a git failure, not an empty result.
413 let bad_regex = GrepOptions {
414 fixed: false,
415 ..GrepOptions::default()
416 };
417 assert!(grep(&env, &repo, &oid, "(", bad_regex).await.is_err());
418 }
419
420 #[test]
421 fn parse_record_truncates_and_strips_cr() {
422 let long = "y".repeat(600);
423 let record = format!("{}:a/b.rs\x007\x00{long}\r", "c".repeat(40));
424 let hit = parse_record(record.as_bytes(), &"c".repeat(40)).unwrap();
425 assert_eq!(hit.path, "a/b.rs");
426 assert_eq!(hit.line_no, 7);
427 assert_eq!(hit.line.chars().count(), MAX_LINE_CHARS);
428 assert!(parse_record(b"other:x\x001\x00t", &"c".repeat(40)).is_none());
429 }
430}