1
0
Fork 0

Adding upstream version 0.6.0.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-04 23:09:13 +02:00
parent 4de83856e9
commit 5b48f7aed6
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
21 changed files with 5187 additions and 0 deletions

153
src/config.rs Normal file
View file

@ -0,0 +1,153 @@
use crate::settings::{BranchSettingsDef, RepoSettings};
use git2::Repository;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
/// Creates the directory `APP_DATA/git-graph/models` if it does not exist,
/// and writes the files for built-in branching models there.
pub fn create_config<P: AsRef<Path> + AsRef<OsStr>>(app_model_path: &P) -> Result<(), String> {
let path: &Path = app_model_path.as_ref();
if !path.exists() {
std::fs::create_dir_all(app_model_path).map_err(|err| err.to_string())?;
let models = [
(BranchSettingsDef::git_flow(), "git-flow.toml"),
(BranchSettingsDef::simple(), "simple.toml"),
(BranchSettingsDef::none(), "none.toml"),
];
for (model, file) in &models {
let mut path = PathBuf::from(&app_model_path);
path.push(file);
let str = toml::to_string_pretty(&model).map_err(|err| err.to_string())?;
std::fs::write(&path, str).map_err(|err| err.to_string())?;
}
}
Ok(())
}
/// Get models available in `APP_DATA/git-graph/models`.
pub fn get_available_models<P: AsRef<Path>>(app_model_path: &P) -> Result<Vec<String>, String> {
let models = std::fs::read_dir(app_model_path)
.map_err(|err| err.to_string())?
.filter_map(|e| match e {
Ok(e) => {
if let (Some(name), Some(ext)) = (e.path().file_name(), e.path().extension()) {
if ext == "toml" {
name.to_str()
.map(|name| (name[..(name.len() - 5)]).to_string())
} else {
None
}
} else {
None
}
}
Err(_) => None,
})
.collect::<Vec<_>>();
Ok(models)
}
/// Get the currently set branching model for a repo.
pub fn get_model_name(repository: &Repository, file_name: &str) -> Result<Option<String>, String> {
let mut config_path = PathBuf::from(repository.path());
config_path.push(file_name);
if config_path.exists() {
let repo_config: RepoSettings =
toml::from_str(&std::fs::read_to_string(config_path).map_err(|err| err.to_string())?)
.map_err(|err| err.to_string())?;
Ok(Some(repo_config.model))
} else {
Ok(None)
}
}
/// Try to get the branch settings for a given model.
/// If no model name is given, returns the branch settings set for the repo, or the default otherwise.
pub fn get_model<P: AsRef<Path> + AsRef<OsStr>>(
repository: &Repository,
model: Option<&str>,
repo_config_file: &str,
app_model_path: &P,
) -> Result<BranchSettingsDef, String> {
match model {
Some(model) => read_model(model, app_model_path),
None => {
let mut config_path = PathBuf::from(repository.path());
config_path.push(repo_config_file);
if config_path.exists() {
let repo_config: RepoSettings = toml::from_str(
&std::fs::read_to_string(config_path).map_err(|err| err.to_string())?,
)
.map_err(|err| err.to_string())?;
read_model(&repo_config.model, app_model_path)
} else {
Ok(read_model("git-flow", app_model_path)
.unwrap_or_else(|_| BranchSettingsDef::git_flow()))
}
}
}
}
/// Read a branching model file.
fn read_model<P: AsRef<Path> + AsRef<OsStr>>(
model: &str,
app_model_path: &P,
) -> Result<BranchSettingsDef, String> {
let mut model_file = PathBuf::from(&app_model_path);
model_file.push(format!("{}.toml", model));
if model_file.exists() {
toml::from_str::<BranchSettingsDef>(
&std::fs::read_to_string(model_file).map_err(|err| err.to_string())?,
)
.map_err(|err| err.to_string())
} else {
let models = get_available_models(&app_model_path)?;
let path: &Path = app_model_path.as_ref();
Err(format!(
"ERROR: No branching model named '{}' found in {}\n Available models are: {}",
model,
path.display(),
itertools::join(models, ", ")
))
}
}
/// Permanently sets the branching model for a repository
pub fn set_model<P: AsRef<Path>>(
repository: &Repository,
model: &str,
repo_config_file: &str,
app_model_path: &P,
) -> Result<(), String> {
let models = get_available_models(&app_model_path)?;
if !models.contains(&model.to_string()) {
return Err(format!(
"ERROR: No branching model named '{}' found in {}\n Available models are: {}",
model,
app_model_path.as_ref().display(),
itertools::join(models, ", ")
));
}
let mut config_path = PathBuf::from(repository.path());
config_path.push(repo_config_file);
let config = RepoSettings {
model: model.to_string(),
};
let str = toml::to_string_pretty(&config).map_err(|err| err.to_string())?;
std::fs::write(&config_path, str).map_err(|err| err.to_string())?;
eprint!("Branching model set to '{}'", model);
Ok(())
}

984
src/graph.rs Normal file
View file

