mod cli;
mod commands;
mod config;
mod runner;
mod subprocess_utils;
use std::path::Path;
use anyhow::{Context, Result};
use clap::Parser;
use cli::{
BranchWhat, BuildWhat, CleanWhat, Cli, Commands, CountWhat, ResetWhat, StashWhat, TagWhat,
};
use config::AppConfig;
fn main() -> Result<()> {
let cli = Cli::parse();
if let Commands::Complete { shell } = &cli.command {
cli::print_completions(*shell);
return Ok(());
}
if matches!(&cli.command, Commands::Version) {
println!(
"rsmultigit {} by {}",
env!("CARGO_PKG_VERSION"),
env!("CARGO_PKG_AUTHORS")
);
println!("GIT_DESCRIBE: {}", env!("GIT_DESCRIBE"));
println!("GIT_SHA: {}", env!("GIT_SHA"));
println!("GIT_BRANCH: {}", env!("GIT_BRANCH"));
println!("GIT_DIRTY: {}", env!("GIT_DIRTY"));
println!("RUSTC_SEMVER: {}", env!("RUSTC_SEMVER"));
println!("RUST_EDITION: {}", env!("RUST_EDITION"));
println!("BUILD_TIMESTAMP: {}", env!("BUILD_TIMESTAMP"));
return Ok(());
}
if matches!(&cli.command, Commands::ConfigExample) {
print!("{}", include_str!("../assets/config-example.toml"));
return Ok(());
}
let config = AppConfig::from(&cli);
let config_path = commands::check::default_config_path()?;
let file_config = commands::check::load_config(&config_path)?;
let projects = commands::check::resolve_repos(&file_config)?;
if let Commands::CheckSame {
checks,
checks_re,
only_failed,
diff,
copy,
allow_empty,
fix_missing,
} = &cli.command
{
let exit_code = run_check_same(
&config,
&file_config,
&projects,
&CheckSameOpts {
requested: checks,
requested_re: checks_re,
only_failed: *only_failed,
show_diff: *diff,
do_copy: *copy,
allow_empty: *allow_empty,
do_fix_missing: *fix_missing,
},
)?;
std::process::exit(exit_code);
}
match &cli.command {
Commands::Count { what } => {
let test_fn: fn(&Path) -> anyhow::Result<bool> = match what {
CountWhat::Dirty => commands::count::is_dirty,
CountWhat::Untracked => commands::count::has_untracked,
CountWhat::Synchronized => commands::count::non_synchronized,
};
runner::do_count(&config, &projects, test_fn)?;
}
Commands::Status => {
if config.verbose {
runner::print_if_data(&config, &projects, commands::status::do_status)?;
} else {
runner::print_if_data(&config, &projects, commands::status::do_status_summary)?;
}
}
Commands::Dirty => {
runner::print_if_data(&config, &projects, commands::status::do_dirty)?;
}
Commands::ListChecks => {
for rule in &file_config.check {
println!("{}", rule.name);
}
}
Commands::ListRepos => {
for project in &projects {
if config.verbose && !config.terse && !config.no_header {
println!("[{}]", project.display());
}
println!("{}", project.display());
}
}
Commands::Age => {
runner::print_if_data(&config, &projects, commands::age::do_age)?;
}
Commands::Authors => {
runner::print_if_data(&config, &projects, commands::authors::do_authors)?;
}
Commands::Config { key } => {
let key = key.clone();
runner::print_if_data(&config, &projects, move |project: &Path| {
commands::config::do_config(project, &key)
})?;
}
Commands::Size => {
runner::print_if_data(&config, &projects, commands::size::do_size)?;
}
Commands::LastTag => {
runner::print_if_data(&config, &projects, commands::last_tag::do_last_tag)?;
}
Commands::Branch { what } => {
let branch_fn: fn(&Path) -> anyhow::Result<bool> = match what {
BranchWhat::Local => commands::branch::branch_local,
BranchWhat::Remote => commands::branch::branch_remote,
BranchWhat::Github => commands::branch::branch_github,
};
runner::do_for_all_projects(&config, &projects, branch_fn)?;
}
Commands::Pull { quiet } => {
let quiet = *quiet;
runner::do_for_all_projects(
&config,
&projects,
move |project: &Path| -> anyhow::Result<bool> {
commands::pull::do_pull(project, quiet)
},
)?;
}
Commands::Push => {
runner::do_for_all_projects(&config, &projects, commands::push::do_push)?;
}
Commands::Fetch => {
runner::do_for_all_projects(&config, &projects, commands::fetch::do_fetch)?;
}
Commands::Clean { what } => {
let clean_fn: fn(&Path) -> anyhow::Result<bool> = match what {
CleanWhat::Hard => commands::clean::clean_hard,
CleanWhat::Soft => commands::clean::clean_soft,
CleanWhat::Make => commands::clean::clean_make,
CleanWhat::Git => commands::clean::clean_git,
CleanWhat::Cargo => commands::clean::clean_cargo,
};
runner::do_for_all_projects(&config, &projects, clean_fn)?;
}
Commands::Stash { what } => {
let stash_fn: fn(&Path) -> anyhow::Result<bool> = match what {
StashWhat::Push => commands::stash::stash_push,
StashWhat::Pop => commands::stash::stash_pop,
};
runner::do_for_all_projects(&config, &projects, stash_fn)?;
}
Commands::Reset { what } => {
let reset_fn: fn(&Path) -> anyhow::Result<bool> = match what {
ResetWhat::Hard => commands::reset::reset_hard,
ResetWhat::Soft => commands::reset::reset_soft,
ResetWhat::Mixed => commands::reset::reset_mixed,
};
runner::do_for_all_projects(&config, &projects, reset_fn)?;
}
Commands::Diff => {
runner::do_for_all_projects(&config, &projects, commands::diff::do_diff)?;
}
Commands::Log { count } => {
let count = *count;
runner::do_for_all_projects(
&config,
&projects,
move |project: &Path| -> anyhow::Result<bool> {
commands::log::do_log(project, count)
},
)?;
}
Commands::Tag { what } => match what {
TagWhat::Local | TagWhat::Remote => {
let tag_fn: fn(&Path) -> anyhow::Result<bool> = match what {
TagWhat::Local => commands::tag::tag_local,
TagWhat::Remote => commands::tag::tag_remote,
_ => unreachable!(),
};
runner::do_for_all_projects(&config, &projects, tag_fn)?;
}
TagWhat::HasLocal | TagWhat::HasRemote => {
let test_fn: fn(&Path) -> anyhow::Result<bool> = match what {
TagWhat::HasLocal => commands::tag::tag_has_local,
TagWhat::HasRemote => commands::tag::tag_has_remote,
_ => unreachable!(),
};
runner::do_count(&config, &projects, test_fn)?;
}
},
Commands::Remote => {
runner::do_for_all_projects(&config, &projects, commands::remote::do_remote)?;
}
Commands::Prune => {
runner::do_for_all_projects(&config, &projects, commands::prune::do_prune)?;
}
Commands::Gc => {
runner::do_for_all_projects(&config, &projects, commands::gc::do_gc)?;
}
Commands::Checkout { branch } => {
let branch = branch.clone();
runner::do_for_all_projects(
&config,
&projects,
move |project: &Path| -> anyhow::Result<bool> {
commands::checkout::do_checkout(project, &branch)
},
)?;
}
Commands::Commit { message } => {
let message = message.clone();
runner::do_for_all_projects(
&config,
&projects,
move |project: &Path| -> anyhow::Result<bool> {
commands::commit::do_commit(project, &message)
},
)?;
}
Commands::SubmoduleUpdate => {
runner::do_for_all_projects(&config, &projects, commands::submodule::submodule_update)?;
}
Commands::Blame { file } => {
let file = file.clone();
runner::do_for_all_projects(
&config,
&projects,
move |project: &Path| -> anyhow::Result<bool> {
commands::blame::do_blame(project, &file)
},
)?;
}
Commands::Grep { regexp, files } => {
let regexp = regexp.clone();
let files = *files;
runner::do_for_all_projects(
&config,
&projects,
move |project: &Path| -> anyhow::Result<bool> {
commands::grep::do_grep(project, ®exp, files)
},
)?;
}
Commands::Run { command } => {
let command = command.clone();
runner::do_for_all_projects(
&config,
&projects,
move |project: &Path| -> anyhow::Result<bool> {
commands::run::do_run(project, &command)
},
)?;
}
Commands::Build { what } => {
type ProjectFn = fn(&Path) -> anyhow::Result<bool>;
let (check_fn, build_fn): (ProjectFn, ProjectFn) = match what {
BuildWhat::Bootstrap => (
commands::build::check_not_disabled,
commands::build::build_bootstrap,
),
BuildWhat::Pydmt => (commands::build::check_pydmt, commands::build::build_pydmt),
BuildWhat::Make => (
commands::build::check_not_disabled,
commands::build::build_make,
),
BuildWhat::VenvMake => (
commands::build::check_not_disabled,
commands::build::build_venv_make,
),
BuildWhat::VenvPydmt => (
commands::build::check_pydmt,
commands::build::build_venv_pydmt,
),
BuildWhat::PydmtBuildVenv => (
commands::build::check_pydmt,
commands::build::build_pydmt_build_venv,
),
BuildWhat::Rsconstruct => (
commands::build::check_rsconstruct,
commands::build::build_rsconstruct,
),
BuildWhat::Cargo => (commands::build::check_cargo, commands::build::build_cargo),
BuildWhat::CargoPublish => (
commands::build::check_cargo,
commands::build::build_cargo_publish,
),
};
runner::do_for_all_projects_with_check(&config, &projects, check_fn, build_fn)?;
}
Commands::CheckSame { .. } => unreachable!("handled above"),
Commands::Complete { .. } => unreachable!("handled above"),
Commands::ConfigExample => unreachable!("handled above"),
Commands::Version => unreachable!("handled above"),
}
Ok(())
}
struct CheckSameOpts<'a> {
requested: &'a [String],
requested_re: &'a [String],
only_failed: bool,
show_diff: bool,
do_copy: bool,
allow_empty: bool,
do_fix_missing: bool,
}
fn run_check_same(
app: &AppConfig,
file_config: &commands::check::CheckConfig,
projects: &[std::path::PathBuf],
opts: &CheckSameOpts<'_>,
) -> Result<i32> {
use commands::check;
use commands::interactive;
let &CheckSameOpts {
requested,
requested_re,
only_failed,
show_diff,
do_copy,
allow_empty,
do_fix_missing,
} = opts;
let rules: Vec<&check::Rule> = if requested.is_empty() && requested_re.is_empty() {
file_config.check.iter().filter(|r| r.enabled).collect()
} else {
let known: std::collections::HashSet<&str> =
file_config.check.iter().map(|r| r.name.as_str()).collect();
let unknown: Vec<&String> = requested
.iter()
.filter(|name| !known.contains(name.as_str()))
.collect();
if !unknown.is_empty() {
let joined = unknown
.iter()
.map(|s| format!("{s:?}"))
.collect::<Vec<_>>()
.join(", ");
anyhow::bail!("unknown check name(s): {joined}");
}
let mut selected: Vec<&check::Rule> = requested
.iter()
.filter_map(|name| file_config.check.iter().find(|r| &r.name == name))
.collect();
for pattern in requested_re {
let re = regex_lite::Regex::new(pattern)
.with_context(|| format!("invalid check regex {pattern:?}"))?;
let mut matched_any = false;
for rule in &file_config.check {
if re.is_match(&rule.name) {
matched_any = true;
if !selected.iter().any(|r| r.name == rule.name) {
selected.push(rule);
}
}
}
if !matched_any {
anyhow::bail!("check regex {pattern:?} matches no check name");
}
}
selected
};
if rules.is_empty() {
if app.verbose {
println!("no rules to check");
}
return Ok(0);
}
let stdin = std::io::stdin();
let stdout = std::io::stdout();
let mut any_mismatch = false;
let mut quit_requested = false;
for rule in rules {
if quit_requested {
break;
}
let result = check::evaluate_rule(rule, projects)?;
if result.matched_nothing() && !allow_empty {
any_mismatch = true;
if app.terse {
println!("{}", result.name);
continue;
}
if !app.no_header {
println!("[{}]", result.name);
}
let suffix = if result.skipped.is_empty() {
String::new()
} else {
format!(" ({} skipped)", result.skipped.len())
};
println!("no files matched{suffix}");
continue;
}
if result.is_consistent() {
if !only_failed && !app.terse {
if !app.no_header {
println!("[{}]", result.name);
}
println!("ok ({} files)", result.total_files);
}
continue;
}
any_mismatch = true;
if app.terse {
println!("{}", result.name);
continue;
}
if !app.no_header {
println!("[{}]", result.name);
}
let mut suffix_parts: Vec<String> = Vec::new();
if !result.must_have_violations.is_empty() {
suffix_parts.push(format!("{} missing", result.must_have_violations.len()));
}
if !result.skipped.is_empty() {
suffix_parts.push(format!("{} skipped", result.skipped.len()));
}
let suffix = if suffix_parts.is_empty() {
String::new()
} else {
format!(" ({})", suffix_parts.join(", "))
};
println!(
"{} files, {} groups{suffix}",
result.total_files,
result.groups.len(),
);
if !app.no_output {
for (i, group) in result.groups.iter().enumerate() {
let label = interactive::group_label(i);
println!(" group {label} ({} files):", group.len());
for file in group {
println!(" {}", file.display());
}
}
if !result.must_have_violations.is_empty() {
println!(" missing in:");
for repo in &result.must_have_violations {
println!(" {}", repo.display());
}
}
if show_diff {
match run_diff(&result, &mut stdin.lock(), &mut stdout.lock())? {
FlowControl::Quit => {
quit_requested = true;
continue;
}
FlowControl::Continue => {}
}
}
if do_copy {
match run_copy(&result, &mut stdin.lock(), &mut stdout.lock())? {
FlowControl::Quit => {
quit_requested = true;
continue;
}
FlowControl::Continue => {}
}
}
if do_fix_missing && !result.must_have_violations.is_empty() {
match run_fix_missing(&result, &mut stdin.lock(), &mut stdout.lock())? {
FlowControl::Quit => {
quit_requested = true;
continue;
}
FlowControl::Continue => {}
}
}
}
}
if do_copy || do_fix_missing {
Ok(0)
} else {
Ok(if any_mismatch { 1 } else { 0 })
}
}
#[derive(Debug, PartialEq, Eq)]
enum FlowControl {
Continue,
Quit,
}
fn run_diff<R: std::io::BufRead, W: std::io::Write>(
result: &commands::check::RuleResult,
reader: &mut R,
writer: &mut W,
) -> Result<FlowControl> {
use commands::interactive::{Choice, confirm, pick_group};
let n = result.groups.len();
if n == 2 {
emit_pair_diff(result, 0, 1, writer);
return Ok(FlowControl::Continue);
}
loop {
let from = match pick_group(&mut *reader, &mut *writer, "diff from group?", n, None)? {
Choice::Value(i) => i,
Choice::Skip => return Ok(FlowControl::Continue),
Choice::Quit => return Ok(FlowControl::Quit),
};
let to = match pick_group(&mut *reader, &mut *writer, "diff to group?", n, Some(from))? {
Choice::Value(i) => i,
Choice::Skip => return Ok(FlowControl::Continue),
Choice::Quit => return Ok(FlowControl::Quit),
};
emit_pair_diff(result, from, to, writer);
if !confirm(&mut *reader, &mut *writer, "diff another pair?")? {
return Ok(FlowControl::Continue);
}
}
}
fn emit_pair_diff<W: std::io::Write>(
result: &commands::check::RuleResult,
a_idx: usize,
b_idx: usize,
writer: &mut W,
) {
let a = &result.groups[a_idx][0];
let b = &result.groups[b_idx][0];
let a_bytes = match std::fs::read(a) {
Ok(b) => b,
Err(e) => {
let _ = writeln!(writer, " (could not read {}: {e})", a.display());
return;
}
};
let b_bytes = match std::fs::read(b) {
Ok(b) => b,
Err(e) => {
let _ = writeln!(writer, " (could not read {}: {e})", b.display());
return;
}
};
let (a_text, b_text) = match (std::str::from_utf8(&a_bytes), std::str::from_utf8(&b_bytes)) {
(Ok(a), Ok(b)) => (a, b),
_ => {
let _ = writeln!(writer, " (binary files differ, not shown)");
return;
}
};
let diff = similar::TextDiff::from_lines(a_text, b_text);
let _ = write!(
writer,
"{}",
diff.unified_diff()
.context_radius(3)
.header(&a.display().to_string(), &b.display().to_string())
);
}
fn run_copy<R: std::io::BufRead, W: std::io::Write>(
result: &commands::check::RuleResult,
reader: &mut R,
writer: &mut W,
) -> Result<FlowControl> {
use commands::interactive::{Choice, confirm, group_label, pick_group};
let n = result.groups.len();
let from = match pick_group(&mut *reader, &mut *writer, "copy from group?", n, None)? {
Choice::Value(i) => i,
Choice::Skip => return Ok(FlowControl::Continue),
Choice::Quit => return Ok(FlowControl::Quit),
};
let to = match pick_group(&mut *reader, &mut *writer, "copy to group?", n, Some(from))? {
Choice::Value(i) => i,
Choice::Skip => return Ok(FlowControl::Continue),
Choice::Quit => return Ok(FlowControl::Quit),
};
let src = &result.groups[from][0];
let dst_group = &result.groups[to];
let prompt = format!(
"overwrite {} file(s) in group {} with content from {}?",
dst_group.len(),
group_label(to),
src.display()
);
if !confirm(&mut *reader, &mut *writer, &prompt)? {
let _ = writeln!(writer, " (skipped)");
return Ok(FlowControl::Continue);
}
for dst in dst_group {
if let Err(e) = copy_preserving_mode(src, dst) {
let _ = writeln!(
writer,
" error: {} -> {}: {e}",
src.display(),
dst.display()
);
} else {
let _ = writeln!(writer, " copied -> {}", dst.display());
}
}
Ok(FlowControl::Continue)
}
fn copy_preserving_mode(src: &std::path::Path, dst: &std::path::Path) -> Result<()> {
let original_mode = std::fs::metadata(dst)
.with_context(|| format!("failed to stat {}", dst.display()))?
.permissions();
std::fs::copy(src, dst)
.with_context(|| format!("failed to copy {} -> {}", src.display(), dst.display()))?;
std::fs::set_permissions(dst, original_mode)
.with_context(|| format!("failed to restore permissions on {}", dst.display()))?;
Ok(())
}
fn run_fix_missing<R: std::io::BufRead, W: std::io::Write>(
result: &commands::check::RuleResult,
reader: &mut R,
writer: &mut W,
) -> Result<FlowControl> {
use commands::interactive::{Choice, confirm, pick_group};
if result.groups.is_empty() {
let _ = writeln!(
writer,
" (cannot --fix-missing: no repo has {} — nothing to seed from)",
result.path
);
return Ok(FlowControl::Continue);
}
let n = result.groups.len();
let from = match pick_group(
&mut *reader,
&mut *writer,
"seed missing files from which group?",
n,
None,
)? {
Choice::Value(i) => i,
Choice::Skip => return Ok(FlowControl::Continue),
Choice::Quit => return Ok(FlowControl::Quit),
};
let src = &result.groups[from][0];
let violators = &result.must_have_violations;
let prompt = format!(
"create {} file(s) using content from {}?",
violators.len(),
src.display()
);
if !confirm(&mut *reader, &mut *writer, &prompt)? {
let _ = writeln!(writer, " (skipped)");
return Ok(FlowControl::Continue);
}
for repo in violators {
let dst = repo.join(&result.path);
if let Some(parent) = dst.parent()
&& let Err(e) = std::fs::create_dir_all(parent)
{
let _ = writeln!(
writer,
" error: failed to create directory {}: {e}",
parent.display()
);
continue;
}
match std::fs::copy(src, &dst) {
Ok(_) => {
let _ = writeln!(writer, " created -> {}", dst.display());
}
Err(e) => {
let _ = writeln!(
writer,
" error: {} -> {}: {e}",
src.display(),
dst.display()
);
}
}
}
Ok(FlowControl::Continue)
}