use crate::file::display_path;
use crate::config::Settings;
use crate::file;
use crate::git::Git;
#[derive(Debug, usage_rs::Args)]
#[usage(
verbatim_doc_comment,
visible_alias = "pre-commit",
example(
r###"mise generate git-pre-commit --write --task=pre-commit
git commit -m "feat: add new feature""###,
help = "Install the hook; committing then runs `mise run pre-commit`."
),
example(
r###"mise generate git-pre-commit --write -- -C subdir"###,
help = r###"config lives in a subdirectory, so the hook has to change into it first"###
)
)]
pub(super) struct GitPreCommit {
#[usage(long, short, default = "pre-commit")]
task: String,
#[usage(long, short)]
write: bool,
#[usage(long, default = "pre-commit")]
hook: String,
#[usage(
double_dash = "required",
value_name = "MISE_ARG",
verbatim_doc_comment
)]
mise_args: Vec<String>,
}
const FILE_ARG_HOOKS: &[&str] = &[
"applypatch-msg",
"commit-msg",
"prepare-commit-msg",
"sendemail-validate",
];
impl GitPreCommit {
pub(super) async fn run(self) -> eyre::Result<()> {
let output = self.generate();
if self.write {
let quiet = Settings::get().quiet;
let path = Git::get_path("hooks")?.join(&self.hook);
if path.exists() {
let old_path = path.with_extension("old");
if !quiet {
miseprintln!(
"Moving existing hook to {:?}",
old_path.file_name().unwrap()
);
}
file::rename(&path, path.with_extension("old"))?;
}
file::write(&path, &output)?;
file::make_executable(&path)?;
if !quiet {
miseprintln!("Wrote to {}", display_path(&path));
}
} else {
miseprintln!("{output}");
}
Ok(())
}
fn generate(&self) -> String {
let task = &self.task;
let mise_args = if self.mise_args.is_empty() {
String::new()
} else {
format!(" {}", shell_words::join(&self.mise_args))
};
let hook_args = if FILE_ARG_HOOKS.contains(&self.hook.as_str()) {
r#" "$1""#
} else {
""
};
format!(
r#"#!/bin/sh
STAGED="$(git diff-index --cached --name-only -z HEAD | xargs -0)"
export STAGED
export MISE_PRE_COMMIT=1
exec mise{mise_args} run {task}{hook_args}
"#
)
}
}
#[cfg(test)]
mod tests {
use super::GitPreCommit;
fn generate(task: &str, hook: &str) -> String {
generate_with_args(task, hook, &[])
}
fn generate_with_args(task: &str, hook: &str, mise_args: &[&str]) -> String {
GitPreCommit {
task: task.to_string(),
write: false,
hook: hook.to_string(),
mise_args: mise_args.iter().map(|s| s.to_string()).collect(),
}
.generate()
}
#[test]
fn forwards_hook_arguments_to_the_task() {
let out = generate("lint-commit-msg", "commit-msg");
assert!(
out.ends_with("exec mise run lint-commit-msg \"$1\"\n"),
"the message file must reach the task:\n{out}"
);
}
#[test]
fn prepare_commit_msg_forwards_only_the_message_file() {
let out = generate("prep", "prepare-commit-msg");
assert!(out.ends_with("exec mise run prep \"$1\"\n"), "{out}");
}
#[test]
fn pre_push_does_not_forward_hook_arguments() {
let out = generate("pre-push", "pre-push");
assert!(out.ends_with("exec mise run pre-push\n"), "{out}");
}
#[test]
fn pre_commit_output_is_unchanged_in_substance() {
let out = generate("pre-commit", "pre-commit");
assert!(out.starts_with("#!/bin/sh\n"), "{out}");
assert!(out.contains("export MISE_PRE_COMMIT=1"), "{out}");
assert!(out.contains("STAGED="), "{out}");
}
const HOOK_WITHOUT_MISE_ARGS: &str = r#"#!/bin/sh
STAGED="$(git diff-index --cached --name-only -z HEAD | xargs -0)"
export STAGED
export MISE_PRE_COMMIT=1
exec mise run pre-commit
"#;
#[test]
fn no_mise_args_leaves_the_hook_unchanged() {
assert_eq!(generate("pre-commit", "pre-commit"), HOOK_WITHOUT_MISE_ARGS);
}
#[test]
fn mise_args_are_inserted_before_run() {
let out = generate_with_args("lint", "commit-msg", &["-C", "subdir", "-E", "ci"]);
assert!(
out.contains(r#"exec mise -C subdir -E ci run lint "$1""#),
"{out}"
);
}
#[test]
fn an_argument_containing_a_space_stays_one_word() {
let out = generate_with_args("lint", "commit-msg", &["-C", "my dir"]);
let exec_line = out
.lines()
.find(|line| line.starts_with("exec mise"))
.expect("generated hook should exec mise");
let words = shell_words::split(exec_line).expect("exec line should be valid shell");
assert_eq!(words, ["exec", "mise", "-C", "my dir", "run", "lint", "$1"]);
}
#[test]
fn passthrough_args_reach_the_command_through_the_parser() {
let argv = [
"mise",
"generate",
"git-pre-commit",
"--task",
"lint",
"--",
"-C",
"subdir",
"-E",
"ci",
];
let argv: Vec<&std::ffi::OsStr> = argv.iter().map(std::ffi::OsStr::new).collect();
let cli = crate::cli::Cli::parse_from_argv(&argv)
.expect("the documented invocation should parse");
assert!(cli.cd.is_none());
assert!(cli.env.is_none());
let Some(crate::cli::Commands::Generate(generate)) = cli.command else {
panic!("generate should be the resolved subcommand");
};
let super::super::Commands::GitPreCommit(command) = generate.command else {
panic!("git-pre-commit should be the resolved subcommand");
};
assert_eq!(command.task, "lint");
assert_eq!(command.mise_args, ["-C", "subdir", "-E", "ci"]);
}
}