@ -0,0 +1,984 @@
//! A graph structure representing the history of a Git repository.
use crate::print::colors::to_terminal_color;
use crate::settings::{BranchOrder, BranchSettings, MergePatterns, Settings};
use git2::{BranchType, Commit, Error, Oid, Reference, Repository};
use itertools::Itertools;
use regex::Regex;
use std::collections::{HashMap, HashSet};
const ORIGIN: &str = "origin/";
const FORK: &str = "fork/";
/// Represents a git history graph.
pub struct GitGraph {
pub repository: Repository,
pub commits: Vec<CommitInfo>,
/// Mapping from commit id to index in `commits`
pub indices: HashMap<Oid, usize>,
/// All detected branches and tags, including merged and deleted
pub all_branches: Vec<BranchInfo>,
/// Indices of all real (still existing) branches in `all_branches`
pub branches: Vec<usize>,
/// Indices of all tags in `all_branches`
pub tags: Vec<usize>,
/// The current HEAD
pub head: HeadInfo,
}
impl GitGraph {
pub fn new(
mut repository: Repository,
settings: &Settings,
max_count: Option<usize>,
) -> Result<Self, String> {
let mut stashes = HashSet::new();
repository
.stash_foreach(|_, _, oid| {
stashes.insert(*oid);
true
})
.map_err(|err| err.message().to_string())?;
let mut walk = repository
.revwalk()
.map_err(|err| err.message().to_string())?;
walk.set_sorting(git2::Sort::TOPOLOGICAL | git2::Sort::TIME)
.map_err(|err| err.message().to_string())?;
walk.push_glob("*")
.map_err(|err| err.message().to_string())?;
if repository.is_shallow() {
return Err("ERROR: git-graph does not support shallow clones due to a missing feature in the underlying libgit2 library.".to_string());
}
let head = HeadInfo::new(&repository.head().map_err(|err| err.message().to_string())?)?;
let mut commits = Vec::new();
let mut indices = HashMap::new();
let mut idx = 0;
for oid in walk {
if let Some(max) = max_count {
if idx >= max {
break;
}
}
if let Ok(oid) = oid {
if !stashes.contains(&oid) {
let commit = repository.find_commit(oid).unwrap();
commits.push(CommitInfo::new(&commit));
indices.insert(oid, idx);
idx += 1;
}
}
}
assign_children(&mut commits, &indices);
let mut all_branches = assign_branches(&repository, &mut commits, &indices, settings)?;
correct_fork_merges(&commits, &indices, &mut all_branches, settings)?;
assign_sources_targets(&commits, &indices, &mut all_branches);
let (shortest_first, forward) = match settings.branch_order {
BranchOrder::ShortestFirst(fwd) => (true, fwd),
BranchOrder::LongestFirst(fwd) => (false, fwd),
};
assign_branch_columns(
&commits,
&indices,
&mut all_branches,
&settings.branches,
shortest_first,
forward,
);
let filtered_commits: Vec<CommitInfo> = commits
.into_iter()
.filter(|info| info.branch_trace.is_some())
.collect();
let filtered_indices: HashMap<Oid, usize> = filtered_commits
.iter()
.enumerate()
.map(|(idx, info)| (info.oid, idx))
.collect();
let index_map: HashMap<usize, Option<&usize>> = indices
.iter()
.map(|(oid, index)| (*index, filtered_indices.get(oid)))
.collect();
for branch in all_branches.iter_mut() {
if let Some(mut start_idx) = branch.range.0 {
let mut idx0 = index_map[&start_idx];
while idx0.is_none() {
start_idx += 1;
idx0 = index_map[&start_idx];
}
branch.range.0 = Some(*idx0.unwrap());
}
if let Some(mut end_idx) = branch.range.1 {
let mut idx0 = index_map[&end_idx];
while idx0.is_none() {
end_idx -= 1;
idx0 = index_map[&end_idx];
}
branch.range.1 = Some(*idx0.unwrap());
}
}
let branches = all_branches
.iter()
.enumerate()
.filter_map(|(idx, br)| {
if !br.is_merged && !br.is_tag {
Some(idx)
} else {
None
}
})
.collect();
let tags = all_branches
.iter()
.enumerate()
.filter_map(|(idx, br)| {
if !br.is_merged && br.is_tag {
Some(idx)
} else {
None
}
})
.collect();
Ok(GitGraph {
repository,
commits: filtered_commits,
indices: filtered_indices,
all_branches,
branches,
tags,
head,
})
}
pub fn take_repository(self) -> Repository {
self.repository
}
pub fn commit(&self, id: Oid) -> Result<Commit, Error> {
self.repository.find_commit(id)
}
}
/// Information about the current HEAD
pub struct HeadInfo {
pub oid: Oid,
pub name: String,
pub is_branch: bool,
}
impl HeadInfo {
fn new(head: &Reference) -> Result<Self, String> {
let name = head.name().ok_or_else(|| "No name for HEAD".to_string())?;
let name = if name == "HEAD" {
name.to_string()
} else {
name[11..].to_string()
};
let h = HeadInfo {
oid: head.target().ok_or_else(|| "No id for HEAD".to_string())?,
name,
is_branch: head.is_branch(),
};
Ok(h)
}
}
/// Represents a commit.
pub struct CommitInfo {
pub oid: Oid,
pub is_merge: bool,
pub parents: [Option<Oid>; 2],
pub children: Vec<Oid>,
pub branches: Vec<usize>,
pub tags: Vec<usize>,
pub branch_trace: Option<usize>,
}
impl CommitInfo {
fn new(commit: &Commit) -> Self {
CommitInfo {
oid: commit.id(),
is_merge: commit.parent_count() > 1,
parents: [commit.parent_id(0).ok(), commit.parent_id(1).ok()],
children: Vec::new(),
branches: Vec::new(),
tags: Vec::new(),
branch_trace: None,
}
}
}
/// Represents a branch (real or derived from merge summary).
pub struct BranchInfo {
pub target: Oid,
pub merge_target: Option<Oid>,
pub source_branch: Option<usize>,
pub target_branch: Option<usize>,
pub name: String,
pub persistence: u8,
pub is_remote: bool,
pub is_merged: bool,
pub is_tag: bool,
pub visual: BranchVis,
pub range: (Option<usize>, Option<usize>),
}
impl BranchInfo {
#[allow(clippy::too_many_arguments)]
fn new(
target: Oid,
merge_target: Option<Oid>,
name: String,
persistence: u8,
is_remote: bool,
is_merged: bool,
is_tag: bool,
visual: BranchVis,
end_index: Option<usize>,
) -> Self {
BranchInfo {
target,
merge_target,
target_branch: None,
source_branch: None,
name,
persistence,
is_remote,
is_merged,
is_tag,
visual,
range: (end_index, None),
}
}
}
/// Branch properties for visualization.
pub struct BranchVis {
/// The branch's column group (left to right)
pub order_group: usize,
/// The branch's merge target column group (left to right)
pub target_order_group: Option<usize>,
/// The branch's source branch column group (left to right)
pub source_order_group: Option<usize>,
/// The branch's terminal color (index in 256-color palette)
pub term_color: u8,
/// SVG color (name or RGB in hex annotation)
pub svg_color: String,
/// The column the branch is located in
pub column: Option<usize>,
}
impl BranchVis {
fn new(order_group: usize, term_color: u8, svg_color: String) -> Self {
BranchVis {
order_group,
target_order_group: None,
source_order_group: None,
term_color,
svg_color,
column: None,
}
}
}
/// Walks through the commits and adds each commit's Oid to the children of its parents.
fn assign_children(commits: &mut [CommitInfo], indices: &HashMap<Oid, usize>) {
for idx in 0..commits.len() {
let (oid, parents) = {
let info = &commits[idx];
(info.oid, info.parents)
};
for par_oid in &parents {
if let Some(par_idx) = par_oid.and_then(|oid| indices.get(&oid)) {
commits[*par_idx].children.push(oid);
}
}
}
}
/// Extracts branches from repository and merge summaries, assigns branches and branch traces to commits.
///
/// Algorithm:
/// * Find all actual branches (incl. target oid) and all extract branches from merge summaries (incl. parent oid)
/// * Sort all branches by persistence
/// * Iterating over all branches in persistence order, trace back over commit parents until a trace is already assigned
fn assign_branches(
repository: &Repository,
commits: &mut [CommitInfo],
indices: &HashMap<Oid, usize>,
settings: &Settings,
) -> Result<Vec<BranchInfo>, String> {
let mut branch_idx = 0;
let mut branches = extract_branches(repository, commits, indices, settings)?;
let mut index_map: Vec<_> = (0..branches.len())
.map(|old_idx| {
let (target, is_tag, is_merged) = {
let branch = &branches[old_idx];
(branch.target, branch.is_tag, branch.is_merged)
};
if let Some(&idx) = &indices.get(&target) {
let info = &mut commits[idx];
if is_tag {
info.tags.push(old_idx);
} else if !is_merged {
info.branches.push(old_idx);
}
let oid = info.oid;
let any_assigned =
trace_branch(repository, commits, indices, &mut branches, oid, old_idx)
.unwrap_or(false);
if any_assigned || !is_merged {
branch_idx += 1;
Some(branch_idx - 1)
} else {
None
}
} else {
None
}
})
.collect();
let mut commit_count = vec![0; branches.len()];
for info in commits.iter_mut() {
if let Some(trace) = info.branch_trace {
commit_count[trace] += 1;
}
}
let mut count_skipped = 0;
for (idx, branch) in branches.iter().enumerate() {
if let Some(mapped) = index_map[idx] {
if commit_count[idx] == 0 && branch.is_merged && !branch.is_tag {
index_map[idx] = None;
count_skipped += 1;
} else {
index_map[idx] = Some(mapped - count_skipped);
}
}
}
for info in commits.iter_mut() {
if let Some(trace) = info.branch_trace {
info.branch_trace = index_map[trace];
for br in info.branches.iter_mut() {
*br = index_map[*br].unwrap();
}
for tag in info.tags.iter_mut() {
*tag = index_map[*tag].unwrap();
}
}
}
let branches: Vec<_> = branches
.into_iter()
.enumerate()
.filter_map(|(arr_index, branch)| {
if index_map[arr_index].is_some() {
Some(branch)
} else {
None
}
})
.collect();
Ok(branches)
}
fn correct_fork_merges(
commits: &[CommitInfo],
indices: &HashMap<Oid, usize>,
branches: &mut [BranchInfo],
settings: &Settings,
) -> Result<(), String> {
for idx in 0..branches.len() {
if let Some(merge_target) = branches[idx]
.merge_target
.and_then(|oid| indices.get(&oid))
.and_then(|idx| commits.get(*idx))
.and_then(|info| info.branch_trace)
.and_then(|trace| branches.get(trace))
{
if branches[idx].name == merge_target.name {
let name = format!("{}{}", FORK, branches[idx].name);
let term_col = to_terminal_color(
&branch_color(
&name,
&settings.branches.terminal_colors[..],
&settings.branches.terminal_colors_unknown,
idx,
)[..],
)?;
let pos = branch_order(&name, &settings.branches.order);
let svg_col = branch_color(
&name,
&settings.branches.svg_colors,
&settings.branches.svg_colors_unknown,
idx,
);
branches[idx].name = format!("{}{}", FORK, branches[idx].name);
branches[idx].visual.order_group = pos;
branches[idx].visual.term_color = term_col;
branches[idx].visual.svg_color = svg_col;
}
}
}
Ok(())
}
fn assign_sources_targets(
commits: &[CommitInfo],
indices: &HashMap<Oid, usize>,
branches: &mut [BranchInfo],
) {
for idx in 0..branches.len() {
let target_branch_idx = branches[idx]
.merge_target
.and_then(|oid| indices.get(&oid))
.and_then(|idx| commits.get(*idx))
.and_then(|info| info.branch_trace);
branches[idx].target_branch = target_branch_idx;
let group = target_branch_idx
.and_then(|trace| branches.get(trace))
.map(|br| br.visual.order_group);
branches[idx].visual.target_order_group = group;
}
for info in commits {
let mut max_par_order = None;
let mut source_branch_id = None;
for par_oid in info.parents.iter() {
let par_info = par_oid
.and_then(|oid| indices.get(&oid))
.and_then(|idx| commits.get(*idx));
if let Some(par_info) = par_info {
if par_info.branch_trace != info.branch_trace {
if let Some(trace) = par_info.branch_trace {
source_branch_id = Some(trace);
}
let group = par_info
.branch_trace
.and_then(|trace| branches.get(trace))
.map(|br| br.visual.order_group);
if let Some(gr) = max_par_order {
if let Some(p_group) = group {
if p_group > gr {
max_par_order = group;
}
}
} else {
max_par_order = group;
}
}
}
}
let branch = info.branch_trace.and_then(|trace| branches.get_mut(trace));
if let Some(branch) = branch {
if let Some(order) = max_par_order {
branch.visual.source_order_group = Some(order);
}
if let Some(source_id) = source_branch_id {
branch.source_branch = Some(source_id);
}
}
}
}
/// Extracts (real or derived from merge summary) and assigns basic properties.
fn extract_branches(
repository: &Repository,
commits: &[CommitInfo],
indices: &HashMap<Oid, usize>,
settings: &Settings,
) -> Result<Vec<BranchInfo>, String> {
let filter = if settings.include_remote {
None
} else {
Some(BranchType::Local)
};
let actual_branches = repository
.branches(filter)
.map_err(|err| err.message().to_string())?
.collect::<Result<Vec<_>, Error>>()
.map_err(|err| err.message().to_string())?;
let mut counter = 0;
let mut valid_branches = actual_branches
.iter()
.filter_map(|(br, tp)| {
br.get().name().and_then(|n| {
br.get().target().map(|t| {
counter += 1;
let start_index = match tp {
BranchType::Local => 11,
BranchType::Remote => 13,
};
let name = &n[start_index..];
let end_index = indices.get(&t).cloned();
let term_color = match to_terminal_color(
&branch_color(
name,
&settings.branches.terminal_colors[..],
&settings.branches.terminal_colors_unknown,
counter,
)[..],
) {
Ok(col) => col,
Err(err) => return Err(err),
};
Ok(BranchInfo::new(
t,
None,
name.to_string(),
branch_order(name, &settings.branches.persistence) as u8,
&BranchType::Remote == tp,
false,
false,
BranchVis::new(
branch_order(name, &settings.branches.order),
term_color,
branch_color(
name,
&settings.branches.svg_colors,
&settings.branches.svg_colors_unknown,
counter,
),
),
end_index,
))
})
})
})
.collect::<Result<Vec<_>, String>>()?;
for (idx, info) in commits.iter().enumerate() {
let commit = repository
.find_commit(info.oid)
.map_err(|err| err.message().to_string())?;
if info.is_merge {
if let Some(summary) = commit.summary() {
counter += 1;
let parent_oid = commit
.parent_id(1)
.map_err(|err| err.message().to_string())?;
let branch_name = parse_merge_summary(summary, &settings.merge_patterns)
.unwrap_or_else(|| "unknown".to_string());
let persistence = branch_order(&branch_name, &settings.branches.persistence) as u8;
let pos = branch_order(&branch_name, &settings.branches.order);
let term_col = to_terminal_color(
&branch_color(
&branch_name,
&settings.branches.terminal_colors[..],
&settings.branches.terminal_colors_unknown,
counter,
)[..],
)?;
let svg_col = branch_color(
&branch_name,
&settings.branches.svg_colors,
&settings.branches.svg_colors_unknown,
counter,
);
let branch_info = BranchInfo::new(
parent_oid,
Some(info.oid),
branch_name,
persistence,
false,
true,
false,
BranchVis::new(pos, term_col, svg_col),
Some(idx + 1),
);
valid_branches.push(branch_info);
}
}
}
valid_branches.sort_by_cached_key(|branch| (branch.persistence, !branch.is_merged));
let mut tags = Vec::new();
repository
.tag_foreach(|oid, name| {
tags.push((oid, name.to_vec()));
true
})
.map_err(|err| err.message().to_string())?;
for (oid, name) in tags {
let name = std::str::from_utf8(&name[5..]).map_err(|err| err.to_string())?;
let target = repository
.find_tag(oid)
.map(|tag| tag.target_id())
.or_else(|_| repository.find_commit(oid).map(|_| oid));
if let Ok(target_oid) = target {
if let Some(target_index) = indices.get(&target_oid) {
counter += 1;
let term_col = to_terminal_color(
&branch_color(
name,
&settings.branches.terminal_colors[..],
&settings.branches.terminal_colors_unknown,
counter,
)[..],
)?;
let pos = branch_order(name, &settings.branches.order);
let svg_col = branch_color(
name,
&settings.branches.svg_colors,
&settings.branches.svg_colors_unknown,
counter,
);
let tag_info = BranchInfo::new(
target_oid,
None,
name.to_string(),
settings.branches.persistence.len() as u8 + 1,
false,
false,
true,
BranchVis::new(pos, term_col, svg_col),
Some(*target_index),
);
valid_branches.push(tag_info);
}
}
}
Ok(valid_branches)
}
/// Traces back branches by following 1st commit parent,
/// until a commit is reached that already has a trace.
fn trace_branch(
repository: &Repository,
commits: &mut [CommitInfo],
indices: &HashMap<Oid, usize>,
branches: &mut [BranchInfo],
oid: Oid,
branch_index: usize,
) -> Result<bool, Error> {
let mut curr_oid = oid;
let mut prev_index: Option<usize> = None;
let mut start_index: Option<i32> = None;
let mut any_assigned = false;
while let Some(index) = indices.get(&curr_oid) {
let info = &mut commits[*index];
if let Some(old_trace) = info.branch_trace {
let (old_name, old_term, old_svg, old_range) = {
let old_branch = &branches[old_trace];
(
&old_branch.name.clone(),
old_branch.visual.term_color,
old_branch.visual.svg_color.clone(),
old_branch.range,
)
};
let new_name = &branches[branch_index].name;
let old_end = old_range.0.unwrap_or(0);
let new_end = branches[branch_index].range.0.unwrap_or(0);
if new_name == old_name && old_end >= new_end {
let old_branch = &mut branches[old_trace];
if let Some(old_end) = old_range.1 {
if index > &old_end {
old_branch.range = (None, None);
} else {
old_branch.range = (Some(*index), old_branch.range.1);
}
} else {
old_branch.range = (Some(*index), old_branch.range.1);
}
} else {
let branch = &mut branches[branch_index];
if branch.name.starts_with(ORIGIN) && branch.name[7..] == old_name[..] {
branch.visual.term_color = old_term;
branch.visual.svg_color = old_svg;
}
match prev_index {
None => start_index = Some(*index as i32 - 1),
Some(prev_index) => {
// TODO: in cases where no crossings occur, the rule for merge commits can also be applied to normal commits
// see also print::get_deviate_index()
if commits[prev_index].is_merge {
let mut temp_index = prev_index;
for sibling_oid in &commits[*index].children {
if sibling_oid != &curr_oid {
let sibling_index = indices[sibling_oid];
if sibling_index > temp_index {
temp_index = sibling_index;
}
}
}
start_index = Some(temp_index as i32);
} else {
start_index = Some(*index as i32 - 1);
}
}
}
break;
}
}
info.branch_trace = Some(branch_index);
any_assigned = true;
let commit = repository.find_commit(curr_oid)?;
match commit.parent_count() {
0 => {
start_index = Some(*index as i32);
break;
}
_ => {
prev_index = Some(*index);
curr_oid = commit.parent_id(0)?;
}
}
}
let branch = &mut branches[branch_index];
if let Some(end) = branch.range.0 {
if let Some(start_index) = start_index {
if start_index < end as i32 {
// TODO: find a better solution (bool field?) to identify non-deleted branches that were not assigned to any commits, and thus should not occupy a column.
branch.range = (None, None);
} else {
branch.range = (branch.range.0, Some(start_index as usize));
}
} else {
branch.range = (branch.range.0, None);
}
} else {
branch.range = (branch.range.0, start_index.map(|si| si as usize));
}
Ok(any_assigned)
}
/// Sorts branches into columns for visualization, that all branches can be
/// visualizes linearly and without overlaps. Uses Shortest-First scheduling.
fn assign_branch_columns(
commits: &[CommitInfo],
indices: &HashMap<Oid, usize>,
branches: &mut [BranchInfo],
settings: &BranchSettings,
shortest_first: bool,
forward: bool,
) {
let mut occupied: Vec<Vec<Vec<(usize, usize)>>> = vec![vec![]; settings.order.len() + 1];
let length_sort_factor = if shortest_first { 1 } else { -1 };
let start_sort_factor = if forward { 1 } else { -1 };
let mut branches_sort: Vec<_> = branches
.iter()
.enumerate()
.filter(|(_idx, br)| br.range.0.is_some() || br.range.1.is_some())
.map(|(idx, br)| {
(
idx,
br.range.0.unwrap_or(0),
br.range.1.unwrap_or(branches.len() - 1),
br.visual
.source_order_group
.unwrap_or(settings.order.len() + 1),
br.visual
.target_order_group
.unwrap_or(settings.order.len() + 1),
)
})
.collect();
branches_sort.sort_by_cached_key(|tup| {
(
std::cmp::max(tup.3, tup.4),
(tup.2 as i32 - tup.1 as i32) * length_sort_factor,
tup.1 as i32 * start_sort_factor,
)
});
for (branch_idx, start, end, _, _) in branches_sort {
let branch = &branches[branch_idx];
let group = branch.visual.order_group;
let group_occ = &mut occupied[group];
let align_right = branch
.source_branch
.map(|src| branches[src].visual.order_group > branch.visual.order_group)
.unwrap_or(false)
|| branch
.target_branch
.map(|trg| branches[trg].visual.order_group > branch.visual.order_group)
.unwrap_or(false);
let len = group_occ.len();
let mut found = len;
for i in 0..len {
let index = if align_right { len - i - 1 } else { i };
let column_occ = &group_occ[index];
let mut occ = false;
for (s, e) in column_occ {
if start <= *e && end >= *s {
occ = true;
break;
}
}
if !occ {
if let Some(merge_trace) = branch
.merge_target
.and_then(|t| indices.get(&t))
.and_then(|t_idx| commits[*t_idx].branch_trace)
{
let merge_branch = &branches[merge_trace];
if merge_branch.visual.order_group == branch.visual.order_group {
if let Some(merge_column) = merge_branch.visual.column {
if merge_column == index {
occ = true;
}
}
}
}
}
if !occ {
found = index;
break;
}
}
let branch = &mut branches[branch_idx];
branch.visual.column = Some(found);
if found == group_occ.len() {
group_occ.push(vec![]);
}
group_occ[found].push((start, end));
}
let group_offset: Vec<usize> = occupied
.iter()
.scan(0, |acc, group| {
*acc += group.len();
Some(*acc)
})
.collect();
for branch in branches {
if let Some(column) = branch.visual.column {
let offset = if branch.visual.order_group == 0 {
0
} else {
group_offset[branch.visual.order_group - 1]
};
branch.visual.column = Some(column + offset);
}
}
}
/// Finds the index for a branch name from a slice of prefixes
fn branch_order(name: &str, order: &[Regex]) -> usize {
order
.iter()
.position(|b| (name.starts_with(ORIGIN) && b.is_match(&name[7..])) || b.is_match(name))
.unwrap_or(order.len())
}
/// Finds the svg color for a branch name.
fn branch_color<T: Clone>(
name: &str,
order: &[(Regex, Vec<T>)],
unknown: &[T],
counter: usize,
) -> T {
let color = order
.iter()
.find_position(|(b, _)| {
(name.starts_with(ORIGIN) && b.is_match(&name[7..])) || b.is_match(name)
})
.map(|(_pos, col)| &col.1[counter % col.1.len()])
.unwrap_or_else(|| &unknown[counter % unknown.len()]);
color.clone()
}
/// Tries to extract the name of a merged-in branch from the merge commit summary.
pub fn parse_merge_summary(summary: &str, patterns: &MergePatterns) -> Option<String> {
for regex in &patterns.patterns {
if let Some(captures) = regex.captures(summary) {
if captures.len() == 2 && captures.get(1).is_some() {
return captures.get(1).map(|m| m.as_str().to_string());
}
}
}
None
}
#[cfg(test)]
mod tests {
use crate::settings::MergePatterns;
#[test]
fn parse_merge_summary() {
let patterns = MergePatterns::default();
let gitlab_pull = "Merge branch 'feature/my-feature' into 'master'";
let git_default = "Merge branch 'feature/my-feature' into dev";
let git_master = "Merge branch 'feature/my-feature'";
let github_pull = "Merge pull request #1 from user-x/feature/my-feature";
let github_pull_2 = "Merge branch 'feature/my-feature' of github.com:user-x/repo";
let bitbucket_pull = "Merged in feature/my-feature (pull request #1)";
assert_eq!(
super::parse_merge_summary(gitlab_pull, &patterns),
Some("feature/my-feature".to_string()),
);
assert_eq!(
super::parse_merge_summary(git_default, &patterns),
Some("feature/my-feature".to_string()),
);
assert_eq!(
super::parse_merge_summary(git_master, &patterns),
Some("feature/my-feature".to_string()),
);
assert_eq!(
super::parse_merge_summary(github_pull, &patterns),
Some("feature/my-feature".to_string()),
);
assert_eq!(
super::parse_merge_summary(github_pull_2, &patterns),
Some("feature/my-feature".to_string()),
);
assert_eq!(
super::parse_merge_summary(bitbucket_pull, &patterns),
Some("feature/my-feature".to_string()),
);
}
}

