1use std::path::{Path, PathBuf};
2
3#[cfg(unix)]
4use std::ffi::OsString;
5
6use termesh_core::{
7 GitBranchStatus, GitChangeKind, GitFailure, GitFailureKind, GitFileStatus, GitResult,
8};
9
10pub fn parse_status(root: &Path, bytes: &[u8]) -> GitResult<(GitBranchStatus, Vec<GitFileStatus>)> {
12 let mut branch = GitBranchStatus::default();
13 let mut files = Vec::new();
14 let mut records = bytes.split(|byte| *byte == 0).filter(|record| !record.is_empty());
15
16 while let Some(record) = records.next() {
17 match record.first().copied() {
18 Some(b'#') => parse_branch_header(root, record, &mut branch)?,
19 Some(b'1') => files.push(parse_ordinary(root, record)?),
20 Some(b'2') => {
21 let original = records
22 .next()
23 .ok_or_else(|| invalid(root, "rename record is missing its original path"))?;
24 files.push(parse_renamed(root, record, original)?);
25 }
26 Some(b'u') => files.push(parse_unmerged(root, record)?),
27 Some(b'?') => {
28 let path = record
29 .strip_prefix(b"? ")
30 .ok_or_else(|| invalid(root, "malformed untracked record"))?;
31 files.push(GitFileStatus {
32 path: path_from_git_bytes(root, path)?,
33 index: None,
34 worktree: Some(GitChangeKind::Untracked),
35 });
36 }
37 Some(b'!') => {}
38 Some(kind) => {
39 return Err(invalid(root, &format!("unknown porcelain-v2 record type {kind:?}")));
40 }
41 None => {}
42 }
43 }
44
45 files.sort_by(|left, right| left.path.cmp(&right.path));
46 Ok((branch, files))
47}
48
49fn parse_branch_header(root: &Path, record: &[u8], branch: &mut GitBranchStatus) -> GitResult<()> {
50 let text = std::str::from_utf8(record)
51 .map_err(|_| invalid(root, "branch header is not valid UTF-8"))?;
52 if let Some(oid) = text.strip_prefix("# branch.oid ") {
53 branch.oid = (oid != "(initial)").then(|| oid.to_owned());
54 } else if let Some(head) = text.strip_prefix("# branch.head ") {
55 if head == "(detached)" {
56 branch.detached = true;
57 branch.head = None;
58 } else {
59 branch.detached = false;
60 branch.head = Some(head.to_owned());
61 }
62 } else if let Some(upstream) = text.strip_prefix("# branch.upstream ") {
63 branch.upstream = Some(upstream.to_owned());
64 } else if let Some(counts) = text.strip_prefix("# branch.ab ") {
65 let mut parts = counts.split_whitespace();
66 branch.ahead = parse_count(root, parts.next(), '+', "ahead")?;
67 branch.behind = parse_count(root, parts.next(), '-', "behind")?;
68 if parts.next().is_some() {
69 return Err(invalid(root, "branch.ab has extra fields"));
70 }
71 }
72 Ok(())
73}
74
75fn parse_count(root: &Path, value: Option<&str>, prefix: char, name: &str) -> GitResult<usize> {
76 let value = value.ok_or_else(|| invalid(root, &format!("branch.ab is missing {name}")))?;
77 let digits = value
78 .strip_prefix(prefix)
79 .ok_or_else(|| invalid(root, &format!("branch.ab has malformed {name}")))?;
80 digits.parse().map_err(|_| invalid(root, &format!("branch.ab has invalid {name}")))
81}
82
83fn parse_ordinary(root: &Path, record: &[u8]) -> GitResult<GitFileStatus> {
84 let fields = fields(root, record, 9, "ordinary")?;
85 let xy = parse_xy(root, fields[1], "ordinary")?;
86 Ok(GitFileStatus {
87 path: path_from_git_bytes(root, fields[8])?,
88 index: change(root, xy[0], None)?,
89 worktree: change(root, xy[1], None)?,
90 })
91}
92
93fn parse_renamed(root: &Path, record: &[u8], original: &[u8]) -> GitResult<GitFileStatus> {
94 let fields = fields(root, record, 10, "rename")?;
95 let xy = parse_xy(root, fields[1], "rename")?;
96 let original = path_from_git_bytes(root, original)?;
97 Ok(GitFileStatus {
98 path: path_from_git_bytes(root, fields[9])?,
99 index: change(root, xy[0], Some(&original))?,
100 worktree: change(root, xy[1], Some(&original))?,
101 })
102}
103
104fn parse_unmerged(root: &Path, record: &[u8]) -> GitResult<GitFileStatus> {
105 let fields = fields(root, record, 11, "unmerged")?;
106 let _ = parse_xy(root, fields[1], "unmerged")?;
107 Ok(GitFileStatus {
108 path: path_from_git_bytes(root, fields[10])?,
109 index: Some(GitChangeKind::Conflicted),
110 worktree: Some(GitChangeKind::Conflicted),
111 })
112}
113
114fn fields<'a>(
115 root: &Path,
116 record: &'a [u8],
117 expected: usize,
118 kind: &str,
119) -> GitResult<Vec<&'a [u8]>> {
120 let values: Vec<&[u8]> = record.splitn(expected, |byte| *byte == b' ').collect();
121 if values.len() != expected || values.last().is_some_and(|value| value.is_empty()) {
122 return Err(invalid(root, &format!("malformed {kind} record")));
123 }
124 Ok(values)
125}
126
127fn parse_xy(root: &Path, value: &[u8], kind: &str) -> GitResult<[u8; 2]> {
128 value.try_into().map_err(|_| invalid(root, &format!("malformed {kind} XY status")))
129}
130
131fn change(root: &Path, code: u8, original: Option<&PathBuf>) -> GitResult<Option<GitChangeKind>> {
132 let kind = match code {
133 b'.' | b' ' => return Ok(None),
134 b'M' | b'T' => GitChangeKind::Modified,
135 b'A' | b'C' => GitChangeKind::Added,
136 b'D' => GitChangeKind::Deleted,
137 b'R' => GitChangeKind::Renamed {
138 from: original
139 .cloned()
140 .ok_or_else(|| invalid(root, "rename status is missing its original path"))?,
141 },
142 b'U' => GitChangeKind::Conflicted,
143 other => {
144 return Err(invalid(root, &format!("unknown porcelain-v2 status code {other:?}")));
145 }
146 };
147 Ok(Some(kind))
148}
149
150#[cfg(unix)]
151fn path_from_git_bytes(_root: &Path, value: &[u8]) -> GitResult<PathBuf> {
152 use std::os::unix::ffi::OsStringExt;
153 Ok(PathBuf::from(OsString::from_vec(value.to_vec())))
154}
155
156#[cfg(not(unix))]
157fn path_from_git_bytes(root: &Path, value: &[u8]) -> GitResult<PathBuf> {
158 let value = std::str::from_utf8(value)
159 .map_err(|_| invalid(root, "Git emitted a path that is not valid UTF-8"))?;
160 Ok(PathBuf::from(value))
161}
162
163fn invalid(root: &Path, message: &str) -> GitFailure {
164 GitFailure {
165 kind: GitFailureKind::InvalidOutput,
166 message: format!("{}: {message}", root.display()),
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use std::path::{Path, PathBuf};
173
174 use termesh_core::{GitChangeKind, GitFailureKind};
175
176 use super::parse_status;
177
178 #[test]
179 fn parses_branch_counts_dual_state_rename_untracked_and_conflict() {
180 let input = b"# branch.oid abc123\0# branch.head feature/git\0# branch.upstream origin/feature/git\0# branch.ab +2 -1\0\
1831 MM N... 100644 100644 100644 aaaaaaa bbbbbbb src/lib.rs\0\
1842 R. N... 100644 100644 100644 aaaaaaa bbbbbbb R100 src/new.rs\0src/old.rs\0\
185? notes with spaces.md\0\
186u UU N... 100644 100644 100644 100644 aaaaaaa bbbbbbb ccccccc conflict.rs\0";
187
188 let (branch, files) = parse_status(Path::new("/repo"), input).unwrap();
189
190 assert_eq!(branch.oid.as_deref(), Some("abc123"));
191 assert_eq!(branch.head.as_deref(), Some("feature/git"));
192 assert_eq!(branch.upstream.as_deref(), Some("origin/feature/git"));
193 assert_eq!((branch.ahead, branch.behind), (2, 1));
194 assert_eq!(files.len(), 4);
195 assert_eq!(files[0].path, PathBuf::from("conflict.rs"));
196 assert_eq!(files[0].index, Some(GitChangeKind::Conflicted));
197 assert_eq!(files[0].worktree, Some(GitChangeKind::Conflicted));
198 assert_eq!(files[1].path, PathBuf::from("notes with spaces.md"));
199 assert_eq!(files[1].worktree, Some(GitChangeKind::Untracked));
200 assert_eq!(files[2].path, PathBuf::from("src/lib.rs"));
201 assert_eq!(files[2].index, Some(GitChangeKind::Modified));
202 assert_eq!(files[2].worktree, Some(GitChangeKind::Modified));
203 assert_eq!(
204 files[3].index,
205 Some(GitChangeKind::Renamed { from: PathBuf::from("src/old.rs") })
206 );
207 }
208
209 #[test]
210 fn parses_detached_and_unborn_heads() {
211 let detached = parse_status(
212 Path::new("/repo"),
213 b"# branch.oid abcdef123456\0# branch.head (detached)\0",
214 )
215 .unwrap()
216 .0;
217 assert!(detached.detached);
218 assert_eq!(detached.head, None);
219 assert_eq!(detached.oid.as_deref(), Some("abcdef123456"));
220
221 let unborn =
222 parse_status(Path::new("/repo"), b"# branch.oid (initial)\0# branch.head main\0")
223 .unwrap()
224 .0;
225 assert!(!unborn.detached);
226 assert_eq!(unborn.head.as_deref(), Some("main"));
227 assert_eq!(unborn.oid, None);
228 }
229
230 #[test]
231 fn malformed_records_fail_instead_of_disappearing() {
232 let error = parse_status(Path::new("/repo"), b"1 MM too-short\0").unwrap_err();
233 assert_eq!(error.kind, GitFailureKind::InvalidOutput);
234 assert!(error.message.contains("ordinary"));
235 }
236
237 #[test]
238 fn leading_dash_and_unicode_paths_remain_data() {
239 let (_, files) =
240 parse_status(Path::new("/repo"), "? -odd.rs\0? src/שלום.rs\0".as_bytes()).unwrap();
241 assert_eq!(files[0].path, PathBuf::from("-odd.rs"));
242 assert_eq!(files[1].path, PathBuf::from("src/שלום.rs"));
243 }
244
245 #[cfg(unix)]
246 #[test]
247 fn non_utf8_paths_keep_their_os_identity() {
248 use std::os::unix::ffi::OsStrExt;
249
250 let (_, files) = parse_status(Path::new("/repo"), b"? bad-\xff.rs\0").unwrap();
251 assert_eq!(files[0].path.as_os_str().as_bytes(), b"bad-\xff.rs");
252 }
253}