Skip to main content

git_spawn/parse/
status.rs

1//! Parser for `git status --porcelain=v1 -z`.
2//!
3//! Porcelain v1 is stable across git versions. Each entry is two status
4//! characters (`XY`) followed by a space, the path, and a NUL terminator.
5//! Renames and copies add a second NUL-terminated path.
6
7use crate::error::{Error, Result};
8
9/// A single parsed entry from `git status --porcelain=v1 -z`.
10#[derive(Debug, Clone, PartialEq, Eq)]
11#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
12pub struct StatusEntry {
13    /// Status of the index (the "X" field of `XY`).
14    pub index: StatusKind,
15    /// Status of the working tree (the "Y" field of `XY`).
16    pub worktree: StatusKind,
17    /// Affected path (post-rename, if applicable).
18    pub path: String,
19    /// Original path for renames/copies, if present.
20    pub original_path: Option<String>,
21}
22
23/// Classification of a status character.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
26pub enum StatusKind {
27    /// Unmodified (`' '`).
28    Unmodified,
29    /// Modified (`M`).
30    Modified,
31    /// Added (`A`).
32    Added,
33    /// Deleted (`D`).
34    Deleted,
35    /// Renamed (`R`).
36    Renamed,
37    /// Copied (`C`).
38    Copied,
39    /// Unmerged (`U`).
40    Unmerged,
41    /// Untracked (`?`).
42    Untracked,
43    /// Ignored (`!`).
44    Ignored,
45    /// Type-changed (`T`).
46    TypeChanged,
47    /// Some other character not recognized.
48    Other(char),
49}
50
51impl From<char> for StatusKind {
52    fn from(c: char) -> Self {
53        match c {
54            ' ' => Self::Unmodified,
55            'M' => Self::Modified,
56            'A' => Self::Added,
57            'D' => Self::Deleted,
58            'R' => Self::Renamed,
59            'C' => Self::Copied,
60            'U' => Self::Unmerged,
61            '?' => Self::Untracked,
62            '!' => Self::Ignored,
63            'T' => Self::TypeChanged,
64            c => Self::Other(c),
65        }
66    }
67}
68
69/// Parse the output of `git status --porcelain=v1 -z`.
70///
71/// # Errors
72/// Returns [`Error::ParseError`] if an entry is malformed.
73///
74/// # Example
75/// ```
76/// use git_spawn::parse::{parse_status, StatusKind};
77/// // Three entries: modified index+worktree, added, untracked.
78/// let input = "MM a.txt\0A  b.txt\0?? c.txt\0";
79/// let entries = parse_status(input).unwrap();
80/// assert_eq!(entries.len(), 3);
81/// assert_eq!(entries[0].index, StatusKind::Modified);
82/// assert_eq!(entries[0].worktree, StatusKind::Modified);
83/// assert_eq!(entries[2].index, StatusKind::Untracked);
84/// ```
85pub fn parse_status(input: &str) -> Result<Vec<StatusEntry>> {
86    let mut out = Vec::new();
87    let mut iter = input.split('\0').peekable();
88    while let Some(record) = iter.next() {
89        if record.is_empty() {
90            continue;
91        }
92        let mut chars = record.chars();
93        let x = chars
94            .next()
95            .ok_or_else(|| Error::parse_error("status entry missing X field"))?;
96        let y = chars
97            .next()
98            .ok_or_else(|| Error::parse_error("status entry missing Y field"))?;
99        // Expect a single space separator, then the path.
100        if chars.next() != Some(' ') {
101            return Err(Error::parse_error(
102                "status entry missing space after XY field",
103            ));
104        }
105        let path: String = chars.collect();
106        let kind_x = StatusKind::from(x);
107        let kind_y = StatusKind::from(y);
108        let original = if matches!(kind_x, StatusKind::Renamed | StatusKind::Copied)
109            || matches!(kind_y, StatusKind::Renamed | StatusKind::Copied)
110        {
111            iter.next().map(str::to_string)
112        } else {
113            None
114        };
115        out.push(StatusEntry {
116            index: kind_x,
117            worktree: kind_y,
118            path,
119            original_path: original,
120        });
121    }
122    Ok(out)
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128
129    #[test]
130    fn parses_simple_entries() {
131        let input = "MM a.txt\0A  b.txt\0?? c.txt\0";
132        let entries = parse_status(input).unwrap();
133        assert_eq!(entries.len(), 3);
134        assert_eq!(entries[0].path, "a.txt");
135        assert_eq!(entries[1].index, StatusKind::Added);
136        assert_eq!(entries[1].worktree, StatusKind::Unmodified);
137        assert_eq!(entries[2].index, StatusKind::Untracked);
138    }
139
140    #[test]
141    fn parses_rename_with_original() {
142        let input = "R  new.txt\0old.txt\0";
143        let entries = parse_status(input).unwrap();
144        assert_eq!(entries.len(), 1);
145        assert_eq!(entries[0].index, StatusKind::Renamed);
146        assert_eq!(entries[0].path, "new.txt");
147        assert_eq!(entries[0].original_path.as_deref(), Some("old.txt"));
148    }
149
150    #[test]
151    fn empty_input_yields_no_entries() {
152        assert!(parse_status("").unwrap().is_empty());
153    }
154
155    #[test]
156    fn malformed_missing_space_errors() {
157        let input = "MMa.txt\0";
158        assert!(parse_status(input).is_err());
159    }
160}