Skip to main content

xei_core/
git_graph.rs

1//! Pretty commit graph layout (VS Code / lazygit style).
2//!
3//! Builds colored **lanes** from `git log` parent topology so the TUI can
4//! draw `●` / `│` / merge connectors instead of raw `git log --graph` ASCII.
5
6/// One cell in the graph column strip.
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum GraphGlyph {
9    Empty,
10    /// Commit node `●`
11    Node(u8),
12    /// Vertical edge `│`
13    Pipe(u8),
14    /// Merge / fork connectors (unicode box drawing)
15    /// `╭` coming from upper-right into this column
16    CornerTL(u8),
17    /// `╮`
18    CornerTR(u8),
19    /// `╰`
20    CornerBL(u8),
21    /// `╯`
22    CornerBR(u8),
23    /// `─`
24    Horizontal(u8),
25    /// `├`
26    TeeRight(u8),
27    /// `┤`
28    TeeLeft(u8),
29    /// `┼`
30    Cross(u8),
31    /// `/` style merge approaching from right-below → left-above (drawn as `╱`)
32    Slash(u8),
33    /// `\` style `╲`
34    Backslash(u8),
35}
36
37impl GraphGlyph {
38    pub fn ch(self) -> char {
39        match self {
40            GraphGlyph::Empty => ' ',
41            GraphGlyph::Node(_) => '●',
42            GraphGlyph::Pipe(_) => '│',
43            GraphGlyph::CornerTL(_) => '╭',
44            GraphGlyph::CornerTR(_) => '╮',
45            GraphGlyph::CornerBL(_) => '╰',
46            GraphGlyph::CornerBR(_) => '╯',
47            GraphGlyph::Horizontal(_) => '─',
48            GraphGlyph::TeeRight(_) => '├',
49            GraphGlyph::TeeLeft(_) => '┤',
50            GraphGlyph::Cross(_) => '┼',
51            GraphGlyph::Slash(_) => '╱',
52            GraphGlyph::Backslash(_) => '╲',
53        }
54    }
55
56    pub fn color_id(self) -> Option<u8> {
57        match self {
58            GraphGlyph::Empty => None,
59            GraphGlyph::Node(c)
60            | GraphGlyph::Pipe(c)
61            | GraphGlyph::CornerTL(c)
62            | GraphGlyph::CornerTR(c)
63            | GraphGlyph::CornerBL(c)
64            | GraphGlyph::CornerBR(c)
65            | GraphGlyph::Horizontal(c)
66            | GraphGlyph::TeeRight(c)
67            | GraphGlyph::TeeLeft(c)
68            | GraphGlyph::Cross(c)
69            | GraphGlyph::Slash(c)
70            | GraphGlyph::Backslash(c) => Some(c),
71        }
72    }
73}
74
75/// One rendered commit row in the graph.
76#[derive(Debug, Clone)]
77pub struct GraphRow {
78    pub hash: String,
79    pub short: String,
80    pub subject: String,
81    pub author: String,
82    pub when: String,
83    /// Decorations e.g. `HEAD -> master, origin/master`
84    pub refs: String,
85    pub lane: usize,
86    pub color: u8,
87    /// Graph strip (one glyph per column; typically width ≤ 8)
88    pub glyphs: Vec<GraphGlyph>,
89}
90
91#[derive(Debug, Clone)]
92pub(crate) struct RawCommit {
93    hash: String,
94    short: String,
95    parents: Vec<String>,
96    refs: String,
97    subject: String,
98    author: String,
99    when: String,
100}
101
102/// Palette size for lane colors (UI maps id → RGB).
103pub const LANE_COLORS: usize = 8;
104
105/// Parse `git log --pretty=format:%H%x00%h%x00%P%x00%d%x00%s%x00%an%x00%ar` output
106/// (records separated by newlines; empty parent field allowed).
107pub(crate) fn parse_log_output(text: &str) -> Vec<RawCommit> {
108    let mut out = Vec::new();
109    for line in text.lines() {
110        if line.is_empty() {
111            continue;
112        }
113        let parts: Vec<&str> = line.split('\0').collect();
114        if parts.len() < 7 {
115            // tolerate missing trailing fields
116            if parts.len() < 3 {
117                continue;
118            }
119        }
120        let hash = parts.first().copied().unwrap_or("").to_string();
121        if hash.len() < 7 {
122            continue;
123        }
124        let short = parts.get(1).copied().unwrap_or(&hash[..7.min(hash.len())]).to_string();
125        let parents: Vec<String> = parts
126            .get(2)
127            .copied()
128            .unwrap_or("")
129            .split_whitespace()
130            .filter(|s| !s.is_empty())
131            .map(|s| s.to_string())
132            .collect();
133        let refs = parts
134            .get(3)
135            .copied()
136            .unwrap_or("")
137            .trim()
138            .trim_start_matches('(')
139            .trim_end_matches(')')
140            .to_string();
141        let subject = parts.get(4).copied().unwrap_or("").to_string();
142        let author = parts.get(5).copied().unwrap_or("").to_string();
143        let when = parts.get(6).copied().unwrap_or("").to_string();
144        out.push(RawCommit {
145            hash,
146            short,
147            parents,
148            refs,
149            subject,
150            author,
151            when,
152        });
153    }
154    out
155}
156
157/// Layout commits into colored lanes (newest first, as `git log` returns).
158pub(crate) fn layout_graph(commits: &[RawCommit]) -> Vec<GraphRow> {
159    if commits.is_empty() {
160        return Vec::new();
161    }
162
163    // Active lanes: each entry is the commit hash expected next in that column
164    // (i.e. child already drawn, waiting for this parent).
165    let mut active: Vec<Option<String>> = Vec::new();
166    // Stable color per lane index
167    let mut rows = Vec::with_capacity(commits.len());
168
169    // Map hash → index for quick "already have lane for parent"
170    let _ = commits;
171
172    for commit in commits {
173        // 1) Find existing lane reserved for this commit, else open a new one
174        let mut lane = None;
175        for (i, slot) in active.iter().enumerate() {
176            if slot.as_ref() == Some(&commit.hash) {
177                lane = Some(i);
178                break;
179            }
180        }
181        if lane.is_none() {
182            if let Some(i) = active.iter().position(|s| s.is_none()) {
183                active[i] = Some(commit.hash.clone());
184                lane = Some(i);
185            } else {
186                active.push(Some(commit.hash.clone()));
187                lane = Some(active.len() - 1);
188            }
189        }
190        let lane = lane.unwrap();
191        let color = (lane % LANE_COLORS) as u8;
192
193        // 2) Build glyph row for *current* active lanes (before parent update)
194        let width = active.len().max(1).min(10);
195        let mut glyphs = vec![GraphGlyph::Empty; width];
196        for (i, slot) in active.iter().enumerate().take(width) {
197            if slot.is_some() {
198                if i == lane {
199                    glyphs[i] = GraphGlyph::Node(color);
200                } else {
201                    let c = (i % LANE_COLORS) as u8;
202                    glyphs[i] = GraphGlyph::Pipe(c);
203                }
204            }
205        }
206
207        // 3) Update active lanes with parents
208        // First parent continues this lane unless already reserved elsewhere (merge).
209        let parents = &commit.parents;
210        if parents.is_empty() {
211            active[lane] = None;
212        } else {
213            let first = &parents[0];
214            if let Some(existing) = active
215                .iter()
216                .enumerate()
217                .find(|(i, s)| *i != lane && s.as_ref() == Some(first))
218                .map(|(i, _)| i)
219            {
220                // Parent already has a lane → close ours and draw merge bridge
221                active[lane] = None;
222                paint_merge_link(&mut glyphs, lane, existing, color);
223            } else {
224                active[lane] = Some(first.clone());
225            }
226
227            for p in parents.iter().skip(1) {
228                if let Some(target) = active.iter().position(|s| s.as_ref() == Some(p)) {
229                    paint_merge_link(&mut glyphs, lane, target, color);
230                    continue;
231                }
232                if let Some(i) = active.iter().position(|s| s.is_none()) {
233                    active[i] = Some(p.clone());
234                    if glyphs.len() <= i {
235                        glyphs.resize(i + 1, GraphGlyph::Empty);
236                    }
237                    paint_merge_link(&mut glyphs, lane, i, (i % LANE_COLORS) as u8);
238                } else if active.len() < 10 {
239                    active.push(Some(p.clone()));
240                    let i = active.len() - 1;
241                    glyphs.resize(i + 1, GraphGlyph::Empty);
242                    paint_merge_link(&mut glyphs, lane, i, (i % LANE_COLORS) as u8);
243                }
244            }
245        }
246
247        // Compact trailing empties from active (keep holes for stability mid-graph)
248        while active.last().is_some_and(|s| s.is_none()) {
249            active.pop();
250        }
251
252        rows.push(GraphRow {
253            hash: commit.hash.clone(),
254            short: commit.short.clone(),
255            subject: commit.subject.clone(),
256            author: commit.author.clone(),
257            when: commit.when.clone(),
258            refs: commit.refs.clone(),
259            lane,
260            color,
261            glyphs,
262        });
263    }
264
265    rows
266}
267
268/// Draw a horizontal merge bridge between two columns on the current row.
269fn paint_merge_link(glyphs: &mut Vec<GraphGlyph>, from: usize, to: usize, color: u8) {
270    if from == to {
271        return;
272    }
273    let (lo, hi) = if from < to { (from, to) } else { (to, from) };
274    if glyphs.len() <= hi {
275        glyphs.resize(hi + 1, GraphGlyph::Empty);
276    }
277    // Keep node at `from`; fill middle with ─; put a tee/corner at ends.
278    for i in lo + 1..hi {
279        match glyphs[i] {
280            GraphGlyph::Empty => glyphs[i] = GraphGlyph::Horizontal(color),
281            GraphGlyph::Pipe(c) => glyphs[i] = GraphGlyph::Cross(c),
282            GraphGlyph::Node(_) => {}
283            _ => glyphs[i] = GraphGlyph::Horizontal(color),
284        }
285    }
286    // Corners at endpoints (don't overwrite Node)
287    if !matches!(glyphs[lo], GraphGlyph::Node(_)) {
288        glyphs[lo] = if from < to {
289            GraphGlyph::TeeRight(color)
290        } else {
291            GraphGlyph::CornerBL(color)
292        };
293    }
294    if !matches!(glyphs[hi], GraphGlyph::Node(_)) {
295        glyphs[hi] = if from < to {
296            GraphGlyph::CornerTR(color)
297        } else {
298            GraphGlyph::TeeLeft(color)
299        };
300    } else if from != hi {
301        // target already has node from another meaning — use pipe under merge feel
302    }
303}
304
305/// High-level: parse + layout from raw git log pretty output.
306pub fn build_graph(log_text: &str) -> Vec<GraphRow> {
307    let commits = parse_log_output(log_text);
308    layout_graph(&commits)
309}
310
311/// Map lane color id → (r,g,b) — VS Code–ish branch colors.
312pub fn lane_rgb(id: u8) -> (u8, u8, u8) {
313    const P: [(u8, u8, u8); LANE_COLORS] = [
314        (180, 120, 255), // purple
315        (80, 180, 255),  // blue
316        (80, 210, 140),  // green
317        (255, 170, 70),  // orange
318        (255, 120, 180), // pink
319        (100, 220, 220), // cyan
320        (255, 220, 100), // yellow
321        (160, 160, 255), // soft indigo
322    ];
323    P[(id as usize) % LANE_COLORS]
324}
325
326/// Build a short detail string for the selection popup / detail line.
327pub fn detail_line(row: &GraphRow) -> String {
328    let mut s = format!("{} · {} · {}", row.short, row.author, row.when);
329    if !row.refs.is_empty() {
330        s.push_str(" · ");
331        s.push_str(&row.refs);
332    }
333    s
334}
335
336#[cfg(test)]
337mod tests {
338    use super::*;
339
340    #[test]
341    fn parse_single_commit() {
342        let text = "aabbccddeeff00112233445566778899aabbccdd\0aabbccd\0\0HEAD -> master\0init\0Alice\02 days ago\n";
343        let c = parse_log_output(text);
344        assert_eq!(c.len(), 1);
345        assert_eq!(c[0].short, "aabbccd");
346        assert_eq!(c[0].subject, "init");
347        assert!(c[0].parents.is_empty());
348    }
349
350    #[test]
351    fn linear_history_one_lane() {
352        // newest first: c2 -> c1 -> c0
353        let text = "\
354ccc0000000000000000000000000000000000002\0ccc0002\0bbb0000000000000000000000000000000000001\0\0third\0A\01 hour ago\n\
355bbb0000000000000000000000000000000000001\0bbb0001\0aaa0000000000000000000000000000000000000\0\0second\0A\02 hours ago\n\
356aaa0000000000000000000000000000000000000\0aaa0000\0\0\0first\0A\03 hours ago\n";
357        let rows = build_graph(text);
358        assert_eq!(rows.len(), 3);
359        // all on lane 0 ideally
360        assert!(rows.iter().all(|r| r.lane == 0));
361        assert!(matches!(rows[0].glyphs[0], GraphGlyph::Node(_)));
362    }
363
364    #[test]
365    fn branch_creates_second_lane() {
366        // c_main parents p
367        // c_feat parents p  (two children of p → two lanes when both shown)
368        // Order newest-first: feat, main, p
369        let p = "ppp0000000000000000000000000000000000000";
370        let main = "mmm0000000000000000000000000000000000000";
371        let feat = "fff0000000000000000000000000000000000000";
372        let text = format!(
373            "{feat}\0fff0000\0{p}\0\0feat work\0A\01 hour ago\n\
374             {main}\0mmm0000\0{p}\0HEAD -> master\0main work\0A\02 hours ago\n\
375             {p}\0ppp0000\0\0\0base\0A\03 hours ago\n"
376        );
377        let rows = build_graph(&text);
378        assert_eq!(rows.len(), 3);
379        // At least one row should use a non-zero lane or we still have nodes
380        assert!(rows.iter().any(|r| r.glyphs.iter().any(|g| matches!(g, GraphGlyph::Node(_)))));
381    }
382
383    #[test]
384    fn glyph_chars() {
385        assert_eq!(GraphGlyph::Node(0).ch(), '●');
386        assert_eq!(GraphGlyph::Pipe(1).ch(), '│');
387    }
388}