Skip to content

Commit 307c0e3

Browse files
committed
Hold every cargo build to the git hooks
A hook that lost its executable bit, or a global core.hooksPath, never touched a file Cargo watches, so build.rs could not notice either and only warned when it did. The check now lives in scripts/hook-check, a workspace member whose build script reruns on every build, so only that empty crate recompiles. A build whose hooks cannot be put in effect fails with the installer's instructions; CODETRIAL_SKIP_HOOKS=1 opts out, and CI sets it. cargo test and commands naming their targets leave the check out. The installer now asks git once and returns early when every hook is in effect, and indent.sh covers the new crate.
1 parent 7a26cf3 commit 307c0e3

11 files changed

Lines changed: 258 additions & 123 deletions

File tree

‎.github/workflows/check.yml‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ env:
6868
# and absent is what it was here, so the file that describes every other
6969
# check was the one file nothing checked.
7070
ACTIONLINT_VERSION: 1.7.12
71+
# scripts/hook-check would install git hooks into each runner's checkout and
72+
# fail the build over them. Nobody commits here, and the commit-log step
73+
# replays every message the hooks would have judged.
74+
CODETRIAL_SKIP_HOOKS: 1
7175

7276
jobs:
7377
audit:
@@ -746,7 +750,7 @@ jobs:
746750
shell: bash
747751
- name: Build the self-contained executable
748752
if: runner.os != 'Linux'
749-
run: cargo build --release --locked --target ${{ matrix.target }}
753+
run: cargo build --release --locked -p codetrial --target ${{ matrix.target }}
750754
# GitHub retired the Ubuntu 20.04 runner, but its glibc 2.31 remains the
751755
# useful Linux release baseline. Build inside Bullseye, which carries the
752756
# same glibc, rather than on the runner. This is deliberately not musl:
@@ -792,7 +796,7 @@ jobs:
792796
--env CXX=/clang/bin/clang++ \
793797
--env GITHUB_SHA \
794798
${{ matrix.image }} \
795-
cargo build --release --locked --target ${{ matrix.target }}
799+
cargo build --release --locked -p codetrial --target ${{ matrix.target }}
796800
# A successful link does not prove the Objective-C categories survived.
797801
# The selector reference remains even when its implementation is absent,
798802
# so match a method name entry rather than any occurrence of the selector.

‎Cargo.lock‎

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎Cargo.toml‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,14 @@ tokio = { version = "1.54.1", features = ["test-util"] }
8585
name = "audio-latency"
8686
test = true
8787

88+
# scripts/hook-check holds every `cargo build` to the git hooks being in
89+
# effect; its build.rs says why it is a member of its own. `vendor/` holds path
90+
# dependencies, not members.
91+
[workspace]
92+
members = ["scripts/hook-check"]
93+
default-members = [".", "scripts/hook-check"]
94+
exclude = ["vendor"]
95+
8896
# Release archives distribute the executable, not a debugging target. Cargo
8997
# already omits debug information in this profile; omit linker symbols too.
9098
#

‎build.rs‎

Lines changed: 2 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
//! Makes `rust-embed` see a changed asset tree, records the commit being
2-
//! built, and installs the git hooks.
1+
//! Makes `rust-embed` see a changed asset tree and records the commit being
2+
//! built.
33
//!
44
//! The derive expands to one `include_bytes!` per file, and rustc records
55
//! those in dep-info, so *editing* an embedded asset already rebuilds. Files
@@ -19,7 +19,6 @@ use std::process::Command;
1919
fn main() {
2020
println!("cargo:rerun-if-changed=web");
2121
set_build_commit();
22-
install_git_hooks();
2322
}
2423

