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}