13
src/lib.rs Normal file
View file

@ -0,0 +1,13 @@
//! Command line tool to show clear git graphs arranged for your branching model.
use git2::Repository;
use std::path::Path;
pub mod config;
pub mod graph;
pub mod print;
pub mod settings;
pub fn get_repo<P: AsRef<Path>>(path: P) -> Result<Repository, git2::Error> {
Repository::discover(path)
}

539
src/main.rs Normal file
View file

@ -0,0 +1,539 @@
use clap::{crate_version, Arg, Command};
use crossterm::cursor::MoveToColumn;
use crossterm::event::{Event, KeyCode, KeyModifiers};
use crossterm::style::Print;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
use crossterm::{ErrorKind, ExecutableCommand};
use git2::Repository;
use git_graph::config::{
create_config, get_available_models, get_model, get_model_name, set_model,
};
use git_graph::get_repo;
use git_graph::graph::GitGraph;
use git_graph::print::format::CommitFormat;
use git_graph::print::svg::print_svg;
use git_graph::print::unicode::print_unicode;
use git_graph::settings::{BranchOrder, BranchSettings, Characters, MergePatterns, Settings};
use platform_dirs::AppDirs;
use std::io::stdout;
use std::str::FromStr;
use std::time::Instant;
const REPO_CONFIG_FILE: &str = "git-graph.toml";
fn main() {
std::process::exit(match from_args() {
Ok(_) => 0,
Err(err) => {
eprintln!("{}", err);
1
}
});
}
fn from_args() -> Result<(), String> {
let app_dir = AppDirs::new(Some("git-graph"), false).unwrap().config_dir;
let mut models_dir = app_dir;
models_dir.push("models");
create_config(&models_dir)?;
let app = Command::new("git-graph")
.version(crate_version!())
.about(
"Structured Git graphs for your branching model.\n \
https://github.com/mlange-42/git-graph\n\
\n\
EXAMPES:\n \
git-graph -> Show graph\n \
git-graph --style round -> Show graph in a different style\n \
git-graph --model <model> -> Show graph using a certain <model>\n \
git-graph model --list -> List available branching models\n \
git-graph model -> Show repo's current branching models\n \
git-graph model <model> -> Permanently set model <model> for this repo",
)
.arg(
Arg::new("reverse")
.long("reverse")
.short('r')
.help("Reverse the order of commits.")
.required(false)
.num_args(0),
)
.arg(
Arg::new("path")
.long("path")
.short('p')
.help("Open repository from this path or above. Default '.'")
.required(false)
.num_args(1),
)
.arg(
Arg::new("max-count")
.long("max-count")
.short('n')
.help("Maximum number of commits")
.required(false)
.num_args(1)
.value_name("n"),
)
.arg(
Arg::new("model")
.long("model")
.short('m')
.help("Branching model. Available presets are [simple|git-flow|none].\n\
Default: git-flow. \n\
Permanently set the model for a repository with\n\
> git-graph model <model>")
.required(false)
.num_args(1),
)
.arg(
Arg::new("local")
.long("local")
.short('l')
.help("Show only local branches, no remotes.")
.required(false)
.num_args(0),
)
.arg(
Arg::new("svg")
.long("svg")
.help("Render graph as SVG instead of text-based.")
.required(false)
.num_args(0),
)
.arg(
Arg::new("debug")
.long("debug")
.short('d')
.help("Additional debug output and graphics.")
.required(false)
.num_args(0),
)
.arg(
Arg::new("sparse")
.long("sparse")
.short('S')
.help("Print a less compact graph: merge lines point to target lines\n\
rather than merge commits.")
.required(false)
.num_args(0),
)
.arg(
Arg::new("color")
.long("color")
.help("Specify when colors should be used. One of [auto|always|never].\n\
Default: auto.")
.required(false)
.num_args(1),
)
.arg(
Arg::new("no-color")
.long("no-color")
.help("Print without colors. Missing color support should be detected\n\
automatically (e.g. when piping to a file).\n\
Overrides option '--color'")
.required(false)
.num_args(0),
)
.arg(
Arg::new("no-pager")
.long("no-pager")
.help("Use no pager (print everything at once without prompt).")
.required(false)
.num_args(0),
)
.arg(
Arg::new("style")
.long("style")
.short('s')
.help("Output style. One of [normal/thin|round|bold|double|ascii].\n \
(First character can be used as abbreviation, e.g. '-s r')")
.required(false)
.num_args(1),
)
.arg(
Arg::new("wrap")
.long("wrap")
.short('w')
.help("Line wrapping for formatted commit text. Default: 'auto 0 8'\n\
Argument format: [<width>|auto|none[ <indent1>[ <indent2>]]]\n\
For examples, consult 'git-graph --help'")
.long_help("Line wrapping for formatted commit text. Default: 'auto 0 8'\n\
Argument format: [<width>|auto|none[ <indent1>[ <indent2>]]]\n\
Examples:\n \
git-graph --wrap auto\n \
git-graph --wrap auto 0 8\n \
git-graph --wrap none\n \
git-graph --wrap 80\n \
git-graph --wrap 80 0 8\n\
'auto' uses the terminal's width if on a terminal.")
.required(false)
.num_args(0..=3),
)
.arg(
Arg::new("format")
.long("format")
.short('f')
.help("Commit format. One of [oneline|short|medium|full|\"<string>\"].\n \
(First character can be used as abbreviation, e.g. '-f m')\n\
Default: oneline.\n\
For placeholders supported in \"<string>\", consult 'git-graph --help'")
.long_help("Commit format. One of [oneline|short|medium|full|\"<string>\"].\n \
(First character can be used as abbreviation, e.g. '-f m')\n\
Formatting placeholders for \"<string>\":\n \
%n newline\n \
%H commit hash\n \
%h abbreviated commit hash\n \
%P parent commit hashes\n \
%p abbreviated parent commit hashes\n \
%d refs (branches, tags)\n \
%s commit summary\n \
%b commit message body\n \
%B raw body (subject and body)\n \
%an author name\n \
%ae author email\n \
%ad author date\n \
%as author date in short format 'YYYY-MM-DD'\n \
%cn committer name\n \
%ce committer email\n \
%cd committer date\n \
%cs committer date in short format 'YYYY-MM-DD'\n \
\n \
If you add a + (plus sign) after % of a placeholder,\n \
a line-feed is inserted immediately before the expansion if\n \
and only if the placeholder expands to a non-empty string.\n \
If you add a - (minus sign) after % of a placeholder, all\n \
consecutive line-feeds immediately preceding the expansion are\n \
deleted if and only if the placeholder expands to an empty string.\n \
If you add a ' ' (space) after % of a placeholder, a space is\n \
inserted immediately before the expansion if and only if\n \
the placeholder expands to a non-empty string.\n\
\n \
See also the respective git help: https://git-scm.com/docs/pretty-formats\n")
.required(false)
.num_args(1),
)
.subcommand(Command::new("model")
.about("Prints or permanently sets the branching model for a repository.")
.arg(
Arg::new("model")
.help("The branching model to be used. Available presets are [simple|git-flow|none].\n\
When not given, prints the currently set model.")
.value_name("model")
.num_args(1)
.required(false)
.index(1))
.arg(
Arg::new("list")
.long("list")
.short('l')
.help("List all available branching models.")
.required(false)
.num_args(0),
));
let matches = app.get_matches();
if let Some(matches) = matches.subcommand_matches("model") {
if matches.get_flag("list") {
println!(
"{}",
itertools::join(get_available_models(&models_dir)?, "\n")
);
return Ok(());
}
}
let dot = ".".to_string();
let path = matches.get_one::<String>("path").unwrap_or(&dot);
let repository = get_repo(path)
.map_err(|err| format!("ERROR: {}\n Navigate into a repository before running git-graph, or use option --path", err.message()))?;
if let Some(matches) = matches.subcommand_matches("model") {
match matches.get_one::<String>("model") {
None => {
let curr_model = get_model_name(&repository, REPO_CONFIG_FILE)?;
match curr_model {
None => print!("No branching model set"),
Some(model) => print!("{}", model),
}
}
Some(model) => set_model(&repository, model, REPO_CONFIG_FILE, &models_dir)?,
};
return Ok(());
}
let commit_limit = match matches.get_one::<String>("max-count") {
None => None,
Some(str) => match str.parse::<usize>() {
Ok(val) => Some(val),
Err(_) => {
return Err(format![
"Option max-count must be a positive number, but got '{}'",
str
])
}
},
};
let include_remote = !matches.get_flag("local");
let reverse_commit_order = matches.get_flag("reverse");
let svg = matches.get_flag("svg");
let pager = !matches.get_flag("no-pager");
let compact = !matches.get_flag("sparse");
let debug = matches.get_flag("debug");
let style = matches
.get_one::<String>("style")
.map(|s| Characters::from_str(s))
.unwrap_or_else(|| Ok(Characters::thin()))?;
let style = if reverse_commit_order {
style.reverse()
} else {
style
};
let model = get_model(
&repository,
matches.get_one::<String>("model").map(|s| &s[..]),
REPO_CONFIG_FILE,
&models_dir,
)?;
let format = match matches.get_one::<String>("format") {
None => CommitFormat::OneLine,
Some(str) => CommitFormat::from_str(str)?,
};
let colored = if matches.get_flag("no-color") {
false
} else if let Some(mode) = matches.get_one::<String>("color") {
match &mode[..] {
"auto" => {
atty::is(atty::Stream::Stdout)
&& (!cfg!(windows) || yansi::Paint::enable_windows_ascii())
}
"always" => {
if cfg!(windows) {
yansi::Paint::enable_windows_ascii();
}
true
}
"never" => false,
other => {
return Err(format!(
"Unknown color mode '{}'. Supports [auto|always|never].",
other
))
}
}
} else {
atty::is(atty::Stream::Stdout) && (!cfg!(windows) || yansi::Paint::enable_windows_ascii())
};
let wrapping = if let Some(wrap_values) = matches.get_many::<String>("wrap") {
let strings = wrap_values.map(|s| s.as_str()).collect::<Vec<_>>();
if strings.is_empty() {
Some((None, Some(0), Some(8)))
} else {
match strings[0] {
"none" => None,
"auto" => {
let wrap = strings
.iter()
.skip(1)
.map(|str| str.parse::<usize>())
.collect::<Result<Vec<_>, _>>()
.map_err(|_| {
format!(
"ERROR: Can't parse option --wrap '{}' to integers.",
strings.join(" ")
)
})?;
Some((None, wrap.first().cloned(), wrap.get(1).cloned()))
}
_ => {
let wrap = strings
.iter()
.map(|str| str.parse::<usize>())
.collect::<Result<Vec<_>, _>>()
.map_err(|_| {
format!(
"ERROR: Can't parse option --wrap '{}' to integers.",
strings.join(" ")
)
})?;
Some((
wrap.first().cloned(),
wrap.get(1).cloned(),
wrap.get(2).cloned(),
))
}
}
}
} else {
Some((None, Some(0), Some(8)))
};
let settings = Settings {
reverse_commit_order,
debug,
colored,
compact,
include_remote,
format,
wrapping,
characters: style,
branch_order: BranchOrder::ShortestFirst(true),
branches: BranchSettings::from(model).map_err(|err| err.to_string())?,
merge_patterns: MergePatterns::default(),
};
run(repository, &settings, svg, commit_limit, pager)
}
fn run(
repository: Repository,
settings: &Settings,
svg: bool,
max_commits: Option<usize>,
pager: bool,
) -> Result<(), String> {
let now = Instant::now();
let graph = GitGraph::new(repository, settings, max_commits)?;
let duration_graph = now.elapsed().as_micros();
if settings.debug {
for branch in &graph.all_branches {
eprintln!(
"{} (col {}) ({:?}) {} s: {:?}, t: {:?}",
branch.name,
branch.visual.column.unwrap_or(99),
branch.range,
if branch.is_merged { "m" } else { "" },
branch.visual.source_order_group,
branch.visual.target_order_group
);
}
}
let now = Instant::now();
if svg {
println!("{}", print_svg(&graph, settings)?);
} else {
let (g_lines, t_lines, _indices) = print_unicode(&graph, settings)?;
if pager && atty::is(atty::Stream::Stdout) {
print_paged(&g_lines, &t_lines).map_err(|err| err.to_string())?;
} else {
print_unpaged(&g_lines, &t_lines);
}
};
let duration_print = now.elapsed().as_micros();
if settings.debug {
eprintln!(
"Graph construction: {:.1} ms, printing: {:.1} ms ({} commits)",
duration_graph as f32 / 1000.0,
duration_print as f32 / 1000.0,
graph.commits.len()
);
}
Ok(())
}
/// Print the graph, paged (i.e. wait for user input once the terminal is filled).
fn print_paged(graph_lines: &[String], text_lines: &[String]) -> Result<(), ErrorKind> {
let (width, height) = crossterm::terminal::size()?;
let width = width as usize;
let mut line_idx = 0;
let mut print_lines = height - 2;
let mut clear = false;
let mut abort = false;
let help = "\r >>> Down: line, PgDown/Enter: page, End: all, Esc/Q/^C: quit\r";
let help = if help.len() > width {
&help[0..width]
} else {
help
};
while line_idx < graph_lines.len() {
if print_lines > 0 {
if clear {
stdout()
.execute(Clear(ClearType::CurrentLine))?
.execute(MoveToColumn(0))?;
}
stdout().execute(Print(format!(
" {} {}\n",
graph_lines[line_idx], text_lines[line_idx]
)))?;
if print_lines == 1 && line_idx < graph_lines.len() - 1 {
stdout().execute(Print(help))?;
}
print_lines -= 1;
line_idx += 1;
} else {
enable_raw_mode()?;
let input = crossterm::event::read()?;
if let Event::Key(evt) = input {
match evt.code {
KeyCode::Down => {
clear = true;
print_lines = 1;
}
KeyCode::Enter | KeyCode::PageDown => {
clear = true;
print_lines = height - 2;
}
KeyCode::End => {
clear = true;
print_lines = graph_lines.len() as u16;
}
KeyCode::Char(c) => match c {
'q' => {
abort = true;
break;
}
'c' if evt.modifiers == KeyModifiers::CONTROL => {
abort = true;
break;
}
_ => {}
},
KeyCode::Esc => {
abort = true;
break;
}
_ => {}
}
}
}
}
if abort {
stdout()
.execute(Clear(ClearType::CurrentLine))?
.execute(MoveToColumn(0))?
.execute(Print(" ...\n"))?;
}
disable_raw_mode()?;
Ok(())
}
/// Print the graph, un-paged.
fn print_unpaged(graph_lines: &[String], text_lines: &[String]) {
for (g_line, t_line) in graph_lines.iter().zip(text_lines.iter()) {
println!(" {} {}", g_line, t_line);
}
}

