use std::io::{BufRead, BufReader, Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdout, Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use once_cell::sync::Lazy;
use regex::Regex;
use rustc_hash::FxHashSet;
use wait_timeout::ChildExt;
use crate::config::git::{self, GIT};
use crate::types::DiffHunk;
static GIT_TIMEOUT_SECS: AtomicU64 = AtomicU64::new(git::DEFAULT_TIMEOUT_SECONDS);
pub fn set_git_timeout(secs: u64) {
GIT_TIMEOUT_SECS.store(secs, Ordering::Relaxed);
}
static TEMP_EXCLUDES_COUNTER: AtomicU64 = AtomicU64::new(0);
fn git_timeout() -> u64 {
GIT_TIMEOUT_SECS.load(Ordering::Relaxed)
}
const SAFE_DIFF_FLAGS: &[&str] = &[
"--no-textconv",
"--no-ext-diff",
"--no-color",
"--src-prefix=a/",
"--dst-prefix=b/",
];
static HUNK_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@").unwrap());
static RANGE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^\s*(\S+?)(\.\.\.?)(\S*?)\s*$").unwrap());
static SAFE_RANGE_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(
r"^[a-zA-Z0-9_.^~/@{}][a-zA-Z0-9_.^~/@{}\-]*(\.\.\.?([a-zA-Z0-9_.^~/@{}][a-zA-Z0-9_.^~/@{}\-]*)?)?$",
)
.unwrap()
});
#[derive(Debug, thiserror::Error)]
pub enum GitError {
#[error("{0}")]
CommandFailed(String),
#[error("not a git repository: {0}")]
NotARepo(PathBuf),
#[error("invalid diff range: {0}")]
InvalidRange(String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("timeout after {0}s")]
Timeout(u64),
}
pub type Result<T> = std::result::Result<T, GitError>;
fn validate_diff_range(diff_range: &str) -> Result<()> {
let trimmed = diff_range.trim();
if trimmed.starts_with('.') || trimmed.starts_with('/') {
return Err(GitError::InvalidRange(diff_range.to_string()));
}
if !SAFE_RANGE_RE.is_match(trimmed) {
return Err(GitError::InvalidRange(diff_range.to_string()));
}
let separator = if trimmed.contains("...") { "..." } else { ".." };
for side in trimmed.split(separator) {
if !side.is_empty() {
validate_rev(side).map_err(|_| GitError::InvalidRange(diff_range.to_string()))?;
}
}
Ok(())
}
fn validate_rev(rev: &str) -> Result<()> {
if rev.is_empty()
|| rev.starts_with('-')
|| rev
.chars()
.any(|c| c.is_whitespace() || c.is_control() || c == '\0')
{
return Err(GitError::InvalidRange(rev.to_string()));
}
Ok(())
}
pub fn git_command(repo_root: &Path) -> Command {
let mut cmd = Command::new("git");
cmd.arg("-C")
.arg(repo_root)
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE");
cmd
}
pub fn run_git(repo_root: &Path, args: &[&str]) -> Result<String> {
let mut cmd = git_command(repo_root);
cmd.args(args).stdout(Stdio::piped()).stderr(Stdio::piped());
let child = cmd.spawn().map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
GitError::CommandFailed("git is not installed or not in PATH".into())
} else {
GitError::Io(e)
}
})?;
let output = wait_with_timeout(child, Duration::from_secs(git_timeout()), args)?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let subcommand = args
.iter()
.find(|a| !a.starts_with('-'))
.copied()
.unwrap_or("command");
let reason = stderr
.lines()
.map(str::trim)
.find(|l| l.starts_with("fatal:") || l.starts_with("error:"))
.or_else(|| stderr.lines().map(str::trim).find(|l| !l.is_empty()))
.unwrap_or("unknown error");
return Err(GitError::CommandFailed(format!(
"git {subcommand} failed: {reason}"
)));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn wait_with_timeout(
child: Child,
timeout: Duration,
_args: &[&str],
) -> Result<std::process::Output> {
let mut child = child;
let stdout_handle = child.stdout.take().map(|mut s| {
std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
let mut buf = Vec::new();
s.read_to_end(&mut buf)?;
Ok(buf)
})
});
let stderr_handle = child.stderr.take().map(|mut s| {
std::thread::spawn(move || -> std::io::Result<Vec<u8>> {
let mut buf = Vec::new();
s.read_to_end(&mut buf)?;
Ok(buf)
})
});
let status = match child.wait_timeout(timeout)? {
Some(status) => status,
None => {
let _ = child.kill();
let _ = child.wait();
return Err(GitError::Timeout(timeout.as_secs()));
}
};
let stdout = stdout_handle
.and_then(|h| h.join().ok())
.and_then(|r| r.ok())
.unwrap_or_default();
let stderr = stderr_handle
.and_then(|h| h.join().ok())
.and_then(|r| r.ok())
.unwrap_or_default();
Ok(std::process::Output {
status,
stdout,
stderr,
})
}
pub fn is_git_repo(path: &Path) -> bool {
run_git(path, &["rev-parse", "--git-dir"]).is_ok()
}
pub fn find_toplevel(path: &Path) -> Option<PathBuf> {
let out = run_git(path, &["rev-parse", "--show-toplevel"]).ok()?;
let trimmed = out.trim();
if trimmed.is_empty() {
return None;
}
Some(PathBuf::from(trimmed))
}
pub fn get_diff_text(repo_root: &Path, diff_range: Option<&str>) -> Result<String> {
let mut args: Vec<&str> = vec!["diff"];
args.extend_from_slice(SAFE_DIFF_FLAGS);
if let Some(range) = diff_range {
validate_diff_range(range)?;
args.push(range);
}
run_git(repo_root, &args)
}
pub(crate) fn unquote_c_style(quoted: &str) -> String {
if !(quoted.starts_with('"') && quoted.ends_with('"')) {
return quoted.to_string();
}
let raw = "ed[1..quoted.len() - 1];
let bytes = raw.as_bytes();
let mut result: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'\\' && i + 1 < bytes.len() {
let nxt = bytes[i + 1];
match nxt {
b't' => {
result.push(b'\t');
i += 2;
}
b'n' => {
result.push(b'\n');
i += 2;
}
b'r' => {
result.push(b'\r');
i += 2;
}
b'b' => {
result.push(0x08);
i += 2;
}
b'f' => {
result.push(0x0C);
i += 2;
}
b'v' => {
result.push(0x0B);
i += 2;
}
b'a' => {
result.push(0x07);
i += 2;
}
b'\\' => {
result.push(b'\\');
i += 2;
}
b'"' => {
result.push(b'"');
i += 2;
}
b'0'..=b'7'
if i + 3 < bytes.len()
&& bytes[i + 2].is_ascii_digit()
&& bytes[i + 2] <= b'7'
&& bytes[i + 3].is_ascii_digit()
&& bytes[i + 3] <= b'7' =>
{
let val = (nxt - b'0') * 64 + (bytes[i + 2] - b'0') * 8 + (bytes[i + 3] - b'0');
result.push(val);
i += 4;
}
_ => {
result.push(b'\\');
i += 1;
}
}
} else {
result.push(bytes[i]);
i += 1;
}
}
String::from_utf8(result).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned())
}
pub(crate) fn parse_path_line(line: &str, repo_root: &Path) -> (&'static str, Option<PathBuf>) {
let resolved_root = repo_root
.canonicalize()
.unwrap_or_else(|_| repo_root.to_path_buf());
if line.starts_with("--- /dev/null") {
return ("old", None);
}
if line.starts_with("+++ /dev/null") {
return ("new", None);
}
if let Some(rest) = line.strip_prefix("--- a/") {
let rel_path = rest.trim();
let resolved = (repo_root.join(rel_path))
.canonicalize()
.unwrap_or_else(|_| repo_root.join(rel_path));
if !resolved.starts_with(&resolved_root) {
return ("", None);
}
return ("old", Some(repo_root.join(rel_path)));
}
if let Some(rest) = line.strip_prefix("+++ b/") {
let rel_path = rest.trim();
let resolved = (repo_root.join(rel_path))
.canonicalize()
.unwrap_or_else(|_| repo_root.join(rel_path));
if !resolved.starts_with(&resolved_root) {
return ("", None);
}
return ("new", Some(repo_root.join(rel_path)));
}
if let Some(rest) = line.strip_prefix("--- ").filter(|r| r.starts_with("\"a/")) {
let quoted = rest.trim();
let unquoted = unquote_c_style(quoted);
let rel_path = unquoted.strip_prefix("a/").unwrap_or(&unquoted);
let resolved = (repo_root.join(rel_path))
.canonicalize()
.unwrap_or_else(|_| repo_root.join(rel_path));
if !resolved.starts_with(&resolved_root) {
return ("", None);
}
return ("old", Some(repo_root.join(rel_path)));
}
if let Some(rest) = line.strip_prefix("+++ ").filter(|r| r.starts_with("\"b/")) {
let quoted = rest.trim();
let unquoted = unquote_c_style(quoted);
let rel_path = unquoted.strip_prefix("b/").unwrap_or(&unquoted);
let resolved = (repo_root.join(rel_path))
.canonicalize()
.unwrap_or_else(|_| repo_root.join(rel_path));
if !resolved.starts_with(&resolved_root) {
return ("", None);
}
return ("new", Some(repo_root.join(rel_path)));
}
("", None)
}
fn parse_hunk_header(caps: ®ex::Captures, path: &Path) -> Option<DiffHunk> {
let old_start: u32 = caps[1].parse().ok()?;
let old_len: u32 = match caps.get(2) {
Some(m) => m.as_str().parse().ok()?,
None => 1,
};
let new_start: u32 = caps[3].parse().ok()?;
let new_len: u32 = match caps.get(4) {
Some(m) => m.as_str().parse().ok()?,
None => 1,
};
Some(DiffHunk {
path: Arc::from(path.to_string_lossy().as_ref()),
new_start,
new_len,
old_start,
old_len,
})
}
pub fn parse_diff(repo_root: &Path, diff_range: Option<&str>) -> Result<Vec<DiffHunk>> {
let mut args: Vec<&str> = vec!["diff"];
args.extend_from_slice(SAFE_DIFF_FLAGS);
args.push("--unified=0");
args.push("-M");
if let Some(range) = diff_range {
validate_diff_range(range)?;
args.push(range);
}
let output = run_git(repo_root, &args)?;
let mut hunks = Vec::new();
let mut old_path: Option<PathBuf> = None;
let mut new_path: Option<PathBuf> = None;
for line in output.lines() {
let (path_type, path) = parse_path_line(line, repo_root);
match path_type {
"old" => {
old_path = path;
continue;
}
"new" => {
new_path = path;
continue;
}
_ => {}
}
if let Some(caps) = HUNK_RE.captures(line) {
let current_path = new_path.as_deref().or(old_path.as_deref());
if let Some(p) = current_path {
if let Some(hunk) = parse_hunk_header(&caps, p) {
hunks.push(hunk);
}
}
}
}
Ok(hunks)
}
pub fn run_git_z(repo_root: &Path, args: &[&str]) -> Result<Vec<String>> {
let output = run_git(repo_root, args)?;
Ok(output
.split('\0')
.filter(|s| !s.is_empty())
.map(String::from)
.collect())
}
pub fn get_changed_files(repo_root: &Path, diff_range: Option<&str>) -> Result<Vec<PathBuf>> {
let mut args: Vec<&str> = vec!["diff"];
args.extend_from_slice(SAFE_DIFF_FLAGS);
args.extend_from_slice(&["--name-only", "-M", "-z"]);
if let Some(range) = diff_range {
validate_diff_range(range)?;
args.push(range);
}
let parts = run_git_z(repo_root, &args)?;
Ok(parts
.iter()
.map(|p| {
repo_root
.join(p)
.canonicalize()
.unwrap_or_else(|_| repo_root.join(p))
})
.collect())
}
pub fn get_deleted_files(repo_root: &Path, diff_range: Option<&str>) -> Result<FxHashSet<PathBuf>> {
let mut args: Vec<&str> = vec!["diff"];
args.extend_from_slice(SAFE_DIFF_FLAGS);
args.extend_from_slice(&["--diff-filter=D", "--name-only", "-M", "-z"]);
if let Some(range) = diff_range {
validate_diff_range(range)?;
args.push(range);
}
let parts = run_git_z(repo_root, &args)?;
Ok(parts
.iter()
.map(|p| {
repo_root
.join(p)
.canonicalize()
.unwrap_or_else(|_| repo_root.join(p))
})
.collect())
}
pub fn get_renamed_paths(
repo_root: &Path,
diff_range: Option<&str>,
min_similarity: u32,
) -> Result<(FxHashSet<PathBuf>, FxHashSet<PathBuf>)> {
let mut args: Vec<&str> = vec!["diff"];
args.extend_from_slice(SAFE_DIFF_FLAGS);
args.extend_from_slice(&["--diff-filter=R", "--name-status", "-M", "-z"]);
if let Some(range) = diff_range {
validate_diff_range(range)?;
args.push(range);
}
let output = run_git(repo_root, &args)?;
let parts: Vec<&str> = output.split('\0').collect();
let mut old_paths = FxHashSet::default();
let mut pure_new_paths = FxHashSet::default();
let mut i = 0;
while i < parts.len() {
if parts[i].starts_with('R') {
let sim: u32 = parts[i][1..].parse().unwrap_or(0);
if i + 1 < parts.len() && !parts[i + 1].is_empty() {
let resolved = repo_root
.join(parts[i + 1])
.canonicalize()
.unwrap_or_else(|_| repo_root.join(parts[i + 1]));
old_paths.insert(resolved);
}
if sim >= min_similarity && i + 2 < parts.len() && !parts[i + 2].is_empty() {
let resolved = repo_root
.join(parts[i + 2])
.canonicalize()
.unwrap_or_else(|_| repo_root.join(parts[i + 2]));
pure_new_paths.insert(resolved);
}
i += 3;
} else {
i += 1;
}
}
Ok((old_paths, pure_new_paths))
}
pub fn get_rename_pairs(
repo_root: &Path,
diff_range: Option<&str>,
) -> Result<Vec<(String, String)>> {
let mut args: Vec<&str> = vec!["diff"];
args.extend_from_slice(SAFE_DIFF_FLAGS);
args.extend_from_slice(&["--diff-filter=R", "--name-status", "-M", "-z"]);
if let Some(range) = diff_range {
validate_diff_range(range)?;
args.push(range);
}
let output = run_git(repo_root, &args)?;
let parts: Vec<&str> = output.split('\0').collect();
let mut pairs = Vec::new();
let mut i = 0;
while i < parts.len() {
if parts[i].starts_with('R') {
if i + 2 < parts.len() && !parts[i + 1].is_empty() && !parts[i + 2].is_empty() {
pairs.push((
parts[i + 1].replace('\\', "/"),
parts[i + 2].replace('\\', "/"),
));
}
i += 3;
} else {
i += 1;
}
}
Ok(pairs)
}
pub fn split_diff_range(range: &str) -> (Option<String>, Option<String>) {
match RANGE_RE.captures(range) {
None => (None, None),
Some(caps) => {
let base = caps
.get(1)
.map(|m| m.as_str().trim().to_string())
.filter(|s| !s.is_empty());
let head = caps
.get(3)
.map(|m| m.as_str().trim().to_string())
.filter(|s| !s.is_empty());
(base, head)
}
}
}
pub fn show_file_at_revision(repo_root: &Path, rev: &str, rel_path: &Path) -> Result<String> {
validate_rev(rev)?;
let spec = format!("{}:{}", rev, rel_path.to_string_lossy().replace('\\', "/"));
run_git(repo_root, &["show", &spec])
}
pub fn get_commit_message(repo_root: &Path, rev: &str) -> Result<String> {
if validate_rev(rev).is_err() {
return Ok(String::new());
}
match run_git(repo_root, &["log", "-1", "--format=%s%n%b", rev]) {
Ok(s) => Ok(s.trim().to_string()),
Err(_) => Ok(String::new()),
}
}
pub fn get_untracked_files(repo_root: &Path) -> Result<Vec<PathBuf>> {
let parts = run_git_z(
repo_root,
&["ls-files", "--others", "--exclude-standard", "-z"],
)?;
Ok(parts
.iter()
.map(|p| {
repo_root
.join(p)
.canonicalize()
.unwrap_or_else(|_| repo_root.join(p))
})
.collect())
}
fn anchor_diffctx_ignore_line(line: &str, rel: &str) -> String {
let (neg, pat) = match line.strip_prefix('!') {
Some(rest) => (true, rest),
None => (false, line),
};
let pat_no_trailing_slash = pat.trim_end_matches('/');
let full = if pat_no_trailing_slash.starts_with('/') || pat_no_trailing_slash.contains('/') {
let anchored = pat.trim_start_matches('/');
if rel.is_empty() {
format!("/{anchored}")
} else {
format!("/{rel}/{anchored}")
}
} else if rel.is_empty() {
pat.to_string()
} else {
format!("{rel}/**/{pat}")
};
if neg { format!("!{full}") } else { full }
}
fn collect_diffctx_ignore_patterns(repo_root: &Path) -> Vec<String> {
let Ok(files) = run_git_z(
repo_root,
&[
"ls-files",
"-z",
"--cached",
"--others",
"--exclude-standard",
"--",
":(glob)**/.diffctx/ignore",
],
) else {
return Vec::new();
};
let mut patterns = Vec::new();
for raw in &files {
let rel_path = unquote_c_style(raw);
if !rel_path.ends_with(".diffctx/ignore") {
continue;
}
let rel_dir = rel_path
.strip_suffix(".diffctx/ignore")
.unwrap_or("")
.trim_end_matches('/');
let Ok(content) = std::fs::read_to_string(repo_root.join(&rel_path)) else {
continue;
};
for line in content.lines() {
let line = line.trim_end();
if line.is_empty() || line.starts_with('#') {
continue;
}
patterns.push(anchor_diffctx_ignore_line(line, rel_dir));
}
}
patterns
}
pub fn find_ignored_paths(repo_root: &Path, rel_paths: &[String]) -> FxHashSet<String> {
if rel_paths.is_empty() {
return FxHashSet::default();
}
let diffctx_patterns = collect_diffctx_ignore_patterns(repo_root);
let temp_excludes = if diffctx_patterns.is_empty() {
None
} else {
let unique = TEMP_EXCLUDES_COUNTER.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!(
"diffctx-ignore-{}-{}.tmp",
std::process::id(),
unique
));
match std::fs::write(&path, diffctx_patterns.join("\n")) {
Ok(()) => Some(path),
Err(_) => None,
}
};
let mut queries: Vec<String> = rel_paths.to_vec();
let mut ancestors: FxHashSet<String> = FxHashSet::default();
for rel in rel_paths {
for ancestor in ancestor_dirs(rel) {
if ancestors.insert(ancestor.clone()) {
queries.push(ancestor);
}
}
}
let mut args: Vec<String> = vec!["check-ignore".into(), "--no-index".into(), "-v".into()];
if let Some(ref path) = temp_excludes {
args.insert(0, format!("core.excludesFile={}", path.display()));
args.insert(0, "-c".into());
}
args.push("--".into());
args.extend(queries);
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let result = (|| -> Result<FxHashSet<String>> {
let mut cmd = git_command(repo_root);
cmd.args(&arg_refs)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let child = cmd.spawn()?;
let output = wait_with_timeout(child, Duration::from_secs(git_timeout()), &arg_refs)?;
if !output.status.success() && output.status.code() != Some(1) {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(GitError::CommandFailed(format!(
"git check-ignore failed: {}",
stderr.trim()
)));
}
let stdout = String::from_utf8_lossy(&output.stdout);
let excludes_source = temp_excludes.as_ref().map(|p| p.display().to_string());
let mut rules: rustc_hash::FxHashMap<String, String> = rustc_hash::FxHashMap::default();
for line in stdout.lines() {
if let Some((rule, path)) = parse_verbose_ignore_match(line) {
rules.insert(path, rule);
}
}
Ok(rel_paths
.iter()
.filter(|rel| match rules.get(*rel) {
None => false,
Some(rule) => {
let from_diffctx = excludes_source
.as_deref()
.is_some_and(|src| rule.starts_with(&format!("{src}:")));
from_diffctx
|| !ancestor_dirs(rel)
.iter()
.any(|dir| rules.get(dir) == Some(rule))
}
})
.cloned()
.collect())
})();
if let Some(path) = temp_excludes {
let _ = std::fs::remove_file(path);
}
result.unwrap_or_default()
}
fn parse_verbose_ignore_match(line: &str) -> Option<(String, String)> {
let (rule, path) = line.rsplit_once('\t')?;
Some((rule.to_string(), unquote_c_style(path)))
}
fn ancestor_dirs(rel: &str) -> Vec<String> {
let mut dirs = Vec::new();
let mut remainder = rel;
while let Some((parent, _)) = remainder.rsplit_once('/') {
dirs.push(parent.to_string());
remainder = parent;
}
dirs
}
pub struct CatFileBatch {
repo_root: PathBuf,
child: Option<Child>,
reader: Option<BufReader<ChildStdout>>,
}
impl CatFileBatch {
pub fn new(repo_root: &Path) -> Result<Self> {
let mut batch = Self {
repo_root: repo_root.to_path_buf(),
child: None,
reader: None,
};
batch.ensure_started()?;
Ok(batch)
}
fn ensure_started(&mut self) -> Result<()> {
let needs_restart = match &mut self.child {
None => true,
Some(child) => child.try_wait().ok().flatten().is_some(),
};
if needs_restart {
let mut child = git_command(&self.repo_root)
.args(["cat-file", "--batch"])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()?;
let stdout = child.stdout.take().ok_or_else(|| {
GitError::CommandFailed("cat-file: failed to capture stdout pipe".into())
})?;
self.reader = Some(BufReader::new(stdout));
self.child = Some(child);
}
Ok(())
}
pub fn get(&mut self, rev: &str, rel_path: &Path) -> Result<String> {
validate_rev(rev)?;
let spec = format!(
"{}:{}\n",
rev,
rel_path.to_string_lossy().replace('\\', "/")
);
self.ensure_started()?;
let stdin = self
.child
.as_mut()
.and_then(|c| c.stdin.as_mut())
.ok_or_else(|| GitError::CommandFailed("cat-file stdin unavailable".into()))?;
stdin.write_all(spec.as_bytes())?;
stdin.flush()?;
let reader = self
.reader
.as_mut()
.ok_or_else(|| GitError::CommandFailed("cat-file stdout unavailable".into()))?;
let mut header_line = String::new();
reader.read_line(&mut header_line)?;
if header_line.is_empty() {
return Err(GitError::CommandFailed(format!(
"cat-file: unexpected EOF for {}",
spec.trim()
)));
}
let header_str = header_line.trim();
if header_str.ends_with("missing") {
return Err(GitError::CommandFailed(format!(
"Path not found: {}",
spec.trim()
)));
}
let parts: Vec<&str> = header_str.split_whitespace().collect();
if parts.len() < 3 {
return Err(GitError::CommandFailed(format!(
"cat-file: malformed header: {}",
header_str
)));
}
let size: usize = parts[2].parse().map_err(|_| {
GitError::CommandFailed(format!("cat-file: invalid size in header: {}", header_str))
})?;
if size > crate::config::limits::MAX_BLOB_READ_BYTES {
let mut remaining = size;
let mut scratch = [0u8; 65536];
while remaining > 0 {
let want = remaining.min(scratch.len());
reader.read_exact(&mut scratch[..want])?;
remaining -= want;
}
let mut trailing = [0u8; 1];
let _ = reader.read_exact(&mut trailing);
return Err(GitError::CommandFailed(format!(
"cat-file: blob too large ({} bytes): {}",
size,
spec.trim()
)));
}
let mut content = vec![0u8; size];
reader.read_exact(&mut content)?;
let mut trailing = [0u8; 1];
let _ = reader.read_exact(&mut trailing);
Ok(String::from_utf8_lossy(&content).into_owned())
}
pub fn close(&mut self) {
self.reader.take();
if let Some(mut child) = self.child.take() {
drop(child.stdin.take());
match child.wait_timeout(Duration::from_secs(GIT.catfile_termination_timeout_seconds)) {
Ok(Some(_)) => {}
_ => {
let _ = child.kill();
let _ = child.wait();
}
}
}
}
}
impl Drop for CatFileBatch {
fn drop(&mut self) {
self.close();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::sync::Barrier;
use tempfile::TempDir;
fn git(dir: &Path, args: &[&str]) {
let status = git_command(dir)
.args(args)
.status()
.unwrap_or_else(|e| panic!("git {args:?}: {e}"));
assert!(status.success(), "git {args:?} failed");
}
fn init_git_repo(dir: &Path) {
git(dir, &["init", "-q", "-b", "main"]);
git(dir, &["config", "user.email", "test@example.com"]);
git(dir, &["config", "user.name", "Test"]);
git(dir, &["config", "commit.gpgsign", "false"]);
}
fn commit_all(dir: &Path, message: &str) {
git(dir, &["add", "-A"]);
git(dir, &["commit", "-q", "-m", message]);
}
fn write_file(root: &Path, rel: &str, content: &str) {
let path = root.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).expect("create parent");
}
fs::write(&path, content).expect("write file");
}
struct HunkShape {
old_start: u32,
old_len: u32,
new_start: u32,
new_len: u32,
}
fn hunk_shapes(hunks: &[DiffHunk]) -> Vec<HunkShape> {
hunks
.iter()
.map(|h| HunkShape {
old_start: h.old_start,
old_len: h.old_len,
new_start: h.new_start,
new_len: h.new_len,
})
.collect()
}
fn basenames(paths: &[PathBuf]) -> Vec<String> {
let mut names: Vec<String> = paths
.iter()
.map(|p| p.file_name().unwrap().to_string_lossy().into_owned())
.collect();
names.sort();
names
}
fn assert_diff_survives_hostile_config(hostile_config: &[&[&str]]) {
let tmp = TempDir::new().expect("tempdir");
let clean_root = tmp.path().join("clean");
let hostile_root = tmp.path().join("hostile");
fs::create_dir_all(&clean_root).expect("mkdir clean");
fs::create_dir_all(&hostile_root).expect("mkdir hostile");
for root in [&clean_root, &hostile_root] {
init_git_repo(root);
write_file(root, "app.py", "def f():\n return 1\n");
commit_all(root, "initial");
write_file(root, "app.py", "def f():\n return 2\n");
commit_all(root, "change");
}
for args in hostile_config {
git(&hostile_root, args);
}
let clean_hunks = parse_diff(&clean_root, Some("HEAD~1..HEAD")).expect("clean parse_diff");
let hostile_hunks =
parse_diff(&hostile_root, Some("HEAD~1..HEAD")).expect("hostile parse_diff");
assert!(
!hostile_hunks.is_empty(),
"hostile git config reduced the diff to zero hunks"
);
assert_eq!(
hunk_shapes(&hostile_hunks)
.iter()
.map(|s| (s.old_start, s.old_len, s.new_start, s.new_len))
.collect::<Vec<_>>(),
hunk_shapes(&clean_hunks)
.iter()
.map(|s| (s.old_start, s.old_len, s.new_start, s.new_len))
.collect::<Vec<_>>(),
"hostile config changed the parsed hunk shape vs a clean-config repo"
);
let clean_files =
get_changed_files(&clean_root, Some("HEAD~1..HEAD")).expect("clean changed files");
let hostile_files =
get_changed_files(&hostile_root, Some("HEAD~1..HEAD")).expect("hostile changed files");
assert!(
!hostile_files.is_empty(),
"hostile git config reduced changed_files to empty"
);
assert_eq!(
basenames(&hostile_files),
basenames(&clean_files),
"hostile config changed the changed_files set vs a clean-config repo"
);
}
#[test]
fn diff_survives_diff_noprefix() {
assert_diff_survives_hostile_config(&[&["config", "diff.noprefix", "true"]]);
}
#[test]
fn diff_survives_diff_mnemonic_prefix() {
assert_diff_survives_hostile_config(&[&["config", "diff.mnemonicPrefix", "true"]]);
}
#[test]
fn diff_survives_custom_src_dst_prefix() {
assert_diff_survives_hostile_config(&[
&["config", "diff.srcPrefix", "x/"],
&["config", "diff.dstPrefix", "y/"],
]);
}
#[test]
fn diff_survives_color_ui_always() {
assert_diff_survives_hostile_config(&[&["config", "color.ui", "always"]]);
}
#[test]
fn validate_diff_range_rejects_option_smuggled_in_range() {
for hostile in ["HEAD..--ext-diff", "a...-p", "..--upload-pack=x"] {
assert!(
validate_diff_range(hostile).is_err(),
"expected {hostile:?} to be rejected"
);
}
}
#[test]
fn validate_diff_range_accepts_legitimate_ranges() {
for legit in [
"HEAD~1..HEAD",
"@{-1}..HEAD",
"HEAD~2...origin/main",
"main..feature/x",
] {
assert!(
validate_diff_range(legit).is_ok(),
"expected {legit:?} to be accepted"
);
}
}
#[test]
fn parse_verbose_ignore_match_splits_on_last_tab_not_first() {
let line = ".gitignore:3:foo\tbar\tsome/real/path.txt";
let (rule, path) = parse_verbose_ignore_match(line).expect("parse");
assert_eq!(path, "some/real/path.txt");
assert_eq!(rule, ".gitignore:3:foo\tbar");
}
#[test]
fn parse_verbose_ignore_match_unquotes_c_style_path() {
let line = ".gitignore:1:*.log\t\"weird\\tfile.log\"";
let (rule, path) = parse_verbose_ignore_match(line).expect("parse");
assert_eq!(path, "weird\tfile.log");
assert_eq!(rule, ".gitignore:1:*.log");
}
#[test]
fn anchor_ignore_line_bare_pattern_at_root() {
assert_eq!(anchor_diffctx_ignore_line("*.log", ""), "*.log");
}
#[test]
fn anchor_ignore_line_bare_pattern_nested() {
assert_eq!(anchor_diffctx_ignore_line("*.log", "sub"), "sub/**/*.log");
}
#[test]
fn anchor_ignore_line_slash_pattern_at_root() {
assert_eq!(
anchor_diffctx_ignore_line("secrets/config.py", ""),
"/secrets/config.py"
);
}
#[test]
fn anchor_ignore_line_slash_pattern_nested() {
assert_eq!(
anchor_diffctx_ignore_line("secrets/config.py", "sub"),
"/sub/secrets/config.py"
);
}
#[test]
fn anchor_ignore_line_negated_bare_pattern() {
assert_eq!(anchor_diffctx_ignore_line("!keep.log", ""), "!keep.log");
}
#[test]
fn anchor_ignore_line_negated_slash_pattern_nested() {
assert_eq!(
anchor_diffctx_ignore_line("!secrets/keep.py", "sub"),
"!/sub/secrets/keep.py"
);
}
#[test]
fn unquote_c_style_decodes_octal_utf8_escapes() {
let quoted = r#""a/caf\303\251.py""#;
assert_eq!(unquote_c_style(quoted), "a/café.py");
}
#[test]
fn unquote_c_style_leaves_unquoted_input_untouched() {
assert_eq!(unquote_c_style("a/plain.py"), "a/plain.py");
}
#[test]
fn parse_path_line_takes_quoted_branch_for_old_and_new_headers() {
let tmp = TempDir::new().expect("tempdir");
let root = tmp.path();
write_file(root, "café.py", "value = 1\n");
let old_line = r#"--- "a/caf\303\251.py""#;
let (kind, path) = parse_path_line(old_line, root);
assert_eq!(kind, "old");
assert_eq!(
path.expect("old path")
.file_name()
.unwrap()
.to_string_lossy(),
"café.py"
);
let new_line = r#"+++ "b/caf\303\251.py""#;
let (kind, path) = parse_path_line(new_line, root);
assert_eq!(kind, "new");
assert_eq!(
path.expect("new path")
.file_name()
.unwrap()
.to_string_lossy(),
"café.py"
);
}
#[test]
fn parse_diff_handles_real_repo_with_default_quoted_unicode_filename() {
let tmp = TempDir::new().expect("tempdir");
let root = tmp.path();
init_git_repo(root);
write_file(root, "café.py", "value = 1\n");
commit_all(root, "initial");
write_file(root, "café.py", "value = 2\n");
commit_all(root, "change");
let hunks = parse_diff(root, Some("HEAD~1..HEAD")).expect("parse_diff");
assert!(
!hunks.is_empty(),
"quoted unicode diff header was not parsed into any hunk"
);
assert!(
hunks.iter().any(|h| h.path.contains("café")),
"no hunk carried the decoded unicode path, got: {:?}",
hunks.iter().map(|h| h.path.as_ref()).collect::<Vec<_>>()
);
}
#[test]
fn wait_with_timeout_kills_long_running_child_and_returns_promptly() {
let child = Command::new("sleep")
.arg("30")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn sleep");
let pid = child.id();
let start = std::time::Instant::now();
let result = wait_with_timeout(child, Duration::from_millis(200), &["sleep", "30"]);
let elapsed = start.elapsed();
assert!(
matches!(result, Err(GitError::Timeout(_))),
"expected Timeout error, got {result:?}"
);
assert!(
elapsed < Duration::from_secs(5),
"wait_with_timeout should return promptly, took {elapsed:?}"
);
let mut still_alive = true;
for _ in 0..20 {
let status = Command::new("kill")
.args(["-0", &pid.to_string()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.expect("spawn kill -0");
if !status.success() {
still_alive = false;
break;
}
std::thread::sleep(Duration::from_millis(50));
}
assert!(!still_alive, "child pid {pid} was not reaped after timeout");
}
#[test]
fn wait_with_timeout_does_not_penalize_fast_commands() {
let child = Command::new("true")
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn true");
let result = wait_with_timeout(child, Duration::from_secs(5), &["true"]);
assert!(matches!(result, Ok(ref out) if out.status.success()));
}
#[test]
fn find_ignored_paths_concurrent_calls_both_see_their_own_ignore_rules() {
let tmp = TempDir::new().expect("tempdir");
let root_a = tmp.path().join("repo_a");
let root_b = tmp.path().join("repo_b");
fs::create_dir_all(&root_a).expect("mkdir a");
fs::create_dir_all(&root_b).expect("mkdir b");
for (root, secret) in [(&root_a, "secret_a.py"), (&root_b, "secret_b.py")] {
init_git_repo(root);
write_file(root, "app.py", "print('hi')\n");
write_file(root, ".diffctx/ignore", &format!("{secret}\n"));
write_file(root, secret, "SECRET\n");
commit_all(root, "initial");
}
for _ in 0..10 {
let barrier = Arc::new(Barrier::new(2));
let root_a_thread = root_a.clone();
let barrier_a = Arc::clone(&barrier);
let handle_a = std::thread::spawn(move || {
barrier_a.wait();
find_ignored_paths(
&root_a_thread,
&["secret_a.py".to_string(), "app.py".to_string()],
)
});
let root_b_thread = root_b.clone();
let barrier_b = Arc::clone(&barrier);
let handle_b = std::thread::spawn(move || {
barrier_b.wait();
find_ignored_paths(
&root_b_thread,
&["secret_b.py".to_string(), "app.py".to_string()],
)
});
let ignored_a = handle_a.join().expect("thread a panicked");
let ignored_b = handle_b.join().expect("thread b panicked");
assert!(
ignored_a.contains("secret_a.py"),
"repo A lost its .diffctx/ignore rule to a concurrent call"
);
assert!(
ignored_b.contains("secret_b.py"),
"repo B lost its .diffctx/ignore rule to a concurrent call"
);
assert!(!ignored_a.contains("app.py"));
assert!(!ignored_b.contains("app.py"));
}
}
}