1mod 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#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum Event {
19 Progress(Progress),
20 Remote(String),
22 Target(String),
24 RefUpdate(RefUpdate),
25 UpToDate,
26 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 pub tail: String,
40}
41
42#[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 pub fn finish(self) -> Option<String> {
72 (!self.buf.is_empty()).then(|| String::from_utf8_lossy(&self.buf).into_owned())
73 }
74}
75
76pub 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
113pub 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
120fn 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 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}