45
src/print/colors.rs Normal file
View file

@ -0,0 +1,45 @@
//! ANSI terminal color handling.
use lazy_static::lazy_static;
use std::collections::HashMap;
/// Converts a color name to the index in the 256-color palette.
pub fn to_terminal_color(color: &str) -> Result<u8, String> {
match NAMED_COLORS.get(color) {
None => match color.parse::<u8>() {
Ok(col) => Ok(col),
Err(_) => Err(format!("Color {} not found", color)),
},
Some(rgb) => Ok(*rgb),
}
}
macro_rules! hashmap {
($( $key: expr => $val: expr ),*) => {{
let mut map = ::std::collections::HashMap::new();
$( map.insert($key, $val); )*
map
}}
}
lazy_static! {
/// Named ANSI colors
pub static ref NAMED_COLORS: HashMap<&'static str, u8> = hashmap![
"black" => 0,
"red" => 1,
"green" => 2,
"yellow" => 3,
"blue" => 4,
"magenta" => 5,
"cyan" => 6,
"white" => 7,
"bright_black" => 8,
"bright_red" => 9,
"bright_green" => 10,
"bright_yellow" => 11,
"bright_blue" => 12,
"bright_magenta" => 13,
"bright_cyan" => 14,
"bright_white" => 15
];
}

548
src/print/format.rs Normal file
View file

