Skip to main content

vcs_runner/
parse_git.rs

1use std::path::PathBuf;
2
3use crate::types::{FileChange, FileChangeKind};
4
5/// Parse `git diff --name-status` output into structured [`FileChange`] values.
6///
7/// git produces tab-separated lines — status letter, then path(s), separated
8/// by tabs. Examples: `M<TAB>path.rs`, `R100<TAB>old.rs<TAB>new.rs`.
9///
10/// The leading status letter may be followed by a similarity score
11/// (e.g. `R100`, `C75`); the score is recognized but not retained.
12///
13/// Unknown status letters are skipped. Blank lines are skipped.
14pub fn parse_git_diff_name_status(output: &str) -> Vec<FileChange> {
15    let mut changes = Vec::new();
16    for line in output.lines() {
17        let line = line.trim_end();
18        if line.is_empty() {
19            continue;
20        }
21
22        let mut parts = line.split('\t');
23        let Some(status) = parts.next() else { continue };
24
25        let kind = match status.chars().next() {
26            Some('M') => FileChangeKind::Modified,
27            Some('A') => FileChangeKind::Added,
28            Some('D') => FileChangeKind::Deleted,
29            Some('R') => FileChangeKind::Renamed,
30            Some('C') => FileChangeKind::Copied,
31            _ => continue,
32        };
33
34        match kind {
35            FileChangeKind::Renamed | FileChangeKind::Copied => {
36                let (Some(from), Some(to)) = (parts.next(), parts.next()) else {
37                    continue;
38                };
39                changes.push(FileChange {
40                    kind,
41                    path: PathBuf::from(to),
42                    from_path: Some(PathBuf::from(from)),
43                });
44            }
45            _ => {
46                let Some(path) = parts.next() else { continue };
47                changes.push(FileChange {
48                    kind,
49                    path: PathBuf::from(path),
50                    from_path: None,
51                });
52            }
53        }
54    }
55    changes
56}
57
58#[cfg(test)]
59mod tests {
60    use super::*;
61
62    #[test]
63    fn empty() {
64        assert!(parse_git_diff_name_status("").is_empty());
65    }
66
67    #[test]
68    fn modified() {
69        let changes = parse_git_diff_name_status("M\tsrc/lib.rs");
70        assert_eq!(changes.len(), 1);
71        assert_eq!(changes[0].kind, FileChangeKind::Modified);
72        assert_eq!(changes[0].path, PathBuf::from("src/lib.rs"));
73    }
74
75    #[test]
76    fn added() {
77        let changes = parse_git_diff_name_status("A\tnew.rs");
78        assert_eq!(changes[0].kind, FileChangeKind::Added);
79    }
80
81    #[test]
82    fn deleted() {
83        let changes = parse_git_diff_name_status("D\told.rs");
84        assert_eq!(changes[0].kind, FileChangeKind::Deleted);
85    }
86
87    #[test]
88    fn renamed_with_similarity_score() {
89        let changes = parse_git_diff_name_status("R100\told.rs\tnew.rs");
90        assert_eq!(changes.len(), 1);
91        assert_eq!(changes[0].kind, FileChangeKind::Renamed);
92        assert_eq!(changes[0].path, PathBuf::from("new.rs"));
93        assert_eq!(changes[0].from_path, Some(PathBuf::from("old.rs")));
94    }
95
96    #[test]
97    fn copied_with_similarity_score() {
98        let changes = parse_git_diff_name_status("C75\tfrom.rs\tto.rs");
99        assert_eq!(changes[0].kind, FileChangeKind::Copied);
100        assert_eq!(changes[0].from_path, Some(PathBuf::from("from.rs")));
101        assert_eq!(changes[0].path, PathBuf::from("to.rs"));
102    }
103
104    #[test]
105    fn multiple_lines() {
106        let output = "M\ta.rs\nA\tb.rs\nD\tc.rs\nR100\told.rs\tnew.rs";
107        let changes = parse_git_diff_name_status(output);
108        assert_eq!(changes.len(), 4);
109        assert_eq!(changes[0].kind, FileChangeKind::Modified);
110        assert_eq!(changes[3].kind, FileChangeKind::Renamed);
111    }
112
113    #[test]
114    fn skips_blank_lines() {
115        let changes = parse_git_diff_name_status("\nM\ta.rs\n\nA\tb.rs\n");
116        assert_eq!(changes.len(), 2);
117    }
118
119    #[test]
120    fn skips_unknown_status() {
121        let changes = parse_git_diff_name_status("X\tfoo.rs\nM\tbar.rs");
122        assert_eq!(changes.len(), 1);
123        assert_eq!(changes[0].path, PathBuf::from("bar.rs"));
124    }
125
126    #[test]
127    fn rename_without_second_path_skipped() {
128        let changes = parse_git_diff_name_status("R100\tjust_one.rs");
129        assert!(changes.is_empty());
130    }
131
132    #[test]
133    fn path_with_spaces_preserved() {
134        let changes = parse_git_diff_name_status("M\tpath with spaces.rs");
135        assert_eq!(changes[0].path, PathBuf::from("path with spaces.rs"));
136    }
137}