devx_pre_commit/
lib.rs

1//! `devx-pre-commit` provides utilities for creating git pre-commit hooks.
2//!
3//! In particular, there are convenient APIs for
4//! - Efficiently running [`rustfmt`] on crates with staged rust source files
5//! - Installing the current binary to `.git/hooks/pre-commit`
6//!
7//! This crate is meant to be used only in dev environment, preferably with
8//! [`cargo-xtask`] setup. By having something like the code bellow in
9//! `xtask` binary crate you will be able to run the following command to install
10//! the git pre-commit hook and never bother running `cargo fmt` manually again:
11//!
12//! ```bash
13//! cargo xtask install-pre-commit-hook
14//! ```
15//!
16//! > ℹ️ Note: This assumes there is an alias in `.cargo/config`:
17//! > ```toml
18//! > [alias]
19//! > xtask = "run --package xtask --bin xtask --"
20//! > ```
21//!
22//! Example dev cli:
23//! ```no_run
24//! use devx_pre_commit::{PreCommitContext, locate_project_root};
25//! use anyhow::Result;
26//! use std::{ffi::OsStr, path::PathBuf};
27//!
28//! fn run_hook() -> Result<()> {
29//!     let mut ctx = PreCommitContext::from_git_diff(locate_project_root()?)?;
30//!
31//!     // Optionally filter out the files you don't want to format
32//!     ctx.retain_staged_files(|path| {
33//!         path.components().all(|it| it.as_os_str() != OsStr::new("generated"))
34//!     });
35//!
36//!     // Run `cargo fmt` against the crates with staged rust source files
37//!     ctx.rustfmt()?;
38//!
39//!     // Stage all the changes potenitally introduced by rustfmt
40//!     // It is super-important to call this method at the end of the hook
41//!     ctx.stage_new_changes()?;
42//!     Ok(())
43//! }
44//!
45//! fn main() -> Result<()> {
46//!     if let Some(true) = std::env::args().next().map(|it| it.contains("pre-commit")) {
47//!         return run_hook();
48//!     }
49//!     match std::env::args().nth(1).expect("No args").as_str() {
50//!         "install-pre-commit-hook" => {
51//!             devx_pre_commit::install_self_as_hook(&locate_project_root()?)?;
52//!         }
53//!         _ => {
54//!             eprintln!("Hi, this is a dev cli, here are the available commands...");
55//!         }
56//!     }
57//!     Ok(())
58//! }
59//! ```
60//!
61//! [`cargo-xtask`]: https://github.com/matklad/cargo-xtask
62//! [`rustfmt`]: https://github.com/rust-lang/rustfmt
63#![warn(missing_docs)]
64#![warn(rust_2018_idioms)]
65// Makes rustc abort compilation if there are any unsafe blocks in the crate.
66// Presence of this annotation is picked up by tools such as cargo-geiger
67// and lets them ensure that there is indeed no unsafe code as opposed to
68// something they couldn't detect (e.g. unsafe added via macro expansion, etc).
69#![forbid(unsafe_code)]
70
71use fs_err as fs;
72use std::{
73    collections::HashSet,
74    env::{self, consts},
75    ffi::OsStr,
76    ops::Deref,
77    path::{Path, PathBuf},
78};
79
80use anyhow::Result;
81use devx_cmd::{cmd, run};
82
83/// Represents the API entrypoint of the git pre-commit hook.
84/// It carries the list of the staged files and the project root path.
85/// Note that staged file paths are all relative to the project root path.
86pub struct PreCommitContext {
87    staged_files: Vec<PathBuf>,
88    project_root: PathBuf,
89}
90
91impl PreCommitContext {
92    /// Creates the git pre-commit context acquiring the staged files via running
93    /// `git diff` in `project_root`.
94    /// The `project_root` is expected to contain the `.git` dir
95    /// (see [`locate_project_root`] function for more on that).
96    ///
97    /// The staged files are stored in [`PreCommitContext`] as paths relative
98    /// to `project_root`.
99    pub fn from_git_diff(project_root: impl Into<PathBuf>) -> Result<Self> {
100        let project_root = project_root.into();
101        let diff = cmd!(
102            "git",
103            "diff",
104            "--diff-filter",
105            "MAR",
106            "--name-only",
107            "--cached"
108        )
109        .current_dir(&project_root)
110        .read()?;
111
112        Ok(Self {
113            staged_files: diff.lines().map(PathBuf::from).collect(),
114            project_root,
115        })
116    }
117
118    /// Returns an iterator over all the files staged for the commit.
119    pub fn staged_files(&self) -> impl Iterator<Item = &Path> {
120        self.staged_files.iter().map(PathBuf::as_path)
121    }
122
123    /// Accepts a function predicate that accepts a relative path to the staged
124    /// file and returns `false` if the given file should be removed from this
125    /// [`PreCommitContext`]
126    pub fn retain_staged_files(&mut self, mut f: impl FnMut(&Path) -> bool) {
127        self.staged_files.retain(|it| f(it));
128    }
129
130    /// Returns the names of the crates that contain [`Self::staged_rust_files()`].
131    ///
132    /// Warning: this heuristically looks for `Cargo.toml` files and
133    /// searches for `name = "` substring in them to get the crate name
134    /// (i.e. it doesn't really parse them properly, but this works 99% of the
135    /// time and lets us save on a full-fledged toml parser dependency).
136    /// This heuristic may be relaxed in the future, and it shouldn't be considered a
137    /// breaking change.
138    pub fn touched_crates(&self) -> HashSet<String> {
139        self.staged_rust_files()
140            .filter_map(|rust_file_path| {
141                rust_file_path.ancestors().find_map(|candidate| {
142                    let cargo_toml = self.project_root.join(candidate).join("Cargo.toml");
143                    let cargo_toml = fs::read_to_string(&cargo_toml).ok()?;
144
145                    Self::parse_crate_name(&cargo_toml)
146                })
147            })
148            .collect()
149    }
150
151    /// Returns an iterator over all staged files with `.rs` extension.
152    pub fn staged_rust_files(&self) -> impl Iterator<Item = &Path> {
153        self.staged_files
154            .iter()
155            .filter(|path| path.extension() == Some(OsStr::new("rs")))
156            .map(PathBuf::as_path)
157    }
158
159    fn parse_crate_name(cargo_toml: &str) -> Option<String> {
160        // FIXME: do some more robust toml parsing here:
161        let name_prefix = "\nname = \"";
162        let name = cargo_toml.find(name_prefix)? + name_prefix.len();
163        let len = cargo_toml[name..]
164            .find('"')
165            .expect("Invalid toml, couldn't find closing double quote");
166        Some(cargo_toml[name..name + len].to_owned())
167    }
168
169    /// Runs `cargo fmt` against the [`Self::touched_crates()`]
170    pub fn rustfmt(&self) -> Result<()> {
171        let touched_crates = self.touched_crates();
172        if touched_crates.is_empty() {
173            return Ok(());
174        }
175
176        cmd!(std::env::var("CARGO")
177            .as_ref()
178            .map(Deref::deref)
179            .unwrap_or("cargo"))
180        .arg("fmt")
181        .arg("--package")
182        .args(touched_crates)
183        .run()?;
184
185        Ok(())
186    }
187
188    /// Pushes the changes introduced to staged files in the working tree
189    /// to the git index. It is important to call this function once you've
190    /// modified some staged files (e.g. via [`Self::rustfmt()`])
191    pub fn stage_new_changes(&self) -> Result<()> {
192        run!("git", "update-index", "--again")?;
193        Ok(())
194    }
195}
196
197/// Copies the [`std::env::current_exe()`] file to `${project_root}/.git/hooks/pre-commit`
198/// That's all you need to register a git pre-commit hook.
199///
200/// It will silently overwrite the existing git pre-commit hook.
201pub fn install_self_as_hook(project_root: impl AsRef<Path>) -> Result<()> {
202    let hook_path = project_root
203        .as_ref()
204        .join(".git")
205        .join("hooks")
206        .join("pre-commit")
207        .with_extension(consts::EXE_EXTENSION);
208
209    let me = env::current_exe()?;
210    fs::copy(me, hook_path)?;
211
212    Ok(())
213}
214
215/// Searches for a project root dir, which is a directory that contains
216/// a `.git` dir as its direct child (it should also be the root of
217/// the project's `Rust` crate or [cargo workspace][cargo-workspace]).
218///
219/// It uses the following steps:
220/// 1. Use the value of [`$GIT_DIR`][git-dir] env variable if it is present.
221/// (This variable is set by git when it invokes current process as a hook).
222/// 2. Fallback to the output of [`git rev-parse --show-toplevel`][git-rev-parse].
223///
224/// [git-dir]: https://stackoverflow.com/a/37927943/9259330
225/// [git-rev-parse]: https://git-scm.com/docs/git-rev-parse#Documentation/git-rev-parse.txt---show-toplevel
226/// [cargo-workspace]: https://doc.rust-lang.org/book/ch14-03-cargo-workspaces.html
227pub fn locate_project_root() -> Result<PathBuf> {
228    Ok(env::var("GIT_DIR").map(Into::into).or_else(|_| {
229        cmd!("git", "rev-parse", "--show-toplevel")
230            .read()
231            .map(|it| it.trim_end().into())
232    })?)
233}