@ -0,0 +1,548 @@
//! Formatting of commits.
use chrono::{FixedOffset, Local, TimeZone};
use git2::{Commit, Time};
use lazy_static::lazy_static;
use std::fmt::Write;
use std::str::FromStr;
use textwrap::Options;
use yansi::Paint;
/// Commit formatting options.
#[derive(Ord, PartialOrd, Eq, PartialEq)]
pub enum CommitFormat {
OneLine,
Short,
Medium,
Full,
Format(String),
}
impl FromStr for CommitFormat {
type Err = String;
fn from_str(str: &str) -> Result<Self, Self::Err> {
match str {
"oneline" | "o" => Ok(CommitFormat::OneLine),
"short" | "s" => Ok(CommitFormat::Short),
"medium" | "m" => Ok(CommitFormat::Medium),
"full" | "f" => Ok(CommitFormat::Full),
str => Ok(CommitFormat::Format(str.to_string())),
}
}
}
const NEW_LINE: usize = 0;
const HASH: usize = 1;
const HASH_ABBREV: usize = 2;
const PARENT_HASHES: usize = 3;
const PARENT_HASHES_ABBREV: usize = 4;
const REFS: usize = 5;
const SUBJECT: usize = 6;
const AUTHOR: usize = 7;
const AUTHOR_EMAIL: usize = 8;
const AUTHOR_DATE: usize = 9;
const AUTHOR_DATE_SHORT: usize = 10;
const COMMITTER: usize = 11;
const COMMITTER_EMAIL: usize = 12;
const COMMITTER_DATE: usize = 13;
const COMMITTER_DATE_SHORT: usize = 14;
const BODY: usize = 15;
const BODY_RAW: usize = 16;
const MODE_SPACE: usize = 1;
const MODE_PLUS: usize = 2;
const MODE_MINUS: usize = 3;
lazy_static! {
pub static ref PLACEHOLDERS: Vec<[String; 4]> = {
let base = vec![
"n", "H", "h", "P", "p", "d", "s", "an", "ae", "ad", "as", "cn", "ce", "cd", "cs", "b",
"B",
];
base.iter()
.map(|b| {
[
format!("%{}", b),
format!("% {}", b),
format!("%+{}", b),
format!("%-{}", b),
]
})
.collect()
};
}
/// Format a commit for `CommitFormat::Format(String)`.
pub fn format_commit(
format: &str,
commit: &Commit,
branches: String,
wrapping: &Option<Options>,
hash_color: Option<u8>,
) -> Result<Vec<String>, String> {
let mut replacements = vec![];
for (idx, arr) in PLACEHOLDERS.iter().enumerate() {
let mut curr = 0;
loop {
let mut found = false;
for (mode, str) in arr.iter().enumerate() {
if let Some(start) = &format[curr..format.len()].find(str) {
replacements.push((curr + start, str.len(), idx, mode));
curr += start + str.len();
found = true;
break;
}
}
if !found {
break;
}
}
}
replacements.sort_by_key(|p| p.0);
let mut lines = vec![];
let mut out = String::new();
if replacements.is_empty() {
write!(out, "{}", format).unwrap();
add_line(&mut lines, &mut out, wrapping);
} else {
let mut curr = 0;
for (start, len, idx, mode) in replacements {
if idx == NEW_LINE {
write!(out, "{}", &format[curr..start]).unwrap();
add_line(&mut lines, &mut out, wrapping);
} else {
write!(out, "{}", &format[curr..start]).unwrap();
match idx {
HASH => {
match mode {
MODE_SPACE => write!(out, " ").unwrap(),
MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
_ => {}
}
if let Some(color) = hash_color {
write!(out, "{}", Paint::fixed(color, commit.id()))
} else {
write!(out, "{}", commit.id())
}
}
HASH_ABBREV => {
match mode {
MODE_SPACE => write!(out, " ").unwrap(),
MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
_ => {}
}
if let Some(color) = hash_color {
write!(
out,
"{}",
Paint::fixed(color, &commit.id().to_string()[..7])
)
} else {
write!(out, "{}", &commit.id().to_string()[..7])
}
}
PARENT_HASHES => {
match mode {
MODE_SPACE => write!(out, " ").unwrap(),
MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
_ => {}
}
for i in 0..commit.parent_count() {
write!(out, "{}", commit.parent_id(i).unwrap()).unwrap();
if i < commit.parent_count() - 1 {
write!(out, " ").unwrap();
}
}
Ok(())
}
PARENT_HASHES_ABBREV => {
match mode {
MODE_SPACE => write!(out, " ").unwrap(),
MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
_ => {}
}
for i in 0..commit.parent_count() {
write!(
out,
"{}",
&commit
.parent_id(i)
.map_err(|err| err.to_string())?
.to_string()[..7]
)
.unwrap();
if i < commit.parent_count() - 1 {
write!(out, " ").unwrap();
}
}
Ok(())
}
REFS => {
match mode {
MODE_SPACE => {
if !branches.is_empty() {
write!(out, " ").unwrap()
}
}
MODE_PLUS => {
if !branches.is_empty() {
add_line(&mut lines, &mut out, wrapping)
}
}
MODE_MINUS => {
if branches.is_empty() {
out = remove_empty_lines(&mut lines, out)
}
}
_ => {}
}
write!(out, "{}", branches)
}
SUBJECT => {
let summary = commit.summary().unwrap_or("");
match mode {
MODE_SPACE => {
if !summary.is_empty() {
write!(out, " ").unwrap()
}
}
MODE_PLUS => {
if !summary.is_empty() {
add_line(&mut lines, &mut out, wrapping)
}
}
MODE_MINUS => {
if summary.is_empty() {
out = remove_empty_lines(&mut lines, out)
}
}
_ => {}
}
write!(out, "{}", summary)
}
AUTHOR => {
match mode {
MODE_SPACE => write!(out, " ").unwrap(),
MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
_ => {}
}
write!(out, "{}", &commit.author().name().unwrap_or(""))
}
AUTHOR_EMAIL => {
match mode {
MODE_SPACE => write!(out, " ").unwrap(),
MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
_ => {}
}
write!(out, "{}", &commit.author().email().unwrap_or(""))
}
AUTHOR_DATE => {
match mode {
MODE_SPACE => write!(out, " ").unwrap(),
MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
_ => {}
}
write!(
out,
"{}",
format_date(commit.author().when(), "%a %b %e %H:%M:%S %Y %z")
)
}
AUTHOR_DATE_SHORT => {
match mode {
MODE_SPACE => write!(out, " ").unwrap(),
MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
_ => {}
}
write!(out, "{}", format_date(commit.author().when(), "%F"))
}
COMMITTER => {
match mode {
MODE_SPACE => write!(out, " ").unwrap(),
MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
_ => {}
}
write!(out, "{}", &commit.committer().name().unwrap_or(""))
}
COMMITTER_EMAIL => {
match mode {
MODE_SPACE => write!(out, " ").unwrap(),
MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
_ => {}
}
write!(out, "{}", &commit.committer().email().unwrap_or(""))
}
COMMITTER_DATE => {
match mode {
MODE_SPACE => write!(out, " ").unwrap(),
MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
_ => {}
}
write!(
out,
"{}",
format_date(commit.committer().when(), "%a %b %e %H:%M:%S %Y %z")
)
}
COMMITTER_DATE_SHORT => {
match mode {
MODE_SPACE => write!(out, " ").unwrap(),
MODE_PLUS => add_line(&mut lines, &mut out, wrapping),
_ => {}
}
write!(out, "{}", format_date(commit.committer().when(), "%F"))
}
BODY => {
let message = commit
.message()
.unwrap_or("")
.lines()
.collect::<Vec<&str>>();
let num_parts = message.len();
match mode {
MODE_SPACE => {
if num_parts > 2 {
write!(out, " ").unwrap()
}
}
MODE_PLUS => {
if num_parts > 2 {
add_line(&mut lines, &mut out, wrapping)
}
}
MODE_MINUS => {
if num_parts <= 2 {
out = remove_empty_lines(&mut lines, out)
}
}
_ => {}
}
for (cnt, line) in message.iter().enumerate() {
if cnt > 1 && (cnt < num_parts - 1 || !line.is_empty()) {
write!(out, "{}", line).unwrap();
add_line(&mut lines, &mut out, wrapping);
}
}
Ok(())
}
BODY_RAW => {
let message = commit
.message()
.unwrap_or("")
.lines()
.collect::<Vec<&str>>();
let num_parts = message.len();
match mode {
MODE_SPACE => {
if !message.is_empty() {
write!(out, " ").unwrap()
}
}
MODE_PLUS => {
if !message.is_empty() {
add_line(&mut lines, &mut out, wrapping)
}
}
MODE_MINUS => {
if message.is_empty() {
out = remove_empty_lines(&mut lines, out)
}
}
_ => {}
}
for (cnt, line) in message.iter().enumerate() {
if cnt < num_parts - 1 || !line.is_empty() {
write!(out, "{}", line).unwrap();
add_line(&mut lines, &mut out, wrapping);
}
}
Ok(())
}
x => return Err(format!("No commit field at index {}", x)),
}
.unwrap();
}
curr = start + len;
}
write!(out, "{}", &format[curr..(format.len())]).unwrap();
if !out.is_empty() {
add_line(&mut lines, &mut out, wrapping);
}
}
Ok(lines)
}
/// Format a commit for `CommitFormat::OneLine`.
pub fn format_oneline(
commit: &Commit,
branches: String,
wrapping: &Option<Options>,
hash_color: Option<u8>,
) -> Vec<String> {
let mut out = String::new();
if let Some(color) = hash_color {
write!(
out,
"{}",
Paint::fixed(color, &commit.id().to_string()[..7])
)
} else {
write!(out, "{}", &commit.id().to_string()[..7])
}
.unwrap();
write!(out, "{} {}", branches, commit.summary().unwrap_or("")).unwrap();
if let Some(wrap) = wrapping {
textwrap::fill(&out, wrap)
.lines()
.map(|str| str.to_string())
.collect()
} else {
vec![out]
}
}
/// Format a commit for `CommitFormat::Short`, `CommitFormat::Medium` or `CommitFormat::Full`.
pub fn format(
commit: &Commit,
branches: String,
wrapping: &Option<Options>,
hash_color: Option<u8>,
format: &CommitFormat,
) -> Result<Vec<String>, String> {
match format {
CommitFormat::OneLine => return Ok(format_oneline(commit, branches, wrapping, hash_color)),
CommitFormat::Format(format) => {
return format_commit(format, commit, branches, wrapping, hash_color)
}
_ => {}
}
let mut out_vec = vec![];
let mut out = String::new();
if let Some(color) = hash_color {
write!(out, "commit {}", Paint::fixed(color, &commit.id()))
} else {
write!(out, "commit {}", &commit.id())
}
.map_err(|err| err.to_string())?;
write!(out, "{}", branches).map_err(|err| err.to_string())?;
append_wrapped(&mut out_vec, out, wrapping);
if commit.parent_count() > 1 {
out = String::new();
write!(
out,
"Merge: {} {}",
&commit.parent_id(0).unwrap().to_string()[..7],
&commit.parent_id(1).unwrap().to_string()[..7]
)
.map_err(|err| err.to_string())?;
append_wrapped(&mut out_vec, out, wrapping);
}
out = String::new();
write!(
out,
"Author: {} <{}>",
commit.author().name().unwrap_or(""),
commit.author().email().unwrap_or("")
)
.map_err(|err| err.to_string())?;
append_wrapped(&mut out_vec, out, wrapping);
if format > &CommitFormat::Medium {
out = String::new();
write!(
out,
"Commit: {} <{}>",
commit.committer().name().unwrap_or(""),
commit.committer().email().unwrap_or("")
)
.map_err(|err| err.to_string())?;
append_wrapped(&mut out_vec, out, wrapping);
}
if format > &CommitFormat::Short {
out = String::new();
write!(
out,
"Date: {}",
format_date(commit.author().when(), "%a %b %e %H:%M:%S %Y %z")
)
.map_err(|err| err.to_string())?;
append_wrapped(&mut out_vec, out, wrapping);
}
if format == &CommitFormat::Short {
out_vec.push("".to_string());
append_wrapped(
&mut out_vec,
format!(" {}", commit.summary().unwrap_or("")),
wrapping,
);
out_vec.push("".to_string());
} else {
out_vec.push("".to_string());
let mut add_line = true;
for line in commit.message().unwrap_or("").lines() {
if line.is_empty() {
out_vec.push(line.to_string());
} else {
append_wrapped(&mut out_vec, format!(" {}", line), wrapping);
}
add_line = !line.trim().is_empty();
}
if add_line {
out_vec.push("".to_string());
}
}
Ok(out_vec)
}
pub fn format_date(time: Time, format: &str) -> String {
let date =
Local::from_offset(&FixedOffset::east(time.offset_minutes())).timestamp(time.seconds(), 0);
format!("{}", date.format(format))
}
fn append_wrapped(vec: &mut Vec<String>, str: String, wrapping: &Option<Options>) {
if str.is_empty() {
vec.push(str);
} else if let Some(wrap) = wrapping {
vec.extend(
textwrap::fill(&str, wrap)
.lines()
.map(|str| str.to_string()),
)
} else {
vec.push(str);
}
}
fn add_line(lines: &mut Vec<String>, line: &mut String, wrapping: &Option<Options>) {
let mut temp = String::new();
std::mem::swap(&mut temp, line);
append_wrapped(lines, temp, wrapping);
}
fn remove_empty_lines(lines: &mut Vec<String>, mut line: String) -> String {
while !lines.is_empty() && lines.last().unwrap().is_empty() {
line = lines.remove(lines.len() - 1);
}
if !lines.is_empty() {
line = lines.remove(lines.len() - 1);
}
line
}