2524
/// The released binary must identify the source it was built from even when
@@ -102,99 +101,3 @@ fn watch_git_head(root: &std::path::Path) {
102101
fn git_path(root: &std::path::Path, name: &str) -> Option<std::path::PathBuf> {
103102
git_output(root, &["rev-parse", "--git-path", name]).map(|path| root.join(path.trim()))
104103
}
105-
106-
/// Installs a wrapper for each `scripts/git-*.sh` in the hooks directory of a
107-
/// checkout, unless they are already in effect.
108-
///
109-
/// A hook nobody installed is a hook nobody runs, and the first person to find
110-
/// that out is whoever reads a commit message the rules would have caught. A
111-
/// build is the one step every contributor takes, so it is where installation
112-
/// belongs; `make hooks` stays for anyone who wants it on its own. `cargo
113-
/// check`, clippy and rust-analyzer share this script's run, so whichever comes
114-
/// first in a fresh clone installs.
115-
///
116-
/// Never fails the build. A checkout can be read-only, a hooks directory can
117-
/// belong to somebody else's tooling, and a Windows machine may have no `sh`.
118-
/// None of that is a reason to refuse to compile, so a failure is a warning
119-
/// and the build goes on.
120-
fn install_git_hooks() {
121-
let root = std::env::var_os("CARGO_MANIFEST_DIR").map(std::path::PathBuf::from);
122-
let Some(root) = root else { return };
123-
124-
// A worktree has a `.git` file rather than a directory, and both mean the
125-
// same thing here: this is a checkout, so it has hooks worth installing. A
126-
// packaged crate has neither.
127-
if !root.join(".git").exists() {
128-
return;
129-
}
130-
131-
// Watched so that a changed installer, or a deleted hook, reruns this.
132-
// Without the second Cargo would keep the build script fresh and a removed
133-
// hook would stay removed until something else forced a rerun. The first
134-
// build after a fresh clone pays one extra rebuild, because installing the
135-
// hooks is itself a change to the directory being watched.
136-
//
137-
// Not the whole of scripts/, though adding a hook there is the one change
138-
// this does not notice. That directory holds every gate script in the
139-
// repository and each edit to one would rebuild the binary; `make hooks`
140-
// covers the rare case at no standing cost.
141-
//
142-
// A checkout without the installer has nothing to run, and a watch on a
143-
// path that does not exist would rerun this script on every build.
144-
if !root.join("scripts/install-git-hooks.sh").is_file() {
145-
return;
146-
}
147-
println!("cargo:rerun-if-changed=scripts/install-git-hooks.sh");
148-
run_hook_installer(&root);
149-
150-
// Asked of git rather than assumed to be `.git/hooks`: a linked worktree
151-
// keeps its hooks with the common directory, and core.hooksPath moves them
152-
// anywhere. After the install rather than before, because a clone made with
153-
// an empty template has no hooks directory until the installer makes one,
154-
// and a directory that was not there to watch is never watched.
155-
if let Some(hooks) = git_path(&root, "hooks")
156-
&& hooks.is_dir()
157-
{
158-
println!("cargo:rerun-if-changed={}", hooks.display());
159-
}
160-
}
161-
162-
fn run_hook_installer(root: &std::path::Path) {
163-
let installer = root.join("scripts/install-git-hooks.sh");
164-
let run = |args: &[&str]| {
165-
Command::new("sh")
166-
.arg(&installer)
167-
.args(args)
168-
.current_dir(root)
169-
.output()
170-
};
171-
172-
// Installed already, which is every build after the first: the check writes
173-
// nothing, so it leaves the watched directory alone and says nothing. The
174-
// installer stays the one judge of what installed means.
175-
//
176-
// No `sh` at all is the other quiet case, in practice a Git for Windows
177-
// install that did not put its shell on PATH. The release job builds a
178-
// Windows target on every push, and a warning nobody can act on teaches
179-
// people to skim them.
180-
match run(&["--check"]) {
181-
Ok(output) if output.status.success() => return,
182-
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return,
183-
_ => {}
184-
}
185-
186-
// Cargo swallows a build script's stdout, which is right for the OK and
187-
// HOOK lines. The installer writes what needs a person to stderr instead: a
188-
// hook it refused to overwrite, a core.hooksPath shared with other
189-
// repositories, and how to fix each. That reaches the terminal line for
190-
// line or nothing does.
191-
let reason = match run(&[]) {
192-
Ok(output) if output.status.success() => return,
193-
Ok(output) => String::from_utf8_lossy(&output.stderr).into_owned(),
194-
Err(error) => error.to_string(),
195-
};
196-
for line in reason.lines().filter(|line| !line.trim().is_empty()) {
197-
println!("cargo::warning=git hooks: {line}");
198-
}
199-
println!("cargo::warning=git hooks are not all in effect; run make hooks");
200-
}

‎docs/development.md‎

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -246,18 +246,27 @@ repository is reported as `SHARED` and nothing is written there. A checkout
246246
from before the scripts existed has nothing to run and commits as before.
247247
`./scripts/install-git-hooks.sh --check` runs the verification alone.
248248

249-
`build.rs` runs that check whenever the build script reruns and the install
250-
only when it fails. The script runs on the first `cargo build`, `cargo check`
251-
or rust-analyzer pass in a clone or worktree, and again when the installer or
252-
the hooks directory changes, so a deleted hook comes back on the next build.
253-
A refusal reaches the terminal as a Cargo warning and never fails the build.
254-
255-
The hooks run the `scripts/` of the checkout they fire in, as `build.rs` does
256-
on a build, so review a branch before committing on it as you would before
257-
building it. No script of ours runs while a merge is concluded: by the time
258-
its commit-msg or prepare-commit-msg fires the incoming tree is written, and
259-
running it would make `git pull` execute what it pulled, so the wrapper skips
260-
it, and there is no pre-merge-commit hook. CI checks a pull request's content.
249+
Every `cargo build`, `cargo check`, `cargo run` and rust-analyzer pass in a
250+
clone or worktree runs the install through `scripts/hook-check`, a
251+
workspace member whose build script reruns on every build, so a deleted or
252+
non-executable hook and a `core.hooksPath` set in a global config are all
253+
caught on the next one. Anything the install cannot settle, a `FOREIGN` hook, a
254+
`SHARED` hooks directory or no `sh` to run it with, fails the build with the
255+
installer's own instructions. `CODETRIAL_SKIP_HOOKS=1` skips the check: CI sets
256+
it, since it replays every commit message anyway, and so can a checkout that
257+
cannot take hooks. It is also what keeps `make uninstall-hooks` in effect, since
258+
the next build otherwise puts the hooks back. `cargo test` leaves the check
259+
out, since it would link an empty test binary for it each time, and so does a
260+
command that names its targets: `-p codetrial`, `--bin`, `--test` or
261+
`cargo install --path .`.
262+
263+
The hooks run the `scripts/` of the checkout they fire in, as
264+
`scripts/hook-check` does on a build, so review a branch before committing on it
265+
as you would before building it. No script of ours runs while a merge is
266+
concluded: by the time its commit-msg or prepare-commit-msg fires the incoming
267+
tree is written, and running it would make `git pull` execute what it pulled, so
268+
the wrapper skips it, and there is no pre-merge-commit hook. CI checks a pull
269+
request's content.
261270

262271
The pre-commit hook runs ESLint, `ruff check` and `shellcheck`, then
263272
`scripts/indent.sh --staged`, over a checkout of the index, so an unstaged edit

‎scripts/hook-check/Cargo.toml‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
[package]
2+
name = "hook-check"
3+
version = "0.0.0"
4+
edition = "2024"
5+
publish = false
6+
7+
# Nothing to compile but the build script, which is the whole of this crate.
8+
[lib]
9+
path = "lib.rs"
10+
test = false
11+
doctest = false

‎scripts/hook-check/build.rs‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
//! Holds every `cargo build` of a checkout to its git hooks being in effect,
2+
//! installing them when they are not and failing the build when that does not
3+
//! work.
4+
//!
5+
//! A crate of its own, which the server does not depend on, so that this script
6+
//! can rerun on every build: Cargo notices a change by a file's mtime, and
7+
//! neither a hook losing its executable bit nor a `core.hooksPath` set in a
8+
//! global config touches one. Rerunning a build script recompiles its package,
9+
//! which for this empty one costs a fraction of a second and for the server
10+
//! would cost a full rebuild.
11+
12+
use std::path::PathBuf;
13+
use std::process::{Command, exit};
14+
15+
fn main() {
16+
// A path that never exists is stale on every build, which is the point.
17+
println!("cargo:rerun-if-changed=.rerun-on-every-build");
18+
19+
// For CI, which commits nothing and replays every message anyway, and for a
20+
// checkout that cannot take hooks at all, such as a read-only mount. Only
21+
// `1`, as documented: a `0` or `false` meant to keep the check would
22+
// otherwise turn it off.
23+
if std::env::var_os("CODETRIAL_SKIP_HOOKS").is_some_and(|value| value == "1") {
24+
return;
25+
}
26+
let Some(root) = checkout() else { return };
27+
28+
// The installer is the one judge of what in effect means: present, current,
29+
// ours and executable, wherever core.hooksPath points today. It rewrites
30+
// only what is missing or stale, so a healthy checkout costs a read. Cargo
31+
// swallows a build script's stdout, which is right for the OK lines, and
32+
// shows its stderr when the script fails: there the installer says what it
33+
// refused, a hook of the user's or a shared hooks directory, and the fix.
34+
let installer = root.join("scripts/install-git-hooks.sh");
35+
let reason = match Command::new("sh")
36+
.arg(&installer)
37+
.current_dir(&root)
38+
.output()
39+
{
40+
Ok(output) if output.status.success() => return,
41+
Ok(output) => String::from_utf8_lossy(&output.stderr).into_owned(),
42+
Err(error) => format!("cannot run {}: {error}\n", installer.display()),
43+
};
44+
eprint!("{reason}");
45+
eprintln!("git hooks are not all in effect; settle the above and build again.");
46+
eprintln!("Where hooks cannot be installed, CODETRIAL_SKIP_HOOKS=1 skips this.");
47+
exit(1);
48+
}
49+
50+
/// The checkout this crate sits in, two levels up, when git agrees it is the
51+
/// top of a work tree. git skips a stray or half-copied `.git` and keeps
52+
/// searching upward, and a tree unpacked inside some other repository must not
53+
/// write hooks into that one, so an empty prefix is the test. A source archive
54+
/// has no repository, and git refuses. So does a git that is missing, refused
55+
/// by safe.directory or facing a broken `.git`, and that is a skip too: hooks
56+
/// guard commits, and a git that cannot run here makes none.
57+
fn checkout() -> Option<PathBuf> {
58+
let manifest = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR")?);
59+
let root = manifest.parent()?.parent()?.to_path_buf();
60+
let output = Command::new("git")
61+
.args(["rev-parse", "--show-prefix"])
62+
.current_dir(&root)
63+
.output()
64+
.ok()?;
65+
(output.status.success() && output.stdout.trim_ascii().is_empty()).then_some(root)
66+
}

‎scripts/hook-check/lib.rs‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
//! Empty on purpose: build.rs beside this file is the check.

‎scripts/indent.sh‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,7 @@ list()
108108
done
109109
}
110110

