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())
}
pub(super) fn files_cached(
root: &Path,
options: &ReviewReadOptions<'_>,
cache: &mut SnapshotCache,
) -> Result<Vec<ReviewFile>, String> {
let (captured, fingerprint) = capture_files(root, options, cache)?;
let mut files = Vec::with_capacity(captured.len());
for file in captured {
let previous = cache
.files
.iter()
.find(|previous| previous.path == file.path && previous.notice.is_none());
files.push(prepare_file_rows(file, previous, options));
}
cache.files = files.clone();
cache.fingerprint = fingerprint;
Ok(files)
}
fn capture_files(
root: &Path,
options: &ReviewReadOptions<'_>,
cache: &mut SnapshotCache,
) -> Result<(Vec<ReviewFile>, (usize, u64)), String> {
use std::hash::{Hash, Hasher};
let (head, paths) = changed_paths(root, options)?;
if cache.root != root || cache.head != head {
cache.files.clear();
cache.root = root.to_owned();
cache.head = head.clone();
}
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();
let mut fingerprint = std::collections::hash_map::DefaultHasher::new();
let count = paths.len();
for (index, (path, change)) in paths.into_iter().enumerate() {
options.check()?;
path.hash(&mut fingerprint);
change.hash(&mut fingerprint);
if index >= MAX_FILES {
continue;
}
let mut file = capture_file_sources(
root,
&path,
change != ReviewFileChange::Written,
(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),
);
file.change = Some(change);
options.check()?;
budget = budget.saturating_sub(file.original.len() + file.current.len());
file.original.hash(&mut fingerprint);
file.current.hash(&mut fingerprint);
file.notice.hash(&mut fingerprint);
files.push(file);
}
if omitted > 0 {
files.push(notice_file(
"[review limit]",
format!("{omitted} files omitted; review limit is {MAX_FILES} files"),
));
}
let fingerprint = if count == 0 { 0 } else { fingerprint.finish() };
Ok((files, (count, fingerprint)))
}
pub(super) fn changed_paths(
root: &Path,
options: &ReviewReadOptions<'_>,
) -> Result<(String, BTreeMap<String, ReviewFileChange>), 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()
};
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 dir = cap_std::fs::Dir::open_ambient_dir(root, cap_std::ambient_authority())
.map_err(|e| e.to_string())?;
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")?;
let change = match status {
"A" => ReviewFileChange::Written,
"D" => {
validate_path(path)?;
match dir.symlink_metadata(path) {
Ok(_) => ReviewFileChange::Edited,
Err(error)
if matches!(
error.kind(),
std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
) =>
{
ReviewFileChange::Deleted
}
Err(error) => return Err(error.to_string()),
}
}
_ => ReviewFileChange::Edited,
};
paths.insert(path.to_owned(), change);
}
}
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(ReviewFileChange::Written);
}
Ok((head, paths))
}
pub(super) fn change_fingerprint(
root: &Path,
options: &ReviewReadOptions<'_>,
) -> Result<(usize, u64), String> {
capture_files(root, options, &mut SnapshotCache::default()).map(|(_, fingerprint)| fingerprint)
}
pub(super) fn notice_file(path: &str, notice: String) -> ReviewFile {
ReviewFile {
path: path.into(),
change: None,
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 {
let file = capture_file_sources(root, path, has_original, limit, options, None, None);
prepare_file_rows(file, None, options)
}
fn capture_file_sources(
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;
}
}
file
}
fn prepare_file_rows(
mut file: ReviewFile,
previous: Option<&ReviewFile>,
options: &ReviewReadOptions<'_>,
) -> ReviewFile {
if file.notice.is_some() {
return file;
}
if let Some(previous) = previous
&& previous.original == file.original
&& previous.current == file.current
&& previous.change == file.change
{
return previous.clone();
}
let remaining = match options.remaining() {
Ok(remaining) => remaining,
Err(error) => {
return ReviewFile {
change: file.change,
..notice_file(&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 ReviewFile {
change: file.change,
..notice_file(&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(())
}