Skip to main content

gw_parse/
pull.rs

1//! Lines git prints on stdout after a pull's fetch (merge/rebase summary, diffstat),
2//! and submodule lines from pull/clone.
3
4use 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/// One classified stdout line. Each variant holds enough to rebuild the line exactly.
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum PullLine {
19    /// `Updating <old>..<new>`
20    Updating { old: String, new: String },
21    Result(PullResult),
22    /// `<path> | <count> <+++-->`; `path` keeps git's leading space and padding, `count` its padding.
23    Diffstat { path: String, count: String, plus: usize, minus: usize },
24    /// `<path> | Bin <a> -> <b> bytes`
25    BinStat { path: String, rest: String },
26    /// ` N files changed`, optional `X insertions(+)`, optional `Y deletions(-)`.
27    Summary { files: String, ins: Option<String>, del: Option<String> },
28    /// ` create mode 100644 x`: verb (`create mode`, `delete mode`, `rename`, `mode change`), dim part, rest.
29    Mode { verb: String, mode: String, rest: String },
30    /// `Submodule path '<path>': <verb> '<sha>'`
31    Submodule { path: String, verb: String, sha: String },
32    /// Merge's per-path work: `Auto-merging <path>`, `Removing <path>`.
33    Action { verb: String, path: String },
34    /// Clone's `Submodule '<name>' (<url>) registered for path '<path>'`.
35    SubmoduleNoise,
36    Other,
37}
38
39pub fn classify_pull_line(line: &str) -> PullLine {
40    if let Some((old, new)) = line.strip_prefix("Updating ").and_then(|r| r.split_once(".."))
41        && !old.is_empty()
42        && !new.is_empty()
43        && !new.contains(' ')
44    {
45        return PullLine::Updating { old: old.into(), new: new.into() };
46    }
47    let result = match line {
48        "Fast-forward" => Some(PullResult::FastForward),
49        "Already up to date." | "Already up-to-date." => Some(PullResult::UpToDate),
50        l if l.starts_with("Merge made by ") => Some(PullResult::Merge),
51        l if l.starts_with("Successfully rebased and updated ") => Some(PullResult::Rebased),
52        l if l.starts_with("CONFLICT ") => Some(PullResult::Conflict),
53        l if l.starts_with("Automatic merge failed") || l.starts_with("Failed to merge submodule ") => Some(PullResult::Failed),
54        _ => None,
55    };
56    if let Some(r) = result {
57        return PullLine::Result(r);
58    }
59    if let Some(rest) = line.strip_prefix("Submodule path '")
60        && let Some((path, rest)) = rest.split_once("': ")
61        && let Some((verb, sha)) = rest.rsplit_once(" '")
62        && let Some(sha) = sha.strip_suffix('\'')
63    {
64        return PullLine::Submodule { path: path.into(), verb: verb.into(), sha: sha.into() };
65    }
66    for verb in ["Auto-merging", "Removing"] {
67        if let Some(path) = line.strip_prefix(verb).and_then(|r| r.strip_prefix(' ')) {
68            return PullLine::Action { verb: verb.into(), path: path.into() };
69        }
70    }
71    if line.starts_with("Submodule '") && line.contains(") registered for path '") {
72        return PullLine::SubmoduleNoise;
73    }
74    if !line.starts_with(' ') {
75        return PullLine::Other;
76    }
77    if let Some((path, stat)) = line.rsplit_once(" | ") {
78        if let Some(rest) = stat.strip_prefix("Bin ") {
79            return PullLine::BinStat { path: path.into(), rest: rest.into() };
80        }
81        let (count, bars) = stat.rsplit_once(' ').filter(|(c, _)| !c.trim().is_empty()).unwrap_or((stat, ""));
82        let plus = bars.chars().take_while(|&c| c == '+').count();
83        let minus = bars.len() - plus;
84        if !count.trim().is_empty()
85            && count.trim().chars().all(|c| c.is_ascii_digit())
86            && bars.chars().skip(plus).all(|c| c == '-')
87        {
88            return PullLine::Diffstat { path: path.into(), count: count.into(), plus, minus };
89        }
90    }
91    let mut parts = line.split(", ");
92    if let Some(files) = parts.next().filter(|f| f.ends_with(" changed") && f.contains(" file")) {
93        let (mut ins, mut del) = (None, None);
94        for p in parts {
95            if p.ends_with("(+)") && ins.is_none() && del.is_none() {
96                ins = Some(p.to_string());
97            } else if p.ends_with("(-)") && del.is_none() {
98                del = Some(p.to_string());
99            } else {
100                return PullLine::Other;
101            }
102        }
103        return PullLine::Summary { files: files.into(), ins, del };
104    }
105    for verb in ["create mode", "delete mode"] {
106        if let Some((mode, rest)) = line.strip_prefix(&format!(" {verb} ")).and_then(|r| r.split_once(' ')) {
107            return PullLine::Mode { verb: verb.into(), mode: mode.into(), rest: rest.into() };
108        }
109    }
110    if let Some(r) = line.strip_prefix(" mode change ") {
111        // `100644 => 100755 path`
112        let mut it = r.splitn(4, ' ');
113        if let (Some(a), Some("=>"), Some(b), Some(path)) = (it.next(), it.next(), it.next(), it.next()) {
114            return PullLine::Mode { verb: "mode change".into(), mode: format!("{a} => {b}"), rest: path.into() };
115        }
116    }
117    if let Some(r) = line.strip_prefix(" rename ") {
118        return PullLine::Mode { verb: "rename".into(), mode: String::new(), rest: r.into() };
119    }
120    PullLine::Other
121}
122
123/// `git submodule status`: `[ +-U]<sha> <path>[ (<describe>)]` -> path to checked-out sha.
124/// Uninitialized (`-`) submodules have no checkout and are left out.
125pub fn parse_submodule_status(text: &str) -> HashMap<String, String> {
126    text.lines()
127        .filter(|l| !l.starts_with('-'))
128        .filter_map(|l| {
129            let (sha, rest) = l.get(1..)?.split_once(' ')?;
130            let path = match rest.rsplit_once(" (") {
131                Some((p, d)) if d.ends_with(')') => p,
132                _ => rest,
133            };
134            Some((path.to_string(), sha.to_string()))
135        })
136        .collect()
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn lines() {
145        assert_eq!(classify_pull_line("Updating 8910aeb..e698aeb"), PullLine::Updating { old: "8910aeb".into(), new: "e698aeb".into() });
146        assert_eq!(classify_pull_line(" a.rs |  4 ++--"), PullLine::Diffstat { path: " a.rs".into(), count: " 4".into(), plus: 2, minus: 2 });
147        assert_eq!(classify_pull_line(" a.rs | 0"), PullLine::Diffstat { path: " a.rs".into(), count: "0".into(), plus: 0, minus: 0 });
148        assert!(matches!(classify_pull_line(" img.png | Bin 0 -> 12 bytes"), PullLine::BinStat { .. }));
149        assert_eq!(
150            classify_pull_line(" 1 file changed, 1 deletion(-)"),
151            PullLine::Summary { files: " 1 file changed".into(), ins: None, del: Some("1 deletion(-)".into()) }
152        );
153        assert_eq!(
154            classify_pull_line(" mode change 100644 => 100755 run.sh"),
155            PullLine::Mode { verb: "mode change".into(), mode: "100644 => 100755".into(), rest: "run.sh".into() }
156        );
157        assert_eq!(classify_pull_line("CONFLICT (content): Merge conflict in a"), PullLine::Result(PullResult::Conflict));
158        assert_eq!(
159            classify_pull_line("Submodule path 'a b': merged in '1234567890'"),
160            PullLine::Submodule { path: "a b".into(), verb: "merged in".into(), sha: "1234567890".into() }
161        );
162        assert_eq!(classify_pull_line("Auto-merging new"), PullLine::Action { verb: "Auto-merging".into(), path: "new".into() });
163        assert_eq!(classify_pull_line("Failed to merge submodule a (commits don't follow merge-base)"), PullLine::Result(PullResult::Failed));
164        assert_eq!(classify_pull_line("Already merged"), PullLine::Other);
165    }
166
167    #[test]
168    fn submodule_status() {
169        let m = parse_submodule_status("+82aa575 doc (heads/main-1-g82aa575)\n 26bb4d4 my lib\n-1111111 gone\n");
170        assert_eq!(m.get("doc").map(String::as_str), Some("82aa575"));
171        assert_eq!(m.get("my lib").map(String::as_str), Some("26bb4d4"));
172        assert!(!m.contains_key("gone"));
173    }
174}