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
16// Thread-local, not a process global: the MCP server runs overlapping
17// pipelines on worker threads, and a shared atomic let one request's short
18// timeout kill another request's in-flight git subprocess (#210). Every git
19// call runs on the thread that entered the pipeline, so the per-thread value
20// is the per-run value.
21thread_local! {
22    static GIT_TIMEOUT_SECS: std::cell::Cell<u64> =
23        const { std::cell::Cell::new(git::DEFAULT_TIMEOUT_SECONDS) };
24}
25
26pub fn set_git_timeout(secs: u64) {
27    GIT_TIMEOUT_SECS.with(|c| c.set(secs));
28}
29
30// PID alone is not unique within a process: the MCP server runs each tool
31// body on its own worker thread, so two overlapping pipelines can both reach
32// `find_ignored_paths_with_source` under the same PID. A shared filename means whoever
33// finishes first deletes the other's still-in-use excludesFile; git tolerates
34// a missing `core.excludesFile` silently, so the loser's `.diffctx/ignore`
35// rules are dropped without error. The counter makes every call's temp path
36// unique regardless of thread interleaving.
37static TEMP_EXCLUDES_COUNTER: AtomicU64 = AtomicU64::new(0);
38
39fn git_timeout() -> u64 {
40    GIT_TIMEOUT_SECS.with(|c| c.get())
41}
42// The prefix and color flags are not cosmetic: the diff parser keys off the
43// literal `--- a/` / `+++ b/` headers, so a user's `diff.noprefix`,
44// `diff.mnemonicPrefix`, `diff.srcPrefix`/`dstPrefix` or `color.ui=always`
45// silently reduced every run to zero fragments and an empty `changed_files`.
46const SAFE_DIFF_FLAGS: &[&str] = &[
47    "--no-textconv",
48    "--no-ext-diff",
49    "--no-color",
50    "--src-prefix=a/",
51    "--dst-prefix=b/",
52];
53
54static HUNK_RE: Lazy<Regex> =
55    Lazy::new(|| Regex::new(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@").unwrap());
56
57static RANGE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\s*(\S+?)(\.\.\.?)(\S*?)\s*$").unwrap());
58
59// Neither side of a range may begin with `-`: a leading dash would be parsed
60// by git as an option rather than a revision, so a caller-supplied range like
61// `--ext-diff` or `--textconv` would re-enable the very filters SAFE_DIFF_FLAGS
62// disables and run repo-configured commands. Refs that begin with a dash are
63// unaddressable on a git command line anyway, so nothing legitimate is lost.
64static SAFE_RANGE_RE: Lazy<Regex> = Lazy::new(|| {
65    Regex::new(
66        r"^[a-zA-Z0-9_.^~/@{}][a-zA-Z0-9_.^~/@{}\-]*(\.\.\.?([a-zA-Z0-9_.^~/@{}][a-zA-Z0-9_.^~/@{}\-]*)?)?$",
67    )
68    .unwrap()
69});
70
71#[derive(Debug, thiserror::Error)]
72pub enum GitError {
73    #[error("{0}")]
74    CommandFailed(String),
75    #[error("not a git repository: {0}")]
76    NotARepo(PathBuf),
77    #[error("invalid diff range: {0}")]
78    InvalidRange(String),
79    #[error("io error: {0}")]
80    Io(#[from] std::io::Error),
81    #[error("timeout after {0}s")]
82    Timeout(u64),
83}
84
85pub type Result<T> = std::result::Result<T, GitError>;
86
87fn validate_diff_range(diff_range: &str) -> Result<()> {
88    let trimmed = diff_range.trim();
89    // Reject leading-dot ranges like `..origin/main` (audit X16): the regex
90    // character class allows `.`, so a string of only dots/identifier-chars
91    // passes as a "ref" before being rejected by git itself with a less
92    // informative `fatal: ambiguous argument`. Surface the dedicated error.
93    if trimmed.starts_with('.') || trimmed.starts_with('/') {
94        return Err(GitError::InvalidRange(diff_range.to_string()));
95    }
96    if !SAFE_RANGE_RE.is_match(trimmed) {
97        return Err(GitError::InvalidRange(diff_range.to_string()));
98    }
99    // The regex alone cannot enforce this: its leading character class is
100    // greedy over `.`, so it swallows the separator and never enters the
101    // second-side group — `a..--ext-diff` matched. Split and check each side,
102    // otherwise the documented "neither side may begin with a dash" gate is
103    // dead code and only the later per-rev check stands between a crafted
104    // range and an argv option.
105    let separator = if trimmed.contains("...") { "..." } else { ".." };
106    for side in trimmed.split(separator) {
107        if !side.is_empty() {
108            validate_rev(side).map_err(|_| GitError::InvalidRange(diff_range.to_string()))?;
109        }
110    }
111    Ok(())
112}
113
114/// Second gate for the single revisions derived from a range (`base`, `head`),
115/// covering the call sites that pass a rev straight into argv or into the
116/// `cat-file --batch` request stream. A leading dash turns the rev into a git
117/// option; whitespace and control characters (notably `\n`) would split one
118/// batch request into two.
119fn validate_rev(rev: &str) -> Result<()> {
120    if rev.is_empty()
121        || rev.starts_with('-')
122        || rev
123            .chars()
124            .any(|c| c.is_whitespace() || c.is_control() || c == '\0')
125    {
126        return Err(GitError::InvalidRange(rev.to_string()));
127    }
128    Ok(())
129}
130
131static DURATION_PART_RE: Lazy<Regex> = Lazy::new(|| {
132    Regex::new(
133        r"(?i)^(\d{1,9})\s*(weeks?|w|days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)",
134    )
135    .unwrap()
136});
137
138/// `--diff 24h` / `8d` / `1h30m`: a Go-style duration, not a revision.
139///
140/// Only a whole spec of `<number><unit>` components counts; anything left over
141/// makes the whole string a revision again, so `8dd` still reaches git as a ref.
142fn parse_duration_seconds(spec: &str) -> Option<u64> {
143    let mut rest = spec.trim();
144    if rest.is_empty() {
145        return None;
146    }
147    let mut total: u64 = 0;
148    while !rest.is_empty() {
149        let caps = DURATION_PART_RE.captures(rest)?;
150        let count: u64 = caps[1].parse().ok()?;
151        let unit_seconds = match caps[2].to_ascii_lowercase().as_str() {
152            "w" | "week" | "weeks" => 7 * 24 * 3600,
153            "d" | "day" | "days" => 24 * 3600,
154            "h" | "hr" | "hrs" | "hour" | "hours" => 3600,
155            "m" | "min" | "mins" | "minute" | "minutes" => 60,
156            _ => 1,
157        };
158        total = total.checked_add(count.checked_mul(unit_seconds)?)?;
159        rest = rest[caps[0].len()..].trim_start();
160    }
161    Some(total)
162}
163
164pub struct ResolvedRange {
165    pub range: Option<String>,
166    /// A duration resolves against the live working tree, so untracked files
167    /// belong in the change set exactly as they do for a bare `--diff`.
168    pub from_duration: bool,
169}
170
171impl ResolvedRange {
172    fn verbatim(diff_range: Option<&str>) -> Self {
173        Self {
174            range: diff_range.map(str::to_string),
175            from_duration: false,
176        }
177    }
178}
179
180/// The oid of the empty tree, used as the base when the repository is younger
181/// than the requested window: every file is then genuinely new within it.
182/// Derived from the repo's hash algorithm rather than hardcoded to sha1.
183fn empty_tree_oid(repo_root: &Path) -> String {
184    const SHA1_EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; // pragma: allowlist secret
185    const SHA256_EMPTY_TREE: &str =
186        "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321"; // pragma: allowlist secret
187    match run_git(repo_root, &["rev-parse", "--show-object-format"]) {
188        Ok(format) if format.trim() == "sha256" => SHA256_EMPTY_TREE.to_string(),
189        _ => SHA1_EMPTY_TREE.to_string(),
190    }
191}
192
193fn rev_exists(repo_root: &Path, rev: &str) -> bool {
194    if validate_rev(rev).is_err() {
195        return false;
196    }
197    let spec = format!("{rev}^{{commit}}");
198    run_git(repo_root, &["rev-parse", "--verify", "--quiet", &spec]).is_ok()
199}
200
201/// Resolves a duration spec to the last commit before the window.
202///
203/// Every other range is left untouched. `git diff <that commit>` covers both
204/// the commits made inside the window and the uncommitted work on top.
205///
206/// A ref that happens to look like a duration (a branch `24h`, an abbreviated
207/// sha `24d`) keeps its git meaning: the revision is probed first, so no
208/// existing invocation changes behaviour.
209pub fn resolve_duration_range(repo_root: &Path, diff_range: Option<&str>) -> Result<ResolvedRange> {
210    let Some(spec) = diff_range else {
211        return Ok(ResolvedRange::verbatim(None));
212    };
213    let trimmed = spec.trim();
214    let Some(seconds) = parse_duration_seconds(trimmed) else {
215        return Ok(ResolvedRange::verbatim(diff_range));
216    };
217    if rev_exists(repo_root, trimmed) {
218        return Ok(ResolvedRange::verbatim(diff_range));
219    }
220    // git owns the calendar arithmetic (local timezone, DST) via approxidate.
221    let before = format!("--before={seconds} seconds ago");
222    let base = run_git(repo_root, &["rev-list", "-1", &before, "HEAD", "--"])
223        .map(|out| out.trim().to_string())
224        .unwrap_or_default();
225    let base = if base.is_empty() {
226        empty_tree_oid(repo_root)
227    } else {
228        base
229    };
230    Ok(ResolvedRange {
231        range: Some(base),
232        from_duration: true,
233    })
234}
235
236/// Always targets the repo via `-C`.
237///
238/// Repo-locating variables inherited from a parent process (e.g. a git
239/// hook exporting `GIT_DIR` / `GIT_INDEX_FILE`) must be scrubbed or they
240/// silently redirect every command to the wrong repository.
241pub fn git_command(repo_root: &Path) -> Command {
242    let mut cmd = Command::new("git");
243    cmd.arg("-C")
244        .arg(repo_root)
245        .env_remove("GIT_DIR")
246        .env_remove("GIT_WORK_TREE")
247        .env_remove("GIT_INDEX_FILE");
248    cmd
249}
250
251pub fn run_git(repo_root: &Path, args: &[&str]) -> Result<String> {
252    let mut cmd = git_command(repo_root);
253    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
254
255    let child = cmd.spawn().map_err(|e| {
256        if e.kind() == std::io::ErrorKind::NotFound {
257            GitError::CommandFailed("git is not installed or not in PATH".into())
258        } else {
259            GitError::Io(e)
260        }
261    })?;
262
263    let output = wait_with_timeout(child, Duration::from_secs(git_timeout()), args)?;
264
265    if !output.status.success() {
266        let stderr = String::from_utf8_lossy(&output.stderr);
267        let subcommand = args
268            .iter()
269            .find(|a| !a.starts_with('-'))
270            .copied()
271            .unwrap_or("command");
272        let reason = stderr
273            .lines()
274            .map(str::trim)
275            .find(|l| l.starts_with("fatal:") || l.starts_with("error:"))
276            .or_else(|| stderr.lines().map(str::trim).find(|l| !l.is_empty()))
277            .unwrap_or("unknown error");
278        return Err(GitError::CommandFailed(format!(
279            "git {subcommand} failed: {reason}"
280        )));
281    }
282
283    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
284}
285
286fn wait_with_timeout(
287    child: Child,
288    timeout: Duration,
289    _args: &[&str],
290) -> Result<std::process::Output> {
291    let mut child = child;
292    let stdout_handle = child.stdout.take().map(|mut s| {
293        std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
294            let mut buf = Vec::new();
295            s.read_to_end(&mut buf)?;
296            Ok(buf)
297        })
298    });
299    let stderr_handle = child.stderr.take().map(|mut s| {
300        std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
301            let mut buf = Vec::new();
302            s.read_to_end(&mut buf)?;
303            Ok(buf)
304        })
305    });
306
307    let status = match child.wait_timeout(timeout)? {
308        Some(status) => status,
309        None => {
310            let _ = child.kill();
311            let _ = child.wait();
312            return Err(GitError::Timeout(timeout.as_secs()));
313        }
314    };
315
316    let stdout = stdout_handle
317        .and_then(|h| h.join().ok())
318        .and_then(|r| r.ok())
319        .unwrap_or_default();
320    let stderr = stderr_handle
321        .and_then(|h| h.join().ok())
322        .and_then(|r| r.ok())
323        .unwrap_or_default();
324
325    Ok(std::process::Output {
326        status,
327        stdout,
328        stderr,
329    })
330}
331
332pub fn is_git_repo(path: &Path) -> bool {
333    run_git(path, &["rev-parse", "--git-dir"]).is_ok()
334}
335
336/// Resolves the actual working-tree root for `path`, which may be a
337/// subdirectory of the repository. `git diff`/`git cat-file` paths are
338/// always reported relative to this root, not to an arbitrary `-C` cwd -
339/// running the pipeline with `path` still set to a subdirectory silently
340/// produces zero fragments because file lookups get double-prefixed
341/// (e.g. `src/src/app.py`).
342pub fn find_toplevel(path: &Path) -> Option<PathBuf> {
343    let out = run_git(path, &["rev-parse", "--show-toplevel"]).ok()?;
344    let trimmed = out.trim();
345    if trimmed.is_empty() {
346        return None;
347    }
348    Some(PathBuf::from(trimmed))
349}
350
351pub fn get_diff_text(repo_root: &Path, diff_range: Option<&str>) -> Result<String> {
352    let mut args: Vec<&str> = vec!["diff"];
353    args.extend_from_slice(SAFE_DIFF_FLAGS);
354    if let Some(range) = diff_range {
355        validate_diff_range(range)?;
356        args.push(range);
357    }
358    run_git(repo_root, &args)
359}
360
361pub(crate) fn unquote_c_style(quoted: &str) -> String {
362    if !(quoted.starts_with('"') && quoted.ends_with('"')) {
363        return quoted.to_string();
364    }
365
366    let raw = &quoted[1..quoted.len() - 1];
367    let bytes = raw.as_bytes();
368    let mut result: Vec<u8> = Vec::with_capacity(bytes.len());
369    let mut i = 0;
370
371    while i < bytes.len() {
372        if bytes[i] == b'\\' && i + 1 < bytes.len() {
373            let nxt = bytes[i + 1];
374            match nxt {
375                b't' => {
376                    result.push(b'\t');
377                    i += 2;
378                }
379                b'n' => {
380                    result.push(b'\n');
381                    i += 2;
382                }
383                b'r' => {
384                    result.push(b'\r');
385                    i += 2;
386                }
387                b'b' => {
388                    result.push(0x08);
389                    i += 2;
390                }
391                b'f' => {
392                    result.push(0x0C);
393                    i += 2;
394                }
395                b'v' => {
396                    result.push(0x0B);
397                    i += 2;
398                }
399                b'a' => {
400                    result.push(0x07);
401                    i += 2;
402                }
403                b'\\' => {
404                    result.push(b'\\');
405                    i += 2;
406                }
407                b'"' => {
408                    result.push(b'"');
409                    i += 2;
410                }
411                b'0'..=b'7'
412                    if i + 3 < bytes.len()
413                        && bytes[i + 2].is_ascii_digit()
414                        && bytes[i + 2] <= b'7'
415                        && bytes[i + 3].is_ascii_digit()
416                        && bytes[i + 3] <= b'7' =>
417                {
418                    let val = (nxt - b'0') * 64 + (bytes[i + 2] - b'0') * 8 + (bytes[i + 3] - b'0');
419                    result.push(val);
420                    i += 4;
421                }
422                _ => {
423                    result.push(b'\\');
424                    i += 1;
425                }
426            }
427        } else {
428            result.push(bytes[i]);
429            i += 1;
430        }
431    }
432
433    String::from_utf8(result).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
434}
435
436/// Resolves a diff-header path against the repository root, or `None` if it
437/// does not stay inside it.
438///
439/// `Path::starts_with` compares components, not locations, and
440/// `canonicalize` fails for any path that does not exist — which is the
441/// normal case for the old side of a deletion, and for a crafted header
442/// naming a file that was never there. The previous guard fell back to the
443/// lexically joined path in that case, and `<root>/../../escape.py` starts
444/// with `<root>` component-wise, so the check passed and the header was
445/// accepted. It only ever failed on macOS, where the temp root canonicalizes
446/// through `/var -> /private/var` and the two spellings stop matching.
447///
448/// A `..` component is therefore rejected outright, before any of this:
449/// git does not emit one for a tracked path, so nothing legitimate needs it,
450/// and downstream `strip_prefix` guards are lexical for exactly the same
451/// reason. Absolute paths are refused for the same reason — `Path::join`
452/// with an absolute argument discards the root entirely.
453pub(crate) fn resolve_in_repo(repo_root: &Path, rel_path: &str) -> Option<PathBuf> {
454    let rel = Path::new(rel_path);
455    if rel.is_absolute()
456        || rel
457            .components()
458            .any(|c| matches!(c, std::path::Component::ParentDir))
459    {
460        return None;
461    }
462
463    let joined = repo_root.join(rel);
464    // Compare like for like. Falling back to the lexical spelling as an
465    // *alternative* to the canonical check (rather than only when
466    // canonicalization is impossible) would make the canonical check dead: with
467    // `..` already excluded, `joined` always starts with `repo_root`, so an
468    // in-repo symlink pointing outside the tree would resolve outside and still
469    // be accepted.
470    match joined.canonicalize() {
471        Ok(resolved) => {
472            let resolved_root = repo_root
473                .canonicalize()
474                .unwrap_or_else(|_| repo_root.to_path_buf());
475            if !resolved.starts_with(&resolved_root) {
476                return None;
477            }
478        }
479        // Unresolvable: the old side of a deletion, or a path that never
480        // existed. `..` and absolute components are rejected above, so the
481        // lexical join cannot leave the root and needs no further check —
482        // which is also why the canonical root's spelling (`/var` vs
483        // `/private/var`) cannot cause a false rejection here.
484        Err(_) => {}
485    }
486    Some(joined)
487}
488
489pub(crate) fn parse_path_line(line: &str, repo_root: &Path) -> (&'static str, Option<PathBuf>) {
490    if line.starts_with("--- /dev/null") {
491        return ("old", None);
492    }
493    if line.starts_with("+++ /dev/null") {
494        return ("new", None);
495    }
496
497    let (kind, rel_path) = if let Some(rest) = line.strip_prefix("--- a/") {
498        ("old", rest.trim().to_string())
499    } else if let Some(rest) = line.strip_prefix("+++ b/") {
500        ("new", rest.trim().to_string())
501    } else if let Some(rest) = line.strip_prefix("--- ").filter(|r| r.starts_with("\"a/")) {
502        let unquoted = unquote_c_style(rest.trim());
503        (
504            "old",
505            unquoted.strip_prefix("a/").unwrap_or(&unquoted).to_string(),
506        )
507    } else if let Some(rest) = line.strip_prefix("+++ ").filter(|r| r.starts_with("\"b/")) {
508        let unquoted = unquote_c_style(rest.trim());
509        (
510            "new",
511            unquoted.strip_prefix("b/").unwrap_or(&unquoted).to_string(),
512        )
513    } else {
514        return ("", None);
515    };
516
517    match resolve_in_repo(repo_root, &rel_path) {
518        Some(path) => (kind, Some(path)),
519        None => ("", None),
520    }
521}
522
523fn parse_hunk_header(caps: &regex::Captures, path: &Path) -> Option<DiffHunk> {
524    // Adversarial-diff hardening: integers parsed from the hunk regex are
525    // small ASCII digits matched by `\d+`, so `parse::<u32>` can only fail
526    // on overflow (e.g. lines > 2^32). Skip such hunks instead of crashing
527    // the host process — they are degenerate and have no useful semantics.
528    let old_start: u32 = caps[1].parse().ok()?;
529    let old_len: u32 = match caps.get(2) {
530        Some(m) => m.as_str().parse().ok()?,
531        None => 1,
532    };
533    let new_start: u32 = caps[3].parse().ok()?;
534    let new_len: u32 = match caps.get(4) {
535        Some(m) => m.as_str().parse().ok()?,
536        None => 1,
537    };
538
539    Some(DiffHunk {
540        path: Arc::from(path.to_string_lossy().as_ref()),
541        new_start,
542        new_len,
543        old_start,
544        old_len,
545    })
546}
547
548pub fn parse_diff(repo_root: &Path, diff_range: Option<&str>) -> Result<Vec<DiffHunk>> {
549    let mut args: Vec<&str> = vec!["diff"];
550    args.extend_from_slice(SAFE_DIFF_FLAGS);
551    args.push("--unified=0");
552    args.push("-M");
553    if let Some(range) = diff_range {
554        validate_diff_range(range)?;
555        args.push(range);
556    }
557
558    let output = run_git(repo_root, &args)?;
559    Ok(parse_hunks_from_diff_output(&output, repo_root))
560}
561
562pub(crate) fn parse_hunks_from_diff_output(output: &str, repo_root: &Path) -> Vec<DiffHunk> {
563    let mut hunks = Vec::new();
564    let mut old_path: Option<PathBuf> = None;
565    let mut new_path: Option<PathBuf> = None;
566
567    for line in output.lines() {
568        // `diff --git` is the per-file boundary git always emits, so clearing
569        // here is what keeps one file's hunks from ever being charged to the
570        // previous one. Relying on the `---`/`+++` pair alone leaves the
571        // previous file's paths live whenever this file's header is not
572        // understood or is rejected — a path escaping the repo root returns
573        // `("", None)`, which is a refusal, not "keep the last path".
574        if line.starts_with("diff --git ") {
575            old_path = None;
576            new_path = None;
577            continue;
578        }
579
580        let (path_type, path) = parse_path_line(line, repo_root);
581        match path_type {
582            "old" => {
583                old_path = path;
584                continue;
585            }
586            "new" => {
587                new_path = path;
588                continue;
589            }
590            _ => {}
591        }
592
593        if let Some(caps) = HUNK_RE.captures(line) {
594            let current_path = new_path.as_deref().or(old_path.as_deref());
595            if let Some(p) = current_path {
596                if let Some(hunk) = parse_hunk_header(&caps, p) {
597                    hunks.push(hunk);
598                }
599            }
600        }
601    }
602
603    hunks
604}
605
606pub fn run_git_z(repo_root: &Path, args: &[&str]) -> Result<Vec<String>> {
607    let output = run_git(repo_root, args)?;
608    Ok(output
609        .split('\0')
610        .filter(|s| !s.is_empty())
611        .map(String::from)
612        .collect())
613}
614
615pub fn get_changed_files(repo_root: &Path, diff_range: Option<&str>) -> Result<Vec<PathBuf>> {
616    let mut args: Vec<&str> = vec!["diff"];
617    args.extend_from_slice(SAFE_DIFF_FLAGS);
618    args.extend_from_slice(&["--name-only", "-M", "-z"]);
619    if let Some(range) = diff_range {
620        validate_diff_range(range)?;
621        args.push(range);
622    }
623    let parts = run_git_z(repo_root, &args)?;
624    Ok(parts
625        .iter()
626        .map(|p| {
627            repo_root
628                .join(p)
629                .canonicalize()
630                .unwrap_or_else(|_| repo_root.join(p))
631        })
632        .collect())
633}
634
635pub fn get_deleted_files(repo_root: &Path, diff_range: Option<&str>) -> Result<FxHashSet<PathBuf>> {
636    let mut args: Vec<&str> = vec!["diff"];
637    args.extend_from_slice(SAFE_DIFF_FLAGS);
638    args.extend_from_slice(&["--diff-filter=D", "--name-only", "-M", "-z"]);
639    if let Some(range) = diff_range {
640        validate_diff_range(range)?;
641        args.push(range);
642    }
643    let parts = run_git_z(repo_root, &args)?;
644    Ok(parts
645        .iter()
646        .map(|p| {
647            repo_root
648                .join(p)
649                .canonicalize()
650                .unwrap_or_else(|_| repo_root.join(p))
651        })
652        .collect())
653}
654
655/// The `R` records of a rename-only diff, as raw `(old, new)` strings.
656///
657/// `--name-status -z` emits renames as `R<similarity>\0old\0new\0`, so the walk
658/// steps three fields per record and one otherwise. Both callers below ran their
659/// own copy of that walk; they disagreed on validation, one accepting a record
660/// whose destination was missing. A rename without a destination is not a
661/// rename, so the stricter reading is the one kept here.
662fn rename_records(repo_root: &Path, diff_range: Option<&str>) -> Result<Vec<(String, String)>> {
663    let mut args: Vec<&str> = vec!["diff"];
664    args.extend_from_slice(SAFE_DIFF_FLAGS);
665    args.extend_from_slice(&["--diff-filter=R", "--name-status", "-M", "-z"]);
666    if let Some(range) = diff_range {
667        validate_diff_range(range)?;
668        args.push(range);
669    }
670    let output = run_git(repo_root, &args)?;
671    let parts: Vec<&str> = output.split('\0').collect();
672
673    let mut records = Vec::new();
674    let mut i = 0;
675    while i < parts.len() {
676        if parts[i].starts_with('R') {
677            if i + 2 < parts.len() && !parts[i + 1].is_empty() && !parts[i + 2].is_empty() {
678                records.push((parts[i + 1].to_string(), parts[i + 2].to_string()));
679            }
680            i += 3;
681        } else {
682            i += 1;
683        }
684    }
685    Ok(records)
686}
687
688/// Rename *source* paths, canonicalized. These no longer exist on disk and
689/// cannot be fragmented, so the pipeline excludes them from the changed set.
690/// The rename destinations need no special handling: they exist on HEAD and
691/// reach the universe through the ordinary changed-file path.
692pub fn get_renamed_paths(repo_root: &Path, diff_range: Option<&str>) -> Result<FxHashSet<PathBuf>> {
693    Ok(rename_records(repo_root, diff_range)?
694        .into_iter()
695        .map(|(old, _)| {
696            repo_root
697                .join(&old)
698                .canonicalize()
699                .unwrap_or_else(|_| repo_root.join(&old))
700        })
701        .collect())
702}
703
704/// Rename pairs as repo-relative display paths (`old -> new`), for the output
705/// header. Unlike `get_renamed_paths` this preserves the pairing and does not
706/// canonicalize (the old path no longer exists on disk).
707pub fn get_rename_pairs(
708    repo_root: &Path,
709    diff_range: Option<&str>,
710) -> Result<Vec<(String, String)>> {
711    Ok(rename_records(repo_root, diff_range)?
712        .into_iter()
713        .map(|(old, new)| {
714            (
715                crate::paths::to_posix_display(std::borrow::Cow::Owned(old)),
716                crate::paths::to_posix_display(std::borrow::Cow::Owned(new)),
717            )
718        })
719        .collect())
720}
721
722pub fn split_diff_range(range: &str) -> (Option<String>, Option<String>) {
723    match RANGE_RE.captures(range) {
724        None => (None, None),
725        Some(caps) => {
726            let base = caps
727                .get(1)
728                .map(|m| m.as_str().trim().to_string())
729                .filter(|s| !s.is_empty());
730            let head = caps
731                .get(3)
732                .map(|m| m.as_str().trim().to_string())
733                .filter(|s| !s.is_empty());
734            (base, head)
735        }
736    }
737}
738
739pub fn show_file_at_revision(repo_root: &Path, rev: &str, rel_path: &Path) -> Result<String> {
740    validate_rev(rev)?;
741    let spec = format!("{}:{}", rev, rel_path.to_string_lossy().replace('\\', "/"));
742    run_git(repo_root, &["show", &spec])
743}
744
745pub fn get_commit_message(repo_root: &Path, rev: &str) -> Result<String> {
746    if validate_rev(rev).is_err() {
747        return Ok(String::new());
748    }
749    match run_git(repo_root, &["log", "-1", "--format=%s%n%b", rev]) {
750        Ok(s) => Ok(s.trim().to_string()),
751        Err(_) => Ok(String::new()),
752    }
753}
754
755pub fn get_untracked_files(repo_root: &Path) -> Result<Vec<PathBuf>> {
756    let parts = run_git_z(
757        repo_root,
758        &["ls-files", "--others", "--exclude-standard", "-z"],
759    )?;
760    Ok(parts
761        .iter()
762        .map(|p| {
763            repo_root
764                .join(p)
765                .canonicalize()
766                .unwrap_or_else(|_| repo_root.join(p))
767        })
768        .collect())
769}
770
771/// Rewrites one `.diffctx/ignore` pattern line to be anchored to the
772/// directory that contains the `.diffctx/` folder (`rel`, repo-root-relative,
773/// "" for the repo root itself). Mirrors `_process_ignore_line` in the
774/// Python tree-mode ignore resolver (`src/diffctx/ignore.py`) so a pattern
775/// declared in `sub/.diffctx/ignore` only ever matches within `sub/`.
776fn anchor_diffctx_ignore_line(line: &str, rel: &str) -> String {
777    let (neg, pat) = match line.strip_prefix('!') {
778        Some(rest) => (true, rest),
779        None => (false, line),
780    };
781    let pat_no_trailing_slash = pat.trim_end_matches('/');
782    let full = if pat_no_trailing_slash.starts_with('/') || pat_no_trailing_slash.contains('/') {
783        let anchored = pat.trim_start_matches('/');
784        if rel.is_empty() {
785            format!("/{anchored}")
786        } else {
787            format!("/{rel}/{anchored}")
788        }
789    } else if rel.is_empty() {
790        pat.to_string()
791    } else {
792        format!("{rel}/**/{pat}")
793    };
794    if neg { format!("!{full}") } else { full }
795}
796
797/// Writes `content` to a fresh file in the system temp directory and returns
798/// its path, or `None` if no such file could be created.
799///
800/// The name used to be `diffctx-ignore-<pid>-<counter>.tmp` written with
801/// `fs::write`, which follows symlinks and truncates the target. Both the pid
802/// and the counter are guessable, so on a shared machine anyone able to write
803/// to the temp directory could pre-plant that name as a symlink and have this
804/// overwrite the file it points at, with repository-controlled content.
805/// `create_new` is `O_CREAT | O_EXCL`: it refuses an existing path, symlink
806/// included, so a planted name costs at most a retry — and if every attempt
807/// loses, the caller degrades to "no `.diffctx/ignore` patterns", which is
808/// already its documented best-effort behaviour.
809fn write_private_temp_file(content: &str) -> Option<PathBuf> {
810    use std::io::Write;
811
812    let dir = std::env::temp_dir();
813    for _ in 0..8 {
814        let unique = TEMP_EXCLUDES_COUNTER.fetch_add(1, Ordering::Relaxed);
815        let nanos = std::time::SystemTime::now()
816            .duration_since(std::time::UNIX_EPOCH)
817            .map(|d| d.subsec_nanos())
818            .unwrap_or(0);
819        let path = dir.join(format!(
820            "diffctx-ignore-{}-{unique}-{nanos}.tmp",
821            std::process::id()
822        ));
823        match create_new_private_file(&path) {
824            Ok(mut file) => {
825                return match file
826                    .write_all(content.as_bytes())
827                    .and_then(|()| file.flush())
828                {
829                    Ok(()) => Some(path),
830                    Err(_) => {
831                        let _ = std::fs::remove_file(&path);
832                        None
833                    }
834                };
835            }
836            Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
837            Err(_) => return None,
838        }
839    }
840    None
841}
842
843/// `O_CREAT | O_EXCL` (`create_new`), which refuses any existing path —
844/// a planted symlink included — instead of following it.
845fn create_new_private_file(path: &Path) -> std::io::Result<std::fs::File> {
846    let mut opts = std::fs::OpenOptions::new();
847    opts.write(true).create_new(true);
848    #[cfg(unix)]
849    {
850        use std::os::unix::fs::OpenOptionsExt;
851        opts.mode(0o600);
852    }
853    opts.open(path)
854}
855
856/// Finds every `.diffctx/ignore` file tracked or present in `repo_root`
857/// (any depth) and returns its patterns rewritten to be repo-root-relative,
858/// ready to feed into a combined gitignore-syntax exclude file.
859fn collect_diffctx_ignore_patterns(repo_root: &Path) -> Vec<String> {
860    let Ok(files) = run_git_z(
861        repo_root,
862        &[
863            "ls-files",
864            "-z",
865            "--cached",
866            "--others",
867            "--exclude-standard",
868            "--",
869            ":(glob)**/.diffctx/ignore",
870        ],
871    ) else {
872        return Vec::new();
873    };
874
875    let mut patterns = Vec::new();
876    for raw in &files {
877        let rel_path = unquote_c_style(raw);
878        if !rel_path.ends_with(".diffctx/ignore") {
879            continue;
880        }
881        let rel_dir = rel_path
882            .strip_suffix(".diffctx/ignore")
883            .unwrap_or("")
884            .trim_end_matches('/');
885        let Ok(content) = std::fs::read_to_string(repo_root.join(&rel_path)) else {
886            continue;
887        };
888        for line in content.lines() {
889            let line = line.trim_end();
890            if line.is_empty() || line.starts_with('#') {
891                continue;
892            }
893            patterns.push(anchor_diffctx_ignore_line(line, rel_dir));
894        }
895    }
896    patterns
897}
898
899/// Which rule family excluded a path. `.diffctx/ignore` is a declared
900/// confidentiality policy, so its exclusions are surfaced only as a count;
901/// gitignore exclusions are mundane and can be listed by path (#188).
902#[derive(Clone, Copy, PartialEq, Eq, Debug)]
903pub enum IgnoreSource {
904    DiffctxPolicy,
905    Gitignore,
906}
907
908/// Returns the subset of `rel_paths` (repo-root-relative) excluded by either
909/// `.gitignore` (via git's own engine, so nesting/negation/`**` are handled
910/// correctly) or `.diffctx/ignore` (patterns anchored per-directory and fed
911/// to git as a temporary `core.excludesFile`, so the same engine evaluates
912/// both mechanisms uniformly), mapped to the rule family that excluded each.
913/// Best-effort: any failure returns an empty map rather than blocking the
914/// diff pipeline on an ignore-resolution problem.
915///
916/// A `.gitignore` exclusion inherited from an excluded ancestor directory does
917/// NOT count. `--no-index` is required for `.diffctx/ignore` to apply to
918/// tracked files at all, but it also revives git's rule that a file cannot be
919/// re-included once a parent directory is excluded. pandoc excludes every
920/// dotted root entry with `/*.*` and re-includes `!.github/**`: git keeps
921/// `.github/workflows/ci.yml` because it is tracked, while `--no-index`
922/// reports it ignored *via the ancestor* — which silently reduced a real
923/// change to an empty selection (#153). A pattern matching the path itself
924/// still excludes it, so `.diffctx/ignore` and per-directory `.gitignore`
925/// rules (#85) keep working.
926pub fn find_ignored_paths_with_source(
927    repo_root: &Path,
928    rel_paths: &[String],
929) -> rustc_hash::FxHashMap<String, IgnoreSource> {
930    if rel_paths.is_empty() {
931        return rustc_hash::FxHashMap::default();
932    }
933
934    let diffctx_patterns = collect_diffctx_ignore_patterns(repo_root);
935    let temp_excludes = if diffctx_patterns.is_empty() {
936        None
937    } else {
938        write_private_temp_file(&diffctx_patterns.join("\n"))
939    };
940
941    // Ancestors are queried alongside the paths themselves so an exclusion can
942    // be attributed: same winning rule on a parent directory means the file was
943    // only caught transitively.
944    let mut queries: Vec<String> = rel_paths.to_vec();
945    let mut ancestors: FxHashSet<String> = FxHashSet::default();
946    for rel in rel_paths {
947        for ancestor in ancestor_dirs(rel) {
948            if ancestors.insert(ancestor.clone()) {
949                queries.push(ancestor);
950            }
951        }
952    }
953
954    // Paths go over stdin, not argv. Every diff path plus every ancestor
955    // directory used to be passed as arguments, so a monorepo-sized range hit
956    // the platform argv limit, `git` failed to spawn, and the fail-open tail
957    // below turned that into "nothing is ignored" — silently disabling the
958    // `.diffctx/ignore` contract exactly when the repo is large enough for it
959    // to matter. stdin has no such ceiling.
960    // `-z` on BOTH sides. Line-delimited input splits a path containing a
961    // newline into two phantom queries: git then answers about `secret` and
962    // `name.py` while the real path `secret\nname.py` never gets a verdict, so
963    // the lookup below misses it and a file the user declared ignored is
964    // published. Verified against git directly — line mode reports the
965    // truncated stem, NUL mode reports the whole path. The rest of this module
966    // is already `-z` throughout.
967    let mut args: Vec<String> = vec![
968        "check-ignore".into(),
969        "--no-index".into(),
970        "-v".into(),
971        "-z".into(),
972        "--stdin".into(),
973    ];
974    if let Some(ref path) = temp_excludes {
975        args.insert(0, format!("core.excludesFile={}", path.display()));
976        args.insert(0, "-c".into());
977    }
978    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
979    // Paths reach `--stdin` through a file rather than a pipe we write
980    // ourselves. Writing the whole payload into a pipe before reading stdout
981    // deadlocks as soon as it outgrows the pipe buffer: git blocks emitting
982    // matches that nobody is draining, we block on the write, and the pipeline
983    // hangs until the git timeout fires. A file hands the feeding to the kernel,
984    // which needs no second thread to stay correct.
985    let query_file = write_private_temp_file(&format!("{}\0", queries.join("\0")));
986
987    let result = (|| -> Result<rustc_hash::FxHashMap<String, IgnoreSource>> {
988        let Some(ref query_path) = query_file else {
989            return Err(GitError::CommandFailed(
990                "could not stage check-ignore query paths".into(),
991            ));
992        };
993        let mut cmd = git_command(repo_root);
994        cmd.args(&arg_refs)
995            .stdin(Stdio::from(std::fs::File::open(query_path)?))
996            .stdout(Stdio::piped())
997            .stderr(Stdio::piped());
998        let child = cmd.spawn()?;
999        let output = wait_with_timeout(child, Duration::from_secs(git_timeout()), &arg_refs)?;
1000        // Exit code 1 from `check-ignore` means "none of the given paths are
1001        // ignored" — not a failure. Any other non-zero code is a real error.
1002        if !output.status.success() && output.status.code() != Some(1) {
1003            let stderr = String::from_utf8_lossy(&output.stderr);
1004            return Err(GitError::CommandFailed(format!(
1005                "git check-ignore failed: {}",
1006                stderr.trim()
1007            )));
1008        }
1009        let stdout = String::from_utf8_lossy(&output.stdout);
1010        let excludes_source = temp_excludes.as_ref().map(|p| p.display().to_string());
1011
1012        let rules = parse_verbose_ignore_records(&stdout);
1013
1014        Ok(rel_paths
1015            .iter()
1016            .filter_map(|rel| {
1017                let rule = rules.get(rel)?;
1018                let from_diffctx = excludes_source
1019                    .as_deref()
1020                    .is_some_and(|src| rule.starts_with(&format!("{src}:")));
1021                if from_diffctx {
1022                    Some((rel.clone(), IgnoreSource::DiffctxPolicy))
1023                } else if !ancestor_dirs(rel)
1024                    .iter()
1025                    .any(|dir| rules.get(dir) == Some(rule))
1026                {
1027                    Some((rel.clone(), IgnoreSource::Gitignore))
1028                } else {
1029                    None
1030                }
1031            })
1032            .collect())
1033    })();
1034
1035    if let Some(path) = temp_excludes {
1036        let _ = std::fs::remove_file(path);
1037    }
1038    if let Some(path) = query_file {
1039        let _ = std::fs::remove_file(path);
1040    }
1041
1042    // Fail closed only where a policy was actually declared.
1043    //
1044    // The returned set is "exclude these", so `unwrap_or_default()` answered a
1045    // failed check with "nothing is ignored" — the one answer that leaks, and
1046    // QA.md calls `.diffctx/ignore` a security contract. But failing closed
1047    // unconditionally is too blunt: `check-ignore` cannot run in a bare clone at
1048    // all, and excluding everything turned a supported repo shape into empty
1049    // output.
1050    //
1051    // So the two cases are separated by whether the user declared anything. With
1052    // `.diffctx/ignore` patterns present, a failed check must not silently
1053    // publish what they asked to withhold. Without them the only loss is
1054    // best-effort gitignore filtering, and refusing to emit anything would be a
1055    // worse answer than emitting a bare clone's diff. A bare repo has no working
1056    // tree, so it never has patterns and always lands on the open branch.
1057    let policy_declared = !diffctx_patterns.is_empty();
1058    result.unwrap_or_else(|e| {
1059        if policy_declared {
1060            tracing::error!(
1061                "git check-ignore failed ({e}); .diffctx/ignore declares {} pattern(s), so all \
1062                 {} queried paths are treated as ignored rather than risk publishing them",
1063                diffctx_patterns.len(),
1064                rel_paths.len()
1065            );
1066            rel_paths
1067                .iter()
1068                .map(|p| (p.clone(), IgnoreSource::DiffctxPolicy))
1069                .collect()
1070        } else {
1071            tracing::warn!(
1072                "git check-ignore failed ({e}); no .diffctx/ignore patterns are declared, so \
1073                 gitignore filtering is skipped for this run"
1074            );
1075            rustc_hash::FxHashMap::default()
1076        }
1077    })
1078}
1079
1080/// `check-ignore -v` emits `<source>:<line>:<pattern>\t<path>`; returns the
1081/// `<source>:<line>:<pattern>` rule identity and the path it matched.
1082///
1083/// Split from the right: git prints the pattern raw but C-quotes any path
1084/// containing a tab, so the last tab is always the separator. Splitting from
1085/// the left mis-parses a pattern that itself contains a tab, and the resulting
1086/// lookup miss reports an ignored file as not ignored — i.e. it leaks.
1087/// `check-ignore -v -z` emits four NUL-separated fields per match —
1088/// `<source>\0<line>\0<pattern>\0<path>\0` — and never quotes the path,
1089/// because NUL cannot appear in one.
1090///
1091/// The text format this replaced (`<source>:<line>:<pattern>\t<path>`) had to
1092/// guess where the pattern ended and the path began, and C-quoting made a path
1093/// containing a tab or a newline ambiguous. Field-delimited records remove the
1094/// guess entirely.
1095fn parse_verbose_ignore_records(stdout: &str) -> rustc_hash::FxHashMap<String, String> {
1096    let mut rules: rustc_hash::FxHashMap<String, String> = rustc_hash::FxHashMap::default();
1097    let fields: Vec<&str> = stdout.split('\0').collect();
1098    // A trailing NUL leaves an empty final element; chunks of four skip it.
1099    for record in fields.chunks(4) {
1100        if record.len() < 4 {
1101            break;
1102        }
1103        let (source, line, pattern, path) = (record[0], record[1], record[2], record[3]);
1104        if path.is_empty() {
1105            continue;
1106        }
1107        // `check-ignore -v` also prints a record when the LAST matching
1108        // pattern is a negation — the path is then explicitly NOT ignored
1109        // (plain `check-ignore` exits 1 for it). Treating any record as "this
1110        // path is ignored" inverted the meaning: a repository that un-ignores
1111        // a file (`!SECURITY.md`) had that file silently dropped from --diff
1112        // output (#193). The same reading is right for `.diffctx/ignore`: a
1113        // negation there is the user explicitly re-including a path in the
1114        // policy's own terms.
1115        if pattern.starts_with('!') {
1116            continue;
1117        }
1118        // The rule identity keeps the text format's shape: callers compare it
1119        // against `format!("{excludes_source}:")` to tell a diffctx-declared
1120        // exclusion from a gitignore one.
1121        rules.insert(path.to_string(), format!("{source}:{line}:{pattern}"));
1122    }
1123    rules
1124}
1125
1126fn ancestor_dirs(rel: &str) -> Vec<String> {
1127    let mut dirs = Vec::new();
1128    let mut remainder = rel;
1129    while let Some((parent, _)) = remainder.rsplit_once('/') {
1130        dirs.push(parent.to_string());
1131        remainder = parent;
1132    }
1133    dirs
1134}
1135
1136pub struct CatFileBatch {
1137    repo_root: PathBuf,
1138    child: Option<Child>,
1139    reader: Option<BufReader<ChildStdout>>,
1140}
1141
1142impl CatFileBatch {
1143    pub fn new(repo_root: &Path) -> Result<Self> {
1144        let mut batch = Self {
1145            repo_root: repo_root.to_path_buf(),
1146            child: None,
1147            reader: None,
1148        };
1149        batch.ensure_started()?;
1150        Ok(batch)
1151    }
1152
1153    fn ensure_started(&mut self) -> Result<()> {
1154        let needs_restart = match &mut self.child {
1155            None => true,
1156            Some(child) => child.try_wait().ok().flatten().is_some(),
1157        };
1158
1159        if needs_restart {
1160            let mut child = git_command(&self.repo_root)
1161                .args(["cat-file", "--batch"])
1162                .stdin(Stdio::piped())
1163                .stdout(Stdio::piped())
1164                .stderr(Stdio::null())
1165                .spawn()?;
1166            let stdout = child.stdout.take().ok_or_else(|| {
1167                GitError::CommandFailed("cat-file: failed to capture stdout pipe".into())
1168            })?;
1169            self.reader = Some(BufReader::new(stdout));
1170            self.child = Some(child);
1171        }
1172
1173        Ok(())
1174    }
1175
1176    pub fn get(&mut self, rev: &str, rel_path: &Path) -> Result<String> {
1177        validate_rev(rev)?;
1178        let spec = format!(
1179            "{}:{}\n",
1180            rev,
1181            rel_path.to_string_lossy().replace('\\', "/")
1182        );
1183
1184        self.ensure_started()?;
1185
1186        let stdin = self
1187            .child
1188            .as_mut()
1189            .and_then(|c| c.stdin.as_mut())
1190            .ok_or_else(|| GitError::CommandFailed("cat-file stdin unavailable".into()))?;
1191        stdin.write_all(spec.as_bytes())?;
1192        stdin.flush()?;
1193
1194        let reader = self
1195            .reader
1196            .as_mut()
1197            .ok_or_else(|| GitError::CommandFailed("cat-file stdout unavailable".into()))?;
1198
1199        let mut header_line = String::new();
1200        reader.read_line(&mut header_line)?;
1201
1202        if header_line.is_empty() {
1203            return Err(GitError::CommandFailed(format!(
1204                "cat-file: unexpected EOF for {}",
1205                spec.trim()
1206            )));
1207        }
1208
1209        let header_str = header_line.trim();
1210        if header_str.ends_with("missing") {
1211            return Err(GitError::CommandFailed(format!(
1212                "Path not found: {}",
1213                spec.trim()
1214            )));
1215        }
1216
1217        let parts: Vec<&str> = header_str.split_whitespace().collect();
1218        if parts.len() < 3 {
1219            return Err(GitError::CommandFailed(format!(
1220                "cat-file: malformed header: {}",
1221                header_str
1222            )));
1223        }
1224
1225        let size: usize = parts[2].parse().map_err(|_| {
1226            GitError::CommandFailed(format!("cat-file: invalid size in header: {}", header_str))
1227        })?;
1228
1229        // Guard against allocating an unbounded blob. Anything larger than the
1230        // biggest size we will ever parse is drained from the stream in bounded
1231        // chunks (to keep the cat-file pipe in sync for the next request) and
1232        // rejected, instead of allocating `size` bytes up front (OOM on a
1233        // pathological multi-hundred-MB blob).
1234        if size > crate::config::limits::MAX_BLOB_READ_BYTES {
1235            let mut remaining = size;
1236            let mut scratch = [0u8; 65536];
1237            while remaining > 0 {
1238                let want = remaining.min(scratch.len());
1239                reader.read_exact(&mut scratch[..want])?;
1240                remaining -= want;
1241            }
1242            let mut trailing = [0u8; 1];
1243            let _ = reader.read_exact(&mut trailing);
1244            return Err(GitError::CommandFailed(format!(
1245                "cat-file: blob too large ({} bytes): {}",
1246                size,
1247                spec.trim()
1248            )));
1249        }
1250
1251        let mut content = vec![0u8; size];
1252        reader.read_exact(&mut content)?;
1253
1254        let mut trailing = [0u8; 1];
1255        let _ = reader.read_exact(&mut trailing);
1256
1257        Ok(String::from_utf8_lossy(&content).into_owned())
1258    }
1259
1260    pub fn close(&mut self) {
1261        self.reader.take();
1262        if let Some(mut child) = self.child.take() {
1263            drop(child.stdin.take());
1264            match child.wait_timeout(Duration::from_secs(GIT.catfile_termination_timeout_seconds)) {
1265                Ok(Some(_)) => {}
1266                _ => {
1267                    let _ = child.kill();
1268                    let _ = child.wait();
1269                }
1270            }
1271        }
1272    }
1273}
1274
1275impl Drop for CatFileBatch {
1276    fn drop(&mut self) {
1277        self.close();
1278    }
1279}
1280
1281#[cfg(test)]
1282mod tests {
1283    use super::*;
1284    use std::fs;
1285    use std::sync::Barrier;
1286    use tempfile::TempDir;
1287
1288    fn git(dir: &Path, args: &[&str]) {
1289        let status = git_command(dir)
1290            .args(args)
1291            .status()
1292            .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
1293        assert!(status.success(), "git {args:?} failed");
1294    }
1295
1296    fn init_git_repo(dir: &Path) {
1297        git(dir, &["init", "-q", "-b", "main"]);
1298        git(dir, &["config", "user.email", "test@example.com"]);
1299        git(dir, &["config", "user.name", "Test"]);
1300        git(dir, &["config", "commit.gpgsign", "false"]);
1301    }
1302
1303    fn commit_all(dir: &Path, message: &str) {
1304        git(dir, &["add", "-A"]);
1305        git(dir, &["commit", "-q", "-m", message]);
1306    }
1307
1308    fn write_file(root: &Path, rel: &str, content: &str) {
1309        let path = root.join(rel);
1310        if let Some(parent) = path.parent() {
1311            fs::create_dir_all(parent).expect("create parent");
1312        }
1313        fs::write(&path, content).expect("write file");
1314    }
1315
1316    // --- SAFE_DIFF_FLAGS pins the parser against hostile repo-local config ---
1317
1318    struct HunkShape {
1319        old_start: u32,
1320        old_len: u32,
1321        new_start: u32,
1322        new_len: u32,
1323    }
1324
1325    fn hunk_shapes(hunks: &[DiffHunk]) -> Vec<HunkShape> {
1326        hunks
1327            .iter()
1328            .map(|h| HunkShape {
1329                old_start: h.old_start,
1330                old_len: h.old_len,
1331                new_start: h.new_start,
1332                new_len: h.new_len,
1333            })
1334            .collect()
1335    }
1336
1337    fn basenames(paths: &[PathBuf]) -> Vec<String> {
1338        let mut names: Vec<String> = paths
1339            .iter()
1340            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1341            .collect();
1342        names.sort();
1343        names
1344    }
1345
1346    fn assert_diff_survives_hostile_config(hostile_config: &[&[&str]]) {
1347        let tmp = TempDir::new().expect("tempdir");
1348        let clean_root = tmp.path().join("clean");
1349        let hostile_root = tmp.path().join("hostile");
1350        fs::create_dir_all(&clean_root).expect("mkdir clean");
1351        fs::create_dir_all(&hostile_root).expect("mkdir hostile");
1352
1353        for root in [&clean_root, &hostile_root] {
1354            init_git_repo(root);
1355            write_file(root, "app.py", "def f():\n    return 1\n");
1356            commit_all(root, "initial");
1357            write_file(root, "app.py", "def f():\n    return 2\n");
1358            commit_all(root, "change");
1359        }
1360        for args in hostile_config {
1361            git(&hostile_root, args);
1362        }
1363
1364        let clean_hunks = parse_diff(&clean_root, Some("HEAD~1..HEAD")).expect("clean parse_diff");
1365        let hostile_hunks =
1366            parse_diff(&hostile_root, Some("HEAD~1..HEAD")).expect("hostile parse_diff");
1367        assert!(
1368            !hostile_hunks.is_empty(),
1369            "hostile git config reduced the diff to zero hunks"
1370        );
1371        assert_eq!(
1372            hunk_shapes(&hostile_hunks)
1373                .iter()
1374                .map(|s| (s.old_start, s.old_len, s.new_start, s.new_len))
1375                .collect::<Vec<_>>(),
1376            hunk_shapes(&clean_hunks)
1377                .iter()
1378                .map(|s| (s.old_start, s.old_len, s.new_start, s.new_len))
1379                .collect::<Vec<_>>(),
1380            "hostile config changed the parsed hunk shape vs a clean-config repo"
1381        );
1382
1383        let clean_files =
1384            get_changed_files(&clean_root, Some("HEAD~1..HEAD")).expect("clean changed files");
1385        let hostile_files =
1386            get_changed_files(&hostile_root, Some("HEAD~1..HEAD")).expect("hostile changed files");
1387        assert!(
1388            !hostile_files.is_empty(),
1389            "hostile git config reduced changed_files to empty"
1390        );
1391        assert_eq!(
1392            basenames(&hostile_files),
1393            basenames(&clean_files),
1394            "hostile config changed the changed_files set vs a clean-config repo"
1395        );
1396    }
1397
1398    #[test]
1399    fn diff_survives_diff_noprefix() {
1400        assert_diff_survives_hostile_config(&[&["config", "diff.noprefix", "true"]]);
1401    }
1402
1403    #[test]
1404    fn diff_survives_diff_mnemonic_prefix() {
1405        assert_diff_survives_hostile_config(&[&["config", "diff.mnemonicPrefix", "true"]]);
1406    }
1407
1408    #[test]
1409    fn diff_survives_custom_src_dst_prefix() {
1410        assert_diff_survives_hostile_config(&[
1411            &["config", "diff.srcPrefix", "x/"],
1412            &["config", "diff.dstPrefix", "y/"],
1413        ]);
1414    }
1415
1416    #[test]
1417    fn diff_survives_color_ui_always() {
1418        assert_diff_survives_hostile_config(&[&["config", "color.ui", "always"]]);
1419    }
1420
1421    // --- validate_diff_range: reject argv-injection ranges, keep legit ones ---
1422
1423    #[test]
1424    fn validate_diff_range_rejects_option_smuggled_in_range() {
1425        for hostile in ["HEAD..--ext-diff", "a...-p", "..--upload-pack=x"] {
1426            assert!(
1427                validate_diff_range(hostile).is_err(),
1428                "expected {hostile:?} to be rejected"
1429            );
1430        }
1431    }
1432
1433    #[test]
1434    fn validate_diff_range_accepts_legitimate_ranges() {
1435        for legit in [
1436            "HEAD~1..HEAD",
1437            "@{-1}..HEAD",
1438            "HEAD~2...origin/main",
1439            "main..feature/x",
1440        ] {
1441            assert!(
1442                validate_diff_range(legit).is_ok(),
1443                "expected {legit:?} to be accepted"
1444            );
1445        }
1446    }
1447
1448    // --- duration ranges: `--diff 24h` is a window, not a revision ---
1449
1450    #[test]
1451    fn duration_specs_cover_the_standard_units_and_compose() {
1452        for (spec, expected) in [
1453            ("5s", 5),
1454            ("90 sec", 90),
1455            ("10min", 600),
1456            ("45m", 2700),
1457            ("24h", 86_400),
1458            ("3hrs", 10_800),
1459            ("8d", 691_200),
1460            ("2 weeks", 1_209_600),
1461            ("1h30m", 5400),
1462            ("1D", 86_400),
1463        ] {
1464            assert_eq!(
1465                parse_duration_seconds(spec),
1466                Some(expected),
1467                "spec {spec:?}"
1468            );
1469        }
1470    }
1471
1472    #[test]
1473    fn anything_that_is_not_wholly_a_duration_stays_a_revision() {
1474        for spec in [
1475            "HEAD",
1476            "HEAD~1..HEAD",
1477            "main",
1478            "8dd",
1479            "24",
1480            "h",
1481            "v1.2",
1482            "",
1483            "1h-",
1484            "deadbeef",
1485        ] {
1486            assert_eq!(parse_duration_seconds(spec), None, "spec {spec:?}");
1487        }
1488    }
1489
1490    #[test]
1491    fn a_duration_resolves_to_the_last_commit_before_the_window() {
1492        let tmp = TempDir::new().expect("tempdir");
1493        let root = tmp.path();
1494        init_git_repo(root);
1495        write_file(root, "old.txt", "old\n");
1496        // `--before` filters on the committer date, so backdating the author
1497        // date alone would leave this commit inside the window.
1498        git(root, &["add", "-A"]);
1499        let status = git_command(root)
1500            .args(["commit", "-q", "-m", "old"])
1501            .env("GIT_AUTHOR_DATE", "2020-01-01T00:00:00+00:00")
1502            .env("GIT_COMMITTER_DATE", "2020-01-01T00:00:00+00:00")
1503            .status()
1504            .expect("commit");
1505        assert!(status.success());
1506        let old_head = run_git(root, &["rev-parse", "HEAD"])
1507            .expect("rev-parse")
1508            .trim()
1509            .to_string();
1510        write_file(root, "new.txt", "new\n");
1511        commit_all(root, "new");
1512
1513        let resolved = resolve_duration_range(root, Some("24h")).expect("resolve");
1514        assert!(resolved.from_duration);
1515        assert_eq!(resolved.range.as_deref(), Some(old_head.as_str()));
1516
1517        let diff = get_diff_text(root, resolved.range.as_deref()).expect("diff");
1518        assert!(diff.contains("new.txt"), "window must cover the new commit");
1519        assert!(
1520            !diff.contains("old.txt"),
1521            "window must exclude the commit before it"
1522        );
1523    }
1524
1525    #[test]
1526    fn a_window_older_than_the_repo_falls_back_to_the_empty_tree() {
1527        let tmp = TempDir::new().expect("tempdir");
1528        let root = tmp.path();
1529        init_git_repo(root);
1530        write_file(root, "only.txt", "only\n");
1531        commit_all(root, "only");
1532
1533        let resolved = resolve_duration_range(root, Some("1w")).expect("resolve");
1534        assert!(resolved.from_duration);
1535        let diff = get_diff_text(root, resolved.range.as_deref()).expect("diff");
1536        assert!(
1537            diff.contains("only.txt"),
1538            "a repo younger than the window is entirely new within it"
1539        );
1540    }
1541
1542    #[test]
1543    fn a_ref_that_looks_like_a_duration_keeps_its_git_meaning() {
1544        let tmp = TempDir::new().expect("tempdir");
1545        let root = tmp.path();
1546        init_git_repo(root);
1547        write_file(root, "a.txt", "a\n");
1548        commit_all(root, "a");
1549        git(root, &["branch", "24h"]);
1550
1551        let resolved = resolve_duration_range(root, Some("24h")).expect("resolve");
1552        assert!(!resolved.from_duration);
1553        assert_eq!(resolved.range.as_deref(), Some("24h"));
1554    }
1555
1556    // --- parse_verbose_ignore_records: NUL fields remove every delimiter guess ---
1557
1558    #[test]
1559    fn a_tab_in_the_pattern_no_longer_needs_disambiguating() {
1560        // The text format put the pattern and the path on one line separated by
1561        // a tab, so a pattern containing a tab had to be split from the right
1562        // and hoped for. Fields make it unambiguous.
1563        let stdout = ".gitignore\x003\x00foo\tbar\x00some/real/path.txt\x00";
1564        let rules = parse_verbose_ignore_records(stdout);
1565        assert_eq!(
1566            rules.get("some/real/path.txt").map(String::as_str),
1567            Some(".gitignore:3:foo\tbar")
1568        );
1569    }
1570
1571    /// The leak that forced `-z`. A newline in a filename split the query into
1572    /// two phantom paths, git answered about the stem, the real path never got
1573    /// a verdict, and a file the user declared ignored was published.
1574    #[test]
1575    fn a_newline_in_the_path_survives_as_one_record() {
1576        let stdout = "excl\x001\x00secret*\x00secret\nname.py\x00";
1577        let rules = parse_verbose_ignore_records(stdout);
1578        assert_eq!(rules.len(), 1);
1579        assert_eq!(
1580            rules.get("secret\nname.py").map(String::as_str),
1581            Some("excl:1:secret*")
1582        );
1583    }
1584
1585    #[test]
1586    fn several_records_and_a_trailing_nul_parse_cleanly() {
1587        let stdout = ".gitignore\x001\x00*.log\x00a.log\x00.gitignore\x002\x00*.tmp\x00b/c.tmp\x00";
1588        let rules = parse_verbose_ignore_records(stdout);
1589        assert_eq!(rules.len(), 2);
1590        assert!(rules.contains_key("a.log"));
1591        assert!(rules.contains_key("b/c.tmp"));
1592    }
1593
1594    #[test]
1595    fn a_truncated_final_record_is_dropped_not_half_read() {
1596        // Killed mid-write, the last record is short. Reading three fields as
1597        // four would key a rule under a pattern.
1598        let stdout = ".gitignore\x001\x00*.log\x00a.log\x00.gitignore\x002\x00*.tmp\x00";
1599        let rules = parse_verbose_ignore_records(stdout);
1600        assert_eq!(rules.len(), 1);
1601        assert!(rules.contains_key("a.log"));
1602    }
1603
1604    // --- anchor_diffctx_ignore_line: 4 reachable outputs, root vs nested, negation ---
1605
1606    #[test]
1607    fn anchor_ignore_line_bare_pattern_at_root() {
1608        assert_eq!(anchor_diffctx_ignore_line("*.log", ""), "*.log");
1609    }
1610
1611    #[test]
1612    fn anchor_ignore_line_bare_pattern_nested() {
1613        assert_eq!(anchor_diffctx_ignore_line("*.log", "sub"), "sub/**/*.log");
1614    }
1615
1616    #[test]
1617    fn anchor_ignore_line_slash_pattern_at_root() {
1618        assert_eq!(
1619            anchor_diffctx_ignore_line("secrets/config.py", ""),
1620            "/secrets/config.py"
1621        );
1622    }
1623
1624    #[test]
1625    fn anchor_ignore_line_slash_pattern_nested() {
1626        assert_eq!(
1627            anchor_diffctx_ignore_line("secrets/config.py", "sub"),
1628            "/sub/secrets/config.py"
1629        );
1630    }
1631
1632    #[test]
1633    fn anchor_ignore_line_negated_bare_pattern() {
1634        assert_eq!(anchor_diffctx_ignore_line("!keep.log", ""), "!keep.log");
1635    }
1636
1637    #[test]
1638    fn anchor_ignore_line_negated_slash_pattern_nested() {
1639        assert_eq!(
1640            anchor_diffctx_ignore_line("!secrets/keep.py", "sub"),
1641            "!/sub/secrets/keep.py"
1642        );
1643    }
1644
1645    // --- unquote_c_style + the quoted diff-header branch ---
1646
1647    #[test]
1648    fn unquote_c_style_decodes_octal_utf8_escapes() {
1649        // Exactly what git emits for `café.py` under the default
1650        // core.quotePath=true: é is UTF-8 0xC3 0xA9, i.e. octal 303 251.
1651        let quoted = r#""a/caf\303\251.py""#;
1652        assert_eq!(unquote_c_style(quoted), "a/café.py");
1653    }
1654
1655    #[test]
1656    fn unquote_c_style_leaves_unquoted_input_untouched() {
1657        assert_eq!(unquote_c_style("a/plain.py"), "a/plain.py");
1658    }
1659
1660    #[test]
1661    fn parse_path_line_takes_quoted_branch_for_old_and_new_headers() {
1662        let tmp = TempDir::new().expect("tempdir");
1663        let root = tmp.path();
1664        // The path must exist on disk: parse_path_line canonicalizes the
1665        // joined path to guard against traversal, and a nonexistent target
1666        // can fail to canonicalize while the (existing) root does, tripping
1667        // the containment check on platforms where the temp dir sits behind
1668        // a symlink (e.g. macOS /var -> /private/var) for reasons unrelated
1669        // to the quoted-header parsing this test targets.
1670        write_file(root, "café.py", "value = 1\n");
1671
1672        let old_line = r#"--- "a/caf\303\251.py""#;
1673        let (kind, path) = parse_path_line(old_line, root);
1674        assert_eq!(kind, "old");
1675        assert_eq!(
1676            path.expect("old path")
1677                .file_name()
1678                .unwrap()
1679                .to_string_lossy(),
1680            "café.py"
1681        );
1682
1683        let new_line = r#"+++ "b/caf\303\251.py""#;
1684        let (kind, path) = parse_path_line(new_line, root);
1685        assert_eq!(kind, "new");
1686        assert_eq!(
1687            path.expect("new path")
1688                .file_name()
1689                .unwrap()
1690                .to_string_lossy(),
1691            "café.py"
1692        );
1693    }
1694
1695    /// The containment check is lexical (`Path::starts_with` compares
1696    /// components) and `canonicalize` cannot resolve a path that does not
1697    /// exist, so the fallback kept `..` in place and `<root>/../x` "started
1698    /// with" `<root>`. This only failed on macOS, where the temp root
1699    /// canonicalizes through `/var -> /private/var` and the two spellings stop
1700    /// matching — so the guard was passing for an accident of layout. Both the
1701    /// existing and non-existing target are covered: the first is the one that
1702    /// actually reads a file outside the repository.
1703    #[test]
1704    fn a_header_escaping_the_repo_root_is_refused_whether_or_not_the_target_exists() {
1705        let tmp = TempDir::new().expect("tempdir");
1706        // Canonicalized on purpose. With the root spelled the same way
1707        // `canonicalize` would spell it, the lexical fallback prefix matches and
1708        // the hole reproduces here exactly as it did on Linux CI; spelled via a
1709        // symlinked temp dir (`/var` on macOS) the mismatch hid it.
1710        let base = tmp.path().canonicalize().expect("canonical tempdir");
1711        let root = base.join("repo");
1712        std::fs::create_dir_all(&root).expect("mkdir repo");
1713        std::fs::write(base.join("outside.py"), "secret = 1\n").expect("write outside");
1714
1715        for rel in ["../outside.py", "../missing.py", "sub/../../outside.py"] {
1716            for line in [format!("--- a/{rel}"), format!("+++ b/{rel}")] {
1717                let (kind, path) = parse_path_line(&line, &root);
1718                assert_eq!(
1719                    (kind, path.as_ref()),
1720                    ("", None),
1721                    "escaping header accepted: {line}"
1722                );
1723            }
1724        }
1725
1726        // An ordinary in-repo header still resolves, including one whose file
1727        // does not exist yet (the old side of a deletion).
1728        std::fs::write(root.join("real.py"), "x = 1\n").expect("write real");
1729        for rel in ["real.py", "gone.py", "nested/deep.py"] {
1730            let (kind, path) = parse_path_line(&format!("--- a/{rel}"), &root);
1731            assert_eq!(kind, "old", "in-repo header refused: {rel}");
1732            assert!(path.expect("path").ends_with(rel));
1733        }
1734    }
1735
1736    /// A symlink inside the repository needs no `..` to point outside it, so
1737    /// rejecting `..` is not on its own enough — the canonical containment check
1738    /// has to stay reachable rather than being short-circuited by a lexical
1739    /// fallback that is always true once `..` is gone.
1740    #[cfg(unix)]
1741    #[test]
1742    fn an_in_repo_symlink_pointing_outside_the_root_is_refused() {
1743        let tmp = TempDir::new().expect("tempdir");
1744        let base = tmp.path().canonicalize().expect("canonical tempdir");
1745        let root = base.join("repo");
1746        std::fs::create_dir_all(&root).expect("mkdir repo");
1747
1748        let outside_dir = base.join("outside");
1749        std::fs::create_dir_all(&outside_dir).expect("mkdir outside");
1750        std::fs::write(outside_dir.join("secret.py"), "token = 1\n").expect("write secret");
1751        std::os::unix::fs::symlink(&outside_dir, root.join("escape"))
1752            .expect("symlink into the repo");
1753
1754        let (kind, path) = parse_path_line("--- a/escape/secret.py", &root);
1755        assert_eq!(
1756            (kind, path.as_ref()),
1757            ("", None),
1758            "a header reaching outside the repo through an in-repo symlink was accepted"
1759        );
1760
1761        // A symlink that stays inside the repository is still fine.
1762        std::fs::create_dir_all(root.join("real")).expect("mkdir real");
1763        std::fs::write(root.join("real/mod.py"), "y = 1\n").expect("write real");
1764        std::os::unix::fs::symlink(root.join("real"), root.join("alias")).expect("inner symlink");
1765        let (kind, _) = parse_path_line("--- a/alias/mod.py", &root);
1766        assert_eq!(kind, "old", "an in-repo symlink was wrongly refused");
1767    }
1768
1769    /// The `core.excludesFile` handed to `git check-ignore` used to be written
1770    /// with `fs::write` under a guessable name in the shared temp directory,
1771    /// which follows a symlink and truncates its target. Anything the pipeline
1772    /// creates there must refuse a path that already exists.
1773    #[test]
1774    fn temp_file_creation_refuses_a_pre_planted_path() {
1775        let tmp = TempDir::new().expect("tempdir");
1776        let victim = tmp.path().join("victim.txt");
1777        std::fs::write(&victim, "precious\n").expect("write victim");
1778
1779        let planted = tmp.path().join("planted.tmp");
1780        #[cfg(unix)]
1781        std::os::unix::fs::symlink(&victim, &planted).expect("symlink");
1782        #[cfg(not(unix))]
1783        std::fs::write(&planted, "").expect("placeholder");
1784
1785        let err = create_new_private_file(&planted).expect_err("must refuse an existing path");
1786        assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
1787        assert_eq!(
1788            std::fs::read_to_string(&victim).expect("victim survives"),
1789            "precious\n",
1790            "the symlink target was written through"
1791        );
1792    }
1793
1794    #[test]
1795    fn temp_excludes_file_is_written_and_readable() {
1796        let path = write_private_temp_file("*.log\n!keep.log").expect("temp file");
1797        let content = std::fs::read_to_string(&path).expect("read back");
1798        assert_eq!(content, "*.log\n!keep.log");
1799        #[cfg(unix)]
1800        {
1801            use std::os::unix::fs::PermissionsExt;
1802            let mode = std::fs::metadata(&path)
1803                .expect("metadata")
1804                .permissions()
1805                .mode()
1806                & 0o777;
1807            assert_eq!(mode, 0o600, "temp excludes file must not be world-readable");
1808        }
1809        let _ = std::fs::remove_file(&path);
1810    }
1811
1812    /// The traversal guard in `parse_path_line` returns `("", None)`, which the
1813    /// hunk loop cannot distinguish from "this line is not a path header". With
1814    /// no per-file reset the previous file's paths stay live, so the rejected
1815    /// entry's hunks are charged to the last legitimate file — the guard drops
1816    /// the path but keeps its line ranges, and those ranges decide which
1817    /// fragments are marked as changed.
1818    #[test]
1819    fn a_rejected_path_header_does_not_charge_its_hunks_to_the_previous_file() {
1820        let tmp = TempDir::new().expect("tempdir");
1821        let root = tmp.path();
1822        write_file(root, "real.py", "a = 1\nb = 2\nc = 3\n");
1823
1824        let output = concat!(
1825            "diff --git a/real.py b/real.py\n",
1826            "--- a/real.py\n",
1827            "+++ b/real.py\n",
1828            "@@ -1,1 +1,1 @@\n",
1829            "-a = 1\n",
1830            "+a = 9\n",
1831            "diff --git a/../../escape.py b/../../escape.py\n",
1832            "--- a/../../escape.py\n",
1833            "+++ b/../../escape.py\n",
1834            "@@ -500,20 +500,20 @@\n",
1835            "-gone\n",
1836            "+new\n",
1837        );
1838
1839        let hunks = parse_hunks_from_diff_output(output, root);
1840        assert_eq!(
1841            hunks.len(),
1842            1,
1843            "expected only the in-repo file's hunk, got {:?}",
1844            hunks
1845                .iter()
1846                .map(|h| (h.path.as_ref().to_string(), h.new_start))
1847                .collect::<Vec<_>>()
1848        );
1849        assert_eq!(hunks[0].new_start, 1);
1850        assert!(hunks[0].path.ends_with("real.py"));
1851    }
1852
1853    /// A deletion emits `+++ /dev/null`, and a creation emits `--- /dev/null`;
1854    /// both must still resolve to the side that names a real file.
1855    #[test]
1856    fn deletions_and_creations_attribute_their_hunks_to_the_named_side() {
1857        let tmp = TempDir::new().expect("tempdir");
1858        let root = tmp.path();
1859        write_file(root, "kept.py", "x = 1\n");
1860        write_file(root, "added.py", "y = 1\n");
1861        write_file(root, "removed.py", "z = 1\n");
1862
1863        let output = concat!(
1864            "diff --git a/kept.py b/kept.py\n",
1865            "--- a/kept.py\n",
1866            "+++ b/kept.py\n",
1867            "@@ -1,1 +1,1 @@\n",
1868            "diff --git a/removed.py b/removed.py\n",
1869            "--- a/removed.py\n",
1870            "+++ /dev/null\n",
1871            "@@ -1,1 +0,0 @@\n",
1872            "diff --git a/added.py b/added.py\n",
1873            "--- /dev/null\n",
1874            "+++ b/added.py\n",
1875            "@@ -0,0 +1,1 @@\n",
1876        );
1877
1878        let paths: Vec<String> = parse_hunks_from_diff_output(output, root)
1879            .iter()
1880            .map(|h| {
1881                Path::new(h.path.as_ref())
1882                    .file_name()
1883                    .unwrap()
1884                    .to_string_lossy()
1885                    .into_owned()
1886            })
1887            .collect();
1888        assert_eq!(paths, vec!["kept.py", "removed.py", "added.py"]);
1889    }
1890
1891    #[test]
1892    fn parse_diff_handles_real_repo_with_default_quoted_unicode_filename() {
1893        // core.quotePath defaults to true, so a real git diff over a renamed
1894        // non-ASCII file exercises the quoted branch end-to-end, not just the
1895        // helper in isolation.
1896        let tmp = TempDir::new().expect("tempdir");
1897        let root = tmp.path();
1898        init_git_repo(root);
1899        write_file(root, "café.py", "value = 1\n");
1900        commit_all(root, "initial");
1901        write_file(root, "café.py", "value = 2\n");
1902        commit_all(root, "change");
1903
1904        let hunks = parse_diff(root, Some("HEAD~1..HEAD")).expect("parse_diff");
1905        assert!(
1906            !hunks.is_empty(),
1907            "quoted unicode diff header was not parsed into any hunk"
1908        );
1909        assert!(
1910            hunks.iter().any(|h| h.path.contains("café")),
1911            "no hunk carried the decoded unicode path, got: {:?}",
1912            hunks.iter().map(|h| h.path.as_ref()).collect::<Vec<_>>()
1913        );
1914    }
1915
1916    // --- subprocess timeout/kill: wait_with_timeout must not hang or orphan ---
1917
1918    #[test]
1919    fn wait_with_timeout_kills_long_running_child_and_returns_promptly() {
1920        let child = Command::new("sleep")
1921            .arg("30")
1922            .stdout(Stdio::piped())
1923            .stderr(Stdio::piped())
1924            .spawn()
1925            .expect("spawn sleep");
1926        let pid = child.id();
1927
1928        let start = std::time::Instant::now();
1929        let result = wait_with_timeout(child, Duration::from_millis(200), &["sleep", "30"]);
1930        let elapsed = start.elapsed();
1931
1932        assert!(
1933            matches!(result, Err(GitError::Timeout(_))),
1934            "expected Timeout error, got {result:?}"
1935        );
1936        assert!(
1937            elapsed < Duration::from_secs(5),
1938            "wait_with_timeout should return promptly, took {elapsed:?}"
1939        );
1940
1941        // The child must actually be reaped, not orphaned: `kill -0` on a
1942        // reaped pid fails once the OS releases it. Retry briefly since the
1943        // OS may hold a zombie slot for a moment after the kill.
1944        let mut still_alive = true;
1945        for _ in 0..20 {
1946            let status = Command::new("kill")
1947                .args(["-0", &pid.to_string()])
1948                .stdout(Stdio::null())
1949                .stderr(Stdio::null())
1950                .status()
1951                .expect("spawn kill -0");
1952            if !status.success() {
1953                still_alive = false;
1954                break;
1955            }
1956            std::thread::sleep(Duration::from_millis(50));
1957        }
1958        assert!(!still_alive, "child pid {pid} was not reaped after timeout");
1959    }
1960
1961    #[test]
1962    fn wait_with_timeout_does_not_penalize_fast_commands() {
1963        let child = Command::new("true")
1964            .stdout(Stdio::piped())
1965            .stderr(Stdio::piped())
1966            .spawn()
1967            .expect("spawn true");
1968        let result = wait_with_timeout(child, Duration::from_secs(5), &["true"]);
1969        assert!(matches!(result, Ok(ref out) if out.status.success()));
1970    }
1971
1972    // --- PID-keyed temp excludesFile: two concurrent calls in one process ---
1973
1974    #[test]
1975    fn ignored_paths_concurrent_calls_both_see_their_own_ignore_rules() {
1976        let tmp = TempDir::new().expect("tempdir");
1977        let root_a = tmp.path().join("repo_a");
1978        let root_b = tmp.path().join("repo_b");
1979        fs::create_dir_all(&root_a).expect("mkdir a");
1980        fs::create_dir_all(&root_b).expect("mkdir b");
1981
1982        for (root, secret) in [(&root_a, "secret_a.py"), (&root_b, "secret_b.py")] {
1983            init_git_repo(root);
1984            write_file(root, "app.py", "print('hi')\n");
1985            write_file(root, ".diffctx/ignore", &format!("{secret}\n"));
1986            write_file(root, secret, "SECRET\n");
1987            commit_all(root, "initial");
1988        }
1989
1990        // Run several concurrent rounds: a single lucky interleaving proved
1991        // the pre-fix PID-only path could collide, so repeat to make a
1992        // regression reliably visible instead of a one-shot coin flip.
1993        for _ in 0..10 {
1994            let barrier = Arc::new(Barrier::new(2));
1995
1996            let root_a_thread = root_a.clone();
1997            let barrier_a = Arc::clone(&barrier);
1998            let handle_a = std::thread::spawn(move || {
1999                barrier_a.wait();
2000                find_ignored_paths_with_source(
2001                    &root_a_thread,
2002                    &["secret_a.py".to_string(), "app.py".to_string()],
2003                )
2004            });
2005
2006            let root_b_thread = root_b.clone();
2007            let barrier_b = Arc::clone(&barrier);
2008            let handle_b = std::thread::spawn(move || {
2009                barrier_b.wait();
2010                find_ignored_paths_with_source(
2011                    &root_b_thread,
2012                    &["secret_b.py".to_string(), "app.py".to_string()],
2013                )
2014            });
2015
2016            let ignored_a = handle_a.join().expect("thread a panicked");
2017            let ignored_b = handle_b.join().expect("thread b panicked");
2018
2019            assert_eq!(
2020                ignored_a.get("secret_a.py"),
2021                Some(&IgnoreSource::DiffctxPolicy),
2022                "repo A lost its .diffctx/ignore rule to a concurrent call"
2023            );
2024            assert_eq!(
2025                ignored_b.get("secret_b.py"),
2026                Some(&IgnoreSource::DiffctxPolicy),
2027                "repo B lost its .diffctx/ignore rule to a concurrent call"
2028            );
2029            assert!(!ignored_a.contains_key("app.py"));
2030            assert!(!ignored_b.contains_key("app.py"));
2031        }
2032    }
2033}
2034
2035#[cfg(test)]
2036mod negation_record_tests {
2037    use super::*;
2038
2039    #[test]
2040    fn a_negation_record_does_not_mark_the_path_ignored() {
2041        // check-ignore -v -z output: source \0 line \0 pattern \0 path \0 ...
2042        let stdout = ".gitignore\x001\x00*.tmp\x00drop.tmp\x00.gitignore\x002\x00!NEWDOC.md\x00NEWDOC.md\x00";
2043        let rules = parse_verbose_ignore_records(stdout);
2044        assert!(
2045            rules.contains_key("drop.tmp"),
2046            "a positive match must stay an exclusion"
2047        );
2048        assert!(
2049            !rules.contains_key("NEWDOC.md"),
2050            "a negation match means the path is explicitly NOT ignored (#193)"
2051        );
2052    }
2053}