use clap::{crate_version, Arg, Command};
use crossterm::cursor::MoveToRow;
use crossterm::event::{Event, KeyCode, KeyModifiers};
use crossterm::style::Print;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode, Clear, ClearType};
use crossterm::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, Error};
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),
)
.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)
)
.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 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 dot = ".".to_string();
let path = matches.get_one::<String>("path").unwrap_or(&dot);
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()))?;
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)?;
eprint!("Branching model set to '{}'", model);
}
};
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::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()
})
};
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, None, 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)?;
let use_pager =
pager
&& atty::is(atty::Stream::Stdout)
&& (crossterm::terminal::size().unwrap().1 as usize) < g_lines.len() + 1;
if use_pager {
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(())
}
fn print_paged(graph_lines: &[String], text_lines: &[String]) -> Result<(), Error> {
let (width, height) = crossterm::terminal::size()?;
let mut start_idx: usize = 0;
let mut should_update: bool = true;
let visible_lines: usize = height as usize - 1;
let help = "\r >>> Down/Up: line, PgDown/Enter: page, End: all, Esc/Q/^C: quit\r";
let help = if help.len() > width as usize {
&help[0..width as usize]
} else {
help
};
enable_raw_mode()?;
loop {
if should_update {
should_update = false;
start_idx = start_idx.min(graph_lines.len().saturating_sub(visible_lines));
stdout()
.execute(MoveToRow(0))?
.execute(Clear(ClearType::CurrentLine))?;
let content_len = visible_lines.min(graph_lines.len());
for curr_idx in 0..content_len {
stdout()
.execute(Clear(ClearType::CurrentLine))?
.execute(Print(format!(
" {} {}\r\n",
graph_lines[start_idx + curr_idx],
text_lines[start_idx + curr_idx]
)))?;
}
if content_len < visible_lines {
break;
}
stdout().execute(Print(help))?;
} else {
let input = crossterm::event::read()?;
if let Event::Key(evt) = input {
match evt.code {
KeyCode::Down => {
start_idx += 1;
should_update = true;
}
KeyCode::Up => {
if start_idx > 0 {
start_idx -= 1;
should_update = true;
}
}
KeyCode::Enter | KeyCode::PageDown => {
start_idx += height as usize - 2;
should_update = true;
}
KeyCode::End => {
start_idx = graph_lines.len() - height as usize - 2;
should_update = true;
}
KeyCode::Char(c) => match c {
'q' => {
break;
}
'c' if evt.modifiers == KeyModifiers::CONTROL => {
break;
}
_ => {}
},
KeyCode::Esc => {
break;
}
_ => {}
}
}
}
}
disable_raw_mode()?;
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);
}
}