use crate::core::repo::{
CommitInfo, ContextCommit, FileChange, RemoteStatus, RepoInfo, UpstreamInfo,
};
use crate::core::shortid::{self, IdAllocator};
use colored::{Color, Colorize};
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use std::fmt::Write;
#[derive(Clone)]
pub struct Theme {
pub graph: Color,
pub branch: Color,
pub label: Color,
pub dim: Color,
pub message: Color,
pub shortid: Color,
pub staged: Color,
pub unstaged: Color,
pub untracked: Color,
pub remote_synced: Color,
pub remote_ahead: Color,
pub remote_gone: Color,
pub conflict: Color,
pub selection_bg: Color,
pub dim_selected: Color,
pub branch_dots: &'static [Color],
}
impl Theme {
pub fn dark() -> Self {
Theme {
graph: Color::BrightBlack,
branch: Color::Green,
label: Color::Cyan,
dim: Color::AnsiColor(240),
message: Color::AnsiColor(248),
shortid: Color::Blue,
staged: Color::Green,
unstaged: Color::Red,
untracked: Color::Magenta,
remote_synced: Color::Green,
remote_ahead: Color::Yellow,
remote_gone: Color::Red,
conflict: Color::Red,
selection_bg: Color::AnsiColor(239),
dim_selected: Color::AnsiColor(245),
branch_dots: BRANCH_DOTS,
}
}
pub fn light() -> Self {
Theme {
graph: Color::AnsiColor(248),
branch: Color::Green,
label: Color::Blue,
dim: Color::AnsiColor(248),
message: Color::AnsiColor(243),
shortid: Color::Blue,
staged: Color::Green,
unstaged: Color::Red,
untracked: Color::Magenta,
remote_synced: Color::Green,
remote_ahead: Color::Yellow,
remote_gone: Color::Red,
conflict: Color::Red,
selection_bg: Color::AnsiColor(251),
dim_selected: Color::AnsiColor(242),
branch_dots: BRANCH_DOTS,
}
}
}
const BRANCH_DOTS: &[Color] = &[
Color::Yellow,
Color::Cyan,
Color::Magenta,
Color::Blue,
Color::Red,
Color::Green,
];
const UNTRACKED_MULTICOLUMN_THRESHOLD: usize = 5;
const GRAPH_PREFIX_WIDTH: usize = 4;
pub struct RenderOpts {
pub terminal_width: Option<u16>,
pub theme: Theme,
pub cwd_prefix: String,
}
pub(crate) enum Section {
WorkingChanges(Vec<FileChange>),
Branch {
names: Vec<(String, Option<RemoteStatus>)>,
commits: Vec<CommitInfo>,
},
Loose(Vec<CommitInfo>),
Upstream(UpstreamInfo),
Context(Vec<ContextCommit>),
}
pub fn default_render_opts(theme: Theme, cwd_prefix: String) -> RenderOpts {
RenderOpts {
terminal_width: crate::core::msg::human_stream_width(),
theme,
cwd_prefix,
}
}
fn display_path(repo_path: &str, cwd_prefix: &str) -> String {
crate::core::repo::cwd_relative_path(repo_path, cwd_prefix)
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(crate) enum FileGroup {
Conflicted,
Tracked,
Untracked,
}
pub(crate) fn file_group(index: char, worktree: char) -> FileGroup {
if index == '!' || worktree == '!' {
FileGroup::Conflicted
} else if index == '?' && worktree == '?' {
FileGroup::Untracked
} else {
FileGroup::Tracked
}
}
fn canonical_branch_name(info: &RepoInfo, tip: git2::Oid) -> &str {
info.branches
.iter()
.find(|b| b.tip_oid == tip)
.map_or("", |b| b.name.as_str())
}
fn assign_commits_to_branches(info: &RepoInfo) -> HashMap<git2::Oid, String> {
let branch_tip_set: HashSet<git2::Oid> = info.branches.iter().map(|b| b.tip_oid).collect();
let parent_map: HashMap<git2::Oid, Option<git2::Oid>> =
info.commits.iter().map(|c| (c.oid, c.parent_oid)).collect();
let mut commit_to_branch: HashMap<git2::Oid, String> = HashMap::new();
let mut seen_tips: HashSet<git2::Oid> = HashSet::new();
for b in &info.branches {
if !seen_tips.insert(b.tip_oid) {
continue; }
let canonical_name = canonical_branch_name(info, b.tip_oid).to_string();
let mut current = Some(b.tip_oid);
let mut is_tip = true;
while let Some(oid) = current {
if !parent_map.contains_key(&oid) {
break; }
if !is_tip && branch_tip_set.contains(&oid) {
break;
}
is_tip = false;
commit_to_branch.insert(oid, canonical_name.clone());
current = parent_map.get(&oid).and_then(|p| *p);
}
}
commit_to_branch
}
pub fn loose_commits(info: &RepoInfo) -> Vec<git2::Oid> {
let commit_to_branch = assign_commits_to_branches(info);
info.commits
.iter()
.filter(|c| !commit_to_branch.contains_key(&c.oid))
.map(|c| c.oid)
.collect()
}
pub fn commits_in_branch(info: &RepoInfo, branch: &str) -> Vec<git2::Oid> {
let Some(tip) = info
.branches
.iter()
.find(|b| b.name == branch)
.map(|b| b.tip_oid)
else {
return Vec::new();
};
let owner = canonical_branch_name(info, tip);
let commit_to_branch = assign_commits_to_branches(info);
info.commits
.iter()
.filter(|c| commit_to_branch.get(&c.oid).is_some_and(|n| n == owner))
.map(|c| c.oid)
.collect()
}
fn canonical_name<'a>(info: &'a RepoInfo, branch: &'a str) -> &'a str {
info.branches
.iter()
.find(|b| b.name == branch)
.map(|b| canonical_branch_name(info, b.tip_oid))
.unwrap_or(branch)
}
pub fn stack_parent(info: &RepoInfo, branch: &str) -> Option<String> {
let tip = info.branches.iter().find(|b| b.name == branch)?.tip_oid;
StackIndex::new(info).parent(tip).map(str::to_string)
}
pub(crate) fn stack_parents(info: &RepoInfo) -> HashMap<String, String> {
let index = StackIndex::new(info);
info.branches
.iter()
.filter_map(|b| Some((b.name.clone(), index.parent(b.tip_oid)?.to_string())))
.collect()
}
struct StackIndex<'a> {
canonical: HashMap<git2::Oid, &'a str>,
oldest: HashMap<String, &'a CommitInfo>,
}
impl<'a> StackIndex<'a> {
fn new(info: &'a RepoInfo) -> Self {
let mut canonical: HashMap<git2::Oid, &str> = HashMap::new();
for b in &info.branches {
canonical.entry(b.tip_oid).or_insert(b.name.as_str());
}
let mut commit_to_branch = assign_commits_to_branches(info);
let mut oldest = HashMap::new();
for c in &info.commits {
if let Some(owner) = commit_to_branch.remove(&c.oid) {
oldest.insert(owner, c);
}
}
Self { canonical, oldest }
}
fn parent(&self, tip: git2::Oid) -> Option<&'a str> {
let owner = *self.canonical.get(&tip)?;
let parent = self.oldest.get(owner)?.parent_oid?;
self.canonical.get(&parent).copied()
}
}
pub fn downstack(info: &RepoInfo, branch: &str) -> Vec<String> {
let mut chain = vec![branch.to_string()];
while let Some(parent) = stack_parent(info, chain.last().unwrap()) {
if chain.contains(&parent) {
break;
}
chain.push(parent);
}
chain.reverse();
chain
}
fn stack_child(info: &RepoInfo, branch: &str) -> Option<String> {
let canonical = canonical_name(info, branch);
info.branches
.iter()
.find(|b| stack_parent(info, &b.name).as_deref() == Some(canonical))
.map(|b| canonical_branch_name(info, b.tip_oid).to_string())
}
pub fn upstack(info: &RepoInfo, branch: &str) -> Vec<String> {
let mut above: Vec<String> = Vec::new();
let mut current = branch.to_string();
while let Some(child) = stack_child(info, ¤t) {
if above.contains(&child) {
break;
}
above.push(child.clone());
current = child;
}
above
}
pub(crate) fn build_sections(info: RepoInfo) -> Vec<Section> {
let commit_to_branch = assign_commits_to_branches(&info);
let mut tip_to_names: HashMap<git2::Oid, Vec<(String, Option<RemoteStatus>)>> = HashMap::new();
for b in &info.branches {
tip_to_names
.entry(b.tip_oid)
.or_default()
.push((b.name.clone(), b.remote.clone()));
}
let mut canonical_to_names: HashMap<String, Vec<(String, Option<RemoteStatus>)>> =
HashMap::new();
for branches in tip_to_names.values() {
let mut reversed = branches.clone();
reversed.reverse();
canonical_to_names.insert(branches[0].0.clone(), reversed);
}
let mut sections: Vec<Section> = Vec::new();
sections.push(Section::WorkingChanges(info.working_changes));
let mut loose_commits: Vec<CommitInfo> = Vec::new();
let mut branch_sections: Vec<Section> = Vec::new();
let mut commits = info.commits.into_iter().peekable();
while let Some(commit) = commits.next() {
if let Some(branch_name) = commit_to_branch.get(&commit.oid) {
let name = branch_name.clone();
let names = canonical_to_names
.get(&name)
.cloned()
.unwrap_or_else(|| vec![(name.clone(), None)]);
let mut branch_commits = vec![commit];
while let Some(next) = commits.peek() {
if commit_to_branch.get(&next.oid) == Some(&name) {
branch_commits.push(commits.next().unwrap());
} else {
break;
}
}
branch_sections.push(Section::Branch {
names,
commits: branch_commits,
});
} else {
loose_commits.push(commit);
while let Some(next) = commits.peek() {
if commit_to_branch.contains_key(&next.oid) {
break;
}
loose_commits.push(commits.next().unwrap());
}
}
}
let represented: HashSet<&String> = commit_to_branch.values().collect();
let mut empty_sections: Vec<Section> = Vec::new();
for branches in canonical_to_names.values() {
if !branches.iter().any(|(n, _)| represented.contains(n)) {
empty_sections.push(Section::Branch {
names: branches.clone(),
commits: vec![],
});
}
}
empty_sections.sort_by(|a, b| match (a, b) {
(Section::Branch { names: a, .. }, Section::Branch { names: b, .. }) => b[0].0.cmp(&a[0].0),
_ => Ordering::Equal,
});
if !loose_commits.is_empty() {
sections.push(Section::Loose(loose_commits));
}
sections.extend(empty_sections);
sections.extend(branch_sections);
sections.push(Section::Upstream(info.upstream));
if !info.context_commits.is_empty() {
sections.push(Section::Context(info.context_commits));
}
sections
}
pub(crate) fn is_stacked_with_next(sections: &[Section], idx: usize) -> bool {
let Section::Branch { commits, .. } = §ions[idx] else {
return false;
};
let Some(Section::Branch {
commits: next_commits,
..
}) = sections.get(idx + 1)
else {
return false;
};
let Some(last) = commits.last() else {
return false;
};
let Some(next_first) = next_commits.first() else {
return false;
};
last.parent_oid == Some(next_first.oid)
}
const COMMIT_ID_SLOT: usize = 4;
pub(crate) fn id_pad(sid: &str) -> String {
" ".repeat(COMMIT_ID_SLOT.saturating_sub(sid.chars().count()) + 1)
}
pub(crate) fn render_sections(
sections: &[Section],
ids: &IdAllocator,
opts: &RenderOpts,
) -> String {
let mut out = String::new();
let last_idx = sections.len() - 1;
let mut branch_color_idx: usize = 0;
for (idx, section) in sections.iter().enumerate() {
match section {
Section::WorkingChanges(changes) => {
render_working_changes(&mut out, changes, ids, opts);
}
Section::Branch { names, commits } => {
let dot_color =
opts.theme.branch_dots[branch_color_idx % opts.theme.branch_dots.len()];
branch_color_idx += 1;
let prev_stacked = idx > 0 && is_stacked_with_next(sections, idx - 1);
let next_stacked = is_stacked_with_next(sections, idx);
render_branch(
&mut out,
names,
commits,
dot_color,
prev_stacked,
next_stacked,
idx < last_idx,
ids,
&opts.theme,
&opts.cwd_prefix,
);
}
Section::Loose(commits) => {
render_loose(
&mut out,
commits,
idx < last_idx,
ids,
&opts.theme,
&opts.cwd_prefix,
);
}
Section::Upstream(info) => {
render_upstream(&mut out, info, &opts.theme);
}
Section::Context(commits) => {
render_context(&mut out, commits, &opts.theme);
}
}
}
out
}
fn render_working_changes(
out: &mut String,
changes: &[FileChange],
ids: &IdAllocator,
opts: &RenderOpts,
) {
let theme = &opts.theme;
writeln!(
out,
"{} {} {}{}{}",
"╭─".color(theme.graph),
ids.get_unstaged().color(theme.shortid).underline(),
"[".color(theme.dim),
"local changes".color(theme.label),
"]".color(theme.dim)
)
.unwrap();
let group_of = |g: FileGroup| -> Vec<&FileChange> {
changes
.iter()
.filter(|f| file_group(f.index, f.worktree) == g)
.collect()
};
let conflicted = group_of(FileGroup::Conflicted);
let tracked = group_of(FileGroup::Tracked);
let untracked = group_of(FileGroup::Untracked);
if conflicted.is_empty() && tracked.is_empty() && untracked.is_empty() {
writeln!(
out,
"{} {}",
"│".color(theme.graph),
"no changes".color(theme.dim)
)
.unwrap();
} else {
for change in &conflicted {
writeln!(
out,
"{} {} {} {}",
"│".color(theme.graph),
ids.get_file(&change.path).color(theme.shortid).underline(),
"!!".color(theme.conflict).bold(),
display_path(&change.path, &opts.cwd_prefix)
.color(theme.conflict)
.bold()
)
.unwrap();
}
for change in &tracked {
writeln!(
out,
"{} {} {}{} {}",
"│".color(theme.graph),
ids.get_file(&change.path).color(theme.shortid).underline(),
change.index.to_string().color(theme.staged),
change.worktree.to_string().color(theme.unstaged),
display_path(&change.path, &opts.cwd_prefix)
)
.unwrap();
}
if !untracked.is_empty() {
render_untracked(out, &untracked, ids, opts);
}
}
writeln!(out, "{}", "│".color(theme.graph)).unwrap();
}
fn render_untracked(
out: &mut String,
untracked: &[&FileChange],
ids: &IdAllocator,
opts: &RenderOpts,
) {
if untracked.len() > UNTRACKED_MULTICOLUMN_THRESHOLD
&& let Some(width) = opts.terminal_width
{
render_untracked_multicolumn(out, untracked, ids, width, &opts.theme, &opts.cwd_prefix);
return;
}
render_untracked_single_column(out, untracked, ids, &opts.theme, &opts.cwd_prefix);
}
fn render_untracked_single_column(
out: &mut String,
untracked: &[&FileChange],
ids: &IdAllocator,
theme: &Theme,
cwd_prefix: &str,
) {
for change in untracked {
writeln!(
out,
"{} {} {} {}",
"│".color(theme.graph),
ids.get_file(&change.path).color(theme.shortid).underline(),
" ⁕".color(theme.untracked),
display_path(&change.path, cwd_prefix)
)
.unwrap();
}
}
fn render_untracked_multicolumn(
out: &mut String,
untracked: &[&FileChange],
ids: &IdAllocator,
term_width: u16,
theme: &Theme,
cwd_prefix: &str,
) {
let available = (term_width as usize).saturating_sub(GRAPH_PREFIX_WIDTH);
let separator = " │ "; let separator_width: usize = 5;
let entry_widths: Vec<usize> = untracked
.iter()
.map(|f| {
let sid = ids.get_file(&f.path);
let disp = display_path(&f.path, cwd_prefix);
sid.len() + 1 + 2 + 1 + disp.len()
})
.collect();
let max_entry_width = entry_widths.iter().copied().max().unwrap_or(1);
let col_slot = max_entry_width + separator_width;
let num_cols = (available / col_slot).max(1).min(untracked.len());
let num_rows = untracked.len().div_ceil(num_cols);
for row in 0..num_rows {
write!(out, "{} ", "│".color(theme.graph)).unwrap();
for col in 0..num_cols {
let idx = col * num_rows + row;
if idx >= untracked.len() {
break;
}
let f = untracked[idx];
let sid = ids.get_file(&f.path);
let disp = display_path(&f.path, cwd_prefix);
write!(
out,
"{} {} {}",
sid.color(theme.shortid).underline(),
" ⁕".color(theme.untracked),
disp
)
.unwrap();
let next_idx = (col + 1) * num_rows + row;
if col + 1 < num_cols && next_idx < untracked.len() {
let padding = max_entry_width.saturating_sub(entry_widths[idx]);
write!(out, "{}{}", " ".repeat(padding), separator.color(theme.dim)).unwrap();
}
}
writeln!(out).unwrap();
}
}
#[allow(clippy::too_many_arguments)]
fn render_branch(
out: &mut String,
names: &[(String, Option<RemoteStatus>)],
commits: &[CommitInfo],
dot_color: Color,
prev_stacked: bool,
next_stacked: bool,
more_sections: bool,
ids: &IdAllocator,
theme: &Theme,
cwd_prefix: &str,
) {
for (i, (name, remote)) in names.iter().enumerate() {
let branch_id = ids.get_branch(name);
let connector = if i == 0 && !prev_stacked {
"│╭─"
} else {
"│├─"
};
let remote_indicator = match remote {
Some(RemoteStatus::Synced) => format!(" {}", "✓".color(theme.remote_synced)),
Some(RemoteStatus::Different) => format!(" {}", "↑".color(theme.remote_ahead)),
Some(RemoteStatus::Gone) => format!(" {}", "✗".color(theme.remote_gone)),
None => String::new(),
};
writeln!(
out,
"{} {} {}{}{}{}",
connector.color(theme.graph),
branch_id.color(theme.shortid).underline(),
"[".color(theme.dim),
name.color(theme.branch).bold(),
"]".color(theme.dim),
remote_indicator,
)
.unwrap();
}
for commit in commits {
let sid = ids.get_commit(commit.oid);
writeln!(
out,
"{}{} {}{}{} {}",
"│".color(theme.graph),
"●".color(dot_color),
sid.color(theme.shortid).underline(),
id_pad(sid),
commit.short_id.color(theme.dim),
commit.message,
)
.unwrap();
for (i, file) in commit.files.iter().enumerate() {
let file_sid = shortid::commit_file_id(sid, i);
writeln!(
out,
"{}{} {} {}{} {}",
"│".color(theme.graph),
"┊".color(dot_color),
file_sid.color(theme.shortid).underline(),
file.index.to_string().color(theme.staged),
file.worktree.to_string().color(theme.unstaged),
display_path(&file.path, cwd_prefix)
)
.unwrap();
}
}
if next_stacked {
writeln!(out, "{}", "││".color(theme.graph)).unwrap();
} else {
writeln!(out, "{}", "├╯".color(theme.graph)).unwrap();
if more_sections {
writeln!(out, "{}", "│".color(theme.graph)).unwrap();
}
}
}
fn render_loose(
out: &mut String,
commits: &[CommitInfo],
more_sections: bool,
ids: &IdAllocator,
theme: &Theme,
cwd_prefix: &str,
) {
for commit in commits {
let sid = ids.get_commit(commit.oid);
writeln!(
out,
"{} {}{}{} {}",
"●".color(theme.graph),
sid.color(theme.shortid).underline(),
id_pad(sid),
commit.short_id.color(theme.dim),
commit.message,
)
.unwrap();
for (i, file) in commit.files.iter().enumerate() {
let file_sid = shortid::commit_file_id(sid, i);
writeln!(
out,
"{} {} {}{} {}",
"┊".color(theme.graph),
file_sid.color(theme.shortid).underline(),
file.index.to_string().color(theme.staged),
file.worktree.to_string().color(theme.unstaged),
display_path(&file.path, cwd_prefix)
)
.unwrap();
}
}
if more_sections {
writeln!(out, "{}", "│".color(theme.graph)).unwrap();
}
}
fn render_upstream(out: &mut String, info: &UpstreamInfo, theme: &Theme) {
if info.commits_ahead > 0 {
let count_text = format!(
"\u{23EB} {} new commit{}",
info.commits_ahead,
if info.commits_ahead == 1 { "" } else { "s" }
)
.color(theme.message);
writeln!(
out,
"{}{} {}{}{} {}",
"│".color(theme.graph),
"●".color(theme.graph),
"[".color(theme.dim),
info.label.color(theme.branch).bold(),
"]".color(theme.dim),
count_text
)
.unwrap();
writeln!(
out,
"{} {} {} {} {}",
"├╯".color(theme.graph),
info.base_short_id.color(theme.dim),
"(common base)".color(theme.label),
info.base_date.color(theme.dim),
info.base_message.color(theme.message)
)
.unwrap();
} else {
writeln!(
out,
"{} {} {} {}{}{} {}",
"●".color(theme.graph),
info.base_short_id.color(theme.dim),
"(upstream)".color(theme.label),
"[".color(theme.dim),
info.label.color(theme.branch).bold(),
"]".color(theme.dim),
info.base_message.color(theme.message)
)
.unwrap();
}
}
fn render_context(out: &mut String, commits: &[ContextCommit], theme: &Theme) {
for commit in commits {
writeln!(
out,
"{} {} {} {}",
"·".color(theme.dim),
commit.short_hash.color(theme.dim),
commit.date.color(theme.dim),
commit.message.color(theme.message),
)
.unwrap();
}
}
#[cfg(test)]
#[path = "graph_test.rs"]
mod tests;