1use crate::Xtask;
5use anyhow::Context;
6use clap::Parser;
7use clap::ValueEnum;
8use serde::Deserialize;
9use serde::Serialize;
10use std::io::BufRead;
11use std::path::Path;
12
13#[derive(Parser)]
17#[clap(
18 about = "Install git pre-commit / pre-push hooks",
19 disable_help_subcommand = true
20)]
21pub struct InstallGitHooks {
22 #[clap(long)]
24 pre_commit: bool,
25
26 #[clap(long)]
28 pre_push: bool,
29
30 #[clap(long, default_value = "yes")]
32 with_fmt: YesNo,
33}
34
35#[derive(Clone, ValueEnum)]
36enum YesNo {
37 Yes,
38 No,
39}
40
41const CONFIG_HEREDOC: &str = "XTASK_HOOK_CONFIG";
42
43const TEMPLATE: &str = r#"
50#!/bin/sh
51
52set -e
53
54###############################################################################
55# ANY MODIFICATIONS MADE TO THIS FILE WILL GET OVERWRITTEN! #
56###############################################################################
57
58# This file is generated (and re-generated) by `cargo xtask`.
59#
60# To opt-out of automatic updates, it is sufficient to delete the following
61# CONFIG variable, and `cargo xtask` will no longer overwrite this file.
62
63CONFIG=$(cat << <<CONFIG_HEREDOC>>
64<<CONFIG>>
65<<CONFIG_HEREDOC>>
66)
67
68# The rest of the script is the "minimum-viable-bash" required to do 2 things:
69#
70# 1. Invoke `cargo xtask hook <hook-kind>`.
71# 2. Encode the $CONFIG blob that gets passed to the xtask, which contains the
72# user-specified hook configuration (as specified via `install-git-hooks`)
73#
74# Any future additions to `xtask`-driven hooks should be done in Rust (as
75# opposed to extending this bash script)
76
77cd "${GIT_DIR-$(git rev-parse --git-dir)}/.."
78
79XTASK="cargo xtask"
80
81USE_PREBUILT_XTASK="<<USE_PREBUILT_XTASK>>"
82if [ -n "$USE_PREBUILT_XTASK" ] && [ -f "<<XTASK_PATH_FILE>>" ]; then
83 XTASK=$(cat "<<XTASK_PATH_FILE>>")
84fi
85
86$XTASK hook <<HOOK_KIND>> $CONFIG
87
88"#;
89
90fn install_hook(
91 root: &Path,
92 config: HookConfig,
93 kind: &str,
94 rebuild: bool,
95 quiet: bool,
96) -> anyhow::Result<()> {
97 let script = TEMPLATE;
98 let script = script.replace("<<CONFIG_HEREDOC>>", CONFIG_HEREDOC);
99 let script = script.replace("<<CONFIG>>", &serde_json::to_string(&config)?);
100 let script = script.replace("<<USE_PREBUILT_XTASK>>", if !rebuild { "1" } else { "" });
101 let script = script.replace("<<XTASK_PATH_FILE>>", crate::XTASK_PATH_FILE);
102 let script = script.replace("<<HOOK_KIND>>", kind);
103 let script = script.trim();
104
105 let path = root.join(".git").join("hooks").join(kind);
106 let already_exists = path.exists();
107
108 fs_err::write(&path, script)?;
109
110 #[cfg(unix)]
112 {
113 use std::os::unix::fs::PermissionsExt;
114 let mut perms = fs_err::metadata(&path)?.permissions();
115 perms.set_mode(perms.mode() | 0o100);
116 fs_err::set_permissions(&path, perms)?;
117 }
118
119 let lvl = {
120 if quiet {
121 log::Level::Debug
122 } else {
123 log::Level::Info
124 }
125 };
126
127 if already_exists {
128 log::log!(lvl, "updated {}", path.display());
129 } else {
130 log::log!(lvl, "installed {}", path.display());
131 }
132
133 Ok(())
134}
135
136fn install_pre_commit(root: &Path, config: HookConfig, quiet: bool) -> anyhow::Result<()> {
137 install_hook(root, config, "pre-commit", false, quiet)
138}
139
140fn install_pre_push(root: &Path, config: HookConfig, quiet: bool) -> anyhow::Result<()> {
141 install_hook(root, config, "pre-push", true, quiet)
142}
143
144impl Xtask for InstallGitHooks {
145 fn run(self, ctx: crate::XtaskCtx) -> anyhow::Result<()> {
146 if ![self.pre_commit, self.pre_push].into_iter().any(|x| x) {
147 log::warn!("no hooks installed! pass at least one of [--pre-commit, --pre-push]")
148 }
149
150 if self.pre_commit {
151 install_pre_commit(
152 &ctx.root,
153 HookConfig {
154 with_fmt: matches!(self.with_fmt, YesNo::Yes),
155 },
156 false,
157 )?;
158 }
159
160 if self.pre_push {
161 install_pre_push(
162 &ctx.root,
163 HookConfig {
164 with_fmt: matches!(self.with_fmt, YesNo::Yes),
165 },
166 false,
167 )?;
168 }
169
170 Ok(())
171 }
172}
173
174#[derive(Default, Serialize, Deserialize)]
175struct HookConfig {
176 with_fmt: bool,
177}
178
179#[derive(Debug)]
180enum HookError {
181 Missing,
182 Custom,
183 MalformedConfig,
184}
185
186fn extract_config(path: &Path) -> Result<HookConfig, HookError> {
187 let f = fs_err::File::open(path).map_err(|_| HookError::Missing)?;
188 let f = std::io::BufReader::new(f);
189 let mut found_config = false;
190 for ln in f.lines() {
191 let ln = ln.map_err(|_| HookError::Custom)?;
193
194 if !found_config {
195 if ln.ends_with(CONFIG_HEREDOC) {
196 found_config = true;
197 }
198 continue;
199 }
200
201 return serde_json::from_str(&ln).map_err(|_| HookError::MalformedConfig);
202 }
203
204 Err(HookError::Custom)
206}
207
208pub fn update_hooks(root: &Path) -> anyhow::Result<()> {
210 let base_path = root.join(".git").join("hooks");
211
212 let update_hook_inner =
213 |hook: &str,
214 install_fn: fn(root: &Path, config: HookConfig, quiet: bool) -> anyhow::Result<()>,
215 quiet: bool|
216 -> anyhow::Result<()> {
217 match extract_config(&base_path.join(hook)) {
218 Ok(config) => (install_fn)(root, config, quiet)?,
219 Err(HookError::MalformedConfig) => {
220 log::warn!("detected malformed {hook} hook!");
221 log::warn!("please rerun `cargo xtask install-git-hooks --{hook}`!");
222 }
223 Err(e) => {
224 log::debug!("could not update {hook} hook: {:?}", e)
225 }
226 }
227
228 Ok(())
229 };
230
231 update_hook_inner("pre-commit", install_pre_commit, true)?;
232 update_hook_inner("pre-push", install_pre_push, true)?;
233
234 Ok(())
235}
236
237#[derive(Parser)]
243pub struct RunGitHook {
244 hook: HookVariety,
245 config: String,
246}
247
248#[derive(Clone, ValueEnum)]
249enum HookVariety {
250 PreCommit,
251 PrePush,
252}
253
254impl Xtask for RunGitHook {
255 fn run(self, ctx: crate::XtaskCtx) -> anyhow::Result<()> {
256 let config: HookConfig =
257 serde_json::from_str(&self.config).context("invalid hook config")?;
258
259 match self.hook {
260 HookVariety::PreCommit => {
262 log::info!("running pre-commit hook");
263
264 if config.with_fmt {
265 const FMT_CMD: &str = "fmt --only-diffed --pass rustfmt --pass house-rules";
266 crate::tasks::Fmt::parse_from(FMT_CMD.split(' ')).run(ctx)?;
267 }
268 }
269 HookVariety::PrePush => {
271 log::info!("running pre-push hook");
272
273 if config.with_fmt {
274 const FMT_CMD: &str = "";
275 crate::tasks::Fmt::parse_from(FMT_CMD.split(' ')).run(ctx)?;
276 }
277 }
278 }
279
280 log::info!("hook completed successfully\n");
281
282 Ok(())
283 }
284}