use std::ffi::OsString;
use std::path::PathBuf;
use amont_runtime::check::Stage;
use amont_runtime::{pushrefs, registry, ListOptions};
const USAGE: &str = "\
usage:
amont --hooks-dir <dir> <hook-name> [args…]
amont list [--json] [--stage pre-commit|pre-push] [--pushed]
amont setup [--local|--global] [--dry-run]
amont install [--force] | uninstall [--binary]
amont trust [--show|--revoke]
amont run [<check>] [--all-files] [--hooks-dir <dir>] | restore
amont agents-md [--check] [--path <file>]
amont --help
";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Sub {
List,
Setup,
Install,
Uninstall,
Run,
Trust,
Restore,
AgentsMd,
}
impl Sub {
#[cfg(test)]
const fn index(self) -> usize {
match self {
Sub::List => 0,
Sub::Setup => 1,
Sub::Install => 2,
Sub::Uninstall => 3,
Sub::Run => 4,
Sub::Trust => 5,
Sub::Restore => 6,
Sub::AgentsMd => 7,
}
}
}
const SUBCOMMANDS: [(&str, Sub); 8] = [
("list", Sub::List),
("setup", Sub::Setup),
("install", Sub::Install),
("uninstall", Sub::Uninstall),
("run", Sub::Run),
("trust", Sub::Trust),
("restore", Sub::Restore),
("agents-md", Sub::AgentsMd),
];
fn subcommand(arg: &str) -> Option<Sub> {
SUBCOMMANDS
.iter()
.find(|(name, _)| *name == arg)
.map(|(_, sub)| *sub)
}
#[derive(Debug, PartialEq, Eq)]
enum Invocation {
Sub {
name: Sub,
args: Vec<OsString>,
},
Hook {
hooks_dir: PathBuf,
name: String,
args: Vec<OsString>,
},
Help,
Usage(String),
}
fn parse(argv: Vec<OsString>) -> Invocation {
let Some(first) = argv.first() else {
return Invocation::Usage("no arguments".to_string());
};
if first == "--help" || first == "-h" {
return Invocation::Help;
}
if let Some(sub) = first.to_str().and_then(subcommand) {
return Invocation::Sub {
name: sub,
args: argv[1..].to_vec(),
};
}
let mut hooks_dir: Option<PathBuf> = None;
let mut name: Option<String> = None;
let mut args: Vec<OsString> = Vec::new();
let mut it = argv.into_iter();
while let Some(a) = it.next() {
if name.is_some() {
args.push(a);
continue;
}
if a == "--hooks-dir" {
let Some(v) = it.next() else {
return Invocation::Usage("--hooks-dir requires a value".to_string());
};
hooks_dir = Some(PathBuf::from(v));
continue;
}
let Some(s) = a.to_str() else {
return Invocation::Usage(format!("hook name is not valid UTF-8: {a:?}"));
};
name = Some(s.to_owned());
}
match (hooks_dir, name) {
(Some(hooks_dir), Some(name)) => Invocation::Hook {
hooks_dir,
name,
args,
},
(None, Some(name)) => Invocation::Usage(format!(
"{name:?} is not a subcommand, and hook mode needs --hooks-dir <dir> before the hook name"
)),
_ => Invocation::Usage("no hook name".to_string()),
}
}
fn main() {
match parse(std::env::args_os().skip(1).collect()) {
Invocation::Help => {
print!("{USAGE}");
std::process::exit(0);
}
Invocation::Usage(why) => {
eprintln!("amont: {why}");
eprint!("{USAGE}");
std::process::exit(2);
}
Invocation::Sub { name, args } => std::process::exit(run_sub(name, &args)),
Invocation::Hook {
hooks_dir,
name,
args,
} => std::process::exit(run_hook(&hooks_dir, &name, &args)),
}
}
fn run_sub(sub: Sub, args: &[OsString]) -> i32 {
match sub {
Sub::List => {
let stage = match stage_flag(args) {
Ok(s) => s,
Err(msg) => {
eprintln!("amont: {msg}");
return 2;
}
};
amont_runtime::list_checks(ListOptions {
json: args.iter().any(|a| a == "--json"),
stage,
pushed: args.iter().any(|a| a == "--pushed"),
})
}
Sub::Install => report(amont_runtime::install::run(
args.iter().any(|a| a == "--force"),
)),
Sub::Uninstall => report(amont_runtime::install::uninstall(
args.iter().any(|a| a == "--binary"),
)),
Sub::Setup => report(amont_runtime::setup::command(args)),
Sub::Restore => report(amont_runtime::staged_only::restore_command()),
Sub::Trust => report(amont_runtime::trust::command(args)),
Sub::Run => run_mode(args),
Sub::AgentsMd => agents_md(args),
}
}
fn report(r: Result<(), String>) -> i32 {
match r {
Ok(()) => 0,
Err(e) => {
eprintln!("{e}");
1
}
}
}
fn run_mode(args: &[OsString]) -> i32 {
let all_files = args.iter().any(|a| a == "--all-files");
let named = args
.iter()
.filter_map(|a| a.to_str())
.find(|a| !a.starts_with("--"))
.map(str::to_owned);
let push = if named.as_deref().is_some_and(needs_synthetic_push_refs) {
match pushrefs::synthetic_from_upstream() {
Ok(r) => pushrefs::PushRefs::preloaded(vec![r]),
Err(e) => {
eprintln!("amont: {e}");
return 2;
}
}
} else {
pushrefs::PushRefs::default()
};
let hooks_dir = match hooks_dir_flag(args) {
Ok(Some(d)) => d,
Ok(None) => PathBuf::from(".git/hooks"),
Err(e) => {
eprintln!("amont: {e}");
return 2;
}
};
let name = named.clone().unwrap_or_else(|| "pre-commit".to_string());
let ctx = registry::Ctx {
name: &name,
args: &[],
hooks_dir: &hooks_dir,
push: &push,
};
let verdict = match named {
Some(check) => match amont_runtime::dispatch::run_named(&ctx, &check, all_files) {
Some(v) => v,
None => {
eprintln!("amont: unknown check {check:?} — try `amont list`");
return 2;
}
},
None => amont_runtime::dispatch::run_all(&ctx, all_files),
};
verdict.exit_code()
}
fn agents_md(args: &[OsString]) -> i32 {
let check_only = args.iter().any(|a| a == "--check");
let path = match path_flag(args) {
Ok(Some(p)) => p,
Ok(None) => match amont_runtime::hooks::common::repo_root_checked() {
Ok(root) => PathBuf::from(root).join("AGENTS.md"),
Err(e) => {
eprintln!("amont: {e}");
return 2;
}
},
Err(e) => {
eprintln!("amont: {e}");
return 2;
}
};
if check_only {
match amont_runtime::agents_md::check(&path) {
Ok(amont_runtime::agents_md::CheckResult::NotPresent) => {
println!(
"{}: not present (opt-in — run without --check to add it)",
path.display()
);
0
}
Ok(amont_runtime::agents_md::CheckResult::MatchesGenerated) => {
println!("{}: up to date", path.display());
0
}
Ok(amont_runtime::agents_md::CheckResult::Drifted) => {
eprintln!(
"{}: drifted from the generated block — run `amont agents-md`",
path.display()
);
1
}
Err(e) => {
eprintln!("{e}");
1
}
}
} else {
match amont_runtime::agents_md::write(&path) {
Ok(()) => {
println!("wrote {}", path.display());
0
}
Err(e) => {
eprintln!("{e}");
1
}
}
}
}
fn run_hook(hooks_dir: &std::path::Path, hook: &str, args: &[OsString]) -> i32 {
let push = pushrefs::PushRefs::default();
let ctx = registry::Ctx {
name: hook,
args,
hooks_dir,
push: &push,
};
const USAGE_ERROR: i32 = 2;
match registry::lookup(hook) {
Some(run_hook) => run_hook(&ctx).exit_code(),
None => {
eprintln!("amont: unknown hook {hook:?}");
USAGE_ERROR
}
}
}
fn stage_flag(rest: &[OsString]) -> Result<Option<Stage>, String> {
let mut iter = rest.iter();
while let Some(a) = iter.next() {
if a == "--stage" {
let v = iter
.next()
.and_then(|v| v.to_str())
.ok_or_else(|| "--stage requires a value".to_string())?;
return match v {
"pre-commit" => Ok(Some(Stage::PreCommit)),
"pre-push" => Ok(Some(Stage::PrePush)),
other => Err(format!(
"--stage must be `pre-commit` or `pre-push`, got {other:?}"
)),
};
}
}
Ok(None)
}
fn path_flag(rest: &[OsString]) -> Result<Option<PathBuf>, String> {
value_flag(rest, "--path")
}
fn hooks_dir_flag(rest: &[OsString]) -> Result<Option<PathBuf>, String> {
value_flag(rest, "--hooks-dir")
}
fn value_flag(rest: &[OsString], flag: &str) -> Result<Option<PathBuf>, String> {
let mut iter = rest.iter();
while let Some(a) = iter.next() {
if a == flag {
let v = iter
.next()
.ok_or_else(|| format!("{flag} requires a value"))?;
return Ok(Some(PathBuf::from(v)));
}
}
Ok(None)
}
fn needs_synthetic_push_refs(name: &str) -> bool {
name == "pre-push" || registry::one_named(name).is_some_and(|c| c.stage() == Stage::PrePush)
}
#[cfg(test)]
mod tests {
use super::*;
fn argv(items: &[&str]) -> Vec<OsString> {
items.iter().map(OsString::from).collect()
}
#[test]
fn a_hook_argument_never_names_a_subcommand() {
for (spelling, _) in SUBCOMMANDS {
let got = parse(argv(&[
"--hooks-dir",
"/d",
"pre-push",
spelling,
"https://example.test/r.git",
]));
assert_eq!(
got,
Invocation::Hook {
hooks_dir: PathBuf::from("/d"),
name: "pre-push".to_string(),
args: argv(&[spelling, "https://example.test/r.git"]),
},
"a remote named {spelling:?} hijacked dispatch"
);
let got = parse(argv(&["--hooks-dir", "/d", "commit-msg", spelling]));
assert_eq!(
got,
Invocation::Hook {
hooks_dir: PathBuf::from("/d"),
name: "commit-msg".to_string(),
args: argv(&[spelling]),
},
"a commit-message file named {spelling:?} hijacked dispatch"
);
}
}
#[test]
fn the_subcommand_table_is_exhaustive() {
for (i, (spelling, sub)) in SUBCOMMANDS.iter().enumerate() {
assert_eq!(sub.index(), i, "{spelling} is at the wrong position");
assert_eq!(
subcommand(spelling),
Some(*sub),
"{spelling} does not round-trip"
);
}
let mut names: Vec<&str> = SUBCOMMANDS.iter().map(|(n, _)| *n).collect();
names.sort_unstable();
let before = names.len();
names.dedup();
assert_eq!(before, names.len(), "a spelling appears twice");
}
#[test]
fn a_subcommand_passes_its_arguments_through_untouched() {
assert_eq!(
parse(argv(&["run", "pre-commit-prettier", "--all-files"])),
Invocation::Sub {
name: Sub::Run,
args: argv(&["pre-commit-prettier", "--all-files"]),
}
);
assert_eq!(
parse(argv(&["agents-md", "--path", "install"])),
Invocation::Sub {
name: Sub::AgentsMd,
args: argv(&["--path", "install"]),
}
);
}
#[test]
fn hooks_dir_after_the_hook_name_is_an_argument_not_a_flag() {
assert_eq!(
parse(argv(&[
"--hooks-dir",
"/d",
"pre-push",
"--hooks-dir",
"origin"
])),
Invocation::Hook {
hooks_dir: PathBuf::from("/d"),
name: "pre-push".to_string(),
args: argv(&["--hooks-dir", "origin"]),
}
);
}
#[test]
fn help_and_emptiness_are_their_own_answers() {
assert_eq!(parse(argv(&["--help"])), Invocation::Help);
assert_eq!(parse(argv(&["-h"])), Invocation::Help);
assert!(matches!(parse(argv(&[])), Invocation::Usage(_)));
assert!(matches!(
parse(argv(&["--hooks-dir"])),
Invocation::Usage(_)
));
assert!(matches!(parse(argv(&["pre-push"])), Invocation::Usage(_)));
}
#[test]
fn the_usage_block_names_every_subcommand() {
for (spelling, _) in SUBCOMMANDS {
assert!(USAGE.contains(spelling), "usage never mentions {spelling}");
}
assert!(USAGE.contains("--hooks-dir"));
assert!(USAGE.contains("--stage"));
}
}