use std::ffi::OsString;
use std::path::PathBuf;
use amont_runtime::check::Stage;
use amont_runtime::{pushrefs, registry, ListOptions};
const USAGE: &str = "\
usage: amont <subcommand> | amont --hooks-dir <dir> <hook-name> [args…]
list what would run in this repository, and why not
[--json] [--stage pre-commit|pre-push] [--pushed]
[--all: the inert checks too, one row each]
install turn hooks on here: copy the binary if needed, bake the shims
[--force] replaces a hook that is not ours
init wire up THIS repository only — the verb a package manager
calls from `prepare`; never copies a binary, never prompts
uninstall remove OUR six shims and nothing else [--binary: the binary too]
setup the commit-style questions, current values as defaults
[--local|--global] [--dry-run]
trust show what amont.conf declares, and accept it
[--show: what is trusted] [--revoke: forget it]
run rehearse checks without committing: the pre-commit stage,
`run pre-push` for the push gate, or one check by name
[--all-files] [--hooks-dir <dir>]
rehearse run the push gate on a snapshot of HEAD in the background
and stamp the tree, so `git push` skips the suite
(`git config amont.rehearseOnCommit true` does this after
every commit) [--wait: follow it, or run it here if none]
[--status] [--stop]
restore bring back unstaged changes a killed pre-commit left parked
agents-md write the agent-guidance block into AGENTS.md, plus the
CLAUDE.md signpost pointing at it (both generated, both
checked) [--check: report drift only] [--path <file>]
enroll the machine-level standing grant: every future clone gets
the hooks [--conventions declared|everywhere]
attest `attest covered` — print the gate names a VALID signed
attestation covers for the tree checked out here (CI's
one-liner; prints nothing and exits 0 on any failure)
[--signers <file>] [--principal <id>]
[--platform <arch-os>|any: which leg is asking; defaults
to this machine's, so a matrix leg only skips work that
really ran on ITS platform]
check what is wrong with these FILES — no index, no staging, no
commit. The content checks only (ban-terms, secrets,
merge-conflict, large-files), reported as
`file:line:col: severity: message [check]`, which every
editor's error parser and every modern terminal already
understand. Reads a path list, or one unsaved buffer on
stdin. Exits 1 if anything blocking was found.
[<path>…] [--stdin-filename <path>] [--format text|json]
warm fill the cache of every cold `tree` gate, so the next commit
can prove it within its slack (after a cold commit this
runs in the background by itself) [--worker]
tree-parity compare each `tree` gate in amont.conf with the workflow
steps skipped on it; exits 1 on any drift (CI runs it,
never skipped)
add vendor a pack's declarations into amont.conf — a pack is a
git repository carrying an `amont.pack` of amont.conf rows.
Resolves the revision to a commit id, refuses anything that
is not a check, and records the id beside the lines. Nothing
is trusted by adding it: the manifest changes, so `amont
trust` must read the commands before any of them can run.
<source>[@<rev>]… [--dry-run]
github:owner/repo forgejo:host/owner/repo <git-url>
--help this text --version the binary's version
Hook mode (what the shims call): amont --hooks-dir <dir> <hook-name> [args…]
The full manual: https://fredericrous.github.io/amont/
";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Sub {
List,
Setup,
Install,
Init,
Uninstall,
Run,
Trust,
Restore,
AgentsMd,
Enroll,
Attest,
Check,
Add,
Rehearse,
TreeParity,
Warm,
}
impl Sub {
#[cfg(test)]
const fn index(self) -> usize {
match self {
Sub::List => 0,
Sub::Setup => 1,
Sub::Install => 2,
Sub::Init => 3,
Sub::Uninstall => 4,
Sub::Run => 5,
Sub::Trust => 6,
Sub::Restore => 7,
Sub::AgentsMd => 8,
Sub::Enroll => 9,
Sub::Attest => 10,
Sub::Check => 11,
Sub::Add => 12,
Sub::Rehearse => 13,
Sub::TreeParity => 14,
Sub::Warm => 15,
}
}
}
const SUBCOMMANDS: [(&str, Sub); 16] = [
("list", Sub::List),
("setup", Sub::Setup),
("install", Sub::Install),
("init", Sub::Init),
("uninstall", Sub::Uninstall),
("run", Sub::Run),
("trust", Sub::Trust),
("restore", Sub::Restore),
("agents-md", Sub::AgentsMd),
("enroll", Sub::Enroll),
("attest", Sub::Attest),
("check", Sub::Check),
("add", Sub::Add),
("rehearse", Sub::Rehearse),
("tree-parity", Sub::TreeParity),
("warm", Sub::Warm),
];
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,
Version,
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 first == "--version" || first == "-V" {
return Invocation::Version;
}
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()),
}
}
#[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() {
die_on_sigpipe();
let _ = amont_runtime::rehearsal::in_snapshot();
match parse(std::env::args_os().skip(1).collect()) {
Invocation::Help => {
print!("{USAGE}");
std::process::exit(0);
}
Invocation::Version => {
println!("amont {}", env!("CARGO_PKG_VERSION"));
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 known_flags(sub: Sub) -> (&'static [&'static str], &'static [&'static str]) {
match sub {
Sub::List => (&["--json", "--pushed", "--all"], &["--stage"]),
Sub::Setup => (&["--local", "--global", "--dry-run"], &[]),
Sub::Install => (&["--force"], &[]),
Sub::Init | Sub::Restore => (&[], &[]),
Sub::Uninstall => (&["--binary"], &[]),
Sub::Run => (&["--all-files"], &["--hooks-dir"]),
Sub::Trust => (&["--show", "--revoke"], &[]),
Sub::AgentsMd => (&["--check"], &["--path"]),
Sub::Enroll => (&[], &["--conventions"]),
Sub::Attest => (&[], &["--signers", "--principal", "--platform"]),
Sub::Check => (&[], &["--stdin-filename", "--format"]),
Sub::Add => (&["--dry-run"], &[]),
Sub::Rehearse => (&["--wait", "--status", "--stop", "--worker"], &[]),
Sub::TreeParity => (&[], &[]),
Sub::Warm => (&["--worker"], &[]),
}
}
fn unknown_flag(args: &[OsString], flags: &[&str], valued: &[&str]) -> Option<String> {
let mut skip_value = false;
for a in args {
if skip_value {
skip_value = false;
continue;
}
let Some(a) = a.to_str() else { continue };
if !a.starts_with('-') {
continue;
}
if valued.contains(&a) {
skip_value = true;
continue;
}
if !flags.contains(&a) {
return Some(a.to_string());
}
}
None
}
fn verb_name(sub: Sub) -> &'static str {
SUBCOMMANDS
.iter()
.find(|(_, s)| *s == sub)
.map(|(n, _)| *n)
.unwrap_or("?")
}
fn run_sub(sub: Sub, args: &[OsString]) -> i32 {
let settings = amont_runtime::config::Settings::default();
if args.iter().any(|a| a == "--help" || a == "-h") {
print!("{USAGE}");
return 0;
}
if args.iter().any(|a| a == "--version" || a == "-V") {
println!("amont {}", env!("CARGO_PKG_VERSION"));
return 0;
}
let (flags, valued) = known_flags(sub);
if let Some(bad) = unknown_flag(args, flags, valued) {
eprintln!("amont: unknown flag {bad:?} for `{}`", verb_name(sub));
eprint!("{USAGE}");
return 2;
}
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"),
all: args.iter().any(|a| a == "--all"),
})
}
Sub::Install => report(amont_runtime::install::run(
&settings,
args.iter().any(|a| a == "--force"),
)),
Sub::Init => report(amont_runtime::install::init()),
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(&settings)),
Sub::Trust => report(amont_runtime::trust::command(args)),
Sub::Run => run_mode(args),
Sub::AgentsMd => agents_md(args),
Sub::Rehearse => amont_runtime::rehearsal::command(args),
Sub::TreeParity => {
let root = amont_runtime::hooks::common::repo_root();
let problems =
amont_runtime::hooks::tree_parity::check_repo(std::path::Path::new(&root));
for p in &problems {
println!("{}", amont_runtime::ui::sanitize(&p.to_string()));
}
i32::from(!problems.is_empty())
}
Sub::Warm => {
let root = amont_runtime::hooks::common::repo_root();
amont_runtime::tree_lint::warm(std::path::Path::new(&root))
}
Sub::Enroll => {
let conventions = match flag_value(args, "--conventions") {
Ok(v) => v,
Err(msg) => {
eprintln!("amont: {msg}");
return 2;
}
};
report(amont_runtime::install::enroll(conventions.as_deref()))
}
Sub::Add => {
use amont_runtime::pack;
let dry_run = args.iter().any(|a| a == "--dry-run");
let specs: Vec<&str> = args
.iter()
.filter_map(|a| a.to_str())
.filter(|a| !a.starts_with("--"))
.collect();
if specs.is_empty() {
eprintln!("amont: add needs a source (github:owner/repo, forgejo:host/owner/repo, or a git URL)");
eprint!("{USAGE}");
return 2;
}
let scratch = std::env::temp_dir().join(format!("amont-pack-{}", std::process::id()));
let mut fetched: Vec<(String, String, Vec<String>)> = Vec::new();
for (i, spec) in specs.iter().enumerate() {
let outcome = pack::parse_source(spec).and_then(|source| {
let id = pack::resolve(&source)?;
let text = pack::fetch(&source, &id, &scratch.join(i.to_string()))?;
let rows = pack::rows(&text)?;
Ok((source.label, id, rows))
});
match outcome {
Ok(got) => fetched.push(got),
Err(msg) => {
let _ = std::fs::remove_dir_all(&scratch);
eprintln!("amont: {msg}");
return 1;
}
}
}
let _ = std::fs::remove_dir_all(&scratch);
for (label, id, rows) in &fetched {
println!("{label} @ {} declares:", &id[..7.min(id.len())]);
for r in rows {
println!(" {}", amont_runtime::ui::sanitize(r));
}
}
if dry_run {
println!("--dry-run: nothing written");
return 0;
}
let root = amont_runtime::hooks::common::repo_root();
let path = std::path::Path::new(&root).join(amont_runtime::manifest::MANIFEST);
let before = std::fs::read_to_string(&path).unwrap_or_default();
let mut after = before.clone();
for (label, id, rows) in &fetched {
match pack::splice(&after, label, id, rows) {
Ok(next) => after = next,
Err(msg) => {
eprintln!("amont: {msg}");
return 1;
}
}
}
if after == before {
println!(
"{} already carries exactly this",
amont_runtime::manifest::MANIFEST
);
return 0;
}
if let Err(e) = std::fs::write(&path, &after) {
eprintln!("amont: cannot write {}: {e}", path.display());
return 1;
}
println!(
"\n{} changed — these commands cannot run until you review them:\n amont trust",
amont_runtime::manifest::MANIFEST
);
0
}
Sub::Check => {
let format = match flag_value(args, "--format") {
Ok(f) => f,
Err(msg) => {
eprintln!("amont: {msg}");
return 2;
}
};
let json = match format.as_deref() {
None | Some("text") => false,
Some("json") => true,
Some(other) => {
eprintln!("amont: unknown --format {other:?} (text or json)");
return 2;
}
};
let stdin_name = match flag_value(args, "--stdin-filename") {
Ok(n) => n,
Err(msg) => {
eprintln!("amont: {msg}");
return 2;
}
};
let mut paths: Vec<String> = Vec::new();
let mut skip = false;
for a in args {
if skip {
skip = false;
continue;
}
let Some(a) = a.to_str() else { continue };
if a == "--format" || a == "--stdin-filename" {
skip = true;
continue;
}
if !a.starts_with("--") {
paths.push(a.to_string());
}
}
let mut findings = Vec::new();
if let Some(name) = &stdin_name {
use std::io::Read;
let mut buf = Vec::new();
if std::io::stdin().read_to_end(&mut buf).is_err() {
eprintln!("amont: could not read stdin");
return 2;
}
findings.extend(amont_runtime::content::scan(&settings, name, &buf));
} else if paths.is_empty() {
eprintln!("amont: check needs a path, or --stdin-filename <path>");
eprint!("{USAGE}");
return 2;
}
for p in &paths {
match std::fs::read(p) {
Ok(bytes) => {
findings.extend(amont_runtime::content::scan(&settings, p, &bytes))
}
Err(e) => eprintln!("amont: {p}: {e}"),
}
}
if json {
println!("{}", amont_runtime::finding::to_json(&findings));
} else {
for f in &findings {
println!("{}", f.render());
}
}
i32::from(
findings
.iter()
.any(|f| f.severity == amont_runtime::check::Severity::Block),
)
}
Sub::Attest => {
if args.first().map(|a| a != "covered").unwrap_or(true) {
eprintln!("amont: attest takes the subaction `covered`");
eprint!("{USAGE}");
return 2;
}
let (signers, principal, platform) = match (
flag_value(args, "--signers"),
flag_value(args, "--principal"),
flag_value(args, "--platform"),
) {
(Ok(s), Ok(p), Ok(pl)) => (s, p, pl),
(Err(msg), _, _) | (_, Err(msg), _) | (_, _, Err(msg)) => {
eprintln!("amont: {msg}");
return 2;
}
};
let Some(signers) = signers
.map(PathBuf::from)
.or_else(amont_runtime::attest::default_signers)
else {
return 0; };
let Some(principal) =
principal.or_else(|| amont_runtime::attest::first_principal(&signers))
else {
return 0; };
let want = match platform.as_deref() {
Some("any") => None,
Some(explicit) => Some(explicit.to_string()),
None => Some(amont_runtime::attest::platform()),
};
if let Some(gates) =
amont_runtime::attest::covered(&signers, &principal, want.as_deref())
{
println!("{gates}");
}
0
}
}
}
fn flag_value(rest: &[OsString], flag: &str) -> Result<Option<String>, String> {
let mut iter = rest.iter();
while let Some(a) = iter.next() {
if a == flag {
return iter
.next()
.and_then(|v| v.to_str())
.map(|v| Some(v.to_string()))
.ok_or_else(|| format!("{flag} requires a value"));
}
}
Ok(None)
}
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 manifest = amont_runtime::manifest::load(std::path::Path::new(
&amont_runtime::hooks::common::repo_root(),
));
let settings = amont_runtime::config::Settings::new(manifest.policy.clone());
let named = match named {
Some(n) if amont_runtime::registry::lookup(&n, &manifest).is_none() => {
match amont_runtime::dispatch::resolve_check_name(&n, &manifest) {
amont_runtime::dispatch::Named2::Resolved(id) => Some(id),
amont_runtime::dispatch::Named2::Unknown => {
eprintln!("amont: unknown check {n:?} — try `amont list`");
return 2;
}
amont_runtime::dispatch::Named2::Ambiguous(ids) => {
eprintln!(
"amont: {n:?} names more than one check — pick one: {}",
ids.join(", ")
);
return 2;
}
}
}
other => other,
};
let push = if named
.as_deref()
.is_some_and(|n| needs_synthetic_push_refs(n, &manifest))
{
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,
manifest: &manifest,
settings: &settings,
};
let verdict = match named {
Some(check) => match amont_runtime::dispatch::run_named(&ctx, &check, all_files) {
amont_runtime::dispatch::Named::Ran(v) => v,
amont_runtime::dispatch::Named::Unknown => {
eprintln!("amont: unknown check {check:?} — try `amont list`");
return 2;
}
amont_runtime::dispatch::Named::Ambiguous(ids) => {
eprintln!(
"amont: {check:?} names more than one check — pick one: {}",
ids.join(", ")
);
return 2;
}
},
None => amont_runtime::dispatch::run_all(&ctx, all_files),
};
verdict.exit_code()
}
fn agents_md(args: &[OsString]) -> i32 {
let settings = amont_runtime::config::Settings::default();
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 {
let pointer_code = pointer_path(&path).map_or(0, |p| {
match amont_runtime::agents_md::check_pointer(&p) {
Ok(amont_runtime::agents_md::CheckResult::Drifted) => {
eprintln!(
"{}: signpost drifted from the generated one — run `amont agents-md`",
p.display()
);
1
}
Err(e) => {
eprintln!("{e}");
1
}
Ok(_) => 0,
}
});
let block_code = match amont_runtime::agents_md::check(&settings, &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
}
};
block_code.max(pointer_code)
} else {
match amont_runtime::agents_md::write(&settings, &path) {
Ok(()) => {
println!("wrote {}", path.display());
pointer_path(&path).map_or(0, |p| {
match amont_runtime::agents_md::write_pointer(&p) {
Ok(()) => {
println!("wrote {} (signpost)", p.display());
0
}
Err(e) => {
eprintln!("{e}");
1
}
}
})
}
Err(e) => {
eprintln!("{e}");
1
}
}
}
}
fn pointer_path(block: &std::path::Path) -> Option<PathBuf> {
if block.file_name()? == "CLAUDE.md" {
return None;
}
Some(block.with_file_name("CLAUDE.md"))
}
fn run_hook(hooks_dir: &std::path::Path, hook: &str, args: &[OsString]) -> i32 {
let push = pushrefs::PushRefs::default();
let manifest = amont_runtime::manifest::load(std::path::Path::new(
&amont_runtime::hooks::common::repo_root(),
));
let settings = amont_runtime::config::Settings::new(manifest.policy.clone());
let ctx = registry::Ctx {
name: hook,
args,
hooks_dir,
push: &push,
manifest: &manifest,
settings: &settings,
};
match registry::lookup(hook, &manifest) {
Some(run_hook) => run_hook(&ctx).exit_code(),
None => amont_runtime::skew::absorb_newer_hook(hook),
}
}
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, manifest: &amont_runtime::manifest::Manifest) -> bool {
if name == "pre-push" {
return true;
}
if let Some(c) = registry::one_named(name, manifest) {
return c.stage() == Stage::PrePush;
}
registry::CHECKS
.iter()
.any(|c| c.stage == Stage::PrePush && amont_runtime::skip_suppresses(c.name, name))
}
#[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"));
}
}