#![warn(clippy::cognitive_complexity)]
#![warn(clippy::too_many_lines)]
mod print;
use crate::print::svg::print_svg;
use clap::ArgMatches;
use clap::{crate_version, Arg, Command};
use git2::Repository;
use gleisbau::config::{create_config, get_available_models, get_model, get_model_name, set_model};
use gleisbau::get_repo;
use gleisbau::graph::Builder as GraphBuilder;
use gleisbau::print::format::CommitFormat;
use gleisbau::print::unicode::print_unicode;
use gleisbau::settings::{
BranchOrder, BranchSettings, BranchSettingsDef, Characters, MergePatterns, Settings,
};
use itertools::enumerate;
use platform_dirs::AppDirs;
use std::path::PathBuf;
use std::borrow::Borrow;
use std::rc::Rc;
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 mut ses = Session::new();
store_default_models(&mut ses)?;
let matches = match_args();
if !configure_session(&mut ses, &matches)? {
return Ok(()); };
run(
ses.repository.unwrap(),
Rc::new(ses.settings.unwrap()),
ses.svg,
ses.commit_limit,
ses.refspecs,
)
}
fn match_args() -> ArgMatches {
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",
);
let app = add_repo_args(app);
let app = add_model_args(app);
let app = add_commit_limit_args(app);
let app = add_color_args(app);
let app = add_wrap_args(app);
let app = add_format_args(app);
let app = app
.arg(
Arg::new("reverse")
.long("reverse")
.short('r')
.help("Reverse the order of commits.")
.required(false)
.num_args(0),
)
.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("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("refspecs")
.help(
"Branch names or refspecs to show.\n \
Only the subgraph between the merge-base and the tips is displayed.",
)
.num_args(1..),
);
app.get_matches()
}
fn configure_session(ses: &mut Session, matches: &ArgMatches) -> Result<bool, String> {
let exit_now = false;
let run_application = true;
if match_model_list(ses, matches)? {
return Ok(exit_now); }
match_repo_args(ses, matches)?;
if match_model_subcommand(ses, matches)? {
return Ok(exit_now); }
match_commit_limit_args(ses, matches)?;
let include_remote = !matches.get_flag("local");
let reverse_commit_order = matches.get_flag("reverse");
ses.svg = matches.get_flag("svg");
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 = match_model_opt(ses, matches)?;
let format = match_format_args(ses, matches)?;
let colored = match_color_args(ses, matches)?;
let wrapping = match_wrap_args(ses, matches)?;
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(),
};
ses.settings = Some(settings);
ses.refspecs = matches
.get_many::<String>("refspecs")
.map(|vals| vals.cloned().collect())
.unwrap_or_default();
Ok(run_application)
}
struct Session {
models_dir: PathBuf,
pub settings: Option<Settings>,
pub repository: Option<Repository>,
pub svg: bool,
pub commit_limit: Option<usize>,
pub refspecs: Vec<String>,
}
impl Session {
pub fn new() -> Self {
Self {
models_dir: PathBuf::new(),
settings: None,
repository: None,
svg: false,
commit_limit: None,
refspecs: Vec::new(),
}
}
}
fn add_repo_args(app: Command) -> Command {
app.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("skip-repo-owner-validation")
.long("skip-repo-owner-validation")
.help(
"Skip owner validation for the repository.\n\
This will turn off libgit2's owner validation, which may increase security risks.\n\
Please do not disable this validation for repositories you do not trust.",
)
.required(false)
.num_args(0),
)
}
fn match_repo_args(ses: &mut Session, matches: &ArgMatches) -> Result<(), String> {
let skip_repo_owner_validation = matches.get_flag("skip-repo-owner-validation");
if skip_repo_owner_validation {
println!("Warning: skip-repo-owner-validation is set! ");
}
let default_path = ".".to_string();
let path = matches.get_one::<String>("path").unwrap_or(&default_path);
let repository = get_repo(path, skip_repo_owner_validation)
.map_err(|err| format!("ERROR: {}\n Navigate into a repository before running git-graph, or use option --path", err.message()))?;
ses.repository = Some(repository);
Ok(())
}
fn store_default_models(ses: &mut Session) -> 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)?;
ses.models_dir = models_dir;
Ok(())
}
fn add_model_args(app: Command) -> Command {
app
.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),
)
.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),
))
}
fn match_model_list(ses: &mut Session, matches: &ArgMatches) -> Result<bool, String> {
if let Some(matches) = matches.subcommand_matches("model") {
if matches.get_flag("list") {
println!(
"{}",
itertools::join(get_available_models(&ses.models_dir)?, "\n")
);
return Ok(true);
}
}
Ok(false)
}
fn match_model_subcommand(ses: &mut Session, matches: &ArgMatches) -> Result<bool, String> {
if let Some(matches) = matches.subcommand_matches("model") {
let repository = ses.repository.as_ref().unwrap();
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, &ses.models_dir)?;
eprint!("Branching model set to '{}'", model);
}
};
return Ok(true);
}
Ok(false)
}
fn match_model_opt(ses: &mut Session, matches: &ArgMatches) -> Result<BranchSettingsDef, String> {
let model = get_model(
ses.repository.as_ref().unwrap(),
matches.get_one::<String>("model").map(|s| &s[..]),
REPO_CONFIG_FILE,
&ses.models_dir,
)?;
Ok(model)
}
fn add_commit_limit_args(app: Command) -> Command {
app.arg(
Arg::new("max-count")
.long("max-count")
.short('n')
.help("Maximum number of commits")
.required(false)
.num_args(1)
.value_name("n"),
)
}
fn match_commit_limit_args(ses: &mut Session, matches: &ArgMatches) -> Result<(), String> {
ses.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
])
}
},
};
Ok(())
}
fn add_color_args(app: Command) -> Command {
app.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),
)
}
fn match_color_args(_ses: &mut Session, matches: &ArgMatches) -> Result<bool, String> {
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::enable();
yansi::is_enabled()
})
}
"always" => {
if cfg!(windows) {
yansi::enable();
}
true
}
"never" => false,
other => {
return Err(format!(
"Unknown color mode '{}'. Supports [auto|always|never].",
other
))
}
}
} else {
atty::is(atty::Stream::Stdout)
&& (!cfg!(windows) || {
yansi::enable();
yansi::is_enabled()
})
};
Ok(colored)
}
fn add_wrap_args(app: Command) -> Command {
app.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),
)
}
type WrapType = Option<(Option<usize>, Option<usize>, Option<usize>)>;
fn match_wrap_args(_ses: &mut Session, matches: &ArgMatches) -> Result<WrapType, String> {
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)))
};
Ok(wrapping)
}
fn add_format_args(app: Command) -> Command {
app
.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),
)
}
fn match_format_args(_ses: &mut Session, matches: &ArgMatches) -> Result<CommitFormat, String> {
let format = match matches.get_one::<String>("format") {
None => CommitFormat::OneLine,
Some(str) => CommitFormat::from_str(str)?,
};
Ok(format)
}
fn run(
repository: Repository,
settings: Rc<Settings>,
svg: bool,
max_commits: Option<usize>,
refspecs: Vec<String>,
) -> Result<(), String> {
let now = Instant::now();
let mut graph_builder = GraphBuilder::new()
.with_repository(repository)
.with_settings(settings.clone())
.with_refspecs(refspecs);
if let Some(max_commits) = max_commits {
graph_builder = graph_builder.with_max_count(max_commits);
}
let graph = graph_builder.build()?;
let settings: &Settings = settings.borrow();
let duration_graph = now.elapsed().as_micros();
if settings.debug {
let tracks = &graph.tracks;
for (br_inx, branch) in enumerate(&tracks.all_branches) {
let Some(branch_vis) = graph.layout.track_visual(br_inx.into()) else {
eprintln!(
"#{} {} (col --) ({:?}) {} s: --, t: --",
br_inx,
branch.name,
branch.range,
if branch.is_merged { "m" } else { "" },
);
continue;
};
eprintln!(
"#{} {} (col {}) ({:?}) {} s: {:?}, t: {:?}",
br_inx,
branch.name,
branch_vis.column.unwrap_or(99),
branch.range,
if branch.is_merged { "m" } else { "" },
branch_vis.source_order_group,
branch_vis.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)?;
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.tracks.commits.len()
);
}
Ok(())
}
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);
}
}