gitkraft_core/features/graph/
ops.rs1use anyhow::Result;
4use git2::Repository;
5use std::collections::HashMap;
6
7use super::types::{GraphEdge, GraphRow};
8use crate::features::commits::CommitInfo;
9
10pub fn build_graph(commits: &[CommitInfo]) -> Vec<GraphRow> {
19 if commits.is_empty() {
20 return Vec::new();
21 }
22
23 let mut lanes: Vec<Option<String>> = Vec::new();
24 let mut color_map: HashMap<String, usize> = HashMap::new();
25 let mut next_color: usize = 0;
26 let mut rows: Vec<GraphRow> = Vec::with_capacity(commits.len());
27
28 for commit in commits {
29 let oid = &commit.oid;
30
31 let node_col = lanes
33 .iter()
34 .position(|l| l.as_deref() == Some(oid))
35 .unwrap_or_else(|| {
36 lanes.push(Some(oid.clone()));
37 lanes.len() - 1
38 });
39
40 let node_color = *color_map.entry(oid.clone()).or_insert_with(|| {
42 let c = next_color;
43 next_color += 1;
44 c
45 });
46
47 let mut edges: Vec<GraphEdge> = Vec::new();
49
50 for (col, lane) in lanes.iter().enumerate() {
52 if col == node_col {
53 continue;
54 }
55 if let Some(ref target_oid) = lane {
56 let lane_color = *color_map.entry(target_oid.clone()).or_insert_with(|| {
57 let c = next_color;
58 next_color += 1;
59 c
60 });
61 edges.push(GraphEdge {
62 from_column: col,
63 to_column: col,
64 color_index: lane_color,
65 });
66 }
67 }
68
69 let parents = &commit.parent_ids;
71
72 if parents.is_empty() {
73 lanes[node_col] = None;
75 } else {
76 let first_parent = &parents[0];
78 lanes[node_col] = Some(first_parent.clone());
79
80 let first_parent_color = *color_map.entry(first_parent.clone()).or_insert_with(|| {
81 let c = next_color;
82 next_color += 1;
83 c
84 });
85
86 edges.push(GraphEdge {
87 from_column: node_col,
88 to_column: node_col,
89 color_index: first_parent_color,
90 });
91
92 for parent_oid in &parents[1..] {
94 let existing = lanes
95 .iter()
96 .position(|l| l.as_deref() == Some(parent_oid.as_str()));
97
98 let target_col = if let Some(col) = existing {
99 col
100 } else if let Some(free) = lanes.iter().position(|l| l.is_none()) {
101 lanes[free] = Some(parent_oid.clone());
102 free
103 } else {
104 lanes.push(Some(parent_oid.clone()));
105 lanes.len() - 1
106 };
107
108 let parent_color = *color_map.entry(parent_oid.clone()).or_insert_with(|| {
109 let c = next_color;
110 next_color += 1;
111 c
112 });
113
114 edges.push(GraphEdge {
115 from_column: node_col,
116 to_column: target_col,
117 color_index: parent_color,
118 });
119 }
120 }
121
122 while lanes.last() == Some(&None) {
124 lanes.pop();
125 }
126
127 let width = lanes.len().max(1);
128
129 rows.push(GraphRow {
130 width,
131 node_column: node_col,
132 node_color,
133 edges,
134 });
135 }
136
137 rows
138}
139
140pub fn build_graph_from_repo(
142 repo: &Repository,
143 max_count: usize,
144) -> Result<(Vec<CommitInfo>, Vec<GraphRow>)> {
145 let commits = crate::features::commits::list_commits(repo, max_count)?;
146 let graph = build_graph(&commits);
147 Ok((commits, graph))
148}
149
150#[cfg(test)]
151mod tests {
152 use super::*;
153 use chrono::Utc;
154
155 fn make_commit(oid: &str, parents: &[&str]) -> CommitInfo {
156 CommitInfo {
157 oid: oid.to_string(),
158 short_oid: oid[..7.min(oid.len())].to_string(),
159 summary: format!("commit {oid}"),
160 message: format!("commit {oid}"),
161 author_name: "Test".to_string(),
162 author_email: "test@test.com".to_string(),
163 time: Utc::now(),
164 parent_ids: parents.iter().map(|s| s.to_string()).collect(),
165 }
166 }
167
168 #[test]
169 fn empty_commits_returns_empty_graph() {
170 let rows = build_graph(&[]);
171 assert!(rows.is_empty());
172 }
173
174 #[test]
175 fn single_commit_no_parents() {
176 let commits = vec![make_commit("aaa0000", &[])];
177 let rows = build_graph(&commits);
178 assert_eq!(rows.len(), 1);
179 assert_eq!(rows[0].node_column, 0);
180 assert!(rows[0].edges.is_empty());
181 }
182
183 #[test]
184 fn linear_history() {
185 let commits = vec![
186 make_commit("ccc0000", &["bbb0000"]),
187 make_commit("bbb0000", &["aaa0000"]),
188 make_commit("aaa0000", &[]),
189 ];
190 let rows = build_graph(&commits);
191 assert_eq!(rows.len(), 3);
192 for row in &rows {
193 assert_eq!(row.node_column, 0);
194 }
195 }
196
197 #[test]
198 fn branch_uses_separate_lane() {
199 let commits = vec![
200 make_commit("ddd0000", &["bbb0000", "ccc0000"]),
201 make_commit("ccc0000", &["aaa0000"]),
202 make_commit("bbb0000", &["aaa0000"]),
203 make_commit("aaa0000", &[]),
204 ];
205 let rows = build_graph(&commits);
206 assert_eq!(rows.len(), 4);
207 let merge_row = &rows[0];
208 assert!(merge_row.edges.len() >= 2);
209 }
210
211 #[test]
212 fn graph_width_is_at_least_node_column_plus_one() {
213 let commits = vec![
214 make_commit("ccc0000", &["bbb0000"]),
215 make_commit("bbb0000", &["aaa0000"]),
216 make_commit("aaa0000", &[]),
217 ];
218 let rows = build_graph(&commits);
219 for row in &rows {
220 assert!(row.width > row.node_column);
221 }
222 }
223}