Skip to main content

wisp/
git_diff.rs

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