45
src/print/mod.rs Normal file
View file

@ -0,0 +1,45 @@
//! Create visual representations of git graphs.
use crate::graph::GitGraph;
use std::cmp::max;
pub mod colors;
pub mod format;
pub mod svg;
pub mod unicode;
/// Find the index at which a between-branch connection
/// has to deviate from the current branch's column.
///
/// Returns the last index on the current column.
fn get_deviate_index(graph: &GitGraph, index: usize, par_index: usize) -> usize {
let info = &graph.commits[index];
let par_info = &graph.commits[par_index];
let par_branch = &graph.all_branches[par_info.branch_trace.unwrap()];
let mut min_split_idx = index;
for sibling_oid in &par_info.children {
if let Some(&sibling_index) = graph.indices.get(sibling_oid) {
if let Some(sibling) = graph.commits.get(sibling_index) {
if let Some(sibling_trace) = sibling.branch_trace {
let sibling_branch = &graph.all_branches[sibling_trace];
if sibling_oid != &info.oid
&& sibling_branch.visual.column == par_branch.visual.column
&& sibling_index > min_split_idx
{
min_split_idx = sibling_index;
}
}
}
}
}
// TODO: in cases where no crossings occur, the rule for merge commits can also be applied to normal commits
// See also branch::trace_branch()
if info.is_merge {
max(index, min_split_idx)
} else {
(par_index as i32 - 1) as usize
}
}

161
src/print/svg.rs Normal file
View file

@ -0,0 +1,161 @@
//! Create graphs in SVG format (Scalable Vector Graphics).
use crate::graph::GitGraph;
use crate::settings::Settings;
use svg::node::element::path::Data;
use svg::node::element::{Circle, Line, Path};
use svg::Document;
/// Creates a SVG visual representation of a graph.
pub fn print_svg(graph: &GitGraph, settings: &Settings) -> Result<String, String> {
let mut document = Document::new();
let max_idx = graph.commits.len();
let mut max_column = 0;
if settings.debug {
for branch in &graph.all_branches {
if let (Some(start), Some(end)) = branch.range {
document = document.add(bold_line(
start,
branch.visual.column.unwrap(),
end,
branch.visual.column.unwrap(),
"cyan",
));
}
}
}
for (idx, info) in graph.commits.iter().enumerate() {
if let Some(trace) = info.branch_trace {
let branch = &graph.all_branches[trace];
let branch_color = &branch.visual.svg_color;
if branch.visual.column.unwrap() > max_column {
max_column = branch.visual.column.unwrap();
}
for p in 0..2 {
if let Some(par_oid) = info.parents[p] {
if let Some(par_idx) = graph.indices.get(&par_oid) {
let par_info = &graph.commits[*par_idx];
let par_branch = &graph.all_branches[par_info.branch_trace.unwrap()];
let color = if info.is_merge {
&par_branch.visual.svg_color
} else {
branch_color
};
if branch.visual.column == par_branch.visual.column {
document = document.add(line(
idx,
branch.visual.column.unwrap(),
*par_idx,
par_branch.visual.column.unwrap(),
color,
));
} else {
let split_index = super::get_deviate_index(graph, idx, *par_idx);
document = document.add(path(
idx,
branch.visual.column.unwrap(),
*par_idx,
par_branch.visual.column.unwrap(),
split_index,
color,
));
}
}
}
}
document = document.add(commit_dot(
idx,
branch.visual.column.unwrap(),
branch_color,
!info.is_merge,
));
}
}
let (x_max, y_max) = commit_coord(max_idx + 1, max_column + 1);
document = document
.set("viewBox", (0, 0, x_max, y_max))
.set("width", x_max)
.set("height", y_max);
let mut out: Vec<u8> = vec![];
svg::write(&mut out, &document).map_err(|err| err.to_string())?;
Ok(String::from_utf8(out).unwrap_or_else(|_| "Invalid UTF8 character.".to_string()))
}
fn commit_dot(index: usize, column: usize, color: &str, filled: bool) -> Circle {
let (x, y) = commit_coord(index, column);
Circle::new()
.set("cx", x)
.set("cy", y)
.set("r", 4)
.set("fill", if filled { color } else { "white" })
.set("stroke", color)
.set("stroke-width", 1)
}
fn line(index1: usize, column1: usize, index2: usize, column2: usize, color: &str) -> Line {
let (x1, y1) = commit_coord(index1, column1);
let (x2, y2) = commit_coord(index2, column2);
Line::new()
.set("x1", x1)
.set("y1", y1)
.set("x2", x2)
.set("y2", y2)
.set("stroke", color)
.set("stroke-width", 1)
}
fn bold_line(index1: usize, column1: usize, index2: usize, column2: usize, color: &str) -> Line {
let (x1, y1) = commit_coord(index1, column1);
let (x2, y2) = commit_coord(index2, column2);
Line::new()
.set("x1", x1)
.set("y1", y1)
.set("x2", x2)
.set("y2", y2)
.set("stroke", color)
.set("stroke-width", 5)
}
fn path(
index1: usize,
column1: usize,
index2: usize,
column2: usize,
split_idx: usize,
color: &str,
) -> Path {
let c0 = commit_coord(index1, column1);
let c1 = commit_coord(split_idx, column1);
let c2 = commit_coord(split_idx + 1, column2);
let c3 = commit_coord(index2, column2);
let m = (0.5 * (c1.0 + c2.0), 0.5 * (c1.1 + c2.1));
let data = Data::new()
.move_to(c0)
.line_to(c1)
.quadratic_curve_to((c1.0, m.1, m.0, m.1))
.quadratic_curve_to((c2.0, m.1, c2.0, c2.1))
.line_to(c3);
Path::new()
.set("d", data)
.set("fill", "none")
.set("stroke", color)
.set("stroke-width", 1)
}
fn commit_coord(index: usize, column: usize) -> (f32, f32) {
(15.0 * (column as f32 + 1.0), 15.0 * (index as f32 + 1.0))
}

725
src/print/unicode.rs Normal file
View file

