mod batch;
use super::*;
use crate::tools::process::{
BoundedChildProcessLimits, BoundedChildProcessOutput, run_bounded_child_process,
};
use std::{
collections::BTreeMap,
io::Read,
process::{Command, Stdio},
time::Duration,
};
fn run(
cwd: &Path,
args: &[&str],
limit: usize,
options: &ReviewReadOptions<'_>,
) -> Result<BoundedChildProcessOutput, String> {
run_with_input(cwd, args, limit, options, Stdio::null())
}
fn run_with_input(
cwd: &Path,
args: &[&str],
limit: usize,
options: &ReviewReadOptions<'_>,
input: Stdio,
) -> Result<BoundedChildProcessOutput, String> {
let timeout = options.remaining()?.min(Duration::from_secs(3));
let mut command = Command::new("git");
command
.current_dir(cwd)
.args([
"--no-pager",
"--literal-pathspecs",
"-c",
"core.fsmonitor=false",
"-c",
"diff.external=",
"-c",
"core.quotePath=false",
])
.args(args)
.env("GIT_OPTIONAL_LOCKS", "0")
.env_remove("GIT_EXTERNAL_DIFF")
.env("LC_ALL", "C")
.env_remove("GIT_DIR")
.env_remove("GIT_WORK_TREE")
.env_remove("GIT_INDEX_FILE")
.env_remove("GIT_COMMON_DIR")
.stdin(input)
.stdout(Stdio::piped())
.stderr(Stdio::piped());
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.process_group(0);
}
let output = run_bounded_child_process(
command.spawn().map_err(|e| e.to_string())?,
BoundedChildProcessLimits {
stdout_max_bytes: limit,
stderr_max_bytes: 4096,
timeout,
poll_interval: Duration::from_millis(10),
},
options.cancellation,
)
.map_err(|e| e.to_string())?;
options.check()?;
if output.timed_out {
return Err("Git review timed out".into());
}
Ok(output)
}
fn success(output: BoundedChildProcessOutput) -> Result<String, String> {
if !output.status.is_some_and(|s| s.success()) {
return Err(format!("Git review failed: {}", output.stderr.trim()));
}
if output.stdout_truncated {
return Err("Git review path list exceeds size limit".into());
}
if output.stdout.contains('\u{fffd}') {
return Err("Git review omitted non-UTF-8 data".into());
}
Ok(output.stdout)
}
pub(super) fn root(cwd: &Path, options: &ReviewReadOptions<'_>) -> Result<Option<PathBuf>, String> {
let output = run(cwd, &["rev-parse", "--show-toplevel"], 16 * 1024, options)?;
if !output.status.is_some_and(|s| s.success()) {
if output.stderr.contains("not a git repository")
|| output.stderr.contains("must be run in a work tree")
{
return Ok(None);
}
return Err(format!(
"Cannot find Git worktree: {}",
output.stderr.trim()
));
}
let root = success(output)?;
std::fs::canonicalize(root.trim_end_matches('\n'))
.map(Some)
.map_err(|e| e.to_string())
}
#[cfg(test)]
pub(super) fn files(
root: &Path,
options: &ReviewReadOptions<'_>,
) -> Result<Vec<ReviewFile>, String> {
files_cached(root, options, &mut SnapshotCache::default())
}
pub(super) fn files_cached(
root: &Path,
options: &ReviewReadOptions<'_>,
cache: &mut SnapshotCache,
) -> Result<Vec<ReviewFile>, String> {
let head = run(
root,
&["rev-parse", "--verify", "--quiet", "HEAD"],
1024,
options,
)?;
let head = if head.status.is_some_and(|s| s.success()) {
success(head)?
} else {
String::new()
};
if cache.root != root || cache.head != head {
cache.files.clear();
cache.root = root.to_owned();
cache.head = head.clone();
}
let mut paths = BTreeMap::new();
if !head.is_empty() {
let names = success(run(
root,
&[
"diff",
"--no-ext-diff",
"--no-textconv",
"--no-renames",
"--name-status",
"-z",
"HEAD",
"--",
],
MAX_SOURCE,
options,
)?)?;
let mut parts = names.split_terminator('\0');
while let Some(status) = parts.next() {
options.check()?;
let path = parts.next().ok_or("Malformed Git diff path list")?;
paths.insert(path.to_owned(), status != "A");
}
}
let args: &[&str] = if !head.is_empty() {
&["ls-files", "--others", "--exclude-standard", "-z"]
} else {
&[
"ls-files",
"--cached",
"--others",
"--exclude-standard",
"-z",
]
};
for path in success(run(root, args, MAX_SOURCE, options)?)?.split_terminator('\0') {
options.check()?;
paths.entry(path.to_owned()).or_insert(false);
}
let omitted = paths.len().saturating_sub(MAX_FILES);
let originals = batch::read_originals(root, &head, &paths, cache, options)?;
let mut budget = MAX_TOTAL_SOURCE;
let mut files = Vec::new();
for (path, has_original) in paths.into_iter().take(MAX_FILES) {
options.check()?;
let file = read_file_cached(
root,
&path,
has_original,
(budget / 2).min(MAX_SOURCE),
options,
cache
.files
.iter()
.find(|file| file.path == path && file.notice.is_none()),
originals.get(&path).map(String::as_str),
);
options.check()?;
budget = budget.saturating_sub(file.original.len() + file.current.len());
files.push(file);
}
if omitted > 0 {
files.push(notice_file(
"[review limit]",
format!("{omitted} files omitted; review limit is {MAX_FILES} files"),
));
}
cache.files = files.clone();
Ok(files)
}
pub(super) fn notice_file(path: &str, notice: String) -> ReviewFile {
ReviewFile {
path: path.into(),
original: String::new(),
current: String::new(),
rows: Vec::new(),
notice: Some(notice),
}
}
pub(super) fn read_file(
root: &Path,
path: &str,
has_original: bool,
limit: usize,
options: &ReviewReadOptions<'_>,
) -> ReviewFile {
read_file_cached(root, path, has_original, limit, options, None, None)
}
fn read_file_cached(
root: &Path,
path: &str,
has_original: bool,
limit: usize,
options: &ReviewReadOptions<'_>,
previous: Option<&ReviewFile>,
batch_original: Option<&str>,
) -> ReviewFile {
let mut file = notice_file(path, String::new());
file.notice = None;
if limit == 0 {
file.notice = Some("Source omitted: snapshot size limit".into());
return file;
}
let original = previous
.map(|file| file.original.as_str())
.or(batch_original);
match read_sources(root, path, has_original, limit, options, original) {
Ok((original, current)) => {
file.original = original;
file.current = current;
}
Err(error) => {
file.notice = Some(error);
return file;
}
}
if let Some(previous) = previous
&& previous.original == file.original
&& previous.current == file.current
{
return previous.clone();
}
let remaining = match options.remaining() {
Ok(remaining) => remaining,
Err(error) => return notice_file(path, error),
};
let diff = similar::TextDiff::configure()
.timeout(remaining.min(Duration::from_millis(30)))
.diff_lines(&file.original, &file.current);
for change in diff.iter_all_changes() {
if let Err(error) = options.check() {
return notice_file(path, error);
}
file.rows.push(ReviewLine {
old_line: change.old_index().map(|n| n + 1),
new_line: change.new_index().map(|n| n + 1),
text: change.value().trim_end_matches('\n').to_owned(),
kind: match change.tag() {
similar::ChangeTag::Equal => ReviewLineKind::Context,
similar::ChangeTag::Insert => ReviewLineKind::Added,
similar::ChangeTag::Delete => ReviewLineKind::Removed,
},
});
}
file
}
fn read_sources(
root: &Path,
path: &str,
has_original: bool,
limit: usize,
options: &ReviewReadOptions<'_>,
cached_original: Option<&str>,
) -> Result<(String, String), String> {
options.check()?;
validate_path(path)?;
let original = if let Some(original) = cached_original.filter(|_| has_original) {
if original.len() > limit {
return Err("Source omitted: original exceeds size limit".into());
}
original.to_owned()
} else if has_original {
let output = run(
root,
&[
"show",
"--no-ext-diff",
"--no-textconv",
&format!("HEAD:{path}"),
],
limit,
options,
)?;
if output.stdout_truncated {
return Err("Source omitted: original exceeds size limit".into());
}
if !output.status.is_some_and(|s| s.success())
&& (output.stderr.contains("does not exist in 'HEAD'")
|| output.stderr.contains("exists on disk, but not in 'HEAD'")
|| output.stderr.contains("invalid object name 'HEAD'"))
{
String::new()
} else {
success(output)?
}
} else {
String::new()
};
options.check()?;
let dir = cap_std::fs::Dir::open_ambient_dir(root, cap_std::ambient_authority())
.map_err(|e| e.to_string())?;
let current = match dir.symlink_metadata(path) {
Err(e) if e.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(e) => return Err(e.to_string()),
Ok(metadata) => {
if !metadata.is_file() {
return Err("Source omitted: symlink, submodule, or non-regular file".into());
}
let mut bytes = Vec::new();
let mut open_options = cap_std::fs::OpenOptions::new();
open_options.read(true);
#[cfg(unix)]
{
use cap_std::fs::OpenOptionsExt;
open_options.custom_flags(libc::O_NONBLOCK | libc::O_NOFOLLOW);
}
let handle = dir
.open_with(path, &open_options)
.map_err(|e| e.to_string())?;
if !handle.metadata().map_err(|e| e.to_string())?.is_file() {
return Err("Source omitted: non-regular file".into());
}
let mut handle = handle.take(limit as u64 + 1);
let mut buffer = [0; 8192];
loop {
options.check()?;
let count = handle.read(&mut buffer).map_err(|e| e.to_string())?;
if count == 0 {
break;
}
bytes.extend_from_slice(&buffer[..count]);
}
if bytes.len() > limit {
return Err("Source omitted: current exceeds size limit".into());
}
String::from_utf8(bytes).map_err(|_| "Source omitted: non-UTF-8 file".to_owned())?
}
};
options.check()?;
if original.contains('\0') || current.contains('\0') || original.contains('\u{fffd}') {
return Err("Source omitted: binary or non-UTF-8 file".into());
}
Ok((original, current))
}
pub(super) fn validate_path(path: &str) -> Result<(), String> {
if path.is_empty()
|| !Path::new(path)
.components()
.all(|c| matches!(c, std::path::Component::Normal(_)))
{
return Err("Invalid review file path".into());
}
Ok(())
}
#[cfg(all(test, unix))]
mod cancellation_tests {
use super::*;
#[test]
fn diff_git_process_stops_on_cancellation_and_shared_deadline() {
let directory = tempfile::tempdir().unwrap();
let (cancellation, handle) = AgentCancellation::default().child_token();
let canceler = std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(50));
handle.cancel();
});
let started = Instant::now();
let error = run(
directory.path(),
&["-c", "alias.review-wait=!sleep 10", "review-wait"],
1024,
&ReviewReadOptions::new(&cancellation),
)
.unwrap_err();
canceler.join().unwrap();
assert!(error.contains("canceled"), "{error}");
assert!(started.elapsed() < Duration::from_secs(2));
let cancellation = AgentCancellation::default();
let options = ReviewReadOptions {
cancellation: &cancellation,
deadline: Instant::now() + Duration::from_millis(50),
};
let started = Instant::now();
let error = run(
directory.path(),
&["-c", "alias.review-wait=!sleep 10", "review-wait"],
1024,
&options,
)
.unwrap_err();
assert!(error.contains("deadline"), "{error}");
assert!(started.elapsed() < Duration::from_secs(2));
}
}