1#[derive(Debug, Clone, Copy, PartialEq, Eq)]
29pub enum GraphStyle {
30 Ascii,
32 Curves,
34 Heavy,
36 Bubbles,
38 BubblesX,
41}
42
43impl GraphStyle {
44 pub fn from_str(s: &str) -> Self {
45 match s {
46 "ascii" => Self::Ascii,
47 "heavy" => Self::Heavy,
48 "bubbles" => Self::Bubbles,
49 "bubbles-x" | "bubbles_x" | "bubblesx" => Self::BubblesX,
50 _ => Self::Curves,
51 }
52 }
53
54 pub fn as_str(self) -> &'static str {
55 match self {
56 Self::Ascii => "ascii",
57 Self::Curves => "curves",
58 Self::Heavy => "heavy",
59 Self::Bubbles => "bubbles",
60 Self::BubblesX => "bubbles-x",
61 }
62 }
63
64 pub fn commit_glyph(self, parent_count: usize) -> char {
70 match (self, parent_count) {
71 (Self::Ascii, _) => '*',
72 (Self::Heavy, 0) => '□',
73 (Self::Heavy, 1) => '◉',
74 (Self::Heavy, _) => '◆',
75 (Self::Curves | Self::Bubbles | Self::BubblesX, 0) => '〇',
76 (Self::Curves | Self::Bubbles | Self::BubblesX, 1) => '⦿',
77 (Self::Curves | Self::Bubbles | Self::BubblesX, _) => '◉',
78 }
79 }
80
81 pub fn lane_glyph(self) -> char {
83 match self {
84 Self::Ascii => '|',
85 Self::Heavy => '┃',
86 Self::Curves | Self::Bubbles | Self::BubblesX => '│',
87 }
88 }
89
90 pub fn close_left_glyph(self) -> char {
94 match self {
95 Self::Ascii => '/',
96 Self::Heavy => '┛',
97 Self::Curves | Self::Bubbles | Self::BubblesX => '◟',
98 }
99 }
100
101 pub fn open_right_glyph(self) -> char {
104 match self {
105 Self::Ascii => '\\',
106 Self::Heavy => '┓',
107 Self::Curves | Self::Bubbles | Self::BubblesX => '◝',
108 }
109 }
110
111 pub fn lane_spacing(self) -> usize {
114 match self {
115 Self::Ascii | Self::Curves | Self::Heavy => 1,
116 Self::Bubbles | Self::BubblesX => 3,
117 }
118 }
119
120 pub fn expanded_extra_lines(self) -> usize {
123 match self {
124 Self::BubblesX => 1,
125 _ => 0,
126 }
127 }
128}
129
130impl Default for GraphStyle {
131 fn default() -> Self {
132 Self::Curves
133 }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct GraphCommit {
138 pub id: String,
139 pub parents: Vec<String>,
140}
141
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct GraphRow {
144 pub commit_line: String,
146 pub transition_line: String,
149 pub lane: usize,
151 pub parent_count: usize,
153}
154
155#[allow(dead_code)]
158pub fn render(commits: &[GraphCommit]) -> Vec<GraphRow> {
159 render_with(commits, GraphStyle::Ascii)
160}
161
162pub fn render_with(commits: &[GraphCommit], style: GraphStyle) -> Vec<GraphRow> {
168 let mut rows = Vec::with_capacity(commits.len());
169 let mut lanes: Vec<Option<String>> = Vec::new();
171
172 for (idx, commit) in commits.iter().enumerate() {
173 let lane = match lanes.iter().position(|l| l.as_deref() == Some(&commit.id)) {
175 Some(i) => i,
176 None => {
177 lanes.push(Some(commit.id.clone()));
179 lanes.len() - 1
180 }
181 };
182
183 let parent_count = commit.parents.len();
185 let pre_width = active_width(&lanes);
186 let lane_g = style.lane_glyph();
187 let commit_g = style.commit_glyph(parent_count);
188 let spacing = style.lane_spacing();
189 let pad: String = std::iter::repeat(' ').take(spacing).collect();
190 let mut commit_line = String::with_capacity(pre_width * (1 + spacing));
191 for i in 0..pre_width {
192 if i > 0 {
193 commit_line.push_str(&pad);
194 }
195 if i == lane {
196 commit_line.push(commit_g);
197 } else if lanes[i].is_some() {
198 commit_line.push(lane_g);
199 } else {
200 commit_line.push(' ');
201 }
202 }
203
204 if parent_count == 0 {
207 lanes[lane] = None;
208 } else {
209 let first = commit.parents[0].clone();
212 let already = lanes
215 .iter()
216 .enumerate()
217 .find(|(i, l)| *i != lane && l.as_deref() == Some(&first));
218 if already.is_some() {
219 lanes[lane] = None;
220 } else {
221 lanes[lane] = Some(first);
222 }
223
224 for p in &commit.parents[1..] {
226 if !lanes.iter().any(|l| l.as_deref() == Some(p.as_str())) {
227 let slot = lanes.iter().position(|l| l.is_none());
229 match slot {
230 Some(i) => lanes[i] = Some(p.clone()),
231 None => lanes.push(Some(p.clone())),
232 }
233 }
234 }
235 }
236
237 let post_width = active_width(&lanes);
241 let width = pre_width.max(post_width);
242 let close_g = style.close_left_glyph();
243 let open_g = style.open_right_glyph();
244 let mut transition = String::with_capacity(width * (1 + spacing));
245 let mut any_change = false;
246 for i in 0..width {
247 if i > 0 {
248 transition.push_str(&pad);
249 }
250 let was_active = i < pre_width
251 && (i == lane || lanes_at_commit_active(&lanes, i, lane, commit, idx));
252 let now_active = i < lanes.len() && lanes[i].is_some();
253 if i == lane && parent_count >= 2 {
254 transition.push(lane_g);
255 } else if !now_active && was_active {
256 transition.push(close_g);
257 any_change = true;
258 } else if now_active {
259 transition.push(lane_g);
260 } else {
261 transition.push(' ');
262 }
263 }
264 if parent_count >= 2 {
268 let new_parent_ids: Vec<&String> = commit.parents[1..].iter().collect();
269 for npid in new_parent_ids {
270 if let Some(i) = lanes.iter().position(|l| l.as_deref() == Some(npid.as_str())) {
271 if i > lane {
272 let chars: Vec<char> = transition.chars().collect();
273 let cell = i * (1 + spacing);
274 if cell < chars.len() {
275 let cur = chars[cell];
276 if cur == lane_g || cur == ' ' {
277 let mut new_chars = chars;
278 new_chars[cell] = open_g;
279 transition = new_chars.into_iter().collect();
280 any_change = true;
281 }
282 }
283 }
284 }
285 }
286 }
287
288 let trimmed = transition.trim_end().to_string();
290 let transition_final = if any_change && !trimmed.is_empty() {
291 trimmed
292 } else {
293 String::new()
294 };
295
296 while lanes.last().map(|l| l.is_none()).unwrap_or(false) {
298 lanes.pop();
299 }
300
301 rows.push(GraphRow {
302 commit_line: commit_line.trim_end().to_string(),
303 transition_line: transition_final,
304 lane,
305 parent_count,
306 });
307 }
308
309 rows
310}
311
312fn active_width(lanes: &[Option<String>]) -> usize {
313 lanes
314 .iter()
315 .rposition(|l| l.is_some())
316 .map(|i| i + 1)
317 .unwrap_or(0)
318}
319
320pub fn padding_row(commit_line: &str, style: GraphStyle) -> String {
328 let lane_g = style.lane_glyph();
329 commit_line
330 .chars()
331 .map(|c| if c == ' ' { ' ' } else { lane_g })
332 .collect()
333}
334
335fn lanes_at_commit_active(
339 _lanes_after: &[Option<String>],
340 _i: usize,
341 _commit_lane: usize,
342 _commit: &GraphCommit,
343 _idx: usize,
344) -> bool {
345 true
346}
347
348pub fn lane_color(lane: usize) -> u8 {
351 const PALETTE: &[u8] = &[
354 39, 208, 207, 226, 46, 99, 202, 51, 220, 129, ];
365 PALETTE[lane % PALETTE.len()]
366}
367
368pub fn format_ref_badge(raw: &str) -> String {
376 if raw.starts_with("HEAD -> ") {
377 format!("★ {}", raw)
378 } else if raw == "HEAD" {
379 "★ HEAD (detached)".to_string()
380 } else if let Some(name) = raw.strip_prefix("tag: ") {
381 format!("◆ {}", name)
382 } else {
383 format!("▸ {}", raw)
384 }
385}
386
387#[allow(dead_code)]
391pub fn ref_badge_color(raw: &str) -> u8 {
392 if raw.starts_with("HEAD") {
393 199 } else if raw.starts_with("tag: ") {
395 220 } else {
397 51 }
399}
400
401#[derive(Debug, Clone)]
407#[allow(dead_code)] pub struct DecoratedCommit {
409 pub id: String,
410 pub short_id: String,
411 pub summary: String,
412 pub author: String,
413 pub timestamp: i64,
414 pub parents: Vec<String>,
415 pub refs: Vec<String>,
417}
418
419pub fn walk_repo(
424 repo: &git2::Repository,
425 limit: usize,
426 include_all: bool,
427) -> Result<Vec<DecoratedCommit>, git2::Error> {
428 use std::collections::HashMap;
429
430 let mut labels: HashMap<git2::Oid, Vec<String>> = HashMap::new();
432 let head_oid = repo.head().ok().and_then(|h| h.target());
433 let head_name = repo
434 .head()
435 .ok()
436 .and_then(|h| h.shorthand().map(|s| s.to_string()));
437
438 for r in repo.references()?.flatten() {
439 let Some(oid) = r.target() else { continue };
440 let Some(name) = r.name() else { continue };
441 let label = if let Some(short) = name.strip_prefix("refs/heads/") {
442 if Some(oid) == head_oid && head_name.as_deref() == Some(short) {
443 format!("HEAD -> {}", short)
444 } else {
445 short.to_string()
446 }
447 } else if let Some(short) = name.strip_prefix("refs/tags/") {
448 format!("tag: {}", short)
449 } else if let Some(short) = name.strip_prefix("refs/remotes/") {
450 let _ = short;
452 continue;
453 } else {
454 continue;
455 };
456 labels.entry(oid).or_default().push(label);
457 }
458
459 if let (Some(oid), Some(_)) = (head_oid, head_name.as_ref()) {
461 let entry = labels.entry(oid).or_default();
462 if !entry.iter().any(|s| s.starts_with("HEAD")) {
463 entry.insert(0, "HEAD".to_string());
464 }
465 }
466
467 let mut walk = repo.revwalk()?;
468 walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
469
470 if include_all {
471 for r in repo.references()?.flatten() {
472 let Some(name) = r.name() else { continue };
473 if name.starts_with("refs/heads/") || name.starts_with("refs/tags/") {
474 if let Some(oid) = r.target() {
475 let _ = walk.push(oid);
476 }
477 }
478 }
479 } else {
480 walk.push_head()?;
481 }
482
483 let mut out = Vec::with_capacity(limit);
484 for oid_res in walk.take(limit) {
485 let oid = oid_res?;
486 let commit = repo.find_commit(oid)?;
487 let id = oid.to_string();
488 let short_id = id.chars().take(7).collect();
489 let summary = commit.summary().unwrap_or("").to_string();
490 let author = commit
491 .author()
492 .name()
493 .unwrap_or("")
494 .to_string();
495 let timestamp = commit.time().seconds();
496 let parents: Vec<String> = commit.parent_ids().map(|p| p.to_string()).collect();
497 let refs = labels.remove(&oid).unwrap_or_default();
498 out.push(DecoratedCommit {
499 id,
500 short_id,
501 summary,
502 author,
503 timestamp,
504 parents,
505 refs,
506 });
507 }
508 Ok(out)
509}
510
511#[allow(dead_code)]
513pub fn render_repo(
514 repo: &git2::Repository,
515 limit: usize,
516 include_all: bool,
517) -> Result<Vec<(DecoratedCommit, GraphRow)>, git2::Error> {
518 render_repo_with(repo, limit, include_all, GraphStyle::Ascii)
519}
520
521pub fn render_repo_with(
523 repo: &git2::Repository,
524 limit: usize,
525 include_all: bool,
526 style: GraphStyle,
527) -> Result<Vec<(DecoratedCommit, GraphRow)>, git2::Error> {
528 let commits = walk_repo(repo, limit, include_all)?;
529 let graph_input: Vec<GraphCommit> = commits
530 .iter()
531 .map(|c| GraphCommit {
532 id: c.id.clone(),
533 parents: c.parents.clone(),
534 })
535 .collect();
536 let rows = render_with(&graph_input, style);
537 Ok(commits.into_iter().zip(rows.into_iter()).collect())
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 fn c(id: &str, parents: &[&str]) -> GraphCommit {
545 GraphCommit {
546 id: id.to_string(),
547 parents: parents.iter().map(|s| s.to_string()).collect(),
548 }
549 }
550
551 #[test]
552 fn linear_history() {
553 let commits = vec![c("c", &["b"]), c("b", &["a"]), c("a", &[])];
554 let rows = render(&commits);
555 assert_eq!(rows.len(), 3);
556 assert_eq!(rows[0].commit_line, "*");
557 assert_eq!(rows[1].commit_line, "*");
558 assert_eq!(rows[2].commit_line, "*");
559 assert_eq!(rows[0].lane, 0);
560 assert_eq!(rows[2].parent_count, 0);
561 }
562
563 #[test]
564 fn simple_merge() {
565 let commits = vec![
573 c("d", &["b", "c"]),
574 c("b", &["a"]),
575 c("c", &["a"]),
576 c("a", &[]),
577 ];
578 let rows = render(&commits);
579 assert_eq!(rows.len(), 4);
580 assert_eq!(rows[0].parent_count, 2);
581 assert_eq!(rows[0].lane, 0);
583 assert!(rows[0].transition_line.contains('\\'));
584 assert_eq!(rows[3].parent_count, 0);
586 }
587
588 #[test]
589 fn fork_then_close() {
590 let commits = vec![c("c", &["a"]), c("b", &["a"]), c("a", &[])];
596 let rows = render(&commits);
597 assert_eq!(rows.len(), 3);
598 assert_eq!(rows[0].commit_line, "*");
601 assert!(rows[1].commit_line.contains('*'));
602 }
603
604 #[test]
605 fn lane_color_stable() {
606 assert_eq!(lane_color(0), lane_color(0));
607 assert_ne!(lane_color(0), lane_color(1));
608 }
609
610 #[test]
611 fn empty_input() {
612 assert_eq!(render(&[]), vec![]);
613 }
614}