gitdataai/libs/git/commit/graph.rs
2026-04-15 09:08:09 +08:00

219 lines
6.5 KiB
Rust

//! 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<CommitGraphLine>,
pub max_parents: usize,
}
#[derive(Default)]
struct GraphState {
column_to_commit: Vec<Option<CommitOid>>,
column_prev_commit: Vec<Option<CommitOid>>,
active_columns: usize,
}
pub struct CommitGraphOptions {
pub rev: Option<String>,
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<CommitGraph> {
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<CommitGraph> {
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<CommitGraphLine> {
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<String> {
let refs = self.commit_refs(oid)?;
let parts: Vec<String> = 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<bool> = 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)
}
}