@ -0,0 +1,725 @@
//! Create graphs in SVG format (Scalable Vector Graphics).
use crate::graph::{CommitInfo, GitGraph, HeadInfo};
use crate::print::format::CommitFormat;
use crate::settings::{Characters, Settings};
use itertools::Itertools;
use std::cmp::max;
use std::collections::hash_map::Entry::{Occupied, Vacant};
use std::collections::HashMap;
use std::fmt::Write;
use textwrap::Options;
use yansi::Paint;
const SPACE: u8 = 0;
const DOT: u8 = 1;
const CIRCLE: u8 = 2;
const VER: u8 = 3;
const HOR: u8 = 4;
const CROSS: u8 = 5;
const R_U: u8 = 6;
const R_D: u8 = 7;
const L_D: u8 = 8;
const L_U: u8 = 9;
const VER_L: u8 = 10;
const VER_R: u8 = 11;
const HOR_U: u8 = 12;
const HOR_D: u8 = 13;
const ARR_L: u8 = 14;
const ARR_R: u8 = 15;
const WHITE: u8 = 7;
const HEAD_COLOR: u8 = 14;
const HASH_COLOR: u8 = 11;
type UnicodeGraphInfo = (Vec<String>, Vec<String>, Vec<usize>);
/// Creates a text-based visual representation of a graph.
pub fn print_unicode(graph: &GitGraph, settings: &Settings) -> Result<UnicodeGraphInfo, String> {
let num_cols = 2 * graph
.all_branches
.iter()
.map(|b| b.visual.column.unwrap_or(0))
.max()
.unwrap()
+ 1;
let head_idx = graph.indices.get(&graph.head.oid);
let inserts = get_inserts(graph, settings.compact);
let (indent1, indent2) = if let Some((_, ind1, ind2)) = settings.wrapping {
(" ".repeat(ind1.unwrap_or(0)), " ".repeat(ind2.unwrap_or(0)))
} else {
("".to_string(), "".to_string())
};
let wrap_options = if let Some((width, _, _)) = settings.wrapping {
create_wrapping_options(width, &indent1, &indent2, num_cols + 4)?
} else {
None
};
let mut index_map = vec![];
let mut text_lines = vec![];
let mut offset = 0;
for (idx, info) in graph.commits.iter().enumerate() {
index_map.push(idx + offset);
let cnt_inserts = if let Some(inserts) = inserts.get(&idx) {
inserts
.iter()
.filter(|vec| {
vec.iter().all(|occ| match occ {
Occ::Commit(_, _) => false,
Occ::Range(_, _, _, _) => true,
})
})
.count()
} else {
0
};
let head = if head_idx.map_or(false, |h| h == &idx) {
Some(&graph.head)
} else {
None
};
let lines = format(
&settings.format,
graph,
info,
head,
settings.colored,
&wrap_options,
)?;
let num_lines = if lines.is_empty() { 0 } else { lines.len() - 1 };
let max_inserts = max(cnt_inserts, num_lines);
let add_lines = max_inserts - num_lines;
text_lines.extend(lines.into_iter().map(Some));
text_lines.extend((0..add_lines).map(|_| None));
offset += max_inserts;
}
let mut grid = Grid::new(
num_cols,
graph.commits.len() + offset,
[SPACE, WHITE, settings.branches.persistence.len() as u8 + 2],
);
for (idx, info) in graph.commits.iter().enumerate() {
if let Some(trace) = info.branch_trace {
let branch = &graph.all_branches[trace];
let column = branch.visual.column.unwrap();
let idx_map = index_map[idx];
let branch_color = branch.visual.term_color;
grid.set(
column * 2,
idx_map,
if info.is_merge { CIRCLE } else { DOT },
branch_color,
branch.persistence,
);
for p in 0..2 {
if let Some(par_oid) = info.parents[p] {
if let Some(par_idx) = graph.indices.get(&par_oid) {
let par_idx_map = index_map[*par_idx];
let par_info = &graph.commits[*par_idx];
let par_branch = &graph.all_branches[par_info.branch_trace.unwrap()];
let par_column = par_branch.visual.column.unwrap();
let (color, pers) = if info.is_merge {
(par_branch.visual.term_color, par_branch.persistence)
} else {
(branch_color, branch.persistence)
};
if branch.visual.column == par_branch.visual.column {
if par_idx_map > idx_map + 1 {
vline(&mut grid, (idx_map, par_idx_map), column, color, pers);
}
} else {
let split_index = super::get_deviate_index(graph, idx, *par_idx);
let split_idx_map = index_map[split_index];
let inserts = &inserts[&split_index];
for (insert_idx, sub_entry) in inserts.iter().enumerate() {
for occ in sub_entry {
match occ {
Occ::Commit(_, _) => {}
Occ::Range(i1, i2, _, _) => {
if *i1 == idx && i2 == par_idx {
vline(
&mut grid,
(idx_map, split_idx_map + insert_idx),
column,
color,
pers,
);
hline(
&mut grid,
split_idx_map + insert_idx,
(par_column, column),
info.is_merge && p > 0,
color,
pers,
);
vline(
&mut grid,
(split_idx_map + insert_idx, par_idx_map),
par_column,
color,
pers,
);
}
}
}
}
}
}
}
}
}
}
}
if settings.reverse_commit_order {
text_lines.reverse();
grid.reverse();
}
let lines = print_graph(&settings.characters, &grid, text_lines, settings.colored);
Ok((lines.0, lines.1, index_map))
}
/// Create `textwrap::Options` from width and indent.
fn create_wrapping_options<'a>(
width: Option<usize>,
indent1: &'a str,
indent2: &'a str,
graph_width: usize,
) -> Result<Option<Options<'a>>, String> {
let wrapping = if let Some(width) = width {
Some(
textwrap::Options::new(width)
.initial_indent(indent1)
.subsequent_indent(indent2),
)
} else if atty::is(atty::Stream::Stdout) {
let width = crossterm::terminal::size()
.map_err(|err| err.to_string())?
.0;
let width = if width as usize > graph_width {
width as usize - graph_width
} else {
1
};
Some(
textwrap::Options::new(width)
.initial_indent(indent1)
.subsequent_indent(indent2),
)
} else {
None
};
Ok(wrapping)
}
/// Draws a vertical line
fn vline(grid: &mut Grid, (from, to): (usize, usize), column: usize, color: u8, pers: u8) {
for i in (from + 1)..to {
let (curr, _, old_pers) = grid.get_tuple(column * 2, i);
let (new_col, new_pers) = if pers < old_pers {
(Some(color), Some(pers))
} else {
(None, None)
};
match curr {
DOT | CIRCLE => {}
HOR => {
grid.set_opt(column * 2, i, Some(CROSS), Some(color), Some(pers));
}
HOR_U | HOR_D => {
grid.set_opt(column * 2, i, Some(CROSS), Some(color), Some(pers));
}
CROSS | VER | VER_L | VER_R => grid.set_opt(column * 2, i, None, new_col, new_pers),
L_D | L_U => {
grid.set_opt(column * 2, i, Some(VER_L), new_col, new_pers);
}
R_D | R_U => {
grid.set_opt(column * 2, i, Some(VER_R), new_col, new_pers);
}
_ => {
grid.set_opt(column * 2, i, Some(VER), new_col, new_pers);
}
}
}
}
/// Draws a horizontal line
fn hline(
grid: &mut Grid,
index: usize,
(from, to): (usize, usize),
merge: bool,
color: u8,
pers: u8,
) {
if from == to {
return;
}
let from_2 = from * 2;
let to_2 = to * 2;
if from < to {
for column in (from_2 + 1)..to_2 {
if merge && column == to_2 - 1 {
grid.set(column, index, ARR_R, color, pers);
} else {
let (curr, _, old_pers) = grid.get_tuple(column, index);
let (new_col, new_pers) = if pers < old_pers {
(Some(color), Some(pers))
} else {
(None, None)
};
match curr {
DOT | CIRCLE => {}
VER => grid.set_opt(column, index, Some(CROSS), None, None),
HOR | CROSS | HOR_U | HOR_D => {
grid.set_opt(column, index, None, new_col, new_pers)
}
L_U | R_U => grid.set_opt(column, index, Some(HOR_U), new_col, new_pers),
L_D | R_D => grid.set_opt(column, index, Some(HOR_D), new_col, new_pers),
_ => {
grid.set_opt(column, index, Some(HOR), new_col, new_pers);
}
}
}
}
let (left, _, old_pers) = grid.get_tuple(from_2, index);
let (new_col, new_pers) = if pers < old_pers {
(Some(color), Some(pers))
} else {
(None, None)
};
match left {
DOT | CIRCLE => {}
VER => grid.set_opt(from_2, index, Some(VER_R), new_col, new_pers),
VER_L => grid.set_opt(from_2, index, Some(CROSS), None, None),
VER_R => {}
HOR | L_U => grid.set_opt(from_2, index, Some(HOR_U), new_col, new_pers),
_ => {
grid.set_opt(from_2, index, Some(R_D), new_col, new_pers);
}
}
let (right, _, old_pers) = grid.get_tuple(to_2, index);
let (new_col, new_pers) = if pers < old_pers {
(Some(color), Some(pers))
} else {
(None, None)
};
match right {
DOT | CIRCLE => {}
VER => grid.set_opt(to_2, index, Some(VER_L), None, None),
VER_L | HOR_U => grid.set_opt(to_2, index, None, new_col, new_pers),
HOR | R_U => grid.set_opt(to_2, index, Some(HOR_U), new_col, new_pers),
_ => {
grid.set_opt(to_2, index, Some(L_U), new_col, new_pers);
}
}
} else {
for column in (to_2 + 1)..from_2 {
if merge && column == to_2 + 1 {
grid.set(column, index, ARR_L, color, pers);
} else {
let (curr, _, old_pers) = grid.get_tuple(column, index);
let (new_col, new_pers) = if pers < old_pers {
(Some(color), Some(pers))
} else {
(None, None)
};
match curr {
DOT | CIRCLE => {}
VER => grid.set_opt(column, index, Some(CROSS), None, None),
HOR | CROSS | HOR_U | HOR_D => {
grid.set_opt(column, index, None, new_col, new_pers)
}
L_U | R_U => grid.set_opt(column, index, Some(HOR_U), new_col, new_pers),
L_D | R_D => grid.set_opt(column, index, Some(HOR_D), new_col, new_pers),
_ => {
grid.set_opt(column, index, Some(HOR), new_col, new_pers);
}
}
}
}
let (left, _, old_pers) = grid.get_tuple(to_2, index);
let (new_col, new_pers) = if pers < old_pers {
(Some(color), Some(pers))
} else {
(None, None)
};
match left {
DOT | CIRCLE => {}
VER => grid.set_opt(to_2, index, Some(VER_R), None, None),
VER_R => grid.set_opt(to_2, index, None, new_col, new_pers),
HOR | L_U => grid.set_opt(to_2, index, Some(HOR_U), new_col, new_pers),
_ => {
grid.set_opt(to_2, index, Some(R_U), new_col, new_pers);
}
}
let (right, _, old_pers) = grid.get_tuple(from_2, index);
let (new_col, new_pers) = if pers < old_pers {
(Some(color), Some(pers))
} else {
(None, None)
};
match right {
DOT | CIRCLE => {}
VER => grid.set_opt(from_2, index, Some(VER_L), new_col, new_pers),
VER_R => grid.set_opt(from_2, index, Some(CROSS), None, None),
VER_L => grid.set_opt(from_2, index, None, new_col, new_pers),
HOR | R_D => grid.set_opt(from_2, index, Some(HOR_D), new_col, new_pers),
_ => {
grid.set_opt(from_2, index, Some(L_D), new_col, new_pers);
}
}
}
}
/// Calculates required additional rows
fn get_inserts(graph: &GitGraph, compact: bool) -> HashMap<usize, Vec<Vec<Occ>>> {
let mut inserts: HashMap<usize, Vec<Vec<Occ>>> = HashMap::new();
for (idx, info) in graph.commits.iter().enumerate() {
let column = graph.all_branches[info.branch_trace.unwrap()]
.visual
.column
.unwrap();
inserts.insert(idx, vec![vec![Occ::Commit(idx, column)]]);
}
for (idx, info) in graph.commits.iter().enumerate() {
if let Some(trace) = info.branch_trace {
let branch = &graph.all_branches[trace];
let column = branch.visual.column.unwrap();
for p in 0..2 {
if let Some(par_oid) = info.parents[p] {
if let Some(par_idx) = graph.indices.get(&par_oid) {
let par_info = &graph.commits[*par_idx];
let par_branch = &graph.all_branches[par_info.branch_trace.unwrap()];
let par_column = par_branch.visual.column.unwrap();
let column_range = sorted(column, par_column);
if column != par_column {
let split_index = super::get_deviate_index(graph, idx, *par_idx);
match inserts.entry(split_index) {
Occupied(mut entry) => {
let mut insert_at = entry.get().len();
for (insert_idx, sub_entry) in entry.get().iter().enumerate() {
let mut occ = false;
for other_range in sub_entry {
if other_range.overlaps(&column_range) {
match other_range {
Occ::Commit(target_index, _) => {
if !compact
|| !info.is_merge
|| idx != *target_index
|| p == 0
{
occ = true;
break;
}
}
Occ::Range(o_idx, o_par_idx, _, _) => {
if idx != *o_idx && par_idx != o_par_idx {
occ = true;
break;
}
}
}
}
}
if !occ {
insert_at = insert_idx;
break;
}
}
let vec = entry.get_mut();
if insert_at == vec.len() {
vec.push(vec![Occ::Range(
idx,
*par_idx,
column_range.0,
column_range.1,
)]);
} else {
vec[insert_at].push(Occ::Range(
idx,
*par_idx,
column_range.0,
column_range.1,
));
}
}
Vacant(entry) => {
entry.insert(vec![vec![Occ::Range(
idx,
*par_idx,
column_range.0,
column_range.1,
)]]);
}
}
}
}
}
}
}
}
inserts
}
/// Creates the complete graph visualization, incl. formatter commits.
fn print_graph(
characters: &Characters,
grid: &Grid,
text_lines: Vec<Option<String>>,
color: bool,
) -> (Vec<String>, Vec<String>) {
let mut g_lines = vec![];
let mut t_lines = vec![];
for (row, line) in grid.data.chunks(grid.width).zip(text_lines.into_iter()) {
let mut g_out = String::new();
let mut t_out = String::new();
if color {
for arr in row {
if arr[0] == SPACE {
write!(g_out, "{}", characters.chars[arr[0] as usize])
} else {
write!(
g_out,
"{}",
Paint::fixed(arr[1], characters.chars[arr[0] as usize])
)
}
.unwrap();
}
} else {
let str = row
.iter()
.map(|arr| characters.chars[arr[0] as usize])
.collect::<String>();
write!(g_out, "{}", str).unwrap();
}
if let Some(line) = line {
write!(t_out, "{}", line).unwrap();
}
g_lines.push(g_out);
t_lines.push(t_out);
}
(g_lines, t_lines)
}
/// Format a commit.
fn format(
format: &CommitFormat,
graph: &GitGraph,
info: &CommitInfo,
head: Option<&HeadInfo>,
color: bool,
wrapping: &Option<Options>,
) -> Result<Vec<String>, String> {
let commit = graph
.repository
.find_commit(info.oid)
.map_err(|err| err.message().to_string())?;
let branch_str = format_branches(graph, info, head, color);
let hash_color = if color { Some(HASH_COLOR) } else { None };
crate::print::format::format(&commit, branch_str, wrapping, hash_color, format)
}
/// Format branches and tags.
pub fn format_branches(
graph: &GitGraph,
info: &CommitInfo,
head: Option<&HeadInfo>,
color: bool,
) -> String {
let curr_color = info
.branch_trace
.map(|branch_idx| &graph.all_branches[branch_idx].visual.term_color);
let mut branch_str = String::new();
let head_str = "HEAD ->";
if let Some(head) = head {
if !head.is_branch {
if color {
write!(branch_str, " {}", Paint::fixed(HEAD_COLOR, head_str))
} else {
write!(branch_str, " {}", head_str)
}
.unwrap();
}
}
if !info.branches.is_empty() {
write!(branch_str, " (").unwrap();
let branches = info.branches.iter().sorted_by_key(|br| {
if let Some(head) = head {
head.name != graph.all_branches[**br].name
} else {
false
}
});
for (idx, branch_index) in branches.enumerate() {
let branch = &graph.all_branches[*branch_index];
let branch_color = branch.visual.term_color;
if let Some(head) = head {
if idx == 0 && head.is_branch {
if color {
write!(branch_str, "{} ", Paint::fixed(14, head_str))
} else {
write!(branch_str, "{} ", head_str)
}
.unwrap();
}
}
if color {
write!(branch_str, "{}", Paint::fixed(branch_color, &branch.name))
} else {
write!(branch_str, "{}", &branch.name)
}
.unwrap();
if idx < info.branches.len() - 1 {
write!(branch_str, ", ").unwrap();
}
}
write!(branch_str, ")").unwrap();
}
if !info.tags.is_empty() {
write!(branch_str, " [").unwrap();
for (idx, tag_index) in info.tags.iter().enumerate() {
let tag = &graph.all_branches[*tag_index];
let tag_color = curr_color.unwrap_or(&tag.visual.term_color);
if color {
write!(branch_str, "{}", Paint::fixed(*tag_color, &tag.name[5..]))
} else {
write!(branch_str, "{}", &tag.name[5..])
}
.unwrap();
if idx < info.tags.len() - 1 {
write!(branch_str, ", ").unwrap();
}
}
write!(branch_str, "]").unwrap();
}
branch_str
}
/// Occupied row ranges
enum Occ {
Commit(usize, usize),
Range(usize, usize, usize, usize),
}
impl Occ {
fn overlaps(&self, (start, end): &(usize, usize)) -> bool {
match self {
Occ::Commit(_, col) => start <= col && end >= col,
Occ::Range(_, _, s, e) => s <= end && e >= start,
}
}
}
/// Sorts two numbers in ascending order
fn sorted(v1: usize, v2: usize) -> (usize, usize) {
if v2 > v1 {
(v1, v2)
} else {
(v2, v1)
}
}
/// Two-dimensional grid with 3 layers, used to produce the graph representation.
#[allow(dead_code)]
struct Grid {
width: usize,
height: usize,
data: Vec<[u8; 3]>,
}
impl Grid {
pub fn new(width: usize, height: usize, initial: [u8; 3]) -> Self {
Grid {
width,
height,
data: vec![initial; width * height],
}
}
pub fn reverse(&mut self) {
self.data.reverse();
}
pub fn index(&self, x: usize, y: usize) -> usize {
y * self.width + x
}
pub fn get_tuple(&self, x: usize, y: usize) -> (u8, u8, u8) {
let v = self.data[self.index(x, y)];
(v[0], v[1], v[2])
}
pub fn set(&mut self, x: usize, y: usize, character: u8, color: u8, pers: u8) {
let idx = self.index(x, y);
self.data[idx] = [character, color, pers];
}
pub fn set_opt(
&mut self,
x: usize,
y: usize,
character: Option<u8>,
color: Option<u8>,
pers: Option<u8>,
) {
let idx = self.index(x, y);
let arr = &mut self.data[idx];
if let Some(character) = character {
arr[0] = character;
}
if let Some(color) = color {
arr[1] = color;
}
if let Some(pers) = pers {
arr[2] = pers;
}
}
}

