use serde::Serialize;
use std::error::Error;
use std::fmt;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffTarget<'a> {
WorkingTree,
Refs {
base: &'a str,
head: &'a str,
},
SinceRef {
base: &'a str,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OwnedDiffTarget {
WorkingTree,
Refs { base: String, head: String },
SinceRef { base: String },
}
impl OwnedDiffTarget {
pub fn from_refs(base: Option<&str>, head: Option<&str>) -> Self {
match base {
Some(base) => Self::Refs {
base: base.to_string(),
head: head.unwrap_or("HEAD").to_string(),
},
None => Self::WorkingTree,
}
}
pub fn as_borrowed(&self) -> DiffTarget<'_> {
match self {
Self::WorkingTree => DiffTarget::WorkingTree,
Self::Refs { base, head } => DiffTarget::Refs { base, head },
Self::SinceRef { base } => DiffTarget::SinceRef { base },
}
}
pub fn base_ref(&self) -> Option<&str> {
match self {
Self::WorkingTree => None,
Self::Refs { base, .. } | Self::SinceRef { base } => Some(base),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ChangedFile {
pub path: PathBuf,
pub status: ChangeStatus,
pub ranges: Vec<ChangedRange>,
#[serde(skip)]
pub hunks: Vec<DiffHunk>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffHunk {
pub new_range: Option<ChangedRange>,
pub old_range: Option<ChangedRange>,
pub added_lines: Vec<String>,
pub removed_lines: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ChangeStatus {
Added,
Modified,
Deleted,
Renamed,
Untracked,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct ChangedRange {
pub start: usize,
pub end: usize,
}
#[derive(Debug)]
pub enum GitDiffError {
GitNotFound,
GitCommandFailed { command: String, stderr: String },
Io(io::Error),
}
impl<'a> DiffTarget<'a> {
pub fn from_refs(base: Option<&'a str>, head: Option<&'a str>) -> Self {
match base {
Some(base) => Self::Refs {
base,
head: head.unwrap_or("HEAD"),
},
None => Self::WorkingTree,
}
}
}
impl ChangedFile {
pub fn path_string(&self) -> String {
self.path.to_string_lossy().replace('\\', "/")
}
pub fn contains_line(&self, line: usize) -> bool {
self.ranges
.iter()
.any(|range| line >= range.start && line <= range.end)
}
}
impl fmt::Display for GitDiffError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GitDiffError::GitNotFound => write!(
formatter,
"git executable was not found; `repopilot review` requires git"
),
GitDiffError::GitCommandFailed { command, stderr } => {
let message = stderr.trim();
if message.is_empty() {
write!(formatter, "git command failed: {command}")
} else {
write!(formatter, "git command failed: {command}: {message}")
}
}
GitDiffError::Io(error) => write!(formatter, "{error}"),
}
}
}
impl Error for GitDiffError {}
impl From<io::Error> for GitDiffError {
fn from(error: io::Error) -> Self {
if error.kind() == io::ErrorKind::NotFound {
GitDiffError::GitNotFound
} else {
GitDiffError::Io(error)
}
}
}
pub fn resolve_git_root(path: &Path) -> Result<PathBuf, GitDiffError> {
let cwd = if path.is_file() {
path.parent().unwrap_or_else(|| Path::new("."))
} else {
path
};
let output = git_output(
cwd,
&["rev-parse", "--show-toplevel"],
"git rev-parse --show-toplevel",
)?;
Ok(PathBuf::from(output.trim()))
}
pub fn validate_git_ref(reference: &str) -> Result<(), GitDiffError> {
if reference.starts_with('-') {
return Err(GitDiffError::GitCommandFailed {
command: "validate_git_ref".to_string(),
stderr: format!(
"Invalid git reference: '{}' cannot start with a hyphen",
reference
),
});
}
if reference
.chars()
.any(|c| c.is_whitespace() || c.is_control())
{
return Err(GitDiffError::GitCommandFailed {
command: "validate_git_ref".to_string(),
stderr: format!(
"Invalid git reference: '{}' cannot contain whitespace or control characters",
reference
),
});
}
Ok(())
}
pub fn load_changed_files(
repo_root: &Path,
target: DiffTarget<'_>,
pathspec: Option<&str>,
) -> Result<Vec<ChangedFile>, GitDiffError> {
match target {
DiffTarget::WorkingTree => {}
DiffTarget::Refs { base, head } => {
validate_git_ref(base)?;
validate_git_ref(head)?;
}
DiffTarget::SinceRef { base } => {
validate_git_ref(base)?;
}
}
let mut files = match target {
DiffTarget::WorkingTree => parse_diff(&git_diff_against_head(repo_root, pathspec)?),
DiffTarget::Refs { base, head } => {
parse_diff(&git_diff_between_refs(repo_root, base, head, pathspec)?)
}
DiffTarget::SinceRef { base } => {
parse_diff(&git_diff_since_ref(repo_root, base, pathspec)?)
}
};
if matches!(
target,
DiffTarget::WorkingTree | DiffTarget::SinceRef { .. }
) {
files.extend(load_untracked_files(repo_root, pathspec)?);
}
files.retain(|file| !is_repopilot_internal_path(&file.path));
files.sort_by(|left, right| left.path.cmp(&right.path));
Ok(files)
}
pub(crate) fn git_show(repo_root: &Path, reference: &str, path: &str) -> Option<String> {
validate_git_ref(reference).ok()?;
let spec = format!("{reference}:{path}");
git_output(repo_root, &["show", spec.as_str()], "git show").ok()
}
pub fn parse_diff(diff: &str) -> Vec<ChangedFile> {
let mut files = Vec::new();
let mut current: Option<ChangedFile> = None;
let mut hunk: Option<DiffHunk> = None;
for line in diff.lines() {
if let Some((_, new_path)) = parse_diff_git_line(line) {
if let Some(file) = current.take() {
files.push(finalize_file(file, hunk.take()));
}
current = Some(ChangedFile {
path: PathBuf::from(new_path),
status: ChangeStatus::Modified,
ranges: Vec::new(),
hunks: Vec::new(),
});
continue;
}
let Some(file) = current.as_mut() else {
continue;
};
if line.starts_with("new file mode ") {
file.status = ChangeStatus::Added;
continue;
}
if line.starts_with("deleted file mode ") {
file.status = ChangeStatus::Deleted;
continue;
}
if line.starts_with("rename from ") {
file.status = ChangeStatus::Renamed;
continue;
}
if let Some(path) = line.strip_prefix("+++ ") {
if let Some(path) = normalize_diff_path(path)
&& file.status != ChangeStatus::Deleted
{
file.path = PathBuf::from(path);
}
continue;
}
if let Some(path) = line.strip_prefix("--- ") {
if let Some(path) = normalize_diff_path(path)
&& file.status == ChangeStatus::Deleted
{
file.path = PathBuf::from(path);
}
continue;
}
if line.starts_with("@@") {
if let Some(done) = hunk.take() {
file.hunks.push(done);
}
hunk = Some(DiffHunk {
new_range: parse_hunk_added_range(line),
old_range: parse_hunk_removed_range(line),
added_lines: Vec::new(),
removed_lines: Vec::new(),
});
continue;
}
if let Some(active) = hunk.as_mut() {
if let Some(added) = line.strip_prefix('+') {
active.added_lines.push(added.to_string());
} else if let Some(removed) = line.strip_prefix('-') {
active.removed_lines.push(removed.to_string());
}
}
}
if let Some(file) = current {
files.push(finalize_file(file, hunk.take()));
}
files
}
fn finalize_file(mut file: ChangedFile, trailing: Option<DiffHunk>) -> ChangedFile {
if let Some(done) = trailing {
file.hunks.push(done);
}
file.ranges = file
.hunks
.iter()
.filter_map(|hunk| hunk.new_range)
.collect();
file
}
include!("diff/helpers.rs");
#[cfg(test)]
mod tests;