use std::path::Path;
use std::process::Command;
const CONTEXT_LINES: &str = "-U3";
const OWN_OUTPUT: [&str; 2] = ["--no-ext-diff", "--no-color"];
const LINE_ENDINGS: [char; 2] = ['\r', '\n'];
pub fn git(repo: &Path, args: &[&str]) -> Option<String> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(args)
.output()
.ok()?;
out.status.success().then(|| {
String::from_utf8_lossy(&out.stdout)
.trim_end_matches(LINE_ENDINGS)
.to_owned()
})
}
#[must_use]
pub fn unified(repo: &Path, range: &[&str], path: &str) -> Option<String> {
let mut args: Vec<&str> = vec!["diff", CONTEXT_LINES];
args.extend(OWN_OUTPUT);
args.extend(range.iter().copied());
args.extend(["--", path]);
git(repo, &args)
}
#[must_use]
pub fn unified_untracked(repo: &Path, path: &str) -> Option<String> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["diff", CONTEXT_LINES])
.args(OWN_OUTPUT)
.args(["--no-index", "--", "/dev/null", path])
.output()
.ok()?;
let stdout = String::from_utf8_lossy(&out.stdout)
.trim_end_matches(LINE_ENDINGS)
.to_owned();
match out.status.code() {
Some(0) => Some(stdout),
Some(1) if out.stderr.is_empty() => Some(stdout),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::PathBuf;
fn repo(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("roteiro-diff-{}-{name}", std::process::id()));
fs::remove_dir_all(&dir).ok();
fs::create_dir_all(&dir).expect("mkdir");
let p = dir.as_path();
let run = |args: &[&str]| {
let ok = Command::new("git")
.arg("-C")
.arg(p)
.args(args)
.output()
.expect("git")
.status
.success();
assert!(ok, "git {args:?} failed");
};
run(&["init", "--quiet"]);
run(&["config", "user.email", "t@example.com"]);
run(&["config", "user.name", "t"]);
fs::write(p.join("kept.rs"), "fn a() {}\n").expect("write");
run(&["add", "kept.rs"]);
run(&["-c", "commit.gpgsign=false", "commit", "--quiet", "-m", "x"]);
dir
}
#[test]
fn a_tracked_edit_produces_hunks() {
let dir = repo("tracked");
fs::write(dir.join("kept.rs"), "fn a() {}\nfn b() {}\n").expect("write");
let d = unified(&dir, &[], "kept.rs").expect("diff");
assert!(d.contains("@@"), "expected a hunk header, got: {d}");
assert!(d.contains("+fn b() {}"), "expected the added line: {d}");
}
#[test]
fn an_unchanged_file_is_empty_but_not_a_failure() {
let dir = repo("unchanged");
assert_eq!(unified(&dir, &[], "kept.rs").as_deref(), Some(""));
}
#[test]
fn an_untracked_file_still_gets_a_diff() {
let dir = repo("untracked");
fs::write(dir.join("new.rs"), "fn c() {}\n").expect("write");
assert_eq!(unified(&dir, &[], "new.rs").as_deref(), Some(""));
let d = unified_untracked(&dir, "new.rs").expect("diff");
assert!(
d.contains("+fn c() {}"),
"expected the new file's text: {d}"
);
}
#[test]
fn an_empty_range_sees_only_unstaged_edits() {
let dir = repo("range");
let run = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(&dir)
.args(args)
.output()
.expect("git")
};
fs::write(dir.join("kept.rs"), "fn a() {}\nfn staged() {}\n").expect("write");
run(&["add", "kept.rs"]);
fs::write(
dir.join("kept.rs"),
"fn a() {}\nfn staged() {}\nfn unstaged() {}\n",
)
.expect("write");
let unstaged_only = unified(&dir, &[], "kept.rs").expect("diff");
assert!(
unstaged_only.contains("+fn unstaged() {}"),
"an empty range shows the unstaged edit: {unstaged_only}"
);
assert!(
!unstaged_only.contains("+fn staged() {}"),
"and does NOT show the staged one — it compares against the index: \
{unstaged_only}"
);
let vs_head = unified(&dir, &["HEAD"], "kept.rs").expect("diff");
assert!(
vs_head.contains("+fn staged() {}") && vs_head.contains("+fn unstaged() {}"),
"`HEAD` shows both, which is why a working-tree review uses it: {vs_head}"
);
}
#[test]
fn trailing_whitespace_on_the_last_diff_line_survives() {
let dir = repo("trailing");
fs::write(dir.join("kept.rs"), "fn a() {} \n").expect("write");
let d = unified(&dir, &[], "kept.rs").expect("diff");
assert!(
d.contains("+fn a() {} "),
"the added line keeps its trailing spaces: {d:?}"
);
assert!(
!d.ends_with("+fn a() {}"),
"and the diff does not end on a truncated version of it: {d:?}"
);
}
#[test]
fn an_external_differ_is_refused() {
let dir = repo("extdiff");
let helper = dir.join("helper.sh");
fs::write(&helper, "#!/bin/sh\necho EXTERNAL-DIFF-EXECUTED\n").expect("write");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&helper, fs::Permissions::from_mode(0o755)).expect("chmod");
}
let configured = Command::new("git")
.arg("-C")
.arg(&dir)
.args(["config", "diff.external"])
.arg(&helper)
.output()
.expect("git")
.status
.success();
assert!(configured, "set diff.external");
fs::write(dir.join("kept.rs"), "fn a() {}\nfn b() {}\n").expect("write");
let d = unified(&dir, &[], "kept.rs").expect("diff");
assert!(
!d.contains("EXTERNAL-DIFF-EXECUTED"),
"the configured helper must not have run: {d}"
);
assert!(
d.contains("+fn b() {}"),
"and git's own diff must be what came back: {d}"
);
}
#[test]
fn head_shows_the_worktree_not_the_index() {
let dir = repo("diverge");
let run = |args: &[&str]| {
Command::new("git")
.arg("-C")
.arg(&dir)
.args(args)
.output()
.expect("git")
};
fs::write(dir.join("kept.rs"), "fn staged_version() {}\n").expect("write");
run(&["add", "kept.rs"]);
fs::write(dir.join("kept.rs"), "fn worktree_version() {}\n").expect("write");
let vs_head = unified(&dir, &["HEAD"], "kept.rs").expect("diff");
assert!(
vs_head.contains("+fn worktree_version() {}"),
"`HEAD` shows the worktree content: {vs_head}"
);
assert!(
!vs_head.contains("+fn staged_version() {}"),
"and not the staged blob, which is what a commit would actually \
record: {vs_head}"
);
let cached = unified(&dir, &["--cached"], "kept.rs").expect("diff");
assert!(
cached.contains("+fn staged_version() {}"),
"`--cached` is the committing view: {cached}"
);
}
#[test]
fn a_mode_only_change_still_produces_a_diff() {
let dir = repo("mode");
let mode_changed = Command::new("git")
.arg("-C")
.arg(&dir)
.args(["update-index", "--chmod=+x", "kept.rs"])
.output()
.expect("git")
.status
.success();
if !mode_changed {
return;
}
let d = unified(&dir, &["--cached"], "kept.rs").expect("diff");
assert!(
!d.is_empty(),
"a mode-only change emits headers, so it is an ordinary non-empty \
diff — not the `Some(\"\")` case"
);
assert!(d.contains("mode"), "and those headers name the mode: {d}");
}
#[test]
fn an_untracked_diff_of_a_missing_path_fails_rather_than_reads_empty() {
let dir = repo("missing");
assert!(unified_untracked(&dir, "absent.rs").is_none());
}
}