353
src/settings.rs Normal file
View file

@ -0,0 +1,353 @@
//! Graph generation settings.
use crate::print::format::CommitFormat;
use regex::{Error, Regex};
use serde_derive::{Deserialize, Serialize};
use std::str::FromStr;
/// Repository settings for the branching model.
/// Used to read repo's git-graph.toml
#[derive(Serialize, Deserialize)]
pub struct RepoSettings {
/// The repository's branching model
pub model: String,
}
/// Ordering policy for branches in visual columns.
pub enum BranchOrder {
/// Recommended! Shortest branches are inserted left-most.
///
/// For branches with equal length, branches ending last are inserted first.
/// Reverse (arg = false): Branches ending first are inserted first.
ShortestFirst(bool),
/// Longest branches are inserted left-most.
///
/// For branches with equal length, branches ending last are inserted first.
/// Reverse (arg = false): Branches ending first are inserted first.
LongestFirst(bool),
}
/// Top-level settings
pub struct Settings {
/// Reverse the order of commits
pub reverse_commit_order: bool,
/// Debug printing and drawing
pub debug: bool,
/// Compact text-based graph
pub compact: bool,
/// Colored text-based graph
pub colored: bool,
/// Include remote branches?
pub include_remote: bool,
/// Formatting for commits
pub format: CommitFormat,
/// Text wrapping options
pub wrapping: Option<(Option<usize>, Option<usize>, Option<usize>)>,
/// Characters to use for text-based graph
pub characters: Characters,
/// Branch column sorting algorithm
pub branch_order: BranchOrder,
/// Settings for branches
pub branches: BranchSettings,
/// Regex patterns for finding branch names in merge commit summaries
pub merge_patterns: MergePatterns,
}
/// Helper for reading BranchSettings, required due to RegEx.
#[derive(Serialize, Deserialize)]
pub struct BranchSettingsDef {
/// Branch persistence
pub persistence: Vec<String>,
/// Branch ordering
pub order: Vec<String>,
/// Branch colors
pub terminal_colors: ColorsDef,
/// Branch colors for SVG output
pub svg_colors: ColorsDef,
}
/// Helper for reading branch colors, required due to RegEx.
#[derive(Serialize, Deserialize)]
pub struct ColorsDef {
matches: Vec<(String, Vec<String>)>,
unknown: Vec<String>,
}
impl BranchSettingsDef {
/// The Git-Flow model.
pub fn git_flow() -> Self {
BranchSettingsDef {
persistence: vec![
r"^(master|main)$".to_string(),
r"^(develop|dev)$".to_string(),
r"^feature.*$".to_string(),
r"^release.*$".to_string(),
r"^hotfix.*$".to_string(),
r"^bugfix.*$".to_string(),
],
order: vec![
r"^(master|main)$".to_string(),
r"^(hotfix|release).*$".to_string(),
r"^(develop|dev)$".to_string(),
],
terminal_colors: ColorsDef {
matches: vec![
(
r"^(master|main)$".to_string(),
vec!["bright_blue".to_string()],
),
(
r"^(develop|dev)$".to_string(),
vec!["bright_yellow".to_string()],
),
(
r"^(feature|fork/).*$".to_string(),
vec!["bright_magenta".to_string(), "bright_cyan".to_string()],
),
(r"^release.*$".to_string(), vec!["bright_green".to_string()]),
(
r"^(bugfix|hotfix).*$".to_string(),
vec!["bright_red".to_string()],
),
(r"^tags/.*$".to_string(), vec!["bright_green".to_string()]),
],
unknown: vec!["white".to_string()],
},
svg_colors: ColorsDef {
matches: vec![
(r"^(master|main)$".to_string(), vec!["blue".to_string()]),
(r"^(develop|dev)$".to_string(), vec!["orange".to_string()]),
(
r"^(feature|fork/).*$".to_string(),
vec!["purple".to_string(), "turquoise".to_string()],
),
(r"^release.*$".to_string(), vec!["green".to_string()]),
(r"^(bugfix|hotfix).*$".to_string(), vec!["red".to_string()]),
(r"^tags/.*$".to_string(), vec!["green".to_string()]),
],
unknown: vec!["gray".to_string()],
},
}
}
/// Simple feature-based model.
pub fn simple() -> Self {
BranchSettingsDef {
persistence: vec![r"^(master|main)$".to_string()],
order: vec![r"^tags/.*$".to_string(), r"^(master|main)$".to_string()],
terminal_colors: ColorsDef {
matches: vec![
(
r"^(master|main)$".to_string(),
vec!["bright_blue".to_string()],
),
(r"^tags/.*$".to_string(), vec!["bright_green".to_string()]),
],
unknown: vec![
"bright_yellow".to_string(),
"bright_green".to_string(),
"bright_red".to_string(),
"bright_magenta".to_string(),
"bright_cyan".to_string(),
],
},
svg_colors: ColorsDef {
matches: vec![
(r"^(master|main)$".to_string(), vec!["blue".to_string()]),
(r"^tags/.*$".to_string(), vec!["green".to_string()]),
],
unknown: vec![
"orange".to_string(),
"green".to_string(),
"red".to_string(),
"purple".to_string(),
"turquoise".to_string(),
],
},
}
}
/// Very simple model without any defined branch roles.
pub fn none() -> Self {
BranchSettingsDef {
persistence: vec![],
order: vec![],
terminal_colors: ColorsDef {
matches: vec![],
unknown: vec![
"bright_blue".to_string(),
"bright_yellow".to_string(),
"bright_green".to_string(),
"bright_red".to_string(),
"bright_magenta".to_string(),
"bright_cyan".to_string(),
],
},
svg_colors: ColorsDef {
matches: vec![],
unknown: vec![
"blue".to_string(),
"orange".to_string(),
"green".to_string(),
"red".to_string(),
"purple".to_string(),
"turquoise".to_string(),
],
},
}
}
}
/// Settings defining branching models
pub struct BranchSettings {
/// Branch persistence
pub persistence: Vec<Regex>,
/// Branch ordering
pub order: Vec<Regex>,
/// Branch colors
pub terminal_colors: Vec<(Regex, Vec<String>)>,
/// Colors for branches not matching any of `colors`
pub terminal_colors_unknown: Vec<String>,
/// Branch colors for SVG output
pub svg_colors: Vec<(Regex, Vec<String>)>,
/// Colors for branches not matching any of `colors` for SVG output
pub svg_colors_unknown: Vec<String>,
}
impl BranchSettings {
pub fn from(def: BranchSettingsDef) -> Result<Self, Error> {
let persistence = def
.persistence
.iter()
.map(|str| Regex::new(str))
.collect::<Result<Vec<_>, Error>>()?;
let order = def
.order
.iter()
.map(|str| Regex::new(str))
.collect::<Result<Vec<_>, Error>>()?;
let terminal_colors = def
.terminal_colors
.matches
.into_iter()
.map(|(str, vec)| Regex::new(&str).map(|re| (re, vec)))
.collect::<Result<Vec<_>, Error>>()?;
let terminal_colors_unknown = def.terminal_colors.unknown;
let svg_colors = def
.svg_colors
.matches
.into_iter()
.map(|(str, vec)| Regex::new(&str).map(|re| (re, vec)))
.collect::<Result<Vec<_>, Error>>()?;
let svg_colors_unknown = def.svg_colors.unknown;
Ok(BranchSettings {
persistence,
order,
terminal_colors,
terminal_colors_unknown,
svg_colors,
svg_colors_unknown,
})
}
}
/// RegEx patterns for extracting branch names from merge commit summaries.
pub struct MergePatterns {
/// The patterns. Evaluated in the given order.
pub patterns: Vec<Regex>,
}
impl Default for MergePatterns {
fn default() -> Self {
MergePatterns {
patterns: vec![
// GitLab pull request
Regex::new(r"^Merge branch '(.+)' into '.+'$").unwrap(),
// Git default
Regex::new(r"^Merge branch '(.+)' into .+$").unwrap(),
// Git default into main branch
Regex::new(r"^Merge branch '(.+)'$").unwrap(),
// GitHub pull request
Regex::new(r"^Merge pull request #[0-9]+ from .[^/]+/(.+)$").unwrap(),
// GitHub pull request (from fork?)
Regex::new(r"^Merge branch '(.+)' of .+$").unwrap(),
// BitBucket pull request
Regex::new(r"^Merged in (.+) \(pull request #[0-9]+\)$").unwrap(),
],
}
}
}
/// The characters used for drawing text-based graphs.
pub struct Characters {
pub chars: Vec<char>,
}
impl FromStr for Characters {
type Err = String;
fn from_str(str: &str) -> Result<Self, Self::Err> {
match str {
"normal" | "thin" | "n" | "t" => Ok(Characters::thin()),
"round" | "r" => Ok(Characters::round()),
"bold" | "b" => Ok(Characters::bold()),
"double" | "d" => Ok(Characters::double()),
"ascii" | "a" => Ok(Characters::ascii()),
_ => Err(format!("Unknown characters/style '{}'. Must be one of [normal|thin|round|bold|double|ascii]", str)),
}
}
}
impl Characters {
/// Default/thin graphs
pub fn thin() -> Self {
Characters {
chars: " ●○│─┼└┌┐┘┤├┴┬<>".chars().collect(),
}
}
/// Graphs with rounded corners
pub fn round() -> Self {
Characters {
chars: " ●○│─┼╰╭╮╯┤├┴┬<>".chars().collect(),
}
}
/// Bold/fat graphs
pub fn bold() -> Self {
Characters {
chars: " ●○┃━╋┗┏┓┛┫┣┻┳<>".chars().collect(),
}
}
/// Double-lined graphs
pub fn double() -> Self {
Characters {
chars: " ●○║═╬╚╔╗╝╣╠╩╦<>".chars().collect(),
}
}
/// ASCII-only graphs
pub fn ascii() -> Self {
Characters {
chars: " *o|-+'..'||++<>".chars().collect(),
}
}
pub fn reverse(self) -> Self {
let mut chars = self.chars;
chars.swap(6, 8);
chars.swap(7, 9);
chars.swap(10, 11);
chars.swap(12, 13);
chars.swap(14, 15);
Characters { chars }
}
}