//! Visual ASCII commit graph output. use serde::{Deserialize, Serialize}; use crate::commit::types::*; use crate::{GitDomain, GitResult}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommitGraphLine { pub oid: CommitOid, pub graph_chars: String, pub refs: String, pub short_message: String, /// Column index (0-based) where the commit dot is rendered. /// Used by @gitgraph/react to assign lane color. pub lane_index: usize, /// Full commit metadata (author, timestamp, parents, etc.) pub meta: CommitMeta, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CommitGraph { pub lines: Vec, pub max_parents: usize, } #[derive(Default)] struct GraphState { column_to_commit: Vec>, column_prev_commit: Vec>, active_columns: usize, } pub struct CommitGraphOptions { pub rev: Option, pub limit: usize, pub first_parent_only: bool, pub show_refs: bool, } impl Default for CommitGraphOptions { fn default() -> Self { Self { rev: None, limit: 0, first_parent_only: false, show_refs: true, } } } impl CommitGraphOptions { pub fn rev(mut self, rev: &str) -> Self { self.rev = Some(rev.to_string()); self } pub fn limit(mut self, n: usize) -> Self { self.limit = n; self } pub fn first_parent_only(mut self) -> Self { self.first_parent_only = true; self } pub fn no_refs(mut self) -> Self { self.show_refs = false; self } } impl GitDomain { pub fn commit_graph(&self, opts: CommitGraphOptions) -> GitResult { let commits = self.commit_walk(crate::commit::traverse::CommitWalkOptions { rev: opts.rev.map(String::from), sort: CommitSort(git2::Sort::TOPOLOGICAL.bits() | git2::Sort::TIME.bits()), limit: opts.limit, first_parent_only: opts.first_parent_only, })?; let mut state = GraphState::default(); let mut lines = Vec::with_capacity(commits.len()); let mut max_parents = 0; for commit in commits { max_parents = max_parents.max(commit.parent_ids.len()); let line = self.build_graph_line(&commit, &mut state, opts.show_refs)?; lines.push(line); } Ok(CommitGraph { lines, max_parents }) } pub fn commit_graph_simple(&self, rev: Option<&str>, limit: usize) -> GitResult { let opts = CommitGraphOptions::default().limit(limit); let opts = match rev { Some(r) => opts.rev(r), None => opts, }; self.commit_graph(opts) } fn build_graph_line( &self, commit: &CommitMeta, state: &mut GraphState, show_refs: bool, ) -> GitResult { let oid = commit.oid.clone(); let short_message = commit.summary.clone(); let refs = if show_refs { self.get_commit_refs_string(&oid)? } else { String::new() }; let (graph_chars, lane_index) = self.render_graph_chars(commit, state); Ok(CommitGraphLine { oid, graph_chars, refs, short_message, lane_index, meta: commit.clone(), }) } fn get_commit_refs_string(&self, oid: &CommitOid) -> GitResult { let refs = self.commit_refs(oid)?; let parts: Vec = refs .iter() .map(|r| { if r.is_tag { r.name.trim_start_matches("refs/tags/").to_string() } else { r.name.trim_start_matches("refs/heads/").to_string() } }) .collect(); Ok(parts.join(", ")) } fn render_graph_chars(&self, commit: &CommitMeta, state: &mut GraphState) -> (String, usize) { let current_oid = &commit.oid; let num_parents = commit.parent_ids.len(); let mut result = String::new(); let active: Vec = state .column_to_commit .iter() .map(|col_oid| col_oid.as_ref().map_or(false, |col| col == current_oid)) .collect(); let current_col = if let Some(pos) = active.iter().position(|a| *a) { pos } else { if state.active_columns == 0 { state.active_columns = 1; } let col = state.column_to_commit.len(); state.column_to_commit.push(Some(current_oid.clone())); state.column_prev_commit.push(None); state.active_columns += 1; col }; for i in 0..state.column_to_commit.len() { if i == current_col { result.push_str("* "); } else if state.column_to_commit[i].is_some() { result.push_str("| "); } else { result.push_str(" "); } } if num_parents > 1 { let available = state .column_to_commit .len() .saturating_sub(state.active_columns); if available > 0 { result.push_str(&format!("/{}", " ".repeat(available * 2 - 1))); } } for col in 0..state.column_to_commit.len() { if state.column_to_commit[col].as_ref() == Some(current_oid) { state.column_to_commit[col] = None; state.active_columns = state.active_columns.saturating_sub(1); } } for (i, parent) in commit.parent_ids.iter().enumerate() { if i == 0 && state.active_columns == 0 { state.column_to_commit[0] = Some(parent.clone()); state.column_prev_commit[0] = Some(current_oid.clone()); state.active_columns = 1; } else { if let Some(idx) = state.column_to_commit.iter().position(|c| c.is_none()) { state.column_to_commit[idx] = Some(parent.clone()); state.column_prev_commit[idx] = Some(current_oid.clone()); state.active_columns += 1; } else { state.column_to_commit.push(Some(parent.clone())); state.column_prev_commit.push(Some(current_oid.clone())); state.active_columns += 1; } } } (result, current_col) } }