Skip to main content

termesh_git/
status.rs

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
10/// Parse `git status --porcelain=v2 --branch -z` without flattening paths into UTF-8.
11pub 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        // Removing either XY column, forgetting the second rename path, or treating an
181        // unmerged record as a normal modification must break these literal expectations.
182        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}