architecture_component!(Diff);
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, thiserror::Error)]
pub enum DiffError {
#[error("--diff requires a Git or Jujutsu repository, but none was found")]
RepoNotFound,
#[error(
"{name} CLI ('{binary}') was not found in PATH. Please install it or ensure it is accessible."
)]
CliNotFound {
name: &'static str,
binary: &'static str,
},
#[error("failed to run {binary} CLI: {source}")]
CliExecution {
binary: &'static str,
#[source]
source: std::io::Error,
},
#[error("{binary} diff failed: {message}")]
CommandFailed {
binary: &'static str,
message: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VcsType {
Git,
Jujutsu,
}
pub type ChangedLines = HashMap<PathBuf, HashSet<usize>>;
pub type DetectedDiff = (VcsType, String, ChangedLines);
pub fn detect_vcs_diff(custom_rev: Option<&str>) -> Result<DetectedDiff, DiffError> {
let Repository {
root: repo_root,
vcs_type,
} = find_repo_root().ok_or(DiffError::RepoNotFound)?;
let resolved_rev = match vcs_type {
VcsType::Jujutsu => custom_rev.unwrap_or("immutable().."),
VcsType::Git => custom_rev.unwrap_or("HEAD"),
};
let diff_content = match vcs_type {
VcsType::Jujutsu => get_jj_diff(&repo_root, resolved_rev)?,
VcsType::Git => get_git_diff(&repo_root, resolved_rev)?,
};
let parsed_diff = parse_git_diff(&diff_content);
let mut absolute_diff = HashMap::new();
for (rel_path, lines) in parsed_diff {
let abs_path = repo_root.join(rel_path);
absolute_diff.insert(abs_path, lines);
}
Ok((vcs_type, resolved_rev.to_string(), absolute_diff))
}
struct Repository {
root: PathBuf,
vcs_type: VcsType,
}
#[must_use]
fn find_repo_root() -> Option<Repository> {
let mut dir = std::env::current_dir().ok()?;
loop {
if dir.join(".jj").is_dir() {
return Some(Repository {
root: dir,
vcs_type: VcsType::Jujutsu,
});
}
if dir.join(".git").exists() {
return Some(Repository {
root: dir,
vcs_type: VcsType::Git,
});
}
if !dir.pop() {
break;
}
}
None
}
const JJ_BINARY: &str = "jj";
const GIT_BINARY: &str = "git";
fn get_jj_diff(repo_root: &Path, rev: &str) -> Result<String, DiffError> {
let mut cmd = Command::new(JJ_BINARY);
cmd.current_dir(repo_root).arg("diff").arg("--git");
if is_complex_jj_revset(rev) {
cmd.arg("-r").arg(rev);
} else {
cmd.arg("--from").arg(rev);
}
run_vcs_command(cmd, "Jujutsu", JJ_BINARY)
}
fn is_complex_jj_revset(rev: &str) -> bool {
rev.contains("..")
|| rev.contains("::")
|| rev.contains('(')
|| rev.contains(')')
|| rev.chars().any(|c| matches!(c, '|' | '&' | '~' | ' '))
}
fn get_git_diff(repo_root: &Path, rev: &str) -> Result<String, DiffError> {
let mut cmd = Command::new(GIT_BINARY);
cmd.current_dir(repo_root).args([
"-c",
"core.quotepath=false",
"diff", "--src-prefix=a/",
"--dst-prefix=b/",
rev,
]);
run_vcs_command(cmd, "Git", GIT_BINARY)
}
fn run_vcs_command(
mut cmd: Command,
name: &'static str,
binary: &'static str,
) -> Result<String, DiffError> {
let output = cmd.output().map_err(|error| {
if error.kind() == std::io::ErrorKind::NotFound {
DiffError::CliNotFound { name, binary }
} else {
DiffError::CliExecution {
binary,
source: error,
}
}
})?;
if !output.status.success() {
let error_message = String::from_utf8_lossy(&output.stderr).trim().to_string();
return Err(DiffError::CommandFailed {
binary,
message: error_message,
});
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
#[must_use]
fn parse_git_diff(content: &str) -> HashMap<PathBuf, HashSet<usize>> {
let mut changed_lines = HashMap::new();
let mut current_file: Option<(PathBuf, HashSet<usize>)> = None;
let mut line_counter = 0;
let mut is_deleted = false;
let mut expecting_file_header = true;
for line in content.lines() {
if line.starts_with("diff --git") {
commit_file_changes(&mut current_file, is_deleted, &mut changed_lines);
is_deleted = false;
line_counter = 0;
expecting_file_header = true;
} else if line.starts_with("+++ ") && expecting_file_header {
if let Some(parsed_path) = extract_diff_header_path(line) {
current_file = Some((parsed_path, HashSet::new()));
} else {
is_deleted = true;
}
expecting_file_header = false;
} else if line.starts_with("@@ ") {
line_counter = parse_hunk_header(line).unwrap_or(0);
} else if let Some((_, lines)) = current_file.as_mut() {
if is_deleted || line_counter == 0 {
continue;
}
if line.starts_with('+') {
lines.insert(line_counter);
line_counter += 1;
} else if line.starts_with(' ') || line.is_empty() {
line_counter += 1;
}
}
}
commit_file_changes(&mut current_file, is_deleted, &mut changed_lines);
changed_lines
}
fn commit_file_changes(
current_file: &mut Option<(PathBuf, HashSet<usize>)>,
is_deleted: bool,
changed_lines: &mut ChangedLines,
) {
if let Some((path, lines)) = current_file.take()
&& !is_deleted
&& !lines.is_empty()
{
changed_lines.insert(path, lines);
}
}
fn extract_diff_header_path(line: &str) -> Option<PathBuf> {
let path_part = &line[4..];
if path_part == "/dev/null" {
return None;
}
let parsed_path = path_part
.split_once('\t')
.map_or(path_part, |(path, _)| path)
.trim_matches('"');
let parsed_path = parsed_path
.strip_prefix("b/")
.unwrap_or(parsed_path)
.trim_matches('"');
Some(PathBuf::from(parsed_path))
}
fn parse_hunk_header(line: &str) -> Option<usize> {
let header = line.split("@@").nth(1)?;
let new_file_part = header.split('+').nth(1)?;
let new_start_text = new_file_part.split(',').next()?.trim();
new_start_text.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_diff() {
let diff = indoc::indoc! {r"
diff --git a/src/lib.rs b/src/lib.rs
index 123456..789101 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,3 +1,4 @@
fn foo() {
- let x = 1;
+ let y = 2;
+ let z = 3;
}
"};
let changed_lines = parse_git_diff(diff);
let path = PathBuf::from("src/lib.rs");
assert_eq!(changed_lines.get(&path), Some(&HashSet::from([2, 3])));
}
#[test]
fn test_parse_single_line_hunk() {
let diff = indoc::indoc! {r"
diff --git a/test.py b/test.py
index 111..222 100644
--- a/test.py
+++ b/test.py
@@ -10 +10 @@
-old
+new
"};
let changed_lines = parse_git_diff(diff);
let path = PathBuf::from("test.py");
let lines = &changed_lines[&path];
assert_eq!(lines.len(), 1);
assert!(lines.contains(&10));
}
#[test]
fn test_parse_deleted_file() {
let diff = indoc::indoc! {r"
diff --git a/test.py b/test.py
deleted file mode 100644
--- a/test.py
+++ /dev/null
@@ -1,2 +0,0 @@
-def foo():
- pass
"};
let changed_lines = parse_git_diff(diff);
assert!(changed_lines.is_empty());
}
#[test]
fn test_parse_quoted_paths_with_spaces() {
let diff = indoc::indoc! {r#"
diff --git a/"some file.rs" b/"some file.rs"
--- a/"some file.rs"
+++ b/"some file.rs"
@@ -1 +1,2 @@
+// added comment
"#};
let changed_lines = parse_git_diff(diff);
let path = PathBuf::from("some file.rs");
assert!(changed_lines.contains_key(&path));
assert!(changed_lines[&path].contains(&1));
}
#[test]
fn test_parse_no_newline_warning() {
let diff = indoc::indoc! {r"
diff --git a/test.py b/test.py
--- a/test.py
+++ b/test.py
@@ -1 +1,2 @@
+def foo(): pass
\ No newline at end of file
"};
let changed_lines = parse_git_diff(diff);
let path = PathBuf::from("test.py");
assert!(changed_lines[&path].contains(&1));
}
#[test]
fn test_parse_path_containing_b_slash() {
let diff = indoc::indoc! {r"
diff --git a/some b/file.rs b/some b/file.rs
--- a/some b/file.rs
+++ b/some b/file.rs
@@ -1 +1,2 @@
+// comment
"};
let changed_lines = parse_git_diff(diff);
let path = PathBuf::from("some b/file.rs");
assert!(changed_lines.contains_key(&path));
assert!(changed_lines[&path].contains(&1));
}
#[test]
fn test_parse_diff_with_added_line_starting_with_plus_plus() {
let diff = indoc::indoc! {r"
diff --git a/test.py b/test.py
--- a/test.py
+++ b/test.py
@@ -1,2 +1,3 @@
def foo():
pass
+++ this looks like a file header but is actually an added line starting with ++
"};
let changed_lines = parse_git_diff(diff);
let path = PathBuf::from("test.py");
assert!(changed_lines.contains_key(&path));
assert!(changed_lines[&path].contains(&3));
}
#[rstest::rstest]
#[case("immutable()..", true)]
#[case("@-..@", true)]
#[case("main..@", true)]
#[case("root()..@", true)]
#[case("all()", true)]
#[case("@-::@", true)]
#[case("a | b", true)]
#[case("a & b", true)]
#[case("~a", true)]
#[case("@-", false)]
#[case("@", false)]
#[case("main", false)]
#[case("v1.0", false)]
#[case("cad67343", false)]
fn test_is_complex_jj_revset(#[case] revset: &str, #[case] expected: bool) {
assert_eq!(is_complex_jj_revset(revset), expected);
}
}