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
22fn git_timeout() -> u64 {
23 GIT_TIMEOUT_SECS.load(Ordering::Relaxed)
24}
25const SAFE_DIFF_FLAGS: &[&str] = &["--no-textconv", "--no-ext-diff"];
26
27static HUNK_RE: Lazy<Regex> =
28 Lazy::new(|| Regex::new(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@").unwrap());
29
30static RANGE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\s*(\S+?)(\.\.\.?)(\S*?)\s*$").unwrap());
31
32static SAFE_RANGE_RE: Lazy<Regex> =
33 Lazy::new(|| Regex::new(r"^[a-zA-Z0-9_.^~/@{}\-]+(\.\.\.?[a-zA-Z0-9_.^~/@{}\-]*)?$").unwrap());
34
35#[derive(Debug, thiserror::Error)]
36pub enum GitError {
37 #[error("{0}")]
38 CommandFailed(String),
39 #[error("not a git repository: {0}")]
40 NotARepo(PathBuf),
41 #[error("invalid diff range: {0}")]
42 InvalidRange(String),
43 #[error("io error: {0}")]
44 Io(#[from] std::io::Error),
45 #[error("timeout after {0}s")]
46 Timeout(u64),
47}
48
49pub type Result<T> = std::result::Result<T, GitError>;
50
51fn validate_diff_range(diff_range: &str) -> Result<()> {
52 let trimmed = diff_range.trim();
53 if trimmed.starts_with('.') || trimmed.starts_with('/') {
58 return Err(GitError::InvalidRange(diff_range.to_string()));
59 }
60 if !SAFE_RANGE_RE.is_match(trimmed) {
61 return Err(GitError::InvalidRange(diff_range.to_string()));
62 }
63 Ok(())
64}
65
66pub fn git_command(repo_root: &Path) -> Command {
72 let mut cmd = Command::new("git");
73 cmd.arg("-C")
74 .arg(repo_root)
75 .env_remove("GIT_DIR")
76 .env_remove("GIT_WORK_TREE")
77 .env_remove("GIT_INDEX_FILE");
78 cmd
79}
80
81pub fn run_git(repo_root: &Path, args: &[&str]) -> Result<String> {
82 let mut cmd = git_command(repo_root);
83 cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
84
85 let child = cmd.spawn().map_err(|e| {
86 if e.kind() == std::io::ErrorKind::NotFound {
87 GitError::CommandFailed("git is not installed or not in PATH".into())
88 } else {
89 GitError::Io(e)
90 }
91 })?;
92
93 let output = wait_with_timeout(child, Duration::from_secs(git_timeout()), args)?;
94
95 if !output.status.success() {
96 let stderr = String::from_utf8_lossy(&output.stderr);
97 let subcommand = args
98 .iter()
99 .find(|a| !a.starts_with('-'))
100 .copied()
101 .unwrap_or("command");
102 let reason = stderr
103 .lines()
104 .map(str::trim)
105 .find(|l| l.starts_with("fatal:") || l.starts_with("error:"))
106 .or_else(|| stderr.lines().map(str::trim).find(|l| !l.is_empty()))
107 .unwrap_or("unknown error");
108 return Err(GitError::CommandFailed(format!(
109 "git {subcommand} failed: {reason}"
110 )));
111 }
112
113 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
114}
115
116fn wait_with_timeout(
117 child: Child,
118 timeout: Duration,
119 _args: &[&str],
120) -> Result<std::process::Output> {
121 let mut child = child;
122 let stdout_handle = child.stdout.take().map(|mut s| {
123 std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
124 let mut buf = Vec::new();
125 s.read_to_end(&mut buf)?;
126 Ok(buf)
127 })
128 });
129 let stderr_handle = child.stderr.take().map(|mut s| {
130 std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
131 let mut buf = Vec::new();
132 s.read_to_end(&mut buf)?;
133 Ok(buf)
134 })
135 });
136
137 let status = match child.wait_timeout(timeout)? {
138 Some(status) => status,
139 None => {
140 let _ = child.kill();
141 let _ = child.wait();
142 return Err(GitError::Timeout(timeout.as_secs()));
143 }
144 };
145
146 let stdout = stdout_handle
147 .and_then(|h| h.join().ok())
148 .and_then(|r| r.ok())
149 .unwrap_or_default();
150 let stderr = stderr_handle
151 .and_then(|h| h.join().ok())
152 .and_then(|r| r.ok())
153 .unwrap_or_default();
154
155 Ok(std::process::Output {
156 status,
157 stdout,
158 stderr,
159 })
160}
161
162pub fn is_git_repo(path: &Path) -> bool {
163 run_git(path, &["rev-parse", "--git-dir"]).is_ok()
164}
165
166pub fn find_toplevel(path: &Path) -> Option<PathBuf> {
173 let out = run_git(path, &["rev-parse", "--show-toplevel"]).ok()?;
174 let trimmed = out.trim();
175 if trimmed.is_empty() {
176 return None;
177 }
178 Some(PathBuf::from(trimmed))
179}
180
181pub fn get_diff_text(repo_root: &Path, diff_range: Option<&str>) -> Result<String> {
182 let mut args: Vec<&str> = vec!["diff"];
183 args.extend_from_slice(SAFE_DIFF_FLAGS);
184 if let Some(range) = diff_range {
185 validate_diff_range(range)?;
186 args.push(range);
187 }
188 run_git(repo_root, &args)
189}
190
191fn unquote_c_style(quoted: &str) -> String {
192 if !(quoted.starts_with('"') && quoted.ends_with('"')) {
193 return quoted.to_string();
194 }
195
196 let raw = "ed[1..quoted.len() - 1];
197 let bytes = raw.as_bytes();
198 let mut result: Vec<u8> = Vec::with_capacity(bytes.len());
199 let mut i = 0;
200
201 while i < bytes.len() {
202 if bytes[i] == b'\\' && i + 1 < bytes.len() {
203 let nxt = bytes[i + 1];
204 match nxt {
205 b't' => {
206 result.push(b'\t');
207 i += 2;
208 }
209 b'n' => {
210 result.push(b'\n');
211 i += 2;
212 }
213 b'r' => {
214 result.push(b'\r');
215 i += 2;
216 }
217 b'b' => {
218 result.push(0x08);
219 i += 2;
220 }
221 b'f' => {
222 result.push(0x0C);
223 i += 2;
224 }
225 b'v' => {
226 result.push(0x0B);
227 i += 2;
228 }
229 b'a' => {
230 result.push(0x07);
231 i += 2;
232 }
233 b'\\' => {
234 result.push(b'\\');
235 i += 2;
236 }
237 b'"' => {
238 result.push(b'"');
239 i += 2;
240 }
241 b'0'..=b'7'
242 if i + 3 < bytes.len()
243 && bytes[i + 2].is_ascii_digit()
244 && bytes[i + 2] <= b'7'
245 && bytes[i + 3].is_ascii_digit()
246 && bytes[i + 3] <= b'7' =>
247 {
248 let val = (nxt - b'0') * 64 + (bytes[i + 2] - b'0') * 8 + (bytes[i + 3] - b'0');
249 result.push(val);
250 i += 4;
251 }
252 _ => {
253 result.push(b'\\');
254 i += 1;
255 }
256 }
257 } else {
258 result.push(bytes[i]);
259 i += 1;
260 }
261 }
262
263 String::from_utf8(result).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
264}
265
266fn parse_path_line(line: &str, repo_root: &Path) -> (&'static str, Option<PathBuf>) {
267 let resolved_root = repo_root
268 .canonicalize()
269 .unwrap_or_else(|_| repo_root.to_path_buf());
270
271 if line.starts_with("--- /dev/null") {
272 return ("old", None);
273 }
274 if line.starts_with("+++ /dev/null") {
275 return ("new", None);
276 }
277
278 if let Some(rest) = line.strip_prefix("--- a/") {
279 let rel_path = rest.trim();
280 let resolved = (repo_root.join(rel_path))
281 .canonicalize()
282 .unwrap_or_else(|_| repo_root.join(rel_path));
283 if !resolved.starts_with(&resolved_root) {
284 return ("", None);
285 }
286 return ("old", Some(repo_root.join(rel_path)));
287 }
288
289 if let Some(rest) = line.strip_prefix("+++ b/") {
290 let rel_path = rest.trim();
291 let resolved = (repo_root.join(rel_path))
292 .canonicalize()
293 .unwrap_or_else(|_| repo_root.join(rel_path));
294 if !resolved.starts_with(&resolved_root) {
295 return ("", None);
296 }
297 return ("new", Some(repo_root.join(rel_path)));
298 }
299
300 if let Some(rest) = line.strip_prefix("--- ").filter(|r| r.starts_with("\"a/")) {
301 let quoted = rest.trim();
302 let unquoted = unquote_c_style(quoted);
303 let rel_path = unquoted.strip_prefix("a/").unwrap_or(&unquoted);
304 let resolved = (repo_root.join(rel_path))
305 .canonicalize()
306 .unwrap_or_else(|_| repo_root.join(rel_path));
307 if !resolved.starts_with(&resolved_root) {
308 return ("", None);
309 }
310 return ("old", Some(repo_root.join(rel_path)));
311 }
312
313 if let Some(rest) = line.strip_prefix("+++ ").filter(|r| r.starts_with("\"b/")) {
314 let quoted = rest.trim();
315 let unquoted = unquote_c_style(quoted);
316 let rel_path = unquoted.strip_prefix("b/").unwrap_or(&unquoted);
317 let resolved = (repo_root.join(rel_path))
318 .canonicalize()
319 .unwrap_or_else(|_| repo_root.join(rel_path));
320 if !resolved.starts_with(&resolved_root) {
321 return ("", None);
322 }
323 return ("new", Some(repo_root.join(rel_path)));
324 }
325
326 ("", None)
327}
328
329fn parse_hunk_header(caps: ®ex::Captures, path: &Path) -> Option<DiffHunk> {
330 let old_start: u32 = caps[1].parse().ok()?;
335 let old_len: u32 = match caps.get(2) {
336 Some(m) => m.as_str().parse().ok()?,
337 None => 1,
338 };
339 let new_start: u32 = caps[3].parse().ok()?;
340 let new_len: u32 = match caps.get(4) {
341 Some(m) => m.as_str().parse().ok()?,
342 None => 1,
343 };
344
345 Some(DiffHunk {
346 path: Arc::from(path.to_string_lossy().as_ref()),
347 new_start,
348 new_len,
349 old_start,
350 old_len,
351 })
352}
353
354pub fn parse_diff(repo_root: &Path, diff_range: Option<&str>) -> Result<Vec<DiffHunk>> {
355 let mut args: Vec<&str> = vec!["diff"];
356 args.extend_from_slice(SAFE_DIFF_FLAGS);
357 args.push("--unified=0");
358 args.push("-M");
359 if let Some(range) = diff_range {
360 validate_diff_range(range)?;
361 args.push(range);
362 }
363
364 let output = run_git(repo_root, &args)?;
365 let mut hunks = Vec::new();
366 let mut old_path: Option<PathBuf> = None;
367 let mut new_path: Option<PathBuf> = None;
368
369 for line in output.lines() {
370 let (path_type, path) = parse_path_line(line, repo_root);
371 match path_type {
372 "old" => {
373 old_path = path;
374 continue;
375 }
376 "new" => {
377 new_path = path;
378 continue;
379 }
380 _ => {}
381 }
382
383 if let Some(caps) = HUNK_RE.captures(line) {
384 let current_path = new_path.as_deref().or(old_path.as_deref());
385 if let Some(p) = current_path {
386 if let Some(hunk) = parse_hunk_header(&caps, p) {
387 hunks.push(hunk);
388 }
389 }
390 }
391 }
392
393 Ok(hunks)
394}
395
396pub fn run_git_z(repo_root: &Path, args: &[&str]) -> Result<Vec<String>> {
397 let output = run_git(repo_root, args)?;
398 Ok(output
399 .split('\0')
400 .filter(|s| !s.is_empty())
401 .map(String::from)
402 .collect())
403}
404
405pub fn get_changed_files(repo_root: &Path, diff_range: Option<&str>) -> Result<Vec<PathBuf>> {
406 let mut args: Vec<&str> = vec!["diff"];
407 args.extend_from_slice(SAFE_DIFF_FLAGS);
408 args.extend_from_slice(&["--name-only", "-M", "-z"]);
409 if let Some(range) = diff_range {
410 validate_diff_range(range)?;
411 args.push(range);
412 }
413 let parts = run_git_z(repo_root, &args)?;
414 Ok(parts
415 .iter()
416 .map(|p| {
417 repo_root
418 .join(p)
419 .canonicalize()
420 .unwrap_or_else(|_| repo_root.join(p))
421 })
422 .collect())
423}
424
425pub fn get_deleted_files(repo_root: &Path, diff_range: Option<&str>) -> Result<FxHashSet<PathBuf>> {
426 let mut args: Vec<&str> = vec!["diff"];
427 args.extend_from_slice(SAFE_DIFF_FLAGS);
428 args.extend_from_slice(&["--diff-filter=D", "--name-only", "-M", "-z"]);
429 if let Some(range) = diff_range {
430 validate_diff_range(range)?;
431 args.push(range);
432 }
433 let parts = run_git_z(repo_root, &args)?;
434 Ok(parts
435 .iter()
436 .map(|p| {
437 repo_root
438 .join(p)
439 .canonicalize()
440 .unwrap_or_else(|_| repo_root.join(p))
441 })
442 .collect())
443}
444
445pub fn get_renamed_paths(
446 repo_root: &Path,
447 diff_range: Option<&str>,
448 min_similarity: u32,
449) -> Result<(FxHashSet<PathBuf>, FxHashSet<PathBuf>)> {
450 let mut args: Vec<&str> = vec!["diff"];
451 args.extend_from_slice(SAFE_DIFF_FLAGS);
452 args.extend_from_slice(&["--diff-filter=R", "--name-status", "-M", "-z"]);
453 if let Some(range) = diff_range {
454 validate_diff_range(range)?;
455 args.push(range);
456 }
457 let output = run_git(repo_root, &args)?;
458 let parts: Vec<&str> = output.split('\0').collect();
459
460 let mut old_paths = FxHashSet::default();
461 let mut pure_new_paths = FxHashSet::default();
462 let mut i = 0;
463
464 while i < parts.len() {
465 if parts[i].starts_with('R') {
466 let sim: u32 = parts[i][1..].parse().unwrap_or(0);
467
468 if i + 1 < parts.len() && !parts[i + 1].is_empty() {
469 let resolved = repo_root
470 .join(parts[i + 1])
471 .canonicalize()
472 .unwrap_or_else(|_| repo_root.join(parts[i + 1]));
473 old_paths.insert(resolved);
474 }
475
476 if sim >= min_similarity && i + 2 < parts.len() && !parts[i + 2].is_empty() {
477 let resolved = repo_root
478 .join(parts[i + 2])
479 .canonicalize()
480 .unwrap_or_else(|_| repo_root.join(parts[i + 2]));
481 pure_new_paths.insert(resolved);
482 }
483
484 i += 3;
485 } else {
486 i += 1;
487 }
488 }
489
490 Ok((old_paths, pure_new_paths))
491}
492
493pub fn get_rename_pairs(
497 repo_root: &Path,
498 diff_range: Option<&str>,
499) -> Result<Vec<(String, String)>> {
500 let mut args: Vec<&str> = vec!["diff"];
501 args.extend_from_slice(SAFE_DIFF_FLAGS);
502 args.extend_from_slice(&["--diff-filter=R", "--name-status", "-M", "-z"]);
503 if let Some(range) = diff_range {
504 validate_diff_range(range)?;
505 args.push(range);
506 }
507 let output = run_git(repo_root, &args)?;
508 let parts: Vec<&str> = output.split('\0').collect();
509
510 let mut pairs = Vec::new();
511 let mut i = 0;
512 while i < parts.len() {
513 if parts[i].starts_with('R') {
514 if i + 2 < parts.len() && !parts[i + 1].is_empty() && !parts[i + 2].is_empty() {
515 pairs.push((
516 parts[i + 1].replace('\\', "/"),
517 parts[i + 2].replace('\\', "/"),
518 ));
519 }
520 i += 3;
521 } else {
522 i += 1;
523 }
524 }
525 Ok(pairs)
526}
527
528pub fn split_diff_range(range: &str) -> (Option<String>, Option<String>) {
529 match RANGE_RE.captures(range) {
530 None => (None, None),
531 Some(caps) => {
532 let base = caps
533 .get(1)
534 .map(|m| m.as_str().trim().to_string())
535 .filter(|s| !s.is_empty());
536 let head = caps
537 .get(3)
538 .map(|m| m.as_str().trim().to_string())
539 .filter(|s| !s.is_empty());
540 (base, head)
541 }
542 }
543}
544
545pub fn show_file_at_revision(repo_root: &Path, rev: &str, rel_path: &Path) -> Result<String> {
546 let spec = format!("{}:{}", rev, rel_path.to_string_lossy().replace('\\', "/"));
547 run_git(repo_root, &["show", &spec])
548}
549
550pub fn get_commit_message(repo_root: &Path, rev: &str) -> Result<String> {
551 match run_git(repo_root, &["log", "-1", "--format=%s%n%b", rev]) {
552 Ok(s) => Ok(s.trim().to_string()),
553 Err(_) => Ok(String::new()),
554 }
555}
556
557pub fn get_untracked_files(repo_root: &Path) -> Result<Vec<PathBuf>> {
558 let parts = run_git_z(
559 repo_root,
560 &["ls-files", "--others", "--exclude-standard", "-z"],
561 )?;
562 Ok(parts
563 .iter()
564 .map(|p| {
565 repo_root
566 .join(p)
567 .canonicalize()
568 .unwrap_or_else(|_| repo_root.join(p))
569 })
570 .collect())
571}
572
573fn anchor_diffctx_ignore_line(line: &str, rel: &str) -> String {
579 let (neg, pat) = match line.strip_prefix('!') {
580 Some(rest) => (true, rest),
581 None => (false, line),
582 };
583 let pat_no_trailing_slash = pat.trim_end_matches('/');
584 let full = if pat_no_trailing_slash.starts_with('/') || pat_no_trailing_slash.contains('/') {
585 let anchored = pat.trim_start_matches('/');
586 if rel.is_empty() {
587 format!("/{anchored}")
588 } else {
589 format!("/{rel}/{anchored}")
590 }
591 } else if rel.is_empty() {
592 pat.to_string()
593 } else {
594 format!("{rel}/**/{pat}")
595 };
596 if neg { format!("!{full}") } else { full }
597}
598
599fn collect_diffctx_ignore_patterns(repo_root: &Path) -> Vec<String> {
603 let Ok(files) = run_git_z(
604 repo_root,
605 &[
606 "ls-files",
607 "-z",
608 "--cached",
609 "--others",
610 "--exclude-standard",
611 "--",
612 ":(glob)**/.diffctx/ignore",
613 ],
614 ) else {
615 return Vec::new();
616 };
617
618 let mut patterns = Vec::new();
619 for raw in &files {
620 let rel_path = unquote_c_style(raw);
621 if !rel_path.ends_with(".diffctx/ignore") {
622 continue;
623 }
624 let rel_dir = rel_path
625 .strip_suffix(".diffctx/ignore")
626 .unwrap_or("")
627 .trim_end_matches('/');
628 let Ok(content) = std::fs::read_to_string(repo_root.join(&rel_path)) else {
629 continue;
630 };
631 for line in content.lines() {
632 let line = line.trim_end();
633 if line.is_empty() || line.starts_with('#') {
634 continue;
635 }
636 patterns.push(anchor_diffctx_ignore_line(line, rel_dir));
637 }
638 }
639 patterns
640}
641
642pub fn find_ignored_paths(repo_root: &Path, rel_paths: &[String]) -> FxHashSet<String> {
649 if rel_paths.is_empty() {
650 return FxHashSet::default();
651 }
652
653 let diffctx_patterns = collect_diffctx_ignore_patterns(repo_root);
654 let temp_excludes = if diffctx_patterns.is_empty() {
655 None
656 } else {
657 let path = std::env::temp_dir().join(format!("diffctx-ignore-{}.tmp", std::process::id()));
658 match std::fs::write(&path, diffctx_patterns.join("\n")) {
659 Ok(()) => Some(path),
660 Err(_) => None,
661 }
662 };
663
664 let mut args: Vec<String> = vec!["check-ignore".into(), "--no-index".into()];
665 if let Some(ref path) = temp_excludes {
666 args.insert(0, format!("core.excludesFile={}", path.display()));
667 args.insert(0, "-c".into());
668 }
669 args.push("--".into());
670 args.extend(rel_paths.iter().cloned());
671 let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
672
673 let result = (|| -> Result<FxHashSet<String>> {
674 let mut cmd = git_command(repo_root);
675 cmd.args(&arg_refs)
676 .stdout(Stdio::piped())
677 .stderr(Stdio::piped());
678 let child = cmd.spawn()?;
679 let output = wait_with_timeout(child, Duration::from_secs(git_timeout()), &arg_refs)?;
680 if !output.status.success() && output.status.code() != Some(1) {
683 let stderr = String::from_utf8_lossy(&output.stderr);
684 return Err(GitError::CommandFailed(format!(
685 "git check-ignore failed: {}",
686 stderr.trim()
687 )));
688 }
689 let stdout = String::from_utf8_lossy(&output.stdout);
690 Ok(stdout.lines().map(unquote_c_style).collect())
691 })();
692
693 if let Some(path) = temp_excludes {
694 let _ = std::fs::remove_file(path);
695 }
696
697 result.unwrap_or_default()
698}
699
700pub struct CatFileBatch {
701 repo_root: PathBuf,
702 child: Option<Child>,
703 reader: Option<BufReader<ChildStdout>>,
704}
705
706impl CatFileBatch {
707 pub fn new(repo_root: &Path) -> Result<Self> {
708 let mut batch = Self {
709 repo_root: repo_root.to_path_buf(),
710 child: None,
711 reader: None,
712 };
713 batch.ensure_started()?;
714 Ok(batch)
715 }
716
717 fn ensure_started(&mut self) -> Result<()> {
718 let needs_restart = match &mut self.child {
719 None => true,
720 Some(child) => child.try_wait().ok().flatten().is_some(),
721 };
722
723 if needs_restart {
724 let mut child = git_command(&self.repo_root)
725 .args(["cat-file", "--batch"])
726 .stdin(Stdio::piped())
727 .stdout(Stdio::piped())
728 .stderr(Stdio::null())
729 .spawn()?;
730 let stdout = child.stdout.take().ok_or_else(|| {
731 GitError::CommandFailed("cat-file: failed to capture stdout pipe".into())
732 })?;
733 self.reader = Some(BufReader::new(stdout));
734 self.child = Some(child);
735 }
736
737 Ok(())
738 }
739
740 pub fn get(&mut self, rev: &str, rel_path: &Path) -> Result<String> {
741 let spec = format!(
742 "{}:{}\n",
743 rev,
744 rel_path.to_string_lossy().replace('\\', "/")
745 );
746
747 self.ensure_started()?;
748
749 let stdin = self
750 .child
751 .as_mut()
752 .and_then(|c| c.stdin.as_mut())
753 .ok_or_else(|| GitError::CommandFailed("cat-file stdin unavailable".into()))?;
754 stdin.write_all(spec.as_bytes())?;
755 stdin.flush()?;
756
757 let reader = self
758 .reader
759 .as_mut()
760 .ok_or_else(|| GitError::CommandFailed("cat-file stdout unavailable".into()))?;
761
762 let mut header_line = String::new();
763 reader.read_line(&mut header_line)?;
764
765 if header_line.is_empty() {
766 return Err(GitError::CommandFailed(format!(
767 "cat-file: unexpected EOF for {}",
768 spec.trim()
769 )));
770 }
771
772 let header_str = header_line.trim();
773 if header_str.ends_with("missing") {
774 return Err(GitError::CommandFailed(format!(
775 "Path not found: {}",
776 spec.trim()
777 )));
778 }
779
780 let parts: Vec<&str> = header_str.split_whitespace().collect();
781 if parts.len() < 3 {
782 return Err(GitError::CommandFailed(format!(
783 "cat-file: malformed header: {}",
784 header_str
785 )));
786 }
787
788 let size: usize = parts[2].parse().map_err(|_| {
789 GitError::CommandFailed(format!("cat-file: invalid size in header: {}", header_str))
790 })?;
791
792 if size > crate::config::limits::MAX_BLOB_READ_BYTES {
798 let mut remaining = size;
799 let mut scratch = [0u8; 65536];
800 while remaining > 0 {
801 let want = remaining.min(scratch.len());
802 reader.read_exact(&mut scratch[..want])?;
803 remaining -= want;
804 }
805 let mut trailing = [0u8; 1];
806 let _ = reader.read_exact(&mut trailing);
807 return Err(GitError::CommandFailed(format!(
808 "cat-file: blob too large ({} bytes): {}",
809 size,
810 spec.trim()
811 )));
812 }
813
814 let mut content = vec![0u8; size];
815 reader.read_exact(&mut content)?;
816
817 let mut trailing = [0u8; 1];
818 let _ = reader.read_exact(&mut trailing);
819
820 Ok(String::from_utf8_lossy(&content).into_owned())
821 }
822
823 pub fn close(&mut self) {
824 self.reader.take();
825 if let Some(mut child) = self.child.take() {
826 drop(child.stdin.take());
827 match child.wait_timeout(Duration::from_secs(GIT.catfile_termination_timeout_seconds)) {
828 Ok(Some(_)) => {}
829 _ => {
830 let _ = child.kill();
831 let _ = child.wait();
832 }
833 }
834 }
835 }
836}
837
838impl Drop for CatFileBatch {
839 fn drop(&mut self) {
840 self.close();
841 }
842}