219 lines
6.5 KiB
Rust
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)
|
|
}
|
|
}
|