Skip to main content

wisp/
git_diff.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3
4#[doc = include_str!("docs/git_diff_document.md")]
5#[allow(dead_code)]
6pub struct GitDiffDocument {
7    pub repo_root: PathBuf,
8    pub files: Vec<FileDiff>,
9}
10
11pub struct FileDiff {
12    pub old_path: Option<String>,
13    pub path: String,
14    pub status: FileStatus,
15    pub hunks: Vec<Hunk>,
16    pub binary: bool,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum FileStatus {
21    Modified,
22    Added,
23    Deleted,
24    Renamed,
25    Untracked,
26}
27
28#[allow(dead_code)]
29pub struct Hunk {
30    pub header: String,
31    pub old_start: usize,
32    pub old_count: usize,
33    pub new_start: usize,
34    pub new_count: usize,
35    pub lines: Vec<PatchLine>,
36}
37
38pub struct PatchLine {
39    pub kind: PatchLineKind,
40    pub text: String,
41    pub old_line_no: Option<usize>,
42    pub new_line_no: Option<usize>,
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
46pub enum PatchLineKind {
47    HunkHeader,
48    Context,
49    Added,
50    Removed,
51    Meta,
52}
53
54#[doc = include_str!("docs/git_diff_error.md")]
55#[derive(Debug)]
56pub enum GitDiffError {
57    NotARepository,
58    CommandFailed { stderr: String },
59    ParseError(String),
60}
61
62impl fmt::Display for GitDiffError {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            Self::NotARepository => write!(f, "Not a git repository"),
66            Self::CommandFailed { stderr } => write!(f, "Git command failed: {stderr}"),
67            Self::ParseError(msg) => write!(f, "Failed to parse diff: {msg}"),
68        }
69    }
70}
71
72impl std::error::Error for GitDiffError {}
73
74impl FileStatus {
75    pub fn marker(self) -> char {
76        match self {
77            Self::Modified => 'M',
78            Self::Added => 'A',
79            Self::Deleted => 'D',
80            Self::Renamed => 'R',
81            Self::Untracked => '?',
82        }
83    }
84
85    pub fn label(self) -> &'static str {
86        match self {
87            Self::Modified => "modified",
88            Self::Added => "new file",
89            Self::Deleted => "deleted",
90            Self::Renamed => "renamed",
91            Self::Untracked => "untracked",
92        }
93    }
94}
95
96impl FileDiff {
97    pub fn additions(&self) -> usize {
98        self.hunks.iter().map(Hunk::additions).sum()
99    }
100
101    pub fn deletions(&self) -> usize {
102        self.hunks.iter().map(Hunk::deletions).sum()
103    }
104
105    pub fn max_line_no(&self) -> usize {
106        self.hunks
107            .iter()
108            .flat_map(|hunk| &hunk.lines)
109            .flat_map(|line| line.old_line_no.into_iter().chain(line.new_line_no))
110            .max()
111            .unwrap_or(0)
112    }
113}
114
115impl Hunk {
116    pub fn additions(&self) -> usize {
117        self.lines.iter().filter(|line| line.kind == PatchLineKind::Added).count()
118    }
119
120    pub fn deletions(&self) -> usize {
121        self.lines.iter().filter(|line| line.kind == PatchLineKind::Removed).count()
122    }
123}
124
125pub(crate) async fn load_git_diff(
126    working_dir: &Path,
127    cached_repo_root: Option<&Path>,
128) -> Result<GitDiffDocument, GitDiffError> {
129    let repo_root = match cached_repo_root {
130        Some(root) => root.to_path_buf(),
131        None => resolve_repo_root(working_dir).await?,
132    };
133    let diff_output = run_git_command(&repo_root, &["diff", "--no-ext-diff", "--find-renames", "HEAD"]).await?;
134
135    let mut files = if diff_output.trim().is_empty() { Vec::new() } else { parse_unified_diff(&diff_output)? };
136
137    let untracked_stdout = run_git_command(&repo_root, &["ls-files", "--others", "--exclude-standard"]).await?;
138    for path in untracked_stdout.lines().filter(|l| !l.is_empty()).map(String::from) {
139        files.push(build_untracked_file_diff(&repo_root, path).await);
140    }
141
142    Ok(GitDiffDocument { repo_root, files })
143}
144
145async fn resolve_repo_root(working_dir: &Path) -> Result<PathBuf, GitDiffError> {
146    let output = tokio::process::Command::new("git")
147        .arg("rev-parse")
148        .arg("--show-toplevel")
149        .current_dir(working_dir)
150        .output()
151        .await
152        .map_err(|e| GitDiffError::CommandFailed { stderr: e.to_string() })?;
153
154    if !output.status.success() {
155        let stderr = String::from_utf8_lossy(&output.stderr);
156        if stderr.contains("not a git repository") {
157            return Err(GitDiffError::NotARepository);
158        }
159        return Err(GitDiffError::CommandFailed { stderr: stderr.into_owned() });
160    }
161
162    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
163    Ok(PathBuf::from(root))
164}
165
166async fn run_git_command(repo_root: &Path, args: &[&str]) -> Result<String, GitDiffError> {
167    let output = tokio::process::Command::new("git")
168        .args(args)
169        .current_dir(repo_root)
170        .output()
171        .await
172        .map_err(|e| GitDiffError::CommandFailed { stderr: e.to_string() })?;
173
174    if !output.status.success() {
175        let stderr = String::from_utf8_lossy(&output.stderr);
176        return Err(GitDiffError::CommandFailed { stderr: stderr.into_owned() });
177    }
178
179    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
180}
181
182async fn build_untracked_file_diff(repo_root: &Path, relative_path: String) -> FileDiff {
183    let full_path = repo_root.join(&relative_path);
184    let Ok(bytes) = tokio::fs::read(&full_path).await else {
185        return binary_untracked(relative_path);
186    };
187
188    if bytes.iter().take(8192).any(|&b| b == 0) {
189        return binary_untracked(relative_path);
190    }
191
192    let Ok(content) = String::from_utf8(bytes) else {
193        return binary_untracked(relative_path);
194    };
195
196    let text_lines: Vec<&str> = content.lines().collect();
197    let line_count = text_lines.len();
198
199    let hunk_header = format!("@@ -0,0 +1,{line_count} @@");
200
201    let mut patch_lines = vec![PatchLine {
202        kind: PatchLineKind::HunkHeader,
203        text: hunk_header.clone(),
204        old_line_no: None,
205        new_line_no: None,
206    }];
207
208    for (i, line) in text_lines.iter().enumerate() {
209        patch_lines.push(PatchLine {
210            kind: PatchLineKind::Added,
211            text: (*line).to_string(),
212            old_line_no: None,
213            new_line_no: Some(i + 1),
214        });
215    }
216
217    let hunk = Hunk {
218        header: hunk_header,
219        old_start: 0,
220        old_count: 0,
221        new_start: 1,
222        new_count: line_count,
223        lines: patch_lines,
224    };
225
226    FileDiff { old_path: None, path: relative_path, status: FileStatus::Untracked, hunks: vec![hunk], binary: false }
227}
228
229fn binary_untracked(path: String) -> FileDiff {
230    FileDiff { old_path: None, path, status: FileStatus::Untracked, hunks: Vec::new(), binary: true }
231}
232
233pub(crate) fn parse_unified_diff(input: &str) -> Result<Vec<FileDiff>, GitDiffError> {
234    split_diff_files(input).into_iter().map(parse_file_diff).collect()
235}
236
237fn split_diff_files(input: &str) -> Vec<&str> {
238    let mut chunks = Vec::new();
239    let mut start = None;
240    let mut line_start = 0;
241
242    while line_start < input.len() {
243        let line_end = input[line_start..].find('\n').map_or(input.len(), |idx| line_start + idx + 1);
244        let line = &input[line_start..line_end];
245
246        if line.starts_with("diff --git ") {
247            if let Some(s) = start {
248                chunks.push(&input[s..line_start]);
249            }
250            start = Some(line_start);
251        }
252
253        line_start = line_end;
254    }
255
256    if let Some(s) = start {
257        chunks.push(&input[s..]);
258    }
259
260    chunks
261}
262
263fn parse_file_diff(chunk: &str) -> Result<FileDiff, GitDiffError> {
264    let lines: Vec<&str> = chunk.lines().collect();
265    if lines.is_empty() {
266        return Err(GitDiffError::ParseError("Empty diff chunk".to_string()));
267    }
268
269    let (old_path, new_path) = parse_diff_header(lines[0])?;
270    let (status, binary, rename_from, hunk_start) = scan_file_metadata(&lines);
271    let hunks = if binary { Vec::new() } else { parse_file_hunks(&lines[hunk_start..])? };
272
273    Ok(FileDiff { old_path: resolve_old_path(status, rename_from, old_path), path: new_path, status, hunks, binary })
274}
275
276fn scan_file_metadata(lines: &[&str]) -> (FileStatus, bool, Option<String>, usize) {
277    let mut status = FileStatus::Modified;
278    let mut binary = false;
279    let mut rename_from = None;
280    let mut i = 1;
281
282    while i < lines.len() {
283        let line = lines[i];
284        if line.starts_with("new file mode") {
285            status = FileStatus::Added;
286        } else if line.starts_with("deleted file mode") {
287            status = FileStatus::Deleted;
288        } else if let Some(from) = line.strip_prefix("rename from ") {
289            status = FileStatus::Renamed;
290            rename_from = Some(from.to_string());
291        } else if line.starts_with("rename to ") {
292            status = FileStatus::Renamed;
293        } else if line.starts_with("Binary files ") {
294            binary = true;
295        } else if line.starts_with("@@") {
296            break;
297        }
298        i += 1;
299    }
300
301    (status, binary, rename_from, i)
302}
303
304fn parse_file_hunks(lines: &[&str]) -> Result<Vec<Hunk>, GitDiffError> {
305    let mut hunks = Vec::new();
306    let mut i = 0;
307
308    while i < lines.len() {
309        if lines[i].starts_with("@@") {
310            let (hunk, consumed) = parse_hunk(&lines[i..])?;
311            hunks.push(hunk);
312            i += consumed;
313        } else {
314            i += 1;
315        }
316    }
317
318    Ok(hunks)
319}
320
321fn resolve_old_path(status: FileStatus, rename_from: Option<String>, old_path: String) -> Option<String> {
322    if status == FileStatus::Added || status == FileStatus::Untracked {
323        None
324    } else if status == FileStatus::Renamed {
325        rename_from.or(Some(old_path))
326    } else {
327        Some(old_path)
328    }
329}
330
331fn parse_diff_header(line: &str) -> Result<(String, String), GitDiffError> {
332    let rest = line
333        .strip_prefix("diff --git ")
334        .ok_or_else(|| GitDiffError::ParseError(format!("Invalid diff header: {line}")))?;
335
336    if let Some((a, b)) = rest.split_once(" b/") {
337        let old = a.strip_prefix("a/").unwrap_or(a).to_string();
338        let new = b.to_string();
339        Ok((old, new))
340    } else {
341        Err(GitDiffError::ParseError(format!("Cannot parse paths from: {line}")))
342    }
343}
344
345fn parse_hunk(lines: &[&str]) -> Result<(Hunk, usize), GitDiffError> {
346    let header = lines[0];
347    let (old_start, old_count, new_start, new_count) = parse_hunk_header(header)?;
348
349    let mut patch_lines = Vec::new();
350    patch_lines.push(PatchLine {
351        kind: PatchLineKind::HunkHeader,
352        text: header.to_string(),
353        old_line_no: None,
354        new_line_no: None,
355    });
356
357    let mut old_line = old_start;
358    let mut new_line = new_start;
359    let mut i = 1;
360
361    while i < lines.len() {
362        let line = lines[i];
363        if line.starts_with("@@") {
364            break;
365        }
366
367        if let Some(text) = line.strip_prefix('+') {
368            patch_lines.push(PatchLine {
369                kind: PatchLineKind::Added,
370                text: text.to_string(),
371                old_line_no: None,
372                new_line_no: Some(new_line),
373            });
374            new_line += 1;
375        } else if let Some(text) = line.strip_prefix('-') {
376            patch_lines.push(PatchLine {
377                kind: PatchLineKind::Removed,
378                text: text.to_string(),
379                old_line_no: Some(old_line),
380                new_line_no: None,
381            });
382            old_line += 1;
383        } else if let Some(text) = line.strip_prefix(' ') {
384            patch_lines.push(PatchLine {
385                kind: PatchLineKind::Context,
386                text: text.to_string(),
387                old_line_no: Some(old_line),
388                new_line_no: Some(new_line),
389            });
390            old_line += 1;
391            new_line += 1;
392        } else if line.starts_with('\\') {
393            patch_lines.push(PatchLine {
394                kind: PatchLineKind::Meta,
395                text: line.to_string(),
396                old_line_no: None,
397                new_line_no: None,
398            });
399        } else {
400            // Treat as context (git sometimes omits the leading space for empty lines)
401            patch_lines.push(PatchLine {
402                kind: PatchLineKind::Context,
403                text: line.to_string(),
404                old_line_no: Some(old_line),
405                new_line_no: Some(new_line),
406            });
407            old_line += 1;
408            new_line += 1;
409        }
410        i += 1;
411    }
412
413    Ok((Hunk { header: header.to_string(), old_start, old_count, new_start, new_count, lines: patch_lines }, i))
414}
415
416fn parse_hunk_header(header: &str) -> Result<(usize, usize, usize, usize), GitDiffError> {
417    // Format: @@ -old_start,old_count +new_start,new_count @@
418    let err = || GitDiffError::ParseError(format!("Invalid hunk header: {header}"));
419
420    let rest = header.strip_prefix("@@ -").ok_or_else(err)?;
421    let at_end = rest.find(" @@").ok_or_else(err)?;
422    let range_part = &rest[..at_end];
423
424    let (old_range, new_range) = range_part.split_once(" +").ok_or_else(err)?;
425
426    let (old_start, old_count) = parse_range(old_range).ok_or_else(err)?;
427    let (new_start, new_count) = parse_range(new_range).ok_or_else(err)?;
428
429    Ok((old_start, old_count, new_start, new_count))
430}
431
432fn parse_range(s: &str) -> Option<(usize, usize)> {
433    if let Some((start, count)) = s.split_once(',') {
434        Some((start.parse().ok()?, count.parse().ok()?))
435    } else {
436        let start: usize = s.parse().ok()?;
437        Some((start, 1))
438    }
439}
440
441#[cfg(test)]
442mod tests {
443    use super::*;
444
445    #[test]
446    fn parse_modified_file() {
447        let input = "\
448diff --git a/src/main.rs b/src/main.rs
449index abc1234..def5678 100644
450--- a/src/main.rs
451+++ b/src/main.rs
452@@ -1,3 +1,4 @@
453 fn main() {
454+    println!(\"hello\");
455     let x = 1;
456 }
457";
458        let files = parse_unified_diff(input).unwrap();
459        assert_eq!(files.len(), 1);
460        assert_eq!(files[0].path, "src/main.rs");
461        assert_eq!(files[0].status, FileStatus::Modified);
462        assert_eq!(files[0].additions(), 1);
463        assert_eq!(files[0].deletions(), 0);
464        assert!(!files[0].binary);
465        assert_eq!(files[0].hunks.len(), 1);
466    }
467
468    #[test]
469    fn parse_added_file() {
470        let input = "\
471diff --git a/new_file.txt b/new_file.txt
472new file mode 100644
473index 0000000..abc1234
474--- /dev/null
475+++ b/new_file.txt
476@@ -0,0 +1,2 @@
477+line one
478+line two
479";
480        let files = parse_unified_diff(input).unwrap();
481        assert_eq!(files.len(), 1);
482        assert_eq!(files[0].path, "new_file.txt");
483        assert_eq!(files[0].status, FileStatus::Added);
484        assert!(files[0].old_path.is_none());
485        assert_eq!(files[0].additions(), 2);
486        assert_eq!(files[0].deletions(), 0);
487    }
488
489    #[test]
490    fn parse_deleted_file() {
491        let input = "\
492diff --git a/old_file.txt b/old_file.txt
493deleted file mode 100644
494index abc1234..0000000
495--- a/old_file.txt
496+++ /dev/null
497@@ -1,2 +0,0 @@
498-line one
499-line two
500";
501        let files = parse_unified_diff(input).unwrap();
502        assert_eq!(files.len(), 1);
503        assert_eq!(files[0].path, "old_file.txt");
504        assert_eq!(files[0].status, FileStatus::Deleted);
505        assert_eq!(files[0].additions(), 0);
506        assert_eq!(files[0].deletions(), 2);
507    }
508
509    #[test]
510    fn parse_renamed_file() {
511        let input = "\
512diff --git a/old_name.rs b/new_name.rs
513similarity index 95%
514rename from old_name.rs
515rename to new_name.rs
516index abc1234..def5678 100644
517--- a/old_name.rs
518+++ b/new_name.rs
519@@ -1,3 +1,3 @@
520 fn main() {
521-    old();
522+    new();
523 }
524";
525        let files = parse_unified_diff(input).unwrap();
526        assert_eq!(files.len(), 1);
527        assert_eq!(files[0].path, "new_name.rs");
528        assert_eq!(files[0].status, FileStatus::Renamed);
529        assert_eq!(files[0].old_path.as_deref(), Some("old_name.rs"));
530        assert_eq!(files[0].additions(), 1);
531        assert_eq!(files[0].deletions(), 1);
532    }
533
534    #[test]
535    fn parse_hunk_header_tracking() {
536        let input = "\
537diff --git a/file.rs b/file.rs
538index abc..def 100644
539--- a/file.rs
540+++ b/file.rs
541@@ -10,4 +10,5 @@ fn context_label() {
542 context
543-removed
544+added1
545+added2
546 context
547";
548        let files = parse_unified_diff(input).unwrap();
549        let hunk = &files[0].hunks[0];
550        assert_eq!(hunk.old_start, 10);
551        assert_eq!(hunk.old_count, 4);
552        assert_eq!(hunk.new_start, 10);
553        assert_eq!(hunk.new_count, 5);
554
555        // Check line number tracking
556        let lines = &hunk.lines;
557        // HunkHeader
558        assert_eq!(lines[0].kind, PatchLineKind::HunkHeader);
559        // context at old=10, new=10
560        assert_eq!(lines[1].kind, PatchLineKind::Context);
561        assert_eq!(lines[1].old_line_no, Some(10));
562        assert_eq!(lines[1].new_line_no, Some(10));
563        // removed at old=11
564        assert_eq!(lines[2].kind, PatchLineKind::Removed);
565        assert_eq!(lines[2].old_line_no, Some(11));
566        assert_eq!(lines[2].new_line_no, None);
567        // added at new=11
568        assert_eq!(lines[3].kind, PatchLineKind::Added);
569        assert_eq!(lines[3].old_line_no, None);
570        assert_eq!(lines[3].new_line_no, Some(11));
571        // added at new=12
572        assert_eq!(lines[4].kind, PatchLineKind::Added);
573        assert_eq!(lines[4].old_line_no, None);
574        assert_eq!(lines[4].new_line_no, Some(12));
575        // context at old=12, new=13
576        assert_eq!(lines[5].kind, PatchLineKind::Context);
577        assert_eq!(lines[5].old_line_no, Some(12));
578        assert_eq!(lines[5].new_line_no, Some(13));
579    }
580
581    #[test]
582    fn parse_meta_line() {
583        let input = "\
584diff --git a/file.txt b/file.txt
585index abc..def 100644
586--- a/file.txt
587+++ b/file.txt
588@@ -1,1 +1,1 @@
589-old
590\\ No newline at end of file
591+new
592";
593        let files = parse_unified_diff(input).unwrap();
594        let hunk = &files[0].hunks[0];
595        let meta = hunk.lines.iter().find(|l| l.kind == PatchLineKind::Meta);
596        assert!(meta.is_some());
597        assert!(meta.unwrap().text.contains("No newline"));
598    }
599
600    #[test]
601    fn parse_binary_diff() {
602        let input = "\
603diff --git a/image.png b/image.png
604new file mode 100644
605index 0000000..abc1234
606Binary files /dev/null and b/image.png differ
607";
608        let files = parse_unified_diff(input).unwrap();
609        assert_eq!(files.len(), 1);
610        assert!(files[0].binary);
611        assert!(files[0].hunks.is_empty());
612    }
613
614    #[test]
615    fn parse_empty_diff() {
616        let files = parse_unified_diff("").unwrap();
617        assert!(files.is_empty());
618    }
619
620    #[test]
621    fn parse_multiple_files() {
622        let input = "\
623diff --git a/a.rs b/a.rs
624index abc..def 100644
625--- a/a.rs
626+++ b/a.rs
627@@ -1,1 +1,1 @@
628-old_a
629+new_a
630diff --git a/b.rs b/b.rs
631new file mode 100644
632index 0000000..abc1234
633--- /dev/null
634+++ b/b.rs
635@@ -0,0 +1,1 @@
636+new_b
637";
638        let files = parse_unified_diff(input).unwrap();
639        assert_eq!(files.len(), 2);
640        assert_eq!(files[0].path, "a.rs");
641        assert_eq!(files[0].status, FileStatus::Modified);
642        assert_eq!(files[1].path, "b.rs");
643        assert_eq!(files[1].status, FileStatus::Added);
644    }
645
646    #[test]
647    fn parse_diff_marker_inside_hunk_line() {
648        let input = "\
649diff --git a/file.rs b/file.rs
650index abc..def 100644
651--- a/file.rs
652+++ b/file.rs
653@@ -1,1 +1,2 @@
654 fn main() {
655+cannot parse paths from: diff --git /m)
656 }
657";
658        let files = parse_unified_diff(input).unwrap();
659        assert_eq!(files.len(), 1);
660        assert_eq!(files[0].path, "file.rs");
661        assert_eq!(files[0].status, FileStatus::Modified);
662        assert_eq!(files[0].additions(), 1);
663    }
664
665    #[test]
666    fn parse_multiple_hunks() {
667        let input = "\
668diff --git a/file.rs b/file.rs
669index abc..def 100644
670--- a/file.rs
671+++ b/file.rs
672@@ -1,3 +1,3 @@
673 fn a() {
674-    old_a();
675+    new_a();
676 }
677@@ -10,3 +10,3 @@
678 fn b() {
679-    old_b();
680+    new_b();
681 }
682";
683        let files = parse_unified_diff(input).unwrap();
684        assert_eq!(files[0].hunks.len(), 2);
685        assert_eq!(files[0].hunks[0].old_start, 1);
686        assert_eq!(files[0].hunks[1].old_start, 10);
687    }
688
689    #[test]
690    fn parse_hunk_header_without_comma() {
691        let (start, count, new_start, new_count) = parse_hunk_header("@@ -1 +1 @@ fn main()").unwrap();
692        assert_eq!(start, 1);
693        assert_eq!(count, 1);
694        assert_eq!(new_start, 1);
695        assert_eq!(new_count, 1);
696    }
697
698    #[test]
699    fn file_status_marker() {
700        assert_eq!(FileStatus::Modified.marker(), 'M');
701        assert_eq!(FileStatus::Added.marker(), 'A');
702        assert_eq!(FileStatus::Deleted.marker(), 'D');
703        assert_eq!(FileStatus::Renamed.marker(), 'R');
704        assert_eq!(FileStatus::Untracked.marker(), '?');
705    }
706
707    #[tokio::test]
708    async fn build_untracked_text_file() {
709        let dir = tempfile::tempdir().unwrap();
710        let file_path = dir.path().join("hello.txt");
711        std::fs::write(&file_path, "line one\nline two\nline three\n").unwrap();
712
713        let diff = build_untracked_file_diff(dir.path(), "hello.txt".to_string()).await;
714        assert_eq!(diff.path, "hello.txt");
715        assert!(diff.old_path.is_none());
716        assert_eq!(diff.status, FileStatus::Untracked);
717        assert!(!diff.binary);
718        assert_eq!(diff.hunks.len(), 1);
719        assert_eq!(diff.additions(), 3);
720        assert_eq!(diff.deletions(), 0);
721
722        let hunk = &diff.hunks[0];
723        assert_eq!(hunk.old_start, 0);
724        assert_eq!(hunk.old_count, 0);
725        assert_eq!(hunk.new_start, 1);
726        assert_eq!(hunk.new_count, 3);
727        assert_eq!(hunk.lines[1].new_line_no, Some(1));
728        assert_eq!(hunk.lines[1].text, "line one");
729    }
730
731    #[tokio::test]
732    async fn build_untracked_binary_file() {
733        let dir = tempfile::tempdir().unwrap();
734        let file_path = dir.path().join("image.bin");
735        std::fs::write(&file_path, b"PNG\x00\x00binary data").unwrap();
736
737        let diff = build_untracked_file_diff(dir.path(), "image.bin".to_string()).await;
738        assert_eq!(diff.status, FileStatus::Untracked);
739        assert!(diff.binary);
740        assert!(diff.hunks.is_empty());
741    }
742
743    #[tokio::test]
744    async fn build_untracked_missing_file() {
745        let dir = tempfile::tempdir().unwrap();
746        let diff = build_untracked_file_diff(dir.path(), "does_not_exist.txt".to_string()).await;
747        assert!(diff.binary);
748        assert!(diff.hunks.is_empty());
749    }
750}