1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
use crate::file::display_path;
use crate::config::Settings;
use crate::file;
use crate::git::Git;
/// Generate a git pre-commit hook
///
/// This command generates a git pre-commit hook that runs a mise task like `mise run pre-commit`
/// when you commit changes to your repository.
///
/// Staged files are passed to the task as `STAGED`.
///
/// For more advanced pre-commit functionality, see mise's sister project: https://hk.jdx.dev/
#[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 {
/// The task to run when the pre-commit hook is triggered
#[usage(long, short, default = "pre-commit")]
task: String,
/// Write to .git/hooks/pre-commit and make it executable
#[usage(long, short)]
write: bool,
/// Which hook to generate (saves to .git/hooks/$hook)
#[usage(long, default = "pre-commit")]
hook: String,
/// mise flags to embed in the generated hook, given after `--`
///
/// These are inserted between `mise` and `run`, so the hook carries the same context you
/// would pass on the command line. Useful when the config is not at the repository root,
/// since git runs hooks from the top level: `-- -C subdir` makes the hook find it.
#[usage(
double_dash = "required",
value_name = "MISE_ARG",
verbatim_doc_comment
)]
mise_args: Vec<String>,
}
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;
// Quoted rather than joined with spaces so an argument that contains one — a
// `-C` pointing at a directory with a space, say — survives as a single word.
let mise_args = if self.mise_args.is_empty() {
String::new()
} else {
format!(" {}", shell_words::join(&self.mise_args))
};
// `"$@"` forwards whatever git passes the hook. `pre-commit` is called with no
// arguments so it is unaffected, but every other `--hook` target gets some — a
// `commit-msg` hook is handed the path to the message file, for instance — and
// without this they are dropped before the task ever sees them.
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} "$@"
"#
)
}
}
#[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() {
// Without the passthrough a `commit-msg` hook cannot reach the message file git
// hands it, which is the whole point of that hook.
let out = generate("lint-commit-msg", "commit-msg");
assert!(
out.contains(r#"exec mise run lint-commit-msg "$@""#),
"hook arguments must reach the task:\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}");
}
/// Frozen copy of the hook as it was before the passthrough existed.
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 "$@"
"#;
/// Passing nothing must leave the hook byte-identical, so the ones already written
/// stay valid. Compared whole rather than by substring: an extra or duplicated line
/// would slip past a `contains` check.
#[test]
fn no_mise_args_leaves_the_hook_unchanged() {
assert_eq!(generate("pre-commit", "pre-commit"), HOOK_WITHOUT_MISE_ARGS);
}
/// git runs hooks from the repository root, so a config kept in a subdirectory is only
/// reachable if the hook carries the flag that gets mise there (discussion #4304).
#[test]
fn mise_args_are_inserted_before_run() {
let out = generate_with_args("lint", "pre-commit", &["-C", "subdir", "-E", "ci"]);
assert!(
out.contains(r#"exec mise -C subdir -E ci run lint "$@""#),
"{out}"
);
}
/// What matters is that a shell reading the hook sees the same argument boundaries we
/// were given — not which quoting style `shell_words::join` happened to pick. Splitting
/// the line back apart asserts the property instead of the spelling.
#[test]
fn an_argument_containing_a_space_stays_one_word() {
let out = generate_with_args("lint", "pre-commit", &["-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", "$@"]);
}
/// The tests above build the struct directly, so they cannot see the question this
/// feature actually turns on: `-C` and `-E` are global flags, and without `last = true`
/// clap would bind them to `Cli` instead of handing them to the hook — leaving the
/// generated script silently unchanged. Parse a real command line to pin that down.
#[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"]);
}
}