#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GraphStyle {
Ascii,
Curves,
Heavy,
Bubbles,
BubblesX,
}
impl GraphStyle {
pub fn from_str(s: &str) -> Self {
match s {
"ascii" => Self::Ascii,
"heavy" => Self::Heavy,
"bubbles" => Self::Bubbles,
"bubbles-x" | "bubbles_x" | "bubblesx" => Self::BubblesX,
_ => Self::Curves,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Ascii => "ascii",
Self::Curves => "curves",
Self::Heavy => "heavy",
Self::Bubbles => "bubbles",
Self::BubblesX => "bubbles-x",
}
}
pub fn commit_glyph(self, parent_count: usize) -> char {
match (self, parent_count) {
(Self::Ascii, _) => '*',
(Self::Heavy, 0) => '□',
(Self::Heavy, 1) => '◉',
(Self::Heavy, _) => '◆',
(Self::Curves | Self::Bubbles | Self::BubblesX, 0) => '〇',
(Self::Curves | Self::Bubbles | Self::BubblesX, 1) => '⦿',
(Self::Curves | Self::Bubbles | Self::BubblesX, _) => '◉',
}
}
pub fn lane_glyph(self) -> char {
match self {
Self::Ascii => '|',
Self::Heavy => '┃',
Self::Curves | Self::Bubbles | Self::BubblesX => '│',
}
}
pub fn close_left_glyph(self) -> char {
match self {
Self::Ascii => '/',
Self::Heavy => '┛',
Self::Curves | Self::Bubbles | Self::BubblesX => '◟',
}
}
pub fn open_right_glyph(self) -> char {
match self {
Self::Ascii => '\\',
Self::Heavy => '┓',
Self::Curves | Self::Bubbles | Self::BubblesX => '◝',
}
}
pub fn lane_spacing(self) -> usize {
match self {
Self::Ascii | Self::Curves | Self::Heavy => 1,
Self::Bubbles | Self::BubblesX => 3,
}
}
pub fn expanded_extra_lines(self) -> usize {
match self {
Self::BubblesX => 1,
_ => 0,
}
}
}
impl Default for GraphStyle {
fn default() -> Self {
Self::Curves
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphCommit {
pub id: String,
pub parents: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GraphRow {
pub commit_line: String,
pub transition_line: String,
pub lane: usize,
pub parent_count: usize,
}
#[allow(dead_code)]
pub fn render(commits: &[GraphCommit]) -> Vec<GraphRow> {
render_with(commits, GraphStyle::Ascii)
}
pub fn render_with(commits: &[GraphCommit], style: GraphStyle) -> Vec<GraphRow> {
let mut rows = Vec::with_capacity(commits.len());
let mut lanes: Vec<Option<String>> = Vec::new();
for (idx, commit) in commits.iter().enumerate() {
let lane = match lanes.iter().position(|l| l.as_deref() == Some(&commit.id)) {
Some(i) => i,
None => {
lanes.push(Some(commit.id.clone()));
lanes.len() - 1
}
};
let parent_count = commit.parents.len();
let pre_width = active_width(&lanes);
let lane_g = style.lane_glyph();
let commit_g = style.commit_glyph(parent_count);
let spacing = style.lane_spacing();
let pad: String = std::iter::repeat(' ').take(spacing).collect();
let mut commit_line = String::with_capacity(pre_width * (1 + spacing));
for i in 0..pre_width {
if i > 0 {
commit_line.push_str(&pad);
}
if i == lane {
commit_line.push(commit_g);
} else if lanes[i].is_some() {
commit_line.push(lane_g);
} else {
commit_line.push(' ');
}
}
if parent_count == 0 {
lanes[lane] = None;
} else {
let first = commit.parents[0].clone();
let already = lanes
.iter()
.enumerate()
.find(|(i, l)| *i != lane && l.as_deref() == Some(&first));
if already.is_some() {
lanes[lane] = None;
} else {
lanes[lane] = Some(first);
}
for p in &commit.parents[1..] {
if !lanes.iter().any(|l| l.as_deref() == Some(p.as_str())) {
let slot = lanes.iter().position(|l| l.is_none());
match slot {
Some(i) => lanes[i] = Some(p.clone()),
None => lanes.push(Some(p.clone())),
}
}
}
}
let post_width = active_width(&lanes);
let width = pre_width.max(post_width);
let close_g = style.close_left_glyph();
let open_g = style.open_right_glyph();
let mut transition = String::with_capacity(width * (1 + spacing));
let mut any_change = false;
for i in 0..width {
if i > 0 {
transition.push_str(&pad);
}
let was_active = i < pre_width
&& (i == lane || lanes_at_commit_active(&lanes, i, lane, commit, idx));
let now_active = i < lanes.len() && lanes[i].is_some();
if i == lane && parent_count >= 2 {
transition.push(lane_g);
} else if !now_active && was_active {
transition.push(close_g);
any_change = true;
} else if now_active {
transition.push(lane_g);
} else {
transition.push(' ');
}
}
if parent_count >= 2 {
let new_parent_ids: Vec<&String> = commit.parents[1..].iter().collect();
for npid in new_parent_ids {
if let Some(i) = lanes.iter().position(|l| l.as_deref() == Some(npid.as_str())) {
if i > lane {
let chars: Vec<char> = transition.chars().collect();
let cell = i * (1 + spacing);
if cell < chars.len() {
let cur = chars[cell];
if cur == lane_g || cur == ' ' {
let mut new_chars = chars;
new_chars[cell] = open_g;
transition = new_chars.into_iter().collect();
any_change = true;
}
}
}
}
}
}
let trimmed = transition.trim_end().to_string();
let transition_final = if any_change && !trimmed.is_empty() {
trimmed
} else {
String::new()
};
while lanes.last().map(|l| l.is_none()).unwrap_or(false) {
lanes.pop();
}
rows.push(GraphRow {
commit_line: commit_line.trim_end().to_string(),
transition_line: transition_final,
lane,
parent_count,
});
}
rows
}
fn active_width(lanes: &[Option<String>]) -> usize {
lanes
.iter()
.rposition(|l| l.is_some())
.map(|i| i + 1)
.unwrap_or(0)
}
pub fn padding_row(commit_line: &str, style: GraphStyle) -> String {
let lane_g = style.lane_glyph();
commit_line
.chars()
.map(|c| if c == ' ' { ' ' } else { lane_g })
.collect()
}
fn lanes_at_commit_active(
_lanes_after: &[Option<String>],
_i: usize,
_commit_lane: usize,
_commit: &GraphCommit,
_idx: usize,
) -> bool {
true
}
pub fn lane_color(lane: usize) -> u8 {
const PALETTE: &[u8] = &[
39, 208, 207, 226, 46, 99, 202, 51, 220, 129, ];
PALETTE[lane % PALETTE.len()]
}
pub fn format_ref_badge(raw: &str) -> String {
if raw.starts_with("HEAD -> ") {
format!("★ {}", raw)
} else if raw == "HEAD" {
"★ HEAD (detached)".to_string()
} else if let Some(name) = raw.strip_prefix("tag: ") {
format!("◆ {}", name)
} else {
format!("▸ {}", raw)
}
}
#[allow(dead_code)]
pub fn ref_badge_color(raw: &str) -> u8 {
if raw.starts_with("HEAD") {
199 } else if raw.starts_with("tag: ") {
220 } else {
51 }
}
#[derive(Debug, Clone)]
#[allow(dead_code)] pub struct DecoratedCommit {
pub id: String,
pub short_id: String,
pub summary: String,
pub author: String,
pub timestamp: i64,
pub parents: Vec<String>,
pub refs: Vec<String>,
}
pub fn walk_repo(
repo: &git2::Repository,
limit: usize,
include_all: bool,
) -> Result<Vec<DecoratedCommit>, git2::Error> {
use std::collections::HashMap;
let mut labels: HashMap<git2::Oid, Vec<String>> = HashMap::new();
let head_oid = repo.head().ok().and_then(|h| h.target());
let head_name = repo
.head()
.ok()
.and_then(|h| h.shorthand().map(|s| s.to_string()));
for r in repo.references()?.flatten() {
let Some(oid) = r.target() else { continue };
let Some(name) = r.name() else { continue };
let label = if let Some(short) = name.strip_prefix("refs/heads/") {
if Some(oid) == head_oid && head_name.as_deref() == Some(short) {
format!("HEAD -> {}", short)
} else {
short.to_string()
}
} else if let Some(short) = name.strip_prefix("refs/tags/") {
format!("tag: {}", short)
} else if let Some(short) = name.strip_prefix("refs/remotes/") {
let _ = short;
continue;
} else {
continue;
};
labels.entry(oid).or_default().push(label);
}
if let (Some(oid), Some(_)) = (head_oid, head_name.as_ref()) {
let entry = labels.entry(oid).or_default();
if !entry.iter().any(|s| s.starts_with("HEAD")) {
entry.insert(0, "HEAD".to_string());
}
}
let mut walk = repo.revwalk()?;
walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)?;
if include_all {
for r in repo.references()?.flatten() {
let Some(name) = r.name() else { continue };
if name.starts_with("refs/heads/") || name.starts_with("refs/tags/") {
if let Some(oid) = r.target() {
let _ = walk.push(oid);
}
}
}
} else {
walk.push_head()?;
}
let mut out = Vec::with_capacity(limit);
for oid_res in walk.take(limit) {
let oid = oid_res?;
let commit = repo.find_commit(oid)?;
let id = oid.to_string();
let short_id = id.chars().take(7).collect();
let summary = commit.summary().unwrap_or("").to_string();
let author = commit
.author()
.name()
.unwrap_or("")
.to_string();
let timestamp = commit.time().seconds();
let parents: Vec<String> = commit.parent_ids().map(|p| p.to_string()).collect();
let refs = labels.remove(&oid).unwrap_or_default();
out.push(DecoratedCommit {
id,
short_id,
summary,
author,
timestamp,
parents,
refs,
});
}
Ok(out)
}
#[allow(dead_code)]
pub fn render_repo(
repo: &git2::Repository,
limit: usize,
include_all: bool,
) -> Result<Vec<(DecoratedCommit, GraphRow)>, git2::Error> {
render_repo_with(repo, limit, include_all, GraphStyle::Ascii)
}
pub fn render_repo_with(
repo: &git2::Repository,
limit: usize,
include_all: bool,
style: GraphStyle,
) -> Result<Vec<(DecoratedCommit, GraphRow)>, git2::Error> {
let commits = walk_repo(repo, limit, include_all)?;
let graph_input: Vec<GraphCommit> = commits
.iter()
.map(|c| GraphCommit {
id: c.id.clone(),
parents: c.parents.clone(),
})
.collect();
let rows = render_with(&graph_input, style);
Ok(commits.into_iter().zip(rows.into_iter()).collect())
}
#[cfg(test)]
mod tests {
use super::*;
fn c(id: &str, parents: &[&str]) -> GraphCommit {
GraphCommit {
id: id.to_string(),
parents: parents.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn linear_history() {
let commits = vec![c("c", &["b"]), c("b", &["a"]), c("a", &[])];
let rows = render(&commits);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].commit_line, "*");
assert_eq!(rows[1].commit_line, "*");
assert_eq!(rows[2].commit_line, "*");
assert_eq!(rows[0].lane, 0);
assert_eq!(rows[2].parent_count, 0);
}
#[test]
fn simple_merge() {
let commits = vec![
c("d", &["b", "c"]),
c("b", &["a"]),
c("c", &["a"]),
c("a", &[]),
];
let rows = render(&commits);
assert_eq!(rows.len(), 4);
assert_eq!(rows[0].parent_count, 2);
assert_eq!(rows[0].lane, 0);
assert!(rows[0].transition_line.contains('\\'));
assert_eq!(rows[3].parent_count, 0);
}
#[test]
fn fork_then_close() {
let commits = vec![c("c", &["a"]), c("b", &["a"]), c("a", &[])];
let rows = render(&commits);
assert_eq!(rows.len(), 3);
assert_eq!(rows[0].commit_line, "*");
assert!(rows[1].commit_line.contains('*'));
}
#[test]
fn lane_color_stable() {
assert_eq!(lane_color(0), lane_color(0));
assert_ne!(lane_color(0), lane_color(1));
}
#[test]
fn empty_input() {
assert_eq!(render(&[]), vec![]);
}
}