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