Skip to main content

oxicode/tui_vt/git_tui/
state.rs

1//! Git status — `git status --porcelain -z` parsing.
2//!
3//! Pure data model — no terminal I/O. Consumed by the git TUI overlay.
4
5/// One entry parsed from `git status --porcelain -z`.
6///
7/// `-z` mode uses NUL (`\0`) as the record separator. Renames produce two
8/// consecutive records: the status line (`XY old\0`) followed by the new
9/// path (`new\0`). Paths are emitted verbatim — `-z` mode does NOT escape or
10/// quote them.
11#[derive(Debug, Default, Clone, PartialEq, Eq)]
12pub struct StatusEntry {
13    /// Final path on disk (the new path on renames).
14    pub path: String,
15    /// Previous path on renames; `None` otherwise.
16    pub old_path: Option<String>,
17    /// Two-character status code. `X` is the staged index status, `Y` is
18    /// the unstaged worktree status. A literal space denotes "no change".
19    pub xy: [char; 2],
20    /// `true` when `R` appears in `xy`.
21    pub is_rename: bool,
22    /// `true` when either index slot or worktree slot is `U`, or when the
23    /// pair is `AA`/`DD` (unmerged add/add or delete/delete conflicts).
24    pub is_unmerged: bool,
25}
26
27/// Parse the raw bytes of `git status --porcelain -z` into [`StatusEntry`]s.
28///
29/// Records are separated by NUL bytes. Each non-rename entry is exactly one
30/// record: `XY path\0`. A rename is two consecutive records: `XY old\0new\0`
31/// — the second record carries only the new path (no status code) and is
32/// merged into the previous entry.
33pub fn parse_status_porcelain_z(data: &[u8]) -> Vec<StatusEntry> {
34    // Split on NUL; trailing empty token (from a final NUL) is dropped.
35    let parts: Vec<&[u8]> = data.split(|b| *b == 0).filter(|p| !p.is_empty()).collect();
36
37    let mut out = Vec::new();
38    let mut i = 0;
39    while i < parts.len() {
40        let rec = parts[i];
41        if rec.len() < 3 {
42            // Malformed — skip.
43            i += 1;
44            continue;
45        }
46        let x = rec[0] as char;
47        let y = rec[1] as char;
48        // `git status --porcelain -z` keeps the single space separator
49        // between the XY status code and the path. Skip it.
50        let path_bytes = if rec.len() > 3 && rec[2] == b' ' {
51            &rec[3..]
52        } else {
53            &rec[2..]
54        };
55        let path = std::str::from_utf8(path_bytes)
56            .map(str::to_string)
57            .unwrap_or_default();
58
59        let is_rename = x == 'R' || y == 'R';
60
61        // `git status --porcelain -z` emits renames as two consecutive
62        // NUL-separated records: the status record (`XY<space>old_path\0`)
63        // followed by a path-only record containing only the new path
64        // (`new_path\0`). When the XY has `R` in either slot we consume
65        // the next record as the new path.
66        let (final_path, old_path) = if is_rename {
67            if let Some(next) = parts.get(i + 1) {
68                let new_path = std::str::from_utf8(next)
69                    .map(str::to_string)
70                    .unwrap_or_default();
71                (new_path, Some(path))
72            } else {
73                // Rename at end of stream with no follow-up — fall back to
74                // the original path (no rename visible).
75                (path, None)
76            }
77        } else {
78            (path, None)
79        };
80
81        let merged = StatusEntry {
82            path: final_path,
83            old_path,
84            xy: [x, y],
85            is_rename,
86            is_unmerged: detect_unmerged(x, y),
87        };
88
89        if is_rename && i + 1 < parts.len() {
90            // Consume the follow-up path token.
91            out.push(merged);
92            i += 2;
93        } else {
94            out.push(merged);
95            i += 1;
96        }
97    }
98
99    out
100}
101
102fn detect_unmerged(x: char, y: char) -> bool {
103    // Per `git status --porcelain`: any of the resolved XY codes that signal
104    // an in-progress conflict between the index and the worktree. The two
105    // `U` wildcards already cover every cross-pair (AU / UA / DU / UD /
106    // UT / TU), so the explicit list below only enumerates the non-`U`
107    // symmetric and asymmetric AA/AD/DA/DD conflicts. Together these flag
108    // every "user intervention required before commit" combination.
109    matches!(
110        (x, y),
111        ('U', _) | (_, 'U') | ('A', 'A') | ('A', 'D') | ('D', 'A') | ('D', 'D')
112    )
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    #[test]
120    fn porcelain_z_parse_handles_rename_and_unmerged() {
121        // Three records: normal M file, rename R  old → new, unmerged UU path.
122        let mut bytes: Vec<u8> = Vec::new();
123        bytes.extend_from_slice(b"M  file.rs\0");
124        bytes.extend_from_slice(b"R  old.rs\0new.rs\0");
125        bytes.extend_from_slice(b"UU conflicted.txt\0");
126
127        let entries = parse_status_porcelain_z(&bytes);
128        assert_eq!(entries.len(), 3);
129
130        // 1) normal entry
131        assert_eq!(entries[0].path, "file.rs");
132        assert_eq!(entries[0].old_path, None);
133        assert_eq!(entries[0].xy, ['M', ' ']);
134        assert!(!entries[0].is_rename);
135        assert!(!entries[0].is_unmerged);
136
137        // 2) rename — new path becomes `path`, old_path becomes the source.
138        assert_eq!(entries[1].path, "new.rs");
139        assert_eq!(entries[1].old_path.as_deref(), Some("old.rs"));
140        assert_eq!(entries[1].xy, ['R', ' ']);
141        assert!(entries[1].is_rename);
142        assert!(!entries[1].is_unmerged);
143
144        // 3) unmerged UU
145        assert_eq!(entries[2].path, "conflicted.txt");
146        assert_eq!(entries[2].old_path, None);
147        assert_eq!(entries[2].xy, ['U', 'U']);
148        assert!(!entries[2].is_rename);
149        assert!(entries[2].is_unmerged);
150    }
151
152    #[test]
153    fn porcelain_z_parse_flags_au_and_ut_as_unmerged() {
154        // AU (add by us / unmerged) and UT (unmerged / type-change) are
155        // symmetric unmerged indicators that the original detection
156        // predicate missed.
157        let mut bytes: Vec<u8> = Vec::new();
158        bytes.extend_from_slice(b"AU halfmerged.txt\0");
159        bytes.extend_from_slice(b"UT typechanged.bin\0");
160
161        let entries = parse_status_porcelain_z(&bytes);
162        assert_eq!(entries.len(), 2);
163
164        assert_eq!(entries[0].xy, ['A', 'U']);
165        assert!(!entries[0].is_rename);
166        assert!(entries[0].is_unmerged, "AU must be flagged unmerged");
167
168        assert_eq!(entries[1].xy, ['U', 'T']);
169        assert!(!entries[1].is_rename);
170        assert!(entries[1].is_unmerged, "UT must be flagged unmerged");
171    }
172}