Adding upstream version 0.6.0.
Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
parent
4de83856e9
commit
5b48f7aed6
21 changed files with 5187 additions and 0 deletions
153
src/config.rs
Normal file
153
src/config.rs
Normal 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
984
src/graph.rs
Normal 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
13
src/lib.rs
Normal 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
539
src/main.rs
Normal 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
45
src/print/colors.rs
Normal 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
548
src/print/format.rs
Normal 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
45
src/print/mod.rs
Normal 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
161
src/print/svg.rs
Normal 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
725
src/print/unicode.rs
Normal 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
353
src/settings.rs
Normal 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 }
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue