mod apply;
mod checks;
mod fix;
mod scan;
mod severities;
mod shim;
mod skips;
mod tui;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
fn shown(p: &Path) -> String {
amont_runtime::ui::sanitize_path(p)
}
const USAGE: &str = "\
usage: amont-fleet [scan|tui|fix|install|uninstall] [--root <dir>] [--depth <n>] [--json]
scan report the fleet (default)
tui the interactive dashboard
install turn hooks on across the root
uninstall take OUR shims back out — never a hook somebody else wrote,
and never hook.skip or amont.severity
fix show what would be changed — DRY RUN unless --apply
fix --apply carry out the plan
--root <dir> where to look for repositories (default: $HOME/Developer)
--depth <n> directory levels to descend (default: 6)
--binary <p> the binary shims should point at (default: $HOME/.local/bin/amont)
--agents-md with fix/install: also roll out the AGENTS.md pointer
--remove-unrecognized
ALSO delete pre-commit-* / pre-push-* files this tool did not
write. Off by default, and read the sentence below first.
--json emit the result as JSON
install and fix --apply never delete a hook they did not write: a
pre-commit-* or pre-push-* file without our marker is reported and left
exactly where it is.
";
#[derive(PartialEq)]
enum Mode {
Scan,
Fix,
Tui,
Install,
Uninstall,
}
struct Args {
mode: Mode,
root: PathBuf,
depth: usize,
json: bool,
apply: bool,
binary: Option<String>,
agents_md: bool,
remove_unrecognized: bool,
}
fn parse(argv: &[String], home: Option<&Path>) -> Result<Args, String> {
let mut mode = Mode::Scan;
let mut root: Option<PathBuf> = None;
let mut depth = 6;
let mut json = false;
let mut apply = false;
let mut binary: Option<String> = None;
let mut agents_md = false;
let mut remove_unrecognized = false;
let mut it = argv.iter().peekable();
if let Some(first) = it.peek() {
match first.as_str() {
"scan" => {
mode = Mode::Scan;
it.next();
}
"fix" => {
mode = Mode::Fix;
it.next();
}
"tui" => {
mode = Mode::Tui;
it.next();
}
"install" => {
mode = Mode::Install;
apply = true;
it.next();
}
"uninstall" => {
mode = Mode::Uninstall;
apply = true;
it.next();
}
_ => {}
}
}
while let Some(arg) = it.next() {
match arg.as_str() {
"--json" => json = true,
"--root" => {
root = Some(it.next().ok_or("--root needs a directory")?.into());
}
"--depth" => {
let v = it.next().ok_or("--depth needs a number")?;
depth = v
.parse()
.map_err(|_| format!("--depth: {v:?} is not a number"))?;
}
"--apply" => apply = true,
"--agents-md" => agents_md = true,
"--remove-unrecognized" => remove_unrecognized = true,
"--binary" => {
binary = Some(it.next().ok_or("--binary needs a path")?.clone());
}
"-h" | "--help" => return Err(String::new()),
other => return Err(format!("unknown argument {other:?}")),
}
}
let root = match root {
Some(r) => r,
None => default_root(home).ok_or_else(|| {
"no --root given and $HOME is not set — refusing to guess \
(the alternative is silently scanning, and `fix --apply`ing, \
from wherever this happened to be launched)"
.to_string()
})?,
};
Ok(Args {
mode,
root,
depth,
json,
apply,
binary,
agents_md,
remove_unrecognized,
})
}
fn home() -> Option<PathBuf> {
std::env::var_os("HOME").map(PathBuf::from)
}
fn default_binary(home: Option<&Path>) -> Option<String> {
home.map(|h| h.join(".local/bin/amont").to_string_lossy().into_owned())
}
fn default_root(home: Option<&Path>) -> Option<PathBuf> {
home.map(|h| h.join("Developer"))
}
fn main() -> ExitCode {
let argv: Vec<String> = std::env::args().skip(1).collect();
let args = match parse(&argv, home().as_deref()) {
Ok(a) => a,
Err(e) => {
if !e.is_empty() {
eprintln!("amont-fleet: {e}");
}
eprint!("{USAGE}");
return ExitCode::from(2);
}
};
if !args.root.is_dir() {
eprintln!(
"amont-fleet: --root {} is not a directory",
args.root.display()
);
return ExitCode::from(2);
}
let started = std::time::Instant::now();
let installed = match args
.binary
.clone()
.or_else(|| default_binary(home().as_deref()))
{
Some(b) => b,
None => {
eprintln!(
"amont-fleet: no --binary given and $HOME is not set — \
refusing to guess which binary the shims should point at"
);
return ExitCode::from(2);
}
};
if args.mode == Mode::Tui {
return match tui::run(args.root.clone(), args.depth, installed.clone()) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("amont-fleet: {e}");
ExitCode::from(1)
}
};
}
let scan = scan::scan(&args.root, args.depth, &installed);
let elapsed = started.elapsed();
if args.mode == Mode::Uninstall {
let mut removed = 0usize;
let mut repos = 0usize;
let mut left: Vec<(PathBuf, amont_runtime::hookfile::Refuse)> = Vec::new();
let mut failed: Vec<(PathBuf, std::io::Error)> = Vec::new();
for repo in scan.repos.iter().filter(|r| r.managed) {
let Some(hooks) = repo.hooks_dir.inside() else {
continue;
};
let mut result = uninstall_repo(hooks);
if !result.removed.is_empty() {
repos += 1;
removed += result.removed.len();
println!(" {} {} shims", shown(&repo.path), result.removed.len());
}
left.append(&mut result.left);
failed.append(&mut result.failed);
}
println!("{removed} shims removed from {repos} repositories");
if !left.is_empty() {
println!();
println!("left alone (not ours):");
for (_, why) in &left {
println!(" {}", amont_runtime::ui::sanitize(&why.explain()));
}
}
if !failed.is_empty() {
println!();
println!("FAILED to remove:");
for (path, e) in &failed {
println!(
" {}: {}",
shown(path),
amont_runtime::ui::sanitize(&e.to_string())
);
}
}
println!("hook.skip and amont.severity were left alone.");
return if failed.is_empty() {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
};
}
if args.mode == Mode::Fix || args.mode == Mode::Install {
let plans: Vec<fix::FixPlan> = scan
.repos
.iter()
.map(|r| {
let intent = if args.mode == Mode::Install {
fix::Intent::Activate
} else {
fix::Intent::Repair
};
fix::plan(
r,
&args.root.join(&r.path),
&installed,
intent,
args.agents_md,
args.remove_unrecognized,
)
})
.collect();
if args.apply {
let reports: Vec<apply::ApplyReport> = plans
.iter()
.map(|p| apply::ApplyReport {
repo: p.repo.clone(),
outcome: apply::apply(p),
})
.collect();
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&reports).unwrap_or_default()
);
} else {
report_apply(&reports, &plans);
}
let failed = reports
.iter()
.any(|r| matches!(r.outcome, apply::Outcome::Failed { .. }));
return if failed {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
};
}
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&plans).unwrap_or_default()
);
} else {
report_fix(&plans);
}
return if scan.looks_like_a_failed_scan() {
ExitCode::from(1)
} else {
ExitCode::SUCCESS
};
}
if args.json {
match serde_json::to_string_pretty(&scan) {
Ok(s) => println!("{s}"),
Err(e) => {
eprintln!("amont-fleet: cannot serialise scan: {e}");
return ExitCode::from(1);
}
}
} else {
report(&scan, elapsed);
}
if scan.looks_like_a_failed_scan() {
return ExitCode::from(1);
}
ExitCode::SUCCESS
}
struct RepoUninstall {
removed: Vec<PathBuf>,
left: Vec<(PathBuf, amont_runtime::hookfile::Refuse)>,
failed: Vec<(PathBuf, std::io::Error)>,
}
fn uninstall_repo(hooks: &Path) -> RepoUninstall {
let mut out = RepoUninstall {
removed: Vec::new(),
left: Vec::new(),
failed: Vec::new(),
};
for name in amont_runtime::install::DISPATCHERS {
let path = hooks.join(name);
if amont_runtime::hookfile::classify(&path) == amont_runtime::hookfile::HookFile::Absent {
continue;
}
match amont_runtime::hookfile::guard_remove(&path, true) {
Err(refuse) => out.left.push((path, refuse)),
Ok(()) => match amont_runtime::hookfile::remove_regular(&path) {
Ok(()) => out.removed.push(path),
Err(e) => out.failed.push((path, e)),
},
}
}
out
}
fn report(s: &scan::FleetScan, elapsed: std::time::Duration) {
if s.looks_like_a_failed_scan() {
println!("No repositories found under {}", s.root.display());
println!();
println!(
"Visited {} directories in {:.1}s and found 0 git repositories.",
s.dirs_visited,
elapsed.as_secs_f64()
);
println!("This is a SCAN FAILURE, not a clean fleet.");
println!(
" • is --root correct? (currently: {})",
s.root.display()
);
println!(" • is --depth deep enough? (currently: {})", s.depth);
if !s.unreadable.is_empty() {
println!(" • {} path(s) could not be read", s.unreadable.len());
}
return;
}
println!("{}", s.root.display());
println!(
" {} git repositories · {} managed · {} unmanaged",
s.git_dirs_found, s.managed_seen, s.unmanaged_seen
);
println!(
" {} hook directories · {} directories visited · {} subtrees skipped · {:.1}s",
s.hook_dirs_seen,
s.dirs_visited,
s.excluded_dirs,
elapsed.as_secs_f64()
);
if !s.unreadable.is_empty() {
println!(" {} unreadable:", s.unreadable.len());
for p in s.unreadable.iter().take(5) {
println!(" {}", shown(p));
}
}
}
fn report_fix(plans: &[fix::FixPlan]) {
let total = plans.len();
let refused: Vec<&fix::FixPlan> = plans.iter().filter(|p| p.refused()).collect();
let acting: Vec<&fix::FixPlan> = plans
.iter()
.filter(|p| !p.refused() && !p.is_noop())
.collect();
for p in &acting {
println!("{}", shown(&p.repo));
for r in &p.remove {
println!(" rm {} ({:?})", shown(&r.path), r.reason);
}
for w in p.write.iter().filter(|w| w.changes) {
println!(" write {}", shown(&w.path));
}
if let Some(w) = &p.write_agents_md {
println!(" write {}", shown(&w.path));
}
}
report_warnings(plans);
println!();
println!(
" {} of {} repositories would change · {} refused · {} already correct",
acting.len(),
total,
refused.len(),
total - acting.len() - refused.len()
);
println!(
" {} removals · {} writes",
acting.iter().map(|p| p.remove.len()).sum::<usize>(),
acting
.iter()
.map(|p| p.write.iter().filter(|w| w.changes).count()
+ usize::from(p.write_agents_md.is_some()))
.sum::<usize>()
);
println!();
println!(" DRY RUN — nothing was written.");
}
fn report_refusals(plans: &[fix::FixPlan]) {
let refused: Vec<&fix::FixPlan> = plans.iter().filter(|p| p.refused()).collect();
if refused.is_empty() {
return;
}
let interesting: Vec<&fix::FixPlan> = refused
.iter()
.copied()
.filter(|p| p.refuse.iter().any(|r| *r != fix::Refusal::Unmanaged))
.collect();
if interesting.is_empty() {
return;
}
println!();
println!("REFUSED (nothing in these repositories was touched):");
for p in interesting {
println!(" {}", shown(&p.repo));
for r in p.refuse.iter().filter(|r| **r != fix::Refusal::Unmanaged) {
println!(" {}", amont_runtime::ui::sanitize(&r.explain()));
}
}
}
fn report_warnings(plans: &[fix::FixPlan]) {
report_refusals(plans);
let warned: Vec<&fix::FixPlan> = plans.iter().filter(|p| !p.warn.is_empty()).collect();
if warned.is_empty() {
return;
}
println!();
println!("LEFT ALONE (not ours — nothing here is deleted or written):");
for p in warned {
for w in &p.warn {
match w {
fix::Warning::UnrecognizedSubHook { path } => {
println!(" {} (a hook we did not write)", shown(path));
}
fix::Warning::HooksDirOutsideRepo { path } => {
println!(
" {} ({}: core.hooksPath points OUTSIDE the repository)",
shown(path),
shown(&p.repo)
);
}
}
}
}
println!(" (pass --remove-unrecognized to delete the sub-hooks above)");
}
fn report_apply(reports: &[apply::ApplyReport], plans: &[fix::FixPlan]) {
let mut applied = 0usize;
let (mut removed, mut written, mut refused, mut unchanged) = (0usize, 0usize, 0usize, 0usize);
for r in reports {
match &r.outcome {
apply::Outcome::Applied {
removed: rm,
written: wr,
} => {
applied += 1;
removed += rm;
written += wr;
println!("{} -{rm} +{wr}", shown(&r.repo));
}
apply::Outcome::Refused => refused += 1,
apply::Outcome::Unchanged => unchanged += 1,
apply::Outcome::Failed { error, at } => {
println!(
"{} FAILED at {at}: {}",
shown(&r.repo),
amont_runtime::ui::sanitize(error)
);
}
}
}
let failed = reports
.iter()
.filter(|r| matches!(r.outcome, apply::Outcome::Failed { .. }))
.count();
report_warnings(plans);
println!();
println!(
" {applied} of {} repositories changed · {refused} refused · {unchanged} already correct · {failed} failed",
reports.len()
);
println!(" {removed} removed · {written} written");
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_flags_and_defaults() {
let home = Path::new("/home/x");
let a = parse(&[], Some(home)).expect("defaults");
assert_eq!(a.depth, 6);
assert!(!a.json);
assert_eq!(a.root, home.join("Developer"));
let a = parse(&["--depth".into(), "2".into(), "--json".into()], Some(home)).unwrap();
assert_eq!(a.depth, 2);
assert!(a.json);
}
#[test]
fn deleting_other_peoples_hooks_is_off_unless_asked_for() {
let home = Some(Path::new("/home/x"));
assert!(!parse(&[], home).unwrap().remove_unrecognized);
assert!(
!parse(&["fix".into(), "--apply".into()], home)
.unwrap()
.remove_unrecognized
);
assert!(
!parse(&["install".into()], home)
.unwrap()
.remove_unrecognized
);
assert!(
parse(&["fix".into(), "--remove-unrecognized".into()], home)
.unwrap()
.remove_unrecognized
);
assert!(USAGE.contains("--remove-unrecognized"), "{USAGE}");
assert!(
USAGE.contains("never delete a hook they did not write"),
"{USAGE}"
);
}
#[test]
fn rejects_bad_input_loudly() {
let home = Some(Path::new("/home/x"));
assert!(parse(&["--depth".into(), "lots".into()], home).is_err());
assert!(parse(&["--depth".into()], home).is_err());
assert!(parse(&["--nope".into()], home).is_err());
}
#[test]
fn no_home_and_no_root_is_refused_not_guessed() {
assert!(
parse(&[], None).is_err(),
"must refuse rather than default to the current directory"
);
}
#[test]
fn an_explicit_root_needs_no_home() {
let a = parse(&["--root".into(), "/somewhere".into()], None).expect("explicit --root");
assert_eq!(a.root, PathBuf::from("/somewhere"));
}
#[test]
fn default_root_and_binary_need_home() {
assert_eq!(
default_root(None),
None,
"no directory to guess without $HOME"
);
assert_eq!(
default_binary(None),
None,
"no binary path to guess without $HOME"
);
let home = Path::new("/home/x");
assert_eq!(default_root(Some(home)), Some(home.join("Developer")));
assert_eq!(
default_binary(Some(home)),
Some(home.join(".local/bin/amont").to_string_lossy().into_owned())
);
}
}