Skip to main content

gw_parse/
status.rs

1/// Parsed `git status --porcelain=v2 --branch -z`.
2#[derive(Debug, Clone, Default, PartialEq, Eq)]
3pub struct Status {
4    pub oid: Option<String>,
5    /// `None` when HEAD is detached.
6    pub head: Option<String>,
7    pub upstream: Option<String>,
8    pub ahead_behind: Option<(u32, u32)>,
9    pub entries: Vec<Entry>,
10}
11
12/// `x`/`y` are git's XY codes (`.` = unchanged). Untracked is `?`/`?`.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Entry {
15    pub x: char,
16    pub y: char,
17    pub unmerged: bool,
18    /// Repo-root relative.
19    pub path: String,
20    /// Source path of a rename/copy.
21    pub orig: Option<String>,
22    /// Set for a submodule (porcelain v2 `S<c><m><u>`), except in conflicts.
23    pub submodule: Option<SubmoduleState>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct SubmoduleState {
28    /// Checked-out commit differs from the index (`C`).
29    pub commit_changed: bool,
30    /// Tracked files inside are modified (`M`).
31    pub modified: bool,
32    /// Untracked files inside (`U`).
33    pub untracked: bool,
34    /// Commit recorded in HEAD (`hH`) and in the index (`hI`).
35    pub head: String,
36    pub index: String,
37}
38
39/// Per-file line counts keyed by repo-relative path; `None` means binary.
40pub type Numstat = std::collections::HashMap<String, Option<(u64, u64)>>;
41
42/// `git diff [--cached] --numstat -z`: `added\tdeleted\tpath\0`, renames as `added\tdeleted\t\0src\0dst\0`.
43pub fn parse_numstat_z(bytes: &[u8]) -> Numstat {
44    let text = String::from_utf8_lossy(bytes);
45    let mut recs = text.split('\0');
46    let mut out = Numstat::new();
47    while let Some(rec) = recs.next() {
48        let mut f = rec.splitn(3, '\t');
49        let (Some(a), Some(d), Some(p)) = (f.next(), f.next(), f.next()) else { continue };
50        let path = if p.is_empty() {
51            recs.next();
52            recs.next().unwrap_or("")
53        } else {
54            p
55        };
56        out.insert(path.to_string(), a.parse().ok().zip(d.parse().ok()));
57    }
58    out
59}
60
61pub fn parse_status_v2(bytes: &[u8]) -> Status {
62    let text = String::from_utf8_lossy(bytes);
63    let mut recs = text.split('\0');
64    let mut st = Status::default();
65    while let Some(rec) = recs.next() {
66        if let Some(h) = rec.strip_prefix("# ") {
67            let (k, v) = h.split_once(' ').unwrap_or((h, ""));
68            match k {
69                "branch.oid" if v != "(initial)" => st.oid = Some(v.to_string()),
70                "branch.head" if v != "(detached)" => st.head = Some(v.to_string()),
71                "branch.upstream" => st.upstream = Some(v.to_string()),
72                "branch.ab" => {
73                    let mut it = v.split(' ').map(|n| n.trim_start_matches(['+', '-']).parse().unwrap_or(0));
74                    st.ahead_behind = Some((it.next().unwrap_or(0), it.next().unwrap_or(0)));
75                }
76                _ => {}
77            }
78            continue;
79        }
80        let fields: Vec<&str> = rec.split(' ').collect();
81        let xy = |s: &str| {
82            let mut c = s.chars();
83            (c.next().unwrap_or('.'), c.next().unwrap_or('.'))
84        };
85        // Path is the last field and may contain spaces, so take it with splitn.
86        let path_after = |n: usize| rec.splitn(n + 1, ' ').nth(n).unwrap_or("").to_string();
87        // `S<c><m><u>` in field 2, hH/hI in fields 6/7 (same layout for `1` and `2` records).
88        let sub = || {
89            let f = fields.get(2)?.strip_prefix('S')?.as_bytes();
90            Some(SubmoduleState {
91                commit_changed: f.first() == Some(&b'C'),
92                modified: f.get(1) == Some(&b'M'),
93                untracked: f.get(2) == Some(&b'U'),
94                head: fields.get(6)?.to_string(),
95                index: fields.get(7)?.to_string(),
96            })
97        };
98        let entry = match fields.first() {
99            Some(&"1") => {
100                let (x, y) = xy(fields[1]);
101                Entry { x, y, unmerged: false, path: path_after(8), orig: None, submodule: sub() }
102            }
103            Some(&"2") => {
104                let (x, y) = xy(fields[1]);
105                let orig = recs.next().map(str::to_string);
106                Entry { x, y, unmerged: false, path: path_after(9), orig, submodule: sub() }
107            }
108            Some(&"u") => {
109                let (x, y) = xy(fields[1]);
110                Entry { x, y, unmerged: true, path: path_after(10), orig: None, submodule: None }
111            }
112            Some(&"?") => Entry { x: '?', y: '?', unmerged: false, path: path_after(1), orig: None, submodule: None },
113            _ => continue,
114        };
115        st.entries.push(entry);
116    }
117    st
118}