Skip to main content

journey/widgets/
graph.rs

1//! Commit-DAG lane assignment — the math behind gitk's colored graph column.
2//!
3//! Given commits in reverse-topological order (newest first) with their parent
4//! SHAs, [`compute_graph`] assigns each commit a *lane* (column) and emits the
5//! line segments to draw in the top and bottom halves of its row, so a branch
6//! shows as a vertical line that forks at a parent and merges back at a child.
7//!
8//! The algorithm is the standard incremental one: walk rows top-to-bottom
9//! keeping a set of *active lanes*, each tracking the SHA it's heading toward
10//! (a parent it expects to reach). A commit takes the lane(s) that were waiting
11//! for it (children merging in), then its first parent continues that lane
12//! while extra parents (merges) open new lanes. It's pure and free of any
13//! rendering, so it can be unit-tested directly.
14
15const MAX_LANES: usize = 24;
16
17/// Per-row graph geometry. Columns are lane indices; the renderer maps them to
18/// x positions. Segments are `(column_at_edge, column_at_center)` for the top
19/// half and `(column_at_center, column_at_edge)` for the bottom half.
20#[derive(Clone, Debug, PartialEq, Eq, Default)]
21pub struct GraphRow {
22    /// Lane the commit's dot sits in.
23    pub node_col: usize,
24    /// Top-half segments: from a lane at the row's top edge down to the center.
25    pub top: Vec<(usize, usize)>,
26    /// Bottom-half segments: from the center down to a lane at the bottom edge.
27    pub bottom: Vec<(usize, usize)>,
28}
29
30/// The full graph plus the number of lanes ever active (for gutter sizing).
31#[derive(Clone, Debug, PartialEq, Eq, Default)]
32pub struct Graph {
33    pub rows: Vec<GraphRow>,
34    pub lane_count: usize,
35}
36
37/// Compute the lane layout for `commits`, each `(sha, parent_shas)`, in
38/// reverse-topological (newest-first) order.
39pub fn compute_graph(commits: &[(String, Vec<String>)]) -> Graph {
40    // Each active lane tracks the SHA it is heading toward (a parent).
41    let mut lanes: Vec<Option<String>> = Vec::new();
42    let mut rows = Vec::with_capacity(commits.len());
43    let mut lane_count = 1usize;
44
45    for (id, parents) in commits {
46        // Lanes whose expected commit is this one — children merging in.
47        let incoming: Vec<usize> = lanes
48            .iter()
49            .enumerate()
50            .filter_map(|(i, l)| (l.as_deref() == Some(id.as_str())).then_some(i))
51            .collect();
52
53        let node_col = match incoming.first() {
54            Some(&first) => first,
55            None => free_slot(&mut lanes), // a tip: open a fresh lane
56        };
57
58        // Top half: every active lane draws a segment to the row center;
59        // lanes waiting for this commit converge into the node's column.
60        let mut top = Vec::new();
61        for (i, lane) in lanes.iter().enumerate() {
62            if lane.is_none() {
63                continue;
64            }
65            if incoming.contains(&i) {
66                top.push((i, node_col));
67            } else {
68                top.push((i, i));
69            }
70        }
71
72        // The converging lanes are consumed by this commit.
73        for &i in &incoming {
74            lanes[i] = None;
75        }
76        if node_col < lanes.len() {
77            lanes[node_col] = None;
78        }
79
80        // Route parents into lanes. The first parent continues the node's
81        // lane; extra parents reuse a lane already heading to them, else open
82        // a new one.
83        let mut parent_cols = Vec::new();
84        for (k, parent) in parents.iter().enumerate() {
85            let col = if let Some(existing) = lanes
86                .iter()
87                .position(|l| l.as_deref() == Some(parent.as_str()))
88            {
89                existing
90            } else if k == 0 {
91                node_col
92            } else {
93                free_slot(&mut lanes)
94            };
95            ensure_len(&mut lanes, col);
96            lanes[col] = Some(parent.clone());
97            if !parent_cols.contains(&col) {
98                parent_cols.push(col);
99            }
100        }
101
102        // Bottom half: every still-active lane draws from the center to the
103        // bottom edge; the node's parents start from the node's column.
104        let mut bottom = Vec::new();
105        for (j, lane) in lanes.iter().enumerate() {
106            if lane.is_none() {
107                continue;
108            }
109            if parent_cols.contains(&j) {
110                bottom.push((node_col, j));
111            } else {
112                bottom.push((j, j));
113            }
114        }
115
116        while matches!(lanes.last(), Some(None)) {
117            lanes.pop();
118        }
119
120        for &(a, b) in top.iter().chain(bottom.iter()) {
121            lane_count = lane_count.max(a + 1).max(b + 1);
122        }
123        lane_count = lane_count.max(node_col + 1).max(lanes.len());
124
125        rows.push(GraphRow {
126            node_col,
127            top,
128            bottom,
129        });
130    }
131
132    Graph {
133        rows,
134        lane_count: lane_count.clamp(1, MAX_LANES),
135    }
136}
137
138fn free_slot(lanes: &mut Vec<Option<String>>) -> usize {
139    match lanes.iter().position(|l| l.is_none()) {
140        Some(i) => i,
141        None => {
142            lanes.push(None);
143            lanes.len() - 1
144        }
145    }
146}
147
148fn ensure_len(lanes: &mut Vec<Option<String>>, idx: usize) {
149    if idx >= lanes.len() {
150        lanes.resize(idx + 1, None);
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    fn c(id: &str, parents: &[&str]) -> (String, Vec<String>) {
159        (
160            id.to_string(),
161            parents.iter().map(|p| p.to_string()).collect(),
162        )
163    }
164
165    #[test]
166    fn linear_history_stays_in_one_lane() {
167        let commits = [c("A", &["B"]), c("B", &["C"]), c("C", &[])];
168        let g = compute_graph(&commits);
169        assert_eq!(g.lane_count, 1);
170        assert!(g.rows.iter().all(|r| r.node_col == 0));
171        // The tip has nothing above it; the root has nothing below it.
172        assert!(g.rows[0].top.is_empty());
173        assert_eq!(g.rows[0].bottom, vec![(0, 0)]);
174        assert!(g.rows[2].bottom.is_empty());
175    }
176
177    #[test]
178    fn branch_then_merge_uses_two_lanes() {
179        // A is a merge of B and D; both lead back to C.
180        let commits = [
181            c("A", &["B", "D"]),
182            c("B", &["C"]),
183            c("D", &["C"]),
184            c("C", &["E"]),
185            c("E", &[]),
186        ];
187        let g = compute_graph(&commits);
188        assert_eq!(g.lane_count, 2, "branch should occupy a second lane");
189        // The merge commit fans out to two lanes below it.
190        assert_eq!(g.rows[0].node_col, 0);
191        assert_eq!(g.rows[0].bottom, vec![(0, 0), (0, 1)]);
192        // D (row 3) lives in lane 1 and merges back down into lane 0 at C.
193        assert_eq!(g.rows[2].node_col, 1);
194        assert_eq!(g.rows[2].bottom, vec![(1, 0)]);
195        // Everything collapses back to one lane by the root.
196        assert_eq!(g.rows[4].node_col, 0);
197        assert!(g.rows[4].bottom.is_empty());
198    }
199}