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