mod apply;
mod bypasses;
mod checks;
mod fix;
mod progress;
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 when it exists)
--depth <n> directory levels to descend (default: 6)
--binary <p> the binary shims should point at (default: the amont on PATH, else $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 no ~/Developer to fall back to — refusing \
to guess (the alternative is silently scanning, and `fix \
--apply`ing, from wherever this happened to be launched). \
Say where the fleet lives: --root <dir>"
.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 amont_on_path() -> Option<String> {
let exe = if cfg!(windows) { "amont.exe" } else { "amont" };
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(exe))
.find(|candidate| candidate.is_file())
.map(|candidate| candidate.to_string_lossy().into_owned())
}
fn default_binary(home: Option<&Path>, on_path: Option<String>) -> Option<String> {
on_path.or_else(|| 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")).filter(|d| d.is_dir())
}
#[cfg(unix)]
fn die_on_sigpipe() {
extern "C" {
fn signal(signum: i32, handler: usize) -> usize;
}
const SIGPIPE: i32 = 13;
const SIG_DFL: usize = 0;
unsafe {
signal(SIGPIPE, SIG_DFL);
}
}
#[cfg(not(unix))]
fn die_on_sigpipe() {}
fn main() -> ExitCode {
die_on_sigpipe();
let argv: Vec<String> = std::env::args().skip(1).collect();
if argv.iter().any(|a| a == "--help" || a == "-h") {
print!("{USAGE}");
return ExitCode::SUCCESS;
}
if argv.iter().any(|a| a == "--version" || a == "-V") {
println!("amont-fleet {}", env!("CARGO_PKG_VERSION"));
return ExitCode::SUCCESS;
}
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(), amont_on_path()))
{
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 mut bar = progress::Bar::start(&args.root, args.depth);
let scan = scan::scan(&args.root, args.depth, &installed, &mut |p| bar.update(p));
bar.finish();
let elapsed = started.elapsed();
if args.mode == Mode::Uninstall {
let mut removed = 0usize;
let mut repos = 0usize;
let mut forgotten = 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());
}
if !result.removed.is_empty() {
let gone =
amont_runtime::install::forget_bookkeeping_in(&args.root.join(&repo.path));
if !gone.is_empty() {
forgotten += 1;
println!(" forgot {}", gone.join(", "));
}
}
left.append(&mut result.left);
failed.append(&mut result.failed);
}
println!("{removed} shims removed from {repos} repositories");
if forgotten > 0 {
println!(
"amont's own bookkeeping (stamps, ledgers, trust) forgotten in \
{forgotten} repositories"
);
}
if !left.is_empty() {
println!();
println!("left alone (not ours):");
for (_, why) in &left {
println!(" {}", 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, as was any held work.");
return if failed.is_empty() {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
};
}
if args.mode == Mode::Fix || args.mode == Mode::Install {
let mut steps = progress::Steps::start("planning", scan.repos.len());
let plans: Vec<fix::FixPlan> = scan
.repos
.iter()
.map(|r| {
steps.step(&r.path);
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();
steps.finish();
if args.apply {
let mut reports: Vec<apply::ApplyReport> = Vec::with_capacity(plans.len());
let mut applying = progress::Steps::start("applying", plans.len());
for p in &plans {
applying.step(&p.repo);
let r = apply::ApplyReport {
repo: p.repo.clone(),
outcome: apply::apply(p),
};
if !args.json {
match &r.outcome {
apply::Outcome::Failed { .. } => applying.interrupt(&apply_line(&r)),
apply::Outcome::Applied { .. } if !applying.is_live() => {
use std::io::Write;
println!("{}", apply_line(&r));
let _ = std::io::stdout().flush();
}
_ => {}
}
}
reports.push(r);
}
applying.finish();
if args.json {
println!(
"{}",
serde_json::to_string_pretty(&reports).unwrap_or_default()
);
} else {
report_apply_summary(&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.bypassed_commits > 0 {
println!(
" {} unverified commits across {} repositories",
s.bypassed_commits, s.repos_with_bypasses
);
}
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;
}
let mut grouped: Vec<(String, Vec<String>)> = Vec::new();
let mut singles: Vec<&fix::FixPlan> = Vec::new();
for p in &interesting {
let redirected = p.refuse.iter().find_map(|r| match r {
fix::Refusal::HooksDirRedirected { path } => {
Some(amont_runtime::install::redirect_culprit(path).unwrap_or("another tool"))
}
_ => None,
});
let only = p
.refuse
.iter()
.filter(|r| **r != fix::Refusal::Unmanaged)
.count()
== 1;
match redirected {
Some(owner) if only => {
let owner = owner.to_string();
match grouped.iter_mut().find(|(o, _)| *o == owner) {
Some((_, repos)) => repos.push(shown(&p.repo)),
None => grouped.push((owner, vec![shown(&p.repo)])),
}
}
_ => singles.push(p),
}
}
println!();
for (owner, repos) in &grouped {
println!(
"{} {} refused — {owner} owns the hooks (core.hooksPath); nothing there was touched",
amont_runtime::ui::warning_sign(),
repos.len()
);
for line in wrap_list(repos, 92) {
println!(" {line}");
}
println!(" hand dispatch back, per repo: git config --unset core.hooksPath");
}
if !singles.is_empty() {
println!(
"{} {} refused (nothing in these repositories was touched):",
amont_runtime::ui::warning_sign(),
singles.len()
);
for p in singles {
println!(" {}", shown(&p.repo));
for r in p.refuse.iter().filter(|r| **r != fix::Refusal::Unmanaged) {
println!(" {}", r.explain());
}
}
}
}
fn wrap_list(names: &[String], width: usize) -> Vec<String> {
let mut lines: Vec<String> = Vec::new();
for name in names {
match lines.last_mut() {
Some(last) if last.chars().count() + 3 + name.chars().count() <= width => {
last.push_str(" · ");
last.push_str(name);
}
_ => lines.push(name.clone()),
}
}
lines
}
fn report_warnings(plans: &[fix::FixPlan]) {
report_refusals(plans);
let mut lines: Vec<String> = Vec::new();
let mut sub_hooks = 0usize;
for p in plans.iter().filter(|p| !p.warn.is_empty()) {
let refused_redirect = p
.refuse
.iter()
.any(|r| matches!(r, fix::Refusal::HooksDirRedirected { .. }));
for w in &p.warn {
match w {
fix::Warning::UnrecognizedSubHook { path } => {
sub_hooks += 1;
lines.push(format!(" {} (a hook we did not write)", shown(path)));
}
fix::Warning::HooksDirOutsideRepo { path } => {
lines.push(format!(
" {} ({}: core.hooksPath points OUTSIDE the repository)",
shown(path),
shown(&p.repo)
));
}
fix::Warning::HooksDirRedirected { path } => {
if refused_redirect {
continue;
}
let owner =
amont_runtime::install::redirect_culprit(path).unwrap_or("another tool");
lines.push(format!(
" {} ({}: core.hooksPath — {owner} owns the hooks, amont is not running)",
shown(path),
shown(&p.repo)
));
}
}
}
}
if lines.is_empty() {
return;
}
println!();
println!("LEFT ALONE (not ours — nothing here is deleted or written):");
for line in lines {
println!("{line}");
}
if sub_hooks > 0 {
println!(" (pass --remove-unrecognized to delete the sub-hooks above)");
}
}
fn apply_line(r: &apply::ApplyReport) -> String {
match &r.outcome {
apply::Outcome::Applied {
removed: rm,
written: wr,
} => format!("{} -{rm} +{wr}", shown(&r.repo)),
apply::Outcome::Failed { error, at } => format!(
"{} FAILED at {at}: {}",
shown(&r.repo),
amont_runtime::ui::sanitize(error)
),
apply::Outcome::Refused | apply::Outcome::Unchanged => String::new(),
}
}
fn report_apply_summary(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;
}
apply::Outcome::Refused => refused += 1,
apply::Outcome::Unchanged => unchanged += 1,
apply::Outcome::Failed { .. } => {}
}
}
let failures: Vec<&apply::ApplyReport> = reports
.iter()
.filter(|r| matches!(r.outcome, apply::Outcome::Failed { .. }))
.collect();
let failed = failures.len();
report_warnings(plans);
if !failures.is_empty() {
println!();
println!("{} {failed} failed:", amont_runtime::ui::error_sign());
for f in &failures {
if let apply::Outcome::Failed { error, at } = &f.outcome {
println!(" {}", shown(&f.repo));
let at_short = at
.split("/.git/")
.nth(1)
.map(|tail| format!(".git/{tail}"))
.unwrap_or_else(|| at.clone());
let reason = error
.strip_prefix(at.as_str())
.map_or(error.as_str(), |t| t.trim_start());
let reason = reason.strip_prefix("is ").unwrap_or(reason);
println!(
" at {}: {}",
amont_runtime::ui::sanitize(&at_short),
amont_runtime::ui::sanitize(reason)
);
}
}
}
let sign = if failed > 0 {
amont_runtime::ui::error_sign()
} else if refused > 0 {
amont_runtime::ui::warning_sign()
} else {
amont_runtime::ui::valid_sign()
};
println!();
println!(
"{sign} {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 wrap_list_packs_names_and_respects_the_width() {
let names: Vec<String> = (0..11).map(|i| format!("Perso/repo-{i:02}")).collect();
let lines = wrap_list(&names, 50);
assert!(lines.len() < names.len(), "{lines:?}");
for line in &lines {
assert!(line.chars().count() <= 50, "{line:?}");
}
let joined = lines.join(" · ");
for name in &names {
assert!(joined.contains(name.as_str()), "{name} lost");
}
}
#[test]
fn wrap_list_keeps_an_oversized_name() {
let names = vec!["a-name-well-beyond-any-reasonable-width-limit-for-a-line".to_string()];
assert_eq!(wrap_list(&names, 20), names);
}
#[test]
fn parses_flags_and_defaults() {
let home = std::env::temp_dir().join(format!("fleet-parse-{}", std::process::id()));
let _ = std::fs::create_dir_all(home.join("Developer"));
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);
let bare = Path::new("/home/x");
let Err(err) = parse(&[], Some(bare)) else {
panic!("a home without ~/Developer must be refused");
};
assert!(err.contains("--root"), "{err}");
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn deleting_other_peoples_hooks_is_off_unless_asked_for() {
let home = Some(Path::new("/home/x"));
let with_root = |mut v: Vec<String>| {
v.extend(["--root".to_string(), "/tmp".to_string()]);
v
};
assert!(!parse(&with_root(vec![]), home).unwrap().remove_unrecognized);
assert!(
!parse(&with_root(vec!["fix".into(), "--apply".into()]), home)
.unwrap()
.remove_unrecognized
);
assert!(
!parse(&with_root(vec!["install".into()]), home)
.unwrap()
.remove_unrecognized
);
assert!(
parse(
&with_root(vec!["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),
None,
"no binary path to guess without $HOME and nothing on PATH"
);
let home = Path::new("/home/x");
assert_eq!(default_root(Some(home)), None);
assert_eq!(
default_binary(Some(home), None),
Some(home.join(".local/bin/amont").to_string_lossy().into_owned())
);
}
#[test]
fn the_path_binary_outranks_the_install_default() {
let home = Path::new("/home/x");
assert_eq!(
default_binary(Some(home), Some("/opt/homebrew/bin/amont".into())),
Some("/opt/homebrew/bin/amont".into())
);
assert_eq!(
default_binary(None, Some("/usr/local/bin/amont".into())),
Some("/usr/local/bin/amont".into())
);
}
#[test]
fn an_existing_developer_dir_is_still_the_default_root() {
let fake_home = std::env::temp_dir().join(format!("fleet-home-{}", std::process::id()));
let _ = std::fs::create_dir_all(fake_home.join("Developer"));
assert_eq!(
default_root(Some(&fake_home)),
Some(fake_home.join("Developer"))
);
let _ = std::fs::remove_dir_all(&fake_home);
}
}