111-
rust=$(list 'build.rs' 'src/*.rs' 'tests/*.rs')
111+
rust=$(list 'build.rs' 'src/*.rs' 'tests/*.rs' 'scripts/*.rs')
112112
shell=$(list '*.sh')
113113

114114
# tests/ as well as scripts/: the Python under tests/ drives the recording and
@@ -249,15 +249,17 @@ if [ "$mode" = check ]; then
249249
work=$(mktemp -d "$notes/tree.XXXXXX") || exit 2
250250

251251
# One tar pipe rather than a mkdir and a cp per file: 81 files was 162
252-
# process spawns and most of this gate's wall clock. Cargo.toml because
253-
# `cargo fmt` reads the manifest to find the crate. A setting the tree does
254-
# not have is left out, or tar would refuse the whole copy over it.
252+
# process spawns and most of this gate's wall clock. Every manifest, because
253+
# `cargo fmt` reads them to find the workspace and each member's files. A
254+
# setting the tree does not have is left out, or tar would refuse the whole
255+
# copy over it.
255256
copy=$files
256257
for setting in $configs; do
257258
[ -f "$setting" ] && copy="$copy
258259
$setting"
259260
done
260-
printf '%s\n' "$copy" Cargo.toml \
261+
manifests=$(list 'Cargo.toml' '*/Cargo.toml')
262+
printf '%s\n' "$copy" "$manifests" \
261263
| tar -cf - -T - \
262264
| (cd "$work" && tar -xf -) || exit 2
263265
fi

