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