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