‎scripts/install-git-hooks.sh‎

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@
66
# invoked it instead of naming one by path.
77
#
88
# Exit 0 means every hook is in effect, not merely that this script ran. --check
9-
# asks the same question without writing anything, and build.rs asks it on every
10-
# build-script rerun.
9+
# asks the same question without writing anything, and scripts/hook-check asks
10+
# it on every build.
1111

1212
set -u
1313

@@ -35,8 +35,18 @@ abs()
3535
*) printf '%s/%s\n' "$ROOT" "$1" ;;
3636
esac
3737
}
38-
hooks=$(abs "$(git rev-parse --git-path hooks)") || exit 1
39-
common=$(abs "$(git rev-parse --git-common-dir)") || exit 1
38+
39+
# One git for both answers, split without another process: hook-check runs this
40+
# on every build.
41+
paths=$(git rev-parse --git-path hooks --git-common-dir) || exit 1
42+
{
43+
IFS= read -r hooks
44+
IFS= read -r common
45+
} << EOF
46+
$paths
47+
EOF
48+
hooks=$(abs "$hooks")
49+
common=$(abs "$common")
4050

4151
# The wrapper says it is ours on its second line, so a later install can tell it
4252
# from a hook somebody wrote and rewrite it when its text changes, rather than
@@ -129,7 +139,7 @@ foreign()
129139
# wrapper that lost it reads as installed while nothing runs.
130140
#
131141
# Progress goes to stdout and anything a person has to act on to stderr, so
132-
# build.rs can pass the second on as it stands and drop the first.
142+
# scripts/hook-check can pass the second on as it stands and drop the first.
133143
verify()
134144
{
135145
status=0
@@ -180,6 +190,13 @@ if [ "$mode" = uninstall ]; then
180190
exit 0
181191
fi
182192

193+
# Every hook in effect already, which is every build after the first: nothing to
194+
# write, and one pass of verify says so.
195+
if report=$(verify 2> /dev/null); then
196+
printf '%s\n' "$report"
197+
exit 0
198+
fi
199+
183200
# core.hooksPath can send every repository's hooks to one shared directory,
184201
# usually from a global config. Writing there would install these wrappers for
185202
# repositories that have no scripts/ to run, so this only reports.

0 commit comments

Comments
 (0)