macro_rules! out {
($($arg:tt)*) => {
$crate::emit(format_args!($($arg)*))
};
}
macro_rules! outln {
() => {
$crate::emit(format_args!("\n"))
};
($($arg:tt)*) => {
$crate::emit(format_args!("{}\n", format_args!($($arg)*)))
};
}
mod app;
mod commands;
mod git;
mod history;
mod tui;
use clap::{Arg, ArgAction, CommandFactory, FromArgMatches, Parser, Subcommand};
use std::ffi::{OsStr, OsString};
use std::process::Command;
const AFTER: &str = concat!(
"\
Ways to run it (not subcommands):
slu <git command> real git, passed straight through (`slu commit -m \"fix\"`, `slu push`)
slu <git options> <git command> git's options before the command reach git as typed (`slu --no-pager log`)
A passthrough is git itself, so the output and the exit code are git's own.
sluuz's own verbs print a table for people rather than data, except
`completions`, whose stdout is the script.
Run `slu <command> --help` for a command's details.",
"\n\n",
env!("CARGO_PKG_REPOSITORY"),
"\ncontributors: ",
env!("CARGO_PKG_AUTHORS"),
);
const LONG_VERSION: &str = concat!(
env!("CARGO_PKG_VERSION"),
"\n",
env!("CARGO_PKG_LICENSE"),
" ",
env!("CARGO_PKG_REPOSITORY"),
"\ncontributors: ",
env!("CARGO_PKG_AUTHORS"),
);
#[derive(Parser)]
#[command(
name = "sluuz",
bin_name = "slu",
version,
long_version = LONG_VERSION,
about,
after_help = AFTER,
arg_required_else_help = true
)]
struct Cli {
#[command(subcommand)]
command: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
#[command(verbatim_doc_comment)]
Search(commands::search::Args),
#[command(verbatim_doc_comment)]
Scan(commands::scan::Args),
#[command(verbatim_doc_comment)]
Repos(commands::repos::Args),
#[command(verbatim_doc_comment)]
Sync(commands::sync::Args),
#[command(verbatim_doc_comment)]
Tidy(commands::tidy::Args),
#[command(verbatim_doc_comment)]
Each(commands::each::Args),
#[command(verbatim_doc_comment)]
Trace(commands::trace::Args),
#[command(verbatim_doc_comment)]
Completions(commands::completions::Args),
#[command(name = "self", subcommand)]
Selfie(commands::selfcmd::Cmd),
#[command(verbatim_doc_comment)]
Iscan(commands::iscan::Args),
#[command(verbatim_doc_comment)]
Irepos(commands::irepos::Args),
#[command(verbatim_doc_comment)]
Ibranch(commands::ibranch::Args),
#[command(verbatim_doc_comment)]
Itag(commands::itag::Args),
#[command(verbatim_doc_comment)]
Iremote(commands::iremote::Args),
#[command(verbatim_doc_comment)]
Ilog(commands::ilog::Args),
#[command(verbatim_doc_comment)]
Istatus(commands::istatus::Args),
#[command(verbatim_doc_comment)]
Istash(commands::istash::Args),
#[command(external_subcommand)]
Git(Vec<String>),
}
fn main() {
let (dirs, globals, argv) = split_leading(std::env::args_os().collect());
for dir in &dirs {
enter(dir);
}
if !globals.is_empty() {
let rest = &argv[1..];
if let Some(name) = rest.first().and_then(|a| a.to_str()).filter(|n| is_own(n)) {
eprintln!(
"slu: `{}` is git's option and `{name}` is slu's own command; run `slu {name}` without it",
globals[0].to_string_lossy()
);
std::process::exit(2);
}
let mut args = globals;
args.extend_from_slice(rest);
exit_like_git(&args, rest);
}
let mut built = Cli::command();
built.build();
let template = help_template(&built);
let matches = Cli::command()
.help_template(template)
.arg(
Arg::new("dir")
.short('C')
.value_name("PATH")
.action(ArgAction::Append)
.help("Run as if slu was started in PATH; repeated, each is relative to the last"),
)
.get_matches_from(argv);
let cli = match Cli::from_arg_matches(&matches) {
Ok(cli) => cli,
Err(e) => e.exit(),
};
match cli.command {
Cmd::Search(args) => commands::search::run(args),
Cmd::Scan(args) => commands::scan::run(args),
Cmd::Repos(args) => commands::repos::run(args),
Cmd::Sync(args) => commands::sync::run(args),
Cmd::Tidy(args) => commands::tidy::run(args),
Cmd::Each(args) => commands::each::run(args),
Cmd::Trace(args) => commands::trace::run(args),
Cmd::Ilog(args) => commands::ilog::run(args),
Cmd::Irepos(args) => commands::irepos::run(args),
Cmd::Ibranch(args) => commands::ibranch::run(args),
Cmd::Itag(args) => commands::itag::run(args),
Cmd::Iremote(args) => commands::iremote::run(args),
Cmd::Iscan(args) => commands::iscan::run(args),
Cmd::Istatus(args) => commands::istatus::run(args),
Cmd::Istash(args) => commands::istash::run(args),
Cmd::Selfie(cmd) => commands::selfcmd::run(cmd),
Cmd::Completions(args) => commands::completions::run(args),
Cmd::Git(args) => passthrough(&args),
}
}
fn help_template(cmd: &clap::Command) -> String {
let subs: Vec<&clap::Command> = cmd.get_subcommands().filter(|c| !c.is_hide_set()).collect();
let width = subs
.iter()
.map(|c| c.get_name().chars().count())
.max()
.unwrap_or(0);
let (tui, plain): (Vec<&&clap::Command>, Vec<&&clap::Command>) =
subs.iter().partition(|c| is_interactive(c));
let styles = cmd.get_styles();
let header = styles.get_header();
let literal = styles.get_literal();
let mut list = String::new();
for (heading, group) in [("Commands:", plain), ("Interactive (TUI):", tui)] {
list.push_str(&format!("{header}{heading}{header:#}\n"));
for c in group {
list.push_str(&entry(c, width, literal));
}
list.push('\n');
}
format!(
"{{about-with-newline}}\n{{usage-heading}} {{usage}}\n\n{list}{header}Options:{header:#}\n{{options}}{{after-help}}\n"
)
}
fn entry(cmd: &clap::Command, width: usize, literal: &clap::builder::styling::Style) -> String {
let about = cmd.get_about().map(ToString::to_string).unwrap_or_default();
let mut lines = about.lines();
let name = cmd.get_name();
let pad = " ".repeat(width - name.chars().count());
let mut row = format!(
" {literal}{name}{literal:#}{pad} {}\n",
lines.next().unwrap_or("")
);
for line in lines {
row.push_str(&format!(" {:<width$} {line}\n", ""));
}
row
}
fn is_interactive(cmd: &clap::Command) -> bool {
cmd.get_name().starts_with('i')
}
fn split_leading(mut argv: Vec<OsString>) -> (Vec<OsString>, Vec<OsString>, Vec<OsString>) {
let (mut dirs, mut globals) = (Vec::new(), Vec::new());
while let Some(arg) = argv.get(1).and_then(|a| a.to_str()) {
if !arg.starts_with('-') || matches!(arg, "-h" | "--help" | "-V" | "--version") {
break;
}
if arg == "-C" {
let Some(dir) = argv.get(2) else { break };
if !dir.is_empty() {
dirs.push(dir.clone());
}
argv.drain(1..3);
continue;
}
let takes_next = matches!(
arg,
"-c" | "--git-dir" | "--work-tree" | "--namespace" | "--config-env" | "--attr-source"
);
let end = (2 + usize::from(takes_next)).min(argv.len());
globals.extend(argv.drain(1..end));
}
(dirs, globals, argv)
}
fn enter(dir: &OsStr) {
if let Err(e) = std::env::set_current_dir(dir) {
let why = match e.kind() {
std::io::ErrorKind::NotFound => "there is no such directory",
std::io::ErrorKind::NotADirectory => "it is not a directory",
std::io::ErrorKind::PermissionDenied => "permission denied",
_ => "it could not be entered",
};
eprintln!(
"slu: cannot run in `{}`: {why}; check the path after `-C`",
dir.to_string_lossy()
);
std::process::exit(128);
}
}
fn is_own(name: &str) -> bool {
Cli::command()
.get_subcommands()
.any(|c| c.get_name() == name || c.get_all_aliases().any(|a| a == name))
}
fn passthrough(args: &[String]) -> ! {
let args: Vec<OsString> = args.iter().map(OsString::from).collect();
exit_like_git(&args, &args);
}
fn exit_like_git(args: &[OsString], command: &[OsString]) -> ! {
let code = git_status(args);
if code != 0 && command.get(1).is_some_and(|a| a == "--help") {
eprintln!(
"slu: no manual to open, so this is `git {} -h`; install git's man pages for the full one",
command[0].to_string_lossy()
);
let options = &args[..args.len() - command.len()];
let mut usage = options.to_vec();
usage.extend([command[0].clone(), OsString::from("-h")]);
std::process::exit(git_status(&usage));
}
std::process::exit(code);
}
fn git_status(args: &[OsString]) -> i32 {
match Command::new("git").args(args).status() {
Ok(status) => status.code().unwrap_or(1),
Err(e) => {
eprintln!("slu: could not run git: {e}");
std::process::exit(127);
}
}
}
fn emit(args: std::fmt::Arguments) {
use std::io::Write;
match std::io::stdout().lock().write_fmt(args) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => std::process::exit(0),
Err(_) => {
eprintln!("error: could not write the output; is the disk it goes to full?");
std::process::exit(1);
}
}
}
#[cfg(test)]
mod tests {
use super::{Cli, help_template};
use clap::CommandFactory;
#[test]
fn every_interactive_command_is_listed_under_its_own_heading() {
let mut built = Cli::command();
built.build();
let rendered = help_template(&built);
let (plain, interactive) = rendered
.split_once("Interactive (TUI):")
.expect("the list is split into two groups");
let literal = built.get_styles().get_literal();
for sub in built.get_subcommands() {
let name = sub.get_name();
let row = format!("\n {literal}{name}{literal:#}");
let (half, group) = if name.starts_with('i') {
(interactive, "interactive")
} else {
(plain, "plain")
};
assert!(
half.contains(&row),
"`{name}` is missing from the {group} group"
);
}
}
}