use anyhow::{Context, Result, anyhow};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::SystemTime;
pub const UNTRACKED_READ_CAP: usize = 8 * 1024;
pub const EMPTY_TREE_SHA: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileDiff {
pub path: PathBuf,
pub status: FileStatus,
pub added: usize,
pub deleted: usize,
pub content: DiffContent,
pub mtime: SystemTime,
pub header_prefix: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffContent {
Text(Vec<Hunk>),
Binary,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileStatus {
Modified,
Added,
Deleted,
Untracked,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hunk {
pub old_start: usize,
pub old_count: usize,
pub new_start: usize,
pub new_count: usize,
pub lines: Vec<DiffLine>,
pub context: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiffLine {
pub kind: LineKind,
pub content: String,
pub has_trailing_newline: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LineKind {
Context,
Added,
Deleted,
}
pub fn diff_single_file(root: &Path, baseline_sha: &str, file_path: &Path) -> Result<String> {
let rel = file_path.strip_prefix(root).unwrap_or(file_path);
let output = Command::new("git")
.args([
"diff",
"--no-renames",
baseline_sha,
"--",
&rel.to_string_lossy(),
])
.current_dir(root)
.output()
.context("git diff single file")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("git diff single file failed: {}", stderr.trim()));
}
let raw = String::from_utf8_lossy(&output.stdout).into_owned();
if !raw.is_empty() {
return Ok(raw);
}
if is_untracked_and_visible(root, rel)? {
match synthesize_untracked_diff_text(root, rel) {
Ok(text) => return Ok(text),
Err(e) => {
let vanished = e.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
});
if vanished {
return Ok(String::new());
}
return Err(e)
.with_context(|| format!("synthesizing untracked snapshot {}", rel.display()));
}
}
}
Ok(raw)
}
fn is_untracked_and_visible(root: &Path, rel: &Path) -> Result<bool> {
let output = Command::new("git")
.args([
"status",
"--porcelain=v1",
"-z",
"--untracked-files=all",
"--",
&rel.to_string_lossy(),
])
.current_dir(root)
.output()
.context("git status --porcelain for untracked classification")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"`git status --porcelain` failed: {}",
stderr.trim()
));
}
for record in output.stdout.split(|&b| b == 0) {
if record.len() < 3 {
continue;
}
if &record[..3] == b"?? " {
return Ok(true);
}
}
Ok(false)
}
fn synthesize_untracked_diff_text(root: &Path, rel: &Path) -> Result<String> {
let synth = synthesize_untracked(root, rel)?;
let mut out = String::new();
let display = rel.to_string_lossy();
out.push_str(&format!("diff --git a/{display} b/{display}\n"));
out.push_str("new file mode 100644\n");
out.push_str("index 0000000..0000000\n");
out.push_str("--- /dev/null\n");
out.push_str(&format!("+++ b/{display}\n"));
match synth.content {
DiffContent::Text(hunks) => {
for hunk in &hunks {
out.push_str(&format!(
"@@ -0,0 +{},{} @@\n",
hunk.new_start, hunk.new_count
));
for line in &hunk.lines {
match line.kind {
LineKind::Added => out.push('+'),
LineKind::Context => out.push(' '),
LineKind::Deleted => out.push('-'),
}
out.push_str(&line.content);
out.push('\n');
}
}
}
DiffContent::Binary => {
out.push_str("Binary files /dev/null and b/");
out.push_str(&display);
out.push_str(" differ\n");
}
}
Ok(out)
}
pub fn compute_diff(root: &Path, baseline_sha: &str) -> Result<Vec<FileDiff>> {
compute_diff_with_snapshots(root, baseline_sha).map(|(files, _)| files)
}
pub fn compute_diff_with_snapshots(
root: &Path,
baseline_sha: &str,
) -> Result<(Vec<FileDiff>, std::collections::HashMap<PathBuf, String>)> {
let output = Command::new("git")
.args(["diff", "--no-renames", baseline_sha, "--"])
.current_dir(root)
.output()
.context("failed to spawn `git diff --no-renames`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("`git diff` failed: {}", stderr.trim()));
}
let raw = String::from_utf8_lossy(&output.stdout);
let mut files = parse_unified_diff(&raw).context("parsing git diff output")?;
let mut snapshots: std::collections::HashMap<PathBuf, String> =
split_raw_diff_by_file(&raw, &files);
for rel in list_untracked(root)? {
match synthesize_untracked(root, &rel) {
Ok(synth) => {
match synthesize_untracked_diff_text(root, &rel) {
Ok(text) => {
snapshots.insert(synth.path.clone(), text);
}
Err(e) => {
let vanished = e.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
});
if !vanished {
return Err(e).with_context(|| {
format!("synthesizing untracked snapshot {}", rel.display())
});
}
}
}
files.push(synth);
}
Err(e) => {
let vanished = e.chain().any(|cause| {
cause
.downcast_ref::<std::io::Error>()
.is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
});
if vanished {
continue;
}
return Err(e)
.with_context(|| format!("synthesizing untracked file {}", rel.display()));
}
}
}
Ok((files, snapshots))
}
fn split_raw_diff_by_file(
raw: &str,
files: &[FileDiff],
) -> std::collections::HashMap<PathBuf, String> {
let mut snapshots = std::collections::HashMap::new();
if files.is_empty() || raw.is_empty() {
return snapshots;
}
let mut file_idx = 0usize;
let mut current = String::new();
for line in raw.lines() {
if line.starts_with("diff --git ")
&& !current.is_empty()
&& let Some(file) = files.get(file_idx)
{
snapshots.insert(file.path.clone(), std::mem::take(&mut current));
file_idx += 1;
}
current.push_str(line);
current.push('\n');
}
if !current.is_empty()
&& let Some(file) = files.get(file_idx)
{
snapshots.insert(file.path.clone(), current);
}
snapshots
}
fn list_untracked(root: &Path) -> Result<Vec<PathBuf>> {
let output = Command::new("git")
.args(["status", "--porcelain=v1", "-z", "--untracked-files=all"])
.current_dir(root)
.output()
.context("failed to spawn `git status --porcelain -z`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("`git status` failed: {}", stderr.trim()));
}
let mut paths = Vec::new();
for record in output.stdout.split(|&b| b == 0) {
if record.len() < 3 {
continue;
}
if &record[..3] == b"?? " {
let path_bytes = &record[3..];
paths.push(bytes_to_path(path_bytes));
}
}
Ok(paths)
}
#[cfg(unix)]
fn bytes_to_path(bytes: &[u8]) -> PathBuf {
use std::os::unix::ffi::OsStrExt;
PathBuf::from(std::ffi::OsStr::from_bytes(bytes))
}
#[cfg(not(unix))]
fn bytes_to_path(bytes: &[u8]) -> PathBuf {
PathBuf::from(String::from_utf8_lossy(bytes).into_owned())
}
fn parse_diff_git_header(rest: &str) -> Option<PathBuf> {
let bytes = rest.as_bytes();
if bytes.starts_with(b"\"a/") {
let (_a_decoded, after_a) = parse_quoted_token(bytes)?;
let after_space = after_a.strip_prefix(b" ")?;
let (b_decoded, _tail) = parse_quoted_token(after_space)?;
if !b_decoded.starts_with(b"b/") {
return None;
}
return Some(bytes_to_path(&b_decoded[2..]));
}
let len = bytes.len();
if len < 5 + 2 {
return None;
}
let inner = len.checked_sub(5)?;
if !inner.is_multiple_of(2) {
return None;
}
let p = inner / 2;
if !bytes.starts_with(b"a/") {
return None;
}
let a_side = &bytes[2..2 + p];
let b_prefix_start = 2 + p;
if bytes.get(b_prefix_start..b_prefix_start + 3) != Some(b" b/") {
return None;
}
let b_side = &bytes[b_prefix_start + 3..];
if a_side != b_side {
return None;
}
Some(bytes_to_path(a_side))
}
fn parse_quoted_token(bytes: &[u8]) -> Option<(Vec<u8>, &[u8])> {
if bytes.first() != Some(&b'"') {
return None;
}
let mut out: Vec<u8> = Vec::new();
let mut i = 1;
while i < bytes.len() {
let c = bytes[i];
if c == b'"' {
return Some((out, &bytes[i + 1..]));
}
if c == b'\\' {
let n = *bytes.get(i + 1)?;
match n {
b'a' => out.push(0x07),
b'b' => out.push(0x08),
b't' => out.push(b'\t'),
b'n' => out.push(b'\n'),
b'v' => out.push(0x0b),
b'f' => out.push(0x0c),
b'r' => out.push(b'\r'),
b'"' => out.push(b'"'),
b'\\' => out.push(b'\\'),
d if (b'0'..=b'7').contains(&d) => {
let end = i + 4;
if end > bytes.len() {
return None;
}
let octal = std::str::from_utf8(&bytes[i + 1..end]).ok()?;
if octal.len() != 3 || !octal.bytes().all(|b| (b'0'..=b'7').contains(&b)) {
return None;
}
let byte = u8::from_str_radix(octal, 8).ok()?;
out.push(byte);
i += 4;
continue;
}
_ => return None,
}
i += 2;
continue;
}
out.push(c);
i += 1;
}
None
}
fn synthesize_untracked(root: &Path, rel_path: &Path) -> Result<FileDiff> {
let abs = root.join(rel_path);
let total_size = std::fs::metadata(&abs)
.with_context(|| format!("statting untracked file {}", abs.display()))?
.len() as usize;
let mut file = std::fs::File::open(&abs)
.with_context(|| format!("opening untracked file {}", abs.display()))?;
let mut buf: Vec<u8> = Vec::with_capacity(UNTRACKED_READ_CAP + 1);
file.by_ref()
.take((UNTRACKED_READ_CAP + 1) as u64)
.read_to_end(&mut buf)
.with_context(|| format!("reading untracked file {}", abs.display()))?;
let truncated = buf.len() > UNTRACKED_READ_CAP;
if truncated {
buf.truncate(UNTRACKED_READ_CAP);
}
if buf.contains(&0u8) {
return Ok(FileDiff {
path: rel_path.to_path_buf(),
status: FileStatus::Untracked,
added: 0,
deleted: 0,
content: DiffContent::Binary,
mtime: SystemTime::UNIX_EPOCH,
header_prefix: None,
});
}
let text = String::from_utf8_lossy(&buf);
let lines: Vec<DiffLine> = split_logical_lines(&text)
.into_iter()
.map(|(line, has_trailing_newline)| DiffLine {
kind: LineKind::Added,
content: line,
has_trailing_newline,
})
.collect();
let mut lines = lines;
if truncated {
let remaining = total_size.saturating_sub(UNTRACKED_READ_CAP);
lines.push(DiffLine {
kind: LineKind::Context,
content: format!("[+{remaining} more bytes from new file]"),
has_trailing_newline: false,
});
}
let added = lines
.iter()
.filter(|line| line.kind == LineKind::Added)
.count();
let new_count = added;
let new_start = if new_count == 0 { 0 } else { 1 };
Ok(FileDiff {
path: rel_path.to_path_buf(),
status: FileStatus::Untracked,
added,
deleted: 0,
content: DiffContent::Text(vec![Hunk {
old_start: 0,
old_count: 0,
new_start,
new_count,
lines,
context: None,
}]),
mtime: SystemTime::UNIX_EPOCH,
header_prefix: None,
})
}
pub fn head_sha(root: &Path) -> Result<String> {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(root)
.output()
.context("failed to spawn `git rev-parse HEAD`")?;
if output.status.success() {
let sha = String::from_utf8(output.stdout)
.context("`git rev-parse HEAD` produced non-UTF8 output")?;
return Ok(sha.trim().to_string());
}
if repo_has_any_commit(root)? {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("`git rev-parse HEAD` failed: {}", stderr.trim()));
}
Ok(EMPTY_TREE_SHA.to_string())
}
fn repo_has_any_commit(root: &Path) -> Result<bool> {
let output = Command::new("git")
.args(["rev-list", "--all", "--max-count=1"])
.current_dir(root)
.output()
.context("failed to spawn `git rev-list --all`")?;
if !output.status.success() {
return Ok(true);
}
let stdout = String::from_utf8_lossy(&output.stdout);
Ok(!stdout.trim().is_empty())
}
pub fn git_dir(root: &Path) -> Result<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--absolute-git-dir"])
.current_dir(root)
.output()
.context("failed to spawn `git rev-parse --absolute-git-dir`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"`git rev-parse --absolute-git-dir` failed: {}",
stderr.trim()
));
}
let raw = String::from_utf8(output.stdout)
.context("`git rev-parse --absolute-git-dir` produced non-UTF8 output")?;
Ok(PathBuf::from(raw.trim()))
}
pub fn current_branch_ref(root: &Path) -> Result<Option<String>> {
let output = Command::new("git")
.args(["symbolic-ref", "--quiet", "HEAD"])
.current_dir(root)
.output()
.context("failed to spawn `git symbolic-ref HEAD`")?;
if output.status.success() {
let raw = String::from_utf8(output.stdout)
.context("`git symbolic-ref HEAD` produced non-UTF8 output")?;
return Ok(Some(raw.trim().to_string()));
}
let stderr_empty = output.stderr.iter().all(|b| b.is_ascii_whitespace());
if stderr_empty {
return Ok(None);
}
let stderr = String::from_utf8_lossy(&output.stderr);
Err(anyhow!("`git symbolic-ref HEAD` failed: {}", stderr.trim()))
}
pub fn git_common_dir(root: &Path) -> Result<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--path-format=absolute", "--git-common-dir"])
.current_dir(root)
.output()
.context("failed to spawn `git rev-parse --git-common-dir`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"`git rev-parse --git-common-dir` failed: {}",
stderr.trim()
));
}
let raw = String::from_utf8(output.stdout)
.context("`git rev-parse --git-common-dir` produced non-UTF8 output")?;
Ok(PathBuf::from(raw.trim()))
}
pub fn find_root(start: &Path) -> Result<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.current_dir(start)
.output()
.context("failed to spawn `git rev-parse --show-toplevel`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!(
"`git rev-parse --show-toplevel` failed: {}",
stderr.trim()
));
}
let raw = String::from_utf8(output.stdout)
.context("`git rev-parse --show-toplevel` produced non-UTF8 output")?;
Ok(PathBuf::from(raw.trim()))
}
pub fn build_hunk_patch(rel_path: &Path, hunk: &Hunk) -> String {
let mut out = String::new();
let display = rel_path.display();
out.push_str(&format!("--- a/{display}\n"));
out.push_str(&format!("+++ b/{display}\n"));
out.push_str(&format!(
"@@ -{},{} +{},{} @@\n",
hunk.old_start, hunk.old_count, hunk.new_start, hunk.new_count,
));
let last = hunk.lines.len().saturating_sub(1);
for (i, line) in hunk.lines.iter().enumerate() {
let prefix = match line.kind {
LineKind::Context => ' ',
LineKind::Added => '+',
LineKind::Deleted => '-',
};
out.push(prefix);
out.push_str(&line.content);
out.push('\n');
if i == last && !line.has_trailing_newline {
out.push_str("\\ No newline at end of file\n");
}
}
out
}
pub fn revert_hunk(root: &Path, patch: &str) -> Result<()> {
use std::io::Write;
use std::process::Stdio;
let mut child = Command::new("git")
.args(["apply", "--reverse", "--whitespace=nowarn", "-"])
.current_dir(root)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn `git apply --reverse`")?;
{
let stdin = child
.stdin
.as_mut()
.ok_or_else(|| anyhow!("`git apply` stdin unavailable"))?;
stdin
.write_all(patch.as_bytes())
.context("failed to write patch to `git apply` stdin")?;
}
let output = child
.wait_with_output()
.context("failed to wait for `git apply --reverse`")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(anyhow!("`git apply --reverse` failed: {}", stderr.trim()));
}
Ok(())
}
pub(crate) fn parse_unified_diff(raw: &str) -> Result<Vec<FileDiff>> {
let mut files: Vec<FileDiff> = Vec::new();
let mut current_hunks: Vec<Hunk> = Vec::new();
let mut current_hunk: Option<Hunk> = None;
fn finish_hunk(current_hunk: &mut Option<Hunk>, hunks: &mut Vec<Hunk>) {
if let Some(h) = current_hunk.take() {
hunks.push(h);
}
}
fn finish_file(
files: &mut [FileDiff],
current_hunks: &mut Vec<Hunk>,
current_hunk: &mut Option<Hunk>,
) {
finish_hunk(current_hunk, current_hunks);
if let Some(file) = files.last_mut() {
let hunks = std::mem::take(current_hunks);
if !matches!(file.content, DiffContent::Binary) {
file.content = DiffContent::Text(hunks);
}
}
}
for line in raw.lines() {
if let Some(rest) = line.strip_prefix("diff --git ") {
finish_file(&mut files, &mut current_hunks, &mut current_hunk);
let path = parse_diff_git_header(rest)
.ok_or_else(|| anyhow!("unparseable `diff --git` header: {rest}"))?;
files.push(FileDiff {
path,
status: FileStatus::Modified,
added: 0,
deleted: 0,
content: DiffContent::Text(Vec::new()),
mtime: SystemTime::UNIX_EPOCH,
header_prefix: None,
});
continue;
}
if line.starts_with("Binary files ") && line.ends_with(" differ") {
if let Some(file) = files.last_mut() {
file.content = DiffContent::Binary;
}
continue;
}
if line.starts_with("new file mode ") {
if let Some(file) = files.last_mut() {
file.status = FileStatus::Added;
}
continue;
}
if line.starts_with("deleted file mode ") {
if let Some(file) = files.last_mut() {
file.status = FileStatus::Deleted;
}
continue;
}
if let Some(rest) = line.strip_prefix("@@ ") {
finish_hunk(&mut current_hunk, &mut current_hunks);
let (header, context) = match rest.split_once(" @@") {
Some((header, tail)) => {
let trimmed = tail.trim();
let context = if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
};
(header, context)
}
None => (rest.trim_end_matches("@@"), None),
};
let mut parts = header.split_whitespace();
let old = parts
.next()
.ok_or_else(|| anyhow!("malformed hunk header missing old range: {line}"))?;
let new = parts
.next()
.ok_or_else(|| anyhow!("malformed hunk header missing new range: {line}"))?;
let (old_start, old_count) = parse_hunk_range(old.trim_start_matches('-'))
.ok_or_else(|| anyhow!("malformed old hunk range: {line}"))?;
let (new_start, new_count) = parse_hunk_range(new.trim_start_matches('+'))
.ok_or_else(|| anyhow!("malformed new hunk range: {line}"))?;
current_hunk = Some(Hunk {
old_start,
old_count,
new_start,
new_count,
lines: Vec::new(),
context,
});
continue;
}
if let Some(hunk) = current_hunk.as_mut() {
if line == r"\ No newline at end of file" {
if let Some(last) = hunk.lines.last_mut() {
last.has_trailing_newline = false;
}
continue;
}
if let Some(content) = line.strip_prefix('+') {
hunk.lines.push(DiffLine {
kind: LineKind::Added,
content: content.to_string(),
has_trailing_newline: true,
});
if let Some(file) = files.last_mut() {
file.added += 1;
}
continue;
}
if let Some(content) = line.strip_prefix('-') {
hunk.lines.push(DiffLine {
kind: LineKind::Deleted,
content: content.to_string(),
has_trailing_newline: true,
});
if let Some(file) = files.last_mut() {
file.deleted += 1;
}
continue;
}
if let Some(content) = line.strip_prefix(' ') {
hunk.lines.push(DiffLine {
kind: LineKind::Context,
content: content.to_string(),
has_trailing_newline: true,
});
continue;
}
}
}
finish_file(&mut files, &mut current_hunks, &mut current_hunk);
Ok(files)
}
fn split_logical_lines(text: &str) -> Vec<(String, bool)> {
if text.is_empty() {
return Vec::new();
}
text.split_inclusive('\n')
.map(|chunk| {
let has_trailing_newline = chunk.ends_with('\n');
let without_newline = chunk.strip_suffix('\n').unwrap_or(chunk);
let line = if has_trailing_newline {
without_newline
.strip_suffix('\r')
.unwrap_or(without_newline)
.to_string()
} else {
without_newline.to_string()
};
(line, has_trailing_newline)
})
.collect()
}
fn parse_hunk_range(spec: &str) -> Option<(usize, usize)> {
match spec.split_once(',') {
Some((start, count)) => Some((start.parse().ok()?, count.parse().ok()?)),
None => Some((spec.parse().ok()?, 1)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn init_repo() -> TempDir {
let dir = tempfile::tempdir().expect("create tempdir");
run_git(dir.path(), &["init", "--quiet", "--initial-branch=main"]);
run_git(dir.path(), &["config", "user.email", "test@example.com"]);
run_git(dir.path(), &["config", "user.name", "kizu test"]);
dir
}
fn run_git(cwd: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(cwd)
.status()
.unwrap_or_else(|e| panic!("git {args:?} failed to spawn: {e}"));
assert!(status.success(), "git {args:?} exited with {status:?}");
}
fn canonical(p: &Path) -> PathBuf {
p.canonicalize().expect("canonicalize")
}
#[test]
fn parse_unified_diff_extracts_single_added_line() {
let raw = "\
diff --git a/foo.rs b/foo.rs
index e69de29..4b825dc 100644
--- a/foo.rs
+++ b/foo.rs
@@ -0,0 +1,1 @@
+fn main() {}
";
let files = parse_unified_diff(raw).expect("parse diff");
assert_eq!(files.len(), 1, "expected exactly one FileDiff");
let file = &files[0];
assert_eq!(file.path, PathBuf::from("foo.rs"));
assert_eq!(file.status, FileStatus::Modified);
assert_eq!(file.added, 1);
assert_eq!(file.deleted, 0);
let hunks = match &file.content {
DiffContent::Text(hunks) => hunks,
DiffContent::Binary => panic!("expected text content, got binary"),
};
assert_eq!(hunks.len(), 1, "expected exactly one hunk");
let hunk = &hunks[0];
assert_eq!(hunk.old_start, 0);
assert_eq!(hunk.old_count, 0);
assert_eq!(hunk.new_start, 1);
assert_eq!(hunk.new_count, 1);
assert_eq!(hunk.lines.len(), 1);
assert_eq!(hunk.lines[0].kind, LineKind::Added);
assert_eq!(hunk.lines[0].content, "fn main() {}");
assert_eq!(hunk.context, None, "no xfuncname context expected");
}
#[test]
fn parse_unified_diff_captures_xfuncname_context_from_at_at_line() {
let raw = "\
diff --git a/src/auth.rs b/src/auth.rs
index e69de29..4b825dc 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -10,6 +10,9 @@ fn verify_token(claims: &Claims) -> Result<bool> {
+ if claims.exp < Utc::now().timestamp() as u64 {
+ return Err(AuthError::Expired);
+ }
";
let files = parse_unified_diff(raw).expect("parse diff");
assert_eq!(files.len(), 1);
let hunks = match &files[0].content {
DiffContent::Text(h) => h,
DiffContent::Binary => panic!("expected text"),
};
assert_eq!(hunks.len(), 1);
assert_eq!(
hunks[0].context.as_deref(),
Some("fn verify_token(claims: &Claims) -> Result<bool> {")
);
}
#[test]
fn parse_unified_diff_extracts_multiple_files() {
let raw = "\
diff --git a/foo.rs b/foo.rs
index e69de29..4b825dc 100644
--- a/foo.rs
+++ b/foo.rs
@@ -1,1 +1,1 @@
-old foo
+new foo
diff --git a/bar.rs b/bar.rs
index 1111111..2222222 100644
--- a/bar.rs
+++ b/bar.rs
@@ -1,1 +1,1 @@
-old bar
+new bar
";
let files = parse_unified_diff(raw).expect("parse diff");
assert_eq!(files.len(), 2, "expected two FileDiffs");
assert_eq!(files[0].path, PathBuf::from("foo.rs"));
assert_eq!(files[0].added, 1);
assert_eq!(files[0].deleted, 1);
assert_eq!(files[1].path, PathBuf::from("bar.rs"));
assert_eq!(files[1].added, 1);
assert_eq!(files[1].deleted, 1);
}
#[test]
fn parse_unified_diff_handles_multiple_hunks_in_one_file() {
let raw = "\
diff --git a/lib.rs b/lib.rs
index 1111111..2222222 100644
--- a/lib.rs
+++ b/lib.rs
@@ -1,2 +1,2 @@
fn one() {}
-fn two() {}
+fn two_v2() {}
@@ -10,2 +10,2 @@
fn ten() {}
-fn eleven() {}
+fn eleven_v2() {}
";
let files = parse_unified_diff(raw).expect("parse diff");
assert_eq!(files.len(), 1);
let hunks = match &files[0].content {
DiffContent::Text(h) => h,
DiffContent::Binary => panic!("expected text content"),
};
assert_eq!(hunks.len(), 2, "expected two hunks");
assert_eq!(hunks[0].new_start, 1);
assert_eq!(hunks[1].new_start, 10);
assert_eq!(files[0].added, 2);
assert_eq!(files[0].deleted, 2);
}
#[test]
fn parse_unified_diff_counts_added_and_deleted_lines() {
let raw = "\
diff --git a/mix.rs b/mix.rs
index 1111111..2222222 100644
--- a/mix.rs
+++ b/mix.rs
@@ -1,5 +1,4 @@
keep
-drop1
-drop2
-drop3
+keep too
+only added
";
let files = parse_unified_diff(raw).expect("parse diff");
assert_eq!(files.len(), 1);
assert_eq!(files[0].added, 2, "two + lines expected");
assert_eq!(files[0].deleted, 3, "three - lines expected");
}
#[test]
fn parse_unified_diff_detects_added_file_status() {
let raw = "\
diff --git a/new.rs b/new.rs
new file mode 100644
index 0000000..2222222
--- /dev/null
+++ b/new.rs
@@ -0,0 +1,1 @@
+brand new
";
let files = parse_unified_diff(raw).expect("parse diff");
assert_eq!(files.len(), 1);
assert_eq!(files[0].status, FileStatus::Added);
}
#[test]
fn parse_unified_diff_detects_binary_diff() {
let raw = "\
diff --git a/icon.png b/icon.png
index 1111111..2222222 100644
Binary files a/icon.png and b/icon.png differ
";
let files = parse_unified_diff(raw).expect("parse diff");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, PathBuf::from("icon.png"));
assert!(matches!(files[0].content, DiffContent::Binary));
assert_eq!(files[0].added, 0);
assert_eq!(files[0].deleted, 0);
}
#[test]
fn parse_unified_diff_marks_missing_terminal_newline_on_previous_line() {
let raw = "\
diff --git a/foo.rs b/foo.rs
index e69de29..4b825dc 100644
--- a/foo.rs
+++ b/foo.rs
@@ -1 +1 @@
-old line
+new line
\\ No newline at end of file
";
let files = parse_unified_diff(raw).expect("parse diff");
let hunks = match &files[0].content {
DiffContent::Text(hunks) => hunks,
DiffContent::Binary => panic!("expected text content"),
};
let last = hunks[0].lines.last().expect("line present");
assert_eq!(last.content, "new line");
assert!(
!last.has_trailing_newline,
"newline marker line must clear the previous diff line's newline flag"
);
}
#[test]
fn parse_unified_diff_rejects_unparseable_diff_git_header() {
let raw = "\
diff --git definitely-not-a-valid-header
@@ -0,0 +1,1 @@
+x
";
let err = parse_unified_diff(raw).expect_err("malformed diff header must surface");
assert!(
err.to_string().contains("unparseable `diff --git` header"),
"unexpected error: {err:#}"
);
}
#[test]
fn parse_unified_diff_rejects_malformed_hunk_header() {
let raw = "\
diff --git a/foo.rs b/foo.rs
@@ -bogus +1 @@
+x
";
let err = parse_unified_diff(raw).expect_err("malformed hunk header must surface");
assert!(
err.to_string().contains("malformed old hunk range"),
"unexpected error: {err:#}"
);
}
#[test]
fn split_logical_lines_preserves_literal_carriage_return_without_newline() {
let lines = split_logical_lines("carriage-return-only\r");
assert_eq!(lines, vec![("carriage-return-only\r".to_string(), false)]);
}
#[test]
fn parse_unified_diff_detects_deleted_file_status() {
let raw = "\
diff --git a/gone.rs b/gone.rs
deleted file mode 100644
index 1111111..0000000
--- a/gone.rs
+++ /dev/null
@@ -1,1 +0,0 @@
-was here
";
let files = parse_unified_diff(raw).expect("parse diff");
assert_eq!(files.len(), 1);
assert_eq!(files[0].status, FileStatus::Deleted);
}
#[test]
fn parse_unified_diff_decodes_c_quoted_tracked_pathname() {
let raw = "\
diff --git \"a/\\t.txt\" \"b/\\t.txt\"
index 1111111..2222222 100644
--- \"a/\\t.txt\"
+++ \"b/\\t.txt\"
@@ -1,1 +1,2 @@
line
+added
";
let files = parse_unified_diff(raw).expect("parse diff");
assert_eq!(files.len(), 1, "expected one file, got {files:?}");
assert_eq!(files[0].path, PathBuf::from("\t.txt"));
assert_eq!(files[0].added, 1);
}
#[test]
fn parse_unified_diff_decodes_c_quoted_octal_escape_in_path() {
let raw = "\
diff --git \"a/caf\\303\\251.txt\" \"b/caf\\303\\251.txt\"
index 1111111..2222222 100644
--- \"a/caf\\303\\251.txt\"
+++ \"b/caf\\303\\251.txt\"
@@ -1,1 +1,2 @@
one
+two
";
let files = parse_unified_diff(raw).expect("parse diff");
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, PathBuf::from("café.txt"));
assert_eq!(files[0].added, 1);
}
#[test]
fn parse_unified_diff_handles_unquoted_path_containing_literal_b_slash() {
let raw = "\
diff --git a/foo b/bar b/foo b/bar
index 1111111..2222222 100644
--- a/foo b/bar
+++ b/foo b/bar
@@ -1,1 +1,2 @@
x
+y
";
let files = parse_unified_diff(raw).expect("parse diff");
assert_eq!(files.len(), 1, "expected one file, got {files:?}");
assert_eq!(files[0].path, PathBuf::from("foo b/bar"));
assert_eq!(files[0].added, 1);
}
#[test]
fn find_root_returns_worktree_root() {
let repo = init_repo();
let root = find_root(repo.path()).expect("find_root");
assert_eq!(canonical(&root), canonical(repo.path()));
}
#[test]
fn git_dir_resolves_in_normal_repo() {
let repo = init_repo();
let gd = git_dir(repo.path()).expect("git_dir");
let expected = repo.path().join(".git");
assert_eq!(canonical(&gd), canonical(&expected));
}
#[test]
fn head_sha_falls_back_to_empty_tree_in_fresh_repo() {
let repo = init_repo();
let sha = head_sha(repo.path()).expect("head_sha");
assert_eq!(sha, EMPTY_TREE_SHA);
}
#[test]
fn head_sha_surfaces_broken_head_when_repo_has_commits() {
let repo = init_repo();
fs::write(repo.path().join("seed.txt"), "seed").expect("write seed");
run_git(repo.path(), &["add", "seed.txt"]);
run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
run_git(
repo.path(),
&["symbolic-ref", "HEAD", "refs/heads/never-existed"],
);
let result = head_sha(repo.path());
let err = match result {
Ok(sha) => {
panic!("broken HEAD must surface an error; got empty-tree fallback sha {sha}")
}
Err(e) => e,
};
let chain = format!("{err:#}");
assert!(
chain.contains("git rev-parse HEAD"),
"error chain should identify the failing command, got: {chain}"
);
}
#[test]
fn head_sha_returns_actual_sha_after_commit() {
let repo = init_repo();
fs::write(repo.path().join("seed.txt"), "hello").expect("write seed");
run_git(repo.path(), &["add", "seed.txt"]);
run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
let sha = head_sha(repo.path()).expect("head_sha");
assert_eq!(sha.len(), 40, "expected a 40-char SHA, got {sha:?}");
assert_ne!(sha, EMPTY_TREE_SHA);
assert!(sha.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn compute_diff_returns_modified_file_against_committed_baseline() {
let repo = init_repo();
fs::write(repo.path().join("greeting.txt"), "hello\n").expect("write seed");
run_git(repo.path(), &["add", "greeting.txt"]);
run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
let baseline = head_sha(repo.path()).expect("head_sha");
fs::write(repo.path().join("greeting.txt"), "hello world\n").expect("write modification");
let files = compute_diff(repo.path(), &baseline).expect("compute_diff");
assert_eq!(files.len(), 1, "expected one modified file");
assert_eq!(files[0].path, PathBuf::from("greeting.txt"));
assert_eq!(files[0].status, FileStatus::Modified);
assert_eq!(files[0].added, 1);
assert_eq!(files[0].deleted, 1);
}
#[test]
fn compute_diff_with_snapshots_pairs_each_filediff_with_its_raw_text() {
let repo = init_repo();
fs::write(repo.path().join("a.txt"), "a-seed\n").expect("write a");
fs::write(repo.path().join("b.txt"), "b-seed\n").expect("write b");
run_git(repo.path(), &["add", "a.txt", "b.txt"]);
run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
let baseline = head_sha(repo.path()).expect("head_sha");
fs::write(repo.path().join("a.txt"), "a-edit\n").expect("edit a");
fs::write(repo.path().join("b.txt"), "b-edit\n").expect("edit b");
fs::write(repo.path().join("c.md"), "new file\n").expect("write c");
let (files, snapshots) = compute_diff_with_snapshots(repo.path(), &baseline)
.expect("compute_diff_with_snapshots");
assert_eq!(files.len(), 3, "expected three files, got {files:?}");
assert_eq!(
snapshots.len(),
files.len(),
"snapshot map must have one entry per FileDiff"
);
for file in &files {
let reference = diff_single_file(repo.path(), &baseline, &file.path)
.expect("diff_single_file reference");
let snapshot = snapshots
.get(&file.path)
.unwrap_or_else(|| panic!("no snapshot for {:?}", file.path));
assert_eq!(
snapshot, &reference,
"snapshot for {:?} must match diff_single_file output",
file.path,
);
}
}
#[test]
fn compute_diff_returns_empty_when_worktree_is_clean() {
let repo = init_repo();
fs::write(repo.path().join("seed.txt"), "x").expect("write");
run_git(repo.path(), &["add", "seed.txt"]);
run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
let baseline = head_sha(repo.path()).expect("head_sha");
let files = compute_diff(repo.path(), &baseline).expect("compute_diff");
assert!(files.is_empty(), "expected empty diff, got {files:?}");
}
#[test]
fn compute_diff_includes_untracked_text_file() {
let repo = init_repo();
fs::write(repo.path().join("seed.txt"), "seed").expect("write seed");
run_git(repo.path(), &["add", "seed.txt"]);
run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
let baseline = head_sha(repo.path()).expect("head_sha");
fs::write(repo.path().join("note.md"), "line one\nline two\n").expect("write untracked");
let files = compute_diff(repo.path(), &baseline).expect("compute_diff");
let untracked: Vec<_> = files
.iter()
.filter(|f| f.status == FileStatus::Untracked)
.collect();
assert_eq!(untracked.len(), 1, "expected one untracked file");
let f = untracked[0];
assert_eq!(f.path, PathBuf::from("note.md"));
assert_eq!(f.added, 2);
assert_eq!(f.deleted, 0);
let hunks = match &f.content {
DiffContent::Text(h) => h,
DiffContent::Binary => panic!("expected text content"),
};
assert_eq!(hunks.len(), 1);
assert_eq!(hunks[0].new_start, 1);
assert_eq!(hunks[0].new_count, 2);
assert_eq!(hunks[0].lines.len(), 2);
assert_eq!(hunks[0].lines[0].content, "line one");
assert_eq!(hunks[0].lines[1].content, "line two");
}
#[test]
fn compute_diff_expands_untracked_files_inside_subdirectories() {
let repo = init_repo();
fs::write(repo.path().join("seed.txt"), "seed").expect("write seed");
run_git(repo.path(), &["add", "seed.txt"]);
run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
let baseline = head_sha(repo.path()).expect("head_sha");
fs::create_dir_all(repo.path().join("subdir")).expect("mkdir subdir");
fs::write(repo.path().join("subdir/a.rs"), "alpha\n").expect("write a");
fs::write(repo.path().join("subdir/b.rs"), "beta\n").expect("write b");
let files = compute_diff(repo.path(), &baseline).expect("compute_diff");
let mut untracked: Vec<_> = files
.iter()
.filter(|f| f.status == FileStatus::Untracked)
.map(|f| f.path.clone())
.collect();
untracked.sort();
assert_eq!(
untracked,
vec![PathBuf::from("subdir/a.rs"), PathBuf::from("subdir/b.rs"),],
"expected both subdirectory files listed individually"
);
}
#[test]
fn compute_diff_includes_untracked_file_with_spaces_in_name() {
let repo = init_repo();
fs::write(repo.path().join("seed.txt"), "seed").expect("write seed");
run_git(repo.path(), &["add", "seed.txt"]);
run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
let baseline = head_sha(repo.path()).expect("head_sha");
fs::write(repo.path().join("design draft.md"), "alpha\nbeta\n").expect("write quoted file");
let files = compute_diff(repo.path(), &baseline).expect("compute_diff");
let found = files
.iter()
.find(|f| f.path == Path::new("design draft.md"))
.expect("untracked file with space in name must be visible");
assert_eq!(found.status, FileStatus::Untracked);
assert_eq!(found.added, 2);
}
#[test]
fn compute_diff_tolerates_non_utf8_tracked_content_via_lossy_decode() {
let repo = init_repo();
let path = "legacy.txt";
fs::write(repo.path().join(path), "hello\n").expect("write seed");
run_git(repo.path(), &["add", path]);
run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
let baseline = head_sha(repo.path()).expect("head_sha");
fs::write(
repo.path().join(path),
b"hello\nlegacy \xFF byte\n".as_slice(),
)
.expect("write legacy content");
let files = compute_diff(repo.path(), &baseline)
.expect("compute_diff must tolerate non-UTF-8 tracked content");
let legacy = files
.iter()
.find(|f| f.path == Path::new(path))
.expect("legacy file must still appear in the diff");
assert_eq!(legacy.status, FileStatus::Modified);
assert!(
legacy.added >= 1,
"the new legacy line must register as an addition, got {}",
legacy.added
);
}
#[test]
fn compute_diff_marks_untracked_binary_file_as_binary() {
let repo = init_repo();
fs::write(repo.path().join("seed.txt"), "seed").expect("write seed");
run_git(repo.path(), &["add", "seed.txt"]);
run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
let baseline = head_sha(repo.path()).expect("head_sha");
let mut bytes = b"some text".to_vec();
bytes.push(0);
bytes.extend_from_slice(b"more bytes");
fs::write(repo.path().join("blob.bin"), bytes).expect("write binary");
let files = compute_diff(repo.path(), &baseline).expect("compute_diff");
let bin = files
.iter()
.find(|f| f.path == Path::new("blob.bin"))
.expect("untracked binary present");
assert_eq!(bin.status, FileStatus::Untracked);
assert!(matches!(bin.content, DiffContent::Binary));
assert_eq!(bin.added, 0);
assert_eq!(bin.deleted, 0);
}
#[test]
fn compute_diff_caps_untracked_file_at_read_limit() {
let repo = init_repo();
fs::write(repo.path().join("seed.txt"), "seed").expect("write seed");
run_git(repo.path(), &["add", "seed.txt"]);
run_git(repo.path(), &["commit", "--quiet", "-m", "initial"]);
let baseline = head_sha(repo.path()).expect("head_sha");
let line: String = "x".repeat(99);
let body = (0..200)
.map(|_| line.as_str())
.collect::<Vec<_>>()
.join("\n");
fs::write(repo.path().join("big.txt"), &body).expect("write big");
assert!(body.len() > UNTRACKED_READ_CAP);
let files = compute_diff(repo.path(), &baseline).expect("compute_diff");
let big = files
.iter()
.find(|f| f.path == Path::new("big.txt"))
.expect("untracked big present");
let hunks = match &big.content {
DiffContent::Text(h) => h,
DiffContent::Binary => panic!("unexpected binary classification"),
};
let total_bytes: usize = hunks[0].lines.iter().map(|l| l.content.len() + 1).sum();
assert!(
total_bytes <= UNTRACKED_READ_CAP + 100,
"untracked content should be capped near {UNTRACKED_READ_CAP} bytes, got {total_bytes}"
);
assert!(
hunks[0]
.lines
.iter()
.any(|line| line.content.contains("more bytes from new file")),
"expected a visible truncation marker instead of silent truncation"
);
assert!(
big.added < 200,
"expected fewer than 200 lines after cap, got {}",
big.added
);
}
#[test]
fn compute_diff_against_empty_tree_in_fresh_repo_shows_all_committed_files() {
let repo = init_repo();
fs::write(repo.path().join("a.txt"), "alpha\n").expect("write a");
fs::write(repo.path().join("b.txt"), "beta\n").expect("write b");
run_git(repo.path(), &["add", "a.txt", "b.txt"]);
run_git(repo.path(), &["commit", "--quiet", "-m", "seed"]);
let files = compute_diff(repo.path(), EMPTY_TREE_SHA).expect("compute_diff");
let paths: Vec<_> = files.iter().map(|f| f.path.clone()).collect();
assert!(paths.contains(&PathBuf::from("a.txt")));
assert!(paths.contains(&PathBuf::from("b.txt")));
for f in &files {
assert_eq!(f.status, FileStatus::Added);
}
}
fn line_context(content: &str) -> DiffLine {
DiffLine {
kind: LineKind::Context,
content: content.to_string(),
has_trailing_newline: true,
}
}
fn line_added(content: &str) -> DiffLine {
DiffLine {
kind: LineKind::Added,
content: content.to_string(),
has_trailing_newline: true,
}
}
fn line_deleted(content: &str) -> DiffLine {
DiffLine {
kind: LineKind::Deleted,
content: content.to_string(),
has_trailing_newline: true,
}
}
#[test]
fn build_hunk_patch_round_trips_a_modify_hunk_into_a_git_apply_payload() {
let hunk = Hunk {
old_start: 1,
old_count: 2,
new_start: 1,
new_count: 2,
lines: vec![line_context("keep"), line_deleted("old"), line_added("new")],
context: None,
};
let patch = build_hunk_patch(Path::new("foo.rs"), &hunk);
assert_eq!(
patch,
"\
--- a/foo.rs
+++ b/foo.rs
@@ -1,2 +1,2 @@
keep
-old
+new
"
);
}
#[test]
fn revert_hunk_undoes_an_added_line_on_disk() {
let repo = init_repo();
let file_path = repo.path().join("hello.rs");
fs::write(&file_path, "fn one() {}\n").expect("write seed");
run_git(repo.path(), &["add", "hello.rs"]);
run_git(repo.path(), &["commit", "--quiet", "-m", "seed"]);
fs::write(&file_path, "fn one() {}\nfn two() {}\n").expect("write modified");
let baseline = head_sha(repo.path()).expect("head_sha");
let files = compute_diff(repo.path(), &baseline).expect("compute_diff");
let file = files
.iter()
.find(|f| f.path == Path::new("hello.rs"))
.expect("hello.rs in diff");
let hunk = match &file.content {
DiffContent::Text(hunks) => hunks.first().expect("one hunk"),
_ => panic!("expected text content"),
};
let patch = build_hunk_patch(&file.path, hunk);
revert_hunk(repo.path(), &patch).expect("revert");
let after = fs::read_to_string(&file_path).expect("read back");
assert_eq!(
after, "fn one() {}\n",
"revert must restore the worktree file to its committed state"
);
}
#[test]
fn revert_hunk_returns_err_when_patch_no_longer_applies_cleanly() {
let repo = init_repo();
let file_path = repo.path().join("drift.rs");
fs::write(&file_path, "alpha\n").expect("seed");
run_git(repo.path(), &["add", "drift.rs"]);
run_git(repo.path(), &["commit", "--quiet", "-m", "seed"]);
fs::write(&file_path, "alpha\nbeta\n").expect("add beta");
let baseline = head_sha(repo.path()).expect("head_sha");
let files = compute_diff(repo.path(), &baseline).expect("diff");
let file = files
.iter()
.find(|f| f.path == Path::new("drift.rs"))
.unwrap();
let hunk = match &file.content {
DiffContent::Text(h) => h.first().unwrap(),
_ => panic!(),
};
let patch = build_hunk_patch(&file.path, hunk);
fs::write(&file_path, "alpha\ngamma\n").expect("drift");
let err = revert_hunk(repo.path(), &patch)
.expect_err("patch must fail when the hunk has drifted");
let msg = format!("{err:#}");
assert!(
msg.contains("git apply"),
"error must name the failing command, got {msg}",
);
}
#[test]
fn file_diff_header_prefix_defaults_to_none_in_parsed_diff() {
let raw = "diff --git a/foo.rs b/foo.rs\n--- a/foo.rs\n+++ b/foo.rs\n@@ -1,1 +1,2 @@\n line1\n+line2\n";
let files = parse_unified_diff(raw).unwrap();
assert_eq!(files.len(), 1);
assert_eq!(files[0].header_prefix, None);
}
#[test]
fn diff_single_file_synthesizes_untracked_file_as_all_added() {
let repo = init_repo();
fs::write(repo.path().join("seed.rs"), "seed\n").expect("write seed");
run_git(repo.path(), &["add", "seed.rs"]);
run_git(repo.path(), &["commit", "-m", "seed", "--quiet"]);
let baseline = {
let out = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo.path())
.output()
.unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_string()
};
let new_rel = Path::new("new.rs");
let new_abs = repo.path().join(new_rel);
fs::write(&new_abs, "alpha\nbeta\n").expect("write new");
let diff = diff_single_file(&canonical(repo.path()), &baseline, new_rel)
.expect("untracked file diff must succeed");
assert!(
diff.contains("+alpha"),
"synthesized diff must contain the file's first line prefixed with `+`, got {diff:?}"
);
assert!(
diff.contains("+beta"),
"synthesized diff must contain the file's second line prefixed with `+`, got {diff:?}"
);
}
#[test]
fn diff_single_file_does_not_synthesize_for_gitignored_file() {
let repo = init_repo();
fs::write(repo.path().join(".gitignore"), "secrets.local\n").expect("write gitignore");
fs::write(repo.path().join("seed.rs"), "seed\n").expect("write seed");
run_git(repo.path(), &["add", ".gitignore", "seed.rs"]);
run_git(repo.path(), &["commit", "-m", "seed", "--quiet"]);
let baseline = head_sha(repo.path()).expect("head_sha");
let secret_rel = Path::new("secrets.local");
fs::write(
repo.path().join(secret_rel),
"API_TOKEN=deadbeef\nSSN=111\n",
)
.expect("write secret");
let diff = diff_single_file(&canonical(repo.path()), &baseline, secret_rel)
.expect("ignored files must not trigger an error, just an empty diff");
assert!(
!diff.contains("API_TOKEN"),
"ignored file contents must never enter the synthesized diff, got {diff:?}"
);
assert!(
diff.is_empty(),
"ignored path must round-trip through the empty-diff path untouched, got {diff:?}"
);
}
#[test]
fn diff_single_file_surfaces_fallback_errors_instead_of_silent_empty_ok() {
let repo = init_repo();
fs::write(repo.path().join("seed.rs"), "seed\n").expect("seed");
run_git(repo.path(), &["add", "seed.rs"]);
run_git(repo.path(), &["commit", "-m", "seed", "--quiet"]);
let baseline = head_sha(repo.path()).expect("head_sha");
#[cfg(unix)]
{
use std::os::unix::fs::symlink;
let link = repo.path().join("dangling.link");
symlink(repo.path().join("no-such-file"), &link).expect("create symlink");
let res = diff_single_file(
&canonical(repo.path()),
&baseline,
Path::new("dangling.link"),
);
if let Ok(s) = res {
assert!(s.is_empty(), "dangling symlink should be empty, got {s:?}");
}
}
}
}