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
16thread_local! {
22 static GIT_TIMEOUT_SECS: std::cell::Cell<u64> =
23 const { std::cell::Cell::new(git::DEFAULT_TIMEOUT_SECONDS) };
24}
25
26pub fn set_git_timeout(secs: u64) {
27 GIT_TIMEOUT_SECS.with(|c| c.set(secs));
28}
29
30static TEMP_EXCLUDES_COUNTER: AtomicU64 = AtomicU64::new(0);
38
39fn git_timeout() -> u64 {
40 GIT_TIMEOUT_SECS.with(|c| c.get())
41}
42const SAFE_DIFF_FLAGS: &[&str] = &[
47 "--no-textconv",
48 "--no-ext-diff",
49 "--no-color",
50 "--src-prefix=a/",
51 "--dst-prefix=b/",
52];
53
54static HUNK_RE: Lazy<Regex> =
55 Lazy::new(|| Regex::new(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@").unwrap());
56
57static RANGE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\s*(\S+?)(\.\.\.?)(\S*?)\s*$").unwrap());
58
59static SAFE_RANGE_RE: Lazy<Regex> = Lazy::new(|| {
65 Regex::new(
66 r"^[a-zA-Z0-9_.^~/@{}][a-zA-Z0-9_.^~/@{}\-]*(\.\.\.?([a-zA-Z0-9_.^~/@{}][a-zA-Z0-9_.^~/@{}\-]*)?)?$",
67 )
68 .unwrap()
69});
70
71#[derive(Debug, thiserror::Error)]
72pub enum GitError {
73 #[error("{0}")]
74 CommandFailed(String),
75 #[error("not a git repository: {0}")]
76 NotARepo(PathBuf),
77 #[error("invalid diff range: {0}")]
78 InvalidRange(String),
79 #[error("io error: {0}")]
80 Io(#[from] std::io::Error),
81 #[error("timeout after {0}s")]
82 Timeout(u64),
83}
84
85pub type Result<T> = std::result::Result<T, GitError>;
86
87fn validate_diff_range(diff_range: &str) -> Result<()> {
88 let trimmed = diff_range.trim();
89 if trimmed.starts_with('.') || trimmed.starts_with('/') {
94 return Err(GitError::InvalidRange(diff_range.to_string()));
95 }
96 if !SAFE_RANGE_RE.is_match(trimmed) {
97 return Err(GitError::InvalidRange(diff_range.to_string()));
98 }
99 let separator = if trimmed.contains("...") { "..." } else { ".." };
106 for side in trimmed.split(separator) {
107 if !side.is_empty() {
108 validate_rev(side).map_err(|_| GitError::InvalidRange(diff_range.to_string()))?;
109 }
110 }
111 Ok(())
112}
113
114fn validate_rev(rev: &str) -> Result<()> {
120 if rev.is_empty()
121 || rev.starts_with('-')
122 || rev
123 .chars()
124 .any(|c| c.is_whitespace() || c.is_control() || c == '\0')
125 {
126 return Err(GitError::InvalidRange(rev.to_string()));
127 }
128 Ok(())
129}
130
131static DURATION_PART_RE: Lazy<Regex> = Lazy::new(|| {
132 Regex::new(
133 r"(?i)^(\d{1,9})\s*(weeks?|w|days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)",
134 )
135 .unwrap()
136});
137
138fn parse_duration_seconds(spec: &str) -> Option<u64> {
143 let mut rest = spec.trim();
144 if rest.is_empty() {
145 return None;
146 }
147 let mut total: u64 = 0;
148 while !rest.is_empty() {
149 let caps = DURATION_PART_RE.captures(rest)?;
150 let count: u64 = caps[1].parse().ok()?;
151 let unit_seconds = match caps[2].to_ascii_lowercase().as_str() {
152 "w" | "week" | "weeks" => 7 * 24 * 3600,
153 "d" | "day" | "days" => 24 * 3600,
154 "h" | "hr" | "hrs" | "hour" | "hours" => 3600,
155 "m" | "min" | "mins" | "minute" | "minutes" => 60,
156 _ => 1,
157 };
158 total = total.checked_add(count.checked_mul(unit_seconds)?)?;
159 rest = rest[caps[0].len()..].trim_start();
160 }
161 Some(total)
162}
163
164pub struct ResolvedRange {
165 pub range: Option<String>,
166 pub from_duration: bool,
169}
170
171impl ResolvedRange {
172 fn verbatim(diff_range: Option<&str>) -> Self {
173 Self {
174 range: diff_range.map(str::to_string),
175 from_duration: false,
176 }
177 }
178}
179
180fn empty_tree_oid(repo_root: &Path) -> String {
184 const SHA1_EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"; const SHA256_EMPTY_TREE: &str =
186 "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321"; match run_git(repo_root, &["rev-parse", "--show-object-format"]) {
188 Ok(format) if format.trim() == "sha256" => SHA256_EMPTY_TREE.to_string(),
189 _ => SHA1_EMPTY_TREE.to_string(),
190 }
191}
192
193fn rev_exists(repo_root: &Path, rev: &str) -> bool {
194 if validate_rev(rev).is_err() {
195 return false;
196 }
197 let spec = format!("{rev}^{{commit}}");
198 run_git(repo_root, &["rev-parse", "--verify", "--quiet", &spec]).is_ok()
199}
200
201pub fn resolve_duration_range(repo_root: &Path, diff_range: Option<&str>) -> Result<ResolvedRange> {
210 let Some(spec) = diff_range else {
211 return Ok(ResolvedRange::verbatim(None));
212 };
213 let trimmed = spec.trim();
214 let Some(seconds) = parse_duration_seconds(trimmed) else {
215 return Ok(ResolvedRange::verbatim(diff_range));
216 };
217 if rev_exists(repo_root, trimmed) {
218 return Ok(ResolvedRange::verbatim(diff_range));
219 }
220 let before = format!("--before={seconds} seconds ago");
222 let base = run_git(repo_root, &["rev-list", "-1", &before, "HEAD", "--"])
223 .map(|out| out.trim().to_string())
224 .unwrap_or_default();
225 let base = if base.is_empty() {
226 empty_tree_oid(repo_root)
227 } else {
228 base
229 };
230 Ok(ResolvedRange {
231 range: Some(base),
232 from_duration: true,
233 })
234}
235
236pub fn git_command(repo_root: &Path) -> Command {
242 let mut cmd = Command::new("git");
243 cmd.arg("-C")
244 .arg(repo_root)
245 .env_remove("GIT_DIR")
246 .env_remove("GIT_WORK_TREE")
247 .env_remove("GIT_INDEX_FILE");
248 cmd
249}
250
251pub fn run_git(repo_root: &Path, args: &[&str]) -> Result<String> {
252 let mut cmd = git_command(repo_root);
253 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
254
255 let child = cmd.spawn().map_err(|e| {
256 if e.kind() == std::io::ErrorKind::NotFound {
257 GitError::CommandFailed("git is not installed or not in PATH".into())
258 } else {
259 GitError::Io(e)
260 }
261 })?;
262
263 let output = wait_with_timeout(child, Duration::from_secs(git_timeout()), args)?;
264
265 if !output.status.success() {
266 let stderr = String::from_utf8_lossy(&output.stderr);
267 let subcommand = args
268 .iter()
269 .find(|a| !a.starts_with('-'))
270 .copied()
271 .unwrap_or("command");
272 let reason = stderr
273 .lines()
274 .map(str::trim)
275 .find(|l| l.starts_with("fatal:") || l.starts_with("error:"))
276 .or_else(|| stderr.lines().map(str::trim).find(|l| !l.is_empty()))
277 .unwrap_or("unknown error");
278 return Err(GitError::CommandFailed(format!(
279 "git {subcommand} failed: {reason}"
280 )));
281 }
282
283 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
284}
285
286fn wait_with_timeout(
287 child: Child,
288 timeout: Duration,
289 _args: &[&str],
290) -> Result<std::process::Output> {
291 let mut child = child;
292 let stdout_handle = child.stdout.take().map(|mut s| {
293 std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
294 let mut buf = Vec::new();
295 s.read_to_end(&mut buf)?;
296 Ok(buf)
297 })
298 });
299 let stderr_handle = child.stderr.take().map(|mut s| {
300 std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
301 let mut buf = Vec::new();
302 s.read_to_end(&mut buf)?;
303 Ok(buf)
304 })
305 });
306
307 let status = match child.wait_timeout(timeout)? {
308 Some(status) => status,
309 None => {
310 let _ = child.kill();
311 let _ = child.wait();
312 return Err(GitError::Timeout(timeout.as_secs()));
313 }
314 };
315
316 let stdout = stdout_handle
317 .and_then(|h| h.join().ok())
318 .and_then(|r| r.ok())
319 .unwrap_or_default();
320 let stderr = stderr_handle
321 .and_then(|h| h.join().ok())
322 .and_then(|r| r.ok())
323 .unwrap_or_default();
324
325 Ok(std::process::Output {
326 status,
327 stdout,
328 stderr,
329 })
330}
331
332pub fn is_git_repo(path: &Path) -> bool {
333 run_git(path, &["rev-parse", "--git-dir"]).is_ok()
334}
335
336pub fn find_toplevel(path: &Path) -> Option<PathBuf> {
343 let out = run_git(path, &["rev-parse", "--show-toplevel"]).ok()?;
344 let trimmed = out.trim();
345 if trimmed.is_empty() {
346 return None;
347 }
348 Some(PathBuf::from(trimmed))
349}
350
351pub fn get_diff_text(repo_root: &Path, diff_range: Option<&str>) -> Result<String> {
352 let mut args: Vec<&str> = vec!["diff"];
353 args.extend_from_slice(SAFE_DIFF_FLAGS);
354 if let Some(range) = diff_range {
355 validate_diff_range(range)?;
356 args.push(range);
357 }
358 run_git(repo_root, &args)
359}
360
361pub(crate) fn unquote_c_style(quoted: &str) -> String {
362 if !(quoted.starts_with('"') && quoted.ends_with('"')) {
363 return quoted.to_string();
364 }
365
366 let raw = "ed[1..quoted.len() - 1];
367 let bytes = raw.as_bytes();
368 let mut result: Vec<u8> = Vec::with_capacity(bytes.len());
369 let mut i = 0;
370
371 while i < bytes.len() {
372 if bytes[i] == b'\\' && i + 1 < bytes.len() {
373 let nxt = bytes[i + 1];
374 match nxt {
375 b't' => {
376 result.push(b'\t');
377 i += 2;
378 }
379 b'n' => {
380 result.push(b'\n');
381 i += 2;
382 }
383 b'r' => {
384 result.push(b'\r');
385 i += 2;
386 }
387 b'b' => {
388 result.push(0x08);
389 i += 2;
390 }
391 b'f' => {
392 result.push(0x0C);
393 i += 2;
394 }
395 b'v' => {
396 result.push(0x0B);
397 i += 2;
398 }
399 b'a' => {
400 result.push(0x07);
401 i += 2;
402 }
403 b'\\' => {
404 result.push(b'\\');
405 i += 2;
406 }
407 b'"' => {
408 result.push(b'"');
409 i += 2;
410 }
411 b'0'..=b'7'
412 if i + 3 < bytes.len()
413 && bytes[i + 2].is_ascii_digit()
414 && bytes[i + 2] <= b'7'
415 && bytes[i + 3].is_ascii_digit()
416 && bytes[i + 3] <= b'7' =>
417 {
418 let val = (nxt - b'0') * 64 + (bytes[i + 2] - b'0') * 8 + (bytes[i + 3] - b'0');
419 result.push(val);
420 i += 4;
421 }
422 _ => {
423 result.push(b'\\');
424 i += 1;
425 }
426 }
427 } else {
428 result.push(bytes[i]);
429 i += 1;
430 }
431 }
432
433 String::from_utf8(result).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
434}
435
436pub(crate) fn resolve_in_repo(repo_root: &Path, rel_path: &str) -> Option<PathBuf> {
454 let rel = Path::new(rel_path);
455 if rel.is_absolute()
456 || rel
457 .components()
458 .any(|c| matches!(c, std::path::Component::ParentDir))
459 {
460 return None;
461 }
462
463 let joined = repo_root.join(rel);
464 match joined.canonicalize() {
471 Ok(resolved) => {
472 let resolved_root = repo_root
473 .canonicalize()
474 .unwrap_or_else(|_| repo_root.to_path_buf());
475 if !resolved.starts_with(&resolved_root) {
476 return None;
477 }
478 }
479 Err(_) => {}
485 }
486 Some(joined)
487}
488
489pub(crate) fn parse_path_line(line: &str, repo_root: &Path) -> (&'static str, Option<PathBuf>) {
490 if line.starts_with("--- /dev/null") {
491 return ("old", None);
492 }
493 if line.starts_with("+++ /dev/null") {
494 return ("new", None);
495 }
496
497 let (kind, rel_path) = if let Some(rest) = line.strip_prefix("--- a/") {
498 ("old", rest.trim().to_string())
499 } else if let Some(rest) = line.strip_prefix("+++ b/") {
500 ("new", rest.trim().to_string())
501 } else if let Some(rest) = line.strip_prefix("--- ").filter(|r| r.starts_with("\"a/")) {
502 let unquoted = unquote_c_style(rest.trim());
503 (
504 "old",
505 unquoted.strip_prefix("a/").unwrap_or(&unquoted).to_string(),
506 )
507 } else if let Some(rest) = line.strip_prefix("+++ ").filter(|r| r.starts_with("\"b/")) {
508 let unquoted = unquote_c_style(rest.trim());
509 (
510 "new",
511 unquoted.strip_prefix("b/").unwrap_or(&unquoted).to_string(),
512 )
513 } else {
514 return ("", None);
515 };
516
517 match resolve_in_repo(repo_root, &rel_path) {
518 Some(path) => (kind, Some(path)),
519 None => ("", None),
520 }
521}
522
523fn parse_hunk_header(caps: ®ex::Captures, path: &Path) -> Option<DiffHunk> {
524 let old_start: u32 = caps[1].parse().ok()?;
529 let old_len: u32 = match caps.get(2) {
530 Some(m) => m.as_str().parse().ok()?,
531 None => 1,
532 };
533 let new_start: u32 = caps[3].parse().ok()?;
534 let new_len: u32 = match caps.get(4) {
535 Some(m) => m.as_str().parse().ok()?,
536 None => 1,
537 };
538
539 Some(DiffHunk {
540 path: Arc::from(path.to_string_lossy().as_ref()),
541 new_start,
542 new_len,
543 old_start,
544 old_len,
545 })
546}
547
548pub fn parse_diff(repo_root: &Path, diff_range: Option<&str>) -> Result<Vec<DiffHunk>> {
549 let mut args: Vec<&str> = vec!["diff"];
550 args.extend_from_slice(SAFE_DIFF_FLAGS);
551 args.push("--unified=0");
552 args.push("-M");
553 if let Some(range) = diff_range {
554 validate_diff_range(range)?;
555 args.push(range);
556 }
557
558 let output = run_git(repo_root, &args)?;
559 Ok(parse_hunks_from_diff_output(&output, repo_root))
560}
561
562pub(crate) fn parse_hunks_from_diff_output(output: &str, repo_root: &Path) -> Vec<DiffHunk> {
563 let mut hunks = Vec::new();
564 let mut old_path: Option<PathBuf> = None;
565 let mut new_path: Option<PathBuf> = None;
566
567 for line in output.lines() {
568 if line.starts_with("diff --git ") {
575 old_path = None;
576 new_path = None;
577 continue;
578 }
579
580 let (path_type, path) = parse_path_line(line, repo_root);
581 match path_type {
582 "old" => {
583 old_path = path;
584 continue;
585 }
586 "new" => {
587 new_path = path;
588 continue;
589 }
590 _ => {}
591 }
592
593 if let Some(caps) = HUNK_RE.captures(line) {
594 let current_path = new_path.as_deref().or(old_path.as_deref());
595 if let Some(p) = current_path {
596 if let Some(hunk) = parse_hunk_header(&caps, p) {
597 hunks.push(hunk);
598 }
599 }
600 }
601 }
602
603 hunks
604}
605
606pub fn run_git_z(repo_root: &Path, args: &[&str]) -> Result<Vec<String>> {
607 let output = run_git(repo_root, args)?;
608 Ok(output
609 .split('\0')
610 .filter(|s| !s.is_empty())
611 .map(String::from)
612 .collect())
613}
614
615pub fn get_changed_files(repo_root: &Path, diff_range: Option<&str>) -> Result<Vec<PathBuf>> {
616 let mut args: Vec<&str> = vec!["diff"];
617 args.extend_from_slice(SAFE_DIFF_FLAGS);
618 args.extend_from_slice(&["--name-only", "-M", "-z"]);
619 if let Some(range) = diff_range {
620 validate_diff_range(range)?;
621 args.push(range);
622 }
623 let parts = run_git_z(repo_root, &args)?;
624 Ok(parts
625 .iter()
626 .map(|p| {
627 repo_root
628 .join(p)
629 .canonicalize()
630 .unwrap_or_else(|_| repo_root.join(p))
631 })
632 .collect())
633}
634
635pub fn get_deleted_files(repo_root: &Path, diff_range: Option<&str>) -> Result<FxHashSet<PathBuf>> {
636 let mut args: Vec<&str> = vec!["diff"];
637 args.extend_from_slice(SAFE_DIFF_FLAGS);
638 args.extend_from_slice(&["--diff-filter=D", "--name-only", "-M", "-z"]);
639 if let Some(range) = diff_range {
640 validate_diff_range(range)?;
641 args.push(range);
642 }
643 let parts = run_git_z(repo_root, &args)?;
644 Ok(parts
645 .iter()
646 .map(|p| {
647 repo_root
648 .join(p)
649 .canonicalize()
650 .unwrap_or_else(|_| repo_root.join(p))
651 })
652 .collect())
653}
654
655fn rename_records(repo_root: &Path, diff_range: Option<&str>) -> Result<Vec<(String, String)>> {
663 let mut args: Vec<&str> = vec!["diff"];
664 args.extend_from_slice(SAFE_DIFF_FLAGS);
665 args.extend_from_slice(&["--diff-filter=R", "--name-status", "-M", "-z"]);
666 if let Some(range) = diff_range {
667 validate_diff_range(range)?;
668 args.push(range);
669 }
670 let output = run_git(repo_root, &args)?;
671 let parts: Vec<&str> = output.split('\0').collect();
672
673 let mut records = Vec::new();
674 let mut i = 0;
675 while i < parts.len() {
676 if parts[i].starts_with('R') {
677 if i + 2 < parts.len() && !parts[i + 1].is_empty() && !parts[i + 2].is_empty() {
678 records.push((parts[i + 1].to_string(), parts[i + 2].to_string()));
679 }
680 i += 3;
681 } else {
682 i += 1;
683 }
684 }
685 Ok(records)
686}
687
688pub fn get_renamed_paths(repo_root: &Path, diff_range: Option<&str>) -> Result<FxHashSet<PathBuf>> {
693 Ok(rename_records(repo_root, diff_range)?
694 .into_iter()
695 .map(|(old, _)| {
696 repo_root
697 .join(&old)
698 .canonicalize()
699 .unwrap_or_else(|_| repo_root.join(&old))
700 })
701 .collect())
702}
703
704pub fn get_rename_pairs(
708 repo_root: &Path,
709 diff_range: Option<&str>,
710) -> Result<Vec<(String, String)>> {
711 Ok(rename_records(repo_root, diff_range)?
712 .into_iter()
713 .map(|(old, new)| {
714 (
715 crate::paths::to_posix_display(std::borrow::Cow::Owned(old)),
716 crate::paths::to_posix_display(std::borrow::Cow::Owned(new)),
717 )
718 })
719 .collect())
720}
721
722pub fn split_diff_range(range: &str) -> (Option<String>, Option<String>) {
723 match RANGE_RE.captures(range) {
724 None => (None, None),
725 Some(caps) => {
726 let base = caps
727 .get(1)
728 .map(|m| m.as_str().trim().to_string())
729 .filter(|s| !s.is_empty());
730 let head = caps
731 .get(3)
732 .map(|m| m.as_str().trim().to_string())
733 .filter(|s| !s.is_empty());
734 (base, head)
735 }
736 }
737}
738
739pub fn show_file_at_revision(repo_root: &Path, rev: &str, rel_path: &Path) -> Result<String> {
740 validate_rev(rev)?;
741 let spec = format!("{}:{}", rev, rel_path.to_string_lossy().replace('\\', "/"));
742 run_git(repo_root, &["show", &spec])
743}
744
745pub fn get_commit_message(repo_root: &Path, rev: &str) -> Result<String> {
746 if validate_rev(rev).is_err() {
747 return Ok(String::new());
748 }
749 match run_git(repo_root, &["log", "-1", "--format=%s%n%b", rev]) {
750 Ok(s) => Ok(s.trim().to_string()),
751 Err(_) => Ok(String::new()),
752 }
753}
754
755pub fn get_untracked_files(repo_root: &Path) -> Result<Vec<PathBuf>> {
756 let parts = run_git_z(
757 repo_root,
758 &["ls-files", "--others", "--exclude-standard", "-z"],
759 )?;
760 Ok(parts
761 .iter()
762 .map(|p| {
763 repo_root
764 .join(p)
765 .canonicalize()
766 .unwrap_or_else(|_| repo_root.join(p))
767 })
768 .collect())
769}
770
771fn anchor_diffctx_ignore_line(line: &str, rel: &str) -> String {
777 let (neg, pat) = match line.strip_prefix('!') {
778 Some(rest) => (true, rest),
779 None => (false, line),
780 };
781 let pat_no_trailing_slash = pat.trim_end_matches('/');
782 let full = if pat_no_trailing_slash.starts_with('/') || pat_no_trailing_slash.contains('/') {
783 let anchored = pat.trim_start_matches('/');
784 if rel.is_empty() {
785 format!("/{anchored}")
786 } else {
787 format!("/{rel}/{anchored}")
788 }
789 } else if rel.is_empty() {
790 pat.to_string()
791 } else {
792 format!("{rel}/**/{pat}")
793 };
794 if neg { format!("!{full}") } else { full }
795}
796
797fn write_private_temp_file(content: &str) -> Option<PathBuf> {
810 use std::io::Write;
811
812 let dir = std::env::temp_dir();
813 for _ in 0..8 {
814 let unique = TEMP_EXCLUDES_COUNTER.fetch_add(1, Ordering::Relaxed);
815 let nanos = std::time::SystemTime::now()
816 .duration_since(std::time::UNIX_EPOCH)
817 .map(|d| d.subsec_nanos())
818 .unwrap_or(0);
819 let path = dir.join(format!(
820 "diffctx-ignore-{}-{unique}-{nanos}.tmp",
821 std::process::id()
822 ));
823 match create_new_private_file(&path) {
824 Ok(mut file) => {
825 return match file
826 .write_all(content.as_bytes())
827 .and_then(|()| file.flush())
828 {
829 Ok(()) => Some(path),
830 Err(_) => {
831 let _ = std::fs::remove_file(&path);
832 None
833 }
834 };
835 }
836 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
837 Err(_) => return None,
838 }
839 }
840 None
841}
842
843fn create_new_private_file(path: &Path) -> std::io::Result<std::fs::File> {
846 let mut opts = std::fs::OpenOptions::new();
847 opts.write(true).create_new(true);
848 #[cfg(unix)]
849 {
850 use std::os::unix::fs::OpenOptionsExt;
851 opts.mode(0o600);
852 }
853 opts.open(path)
854}
855
856fn collect_diffctx_ignore_patterns(repo_root: &Path) -> Vec<String> {
860 let Ok(files) = run_git_z(
861 repo_root,
862 &[
863 "ls-files",
864 "-z",
865 "--cached",
866 "--others",
867 "--exclude-standard",
868 "--",
869 ":(glob)**/.diffctx/ignore",
870 ],
871 ) else {
872 return Vec::new();
873 };
874
875 let mut patterns = Vec::new();
876 for raw in &files {
877 let rel_path = unquote_c_style(raw);
878 if !rel_path.ends_with(".diffctx/ignore") {
879 continue;
880 }
881 let rel_dir = rel_path
882 .strip_suffix(".diffctx/ignore")
883 .unwrap_or("")
884 .trim_end_matches('/');
885 let Ok(content) = std::fs::read_to_string(repo_root.join(&rel_path)) else {
886 continue;
887 };
888 for line in content.lines() {
889 let line = line.trim_end();
890 if line.is_empty() || line.starts_with('#') {
891 continue;
892 }
893 patterns.push(anchor_diffctx_ignore_line(line, rel_dir));
894 }
895 }
896 patterns
897}
898
899#[derive(Clone, Copy, PartialEq, Eq, Debug)]
903pub enum IgnoreSource {
904 DiffctxPolicy,
905 Gitignore,
906}
907
908pub fn find_ignored_paths_with_source(
927 repo_root: &Path,
928 rel_paths: &[String],
929) -> rustc_hash::FxHashMap<String, IgnoreSource> {
930 if rel_paths.is_empty() {
931 return rustc_hash::FxHashMap::default();
932 }
933
934 let diffctx_patterns = collect_diffctx_ignore_patterns(repo_root);
935 let temp_excludes = if diffctx_patterns.is_empty() {
936 None
937 } else {
938 write_private_temp_file(&diffctx_patterns.join("\n"))
939 };
940
941 let mut queries: Vec<String> = rel_paths.to_vec();
945 let mut ancestors: FxHashSet<String> = FxHashSet::default();
946 for rel in rel_paths {
947 for ancestor in ancestor_dirs(rel) {
948 if ancestors.insert(ancestor.clone()) {
949 queries.push(ancestor);
950 }
951 }
952 }
953
954 let mut args: Vec<String> = vec![
968 "check-ignore".into(),
969 "--no-index".into(),
970 "-v".into(),
971 "-z".into(),
972 "--stdin".into(),
973 ];
974 if let Some(ref path) = temp_excludes {
975 args.insert(0, format!("core.excludesFile={}", path.display()));
976 args.insert(0, "-c".into());
977 }
978 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
979 let query_file = write_private_temp_file(&format!("{}\0", queries.join("\0")));
986
987 let result = (|| -> Result<rustc_hash::FxHashMap<String, IgnoreSource>> {
988 let Some(ref query_path) = query_file else {
989 return Err(GitError::CommandFailed(
990 "could not stage check-ignore query paths".into(),
991 ));
992 };
993 let mut cmd = git_command(repo_root);
994 cmd.args(&arg_refs)
995 .stdin(Stdio::from(std::fs::File::open(query_path)?))
996 .stdout(Stdio::piped())
997 .stderr(Stdio::piped());
998 let child = cmd.spawn()?;
999 let output = wait_with_timeout(child, Duration::from_secs(git_timeout()), &arg_refs)?;
1000 if !output.status.success() && output.status.code() != Some(1) {
1003 let stderr = String::from_utf8_lossy(&output.stderr);
1004 return Err(GitError::CommandFailed(format!(
1005 "git check-ignore failed: {}",
1006 stderr.trim()
1007 )));
1008 }
1009 let stdout = String::from_utf8_lossy(&output.stdout);
1010 let excludes_source = temp_excludes.as_ref().map(|p| p.display().to_string());
1011
1012 let rules = parse_verbose_ignore_records(&stdout);
1013
1014 Ok(rel_paths
1015 .iter()
1016 .filter_map(|rel| {
1017 let rule = rules.get(rel)?;
1018 let from_diffctx = excludes_source
1019 .as_deref()
1020 .is_some_and(|src| rule.starts_with(&format!("{src}:")));
1021 if from_diffctx {
1022 Some((rel.clone(), IgnoreSource::DiffctxPolicy))
1023 } else if !ancestor_dirs(rel)
1024 .iter()
1025 .any(|dir| rules.get(dir) == Some(rule))
1026 {
1027 Some((rel.clone(), IgnoreSource::Gitignore))
1028 } else {
1029 None
1030 }
1031 })
1032 .collect())
1033 })();
1034
1035 if let Some(path) = temp_excludes {
1036 let _ = std::fs::remove_file(path);
1037 }
1038 if let Some(path) = query_file {
1039 let _ = std::fs::remove_file(path);
1040 }
1041
1042 let policy_declared = !diffctx_patterns.is_empty();
1058 result.unwrap_or_else(|e| {
1059 if policy_declared {
1060 tracing::error!(
1061 "git check-ignore failed ({e}); .diffctx/ignore declares {} pattern(s), so all \
1062 {} queried paths are treated as ignored rather than risk publishing them",
1063 diffctx_patterns.len(),
1064 rel_paths.len()
1065 );
1066 rel_paths
1067 .iter()
1068 .map(|p| (p.clone(), IgnoreSource::DiffctxPolicy))
1069 .collect()
1070 } else {
1071 tracing::warn!(
1072 "git check-ignore failed ({e}); no .diffctx/ignore patterns are declared, so \
1073 gitignore filtering is skipped for this run"
1074 );
1075 rustc_hash::FxHashMap::default()
1076 }
1077 })
1078}
1079
1080fn parse_verbose_ignore_records(stdout: &str) -> rustc_hash::FxHashMap<String, String> {
1096 let mut rules: rustc_hash::FxHashMap<String, String> = rustc_hash::FxHashMap::default();
1097 let fields: Vec<&str> = stdout.split('\0').collect();
1098 for record in fields.chunks(4) {
1100 if record.len() < 4 {
1101 break;
1102 }
1103 let (source, line, pattern, path) = (record[0], record[1], record[2], record[3]);
1104 if path.is_empty() {
1105 continue;
1106 }
1107 if pattern.starts_with('!') {
1116 continue;
1117 }
1118 rules.insert(path.to_string(), format!("{source}:{line}:{pattern}"));
1122 }
1123 rules
1124}
1125
1126fn ancestor_dirs(rel: &str) -> Vec<String> {
1127 let mut dirs = Vec::new();
1128 let mut remainder = rel;
1129 while let Some((parent, _)) = remainder.rsplit_once('/') {
1130 dirs.push(parent.to_string());
1131 remainder = parent;
1132 }
1133 dirs
1134}
1135
1136pub struct CatFileBatch {
1137 repo_root: PathBuf,
1138 child: Option<Child>,
1139 reader: Option<BufReader<ChildStdout>>,
1140}
1141
1142impl CatFileBatch {
1143 pub fn new(repo_root: &Path) -> Result<Self> {
1144 let mut batch = Self {
1145 repo_root: repo_root.to_path_buf(),
1146 child: None,
1147 reader: None,
1148 };
1149 batch.ensure_started()?;
1150 Ok(batch)
1151 }
1152
1153 fn ensure_started(&mut self) -> Result<()> {
1154 let needs_restart = match &mut self.child {
1155 None => true,
1156 Some(child) => child.try_wait().ok().flatten().is_some(),
1157 };
1158
1159 if needs_restart {
1160 let mut child = git_command(&self.repo_root)
1161 .args(["cat-file", "--batch"])
1162 .stdin(Stdio::piped())
1163 .stdout(Stdio::piped())
1164 .stderr(Stdio::null())
1165 .spawn()?;
1166 let stdout = child.stdout.take().ok_or_else(|| {
1167 GitError::CommandFailed("cat-file: failed to capture stdout pipe".into())
1168 })?;
1169 self.reader = Some(BufReader::new(stdout));
1170 self.child = Some(child);
1171 }
1172
1173 Ok(())
1174 }
1175
1176 pub fn get(&mut self, rev: &str, rel_path: &Path) -> Result<String> {
1177 validate_rev(rev)?;
1178 let spec = format!(
1179 "{}:{}\n",
1180 rev,
1181 rel_path.to_string_lossy().replace('\\', "/")
1182 );
1183
1184 self.ensure_started()?;
1185
1186 let stdin = self
1187 .child
1188 .as_mut()
1189 .and_then(|c| c.stdin.as_mut())
1190 .ok_or_else(|| GitError::CommandFailed("cat-file stdin unavailable".into()))?;
1191 stdin.write_all(spec.as_bytes())?;
1192 stdin.flush()?;
1193
1194 let reader = self
1195 .reader
1196 .as_mut()
1197 .ok_or_else(|| GitError::CommandFailed("cat-file stdout unavailable".into()))?;
1198
1199 let mut header_line = String::new();
1200 reader.read_line(&mut header_line)?;
1201
1202 if header_line.is_empty() {
1203 return Err(GitError::CommandFailed(format!(
1204 "cat-file: unexpected EOF for {}",
1205 spec.trim()
1206 )));
1207 }
1208
1209 let header_str = header_line.trim();
1210 if header_str.ends_with("missing") {
1211 return Err(GitError::CommandFailed(format!(
1212 "Path not found: {}",
1213 spec.trim()
1214 )));
1215 }
1216
1217 let parts: Vec<&str> = header_str.split_whitespace().collect();
1218 if parts.len() < 3 {
1219 return Err(GitError::CommandFailed(format!(
1220 "cat-file: malformed header: {}",
1221 header_str
1222 )));
1223 }
1224
1225 let size: usize = parts[2].parse().map_err(|_| {
1226 GitError::CommandFailed(format!("cat-file: invalid size in header: {}", header_str))
1227 })?;
1228
1229 if size > crate::config::limits::MAX_BLOB_READ_BYTES {
1235 let mut remaining = size;
1236 let mut scratch = [0u8; 65536];
1237 while remaining > 0 {
1238 let want = remaining.min(scratch.len());
1239 reader.read_exact(&mut scratch[..want])?;
1240 remaining -= want;
1241 }
1242 let mut trailing = [0u8; 1];
1243 let _ = reader.read_exact(&mut trailing);
1244 return Err(GitError::CommandFailed(format!(
1245 "cat-file: blob too large ({} bytes): {}",
1246 size,
1247 spec.trim()
1248 )));
1249 }
1250
1251 let mut content = vec![0u8; size];
1252 reader.read_exact(&mut content)?;
1253
1254 let mut trailing = [0u8; 1];
1255 let _ = reader.read_exact(&mut trailing);
1256
1257 Ok(String::from_utf8_lossy(&content).into_owned())
1258 }
1259
1260 pub fn close(&mut self) {
1261 self.reader.take();
1262 if let Some(mut child) = self.child.take() {
1263 drop(child.stdin.take());
1264 match child.wait_timeout(Duration::from_secs(GIT.catfile_termination_timeout_seconds)) {
1265 Ok(Some(_)) => {}
1266 _ => {
1267 let _ = child.kill();
1268 let _ = child.wait();
1269 }
1270 }
1271 }
1272 }
1273}
1274
1275impl Drop for CatFileBatch {
1276 fn drop(&mut self) {
1277 self.close();
1278 }
1279}
1280
1281#[cfg(test)]
1282mod tests {
1283 use super::*;
1284 use std::fs;
1285 use std::sync::Barrier;
1286 use tempfile::TempDir;
1287
1288 fn git(dir: &Path, args: &[&str]) {
1289 let status = git_command(dir)
1290 .args(args)
1291 .status()
1292 .unwrap_or_else(|e| panic!("git {args:?}: {e}"));
1293 assert!(status.success(), "git {args:?} failed");
1294 }
1295
1296 fn init_git_repo(dir: &Path) {
1297 git(dir, &["init", "-q", "-b", "main"]);
1298 git(dir, &["config", "user.email", "test@example.com"]);
1299 git(dir, &["config", "user.name", "Test"]);
1300 git(dir, &["config", "commit.gpgsign", "false"]);
1301 }
1302
1303 fn commit_all(dir: &Path, message: &str) {
1304 git(dir, &["add", "-A"]);
1305 git(dir, &["commit", "-q", "-m", message]);
1306 }
1307
1308 fn write_file(root: &Path, rel: &str, content: &str) {
1309 let path = root.join(rel);
1310 if let Some(parent) = path.parent() {
1311 fs::create_dir_all(parent).expect("create parent");
1312 }
1313 fs::write(&path, content).expect("write file");
1314 }
1315
1316 struct HunkShape {
1319 old_start: u32,
1320 old_len: u32,
1321 new_start: u32,
1322 new_len: u32,
1323 }
1324
1325 fn hunk_shapes(hunks: &[DiffHunk]) -> Vec<HunkShape> {
1326 hunks
1327 .iter()
1328 .map(|h| HunkShape {
1329 old_start: h.old_start,
1330 old_len: h.old_len,
1331 new_start: h.new_start,
1332 new_len: h.new_len,
1333 })
1334 .collect()
1335 }
1336
1337 fn basenames(paths: &[PathBuf]) -> Vec<String> {
1338 let mut names: Vec<String> = paths
1339 .iter()
1340 .map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
1341 .collect();
1342 names.sort();
1343 names
1344 }
1345
1346 fn assert_diff_survives_hostile_config(hostile_config: &[&[&str]]) {
1347 let tmp = TempDir::new().expect("tempdir");
1348 let clean_root = tmp.path().join("clean");
1349 let hostile_root = tmp.path().join("hostile");
1350 fs::create_dir_all(&clean_root).expect("mkdir clean");
1351 fs::create_dir_all(&hostile_root).expect("mkdir hostile");
1352
1353 for root in [&clean_root, &hostile_root] {
1354 init_git_repo(root);
1355 write_file(root, "app.py", "def f():\n return 1\n");
1356 commit_all(root, "initial");
1357 write_file(root, "app.py", "def f():\n return 2\n");
1358 commit_all(root, "change");
1359 }
1360 for args in hostile_config {
1361 git(&hostile_root, args);
1362 }
1363
1364 let clean_hunks = parse_diff(&clean_root, Some("HEAD~1..HEAD")).expect("clean parse_diff");
1365 let hostile_hunks =
1366 parse_diff(&hostile_root, Some("HEAD~1..HEAD")).expect("hostile parse_diff");
1367 assert!(
1368 !hostile_hunks.is_empty(),
1369 "hostile git config reduced the diff to zero hunks"
1370 );
1371 assert_eq!(
1372 hunk_shapes(&hostile_hunks)
1373 .iter()
1374 .map(|s| (s.old_start, s.old_len, s.new_start, s.new_len))
1375 .collect::<Vec<_>>(),
1376 hunk_shapes(&clean_hunks)
1377 .iter()
1378 .map(|s| (s.old_start, s.old_len, s.new_start, s.new_len))
1379 .collect::<Vec<_>>(),
1380 "hostile config changed the parsed hunk shape vs a clean-config repo"
1381 );
1382
1383 let clean_files =
1384 get_changed_files(&clean_root, Some("HEAD~1..HEAD")).expect("clean changed files");
1385 let hostile_files =
1386 get_changed_files(&hostile_root, Some("HEAD~1..HEAD")).expect("hostile changed files");
1387 assert!(
1388 !hostile_files.is_empty(),
1389 "hostile git config reduced changed_files to empty"
1390 );
1391 assert_eq!(
1392 basenames(&hostile_files),
1393 basenames(&clean_files),
1394 "hostile config changed the changed_files set vs a clean-config repo"
1395 );
1396 }
1397
1398 #[test]
1399 fn diff_survives_diff_noprefix() {
1400 assert_diff_survives_hostile_config(&[&["config", "diff.noprefix", "true"]]);
1401 }
1402
1403 #[test]
1404 fn diff_survives_diff_mnemonic_prefix() {
1405 assert_diff_survives_hostile_config(&[&["config", "diff.mnemonicPrefix", "true"]]);
1406 }
1407
1408 #[test]
1409 fn diff_survives_custom_src_dst_prefix() {
1410 assert_diff_survives_hostile_config(&[
1411 &["config", "diff.srcPrefix", "x/"],
1412 &["config", "diff.dstPrefix", "y/"],
1413 ]);
1414 }
1415
1416 #[test]
1417 fn diff_survives_color_ui_always() {
1418 assert_diff_survives_hostile_config(&[&["config", "color.ui", "always"]]);
1419 }
1420
1421 #[test]
1424 fn validate_diff_range_rejects_option_smuggled_in_range() {
1425 for hostile in ["HEAD..--ext-diff", "a...-p", "..--upload-pack=x"] {
1426 assert!(
1427 validate_diff_range(hostile).is_err(),
1428 "expected {hostile:?} to be rejected"
1429 );
1430 }
1431 }
1432
1433 #[test]
1434 fn validate_diff_range_accepts_legitimate_ranges() {
1435 for legit in [
1436 "HEAD~1..HEAD",
1437 "@{-1}..HEAD",
1438 "HEAD~2...origin/main",
1439 "main..feature/x",
1440 ] {
1441 assert!(
1442 validate_diff_range(legit).is_ok(),
1443 "expected {legit:?} to be accepted"
1444 );
1445 }
1446 }
1447
1448 #[test]
1451 fn duration_specs_cover_the_standard_units_and_compose() {
1452 for (spec, expected) in [
1453 ("5s", 5),
1454 ("90 sec", 90),
1455 ("10min", 600),
1456 ("45m", 2700),
1457 ("24h", 86_400),
1458 ("3hrs", 10_800),
1459 ("8d", 691_200),
1460 ("2 weeks", 1_209_600),
1461 ("1h30m", 5400),
1462 ("1D", 86_400),
1463 ] {
1464 assert_eq!(
1465 parse_duration_seconds(spec),
1466 Some(expected),
1467 "spec {spec:?}"
1468 );
1469 }
1470 }
1471
1472 #[test]
1473 fn anything_that_is_not_wholly_a_duration_stays_a_revision() {
1474 for spec in [
1475 "HEAD",
1476 "HEAD~1..HEAD",
1477 "main",
1478 "8dd",
1479 "24",
1480 "h",
1481 "v1.2",
1482 "",
1483 "1h-",
1484 "deadbeef",
1485 ] {
1486 assert_eq!(parse_duration_seconds(spec), None, "spec {spec:?}");
1487 }
1488 }
1489
1490 #[test]
1491 fn a_duration_resolves_to_the_last_commit_before_the_window() {
1492 let tmp = TempDir::new().expect("tempdir");
1493 let root = tmp.path();
1494 init_git_repo(root);
1495 write_file(root, "old.txt", "old\n");
1496 git(root, &["add", "-A"]);
1499 let status = git_command(root)
1500 .args(["commit", "-q", "-m", "old"])
1501 .env("GIT_AUTHOR_DATE", "2020-01-01T00:00:00+00:00")
1502 .env("GIT_COMMITTER_DATE", "2020-01-01T00:00:00+00:00")
1503 .status()
1504 .expect("commit");
1505 assert!(status.success());
1506 let old_head = run_git(root, &["rev-parse", "HEAD"])
1507 .expect("rev-parse")
1508 .trim()
1509 .to_string();
1510 write_file(root, "new.txt", "new\n");
1511 commit_all(root, "new");
1512
1513 let resolved = resolve_duration_range(root, Some("24h")).expect("resolve");
1514 assert!(resolved.from_duration);
1515 assert_eq!(resolved.range.as_deref(), Some(old_head.as_str()));
1516
1517 let diff = get_diff_text(root, resolved.range.as_deref()).expect("diff");
1518 assert!(diff.contains("new.txt"), "window must cover the new commit");
1519 assert!(
1520 !diff.contains("old.txt"),
1521 "window must exclude the commit before it"
1522 );
1523 }
1524
1525 #[test]
1526 fn a_window_older_than_the_repo_falls_back_to_the_empty_tree() {
1527 let tmp = TempDir::new().expect("tempdir");
1528 let root = tmp.path();
1529 init_git_repo(root);
1530 write_file(root, "only.txt", "only\n");
1531 commit_all(root, "only");
1532
1533 let resolved = resolve_duration_range(root, Some("1w")).expect("resolve");
1534 assert!(resolved.from_duration);
1535 let diff = get_diff_text(root, resolved.range.as_deref()).expect("diff");
1536 assert!(
1537 diff.contains("only.txt"),
1538 "a repo younger than the window is entirely new within it"
1539 );
1540 }
1541
1542 #[test]
1543 fn a_ref_that_looks_like_a_duration_keeps_its_git_meaning() {
1544 let tmp = TempDir::new().expect("tempdir");
1545 let root = tmp.path();
1546 init_git_repo(root);
1547 write_file(root, "a.txt", "a\n");
1548 commit_all(root, "a");
1549 git(root, &["branch", "24h"]);
1550
1551 let resolved = resolve_duration_range(root, Some("24h")).expect("resolve");
1552 assert!(!resolved.from_duration);
1553 assert_eq!(resolved.range.as_deref(), Some("24h"));
1554 }
1555
1556 #[test]
1559 fn a_tab_in_the_pattern_no_longer_needs_disambiguating() {
1560 let stdout = ".gitignore\x003\x00foo\tbar\x00some/real/path.txt\x00";
1564 let rules = parse_verbose_ignore_records(stdout);
1565 assert_eq!(
1566 rules.get("some/real/path.txt").map(String::as_str),
1567 Some(".gitignore:3:foo\tbar")
1568 );
1569 }
1570
1571 #[test]
1575 fn a_newline_in_the_path_survives_as_one_record() {
1576 let stdout = "excl\x001\x00secret*\x00secret\nname.py\x00";
1577 let rules = parse_verbose_ignore_records(stdout);
1578 assert_eq!(rules.len(), 1);
1579 assert_eq!(
1580 rules.get("secret\nname.py").map(String::as_str),
1581 Some("excl:1:secret*")
1582 );
1583 }
1584
1585 #[test]
1586 fn several_records_and_a_trailing_nul_parse_cleanly() {
1587 let stdout = ".gitignore\x001\x00*.log\x00a.log\x00.gitignore\x002\x00*.tmp\x00b/c.tmp\x00";
1588 let rules = parse_verbose_ignore_records(stdout);
1589 assert_eq!(rules.len(), 2);
1590 assert!(rules.contains_key("a.log"));
1591 assert!(rules.contains_key("b/c.tmp"));
1592 }
1593
1594 #[test]
1595 fn a_truncated_final_record_is_dropped_not_half_read() {
1596 let stdout = ".gitignore\x001\x00*.log\x00a.log\x00.gitignore\x002\x00*.tmp\x00";
1599 let rules = parse_verbose_ignore_records(stdout);
1600 assert_eq!(rules.len(), 1);
1601 assert!(rules.contains_key("a.log"));
1602 }
1603
1604 #[test]
1607 fn anchor_ignore_line_bare_pattern_at_root() {
1608 assert_eq!(anchor_diffctx_ignore_line("*.log", ""), "*.log");
1609 }
1610
1611 #[test]
1612 fn anchor_ignore_line_bare_pattern_nested() {
1613 assert_eq!(anchor_diffctx_ignore_line("*.log", "sub"), "sub/**/*.log");
1614 }
1615
1616 #[test]
1617 fn anchor_ignore_line_slash_pattern_at_root() {
1618 assert_eq!(
1619 anchor_diffctx_ignore_line("secrets/config.py", ""),
1620 "/secrets/config.py"
1621 );
1622 }
1623
1624 #[test]
1625 fn anchor_ignore_line_slash_pattern_nested() {
1626 assert_eq!(
1627 anchor_diffctx_ignore_line("secrets/config.py", "sub"),
1628 "/sub/secrets/config.py"
1629 );
1630 }
1631
1632 #[test]
1633 fn anchor_ignore_line_negated_bare_pattern() {
1634 assert_eq!(anchor_diffctx_ignore_line("!keep.log", ""), "!keep.log");
1635 }
1636
1637 #[test]
1638 fn anchor_ignore_line_negated_slash_pattern_nested() {
1639 assert_eq!(
1640 anchor_diffctx_ignore_line("!secrets/keep.py", "sub"),
1641 "!/sub/secrets/keep.py"
1642 );
1643 }
1644
1645 #[test]
1648 fn unquote_c_style_decodes_octal_utf8_escapes() {
1649 let quoted = r#""a/caf\303\251.py""#;
1652 assert_eq!(unquote_c_style(quoted), "a/café.py");
1653 }
1654
1655 #[test]
1656 fn unquote_c_style_leaves_unquoted_input_untouched() {
1657 assert_eq!(unquote_c_style("a/plain.py"), "a/plain.py");
1658 }
1659
1660 #[test]
1661 fn parse_path_line_takes_quoted_branch_for_old_and_new_headers() {
1662 let tmp = TempDir::new().expect("tempdir");
1663 let root = tmp.path();
1664 write_file(root, "café.py", "value = 1\n");
1671
1672 let old_line = r#"--- "a/caf\303\251.py""#;
1673 let (kind, path) = parse_path_line(old_line, root);
1674 assert_eq!(kind, "old");
1675 assert_eq!(
1676 path.expect("old path")
1677 .file_name()
1678 .unwrap()
1679 .to_string_lossy(),
1680 "café.py"
1681 );
1682
1683 let new_line = r#"+++ "b/caf\303\251.py""#;
1684 let (kind, path) = parse_path_line(new_line, root);
1685 assert_eq!(kind, "new");
1686 assert_eq!(
1687 path.expect("new path")
1688 .file_name()
1689 .unwrap()
1690 .to_string_lossy(),
1691 "café.py"
1692 );
1693 }
1694
1695 #[test]
1704 fn a_header_escaping_the_repo_root_is_refused_whether_or_not_the_target_exists() {
1705 let tmp = TempDir::new().expect("tempdir");
1706 let base = tmp.path().canonicalize().expect("canonical tempdir");
1711 let root = base.join("repo");
1712 std::fs::create_dir_all(&root).expect("mkdir repo");
1713 std::fs::write(base.join("outside.py"), "secret = 1\n").expect("write outside");
1714
1715 for rel in ["../outside.py", "../missing.py", "sub/../../outside.py"] {
1716 for line in [format!("--- a/{rel}"), format!("+++ b/{rel}")] {
1717 let (kind, path) = parse_path_line(&line, &root);
1718 assert_eq!(
1719 (kind, path.as_ref()),
1720 ("", None),
1721 "escaping header accepted: {line}"
1722 );
1723 }
1724 }
1725
1726 std::fs::write(root.join("real.py"), "x = 1\n").expect("write real");
1729 for rel in ["real.py", "gone.py", "nested/deep.py"] {
1730 let (kind, path) = parse_path_line(&format!("--- a/{rel}"), &root);
1731 assert_eq!(kind, "old", "in-repo header refused: {rel}");
1732 assert!(path.expect("path").ends_with(rel));
1733 }
1734 }
1735
1736 #[cfg(unix)]
1741 #[test]
1742 fn an_in_repo_symlink_pointing_outside_the_root_is_refused() {
1743 let tmp = TempDir::new().expect("tempdir");
1744 let base = tmp.path().canonicalize().expect("canonical tempdir");
1745 let root = base.join("repo");
1746 std::fs::create_dir_all(&root).expect("mkdir repo");
1747
1748 let outside_dir = base.join("outside");
1749 std::fs::create_dir_all(&outside_dir).expect("mkdir outside");
1750 std::fs::write(outside_dir.join("secret.py"), "token = 1\n").expect("write secret");
1751 std::os::unix::fs::symlink(&outside_dir, root.join("escape"))
1752 .expect("symlink into the repo");
1753
1754 let (kind, path) = parse_path_line("--- a/escape/secret.py", &root);
1755 assert_eq!(
1756 (kind, path.as_ref()),
1757 ("", None),
1758 "a header reaching outside the repo through an in-repo symlink was accepted"
1759 );
1760
1761 std::fs::create_dir_all(root.join("real")).expect("mkdir real");
1763 std::fs::write(root.join("real/mod.py"), "y = 1\n").expect("write real");
1764 std::os::unix::fs::symlink(root.join("real"), root.join("alias")).expect("inner symlink");
1765 let (kind, _) = parse_path_line("--- a/alias/mod.py", &root);
1766 assert_eq!(kind, "old", "an in-repo symlink was wrongly refused");
1767 }
1768
1769 #[test]
1774 fn temp_file_creation_refuses_a_pre_planted_path() {
1775 let tmp = TempDir::new().expect("tempdir");
1776 let victim = tmp.path().join("victim.txt");
1777 std::fs::write(&victim, "precious\n").expect("write victim");
1778
1779 let planted = tmp.path().join("planted.tmp");
1780 #[cfg(unix)]
1781 std::os::unix::fs::symlink(&victim, &planted).expect("symlink");
1782 #[cfg(not(unix))]
1783 std::fs::write(&planted, "").expect("placeholder");
1784
1785 let err = create_new_private_file(&planted).expect_err("must refuse an existing path");
1786 assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
1787 assert_eq!(
1788 std::fs::read_to_string(&victim).expect("victim survives"),
1789 "precious\n",
1790 "the symlink target was written through"
1791 );
1792 }
1793
1794 #[test]
1795 fn temp_excludes_file_is_written_and_readable() {
1796 let path = write_private_temp_file("*.log\n!keep.log").expect("temp file");
1797 let content = std::fs::read_to_string(&path).expect("read back");
1798 assert_eq!(content, "*.log\n!keep.log");
1799 #[cfg(unix)]
1800 {
1801 use std::os::unix::fs::PermissionsExt;
1802 let mode = std::fs::metadata(&path)
1803 .expect("metadata")
1804 .permissions()
1805 .mode()
1806 & 0o777;
1807 assert_eq!(mode, 0o600, "temp excludes file must not be world-readable");
1808 }
1809 let _ = std::fs::remove_file(&path);
1810 }
1811
1812 #[test]
1819 fn a_rejected_path_header_does_not_charge_its_hunks_to_the_previous_file() {
1820 let tmp = TempDir::new().expect("tempdir");
1821 let root = tmp.path();
1822 write_file(root, "real.py", "a = 1\nb = 2\nc = 3\n");
1823
1824 let output = concat!(
1825 "diff --git a/real.py b/real.py\n",
1826 "--- a/real.py\n",
1827 "+++ b/real.py\n",
1828 "@@ -1,1 +1,1 @@\n",
1829 "-a = 1\n",
1830 "+a = 9\n",
1831 "diff --git a/../../escape.py b/../../escape.py\n",
1832 "--- a/../../escape.py\n",
1833 "+++ b/../../escape.py\n",
1834 "@@ -500,20 +500,20 @@\n",
1835 "-gone\n",
1836 "+new\n",
1837 );
1838
1839 let hunks = parse_hunks_from_diff_output(output, root);
1840 assert_eq!(
1841 hunks.len(),
1842 1,
1843 "expected only the in-repo file's hunk, got {:?}",
1844 hunks
1845 .iter()
1846 .map(|h| (h.path.as_ref().to_string(), h.new_start))
1847 .collect::<Vec<_>>()
1848 );
1849 assert_eq!(hunks[0].new_start, 1);
1850 assert!(hunks[0].path.ends_with("real.py"));
1851 }
1852
1853 #[test]
1856 fn deletions_and_creations_attribute_their_hunks_to_the_named_side() {
1857 let tmp = TempDir::new().expect("tempdir");
1858 let root = tmp.path();
1859 write_file(root, "kept.py", "x = 1\n");
1860 write_file(root, "added.py", "y = 1\n");
1861 write_file(root, "removed.py", "z = 1\n");
1862
1863 let output = concat!(
1864 "diff --git a/kept.py b/kept.py\n",
1865 "--- a/kept.py\n",
1866 "+++ b/kept.py\n",
1867 "@@ -1,1 +1,1 @@\n",
1868 "diff --git a/removed.py b/removed.py\n",
1869 "--- a/removed.py\n",
1870 "+++ /dev/null\n",
1871 "@@ -1,1 +0,0 @@\n",
1872 "diff --git a/added.py b/added.py\n",
1873 "--- /dev/null\n",
1874 "+++ b/added.py\n",
1875 "@@ -0,0 +1,1 @@\n",
1876 );
1877
1878 let paths: Vec<String> = parse_hunks_from_diff_output(output, root)
1879 .iter()
1880 .map(|h| {
1881 Path::new(h.path.as_ref())
1882 .file_name()
1883 .unwrap()
1884 .to_string_lossy()
1885 .into_owned()
1886 })
1887 .collect();
1888 assert_eq!(paths, vec!["kept.py", "removed.py", "added.py"]);
1889 }
1890
1891 #[test]
1892 fn parse_diff_handles_real_repo_with_default_quoted_unicode_filename() {
1893 let tmp = TempDir::new().expect("tempdir");
1897 let root = tmp.path();
1898 init_git_repo(root);
1899 write_file(root, "café.py", "value = 1\n");
1900 commit_all(root, "initial");
1901 write_file(root, "café.py", "value = 2\n");
1902 commit_all(root, "change");
1903
1904 let hunks = parse_diff(root, Some("HEAD~1..HEAD")).expect("parse_diff");
1905 assert!(
1906 !hunks.is_empty(),
1907 "quoted unicode diff header was not parsed into any hunk"
1908 );
1909 assert!(
1910 hunks.iter().any(|h| h.path.contains("café")),
1911 "no hunk carried the decoded unicode path, got: {:?}",
1912 hunks.iter().map(|h| h.path.as_ref()).collect::<Vec<_>>()
1913 );
1914 }
1915
1916 #[test]
1919 fn wait_with_timeout_kills_long_running_child_and_returns_promptly() {
1920 let child = Command::new("sleep")
1921 .arg("30")
1922 .stdout(Stdio::piped())
1923 .stderr(Stdio::piped())
1924 .spawn()
1925 .expect("spawn sleep");
1926 let pid = child.id();
1927
1928 let start = std::time::Instant::now();
1929 let result = wait_with_timeout(child, Duration::from_millis(200), &["sleep", "30"]);
1930 let elapsed = start.elapsed();
1931
1932 assert!(
1933 matches!(result, Err(GitError::Timeout(_))),
1934 "expected Timeout error, got {result:?}"
1935 );
1936 assert!(
1937 elapsed < Duration::from_secs(5),
1938 "wait_with_timeout should return promptly, took {elapsed:?}"
1939 );
1940
1941 let mut still_alive = true;
1945 for _ in 0..20 {
1946 let status = Command::new("kill")
1947 .args(["-0", &pid.to_string()])
1948 .stdout(Stdio::null())
1949 .stderr(Stdio::null())
1950 .status()
1951 .expect("spawn kill -0");
1952 if !status.success() {
1953 still_alive = false;
1954 break;
1955 }
1956 std::thread::sleep(Duration::from_millis(50));
1957 }
1958 assert!(!still_alive, "child pid {pid} was not reaped after timeout");
1959 }
1960
1961 #[test]
1962 fn wait_with_timeout_does_not_penalize_fast_commands() {
1963 let child = Command::new("true")
1964 .stdout(Stdio::piped())
1965 .stderr(Stdio::piped())
1966 .spawn()
1967 .expect("spawn true");
1968 let result = wait_with_timeout(child, Duration::from_secs(5), &["true"]);
1969 assert!(matches!(result, Ok(ref out) if out.status.success()));
1970 }
1971
1972 #[test]
1975 fn ignored_paths_concurrent_calls_both_see_their_own_ignore_rules() {
1976 let tmp = TempDir::new().expect("tempdir");
1977 let root_a = tmp.path().join("repo_a");
1978 let root_b = tmp.path().join("repo_b");
1979 fs::create_dir_all(&root_a).expect("mkdir a");
1980 fs::create_dir_all(&root_b).expect("mkdir b");
1981
1982 for (root, secret) in [(&root_a, "secret_a.py"), (&root_b, "secret_b.py")] {
1983 init_git_repo(root);
1984 write_file(root, "app.py", "print('hi')\n");
1985 write_file(root, ".diffctx/ignore", &format!("{secret}\n"));
1986 write_file(root, secret, "SECRET\n");
1987 commit_all(root, "initial");
1988 }
1989
1990 for _ in 0..10 {
1994 let barrier = Arc::new(Barrier::new(2));
1995
1996 let root_a_thread = root_a.clone();
1997 let barrier_a = Arc::clone(&barrier);
1998 let handle_a = std::thread::spawn(move || {
1999 barrier_a.wait();
2000 find_ignored_paths_with_source(
2001 &root_a_thread,
2002 &["secret_a.py".to_string(), "app.py".to_string()],
2003 )
2004 });
2005
2006 let root_b_thread = root_b.clone();
2007 let barrier_b = Arc::clone(&barrier);
2008 let handle_b = std::thread::spawn(move || {
2009 barrier_b.wait();
2010 find_ignored_paths_with_source(
2011 &root_b_thread,
2012 &["secret_b.py".to_string(), "app.py".to_string()],
2013 )
2014 });
2015
2016 let ignored_a = handle_a.join().expect("thread a panicked");
2017 let ignored_b = handle_b.join().expect("thread b panicked");
2018
2019 assert_eq!(
2020 ignored_a.get("secret_a.py"),
2021 Some(&IgnoreSource::DiffctxPolicy),
2022 "repo A lost its .diffctx/ignore rule to a concurrent call"
2023 );
2024 assert_eq!(
2025 ignored_b.get("secret_b.py"),
2026 Some(&IgnoreSource::DiffctxPolicy),
2027 "repo B lost its .diffctx/ignore rule to a concurrent call"
2028 );
2029 assert!(!ignored_a.contains_key("app.py"));
2030 assert!(!ignored_b.contains_key("app.py"));
2031 }
2032 }
2033}
2034
2035#[cfg(test)]
2036mod negation_record_tests {
2037 use super::*;
2038
2039 #[test]
2040 fn a_negation_record_does_not_mark_the_path_ignored() {
2041 let stdout = ".gitignore\x001\x00*.tmp\x00drop.tmp\x00.gitignore\x002\x00!NEWDOC.md\x00NEWDOC.md\x00";
2043 let rules = parse_verbose_ignore_records(stdout);
2044 assert!(
2045 rules.contains_key("drop.tmp"),
2046 "a positive match must stay an exclusion"
2047 );
2048 assert!(
2049 !rules.contains_key("NEWDOC.md"),
2050 "a negation match means the path is explicitly NOT ignored (#193)"
2051 );
2052 }
2053}