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}