use std::io::{BufRead, Read, Write};
pub(crate) fn read_git_show_files_batch(
cwd: Option<&std::path::Path>,
head: &str,
paths: Vec<String>,
) -> anyhow::Result<Vec<(String, String)>> {
let mut command = std::process::Command::new("git");
command
.args(["cat-file", "--batch"])
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
if let Some(cwd) = cwd {
command.current_dir(cwd);
}
let mut child = command
.spawn()
.map_err(|source| anyhow::anyhow!("failed to start git cat-file --batch: {source}"))?;
let mut stdin = child
.stdin
.take()
.expect("stdin is piped, so it must be present");
let stdout = child
.stdout
.take()
.expect("stdout is piped, so it must be present");
let stderr = child
.stderr
.take()
.expect("stderr is piped, so it must be present");
let mut reader = std::io::BufReader::new(stdout);
let stderr_reader = std::thread::spawn(move || {
let mut stderr = stderr;
let mut buf = Vec::new();
let _ = stderr.read_to_end(&mut buf);
buf
});
let pump_result = pump_cat_file_batch_requests(&mut stdin, &mut reader, head, paths);
drop(stdin);
let status = child
.wait()
.map_err(|source| anyhow::anyhow!("failed to wait on git cat-file --batch: {source}"))?;
let stderr_output = stderr_reader
.join()
.unwrap_or_else(|_| b"<failed to read stderr: reader thread panicked>".to_vec());
combine_cat_file_batch_result(pump_result, &status, &stderr_output)
}
fn pump_cat_file_batch_requests(
stdin: &mut std::process::ChildStdin,
reader: &mut impl BufRead,
head: &str,
paths: Vec<String>,
) -> anyhow::Result<Vec<(String, String)>> {
let mut files = Vec::with_capacity(paths.len());
for path in paths {
let object = format!("{head}:{path}");
writeln!(stdin, "{object}").map_err(|source| {
anyhow::anyhow!("failed to write to git cat-file --batch: {source}")
})?;
stdin.flush().map_err(|source| {
anyhow::anyhow!("failed to flush git cat-file --batch stdin: {source}")
})?;
match read_cat_file_batch_response(reader, &object)? {
Some(content) => files.push((path, content)),
None => continue,
}
}
Ok(files)
}
fn combine_cat_file_batch_result(
pump_result: anyhow::Result<Vec<(String, String)>>,
status: &std::process::ExitStatus,
stderr_output: &[u8],
) -> anyhow::Result<Vec<(String, String)>> {
match (pump_result, status.success()) {
(Ok(files), true) => Ok(files),
(Ok(_), false) => Err(anyhow::anyhow!(
"git cat-file --batch exited with {status}: {}",
String::from_utf8_lossy(stderr_output)
)),
(Err(pump_error), false) => Err(anyhow::anyhow!(
"git cat-file --batch exited with {status}: {} (pump error: {pump_error})",
String::from_utf8_lossy(stderr_output)
)),
(Err(pump_error), true) => Err(pump_error),
}
}
fn read_cat_file_batch_response(
reader: &mut impl BufRead,
object: &str,
) -> anyhow::Result<Option<String>> {
let mut header = String::new();
reader.read_line(&mut header).map_err(|source| {
anyhow::anyhow!("failed to read git cat-file --batch header: {source}")
})?;
let header = header.trim_end_matches('\n');
let Some(size) = header
.rsplit(' ')
.next()
.and_then(|s| s.parse::<usize>().ok())
else {
return Ok(None);
};
let mut content = vec![0u8; size];
reader.read_exact(&mut content).map_err(|source| {
anyhow::anyhow!("failed to read git cat-file --batch content for {object}: {source}")
})?;
let mut trailing_newline = [0u8; 1];
reader.read_exact(&mut trailing_newline).map_err(|source| {
anyhow::anyhow!(
"failed to read git cat-file --batch trailing newline for {object}: {source}"
)
})?;
match String::from_utf8(content) {
Ok(content) => Ok(Some(content)),
Err(_) => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::{init_repo_with_committed_file, run_git};
use pretty_assertions::assert_eq;
#[test]
fn should_read_every_path_via_a_single_cat_file_batch_process() {
let dir = tempfile::TempDir::new().expect("create tempdir");
run_git(dir.path(), &["init", "--initial-branch=main"]);
run_git(dir.path(), &["config", "user.email", "test@example.com"]);
run_git(dir.path(), &["config", "user.name", "Test"]);
std::fs::write(dir.path().join("a.rs"), "fn a() {}\n").expect("write a.rs");
std::fs::write(dir.path().join("b.rs"), "fn b() {}\n").expect("write b.rs");
run_git(dir.path(), &["add", "a.rs", "b.rs"]);
run_git(dir.path(), &["commit", "-m", "initial commit"]);
let mut actual = read_git_show_files_batch(
Some(dir.path()),
"HEAD",
vec!["a.rs".to_string(), "b.rs".to_string()],
)
.expect("git cat-file --batch should succeed for tracked files");
actual.sort();
assert_eq!(
vec![
("a.rs".to_string(), "fn a() {}\n".to_string()),
("b.rs".to_string(), "fn b() {}\n".to_string()),
],
actual
);
}
#[test]
fn should_read_committed_content_via_batch_when_working_tree_is_dirty() {
let dir = tempfile::TempDir::new().expect("create tempdir");
let committed = "fn foo(a: i32) -> i32 {\n a\n}\n";
init_repo_with_committed_file(dir.path(), committed);
std::fs::write(
dir.path().join("src/lib.rs"),
"fn foo(a: i32) -> i32 {\n a + 999\n}\n",
)
.expect("dirty the working tree");
let actual =
read_git_show_files_batch(Some(dir.path()), "HEAD", vec!["src/lib.rs".to_string()])
.expect("git cat-file --batch should succeed for a committed file");
assert_eq!(
vec![("src/lib.rs".to_string(), committed.to_string())],
actual
);
}
#[test]
fn should_skip_missing_paths_when_reading_via_batch() {
let dir = tempfile::TempDir::new().expect("create tempdir");
init_repo_with_committed_file(dir.path(), "fn foo() {}\n");
let actual = read_git_show_files_batch(
Some(dir.path()),
"HEAD",
vec!["src/lib.rs".to_string(), "does/not/exist.rs".to_string()],
)
.expect("git cat-file --batch should succeed even with a missing path");
assert_eq!(
vec![("src/lib.rs".to_string(), "fn foo() {}\n".to_string())],
actual
);
}
#[test]
fn should_skip_non_utf8_content_when_reading_via_batch() {
let dir = tempfile::TempDir::new().expect("create tempdir");
run_git(dir.path(), &["init", "--initial-branch=main"]);
run_git(dir.path(), &["config", "user.email", "test@example.com"]);
run_git(dir.path(), &["config", "user.name", "Test"]);
std::fs::write(dir.path().join("text.rs"), "fn ok() {}\n").expect("write text.rs");
std::fs::write(dir.path().join("binary.dat"), [0xff_u8, 0xfe, 0x00, 0x01])
.expect("write binary.dat");
run_git(dir.path(), &["add", "text.rs", "binary.dat"]);
run_git(dir.path(), &["commit", "-m", "initial commit"]);
let mut actual = read_git_show_files_batch(
Some(dir.path()),
"HEAD",
vec!["text.rs".to_string(), "binary.dat".to_string()],
)
.expect("git cat-file --batch should succeed even with binary content present");
actual.sort();
assert_eq!(
vec![("text.rs".to_string(), "fn ok() {}\n".to_string())],
actual
);
}
mod read_cat_file_batch_response_tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn should_return_content_when_response_is_found() {
let mut reader = std::io::Cursor::new(b"abc123 blob 5\nhello\n".to_vec());
let actual = read_cat_file_batch_response(&mut reader, "HEAD:a.rs")
.expect("a well-formed found response must parse");
assert_eq!(Some("hello".to_string()), actual);
}
#[test]
fn should_return_none_when_response_is_missing() {
let mut reader = std::io::Cursor::new(b"HEAD:a.rs missing\n".to_vec());
let actual = read_cat_file_batch_response(&mut reader, "HEAD:a.rs")
.expect("a missing response must not be a hard error");
assert_eq!(None, actual);
}
#[test]
fn should_return_none_when_response_is_ambiguous() {
let mut reader = std::io::Cursor::new(b"abc1 ambiguous\n".to_vec());
let actual = read_cat_file_batch_response(&mut reader, "abc1")
.expect("an ambiguous response must not be a hard error");
assert_eq!(None, actual);
}
#[test]
fn should_return_none_when_response_is_a_submodule_entry() {
let mut reader = std::io::Cursor::new(
b"3eb8e680cc28d03641be1d2af8e098e8ac6a42f8 submodule\n".to_vec(),
);
let actual = read_cat_file_batch_response(&mut reader, "HEAD:sub")
.expect("a submodule response must not be a hard error");
assert_eq!(None, actual);
}
#[test]
fn should_return_none_when_found_content_is_not_valid_utf8() {
let mut reader = std::io::Cursor::new(
[
b"abc123 blob 4\n".as_slice(),
&[0xff, 0xfe, 0x00, 0x01],
b"\n",
]
.concat(),
);
let actual = read_cat_file_batch_response(&mut reader, "HEAD:binary.dat")
.expect("non-UTF-8 content must not be a hard error");
assert_eq!(None, actual);
}
#[test]
fn should_return_error_when_content_is_truncated() {
let mut reader = std::io::Cursor::new(b"abc123 blob 100\nshort\n".to_vec());
let actual = read_cat_file_batch_response(&mut reader, "HEAD:a.rs");
assert!(actual.is_err());
}
}
mod combine_cat_file_batch_result_tests {
use super::*;
use pretty_assertions::assert_eq;
use std::os::unix::process::ExitStatusExt;
fn exit_status(code: i32) -> std::process::ExitStatus {
std::process::ExitStatus::from_raw(code)
}
#[test]
fn should_return_files_when_pump_succeeds_and_status_succeeds() {
let pump_result = Ok(vec![("a.rs".to_string(), "fn a() {}\n".to_string())]);
let actual = combine_cat_file_batch_result(pump_result, &exit_status(0), b"")
.expect("a successful pump and exit status must not error");
assert_eq!(
vec![("a.rs".to_string(), "fn a() {}\n".to_string())],
actual
);
}
#[test]
fn should_return_stderr_error_when_pump_succeeds_and_status_fails() {
let pump_result = Ok(vec![]);
let actual = combine_cat_file_batch_result(
pump_result,
&exit_status(1 << 8),
b"fatal: not a git repository (or any of the parent directories): .git\n",
);
let message = actual
.expect_err("a failing exit status must be an error")
.to_string();
assert!(
message.contains(
"fatal: not a git repository (or any of the parent directories): .git"
),
"expected the stderr text in the error message, got: {message:?}"
);
}
#[test]
fn should_prefer_stderr_error_over_pump_error_when_both_fail() {
let pump_result: anyhow::Result<Vec<(String, String)>> = Err(anyhow::anyhow!(
"failed to write to git cat-file --batch: Broken pipe (os error 32)"
));
let actual = combine_cat_file_batch_result(
pump_result,
&exit_status(128 << 8),
b"fatal: not a git repository (or any of the parent directories): .git\n",
);
let message = actual
.expect_err("a failing pump and a failing exit status must be an error")
.to_string();
assert!(
message.starts_with("git cat-file --batch exited with"),
"expected the stderr-derived message to be primary, got: {message:?}"
);
assert!(
message.contains(
"fatal: not a git repository (or any of the parent directories): .git"
),
"expected the stderr text in the error message, got: {message:?}"
);
assert!(
message.contains("Broken pipe"),
"expected the pump error to be folded in as extra detail, got: {message:?}"
);
}
#[test]
fn should_return_pump_error_unchanged_when_pump_fails_and_status_succeeds() {
let pump_result: anyhow::Result<Vec<(String, String)>> = Err(anyhow::anyhow!(
"failed to read git cat-file --batch header: unexpected EOF"
));
let actual = combine_cat_file_batch_result(pump_result, &exit_status(0), b"");
let message = actual
.expect_err("a failing pump must be an error even if the exit status succeeded")
.to_string();
assert_eq!(
"failed to read git cat-file --batch header: unexpected EOF",
message
);
}
}
#[test]
fn should_include_stderr_in_error_when_git_cat_file_batch_exits_non_zero() {
let dir = tempfile::TempDir::new().expect("create tempdir");
let actual = read_git_show_files_batch(Some(dir.path()), "HEAD", vec!["a.rs".to_string()]);
let error = actual.expect_err("a non-git cwd must fail rather than silently succeed");
let message = error.to_string();
assert!(
message.contains("not a git repository"),
"expected the child's stderr to be included in the error, got: {message:?}"
);
}
}