use git2::{Oid, Repository, Sort};
use std::collections::{BTreeSet, HashSet};
use std::path::Path;
use crate::config::BranchFilter;
mod refs;
mod segments;
#[cfg(test)]
mod tests;
use refs::{merge_stash_labels, resolve_refs};
pub(crate) use segments::{BranchSegment, compute_branch_segments};
const MAX_COMMITS: usize = 200;
const PALETTE_SIZE: usize = 6;
#[derive(Clone, Debug)]
pub(crate) struct BranchLabel {
pub name: String,
pub is_head: bool,
pub is_remote: bool,
pub is_worktree: bool,
pub is_tag: bool,
pub is_stash: bool,
}
#[derive(Clone, Debug)]
pub(crate) struct GraphOptions {
pub branch_filter: BranchFilter,
pub label_max_len: usize,
pub first_parent: bool,
pub show_stats: bool,
pub filters: GraphFilters,
}
impl Default for GraphOptions {
fn default() -> Self {
Self {
branch_filter: BranchFilter::All,
label_max_len: 24,
first_parent: false,
show_stats: true,
filters: GraphFilters::default(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub(crate) struct GraphFilters {
pub branches: Option<BTreeSet<String>>,
pub authors: Option<BTreeSet<String>>,
pub refs: GraphRefFilters,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct GraphRefFilters {
pub local: bool,
pub remote: bool,
pub tags: bool,
pub stashes: bool,
}
impl Default for GraphRefFilters {
fn default() -> Self {
Self {
local: true,
remote: true,
tags: true,
stashes: true,
}
}
}
impl GraphRefFilters {
fn includes(&self, label: &BranchLabel) -> bool {
if label.is_stash {
self.stashes
} else if label.is_tag {
self.tags
} else if label.is_remote {
self.remote
} else {
self.local
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct DiffStat {
pub additions: usize,
pub deletions: usize,
}
#[derive(Clone, Debug)]
#[allow(dead_code)]
pub(crate) struct GraphRow {
pub commit_col: usize,
pub lanes: Vec<LaneSegment>,
pub oid: Oid,
pub short_id: String,
pub message: String,
pub author: String,
pub time: i64,
pub labels: Vec<BranchLabel>,
pub is_merge: bool,
pub horizontal_spans: Vec<(usize, usize, usize)>,
pub parent_oids: Vec<Oid>,
pub diff_stat: Option<DiffStat>,
pub collapsed: Option<(String, usize)>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum LaneSegment {
Empty,
Straight,
Commit,
MergeLeft,
MergeRight,
ForkLeft,
ForkRight,
Horizontal,
CrossHorizontal,
RightTee,
LeftTee,
}
#[derive(Clone, Debug)]
pub(crate) struct GraphBuilder {
active_lanes: Vec<Option<Oid>>,
}
impl GraphBuilder {
pub fn new() -> Self {
Self {
active_lanes: Vec::new(),
}
}
pub fn build(
mut self,
path: &Path,
options: &GraphOptions,
) -> color_eyre::Result<Vec<GraphRow>> {
let mut repo = Repository::open(path)?;
let mut ref_map = resolve_refs(&repo, &options.branch_filter);
merge_stash_labels(&mut repo, &mut ref_map);
let selected_branch_oids = options.filters.branches.as_ref().map(|branches| {
ref_map
.iter()
.filter(|(_, labels)| {
labels.iter().any(|label| {
!label.is_tag
&& !label.is_stash
&& options.filters.refs.includes(label)
&& branches.contains(&label.name)
})
})
.map(|(oid, _)| *oid)
.collect::<HashSet<_>>()
});
if selected_branch_oids.as_ref().is_some_and(HashSet::is_empty) {
return Ok(Vec::new());
}
for labels in ref_map.values_mut() {
labels.retain(|label| options.filters.refs.includes(label));
}
ref_map.retain(|_, labels| !labels.is_empty());
if let Some(branches) = &options.filters.branches {
for labels in ref_map.values_mut() {
labels.retain(|label| {
label.is_tag || label.is_stash || branches.contains(&label.name)
});
}
ref_map.retain(|_, labels| !labels.is_empty());
}
let mut revwalk = repo.revwalk()?;
if options.filters.branches.is_none() && options.filters.refs.local {
revwalk.push_head().ok(); }
if let Some(oids) = &selected_branch_oids {
for &oid in oids {
revwalk.push(oid).ok(); }
} else {
for &oid in ref_map.keys() {
revwalk.push(oid).ok(); }
}
revwalk.set_sorting(Sort::TOPOLOGICAL | Sort::TIME)?;
if options.first_parent {
revwalk.simplify_first_parent()?;
}
let mut rows = Vec::new();
for oid_result in revwalk.take(MAX_COMMITS) {
let oid = oid_result?;
let commit = repo.find_commit(oid)?;
let parent_oids: Vec<Oid> = commit.parent_ids().collect();
let is_merge = commit.parent_count() > 1;
let labels = ref_map.remove(&oid).unwrap_or_default();
let (commit_col, lanes, horizontal_spans) = self.process_commit(oid, &parent_oids);
let short_id = oid.to_string()[..7].to_string();
let message = commit.summary().ok().flatten().unwrap_or("").to_string();
let author = commit.author().name().unwrap_or("").to_string();
let time = commit.time().seconds();
if options
.filters
.authors
.as_ref()
.is_some_and(|authors| !authors.contains(&author))
{
continue;
}
rows.push(GraphRow {
commit_col,
lanes,
oid,
short_id,
message,
author,
time,
labels,
is_merge,
horizontal_spans,
parent_oids,
diff_stat: None,
collapsed: None,
});
}
Ok(rows)
}
pub fn branch_names(
path: &Path,
branch_filter: &BranchFilter,
) -> color_eyre::Result<Vec<String>> {
let repo = Repository::open(path)?;
let refs = resolve_refs(&repo, branch_filter);
let names = refs
.values()
.flat_map(|labels| labels.iter())
.filter(|label| !label.is_tag && !label.is_stash)
.map(|label| label.name.clone())
.collect::<BTreeSet<_>>();
Ok(names.into_iter().collect())
}
fn process_commit(
&mut self,
oid: Oid,
parent_oids: &[Oid],
) -> (usize, Vec<LaneSegment>, Vec<(usize, usize, usize)>) {
let commit_col = self
.active_lanes
.iter()
.position(|lane| *lane == Some(oid))
.unwrap_or_else(|| {
let col = self.find_free_lane();
if col < self.active_lanes.len() {
self.active_lanes[col] = Some(oid);
} else {
self.active_lanes.push(Some(oid));
}
col
});
let lane_count = self.active_lanes.len().max(commit_col + 1);
let mut lanes = vec![LaneSegment::Empty; lane_count];
for (i, lane) in self.active_lanes.iter().enumerate() {
if i < lanes.len() && lane.is_some() && i != commit_col {
lanes[i] = LaneSegment::Straight;
}
}
lanes[commit_col] = LaneSegment::Commit;
self.active_lanes[commit_col] = None;
let mut spans: Vec<(usize, usize, usize)> = Vec::new();
if !parent_oids.is_empty() {
let first_parent = parent_oids[0];
let existing_lane = self
.active_lanes
.iter()
.position(|lane| *lane == Some(first_parent));
if let Some(existing) = existing_lane {
if existing < commit_col {
lanes[commit_col] = LaneSegment::MergeLeft;
spans.push((existing, commit_col, lane_color(commit_col)));
} else if existing > commit_col {
lanes[commit_col] = LaneSegment::MergeRight;
spans.push((commit_col, existing, lane_color(commit_col)));
}
} else {
self.active_lanes[commit_col] = Some(first_parent);
}
for &parent_oid in &parent_oids[1..] {
let existing = self
.active_lanes
.iter()
.position(|lane| *lane == Some(parent_oid));
if existing.is_none() {
let new_col = self.find_free_lane();
if new_col < self.active_lanes.len() {
self.active_lanes[new_col] = Some(parent_oid);
} else {
self.active_lanes.push(Some(parent_oid));
}
while lanes.len() <= new_col {
lanes.push(LaneSegment::Empty);
}
if new_col > commit_col {
lanes[new_col] = LaneSegment::ForkRight;
spans.push((commit_col, new_col, lane_color(new_col)));
} else {
lanes[new_col] = LaneSegment::ForkLeft;
spans.push((new_col, commit_col, lane_color(new_col)));
}
}
}
}
for &(left, right, _) in &spans {
if lanes[left] == LaneSegment::Straight {
lanes[left] = LaneSegment::RightTee;
}
if right < lanes.len() && lanes[right] == LaneSegment::Straight {
lanes[right] = LaneSegment::LeftTee;
}
for col in (left + 1)..right {
if col < lanes.len() {
if lanes[col] == LaneSegment::Straight {
lanes[col] = LaneSegment::CrossHorizontal;
} else if lanes[col] == LaneSegment::Empty {
lanes[col] = LaneSegment::Horizontal;
}
}
}
}
while self.active_lanes.last() == Some(&None) {
self.active_lanes.pop();
}
(commit_col, lanes, spans)
}
fn find_free_lane(&self) -> usize {
self.active_lanes
.iter()
.position(|lane| lane.is_none())
.unwrap_or(self.active_lanes.len())
}
}
pub(crate) fn lane_color(col: usize) -> usize {
col % PALETTE_SIZE
}