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//! A rename or copy (signalled in the index column `X`) adds one more
6//! NUL-terminated field: the entry's path is the new (destination) path and
7//! the extra field is the original path.
8
9use crate::error::{Error, Result};
10
11/// A single parsed entry from `git status --porcelain=v1 -z`.
12#[derive(Debug, Clone, PartialEq, Eq)]
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
14pub struct StatusEntry {
15    /// Status of the index (the "X" field of `XY`).
16    pub index: StatusKind,
17    /// Status of the working tree (the "Y" field of `XY`).
18    pub worktree: StatusKind,
19    /// Affected path (post-rename, if applicable).
20    pub path: String,
21    /// Original path for renames/copies, if present.
22    pub original_path: Option<String>,
23}
24
25/// Classification of a status character.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
28pub enum StatusKind {
29    /// Unmodified (`' '`).
30    Unmodified,
31    /// Modified (`M`).
32    Modified,
33    /// Added (`A`).
34    Added,
35    /// Deleted (`D`).
36    Deleted,
37    /// Renamed (`R`).
38    Renamed,
39    /// Copied (`C`).
40    Copied,
41    /// Unmerged (`U`).
42    Unmerged,
43    /// Untracked (`?`).
44    Untracked,
45    /// Ignored (`!`).
46    Ignored,
47    /// Type-changed (`T`).
48    TypeChanged,
49    /// Some other character not recognized.
50    Other(char),
51}
52
53impl From<char> for StatusKind {
54    fn from(c: char) -> Self {
55        match c {
56            ' ' => Self::Unmodified,
57            'M' => Self::Modified,
58            'A' => Self::Added,
59            'D' => Self::Deleted,
60            'R' => Self::Renamed,
61            'C' => Self::Copied,
62            'U' => Self::Unmerged,
63            '?' => Self::Untracked,
64            '!' => Self::Ignored,
65            'T' => Self::TypeChanged,
66            c => Self::Other(c),
67        }
68    }
69}
70
71/// Parse the output of `git status --porcelain=v1 -z`.
72///
73/// # Errors
74/// Returns [`Error::ParseError`] if an entry is malformed.
75///
76/// # Example
77/// ```
78/// use git_spawn::parse::{parse_status, StatusKind};
79/// // Three entries: modified index+worktree, added, untracked.
80/// let input = "MM a.txt\0A  b.txt\0?? c.txt\0";
81/// let entries = parse_status(input).unwrap();
82/// assert_eq!(entries.len(), 3);
83/// assert_eq!(entries[0].index, StatusKind::Modified);
84/// assert_eq!(entries[0].worktree, StatusKind::Modified);
85/// assert_eq!(entries[2].index, StatusKind::Untracked);
86/// ```
87pub fn parse_status(input: &str) -> Result<Vec<StatusEntry>> {
88    let mut out = Vec::new();
89    let mut iter = input.split('\0').peekable();
90    while let Some(record) = iter.next() {
91        if record.is_empty() {
92            continue;
93        }
94        let mut chars = record.chars();
95        let x = chars
96            .next()
97            .ok_or_else(|| Error::parse_error("status entry missing X field"))?;
98        let y = chars
99            .next()
100            .ok_or_else(|| Error::parse_error("status entry missing Y field"))?;
101        // Expect a single space separator, then the path.
102        if chars.next() != Some(' ') {
103            return Err(Error::parse_error(
104                "status entry missing space after XY field",
105            ));
106        }
107        let path: String = chars.collect();
108        let kind_x = StatusKind::from(x);
109        let kind_y = StatusKind::from(y);
110        // In porcelain v1 -z, a rename or copy is signalled in the index
111        // column (X) and is followed by exactly one extra NUL-terminated
112        // field: the original path. The path on the XY record itself is the
113        // new (destination) path. git does not run rename detection against
114        // the worktree, so the worktree column (Y) never carries R/C in v1 --
115        // only X drives the extra read. Keying on Y as well would consume the
116        // following entry's record as a phantom original path.
117        let original = if matches!(kind_x, StatusKind::Renamed | StatusKind::Copied) {
118            iter.next().map(str::to_string)
119        } else {
120            None
121        };
122        out.push(StatusEntry {
123            index: kind_x,
124            worktree: kind_y,
125            path,
126            original_path: original,
127        });
128    }
129    Ok(out)
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn parses_simple_entries() {
138        let input = "MM a.txt\0A  b.txt\0?? c.txt\0";
139        let entries = parse_status(input).unwrap();
140        assert_eq!(entries.len(), 3);
141        assert_eq!(entries[0].path, "a.txt");
142        assert_eq!(entries[1].index, StatusKind::Added);
143        assert_eq!(entries[1].worktree, StatusKind::Unmodified);
144        assert_eq!(entries[2].index, StatusKind::Untracked);
145    }
146
147    #[test]
148    fn parses_rename_with_original() {
149        let input = "R  new.txt\0old.txt\0";
150        let entries = parse_status(input).unwrap();
151        assert_eq!(entries.len(), 1);
152        assert_eq!(entries[0].index, StatusKind::Renamed);
153        assert_eq!(entries[0].path, "new.txt");
154        assert_eq!(entries[0].original_path.as_deref(), Some("old.txt"));
155    }
156
157    #[test]
158    fn parses_rename_with_worktree_modification() {
159        // Real git emits `RM <new>\0<old>\0` when a staged rename is also
160        // modified in the worktree. The original-path field must be consumed
161        // so the following entry parses cleanly.
162        let input = "RM new.txt\0old.txt\0MM other.txt\0";
163        let entries = parse_status(input).unwrap();
164        assert_eq!(entries.len(), 2);
165        assert_eq!(entries[0].index, StatusKind::Renamed);
166        assert_eq!(entries[0].worktree, StatusKind::Modified);
167        assert_eq!(entries[0].path, "new.txt");
168        assert_eq!(entries[0].original_path.as_deref(), Some("old.txt"));
169        assert_eq!(entries[1].path, "other.txt");
170        assert_eq!(entries[1].original_path, None);
171    }
172
173    #[test]
174    fn parses_copy_with_original() {
175        let input = "C  copy.txt\0src.txt\0";
176        let entries = parse_status(input).unwrap();
177        assert_eq!(entries.len(), 1);
178        assert_eq!(entries[0].index, StatusKind::Copied);
179        assert_eq!(entries[0].path, "copy.txt");
180        assert_eq!(entries[0].original_path.as_deref(), Some("src.txt"));
181    }
182
183    #[test]
184    fn empty_input_yields_no_entries() {
185        assert!(parse_status("").unwrap().is_empty());
186    }
187
188    #[test]
189    fn malformed_missing_space_errors() {
190        let input = "MMa.txt\0";
191        assert!(parse_status(input).is_err());
192    }
193}