1use std::collections::HashMap;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum PullResult {
8 FastForward,
9 Merge,
10 Rebased,
11 UpToDate,
12 Conflict,
13 Failed,
14}
15
16#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum PullLine {
19 Updating { old: String, new: String },
21 Result(PullResult),
22 Diffstat { path: String, count: String, plus: usize, minus: usize },
24 BinStat { path: String, rest: String },
26 Summary { files: String, ins: Option<String>, del: Option<String> },
28 Mode { verb: String, mode: String, rest: String },
30 Submodule { path: String, verb: String, sha: String },
32 SubmoduleNoise,
34 Other,
35}
36
37pub fn classify_pull_line(line: &str) -> PullLine {
38 if let Some((old, new)) = line.strip_prefix("Updating ").and_then(|r| r.split_once(".."))
39 && !old.is_empty()
40 && !new.is_empty()
41 && !new.contains(' ')
42 {
43 return PullLine::Updating { old: old.into(), new: new.into() };
44 }
45 let result = match line {
46 "Fast-forward" => Some(PullResult::FastForward),
47 "Already up to date." | "Already up-to-date." => Some(PullResult::UpToDate),
48 l if l.starts_with("Merge made by ") => Some(PullResult::Merge),
49 l if l.starts_with("Successfully rebased and updated ") => Some(PullResult::Rebased),
50 l if l.starts_with("CONFLICT ") => Some(PullResult::Conflict),
51 l if l.starts_with("Automatic merge failed") => Some(PullResult::Failed),
52 _ => None,
53 };
54 if let Some(r) = result {
55 return PullLine::Result(r);
56 }
57 if let Some(rest) = line.strip_prefix("Submodule path '")
58 && let Some((path, rest)) = rest.split_once("': ")
59 && let Some((verb, sha)) = rest.rsplit_once(" '")
60 && let Some(sha) = sha.strip_suffix('\'')
61 {
62 return PullLine::Submodule { path: path.into(), verb: verb.into(), sha: sha.into() };
63 }
64 if line.starts_with("Submodule '") && line.contains(") registered for path '") {
65 return PullLine::SubmoduleNoise;
66 }
67 if !line.starts_with(' ') {
68 return PullLine::Other;
69 }
70 if let Some((path, stat)) = line.rsplit_once(" | ") {
71 if let Some(rest) = stat.strip_prefix("Bin ") {
72 return PullLine::BinStat { path: path.into(), rest: rest.into() };
73 }
74 let (count, bars) = stat.rsplit_once(' ').filter(|(c, _)| !c.trim().is_empty()).unwrap_or((stat, ""));
75 let plus = bars.chars().take_while(|&c| c == '+').count();
76 let minus = bars.len() - plus;
77 if !count.trim().is_empty()
78 && count.trim().chars().all(|c| c.is_ascii_digit())
79 && bars.chars().skip(plus).all(|c| c == '-')
80 {
81 return PullLine::Diffstat { path: path.into(), count: count.into(), plus, minus };
82 }
83 }
84 let mut parts = line.split(", ");
85 if let Some(files) = parts.next().filter(|f| f.ends_with(" changed") && f.contains(" file")) {
86 let (mut ins, mut del) = (None, None);
87 for p in parts {
88 if p.ends_with("(+)") && ins.is_none() && del.is_none() {
89 ins = Some(p.to_string());
90 } else if p.ends_with("(-)") && del.is_none() {
91 del = Some(p.to_string());
92 } else {
93 return PullLine::Other;
94 }
95 }
96 return PullLine::Summary { files: files.into(), ins, del };
97 }
98 for verb in ["create mode", "delete mode"] {
99 if let Some((mode, rest)) = line.strip_prefix(&format!(" {verb} ")).and_then(|r| r.split_once(' ')) {
100 return PullLine::Mode { verb: verb.into(), mode: mode.into(), rest: rest.into() };
101 }
102 }
103 if let Some(r) = line.strip_prefix(" mode change ") {
104 let mut it = r.splitn(4, ' ');
106 if let (Some(a), Some("=>"), Some(b), Some(path)) = (it.next(), it.next(), it.next(), it.next()) {
107 return PullLine::Mode { verb: "mode change".into(), mode: format!("{a} => {b}"), rest: path.into() };
108 }
109 }
110 if let Some(r) = line.strip_prefix(" rename ") {
111 return PullLine::Mode { verb: "rename".into(), mode: String::new(), rest: r.into() };
112 }
113 PullLine::Other
114}
115
116pub fn parse_submodule_status(text: &str) -> HashMap<String, String> {
119 text.lines()
120 .filter(|l| !l.starts_with('-'))
121 .filter_map(|l| {
122 let (sha, rest) = l.get(1..)?.split_once(' ')?;
123 let path = match rest.rsplit_once(" (") {
124 Some((p, d)) if d.ends_with(')') => p,
125 _ => rest,
126 };
127 Some((path.to_string(), sha.to_string()))
128 })
129 .collect()
130}
131
132#[cfg(test)]
133mod tests {
134 use super::*;
135
136 #[test]
137 fn lines() {
138 assert_eq!(classify_pull_line("Updating 8910aeb..e698aeb"), PullLine::Updating { old: "8910aeb".into(), new: "e698aeb".into() });
139 assert_eq!(classify_pull_line(" a.rs | 4 ++--"), PullLine::Diffstat { path: " a.rs".into(), count: " 4".into(), plus: 2, minus: 2 });
140 assert_eq!(classify_pull_line(" a.rs | 0"), PullLine::Diffstat { path: " a.rs".into(), count: "0".into(), plus: 0, minus: 0 });
141 assert!(matches!(classify_pull_line(" img.png | Bin 0 -> 12 bytes"), PullLine::BinStat { .. }));
142 assert_eq!(
143 classify_pull_line(" 1 file changed, 1 deletion(-)"),
144 PullLine::Summary { files: " 1 file changed".into(), ins: None, del: Some("1 deletion(-)".into()) }
145 );
146 assert_eq!(
147 classify_pull_line(" mode change 100644 => 100755 run.sh"),
148 PullLine::Mode { verb: "mode change".into(), mode: "100644 => 100755".into(), rest: "run.sh".into() }
149 );
150 assert_eq!(classify_pull_line("CONFLICT (content): Merge conflict in a"), PullLine::Result(PullResult::Conflict));
151 assert_eq!(
152 classify_pull_line("Submodule path 'a b': merged in '1234567890'"),
153 PullLine::Submodule { path: "a b".into(), verb: "merged in".into(), sha: "1234567890".into() }
154 );
155 assert_eq!(classify_pull_line("Auto-merging new"), PullLine::Other);
156 }
157
158 #[test]
159 fn submodule_status() {
160 let m = parse_submodule_status("+82aa575 doc (heads/main-1-g82aa575)\n 26bb4d4 my lib\n-1111111 gone\n");
161 assert_eq!(m.get("doc").map(String::as_str), Some("82aa575"));
162 assert_eq!(m.get("my lib").map(String::as_str), Some("26bb4d4"));
163 assert!(!m.contains_key("gone"));
164 }
165}