Skip to main content

_diffctx/
git.rs

1use std::io::{BufRead, BufReader, Read, Write};
2use std::path::{Path, PathBuf};
3use std::process::{Child, ChildStdout, Command, Stdio};
4use std::sync::Arc;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::time::Duration;
7
8use once_cell::sync::Lazy;
9use regex::Regex;
10use rustc_hash::FxHashSet;
11use wait_timeout::ChildExt;
12
13use crate::config::git::{self, GIT};
14use crate::types::DiffHunk;
15
16static GIT_TIMEOUT_SECS: AtomicU64 = AtomicU64::new(git::DEFAULT_TIMEOUT_SECONDS);
17
18pub fn set_git_timeout(secs: u64) {
19    GIT_TIMEOUT_SECS.store(secs, Ordering::Relaxed);
20}
21
22fn git_timeout() -> u64 {
23    GIT_TIMEOUT_SECS.load(Ordering::Relaxed)
24}
25const SAFE_DIFF_FLAGS: &[&str] = &["--no-textconv", "--no-ext-diff"];
26
27static HUNK_RE: Lazy<Regex> =
28    Lazy::new(|| Regex::new(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@").unwrap());
29
30static RANGE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\s*(\S+?)(\.\.\.?)(\S*?)\s*$").unwrap());
31
32static SAFE_RANGE_RE: Lazy<Regex> =
33    Lazy::new(|| Regex::new(r"^[a-zA-Z0-9_.^~/@{}\-]+(\.\.\.?[a-zA-Z0-9_.^~/@{}\-]*)?$").unwrap());
34
35#[derive(Debug, thiserror::Error)]
36pub enum GitError {
37    #[error("{0}")]
38    CommandFailed(String),
39    #[error("not a git repository: {0}")]
40    NotARepo(PathBuf),
41    #[error("invalid diff range: {0}")]
42    InvalidRange(String),
43    #[error("io error: {0}")]
44    Io(#[from] std::io::Error),
45    #[error("timeout after {0}s")]
46    Timeout(u64),
47}
48
49pub type Result<T> = std::result::Result<T, GitError>;
50
51fn validate_diff_range(diff_range: &str) -> Result<()> {
52    let trimmed = diff_range.trim();
53    // Reject leading-dot ranges like `..origin/main` (audit X16): the regex
54    // character class allows `.`, so a string of only dots/identifier-chars
55    // passes as a "ref" before being rejected by git itself with a less
56    // informative `fatal: ambiguous argument`. Surface the dedicated error.
57    if trimmed.starts_with('.') || trimmed.starts_with('/') {
58        return Err(GitError::InvalidRange(diff_range.to_string()));
59    }
60    if !SAFE_RANGE_RE.is_match(trimmed) {
61        return Err(GitError::InvalidRange(diff_range.to_string()));
62    }
63    Ok(())
64}
65
66/// Always targets the repo via `-C`.
67///
68/// Repo-locating variables inherited from a parent process (e.g. a git
69/// hook exporting `GIT_DIR` / `GIT_INDEX_FILE`) must be scrubbed or they
70/// silently redirect every command to the wrong repository.
71pub fn git_command(repo_root: &Path) -> Command {
72    let mut cmd = Command::new("git");
73    cmd.arg("-C")
74        .arg(repo_root)
75        .env_remove("GIT_DIR")
76        .env_remove("GIT_WORK_TREE")
77        .env_remove("GIT_INDEX_FILE");
78    cmd
79}
80
81pub fn run_git(repo_root: &Path, args: &[&str]) -> Result<String> {
82    let mut cmd = git_command(repo_root);
83    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
84
85    let child = cmd.spawn().map_err(|e| {
86        if e.kind() == std::io::ErrorKind::NotFound {
87            GitError::CommandFailed("git is not installed or not in PATH".into())
88        } else {
89            GitError::Io(e)
90        }
91    })?;
92
93    let output = wait_with_timeout(child, Duration::from_secs(git_timeout()), args)?;
94
95    if !output.status.success() {
96        let stderr = String::from_utf8_lossy(&output.stderr);
97        let subcommand = args
98            .iter()
99            .find(|a| !a.starts_with('-'))
100            .copied()
101            .unwrap_or("command");
102        let reason = stderr
103            .lines()
104            .map(str::trim)
105            .find(|l| l.starts_with("fatal:") || l.starts_with("error:"))
106            .or_else(|| stderr.lines().map(str::trim).find(|l| !l.is_empty()))
107            .unwrap_or("unknown error");
108        return Err(GitError::CommandFailed(format!(
109            "git {subcommand} failed: {reason}"
110        )));
111    }
112
113    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
114}
115
116fn wait_with_timeout(
117    child: Child,
118    timeout: Duration,
119    _args: &[&str],
120) -> Result<std::process::Output> {
121    let mut child = child;
122    let stdout_handle = child.stdout.take().map(|mut s| {
123        std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
124            let mut buf = Vec::new();
125            s.read_to_end(&mut buf)?;
126            Ok(buf)
127        })
128    });
129    let stderr_handle = child.stderr.take().map(|mut s| {
130        std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
131            let mut buf = Vec::new();
132            s.read_to_end(&mut buf)?;
133            Ok(buf)
134        })
135    });
136
137    let status = match child.wait_timeout(timeout)? {
138        Some(status) => status,
139        None => {
140            let _ = child.kill();
141            let _ = child.wait();
142            return Err(GitError::Timeout(timeout.as_secs()));
143        }
144    };
145
146    let stdout = stdout_handle
147        .and_then(|h| h.join().ok())
148        .and_then(|r| r.ok())
149        .unwrap_or_default();
150    let stderr = stderr_handle
151        .and_then(|h| h.join().ok())
152        .and_then(|r| r.ok())
153        .unwrap_or_default();
154
155    Ok(std::process::Output {
156        status,
157        stdout,
158        stderr,
159    })
160}
161
162pub fn is_git_repo(path: &Path) -> bool {
163    run_git(path, &["rev-parse", "--git-dir"]).is_ok()
164}
165
166/// Resolves the actual working-tree root for `path`, which may be a
167/// subdirectory of the repository. `git diff`/`git cat-file` paths are
168/// always reported relative to this root, not to an arbitrary `-C` cwd -
169/// running the pipeline with `path` still set to a subdirectory silently
170/// produces zero fragments because file lookups get double-prefixed
171/// (e.g. `src/src/app.py`).
172pub fn find_toplevel(path: &Path) -> Option<PathBuf> {
173    let out = run_git(path, &["rev-parse", "--show-toplevel"]).ok()?;
174    let trimmed = out.trim();
175    if trimmed.is_empty() {
176        return None;
177    }
178    Some(PathBuf::from(trimmed))
179}
180
181pub fn get_diff_text(repo_root: &Path, diff_range: Option<&str>) -> Result<String> {
182    let mut args: Vec<&str> = vec!["diff"];
183    args.extend_from_slice(SAFE_DIFF_FLAGS);
184    if let Some(range) = diff_range {
185        validate_diff_range(range)?;
186        args.push(range);
187    }
188    run_git(repo_root, &args)
189}
190
191fn unquote_c_style(quoted: &str) -> String {
192    if !(quoted.starts_with('"') && quoted.ends_with('"')) {
193        return quoted.to_string();
194    }
195
196    let raw = &quoted[1..quoted.len() - 1];
197    let bytes = raw.as_bytes();
198    let mut result: Vec<u8> = Vec::with_capacity(bytes.len());
199    let mut i = 0;
200
201    while i < bytes.len() {
202        if bytes[i] == b'\\' && i + 1 < bytes.len() {
203            let nxt = bytes[i + 1];
204            match nxt {
205                b't' => {
206                    result.push(b'\t');
207                    i += 2;
208                }
209                b'n' => {
210                    result.push(b'\n');
211                    i += 2;
212                }
213                b'r' => {
214                    result.push(b'\r');
215                    i += 2;
216                }
217                b'b' => {
218                    result.push(0x08);
219                    i += 2;
220                }
221                b'f' => {
222                    result.push(0x0C);
223                    i += 2;
224                }
225                b'v' => {
226                    result.push(0x0B);
227                    i += 2;
228                }
229                b'a' => {
230                    result.push(0x07);
231                    i += 2;
232                }
233                b'\\' => {
234                    result.push(b'\\');
235                    i += 2;
236                }
237                b'"' => {
238                    result.push(b'"');
239                    i += 2;
240                }
241                b'0'..=b'7'
242                    if i + 3 < bytes.len()
243                        && bytes[i + 2].is_ascii_digit()
244                        && bytes[i + 2] <= b'7'
245                        && bytes[i + 3].is_ascii_digit()
246                        && bytes[i + 3] <= b'7' =>
247                {
248                    let val = (nxt - b'0') * 64 + (bytes[i + 2] - b'0') * 8 + (bytes[i + 3] - b'0');
249                    result.push(val);
250                    i += 4;
251                }
252                _ => {
253                    result.push(b'\\');
254                    i += 1;
255                }
256            }
257        } else {
258            result.push(bytes[i]);
259            i += 1;
260        }
261    }
262
263    String::from_utf8(result).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
264}
265
266fn parse_path_line(line: &str, repo_root: &Path) -> (&'static str, Option<PathBuf>) {
267    let resolved_root = repo_root
268        .canonicalize()
269        .unwrap_or_else(|_| repo_root.to_path_buf());
270
271    if line.starts_with("--- /dev/null") {
272        return ("old", None);
273    }
274    if line.starts_with("+++ /dev/null") {
275        return ("new", None);
276    }
277
278    if let Some(rest) = line.strip_prefix("--- a/") {
279        let rel_path = rest.trim();
280        let resolved = (repo_root.join(rel_path))
281            .canonicalize()
282            .unwrap_or_else(|_| repo_root.join(rel_path));
283        if !resolved.starts_with(&resolved_root) {
284            return ("", None);
285        }
286        return ("old", Some(repo_root.join(rel_path)));
287    }
288
289    if let Some(rest) = line.strip_prefix("+++ b/") {
290        let rel_path = rest.trim();
291        let resolved = (repo_root.join(rel_path))
292            .canonicalize()
293            .unwrap_or_else(|_| repo_root.join(rel_path));
294        if !resolved.starts_with(&resolved_root) {
295            return ("", None);
296        }
297        return ("new", Some(repo_root.join(rel_path)));
298    }
299
300    if let Some(rest) = line.strip_prefix("--- ").filter(|r| r.starts_with("\"a/")) {
301        let quoted = rest.trim();
302        let unquoted = unquote_c_style(quoted);
303        let rel_path = unquoted.strip_prefix("a/").unwrap_or(&unquoted);
304        let resolved = (repo_root.join(rel_path))
305            .canonicalize()
306            .unwrap_or_else(|_| repo_root.join(rel_path));
307        if !resolved.starts_with(&resolved_root) {
308            return ("", None);
309        }
310        return ("old", Some(repo_root.join(rel_path)));
311    }
312
313    if let Some(rest) = line.strip_prefix("+++ ").filter(|r| r.starts_with("\"b/")) {
314        let quoted = rest.trim();
315        let unquoted = unquote_c_style(quoted);
316        let rel_path = unquoted.strip_prefix("b/").unwrap_or(&unquoted);
317        let resolved = (repo_root.join(rel_path))
318            .canonicalize()
319            .unwrap_or_else(|_| repo_root.join(rel_path));
320        if !resolved.starts_with(&resolved_root) {
321            return ("", None);
322        }
323        return ("new", Some(repo_root.join(rel_path)));
324    }
325
326    ("", None)
327}
328
329fn parse_hunk_header(caps: &regex::Captures, path: &Path) -> Option<DiffHunk> {
330    // Adversarial-diff hardening: integers parsed from the hunk regex are
331    // small ASCII digits matched by `\d+`, so `parse::<u32>` can only fail
332    // on overflow (e.g. lines > 2^32). Skip such hunks instead of crashing
333    // the host process — they are degenerate and have no useful semantics.
334    let old_start: u32 = caps[1].parse().ok()?;
335    let old_len: u32 = match caps.get(2) {
336        Some(m) => m.as_str().parse().ok()?,
337        None => 1,
338    };
339    let new_start: u32 = caps[3].parse().ok()?;
340    let new_len: u32 = match caps.get(4) {
341        Some(m) => m.as_str().parse().ok()?,
342        None => 1,
343    };
344
345    Some(DiffHunk {
346        path: Arc::from(path.to_string_lossy().as_ref()),
347        new_start,
348        new_len,
349        old_start,
350        old_len,
351    })
352}
353
354pub fn parse_diff(repo_root: &Path, diff_range: Option<&str>) -> Result<Vec<DiffHunk>> {
355    let mut args: Vec<&str> = vec!["diff"];
356    args.extend_from_slice(SAFE_DIFF_FLAGS);
357    args.push("--unified=0");
358    args.push("-M");
359    if let Some(range) = diff_range {
360        validate_diff_range(range)?;
361        args.push(range);
362    }
363
364    let output = run_git(repo_root, &args)?;
365    let mut hunks = Vec::new();
366    let mut old_path: Option<PathBuf> = None;
367    let mut new_path: Option<PathBuf> = None;
368
369    for line in output.lines() {
370        let (path_type, path) = parse_path_line(line, repo_root);
371        match path_type {
372            "old" => {
373                old_path = path;
374                continue;
375            }
376            "new" => {
377                new_path = path;
378                continue;
379            }
380            _ => {}
381        }
382
383        if let Some(caps) = HUNK_RE.captures(line) {
384            let current_path = new_path.as_deref().or(old_path.as_deref());
385            if let Some(p) = current_path {
386                if let Some(hunk) = parse_hunk_header(&caps, p) {
387                    hunks.push(hunk);
388                }
389            }
390        }
391    }
392
393    Ok(hunks)
394}
395
396pub fn run_git_z(repo_root: &Path, args: &[&str]) -> Result<Vec<String>> {
397    let output = run_git(repo_root, args)?;
398    Ok(output
399        .split('\0')
400        .filter(|s| !s.is_empty())
401        .map(String::from)
402        .collect())
403}
404
405pub fn get_changed_files(repo_root: &Path, diff_range: Option<&str>) -> Result<Vec<PathBuf>> {
406    let mut args: Vec<&str> = vec!["diff"];
407    args.extend_from_slice(SAFE_DIFF_FLAGS);
408    args.extend_from_slice(&["--name-only", "-M", "-z"]);
409    if let Some(range) = diff_range {
410        validate_diff_range(range)?;
411        args.push(range);
412    }
413    let parts = run_git_z(repo_root, &args)?;
414    Ok(parts
415        .iter()
416        .map(|p| {
417            repo_root
418                .join(p)
419                .canonicalize()
420                .unwrap_or_else(|_| repo_root.join(p))
421        })
422        .collect())
423}
424
425pub fn get_deleted_files(repo_root: &Path, diff_range: Option<&str>) -> Result<FxHashSet<PathBuf>> {
426    let mut args: Vec<&str> = vec!["diff"];
427    args.extend_from_slice(SAFE_DIFF_FLAGS);
428    args.extend_from_slice(&["--diff-filter=D", "--name-only", "-M", "-z"]);
429    if let Some(range) = diff_range {
430        validate_diff_range(range)?;
431        args.push(range);
432    }
433    let parts = run_git_z(repo_root, &args)?;
434    Ok(parts
435        .iter()
436        .map(|p| {
437            repo_root
438                .join(p)
439                .canonicalize()
440                .unwrap_or_else(|_| repo_root.join(p))
441        })
442        .collect())
443}
444
445pub fn get_renamed_paths(
446    repo_root: &Path,
447    diff_range: Option<&str>,
448    min_similarity: u32,
449) -> Result<(FxHashSet<PathBuf>, FxHashSet<PathBuf>)> {
450    let mut args: Vec<&str> = vec!["diff"];
451    args.extend_from_slice(SAFE_DIFF_FLAGS);
452    args.extend_from_slice(&["--diff-filter=R", "--name-status", "-M", "-z"]);
453    if let Some(range) = diff_range {
454        validate_diff_range(range)?;
455        args.push(range);
456    }
457    let output = run_git(repo_root, &args)?;
458    let parts: Vec<&str> = output.split('\0').collect();
459
460    let mut old_paths = FxHashSet::default();
461    let mut pure_new_paths = FxHashSet::default();
462    let mut i = 0;
463
464    while i < parts.len() {
465        if parts[i].starts_with('R') {
466            let sim: u32 = parts[i][1..].parse().unwrap_or(0);
467
468            if i + 1 < parts.len() && !parts[i + 1].is_empty() {
469                let resolved = repo_root
470                    .join(parts[i + 1])
471                    .canonicalize()
472                    .unwrap_or_else(|_| repo_root.join(parts[i + 1]));
473                old_paths.insert(resolved);
474            }
475
476            if sim >= min_similarity && i + 2 < parts.len() && !parts[i + 2].is_empty() {
477                let resolved = repo_root
478                    .join(parts[i + 2])
479                    .canonicalize()
480                    .unwrap_or_else(|_| repo_root.join(parts[i + 2]));
481                pure_new_paths.insert(resolved);
482            }
483
484            i += 3;
485        } else {
486            i += 1;
487        }
488    }
489
490    Ok((old_paths, pure_new_paths))
491}
492
493/// Rename pairs as repo-relative display paths (`old -> new`), for the output
494/// header. Unlike `get_renamed_paths` this preserves the pairing and does not
495/// canonicalize (the old path no longer exists on disk).
496pub fn get_rename_pairs(
497    repo_root: &Path,
498    diff_range: Option<&str>,
499) -> Result<Vec<(String, String)>> {
500    let mut args: Vec<&str> = vec!["diff"];
501    args.extend_from_slice(SAFE_DIFF_FLAGS);
502    args.extend_from_slice(&["--diff-filter=R", "--name-status", "-M", "-z"]);
503    if let Some(range) = diff_range {
504        validate_diff_range(range)?;
505        args.push(range);
506    }
507    let output = run_git(repo_root, &args)?;
508    let parts: Vec<&str> = output.split('\0').collect();
509
510    let mut pairs = Vec::new();
511    let mut i = 0;
512    while i < parts.len() {
513        if parts[i].starts_with('R') {
514            if i + 2 < parts.len() && !parts[i + 1].is_empty() && !parts[i + 2].is_empty() {
515                pairs.push((
516                    parts[i + 1].replace('\\', "/"),
517                    parts[i + 2].replace('\\', "/"),
518                ));
519            }
520            i += 3;
521        } else {
522            i += 1;
523        }
524    }
525    Ok(pairs)
526}
527
528pub fn split_diff_range(range: &str) -> (Option<String>, Option<String>) {
529    match RANGE_RE.captures(range) {
530        None => (None, None),
531        Some(caps) => {
532            let base = caps
533                .get(1)
534                .map(|m| m.as_str().trim().to_string())
535                .filter(|s| !s.is_empty());
536            let head = caps
537                .get(3)
538                .map(|m| m.as_str().trim().to_string())
539                .filter(|s| !s.is_empty());
540            (base, head)
541        }
542    }
543}
544
545pub fn show_file_at_revision(repo_root: &Path, rev: &str, rel_path: &Path) -> Result<String> {
546    let spec = format!("{}:{}", rev, rel_path.to_string_lossy().replace('\\', "/"));
547    run_git(repo_root, &["show", &spec])
548}
549
550pub fn get_commit_message(repo_root: &Path, rev: &str) -> Result<String> {
551    match run_git(repo_root, &["log", "-1", "--format=%s%n%b", rev]) {
552        Ok(s) => Ok(s.trim().to_string()),
553        Err(_) => Ok(String::new()),
554    }
555}
556
557pub fn get_untracked_files(repo_root: &Path) -> Result<Vec<PathBuf>> {
558    let parts = run_git_z(
559        repo_root,
560        &["ls-files", "--others", "--exclude-standard", "-z"],
561    )?;
562    Ok(parts
563        .iter()
564        .map(|p| {
565            repo_root
566                .join(p)
567                .canonicalize()
568                .unwrap_or_else(|_| repo_root.join(p))
569        })
570        .collect())
571}
572
573/// Rewrites one `.diffctx/ignore` pattern line to be anchored to the
574/// directory that contains the `.diffctx/` folder (`rel`, repo-root-relative,
575/// "" for the repo root itself). Mirrors `_process_ignore_line` in the
576/// Python tree-mode ignore resolver (`src/diffctx/ignore.py`) so a pattern
577/// declared in `sub/.diffctx/ignore` only ever matches within `sub/`.
578fn anchor_diffctx_ignore_line(line: &str, rel: &str) -> String {
579    let (neg, pat) = match line.strip_prefix('!') {
580        Some(rest) => (true, rest),
581        None => (false, line),
582    };
583    let pat_no_trailing_slash = pat.trim_end_matches('/');
584    let full = if pat_no_trailing_slash.starts_with('/') || pat_no_trailing_slash.contains('/') {
585        let anchored = pat.trim_start_matches('/');
586        if rel.is_empty() {
587            format!("/{anchored}")
588        } else {
589            format!("/{rel}/{anchored}")
590        }
591    } else if rel.is_empty() {
592        pat.to_string()
593    } else {
594        format!("{rel}/**/{pat}")
595    };
596    if neg { format!("!{full}") } else { full }
597}
598
599/// Finds every `.diffctx/ignore` file tracked or present in `repo_root`
600/// (any depth) and returns its patterns rewritten to be repo-root-relative,
601/// ready to feed into a combined gitignore-syntax exclude file.
602fn collect_diffctx_ignore_patterns(repo_root: &Path) -> Vec<String> {
603    let Ok(files) = run_git_z(
604        repo_root,
605        &[
606            "ls-files",
607            "-z",
608            "--cached",
609            "--others",
610            "--exclude-standard",
611            "--",
612            ":(glob)**/.diffctx/ignore",
613        ],
614    ) else {
615        return Vec::new();
616    };
617
618    let mut patterns = Vec::new();
619    for raw in &files {
620        let rel_path = unquote_c_style(raw);
621        if !rel_path.ends_with(".diffctx/ignore") {
622            continue;
623        }
624        let rel_dir = rel_path
625            .strip_suffix(".diffctx/ignore")
626            .unwrap_or("")
627            .trim_end_matches('/');
628        let Ok(content) = std::fs::read_to_string(repo_root.join(&rel_path)) else {
629            continue;
630        };
631        for line in content.lines() {
632            let line = line.trim_end();
633            if line.is_empty() || line.starts_with('#') {
634                continue;
635            }
636            patterns.push(anchor_diffctx_ignore_line(line, rel_dir));
637        }
638    }
639    patterns
640}
641
642/// Returns the subset of `rel_paths` (repo-root-relative) excluded by either
643/// `.gitignore` (via git's own engine, so nesting/negation/`**` are handled
644/// correctly) or `.diffctx/ignore` (patterns anchored per-directory and fed
645/// to git as a temporary `core.excludesFile`, so the same engine evaluates
646/// both mechanisms uniformly). Best-effort: any failure returns an empty set
647/// rather than blocking the diff pipeline on an ignore-resolution problem.
648pub fn find_ignored_paths(repo_root: &Path, rel_paths: &[String]) -> FxHashSet<String> {
649    if rel_paths.is_empty() {
650        return FxHashSet::default();
651    }
652
653    let diffctx_patterns = collect_diffctx_ignore_patterns(repo_root);
654    let temp_excludes = if diffctx_patterns.is_empty() {
655        None
656    } else {
657        let path = std::env::temp_dir().join(format!("diffctx-ignore-{}.tmp", std::process::id()));
658        match std::fs::write(&path, diffctx_patterns.join("\n")) {
659            Ok(()) => Some(path),
660            Err(_) => None,
661        }
662    };
663
664    let mut args: Vec<String> = vec!["check-ignore".into(), "--no-index".into()];
665    if let Some(ref path) = temp_excludes {
666        args.insert(0, format!("core.excludesFile={}", path.display()));
667        args.insert(0, "-c".into());
668    }
669    args.push("--".into());
670    args.extend(rel_paths.iter().cloned());
671    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
672
673    let result = (|| -> Result<FxHashSet<String>> {
674        let mut cmd = git_command(repo_root);
675        cmd.args(&arg_refs)
676            .stdout(Stdio::piped())
677            .stderr(Stdio::piped());
678        let child = cmd.spawn()?;
679        let output = wait_with_timeout(child, Duration::from_secs(git_timeout()), &arg_refs)?;
680        // Exit code 1 from `check-ignore` means "none of the given paths are
681        // ignored" — not a failure. Any other non-zero code is a real error.
682        if !output.status.success() && output.status.code() != Some(1) {
683            let stderr = String::from_utf8_lossy(&output.stderr);
684            return Err(GitError::CommandFailed(format!(
685                "git check-ignore failed: {}",
686                stderr.trim()
687            )));
688        }
689        let stdout = String::from_utf8_lossy(&output.stdout);
690        Ok(stdout.lines().map(unquote_c_style).collect())
691    })();
692
693    if let Some(path) = temp_excludes {
694        let _ = std::fs::remove_file(path);
695    }
696
697    result.unwrap_or_default()
698}
699
700pub struct CatFileBatch {
701    repo_root: PathBuf,
702    child: Option<Child>,
703    reader: Option<BufReader<ChildStdout>>,
704}
705
706impl CatFileBatch {
707    pub fn new(repo_root: &Path) -> Result<Self> {
708        let mut batch = Self {
709            repo_root: repo_root.to_path_buf(),
710            child: None,
711            reader: None,
712        };
713        batch.ensure_started()?;
714        Ok(batch)
715    }
716
717    fn ensure_started(&mut self) -> Result<()> {
718        let needs_restart = match &mut self.child {
719            None => true,
720            Some(child) => child.try_wait().ok().flatten().is_some(),
721        };
722
723        if needs_restart {
724            let mut child = git_command(&self.repo_root)
725                .args(["cat-file", "--batch"])
726                .stdin(Stdio::piped())
727                .stdout(Stdio::piped())
728                .stderr(Stdio::null())
729                .spawn()?;
730            let stdout = child.stdout.take().ok_or_else(|| {
731                GitError::CommandFailed("cat-file: failed to capture stdout pipe".into())
732            })?;
733            self.reader = Some(BufReader::new(stdout));
734            self.child = Some(child);
735        }
736
737        Ok(())
738    }
739
740    pub fn get(&mut self, rev: &str, rel_path: &Path) -> Result<String> {
741        let spec = format!(
742            "{}:{}\n",
743            rev,
744            rel_path.to_string_lossy().replace('\\', "/")
745        );
746
747        self.ensure_started()?;
748
749        let stdin = self
750            .child
751            .as_mut()
752            .and_then(|c| c.stdin.as_mut())
753            .ok_or_else(|| GitError::CommandFailed("cat-file stdin unavailable".into()))?;
754        stdin.write_all(spec.as_bytes())?;
755        stdin.flush()?;
756
757        let reader = self
758            .reader
759            .as_mut()
760            .ok_or_else(|| GitError::CommandFailed("cat-file stdout unavailable".into()))?;
761
762        let mut header_line = String::new();
763        reader.read_line(&mut header_line)?;
764
765        if header_line.is_empty() {
766            return Err(GitError::CommandFailed(format!(
767                "cat-file: unexpected EOF for {}",
768                spec.trim()
769            )));
770        }
771
772        let header_str = header_line.trim();
773        if header_str.ends_with("missing") {
774            return Err(GitError::CommandFailed(format!(
775                "Path not found: {}",
776                spec.trim()
777            )));
778        }
779
780        let parts: Vec<&str> = header_str.split_whitespace().collect();
781        if parts.len() < 3 {
782            return Err(GitError::CommandFailed(format!(
783                "cat-file: malformed header: {}",
784                header_str
785            )));
786        }
787
788        let size: usize = parts[2].parse().map_err(|_| {
789            GitError::CommandFailed(format!("cat-file: invalid size in header: {}", header_str))
790        })?;
791
792        // Guard against allocating an unbounded blob. Anything larger than the
793        // biggest size we will ever parse is drained from the stream in bounded
794        // chunks (to keep the cat-file pipe in sync for the next request) and
795        // rejected, instead of allocating `size` bytes up front (OOM on a
796        // pathological multi-hundred-MB blob).
797        if size > crate::config::limits::MAX_BLOB_READ_BYTES {
798            let mut remaining = size;
799            let mut scratch = [0u8; 65536];
800            while remaining > 0 {
801                let want = remaining.min(scratch.len());
802                reader.read_exact(&mut scratch[..want])?;
803                remaining -= want;
804            }
805            let mut trailing = [0u8; 1];
806            let _ = reader.read_exact(&mut trailing);
807            return Err(GitError::CommandFailed(format!(
808                "cat-file: blob too large ({} bytes): {}",
809                size,
810                spec.trim()
811            )));
812        }
813
814        let mut content = vec![0u8; size];
815        reader.read_exact(&mut content)?;
816
817        let mut trailing = [0u8; 1];
818        let _ = reader.read_exact(&mut trailing);
819
820        Ok(String::from_utf8_lossy(&content).into_owned())
821    }
822
823    pub fn close(&mut self) {
824        self.reader.take();
825        if let Some(mut child) = self.child.take() {
826            drop(child.stdin.take());
827            match child.wait_timeout(Duration::from_secs(GIT.catfile_termination_timeout_seconds)) {
828                Ok(Some(_)) => {}
829                _ => {
830                    let _ = child.kill();
831                    let _ = child.wait();
832                }
833            }
834        }
835    }
836}
837
838impl Drop for CatFileBatch {
839    fn drop(&mut self) {
840        self.close();
841    }
842}