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 refs: Vec::new(),
166 }
167 }
168
169 #[test]
170 fn empty_commits_returns_empty_graph() {
171 let rows = build_graph(&[]);
172 assert!(rows.is_empty());
173 }
174
175 #[test]
176 fn single_commit_no_parents() {
177 let commits = vec![make_commit("aaa0000", &[])];
178 let rows = build_graph(&commits);
179 assert_eq!(rows.len(), 1);
180 assert_eq!(rows[0].node_column, 0);
181 assert!(rows[0].edges.is_empty());
182 }
183
184 #[test]
185 fn linear_history() {
186 let commits = vec![
187 make_commit("ccc0000", &["bbb0000"]),
188 make_commit("bbb0000", &["aaa0000"]),
189 make_commit("aaa0000", &[]),
190 ];
191 let rows = build_graph(&commits);
192 assert_eq!(rows.len(), 3);
193 for row in &rows {
194 assert_eq!(row.node_column, 0);
195 }
196 }
197
198 #[test]
199 fn branch_uses_separate_lane() {
200 let commits = vec![
201 make_commit("ddd0000", &["bbb0000", "ccc0000"]),
202 make_commit("ccc0000", &["aaa0000"]),
203 make_commit("bbb0000", &["aaa0000"]),
204 make_commit("aaa0000", &[]),
205 ];
206 let rows = build_graph(&commits);
207 assert_eq!(rows.len(), 4);
208 let merge_row = &rows[0];
209 assert!(merge_row.edges.len() >= 2);
210 }
211
212 #[test]
213 fn graph_width_is_at_least_node_column_plus_one() {
214 let commits = vec![
215 make_commit("ccc0000", &["bbb0000"]),
216 make_commit("bbb0000", &["aaa0000"]),
217 make_commit("aaa0000", &[]),
218 ];
219 let rows = build_graph(&commits);
220 for row in &rows {
221 assert!(row.width > row.node_column);
222 }
223 }
224}