Skip to main content

gw_parse/
log.rs

1//! Parsers for `git log --graph`, `for-each-ref`, `stash list`, `shortlog`, and `--name-status -z`.
2
3/// Field separator used in the formats below (`%x1f` / `%1f`).
4pub const US: char = '\x1f';
5
6/// Format string for `git log --graph`: graph prefix, then US-separated fields.
7pub const LOG_FORMAT: &str = "--format=%x1f%H%x1f%h%x1f%at%x1f%an%x1f%D%x1f%s";
8
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Commit {
11    pub hash: String,
12    pub short: String,
13    /// Author time, unix seconds.
14    pub time: i64,
15    pub author: String,
16    /// Decorations from `%D`, e.g. `HEAD -> main`, `origin/main`, `tag: v1`.
17    pub refs: Vec<String>,
18    pub subject: String,
19}
20
21/// One output line of `git log --graph`: a commit, or a graph-only connector line.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct LogRow {
24    pub graph: String,
25    pub commit: Option<Commit>,
26}
27
28pub fn parse_graph_line(line: &str) -> LogRow {
29    let Some((graph, rest)) = line.split_once(US) else {
30        return LogRow { graph: line.trim_end().to_string(), commit: None };
31    };
32    let f: Vec<&str> = rest.split(US).collect();
33    let get = |i: usize| f.get(i).copied().unwrap_or("");
34    let refs = get(4).split(", ").filter(|r| !r.is_empty()).map(str::to_string).collect();
35    LogRow {
36        graph: graph.trim_end().to_string(),
37        commit: Some(Commit {
38            hash: get(0).into(),
39            short: get(1).into(),
40            time: get(2).parse().unwrap_or(0),
41            author: get(3).into(),
42            refs,
43            // Subject is last; rejoin in case it contained the separator.
44            subject: f.get(5..).map(|s| s.join("\u{1f}")).unwrap_or_default(),
45        }),
46    }
47}
48
49/// Format for `git for-each-ref refs/heads` parsed by [`parse_branches`].
50pub const BRANCH_FORMAT: &str = "--format=%(HEAD)%1f%(refname:short)%1f%(upstream:short)%1f%(upstream:track,nobracket)%1f%(committerdate:relative)%1f%(subject)";
51
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct Branch {
54    pub name: String,
55    pub current: bool,
56    pub upstream: Option<String>,
57    pub ahead: u32,
58    pub behind: u32,
59    /// Upstream configured but deleted.
60    pub gone: bool,
61    pub age: String,
62    pub subject: String,
63}
64
65pub fn parse_branches(text: &str) -> Vec<Branch> {
66    text.lines()
67        .filter_map(|l| {
68            let f: Vec<&str> = l.split(US).collect();
69            if f.len() < 6 {
70                return None;
71            }
72            let track = f[3];
73            let num = |key: &str| {
74                track.split(", ").find_map(|p| p.strip_prefix(key)).and_then(|n| n.trim().parse().ok()).unwrap_or(0)
75            };
76            Some(Branch {
77                current: f[0] == "*",
78                name: f[1].into(),
79                upstream: (!f[2].is_empty()).then(|| f[2].to_string()),
80                ahead: num("ahead "),
81                behind: num("behind "),
82                gone: track == "gone",
83                age: f[4].into(),
84                subject: f[5..].join("\u{1f}"),
85            })
86        })
87        .collect()
88}
89
90/// Format for `git stash list` parsed by [`parse_stash_list`].
91pub const STASH_FORMAT: &str = "--format=%gd%x1f%cr%x1f%gs";
92
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Stash {
95    /// `stash@{N}`
96    pub reference: String,
97    pub index: usize,
98    pub age: String,
99    pub branch: Option<String>,
100    pub message: String,
101}
102
103pub fn parse_stash_list(text: &str) -> Vec<Stash> {
104    text.lines()
105        .filter_map(|l| {
106            let mut f = l.splitn(3, US);
107            let (reference, age, gs) = (f.next()?, f.next()?, f.next()?);
108            let index = reference.strip_prefix("stash@{")?.strip_suffix('}')?.parse().ok()?;
109            // "%gs" is "WIP on <branch>: <sha> <subject>" or "On <branch>: <message>".
110            let body = gs.strip_prefix("WIP on ").or_else(|| gs.strip_prefix("On "));
111            let (branch, message) = match body.and_then(|b| b.split_once(": ")) {
112                Some((b, m)) => (Some(b.to_string()), m.to_string()),
113                None => (None, gs.to_string()),
114            };
115            Some(Stash { reference: reference.into(), index, age: age.into(), branch, message })
116        })
117        .collect()
118}
119
120/// `git shortlog -sne`: `(count, "Name <email>")`, most commits first (git's order).
121pub fn parse_shortlog(text: &str) -> Vec<(u32, String)> {
122    text.lines()
123        .filter_map(|l| {
124            let (n, who) = l.trim_start().split_once('\t')?;
125            Some((n.parse().ok()?, who.to_string()))
126        })
127        .collect()
128}
129
130/// `git diff/show --name-status -z`: `(code, path, orig)`; renames/copies carry the source path.
131pub fn parse_name_status_z(bytes: &[u8]) -> Vec<(char, String, Option<String>)> {
132    let text = String::from_utf8_lossy(bytes);
133    let mut it = text.split('\0').filter(|s| !s.is_empty());
134    let mut out = Vec::new();
135    while let Some(status) = it.next() {
136        let code = status.chars().next().unwrap_or('M');
137        if matches!(code, 'R' | 'C') {
138            let (Some(from), Some(to)) = (it.next(), it.next()) else { break };
139            out.push((code, to.to_string(), Some(from.to_string())));
140        } else if let Some(p) = it.next() {
141            out.push((code, p.to_string(), None));
142        }
143    }
144    out
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn connector_and_commit() {
153        assert_eq!(parse_graph_line("|\\  "), LogRow { graph: "|\\".into(), commit: None });
154        let r = parse_graph_line("* \x1fabc\x1fa\x1f5\x1fN\x1fHEAD -> main, tag: v1\x1fs: x");
155        let c = r.commit.unwrap();
156        assert_eq!((r.graph.as_str(), c.refs.len(), c.subject.as_str()), ("*", 2, "s: x"));
157    }
158
159    #[test]
160    fn name_status() {
161        let v = parse_name_status_z(b"M\0a.rs\0R087\0old\0new\0D\0gone\0");
162        assert_eq!(v[1], ('R', "new".into(), Some("old".into())));
163        assert_eq!(v.len(), 3);
164    }
165}