Skip to main content

gw_parse/
refs.rs

1use crate::Event;
2
3#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4pub enum RefKind {
5    FastForward,
6    Forced,
7    New,
8    Deleted,
9    Rejected,
10    UpToDate,
11}
12
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct RefUpdate {
15    pub kind: RefKind,
16    pub old: Option<String>,
17    pub new: Option<String>,
18    /// Empty when git does not report a source (fetch porcelain).
19    pub src: String,
20    pub dst: String,
21    pub reason: Option<String>,
22}
23
24fn short_ref(r: &str) -> String {
25    ["refs/heads/", "refs/tags/", "refs/remotes/"]
26        .iter()
27        .find_map(|p| r.strip_prefix(p))
28        .unwrap_or(r)
29        .to_string()
30}
31
32fn kind_from_flag(flag: char) -> Option<RefKind> {
33    Some(match flag {
34        ' ' => RefKind::FastForward,
35        '+' | 't' => RefKind::Forced,
36        '*' => RefKind::New,
37        '-' => RefKind::Deleted,
38        '!' => RefKind::Rejected,
39        '=' => RefKind::UpToDate,
40        _ => return None,
41    })
42}
43
44/// Splits `a..b` / `a...b` into SHAs.
45fn shas(summary: &str) -> (Option<String>, Option<String>) {
46    match summary.split_once("...").or_else(|| summary.split_once("..")) {
47        Some((a, b)) => (Some(a.to_string()), Some(b.to_string())),
48        None => (None, None),
49    }
50}
51
52/// `git push --porcelain` stdout: `To <url>`, `<flag>\t<src>:<dst>\t<summary>`, `Done`.
53pub fn parse_push_porcelain(text: &str) -> Vec<Event> {
54    let mut out = Vec::new();
55    for line in text.lines() {
56        if let Some(url) = line.strip_prefix("To ") {
57            out.push(Event::Target(url.to_string()));
58            continue;
59        }
60        let mut f = line.splitn(3, '\t');
61        let (Some(flag), Some(refs), Some(summary)) = (f.next(), f.next(), f.next()) else { continue };
62        let Some(kind) = flag.chars().next().and_then(kind_from_flag) else { continue };
63        let (src, dst) = refs.split_once(':').unwrap_or(("", refs));
64        // summary: "a..b", "+a...b (forced update)", "[new branch]", "[rejected] (fetch first)"
65        let (head, reason) = match summary.split_once(" (") {
66            Some((h, r)) => (h, Some(r.trim_end_matches(')').to_string())),
67            None => (summary, None),
68        };
69        let (old, new) = shas(head.trim_start_matches('+'));
70        let reason = reason.filter(|r| kind == RefKind::Rejected || r != "forced update");
71        out.push(Event::RefUpdate(RefUpdate { kind, old, new, src: short_ref(src), dst: short_ref(dst), reason }));
72    }
73    out
74}
75
76/// `git fetch --porcelain` stdout: `<flag> <old> <new> <ref>`.
77pub fn parse_fetch_porcelain(text: &str) -> Vec<RefUpdate> {
78    text.lines()
79        .filter_map(|line| {
80            let mut chars = line.chars();
81            let kind = kind_from_flag(chars.next()?)?;
82            let mut f = chars.as_str().split_whitespace();
83            let (old, new, dst) = (f.next()?, f.next()?, f.next()?);
84            let zero = |s: &str| s.bytes().all(|b| b == b'0');
85            Some(RefUpdate {
86                kind,
87                old: (!zero(old)).then(|| old.to_string()),
88                new: (!zero(new)).then(|| new.to_string()),
89                src: String::new(),
90                dst: short_ref(dst),
91                reason: None,
92            })
93        })
94        .collect()
95}
96
97/// Human ref line from pull/clone/fetch stderr (C locale):
98/// `   old..new  src -> dst`, ` * [new branch]  src -> dst`, ` + a...b  src -> dst  (forced update)`.
99pub fn parse_human_ref_line(line: &str) -> Option<RefUpdate> {
100    let rest = line.strip_prefix(' ')?;
101    let mut chars = rest.chars();
102    let flag = chars.next()?;
103    let mut kind = kind_from_flag(flag)?;
104    let rest = chars.as_str().strip_prefix(' ')?.trim_start();
105    let (summary, rest) = if rest.starts_with('[') {
106        let end = rest.find(']')?;
107        (&rest[..=end], &rest[end + 1..])
108    } else {
109        rest.split_once(' ').unwrap_or((rest, ""))
110    };
111    let (refs, reason) = match rest.rfind(" (") {
112        Some(i) if rest.trim_end().ends_with(')') => (&rest[..i], Some(rest[i + 2..].trim_end().trim_end_matches(')'))),
113        _ => (rest, None),
114    };
115    let (src, dst) = refs.split_once(" -> ")?;
116    let (old, new) = shas(summary);
117    // " * branch  main -> FETCH_HEAD" only records FETCH_HEAD: nothing changed.
118    if summary == "branch" || summary == "tag" {
119        kind = RefKind::UpToDate;
120    }
121    let reason = reason.filter(|r| *r != "forced update").map(str::to_string);
122    Some(RefUpdate { kind, old, new, src: src.trim().to_string(), dst: dst.trim().to_string(), reason })
123}