Skip to main content

wisp/
git_diff.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use thiserror::Error;
4
5#[doc = include_str!("docs/git_diff_document.md")]
6#[allow(dead_code)]
7pub struct GitDiffDocument {
8    pub repo_root: PathBuf,
9    pub files: Vec<FileDiff>,
10}
11
12pub struct FileDiff {
13    pub old_path: Option<String>,
14    pub path: String,
15    pub status: FileStatus,
16    pub staged: StageState,
17    pub hunks: Vec<Hunk>,
18    pub binary: bool,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
22pub enum DiffScope {
23    Unstaged,
24    Staged,
25    #[default]
26    Both,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub enum FileStatus {
31    Modified,
32    Added,
33    Deleted,
34    Renamed,
35    Untracked,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum StageState {
40    Unstaged,
41    Staged,
42    PartiallyStaged,
43}
44
45#[allow(dead_code)]
46pub struct Hunk {
47    pub header: String,
48    pub old_start: usize,
49    pub old_count: usize,
50    pub new_start: usize,
51    pub new_count: usize,
52    pub lines: Vec<PatchLine>,
53}
54
55pub struct PatchLine {
56    pub kind: PatchLineKind,
57    pub text: String,
58    pub old_line_no: Option<usize>,
59    pub new_line_no: Option<usize>,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum PatchLineKind {
64    HunkHeader,
65    Context,
66    Added,
67    Removed,
68    Meta,
69}
70
71#[doc = include_str!("docs/git_diff_error.md")]
72#[derive(Debug, Error)]
73pub enum GitDiffError {
74    #[error("Not a git repository")]
75    NotARepository,
76    #[error("Git command failed: {stderr}")]
77    CommandFailed { stderr: String },
78    #[error("Failed to parse diff: {0}")]
79    ParseError(String),
80}
81
82impl DiffScope {
83    pub fn label(self) -> &'static str {
84        match self {
85            Self::Unstaged => "Unstaged",
86            Self::Staged => "Staged",
87            Self::Both => "Both",
88        }
89    }
90
91    pub(crate) fn next(self) -> Self {
92        match self {
93            Self::Both => Self::Unstaged,
94            Self::Unstaged => Self::Staged,
95            Self::Staged => Self::Both,
96        }
97    }
98
99    fn includes_untracked(self) -> bool {
100        !matches!(self, Self::Staged)
101    }
102}
103
104impl FileStatus {
105    pub fn marker(self) -> char {
106        match self {
107            Self::Modified => 'M',
108            Self::Added => 'A',
109            Self::Deleted => 'D',
110            Self::Renamed => 'R',
111            Self::Untracked => '?',
112        }
113    }
114
115    pub fn label(self) -> &'static str {
116        match self {
117            Self::Modified => "modified",
118            Self::Added => "new file",
119            Self::Deleted => "deleted",
120            Self::Renamed => "renamed",
121            Self::Untracked => "untracked",
122        }
123    }
124}
125
126impl FileDiff {
127    pub fn additions(&self) -> usize {
128        self.hunks.iter().map(Hunk::additions).sum()
129    }
130
131    pub fn deletions(&self) -> usize {
132        self.hunks.iter().map(Hunk::deletions).sum()
133    }
134
135    pub fn max_line_no(&self) -> usize {
136        self.hunks
137            .iter()
138            .flat_map(|hunk| &hunk.lines)
139            .flat_map(|line| line.old_line_no.into_iter().chain(line.new_line_no))
140            .max()
141            .unwrap_or(0)
142    }
143}
144
145impl Hunk {
146    pub fn additions(&self) -> usize {
147        self.lines.iter().filter(|line| line.kind == PatchLineKind::Added).count()
148    }
149
150    pub fn deletions(&self) -> usize {
151        self.lines.iter().filter(|line| line.kind == PatchLineKind::Removed).count()
152    }
153}
154
155pub(crate) async fn load_git_diff(
156    working_dir: &Path,
157    cached_repo_root: Option<&Path>,
158    scope: DiffScope,
159) -> Result<GitDiffDocument, GitDiffError> {
160    let repo_root = match cached_repo_root {
161        Some(root) => root.to_path_buf(),
162        None => resolve_repo_root(working_dir).await?,
163    };
164    let diff_args = match scope {
165        DiffScope::Unstaged => &["diff", "--no-ext-diff", "--find-renames"][..],
166        DiffScope::Staged => &["diff", "--cached", "--no-ext-diff", "--find-renames"][..],
167        DiffScope::Both => &["diff", "--no-ext-diff", "--find-renames", "HEAD"][..],
168    };
169    let diff_output = git(&repo_root, diff_args).await?;
170
171    let mut files = if diff_output.trim().is_empty() { Vec::new() } else { parse_unified_diff(&diff_output)? };
172
173    if scope.includes_untracked() {
174        let untracked_stdout = git(&repo_root, &["ls-files", "--others", "--exclude-standard"]).await?;
175        for path in untracked_stdout.lines().filter(|l| !l.is_empty()).map(String::from) {
176            files.push(build_untracked_file_diff(&repo_root, path).await);
177        }
178    }
179
180    let status_map = load_status_map(&repo_root).await?;
181    for file in &mut files {
182        file.staged = status_map.get(&file.path).copied().unwrap_or(StageState::Unstaged);
183    }
184
185    Ok(GitDiffDocument { repo_root, files })
186}
187
188pub(crate) async fn stage_files(repo_root: &Path, paths: &[&str]) -> Result<(), GitDiffError> {
189    let mut args = vec!["add", "--"];
190    args.extend_from_slice(paths);
191    git(repo_root, &args).await.map(drop)
192}
193
194pub(crate) async fn unstage_files(repo_root: &Path, paths: &[&str]) -> Result<(), GitDiffError> {
195    let mut args = vec!["reset", "--quiet", "--"];
196    args.extend_from_slice(paths);
197    git(repo_root, &args).await.map(drop)
198}
199
200pub(crate) async fn stage_all(repo_root: &Path) -> Result<(), GitDiffError> {
201    git(repo_root, &["add", "-A"]).await.map(drop)
202}
203
204pub(crate) async fn unstage_all(repo_root: &Path) -> Result<(), GitDiffError> {
205    git(repo_root, &["reset", "--quiet"]).await.map(drop)
206}
207
208pub(crate) async fn discard_file(repo_root: &Path, path: &str, status: FileStatus) -> Result<(), GitDiffError> {
209    match status {
210        FileStatus::Untracked => git(repo_root, &["clean", "-f", "--", path]).await.map(drop),
211        _ => git(repo_root, &["restore", "--source=HEAD", "--staged", "--worktree", "--", path]).await.map(drop),
212    }
213}
214
215pub(crate) async fn commit(repo_root: &Path, message: &str) -> Result<(), GitDiffError> {
216    if message.trim().is_empty() {
217        return Err(GitDiffError::CommandFailed { stderr: "empty commit message".to_string() });
218    }
219    git(repo_root, &["commit", "-m", message]).await.map(drop)
220}
221
222async fn load_status_map(repo_root: &Path) -> Result<HashMap<String, StageState>, GitDiffError> {
223    let output = git(repo_root, &["status", "--porcelain=v1", "-z"]).await?;
224    Ok(parse_porcelain_status(&output))
225}
226
227fn parse_porcelain_status(input: &str) -> HashMap<String, StageState> {
228    let mut map = HashMap::new();
229    let mut tokens = input.split('\0').filter(|token| !token.is_empty());
230
231    while let Some(record) = tokens.next() {
232        if record.len() < 3 {
233            continue;
234        }
235        let bytes = record.as_bytes();
236        let index = bytes[0] as char;
237        let worktree = bytes[1] as char;
238        let path = &record[3..];
239
240        if matches!(index, 'R' | 'C') || matches!(worktree, 'R' | 'C') {
241            tokens.next();
242        }
243
244        let state = match (index, worktree) {
245            ('?', '?') | (' ', _) => StageState::Unstaged,
246            (_, ' ') => StageState::Staged,
247            _ => StageState::PartiallyStaged,
248        };
249        map.insert(path.to_string(), state);
250    }
251
252    map
253}
254
255async fn resolve_repo_root(working_dir: &Path) -> Result<PathBuf, GitDiffError> {
256    let output = tokio::process::Command::new("git")
257        .arg("rev-parse")
258        .arg("--show-toplevel")
259        .current_dir(working_dir)
260        .output()
261        .await
262        .map_err(|e| GitDiffError::CommandFailed { stderr: e.to_string() })?;
263
264    if !output.status.success() {
265        let stderr = String::from_utf8_lossy(&output.stderr);
266        if stderr.contains("not a git repository") {
267            return Err(GitDiffError::NotARepository);
268        }
269        return Err(GitDiffError::CommandFailed { stderr: stderr.into_owned() });
270    }
271
272    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
273    Ok(PathBuf::from(root))
274}
275
276async fn git(repo_root: &Path, args: &[&str]) -> Result<String, GitDiffError> {
277    let output = tokio::process::Command::new("git")
278        .args(args)
279        .current_dir(repo_root)
280        .output()
281        .await
282        .map_err(|e| GitDiffError::CommandFailed { stderr: e.to_string() })?;
283
284    if !output.status.success() {
285        let stderr = String::from_utf8_lossy(&output.stderr);
286        return Err(GitDiffError::CommandFailed { stderr: stderr.into_owned() });
287    }
288
289    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
290}
291
292async fn build_untracked_file_diff(repo_root: &Path, relative_path: String) -> FileDiff {
293    let full_path = repo_root.join(&relative_path);
294    let Ok(bytes) = tokio::fs::read(&full_path).await else {
295        return binary_untracked(relative_path);
296    };
297
298    if bytes.iter().take(8192).any(|&b| b == 0) {
299        return binary_untracked(relative_path);
300    }
301
302    let Ok(content) = String::from_utf8(bytes) else {
303        return binary_untracked(relative_path);
304    };
305
306    let text_lines: Vec<&str> = content.lines().collect();
307    let line_count = text_lines.len();
308
309    let hunk_header = format!("@@ -0,0 +1,{line_count} @@");
310
311    let mut patch_lines = vec![PatchLine {
312        kind: PatchLineKind::HunkHeader,
313        text: hunk_header.clone(),
314        old_line_no: None,
315        new_line_no: None,
316    }];
317
318    for (i, line) in text_lines.iter().enumerate() {
319        patch_lines.push(PatchLine {
320            kind: PatchLineKind::Added,
321            text: (*line).to_string(),
322            old_line_no: None,
323            new_line_no: Some(i + 1),
324        });
325    }
326
327    let hunk = Hunk {
328        header: hunk_header,
329        old_start: 0,
330        old_count: 0,
331        new_start: 1,
332        new_count: line_count,
333        lines: patch_lines,
334    };
335
336    FileDiff {
337        old_path: None,
338        path: relative_path,
339        status: FileStatus::Untracked,
340        staged: StageState::Unstaged,
341        hunks: vec![hunk],
342        binary: false,
343    }
344}
345
346fn binary_untracked(path: String) -> FileDiff {
347    FileDiff {
348        old_path: None,
349        path,
350        status: FileStatus::Untracked,
351        staged: StageState::Unstaged,
352        hunks: Vec::new(),
353        binary: true,
354    }
355}
356
357pub(crate) fn parse_unified_diff(input: &str) -> Result<Vec<FileDiff>, GitDiffError> {
358    split_diff_files(input).into_iter().map(parse_file_diff).collect()
359}
360
361fn split_diff_files(input: &str) -> Vec<&str> {
362    let mut chunks = Vec::new();
363    let mut start = None;
364    let mut line_start = 0;
365
366    while line_start < input.len() {
367        let line_end = input[line_start..].find('\n').map_or(input.len(), |idx| line_start + idx + 1);
368        let line = &input[line_start..line_end];
369
370        if line.starts_with("diff --git ") {
371            if let Some(s) = start {
372                chunks.push(&input[s..line_start]);
373            }
374            start = Some(line_start);
375        }
376
377        line_start = line_end;
378    }
379
380    if let Some(s) = start {
381        chunks.push(&input[s..]);
382    }
383
384    chunks
385}
386
387fn parse_file_diff(chunk: &str) -> Result<FileDiff, GitDiffError> {
388    let lines: Vec<&str> = chunk.lines().collect();
389    if lines.is_empty() {
390        return Err(GitDiffError::ParseError("Empty diff chunk".to_string()));
391    }
392
393    let (old_path, new_path) = parse_diff_header(lines[0])?;
394    let (status, binary, rename_from, hunk_start) = scan_file_metadata(&lines);
395    let hunks = if binary { Vec::new() } else { parse_file_hunks(&lines[hunk_start..])? };
396
397    Ok(FileDiff {
398        old_path: resolve_old_path(status, rename_from, old_path),
399        path: new_path,
400        status,
401        staged: StageState::Unstaged,
402        hunks,
403        binary,
404    })
405}
406
407fn scan_file_metadata(lines: &[&str]) -> (FileStatus, bool, Option<String>, usize) {
408    let mut status = FileStatus::Modified;
409    let mut binary = false;
410    let mut rename_from = None;
411    let mut i = 1;
412
413    while i < lines.len() {
414        let line = lines[i];
415        if line.starts_with("new file mode") {
416            status = FileStatus::Added;
417        } else if line.starts_with("deleted file mode") {
418            status = FileStatus::Deleted;
419        } else if let Some(from) = line.strip_prefix("rename from ") {
420            status = FileStatus::Renamed;
421            rename_from = Some(from.to_string());
422        } else if line.starts_with("rename to ") {
423            status = FileStatus::Renamed;
424        } else if line.starts_with("Binary files ") {
425            binary = true;
426        } else if line.starts_with("@@") {
427            break;
428        }
429        i += 1;
430    }
431
432    (status, binary, rename_from, i)
433}
434
435fn parse_file_hunks(lines: &[&str]) -> Result<Vec<Hunk>, GitDiffError> {
436    let mut hunks = Vec::new();
437    let mut i = 0;
438
439    while i < lines.len() {
440        if lines[i].starts_with("@@") {
441            let (hunk, consumed) = parse_hunk(&lines[i..])?;
442            hunks.push(hunk);
443            i += consumed;
444        } else {
445            i += 1;
446        }
447    }
448
449    Ok(hunks)
450}
451
452fn resolve_old_path(status: FileStatus, rename_from: Option<String>, old_path: String) -> Option<String> {
453    if status == FileStatus::Added || status == FileStatus::Untracked {
454        None
455    } else if status == FileStatus::Renamed {
456        rename_from.or(Some(old_path))
457    } else {
458        Some(old_path)
459    }
460}
461
462fn parse_diff_header(line: &str) -> Result<(String, String), GitDiffError> {
463    let rest = line
464        .strip_prefix("diff --git ")
465        .ok_or_else(|| GitDiffError::ParseError(format!("Invalid diff header: {line}")))?;
466
467    if let Some((a, b)) = rest.split_once(" b/") {
468        let old = a.strip_prefix("a/").unwrap_or(a).to_string();
469        let new = b.to_string();
470        Ok((old, new))
471    } else {
472        Err(GitDiffError::ParseError(format!("Cannot parse paths from: {line}")))
473    }
474}
475
476fn parse_hunk(lines: &[&str]) -> Result<(Hunk, usize), GitDiffError> {
477    let header = lines[0];
478    let (old_start, old_count, new_start, new_count) = parse_hunk_header(header)?;
479
480    let mut patch_lines = Vec::new();
481    patch_lines.push(PatchLine {
482        kind: PatchLineKind::HunkHeader,
483        text: header.to_string(),
484        old_line_no: None,
485        new_line_no: None,
486    });
487
488    let mut old_line = old_start;
489    let mut new_line = new_start;
490    let mut i = 1;
491
492    while i < lines.len() {
493        let line = lines[i];
494        if line.starts_with("@@") {
495            break;
496        }
497
498        if let Some(text) = line.strip_prefix('+') {
499            patch_lines.push(PatchLine {
500                kind: PatchLineKind::Added,
501                text: text.to_string(),
502                old_line_no: None,
503                new_line_no: Some(new_line),
504            });
505            new_line += 1;
506        } else if let Some(text) = line.strip_prefix('-') {
507            patch_lines.push(PatchLine {
508                kind: PatchLineKind::Removed,
509                text: text.to_string(),
510                old_line_no: Some(old_line),
511                new_line_no: None,
512            });
513            old_line += 1;
514        } else if let Some(text) = line.strip_prefix(' ') {
515            patch_lines.push(PatchLine {
516                kind: PatchLineKind::Context,
517                text: text.to_string(),
518                old_line_no: Some(old_line),
519                new_line_no: Some(new_line),
520            });
521            old_line += 1;
522            new_line += 1;
523        } else if line.starts_with('\\') {
524            patch_lines.push(PatchLine {
525                kind: PatchLineKind::Meta,
526                text: line.to_string(),
527                old_line_no: None,
528                new_line_no: None,
529            });
530        } else {
531            // Treat as context (git sometimes omits the leading space for empty lines)
532            patch_lines.push(PatchLine {
533                kind: PatchLineKind::Context,
534                text: line.to_string(),
535                old_line_no: Some(old_line),
536                new_line_no: Some(new_line),
537            });
538            old_line += 1;
539            new_line += 1;
540        }
541        i += 1;
542    }
543
544    Ok((Hunk { header: header.to_string(), old_start, old_count, new_start, new_count, lines: patch_lines }, i))
545}
546
547fn parse_hunk_header(header: &str) -> Result<(usize, usize, usize, usize), GitDiffError> {
548    // Format: @@ -old_start,old_count +new_start,new_count @@
549    let err = || GitDiffError::ParseError(format!("Invalid hunk header: {header}"));
550
551    let rest = header.strip_prefix("@@ -").ok_or_else(err)?;
552    let at_end = rest.find(" @@").ok_or_else(err)?;
553    let range_part = &rest[..at_end];
554
555    let (old_range, new_range) = range_part.split_once(" +").ok_or_else(err)?;
556
557    let (old_start, old_count) = parse_range(old_range).ok_or_else(err)?;
558    let (new_start, new_count) = parse_range(new_range).ok_or_else(err)?;
559
560    Ok((old_start, old_count, new_start, new_count))
561}
562
563fn parse_range(s: &str) -> Option<(usize, usize)> {
564    if let Some((start, count)) = s.split_once(',') {
565        Some((start.parse().ok()?, count.parse().ok()?))
566    } else {
567        let start: usize = s.parse().ok()?;
568        Some((start, 1))
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575
576    #[test]
577    fn parse_modified_file() {
578        let input = "\
579diff --git a/src/main.rs b/src/main.rs
580index abc1234..def5678 100644
581--- a/src/main.rs
582+++ b/src/main.rs
583@@ -1,3 +1,4 @@
584 fn main() {
585+    println!(\"hello\");
586     let x = 1;
587 }
588";
589        let files = parse_unified_diff(input).unwrap();
590        assert_eq!(files.len(), 1);
591        assert_eq!(files[0].path, "src/main.rs");
592        assert_eq!(files[0].status, FileStatus::Modified);
593        assert_eq!(files[0].additions(), 1);
594        assert_eq!(files[0].deletions(), 0);
595        assert!(!files[0].binary);
596        assert_eq!(files[0].hunks.len(), 1);
597    }
598
599    #[test]
600    fn parse_added_file() {
601        let input = "\
602diff --git a/new_file.txt b/new_file.txt
603new file mode 100644
604index 0000000..abc1234
605--- /dev/null
606+++ b/new_file.txt
607@@ -0,0 +1,2 @@
608+line one
609+line two
610";
611        let files = parse_unified_diff(input).unwrap();
612        assert_eq!(files.len(), 1);
613        assert_eq!(files[0].path, "new_file.txt");
614        assert_eq!(files[0].status, FileStatus::Added);
615        assert!(files[0].old_path.is_none());
616        assert_eq!(files[0].additions(), 2);
617        assert_eq!(files[0].deletions(), 0);
618    }
619
620    #[test]
621    fn parse_deleted_file() {
622        let input = "\
623diff --git a/old_file.txt b/old_file.txt
624deleted file mode 100644
625index abc1234..0000000
626--- a/old_file.txt
627+++ /dev/null
628@@ -1,2 +0,0 @@
629-line one
630-line two
631";
632        let files = parse_unified_diff(input).unwrap();
633        assert_eq!(files.len(), 1);
634        assert_eq!(files[0].path, "old_file.txt");
635        assert_eq!(files[0].status, FileStatus::Deleted);
636        assert_eq!(files[0].additions(), 0);
637        assert_eq!(files[0].deletions(), 2);
638    }
639
640    #[test]
641    fn parse_renamed_file() {
642        let input = "\
643diff --git a/old_name.rs b/new_name.rs
644similarity index 95%
645rename from old_name.rs
646rename to new_name.rs
647index abc1234..def5678 100644
648--- a/old_name.rs
649+++ b/new_name.rs
650@@ -1,3 +1,3 @@
651 fn main() {
652-    old();
653+    new();
654 }
655";
656        let files = parse_unified_diff(input).unwrap();
657        assert_eq!(files.len(), 1);
658        assert_eq!(files[0].path, "new_name.rs");
659        assert_eq!(files[0].status, FileStatus::Renamed);
660        assert_eq!(files[0].old_path.as_deref(), Some("old_name.rs"));
661        assert_eq!(files[0].additions(), 1);
662        assert_eq!(files[0].deletions(), 1);
663    }
664
665    #[test]
666    fn parse_hunk_header_tracking() {
667        let input = "\
668diff --git a/file.rs b/file.rs
669index abc..def 100644
670--- a/file.rs
671+++ b/file.rs
672@@ -10,4 +10,5 @@ fn context_label() {
673 context
674-removed
675+added1
676+added2
677 context
678";
679        let files = parse_unified_diff(input).unwrap();
680        let hunk = &files[0].hunks[0];
681        assert_eq!(hunk.old_start, 10);
682        assert_eq!(hunk.old_count, 4);
683        assert_eq!(hunk.new_start, 10);
684        assert_eq!(hunk.new_count, 5);
685
686        // Check line number tracking
687        let lines = &hunk.lines;
688        // HunkHeader
689        assert_eq!(lines[0].kind, PatchLineKind::HunkHeader);
690        // context at old=10, new=10
691        assert_eq!(lines[1].kind, PatchLineKind::Context);
692        assert_eq!(lines[1].old_line_no, Some(10));
693        assert_eq!(lines[1].new_line_no, Some(10));
694        // removed at old=11
695        assert_eq!(lines[2].kind, PatchLineKind::Removed);
696        assert_eq!(lines[2].old_line_no, Some(11));
697        assert_eq!(lines[2].new_line_no, None);
698        // added at new=11
699        assert_eq!(lines[3].kind, PatchLineKind::Added);
700        assert_eq!(lines[3].old_line_no, None);
701        assert_eq!(lines[3].new_line_no, Some(11));
702        // added at new=12
703        assert_eq!(lines[4].kind, PatchLineKind::Added);
704        assert_eq!(lines[4].old_line_no, None);
705        assert_eq!(lines[4].new_line_no, Some(12));
706        // context at old=12, new=13
707        assert_eq!(lines[5].kind, PatchLineKind::Context);
708        assert_eq!(lines[5].old_line_no, Some(12));
709        assert_eq!(lines[5].new_line_no, Some(13));
710    }
711
712    #[test]
713    fn parse_meta_line() {
714        let input = "\
715diff --git a/file.txt b/file.txt
716index abc..def 100644
717--- a/file.txt
718+++ b/file.txt
719@@ -1,1 +1,1 @@
720-old
721\\ No newline at end of file
722+new
723";
724        let files = parse_unified_diff(input).unwrap();
725        let hunk = &files[0].hunks[0];
726        let meta = hunk.lines.iter().find(|l| l.kind == PatchLineKind::Meta);
727        assert!(meta.is_some());
728        assert!(meta.unwrap().text.contains("No newline"));
729    }
730
731    #[test]
732    fn parse_binary_diff() {
733        let input = "\
734diff --git a/image.png b/image.png
735new file mode 100644
736index 0000000..abc1234
737Binary files /dev/null and b/image.png differ
738";
739        let files = parse_unified_diff(input).unwrap();
740        assert_eq!(files.len(), 1);
741        assert!(files[0].binary);
742        assert!(files[0].hunks.is_empty());
743    }
744
745    #[test]
746    fn parse_empty_diff() {
747        let files = parse_unified_diff("").unwrap();
748        assert!(files.is_empty());
749    }
750
751    #[test]
752    fn parse_multiple_files() {
753        let input = "\
754diff --git a/a.rs b/a.rs
755index abc..def 100644
756--- a/a.rs
757+++ b/a.rs
758@@ -1,1 +1,1 @@
759-old_a
760+new_a
761diff --git a/b.rs b/b.rs
762new file mode 100644
763index 0000000..abc1234
764--- /dev/null
765+++ b/b.rs
766@@ -0,0 +1,1 @@
767+new_b
768";
769        let files = parse_unified_diff(input).unwrap();
770        assert_eq!(files.len(), 2);
771        assert_eq!(files[0].path, "a.rs");
772        assert_eq!(files[0].status, FileStatus::Modified);
773        assert_eq!(files[1].path, "b.rs");
774        assert_eq!(files[1].status, FileStatus::Added);
775    }
776
777    #[test]
778    fn parse_diff_marker_inside_hunk_line() {
779        let input = "\
780diff --git a/file.rs b/file.rs
781index abc..def 100644
782--- a/file.rs
783+++ b/file.rs
784@@ -1,1 +1,2 @@
785 fn main() {
786+cannot parse paths from: diff --git /m)
787 }
788";
789        let files = parse_unified_diff(input).unwrap();
790        assert_eq!(files.len(), 1);
791        assert_eq!(files[0].path, "file.rs");
792        assert_eq!(files[0].status, FileStatus::Modified);
793        assert_eq!(files[0].additions(), 1);
794    }
795
796    #[test]
797    fn parse_multiple_hunks() {
798        let input = "\
799diff --git a/file.rs b/file.rs
800index abc..def 100644
801--- a/file.rs
802+++ b/file.rs
803@@ -1,3 +1,3 @@
804 fn a() {
805-    old_a();
806+    new_a();
807 }
808@@ -10,3 +10,3 @@
809 fn b() {
810-    old_b();
811+    new_b();
812 }
813";
814        let files = parse_unified_diff(input).unwrap();
815        assert_eq!(files[0].hunks.len(), 2);
816        assert_eq!(files[0].hunks[0].old_start, 1);
817        assert_eq!(files[0].hunks[1].old_start, 10);
818    }
819
820    #[test]
821    fn parse_hunk_header_without_comma() {
822        let (start, count, new_start, new_count) = parse_hunk_header("@@ -1 +1 @@ fn main()").unwrap();
823        assert_eq!(start, 1);
824        assert_eq!(count, 1);
825        assert_eq!(new_start, 1);
826        assert_eq!(new_count, 1);
827    }
828
829    #[test]
830    fn file_status_marker() {
831        assert_eq!(FileStatus::Modified.marker(), 'M');
832        assert_eq!(FileStatus::Added.marker(), 'A');
833        assert_eq!(FileStatus::Deleted.marker(), 'D');
834        assert_eq!(FileStatus::Renamed.marker(), 'R');
835        assert_eq!(FileStatus::Untracked.marker(), '?');
836    }
837
838    #[tokio::test]
839    async fn build_untracked_text_file() {
840        let dir = tempfile::tempdir().unwrap();
841        let file_path = dir.path().join("hello.txt");
842        std::fs::write(&file_path, "line one\nline two\nline three\n").unwrap();
843
844        let diff = build_untracked_file_diff(dir.path(), "hello.txt".to_string()).await;
845        assert_eq!(diff.path, "hello.txt");
846        assert!(diff.old_path.is_none());
847        assert_eq!(diff.status, FileStatus::Untracked);
848        assert!(!diff.binary);
849        assert_eq!(diff.hunks.len(), 1);
850        assert_eq!(diff.additions(), 3);
851        assert_eq!(diff.deletions(), 0);
852
853        let hunk = &diff.hunks[0];
854        assert_eq!(hunk.old_start, 0);
855        assert_eq!(hunk.old_count, 0);
856        assert_eq!(hunk.new_start, 1);
857        assert_eq!(hunk.new_count, 3);
858        assert_eq!(hunk.lines[1].new_line_no, Some(1));
859        assert_eq!(hunk.lines[1].text, "line one");
860    }
861
862    #[tokio::test]
863    async fn build_untracked_binary_file() {
864        let dir = tempfile::tempdir().unwrap();
865        let file_path = dir.path().join("image.bin");
866        std::fs::write(&file_path, b"PNG\x00\x00binary data").unwrap();
867
868        let diff = build_untracked_file_diff(dir.path(), "image.bin".to_string()).await;
869        assert_eq!(diff.status, FileStatus::Untracked);
870        assert!(diff.binary);
871        assert!(diff.hunks.is_empty());
872    }
873
874    #[tokio::test]
875    async fn build_untracked_missing_file() {
876        let dir = tempfile::tempdir().unwrap();
877        let diff = build_untracked_file_diff(dir.path(), "does_not_exist.txt".to_string()).await;
878        assert!(diff.binary);
879        assert!(diff.hunks.is_empty());
880    }
881
882    async fn init_repo() -> tempfile::TempDir {
883        let dir = tempfile::tempdir().unwrap();
884        let path = dir.path();
885        git(path, &["init", "--quiet"]).await.unwrap();
886        git(path, &["config", "user.email", "test@example.com"]).await.unwrap();
887        git(path, &["config", "user.name", "Test"]).await.unwrap();
888        git(path, &["config", "commit.gpgsign", "false"]).await.unwrap();
889        dir
890    }
891
892    async fn write_file(dir: &Path, name: &str, content: &str) {
893        tokio::fs::write(dir.join(name), content).await.unwrap();
894    }
895
896    async fn commit_file(dir: &Path, name: &str, content: &str, message: &str) {
897        write_file(dir, name, content).await;
898        git(dir, &["add", "-A"]).await.unwrap();
899        git(dir, &["commit", "--quiet", "-m", message]).await.unwrap();
900    }
901
902    fn staged_of(doc: &GitDiffDocument, path: &str) -> StageState {
903        doc.files.iter().find(|file| file.path == path).unwrap_or_else(|| panic!("{path} not in diff")).staged
904    }
905
906    #[tokio::test]
907    async fn stage_toggles_status() {
908        let repo = init_repo().await;
909        let dir = repo.path();
910        commit_file(dir, "a.txt", "one\n", "init").await;
911        write_file(dir, "a.txt", "one\ntwo\n").await;
912
913        let doc = load_git_diff(dir, None, DiffScope::Both).await.unwrap();
914        assert_eq!(staged_of(&doc, "a.txt"), StageState::Unstaged);
915
916        stage_files(dir, &["a.txt"]).await.unwrap();
917        let doc = load_git_diff(dir, None, DiffScope::Both).await.unwrap();
918        assert_eq!(staged_of(&doc, "a.txt"), StageState::Staged);
919
920        unstage_files(dir, &["a.txt"]).await.unwrap();
921        let doc = load_git_diff(dir, None, DiffScope::Both).await.unwrap();
922        assert_eq!(staged_of(&doc, "a.txt"), StageState::Unstaged);
923    }
924
925    #[tokio::test]
926    async fn stage_all_marks_all_staged() {
927        let repo = init_repo().await;
928        let dir = repo.path();
929        commit_file(dir, "a.txt", "a1\n", "init").await;
930        commit_file(dir, "b.txt", "b1\n", "add b").await;
931        write_file(dir, "a.txt", "a2\n").await;
932        write_file(dir, "b.txt", "b2\n").await;
933        write_file(dir, "c.txt", "c1\n").await;
934
935        stage_all(dir).await.unwrap();
936        let doc = load_git_diff(dir, None, DiffScope::Both).await.unwrap();
937        assert_eq!(staged_of(&doc, "a.txt"), StageState::Staged);
938        assert_eq!(staged_of(&doc, "b.txt"), StageState::Staged);
939        assert_eq!(staged_of(&doc, "c.txt"), StageState::Staged);
940    }
941
942    #[tokio::test]
943    async fn commit_creates_commit_and_clears_staged() {
944        let repo = init_repo().await;
945        let dir = repo.path();
946        commit_file(dir, "a.txt", "one\n", "init").await;
947        write_file(dir, "a.txt", "one\ntwo\n").await;
948        stage_files(dir, &["a.txt"]).await.unwrap();
949        commit(dir, "second commit").await.unwrap();
950        let log = git(dir, &["log", "--oneline", "-1"]).await.unwrap();
951        assert!(log.contains("second commit"), "log was: {log}");
952
953        let doc = load_git_diff(dir, None, DiffScope::Both).await.unwrap();
954        assert!(doc.files.is_empty(), "expected clean tree, got {} files", doc.files.len());
955    }
956
957    #[tokio::test]
958    async fn discard_reverts_tracked_file() {
959        let repo = init_repo().await;
960        let dir = repo.path();
961        commit_file(dir, "a.txt", "v1\n", "init").await;
962        write_file(dir, "a.txt", "v2\n").await;
963        discard_file(dir, "a.txt", FileStatus::Modified).await.unwrap();
964        let content = tokio::fs::read_to_string(dir.join("a.txt")).await.unwrap();
965        assert_eq!(content, "v1\n");
966    }
967
968    #[tokio::test]
969    async fn discard_removes_untracked_file() {
970        let repo = init_repo().await;
971        let dir = repo.path();
972        commit_file(dir, "a.txt", "a1\n", "init").await;
973        write_file(dir, "new.txt", "scratch\n").await;
974        discard_file(dir, "new.txt", FileStatus::Untracked).await.unwrap();
975        assert!(!dir.join("new.txt").exists());
976    }
977
978    #[tokio::test]
979    async fn commit_with_empty_message_is_rejected() {
980        let repo = init_repo().await;
981        assert!(commit(repo.path(), "   ").await.is_err());
982    }
983
984    #[tokio::test]
985    async fn unstage_on_unborn_branch_untracks_file() {
986        let repo = init_repo().await;
987        let dir = repo.path();
988        write_file(dir, "a.txt", "hi\n").await;
989        git(dir, &["add", "a.txt"]).await.unwrap();
990        unstage_files(dir, &["a.txt"]).await.unwrap();
991        let status = git(dir, &["status", "--porcelain"]).await.unwrap();
992        assert!(status.contains("?? a.txt"), "status was: {status}");
993    }
994
995    #[tokio::test]
996    async fn load_git_diff_filters_changes_by_scope() {
997        let repo = init_repo().await;
998        let dir = repo.path();
999        commit_file(dir, "a.txt", "one\n", "init").await;
1000
1001        write_file(dir, "a.txt", "two\n").await;
1002        stage_files(dir, &["a.txt"]).await.unwrap();
1003        write_file(dir, "a.txt", "three\n").await;
1004        write_file(dir, "untracked.txt", "scratch\n").await;
1005
1006        let unstaged = load_git_diff(dir, None, DiffScope::Unstaged).await.unwrap();
1007        assert_eq!(
1008            unstaged.files.iter().map(|file| file.path.as_str()).collect::<Vec<_>>(),
1009            vec!["a.txt", "untracked.txt"]
1010        );
1011        assert!(
1012            unstaged
1013                .files
1014                .iter()
1015                .any(|file| file.path == "a.txt" && file.hunks[0].lines.iter().any(|line| line.text == "three"))
1016        );
1017
1018        let staged = load_git_diff(dir, None, DiffScope::Staged).await.unwrap();
1019        assert_eq!(staged.files.iter().map(|file| file.path.as_str()).collect::<Vec<_>>(), vec!["a.txt"]);
1020        assert!(staged.files[0].hunks[0].lines.iter().any(|line| line.text == "two"));
1021
1022        let both = load_git_diff(dir, None, DiffScope::Both).await.unwrap();
1023        assert_eq!(
1024            both.files.iter().map(|file| file.path.as_str()).collect::<Vec<_>>(),
1025            vec!["a.txt", "untracked.txt"]
1026        );
1027        assert!(
1028            both.files
1029                .iter()
1030                .any(|file| file.path == "a.txt" && file.hunks[0].lines.iter().any(|line| line.text == "three"))
1031        );
1032    }
1033
1034    #[test]
1035    fn parse_porcelain_status_handles_rename() {
1036        let map = parse_porcelain_status("R  new.txt\0old.txt\0 M other.txt\0");
1037        assert_eq!(map.get("new.txt"), Some(&StageState::Staged));
1038        assert_eq!(map.get("old.txt"), None);
1039        assert_eq!(map.get("other.txt"), Some(&StageState::Unstaged));
1040    }
1041
1042    #[test]
1043    fn parse_porcelain_status_classifies_states() {
1044        let map = parse_porcelain_status("M  staged.txt\0 M unstaged.txt\0MM partial.txt\0?? untracked.txt\0");
1045        assert_eq!(map.get("staged.txt"), Some(&StageState::Staged));
1046        assert_eq!(map.get("unstaged.txt"), Some(&StageState::Unstaged));
1047        assert_eq!(map.get("partial.txt"), Some(&StageState::PartiallyStaged));
1048        assert_eq!(map.get("untracked.txt"), Some(&StageState::Unstaged));
1049    }
1050}