xtask\tasks/
git_hooks.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4use 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/// Xtask to install git hooks back into `xtask`.
14///
15/// Must be installed alongside [`RunGitHook`] as a top-level `hook` subcommand.
16#[derive(Parser)]
17#[clap(
18    about = "Install git pre-commit / pre-push hooks",
19    disable_help_subcommand = true
20)]
21pub struct InstallGitHooks {
22    /// Install the pre-commit hook (only runs quick checks)
23    #[clap(long)]
24    pre_commit: bool,
25
26    /// Install the pre-push hook
27    #[clap(long)]
28    pre_push: bool,
29
30    /// Run formatting checks as part of the hook
31    #[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
43// This bit of bash script is the "minimum-viable-glue" required to do 2 things:
44//
45// 1. Invoke `cargo xtask hook <hook-kind>`.
46// 2. Encode the CONFIG blob that gets passed to the xtask (which contains
47//    user-customizable hook configuration, generated based on what args were
48//    passed to `install-git-hooks`)
49const 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    // enable exec on unix systems
111    #[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        // is a line isn't UTF-8, assume this is a custom hook
192        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    // if we couldn't find the config, assume this is a custom git hook
205    Err(HookError::Custom)
206}
207
208/// Keeps any installed hooks up to date.
209pub 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/// Private subcommand to run hooks (invoked via `git`).
238///
239/// This subcommand should be marked as `#[clap(hide = true)]`, as it shouldn't
240/// be invoked by end-users. It is an internal implementation detail of the
241/// `xtask` git hook infrastructure.
242#[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            // pre-commit should only do quick checks on modified files
261            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            // pre-push should do all "heavier" checks
270            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}