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
123/// Always targets the repo via `-C`.
124///
125/// Repo-locating variables inherited from a parent process (e.g. a git
126/// hook exporting `GIT_DIR` / `GIT_INDEX_FILE`) must be scrubbed or they
127/// silently redirect every command to the wrong repository.
128pub fn git_command(repo_root: &Path) -> Command {
129    let mut cmd = Command::new("git");
130    cmd.arg("-C")
131        .arg(repo_root)
132        .env_remove("GIT_DIR")
133        .env_remove("GIT_WORK_TREE")
134        .env_remove("GIT_INDEX_FILE");
135    cmd
136}
137
138pub fn run_git(repo_root: &Path, args: &[&str]) -> Result<String> {
139    let mut cmd = git_command(repo_root);
140    cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
141
142    let child = cmd.spawn().map_err(|e| {
143        if e.kind() == std::io::ErrorKind::NotFound {
144            GitError::CommandFailed("git is not installed or not in PATH".into())
145        } else {
146            GitError::Io(e)
147        }
148    })?;
149
150    let output = wait_with_timeout(child, Duration::from_secs(git_timeout()), args)?;
151
152    if !output.status.success() {
153        let stderr = String::from_utf8_lossy(&output.stderr);
154        let subcommand = args
155            .iter()
156            .find(|a| !a.starts_with('-'))
157            .copied()
158            .unwrap_or("command");
159        let reason = stderr
160            .lines()
161            .map(str::trim)
162            .find(|l| l.starts_with("fatal:") || l.starts_with("error:"))
163            .or_else(|| stderr.lines().map(str::trim).find(|l| !l.is_empty()))
164            .unwrap_or("unknown error");
165        return Err(GitError::CommandFailed(format!(
166            "git {subcommand} failed: {reason}"
167        )));
168    }
169
170    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
171}
172
173fn wait_with_timeout(
174    child: Child,
175    timeout: Duration,
176    _args: &[&str],
177) -> Result<std::process::Output> {
178    let mut child = child;
179    let stdout_handle = child.stdout.take().map(|mut s| {
180        std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
181            let mut buf = Vec::new();
182            s.read_to_end(&mut buf)?;
183            Ok(buf)
184        })
185    });
186    let stderr_handle = child.stderr.take().map(|mut s| {
187        std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
188            let mut buf = Vec::new();
189            s.read_to_end(&mut buf)?;
190            Ok(buf)
191        })
192    });
193
194    let status = match child.wait_timeout(timeout)? {
195        Some(status) => status,
196        None => {
197            let _ = child.kill();
198            let _ = child.wait();
199            return Err(GitError::Timeout(timeout.as_secs()));
200        }
201    };
202
203    let stdout = stdout_handle
204        .and_then(|h| h.join().ok())
205        .and_then(|r| r.ok())
206        .unwrap_or_default();
207    let stderr = stderr_handle
208        .and_then(|h| h.join().ok())
209        .and_then(|r| r.ok())
210        .unwrap_or_default();
211
212    Ok(std::process::Output {
213        status,
214        stdout,
215        stderr,
216    })
217}
218
219pub fn is_git_repo(path: &Path) -> bool {
220    run_git(path, &["rev-parse", "--git-dir"]).is_ok()
221}
222
223/// Resolves the actual working-tree root for `path`, which may be a
224/// subdirectory of the repository. `git diff`/`git cat-file` paths are
225/// always reported relative to this root, not to an arbitrary `-C` cwd -
226/// running the pipeline with `path` still set to a subdirectory silently
227/// produces zero fragments because file lookups get double-prefixed
228/// (e.g. `src/src/app.py`).
229pub fn find_toplevel(path: &Path) -> Option<PathBuf> {
230    let out = run_git(path, &["rev-parse", "--show-toplevel"]).ok()?;
231    let trimmed = out.trim();
232    if trimmed.is_empty() {
233        return None;
234    }
235    Some(PathBuf::from(trimmed))
236}
237
238pub fn get_diff_text(repo_root: &Path, diff_range: Option<&str>) -> Result<String> {
239    let mut args: Vec<&str> = vec!["diff"];
240    args.extend_from_slice(SAFE_DIFF_FLAGS);
241    if let Some(range) = diff_range {
242        validate_diff_range(range)?;
243        args.push(range);
244    }
245    run_git(repo_root, &args)
246}
247
248pub(crate) fn unquote_c_style(quoted: &str) -> String {
249    if !(quoted.starts_with('"') && quoted.ends_with('"')) {
250        return quoted.to_string();
251    }
252
253    let raw = &quoted[1..quoted.len() - 1];
254    let bytes = raw.as_bytes();
255    let mut result: Vec<u8> = Vec::with_capacity(bytes.len());
256    let mut i = 0;
257
258    while i < bytes.len() {
259        if bytes[i] == b'\\' && i + 1 < bytes.len() {
260            let nxt = bytes[i + 1];
261            match nxt {
262                b't' => {
263                    result.push(b'\t');
264                    i += 2;
265                }
266                b'n' => {
267                    result.push(b'\n');
268                    i += 2;
269                }
270                b'r' => {
271                    result.push(b'\r');
272                    i += 2;
273                }
274                b'b' => {
275                    result.push(0x08);
276                    i += 2;
277                }
278                b'f' => {
279                    result.push(0x0C);
280                    i += 2;
281                }
282                b'v' => {
283                    result.push(0x0B);
284                    i += 2;
285                }
286                b'a' => {
287                    result.push(0x07);
288                    i += 2;
289                }
290                b'\\' => {
291                    result.push(b'\\');
292                    i += 2;
293                }
294                b'"' => {
295                    result.push(b'"');
296                    i += 2;
297                }
298                b'0'..=b'7'
299                    if i + 3 < bytes.len()
300                        && bytes[i + 2].is_ascii_digit()
301                        && bytes[i + 2] <= b'7'
302                        && bytes[i + 3].is_ascii_digit()
303                        && bytes[i + 3] <= b'7' =>
304                {
305                    let val = (nxt - b'0') * 64 + (bytes[i + 2] - b'0') * 8 + (bytes[i + 3] - b'0');
306                    result.push(val);
307                    i += 4;
308                }
309                _ => {
310                    result.push(b'\\');
311                    i += 1;
312                }
313            }
314        } else {
315            result.push(bytes[i]);
316            i += 1;
317        }
318    }
319
320    String::from_utf8(result).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
321}
322
323pub(crate) fn parse_path_line(line: &str, repo_root: &Path) -> (&'static str, Option<PathBuf>) {
324    let resolved_root = repo_root
325        .canonicalize()
326        .unwrap_or_else(|_| repo_root.to_path_buf());
327
328    if line.starts_with("--- /dev/null") {
329        return ("old", None);
330    }
331    if line.starts_with("+++ /dev/null") {
332        return ("new", None);
333    }
334
335    if let Some(rest) = line.strip_prefix("--- a/") {
336        let rel_path = rest.trim();
337        let resolved = (repo_root.join(rel_path))
338            .canonicalize()
339            .unwrap_or_else(|_| repo_root.join(rel_path));
340        if !resolved.starts_with(&resolved_root) {
341            return ("", None);
342        }
343        return ("old", Some(repo_root.join(rel_path)));
344    }
345
346    if let Some(rest) = line.strip_prefix("+++ b/") {
347        let rel_path = rest.trim();
348        let resolved = (repo_root.join(rel_path))
349            .canonicalize()
350            .unwrap_or_else(|_| repo_root.join(rel_path));
351        if !resolved.starts_with(&resolved_root) {
352            return ("", None);
353        }
354        return ("new", Some(repo_root.join(rel_path)));
355    }
356
357    if let Some(rest) = line.strip_prefix("--- ").filter(|r| r.starts_with("\"a/")) {
358        let quoted = rest.trim();
359        let unquoted = unquote_c_style(quoted);
360        let rel_path = unquoted.strip_prefix("a/").unwrap_or(&unquoted);
361        let resolved = (repo_root.join(rel_path))
362            .canonicalize()
363            .unwrap_or_else(|_| repo_root.join(rel_path));
364        if !resolved.starts_with(&resolved_root) {
365            return ("", None);
366        }
367        return ("old", Some(repo_root.join(rel_path)));
368    }
369
370    if let Some(rest) = line.strip_prefix("+++ ").filter(|r| r.starts_with("\"b/")) {
371        let quoted = rest.trim();
372        let unquoted = unquote_c_style(quoted);
373        let rel_path = unquoted.strip_prefix("b/").unwrap_or(&unquoted);
374        let resolved = (repo_root.join(rel_path))
375            .canonicalize()
376            .unwrap_or_else(|_| repo_root.join(rel_path));
377        if !resolved.starts_with(&resolved_root) {
378            return ("", None);
379        }
380        return ("new", Some(repo_root.join(rel_path)));
381    }
382
383    ("", None)
384}
385
386fn parse_hunk_header(caps: &regex::Captures, path: &Path) -> Option<DiffHunk> {
387    // Adversarial-diff hardening: integers parsed from the hunk regex are
388    // small ASCII digits matched by `\d+`, so `parse::<u32>` can only fail
389    // on overflow (e.g. lines > 2^32). Skip such hunks instead of crashing
390    // the host process — they are degenerate and have no useful semantics.
391    let old_start: u32 = caps[1].parse().ok()?;
392    let old_len: u32 = match caps.get(2) {
393        Some(m) => m.as_str().parse().ok()?,
394        None => 1,
395    };
396    let new_start: u32 = caps[3].parse().ok()?;
397    let new_len: u32 = match caps.get(4) {
398        Some(m) => m.as_str().parse().ok()?,
399        None => 1,
400    };
401
402    Some(DiffHunk {
403        path: Arc::from(path.to_string_lossy().as_ref()),
404        new_start,
405        new_len,
406        old_start,
407        old_len,
408    })
409}
410
411pub fn parse_diff(repo_root: &Path, diff_range: Option<&str>) -> Result<Vec<DiffHunk>> {
412    let mut args: Vec<&str> = vec!["diff"];
413    args.extend_from_slice(SAFE_DIFF_FLAGS);
414    args.push("--unified=0");
415    args.push("-M");
416    if let Some(range) = diff_range {
417        validate_diff_range(range)?;
418        args.push(range);
419    }
420
421    let output = run_git(repo_root, &args)?;
422    let mut hunks = Vec::new();
423    let mut old_path: Option<PathBuf> = None;
424    let mut new_path: Option<PathBuf> = None;
425
426    for line in output.lines() {
427        let (path_type, path) = parse_path_line(line, repo_root);
428        match path_type {
429            "old" => {
430                old_path = path;
431                continue;
432            }
433            "new" => {
434                new_path = path;
435                continue;
436            }
437            _ => {}
438        }
439
440        if let Some(caps) = HUNK_RE.captures(line) {
441            let current_path = new_path.as_deref().or(old_path.as_deref());
442            if let Some(p) = current_path {
443                if let Some(hunk) = parse_hunk_header(&caps, p) {
444                    hunks.push(hunk);
445                }
446            }
447        }
448    }
449
450    Ok(hunks)
451}
452
453pub fn run_git_z(repo_root: &Path, args: &[&str]) -> Result<Vec<String>> {
454    let output = run_git(repo_root, args)?;
455    Ok(output
456        .split('\0')
457        .filter(|s| !s.is_empty())
458        .map(String::from)
459        .collect())
460}
461
462pub fn get_changed_files(repo_root: &Path, diff_range: Option<&str>) -> Result<Vec<PathBuf>> {
463    let mut args: Vec<&str> = vec!["diff"];
464    args.extend_from_slice(SAFE_DIFF_FLAGS);
465    args.extend_from_slice(&["--name-only", "-M", "-z"]);
466    if let Some(range) = diff_range {
467        validate_diff_range(range)?;
468        args.push(range);
469    }
470    let parts = run_git_z(repo_root, &args)?;
471    Ok(parts
472        .iter()
473        .map(|p| {
474            repo_root
475                .join(p)
476                .canonicalize()
477                .unwrap_or_else(|_| repo_root.join(p))
478        })
479        .collect())
480}
481
482pub fn get_deleted_files(repo_root: &Path, diff_range: Option<&str>) -> Result<FxHashSet<PathBuf>> {
483    let mut args: Vec<&str> = vec!["diff"];
484    args.extend_from_slice(SAFE_DIFF_FLAGS);
485    args.extend_from_slice(&["--diff-filter=D", "--name-only", "-M", "-z"]);
486    if let Some(range) = diff_range {
487        validate_diff_range(range)?;
488        args.push(range);
489    }
490    let parts = run_git_z(repo_root, &args)?;
491    Ok(parts
492        .iter()
493        .map(|p| {
494            repo_root
495                .join(p)
496                .canonicalize()
497                .unwrap_or_else(|_| repo_root.join(p))
498        })
499        .collect())
500}
501
502pub fn get_renamed_paths(
503    repo_root: &Path,
504    diff_range: Option<&str>,
505    min_similarity: u32,
506) -> Result<(FxHashSet<PathBuf>, FxHashSet<PathBuf>)> {
507    let mut args: Vec<&str> = vec!["diff"];
508    args.extend_from_slice(SAFE_DIFF_FLAGS);
509    args.extend_from_slice(&["--diff-filter=R", "--name-status", "-M", "-z"]);
510    if let Some(range) = diff_range {
511        validate_diff_range(range)?;
512        args.push(range);
513    }
514    let output = run_git(repo_root, &args)?;
515    let parts: Vec<&str> = output.split('\0').collect();
516
517    let mut old_paths = FxHashSet::default();
518    let mut pure_new_paths = FxHashSet::default();
519    let mut i = 0;
520
521    while i < parts.len() {
522        if parts[i].starts_with('R') {
523            let sim: u32 = parts[i][1..].parse().unwrap_or(0);
524
525            if i + 1 < parts.len() && !parts[i + 1].is_empty() {
526                let resolved = repo_root
527                    .join(parts[i + 1])
528                    .canonicalize()
529                    .unwrap_or_else(|_| repo_root.join(parts[i + 1]));
530                old_paths.insert(resolved);
531            }
532
533            if sim >= min_similarity && i + 2 < parts.len() && !parts[i + 2].is_empty() {
534                let resolved = repo_root
535                    .join(parts[i + 2])
536                    .canonicalize()
537                    .unwrap_or_else(|_| repo_root.join(parts[i + 2]));
538                pure_new_paths.insert(resolved);
539            }
540
541            i += 3;
542        } else {
543            i += 1;
544        }
545    }
546
547    Ok((old_paths, pure_new_paths))
548}
549
550/// Rename pairs as repo-relative display paths (`old -> new`), for the output
551/// header. Unlike `get_renamed_paths` this preserves the pairing and does not
552/// canonicalize (the old path no longer exists on disk).
553pub fn get_rename_pairs(
554    repo_root: &Path,
555    diff_range: Option<&str>,
556) -> Result<Vec<(String, String)>> {
557    let mut args: Vec<&str> = vec!["diff"];
558    args.extend_from_slice(SAFE_DIFF_FLAGS);
559    args.extend_from_slice(&["--diff-filter=R", "--name-status", "-M", "-z"]);
560    if let Some(range) = diff_range {
561        validate_diff_range(range)?;
562        args.push(range);
563    }
564    let output = run_git(repo_root, &args)?;
565    let parts: Vec<&str> = output.split('\0').collect();
566
567    let mut pairs = Vec::new();
568    let mut i = 0;
569    while i < parts.len() {
570        if parts[i].starts_with('R') {
571            if i + 2 < parts.len() && !parts[i + 1].is_empty() && !parts[i + 2].is_empty() {
572                pairs.push((
573                    parts[i + 1].replace('\\', "/"),
574                    parts[i + 2].replace('\\', "/"),
575                ));
576            }
577            i += 3;
578        } else {
579            i += 1;
580        }
581    }
582    Ok(pairs)
583}
584
585pub fn split_diff_range(range: &str) -> (Option<String>, Option<String>) {
586    match RANGE_RE.captures(range) {
587        None => (None, None),
588        Some(caps) => {
589            let base = caps
590                .get(1)
591                .map(|m| m.as_str().trim().to_string())
592                .filter(|s| !s.is_empty());
593            let head = caps
594                .get(3)
595                .map(|m| m.as_str().trim().to_string())
596                .filter(|s| !s.is_empty());
597            (base, head)
598        }
599    }
600}
601
602pub fn show_file_at_revision(repo_root: &Path, rev: &str, rel_path: &Path) -> Result<String> {
603    validate_rev(rev)?;
604    let spec = format!("{}:{}", rev, rel_path.to_string_lossy().replace('\\', "/"));
605    run_git(repo_root, &["show", &spec])
606}
607
608pub fn get_commit_message(repo_root: &Path, rev: &str) -> Result<String> {
609    if validate_rev(rev).is_err() {
610        return Ok(String::new());
611    }
612    match run_git(repo_root, &["log", "-1", "--format=%s%n%b", rev]) {
613        Ok(s) => Ok(s.trim().to_string()),
614        Err(_) => Ok(String::new()),
615    }
616}
617
618pub fn get_untracked_files(repo_root: &Path) -> Result<Vec<PathBuf>> {
619    let parts = run_git_z(
620        repo_root,
621        &["ls-files", "--others", "--exclude-standard", "-z"],
622    )?;
623    Ok(parts
624        .iter()
625        .map(|p| {
626            repo_root
627                .join(p)
628                .canonicalize()
629                .unwrap_or_else(|_| repo_root.join(p))
630        })
631        .collect())
632}
633
634/// Rewrites one `.diffctx/ignore` pattern line to be anchored to the
635/// directory that contains the `.diffctx/` folder (`rel`, repo-root-relative,
636/// "" for the repo root itself). Mirrors `_process_ignore_line` in the
637/// Python tree-mode ignore resolver (`src/diffctx/ignore.py`) so a pattern
638/// declared in `sub/.diffctx/ignore` only ever matches within `sub/`.
639fn anchor_diffctx_ignore_line(line: &str, rel: &str) -> String {
640    let (neg, pat) = match line.strip_prefix('!') {
641        Some(rest) => (true, rest),
642        None => (false, line),
643    };
644    let pat_no_trailing_slash = pat.trim_end_matches('/');
645    let full = if pat_no_trailing_slash.starts_with('/') || pat_no_trailing_slash.contains('/') {
646        let anchored = pat.trim_start_matches('/');
647        if rel.is_empty() {
648            format!("/{anchored}")
649        } else {
650            format!("/{rel}/{anchored}")
651        }
652    } else if rel.is_empty() {
653        pat.to_string()
654    } else {
655        format!("{rel}/**/{pat}")
656    };
657    if neg { format!("!{full}") } else { full }
658}
659
660/// Finds every `.diffctx/ignore` file tracked or present in `repo_root`
661/// (any depth) and returns its patterns rewritten to be repo-root-relative,
662/// ready to feed into a combined gitignore-syntax exclude file.
663fn collect_diffctx_ignore_patterns(repo_root: &Path) -> Vec<String> {
664    let Ok(files) = run_git_z(
665        repo_root,
666        &[
667            "ls-files",
668            "-z",
669            "--cached",
670            "--others",
671            "--exclude-standard",
672            "--",
673            ":(glob)**/.diffctx/ignore",
674        ],
675    ) else {
676        return Vec::new();
677    };
678
679    let mut patterns = Vec::new();
680    for raw in &files {
681        let rel_path = unquote_c_style(raw);
682        if !rel_path.ends_with(".diffctx/ignore") {
683            continue;
684        }
685        let rel_dir = rel_path
686            .strip_suffix(".diffctx/ignore")
687            .unwrap_or("")
688            .trim_end_matches('/');
689        let Ok(content) = std::fs::read_to_string(repo_root.join(&rel_path)) else {
690            continue;
691        };
692        for line in content.lines() {
693            let line = line.trim_end();
694            if line.is_empty() || line.starts_with('#') {
695                continue;
696            }
697            patterns.push(anchor_diffctx_ignore_line(line, rel_dir));
698        }
699    }
700    patterns
701}
702
703/// Returns the subset of `rel_paths` (repo-root-relative) excluded by either
704/// `.gitignore` (via git's own engine, so nesting/negation/`**` are handled
705/// correctly) or `.diffctx/ignore` (patterns anchored per-directory and fed
706/// to git as a temporary `core.excludesFile`, so the same engine evaluates
707/// both mechanisms uniformly). Best-effort: any failure returns an empty set
708/// rather than blocking the diff pipeline on an ignore-resolution problem.
709///
710/// A `.gitignore` exclusion inherited from an excluded ancestor directory does
711/// NOT count. `--no-index` is required for `.diffctx/ignore` to apply to
712/// tracked files at all, but it also revives git's rule that a file cannot be
713/// re-included once a parent directory is excluded. pandoc excludes every
714/// dotted root entry with `/*.*` and re-includes `!.github/**`: git keeps
715/// `.github/workflows/ci.yml` because it is tracked, while `--no-index`
716/// reports it ignored *via the ancestor* — which silently reduced a real
717/// change to an empty selection (#153). A pattern matching the path itself
718/// still excludes it, so `.diffctx/ignore` and per-directory `.gitignore`
719/// rules (#85) keep working.
720pub fn find_ignored_paths(repo_root: &Path, rel_paths: &[String]) -> FxHashSet<String> {
721    if rel_paths.is_empty() {
722        return FxHashSet::default();
723    }
724
725    let diffctx_patterns = collect_diffctx_ignore_patterns(repo_root);
726    let temp_excludes = if diffctx_patterns.is_empty() {
727        None
728    } else {
729        let unique = TEMP_EXCLUDES_COUNTER.fetch_add(1, Ordering::Relaxed);
730        let path = std::env::temp_dir().join(format!(
731            "diffctx-ignore-{}-{}.tmp",
732            std::process::id(),
733            unique
734        ));
735        match std::fs::write(&path, diffctx_patterns.join("\n")) {
736            Ok(()) => Some(path),
737            Err(_) => None,
738        }
739    };
740
741    // Ancestors are queried alongside the paths themselves so an exclusion can
742    // be attributed: same winning rule on a parent directory means the file was
743    // only caught transitively.
744    let mut queries: Vec<String> = rel_paths.to_vec();
745    let mut ancestors: FxHashSet<String> = FxHashSet::default();
746    for rel in rel_paths {
747        for ancestor in ancestor_dirs(rel) {
748            if ancestors.insert(ancestor.clone()) {
749                queries.push(ancestor);
750            }
751        }
752    }
753
754    let mut args: Vec<String> = vec!["check-ignore".into(), "--no-index".into(), "-v".into()];
755    if let Some(ref path) = temp_excludes {
756        args.insert(0, format!("core.excludesFile={}", path.display()));
757        args.insert(0, "-c".into());
758    }
759    args.push("--".into());
760    args.extend(queries);
761    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
762
763    let result = (|| -> Result<FxHashSet<String>> {
764        let mut cmd = git_command(repo_root);
765        cmd.args(&arg_refs)
766            .stdout(Stdio::piped())
767            .stderr(Stdio::piped());
768        let child = cmd.spawn()?;
769        let output = wait_with_timeout(child, Duration::from_secs(git_timeout()), &arg_refs)?;
770        // Exit code 1 from `check-ignore` means "none of the given paths are
771        // ignored" — not a failure. Any other non-zero code is a real error.
772        if !output.status.success() && output.status.code() != Some(1) {
773            let stderr = String::from_utf8_lossy(&output.stderr);
774            return Err(GitError::CommandFailed(format!(
775                "git check-ignore failed: {}",
776                stderr.trim()
777            )));
778        }
779        let stdout = String::from_utf8_lossy(&output.stdout);
780        let excludes_source = temp_excludes.as_ref().map(|p| p.display().to_string());
781
782        let mut rules: rustc_hash::FxHashMap<String, String> = rustc_hash::FxHashMap::default();
783        for line in stdout.lines() {
784            if let Some((rule, path)) = parse_verbose_ignore_match(line) {
785                rules.insert(path, rule);
786            }
787        }
788
789        Ok(rel_paths
790            .iter()
791            .filter(|rel| match rules.get(*rel) {
792                None => false,
793                Some(rule) => {
794                    let from_diffctx = excludes_source
795                        .as_deref()
796                        .is_some_and(|src| rule.starts_with(&format!("{src}:")));
797                    from_diffctx
798                        || !ancestor_dirs(rel)
799                            .iter()
800                            .any(|dir| rules.get(dir) == Some(rule))
801                }
802            })
803            .cloned()
804            .collect())
805    })();
806
807    if let Some(path) = temp_excludes {
808        let _ = std::fs::remove_file(path);
809    }
810
811    result.unwrap_or_default()
812}
813
814/// `check-ignore -v` emits `<source>:<line>:<pattern>\t<path>`; returns the
815/// `<source>:<line>:<pattern>` rule identity and the path it matched.
816///
817/// Split from the right: git prints the pattern raw but C-quotes any path
818/// containing a tab, so the last tab is always the separator. Splitting from
819/// the left mis-parses a pattern that itself contains a tab, and the resulting
820/// lookup miss reports an ignored file as not ignored — i.e. it leaks.
821fn parse_verbose_ignore_match(line: &str) -> Option<(String, String)> {
822    let (rule, path) = line.rsplit_once('\t')?;
823    Some((rule.to_string(), unquote_c_style(path)))
824}
825
826fn ancestor_dirs(rel: &str) -> Vec<String> {
827    let mut dirs = Vec::new();
828    let mut remainder = rel;
829    while let Some((parent, _)) = remainder.rsplit_once('/') {
830        dirs.push(parent.to_string());
831        remainder = parent;
832    }
833    dirs
834}
835
836pub struct CatFileBatch {
837    repo_root: PathBuf,
838    child: Option<Child>,
839    reader: Option<BufReader<ChildStdout>>,
840}
841
842impl CatFileBatch {
843    pub fn new(repo_root: &Path) -> Result<Self> {
844        let mut batch = Self {
845            repo_root: repo_root.to_path_buf(),
846            child: None,
847            reader: None,
848        };
849        batch.ensure_started()?;
850        Ok(batch)
851    }
852
853    fn ensure_started(&mut self) -> Result<()> {
854        let needs_restart = match &mut self.child {
855            None => true,
856            Some(child) => child.try_wait().ok().flatten().is_some(),
857        };
858
859        if needs_restart {
860            let mut child = git_command(&self.repo_root)
861                .args(["cat-file", "--batch"])
862                .stdin(Stdio::piped())
863                .stdout(Stdio::piped())
864                .stderr(Stdio::null())
865                .spawn()?;
866            let stdout = child.stdout.take().ok_or_else(|| {
867                GitError::CommandFailed("cat-file: failed to capture stdout pipe".into())
868            })?;
869            self.reader = Some(BufReader::new(stdout));
870            self.child = Some(child);
871        }
872
873        Ok(())
874    }
875
876    pub fn get(&mut self, rev: &str, rel_path: &Path) -> Result<String> {
877        validate_rev(rev)?;
878        let spec = format!(
879            "{}:{}\n",
880            rev,
881            rel_path.to_string_lossy().replace('\\', "/")
882        );
883
884        self.ensure_started()?;
885
886        let stdin = self
887            .child
888            .as_mut()
889            .and_then(|c| c.stdin.as_mut())
890            .ok_or_else(|| GitError::CommandFailed("cat-file stdin unavailable".into()))?;
891        stdin.write_all(spec.as_bytes())?;
892        stdin.flush()?;
893
894        let reader = self
895            .reader
896            .as_mut()
897            .ok_or_else(|| GitError::CommandFailed("cat-file stdout unavailable".into()))?;
898
899        let mut header_line = String::new();
900        reader.read_line(&mut header_line)?;
901
902        if header_line.is_empty() {
903            return Err(GitError::CommandFailed(format!(
904                "cat-file: unexpected EOF for {}",
905                spec.trim()
906            )));
907        }
908
909        let header_str = header_line.trim();
910        if header_str.ends_with("missing") {
911            return Err(GitError::CommandFailed(format!(
912                "Path not found: {}",
913                spec.trim()
914            )));
915        }
916
917        let parts: Vec<&str> = header_str.split_whitespace().collect();
918        if parts.len() < 3 {
919            return Err(GitError::CommandFailed(format!(
920                "cat-file: malformed header: {}",
921                header_str
922            )));
923        }
924
925        let size: usize = parts[2].parse().map_err(|_| {
926            GitError::CommandFailed(format!("cat-file: invalid size in header: {}", header_str))
927        })?;
928
929        // Guard against allocating an unbounded blob. Anything larger than the
930        // biggest size we will ever parse is drained from the stream in bounded
931        // chunks (to keep the cat-file pipe in sync for the next request) and
932        // rejected, instead of allocating `size` bytes up front (OOM on a
933        // pathological multi-hundred-MB blob).
934        if size > crate::config::limits::MAX_BLOB_READ_BYTES {
935            let mut remaining = size;
936            let mut scratch = [0u8; 65536];
937            while remaining > 0 {
938                let want = remaining.min(scratch.len());
939                reader.read_exact(&mut scratch[..want])?;
940                remaining -= want;
941            }
942            let mut trailing = [0u8; 1];
943            let _ = reader.read_exact(&mut trailing);
944            return Err(GitError::CommandFailed(format!(
945                "cat-file: blob too large ({} bytes): {}",
946                size,
947                spec.trim()
948            )));
949        }
950
951        let mut content = vec![0u8; size];
952        reader.read_exact(&mut content)?;
953
954        let mut trailing = [0u8; 1];
955        let _ = reader.read_exact(&mut trailing);
956
957        Ok(String::from_utf8_lossy(&content).into_owned())
958    }
959
960    pub fn close(&mut self) {
961        self.reader.take();
962        if let Some(mut child) = self.child.take() {
963            drop(child.stdin.take());
964            match child.wait_timeout(Duration::from_secs(GIT.catfile_termination_timeout_seconds)) {
965                Ok(Some(_)) => {}
966                _ => {
967                    let _ = child.kill();
968                    let _ = child.wait();
969                }
970            }
971        }
972    }
973}
974
975impl Drop for CatFileBatch {
976    fn drop(&mut self) {
977        self.close();
978    }
979}
980
981#[cfg(test)]
982mod tests {
983    use super::*;
984    use std::fs;
985    use std::sync::Barrier;
986    use tempfile::TempDir;
987
988    fn git(dir: &Path, args: &[&str]) {
989        let status = git_command(dir)
990            .args(args)
991            .status()
992            .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
993        assert!(status.success(), "git {args:?} failed");
994    }
995
996    fn init_git_repo(dir: &Path) {
997        git(dir, &["init", "-q", "-b", "main"]);
998        git(dir, &["config", "user.email", "test@example.com"]);
999        git(dir, &["config", "user.name", "Test"]);
1000        git(dir, &["config", "commit.gpgsign", "false"]);
1001    }
1002
1003    fn commit_all(dir: &Path, message: &str) {
1004        git(dir, &["add", "-A"]);
1005        git(dir, &["commit", "-q", "-m", message]);
1006    }
1007
1008    fn write_file(root: &Path, rel: &str, content: &str) {
1009        let path = root.join(rel);
1010        if let Some(parent) = path.parent() {
1011            fs::create_dir_all(parent).expect("create parent");
1012        }
1013        fs::write(&path, content).expect("write file");
1014    }
1015
1016    // --- SAFE_DIFF_FLAGS pins the parser against hostile repo-local config ---
1017
1018    struct HunkShape {
1019        old_start: u32,
1020        old_len: u32,
1021        new_start: u32,
1022        new_len: u32,
1023    }
1024
1025    fn hunk_shapes(hunks: &[DiffHunk]) -> Vec<HunkShape> {
1026        hunks
1027            .iter()
1028            .map(|h| HunkShape {
1029                old_start: h.old_start,
1030                old_len: h.old_len,
1031                new_start: h.new_start,
1032                new_len: h.new_len,
1033            })
1034            .collect()
1035    }
1036
1037    fn basenames(paths: &[PathBuf]) -> Vec<String> {
1038        let mut names: Vec<String> = paths
1039            .iter()
1040            .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1041            .collect();
1042        names.sort();
1043        names
1044    }
1045
1046    fn assert_diff_survives_hostile_config(hostile_config: &[&[&str]]) {
1047        let tmp = TempDir::new().expect("tempdir");
1048        let clean_root = tmp.path().join("clean");
1049        let hostile_root = tmp.path().join("hostile");
1050        fs::create_dir_all(&clean_root).expect("mkdir clean");
1051        fs::create_dir_all(&hostile_root).expect("mkdir hostile");
1052
1053        for root in [&clean_root, &hostile_root] {
1054            init_git_repo(root);
1055            write_file(root, "app.py", "def f():\n    return 1\n");
1056            commit_all(root, "initial");
1057            write_file(root, "app.py", "def f():\n    return 2\n");
1058            commit_all(root, "change");
1059        }
1060        for args in hostile_config {
1061            git(&hostile_root, args);
1062        }
1063
1064        let clean_hunks = parse_diff(&clean_root, Some("HEAD~1..HEAD")).expect("clean parse_diff");
1065        let hostile_hunks =
1066            parse_diff(&hostile_root, Some("HEAD~1..HEAD")).expect("hostile parse_diff");
1067        assert!(
1068            !hostile_hunks.is_empty(),
1069            "hostile git config reduced the diff to zero hunks"
1070        );
1071        assert_eq!(
1072            hunk_shapes(&hostile_hunks)
1073                .iter()
1074                .map(|s| (s.old_start, s.old_len, s.new_start, s.new_len))
1075                .collect::<Vec<_>>(),
1076            hunk_shapes(&clean_hunks)
1077                .iter()
1078                .map(|s| (s.old_start, s.old_len, s.new_start, s.new_len))
1079                .collect::<Vec<_>>(),
1080            "hostile config changed the parsed hunk shape vs a clean-config repo"
1081        );
1082
1083        let clean_files =
1084            get_changed_files(&clean_root, Some("HEAD~1..HEAD")).expect("clean changed files");
1085        let hostile_files =
1086            get_changed_files(&hostile_root, Some("HEAD~1..HEAD")).expect("hostile changed files");
1087        assert!(
1088            !hostile_files.is_empty(),
1089            "hostile git config reduced changed_files to empty"
1090        );
1091        assert_eq!(
1092            basenames(&hostile_files),
1093            basenames(&clean_files),
1094            "hostile config changed the changed_files set vs a clean-config repo"
1095        );
1096    }
1097
1098    #[test]
1099    fn diff_survives_diff_noprefix() {
1100        assert_diff_survives_hostile_config(&[&["config", "diff.noprefix", "true"]]);
1101    }
1102
1103    #[test]
1104    fn diff_survives_diff_mnemonic_prefix() {
1105        assert_diff_survives_hostile_config(&[&["config", "diff.mnemonicPrefix", "true"]]);
1106    }
1107
1108    #[test]
1109    fn diff_survives_custom_src_dst_prefix() {
1110        assert_diff_survives_hostile_config(&[
1111            &["config", "diff.srcPrefix", "x/"],
1112            &["config", "diff.dstPrefix", "y/"],
1113        ]);
1114    }
1115
1116    #[test]
1117    fn diff_survives_color_ui_always() {
1118        assert_diff_survives_hostile_config(&[&["config", "color.ui", "always"]]);
1119    }
1120
1121    // --- validate_diff_range: reject argv-injection ranges, keep legit ones ---
1122
1123    #[test]
1124    fn validate_diff_range_rejects_option_smuggled_in_range() {
1125        for hostile in ["HEAD..--ext-diff", "a...-p", "..--upload-pack=x"] {
1126            assert!(
1127                validate_diff_range(hostile).is_err(),
1128                "expected {hostile:?} to be rejected"
1129            );
1130        }
1131    }
1132
1133    #[test]
1134    fn validate_diff_range_accepts_legitimate_ranges() {
1135        for legit in [
1136            "HEAD~1..HEAD",
1137            "@{-1}..HEAD",
1138            "HEAD~2...origin/main",
1139            "main..feature/x",
1140        ] {
1141            assert!(
1142                validate_diff_range(legit).is_ok(),
1143                "expected {legit:?} to be accepted"
1144            );
1145        }
1146    }
1147
1148    // --- parse_verbose_ignore_match: last-tab split survives a tab in the pattern ---
1149
1150    #[test]
1151    fn parse_verbose_ignore_match_splits_on_last_tab_not_first() {
1152        // The pattern itself ("foo\tbar") contains a literal tab. Splitting
1153        // from the left would take "foo" as the path, silently reporting an
1154        // ignored file as not ignored.
1155        let line = ".gitignore:3:foo\tbar\tsome/real/path.txt";
1156        let (rule, path) = parse_verbose_ignore_match(line).expect("parse");
1157        assert_eq!(path, "some/real/path.txt");
1158        assert_eq!(rule, ".gitignore:3:foo\tbar");
1159    }
1160
1161    #[test]
1162    fn parse_verbose_ignore_match_unquotes_c_style_path() {
1163        let line = ".gitignore:1:*.log\t\"weird\\tfile.log\"";
1164        let (rule, path) = parse_verbose_ignore_match(line).expect("parse");
1165        assert_eq!(path, "weird\tfile.log");
1166        assert_eq!(rule, ".gitignore:1:*.log");
1167    }
1168
1169    // --- anchor_diffctx_ignore_line: 4 reachable outputs, root vs nested, negation ---
1170
1171    #[test]
1172    fn anchor_ignore_line_bare_pattern_at_root() {
1173        assert_eq!(anchor_diffctx_ignore_line("*.log", ""), "*.log");
1174    }
1175
1176    #[test]
1177    fn anchor_ignore_line_bare_pattern_nested() {
1178        assert_eq!(anchor_diffctx_ignore_line("*.log", "sub"), "sub/**/*.log");
1179    }
1180
1181    #[test]
1182    fn anchor_ignore_line_slash_pattern_at_root() {
1183        assert_eq!(
1184            anchor_diffctx_ignore_line("secrets/config.py", ""),
1185            "/secrets/config.py"
1186        );
1187    }
1188
1189    #[test]
1190    fn anchor_ignore_line_slash_pattern_nested() {
1191        assert_eq!(
1192            anchor_diffctx_ignore_line("secrets/config.py", "sub"),
1193            "/sub/secrets/config.py"
1194        );
1195    }
1196
1197    #[test]
1198    fn anchor_ignore_line_negated_bare_pattern() {
1199        assert_eq!(anchor_diffctx_ignore_line("!keep.log", ""), "!keep.log");
1200    }
1201
1202    #[test]
1203    fn anchor_ignore_line_negated_slash_pattern_nested() {
1204        assert_eq!(
1205            anchor_diffctx_ignore_line("!secrets/keep.py", "sub"),
1206            "!/sub/secrets/keep.py"
1207        );
1208    }
1209
1210    // --- unquote_c_style + the quoted diff-header branch ---
1211
1212    #[test]
1213    fn unquote_c_style_decodes_octal_utf8_escapes() {
1214        // Exactly what git emits for `café.py` under the default
1215        // core.quotePath=true: é is UTF-8 0xC3 0xA9, i.e. octal 303 251.
1216        let quoted = r#""a/caf\303\251.py""#;
1217        assert_eq!(unquote_c_style(quoted), "a/café.py");
1218    }
1219
1220    #[test]
1221    fn unquote_c_style_leaves_unquoted_input_untouched() {
1222        assert_eq!(unquote_c_style("a/plain.py"), "a/plain.py");
1223    }
1224
1225    #[test]
1226    fn parse_path_line_takes_quoted_branch_for_old_and_new_headers() {
1227        let tmp = TempDir::new().expect("tempdir");
1228        let root = tmp.path();
1229        // The path must exist on disk: parse_path_line canonicalizes the
1230        // joined path to guard against traversal, and a nonexistent target
1231        // can fail to canonicalize while the (existing) root does, tripping
1232        // the containment check on platforms where the temp dir sits behind
1233        // a symlink (e.g. macOS /var -> /private/var) for reasons unrelated
1234        // to the quoted-header parsing this test targets.
1235        write_file(root, "café.py", "value = 1\n");
1236
1237        let old_line = r#"--- "a/caf\303\251.py""#;
1238        let (kind, path) = parse_path_line(old_line, root);
1239        assert_eq!(kind, "old");
1240        assert_eq!(
1241            path.expect("old path")
1242                .file_name()
1243                .unwrap()
1244                .to_string_lossy(),
1245            "café.py"
1246        );
1247
1248        let new_line = r#"+++ "b/caf\303\251.py""#;
1249        let (kind, path) = parse_path_line(new_line, root);
1250        assert_eq!(kind, "new");
1251        assert_eq!(
1252            path.expect("new path")
1253                .file_name()
1254                .unwrap()
1255                .to_string_lossy(),
1256            "café.py"
1257        );
1258    }
1259
1260    #[test]
1261    fn parse_diff_handles_real_repo_with_default_quoted_unicode_filename() {
1262        // core.quotePath defaults to true, so a real git diff over a renamed
1263        // non-ASCII file exercises the quoted branch end-to-end, not just the
1264        // helper in isolation.
1265        let tmp = TempDir::new().expect("tempdir");
1266        let root = tmp.path();
1267        init_git_repo(root);
1268        write_file(root, "café.py", "value = 1\n");
1269        commit_all(root, "initial");
1270        write_file(root, "café.py", "value = 2\n");
1271        commit_all(root, "change");
1272
1273        let hunks = parse_diff(root, Some("HEAD~1..HEAD")).expect("parse_diff");
1274        assert!(
1275            !hunks.is_empty(),
1276            "quoted unicode diff header was not parsed into any hunk"
1277        );
1278        assert!(
1279            hunks.iter().any(|h| h.path.contains("café")),
1280            "no hunk carried the decoded unicode path, got: {:?}",
1281            hunks.iter().map(|h| h.path.as_ref()).collect::<Vec<_>>()
1282        );
1283    }
1284
1285    // --- subprocess timeout/kill: wait_with_timeout must not hang or orphan ---
1286
1287    #[test]
1288    fn wait_with_timeout_kills_long_running_child_and_returns_promptly() {
1289        let child = Command::new("sleep")
1290            .arg("30")
1291            .stdout(Stdio::piped())
1292            .stderr(Stdio::piped())
1293            .spawn()
1294            .expect("spawn sleep");
1295        let pid = child.id();
1296
1297        let start = std::time::Instant::now();
1298        let result = wait_with_timeout(child, Duration::from_millis(200), &["sleep", "30"]);
1299        let elapsed = start.elapsed();
1300
1301        assert!(
1302            matches!(result, Err(GitError::Timeout(_))),
1303            "expected Timeout error, got {result:?}"
1304        );
1305        assert!(
1306            elapsed < Duration::from_secs(5),
1307            "wait_with_timeout should return promptly, took {elapsed:?}"
1308        );
1309
1310        // The child must actually be reaped, not orphaned: `kill -0` on a
1311        // reaped pid fails once the OS releases it. Retry briefly since the
1312        // OS may hold a zombie slot for a moment after the kill.
1313        let mut still_alive = true;
1314        for _ in 0..20 {
1315            let status = Command::new("kill")
1316                .args(["-0", &pid.to_string()])
1317                .stdout(Stdio::null())
1318                .stderr(Stdio::null())
1319                .status()
1320                .expect("spawn kill -0");
1321            if !status.success() {
1322                still_alive = false;
1323                break;
1324            }
1325            std::thread::sleep(Duration::from_millis(50));
1326        }
1327        assert!(!still_alive, "child pid {pid} was not reaped after timeout");
1328    }
1329
1330    #[test]
1331    fn wait_with_timeout_does_not_penalize_fast_commands() {
1332        let child = Command::new("true")
1333            .stdout(Stdio::piped())
1334            .stderr(Stdio::piped())
1335            .spawn()
1336            .expect("spawn true");
1337        let result = wait_with_timeout(child, Duration::from_secs(5), &["true"]);
1338        assert!(matches!(result, Ok(ref out) if out.status.success()));
1339    }
1340
1341    // --- PID-keyed temp excludesFile: two concurrent calls in one process ---
1342
1343    #[test]
1344    fn find_ignored_paths_concurrent_calls_both_see_their_own_ignore_rules() {
1345        let tmp = TempDir::new().expect("tempdir");
1346        let root_a = tmp.path().join("repo_a");
1347        let root_b = tmp.path().join("repo_b");
1348        fs::create_dir_all(&root_a).expect("mkdir a");
1349        fs::create_dir_all(&root_b).expect("mkdir b");
1350
1351        for (root, secret) in [(&root_a, "secret_a.py"), (&root_b, "secret_b.py")] {
1352            init_git_repo(root);
1353            write_file(root, "app.py", "print('hi')\n");
1354            write_file(root, ".diffctx/ignore", &format!("{secret}\n"));
1355            write_file(root, secret, "SECRET\n");
1356            commit_all(root, "initial");
1357        }
1358
1359        // Run several concurrent rounds: a single lucky interleaving proved
1360        // the pre-fix PID-only path could collide, so repeat to make a
1361        // regression reliably visible instead of a one-shot coin flip.
1362        for _ in 0..10 {
1363            let barrier = Arc::new(Barrier::new(2));
1364
1365            let root_a_thread = root_a.clone();
1366            let barrier_a = Arc::clone(&barrier);
1367            let handle_a = std::thread::spawn(move || {
1368                barrier_a.wait();
1369                find_ignored_paths(
1370                    &root_a_thread,
1371                    &["secret_a.py".to_string(), "app.py".to_string()],
1372                )
1373            });
1374
1375            let root_b_thread = root_b.clone();
1376            let barrier_b = Arc::clone(&barrier);
1377            let handle_b = std::thread::spawn(move || {
1378                barrier_b.wait();
1379                find_ignored_paths(
1380                    &root_b_thread,
1381                    &["secret_b.py".to_string(), "app.py".to_string()],
1382                )
1383            });
1384
1385            let ignored_a = handle_a.join().expect("thread a panicked");
1386            let ignored_b = handle_b.join().expect("thread b panicked");
1387
1388            assert!(
1389                ignored_a.contains("secret_a.py"),
1390                "repo A lost its .diffctx/ignore rule to a concurrent call"
1391            );
1392            assert!(
1393                ignored_b.contains("secret_b.py"),
1394                "repo B lost its .diffctx/ignore rule to a concurrent call"
1395            );
1396            assert!(!ignored_a.contains("app.py"));
1397            assert!(!ignored_b.contains("app.py"));
1398        }
1399    }
1400}