Skip to main content

gw_parse/
lib.rs

1//! Pure parsers for git's output. No IO, no terminal.
2
3mod log;
4mod pull;
5mod refs;
6mod status;
7
8pub use log::{
9    BRANCH_FORMAT, Branch, Commit, LOG_FORMAT, LogRow, STASH_FORMAT, Stash, US, parse_branches, parse_graph_line,
10    parse_name_status_z, parse_shortlog, parse_stash_list,
11};
12pub use pull::{PullLine, PullResult, classify_pull_line, parse_submodule_status};
13pub use refs::{RefKind, RefUpdate, parse_fetch_porcelain, parse_human_ref_line, parse_push_porcelain};
14pub use status::{Entry, SubmoduleState, Numstat, Status, parse_numstat_z, parse_status_v2};
15
16/// One classified line of git output.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Event {
19    Progress(Progress),
20    /// A `remote:` line that is not progress, prefix (and one space) stripped.
21    Remote(String),
22    /// `To <url>` / `From <url>`.
23    Target(String),
24    RefUpdate(RefUpdate),
25    UpToDate,
26    /// `Fetching submodule <path>` (recursive fetch/pull).
27    SubmoduleFetch(String),
28    Other(String),
29}
30
31#[derive(Debug, Clone, PartialEq, Eq, Default)]
32pub struct Progress {
33    pub remote: bool,
34    pub label: String,
35    pub cur: Option<u64>,
36    pub total: Option<u64>,
37    pub pct: Option<u8>,
38    /// Text after the counters, e.g. `, 840 bytes | 840.00 KiB/s, done.`
39    pub tail: String,
40}
41
42/// Streaming splitter: yields a line on `\r` or `\n` (a `\r\n` pair yields one line),
43/// even when a line spans several chunks. Bytes are decoded as lossy UTF-8.
44#[derive(Default)]
45pub struct Splitter {
46    buf: Vec<u8>,
47    last_cr: bool,
48}
49
50impl Splitter {
51    pub fn push(&mut self, chunk: &[u8]) -> Vec<String> {
52        let mut out = Vec::new();
53        for &b in chunk {
54            match b {
55                b'\n' if self.last_cr => self.last_cr = false,
56                b'\r' | b'\n' => {
57                    out.push(String::from_utf8_lossy(&self.buf).into_owned());
58                    self.buf.clear();
59                    self.last_cr = b == b'\r';
60                }
61                _ => {
62                    self.buf.push(b);
63                    self.last_cr = false;
64                }
65            }
66        }
67        out
68    }
69
70    /// Remaining bytes without a line terminator, if any.
71    pub fn finish(self) -> Option<String> {
72        (!self.buf.is_empty()).then(|| String::from_utf8_lossy(&self.buf).into_owned())
73    }
74}
75
76/// Classify one line from git's stderr.
77pub fn classify(line: &str) -> Event {
78    if is_pack_stats(line) {
79        return Event::Other(line.to_string());
80    }
81    if line.starts_with("Delta compression using up to ") {
82        return Event::Progress(Progress { label: "Delta compression".into(), ..Default::default() });
83    }
84    if let Some(body) = line.strip_prefix("remote:") {
85        let body = body.strip_prefix(' ').unwrap_or(body).trim_end();
86        return match parse_progress(body) {
87            Some(mut p) => {
88                p.remote = true;
89                Event::Progress(p)
90            }
91            None => Event::Remote(body.to_string()),
92        };
93    }
94    let t = line.trim_end();
95    if let Some(p) = parse_progress(t) {
96        return Event::Progress(p);
97    }
98    if let Some(url) = t.strip_prefix("To ").or_else(|| t.strip_prefix("From ")) {
99        return Event::Target(url.to_string());
100    }
101    if let Some(p) = t.strip_prefix("Fetching submodule ") {
102        return Event::SubmoduleFetch(p.to_string());
103    }
104    if t == "Everything up-to-date" {
105        return Event::UpToDate;
106    }
107    if let Some(r) = parse_human_ref_line(t) {
108        return Event::RefUpdate(r);
109    }
110    Event::Other(line.to_string())
111}
112
113/// Pack statistics line (`Total 11 (delta 10), reused ...`), local or `remote:`.
114/// Not progress and not a server message: callers drop it.
115pub fn is_pack_stats(line: &str) -> bool {
116    let body = line.strip_prefix("remote:").map_or(line, str::trim_start);
117    body.starts_with("Total ") && body.contains("(delta ")
118}
119
120/// `<Label>: <pct>% (<cur>/<total>)<tail>`, `<Label>: <n>, done.` or `<Label>: <n>`.
121fn parse_progress(s: &str) -> Option<Progress> {
122    let (label, rest) = s.split_once(": ")?;
123    if label.is_empty() || !label.starts_with(|c: char| c.is_ascii_uppercase()) || label.contains(':') {
124        return None;
125    }
126    let rest = rest.trim_start();
127    let label = label.to_string();
128    if let Some((pct, after)) = rest.split_once("% (") {
129        let pct: u8 = pct.parse().ok()?;
130        let (counts, tail) = after.split_once(')')?;
131        let (cur, total) = counts.split_once('/')?;
132        return Some(Progress {
133            label,
134            cur: Some(cur.parse().ok()?),
135            total: Some(total.parse().ok()?),
136            pct: Some(pct),
137            tail: tail.to_string(),
138            ..Default::default()
139        });
140    }
141    // Stages without percentage: "Enumerating objects: 8, done." / "Delta compression ...: 3"
142    let digits = rest.find(|c: char| !c.is_ascii_digit()).unwrap_or(rest.len());
143    if digits == 0 {
144        return None;
145    }
146    let tail = &rest[digits..];
147    if !(tail.is_empty() || tail.ends_with(", done.")) {
148        return None;
149    }
150    Some(Progress { label, cur: rest[..digits].parse().ok(), tail: tail.to_string(), ..Default::default() })
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn split_across_chunks() {
159        let mut s = Splitter::default();
160        assert_eq!(s.push(b"Writing obj"), Vec::<String>::new());
161        assert_eq!(s.push(b"ects: 1\rWri"), vec!["Writing objects: 1"]);
162        assert_eq!(s.push(b"ting\n"), vec!["Writing"]);
163        assert_eq!(s.finish(), None);
164    }
165
166    #[test]
167    fn crlf_is_one_line() {
168        let mut s = Splitter::default();
169        assert_eq!(s.push(b"a\r"), vec!["a"]);
170        assert_eq!(s.push(b"\nb\r\n\nc"), vec!["b", ""]);
171        assert_eq!(s.finish().as_deref(), Some("c"));
172    }
173
174    #[test]
175    fn lossy_utf8() {
176        let mut s = Splitter::default();
177        assert_eq!(s.push(b"\xffok\n"), vec!["\u{fffd}ok"]);
178    }
179
180    #[test]
181    fn progress_kinds() {
182        let Event::Progress(p) = classify("remote: Resolving deltas:  50% (3/6)        ") else { panic!() };
183        assert!(p.remote);
184        assert_eq!((p.label.as_str(), p.cur, p.total, p.pct), ("Resolving deltas", Some(3), Some(6), Some(50)));
185        assert!(matches!(classify("Enumerating objects: 8, done."), Event::Progress(_)));
186        assert!(matches!(classify("remote: Processing changes: refs: 1, done"), Event::Remote(_)));
187        assert!(matches!(classify("error: failed to push some refs to 'x'"), Event::Other(_)));
188        assert_eq!(classify("Fetching submodule lib/x"), Event::SubmoduleFetch("lib/x".into()));
189        assert!(is_pack_stats("remote: Total 6 (delta 3), reused 0 (delta 0)"));
190    }
191}