diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 1728b2183..9682350c9 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -68,6 +68,10 @@ env: # and absent is what it was here, so the file that describes every other # check was the one file nothing checked. ACTIONLINT_VERSION: 1.7.12 + # scripts/hook-check would install git hooks into each runner's checkout and + # fail the build over them. Nobody commits here, and the commit-log step + # replays every message the hooks would have judged. + CODETRIAL_SKIP_HOOKS: 1 # The license and source policy in deny.toml, pinned like the tools above: # a release that reads that file differently should arrive as a bump. @@ -749,7 +753,7 @@ jobs: shell: bash - name: Build the self-contained executable if: runner.os != 'Linux' - run: cargo build --release --locked --target ${{ matrix.target }} + run: cargo build --release --locked -p codetrial --target ${{ matrix.target }} # GitHub retired the Ubuntu 20.04 runner, but its glibc 2.31 remains the # useful Linux release baseline. Build inside Bullseye, which carries the # same glibc, rather than on the runner. This is deliberately not musl: @@ -801,7 +805,7 @@ jobs: --env CXX=/clang/bin/clang++ \ --env GITHUB_SHA \ ${{ matrix.image }} \ - sh -c 'RUSTUP_TOOLCHAIN=${RUST_VERSION:?} exec cargo build --release --locked --target "$1"' \ + sh -c 'RUSTUP_TOOLCHAIN=${RUST_VERSION:?} exec cargo build --release --locked -p codetrial --target "$1"' \ sh ${{ matrix.target }} # A successful link does not prove the Objective-C categories survived. # The selector reference remains even when its implementation is absent, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 46a0b6c69..ebfa9fbdc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,8 +97,9 @@ own already in the way, or a `core.hooksPath` shared with other repositories, is left alone and reported with how to integrate it. Your own hook keeps running once moved to `.git/hooks/.local`, after ours. `./scripts/install-git-hooks.sh --check` asks the same question without writing. -`cargo build` runs the same installer whenever that check fails, and skips it -once the hooks are in effect; what needs you shows up as a Cargo warning. +`cargo build` and `cargo check` run the same installer on every build, and +fail when the hooks cannot be put in effect, printing what needs you. Set +`CODETRIAL_SKIP_HOOKS=1` for a checkout that cannot take hooks. Formatting is required of every contributor; only a pull request Dependabot opened and wrote has formatting drift reported rather than failed. The pre-commit @@ -130,11 +131,13 @@ Refuse a permission listing that may be truncated Prove an empty test run is not a pass ``` -A commit needs a body: the hook and CI refuse a subject alone. Fixup, squash and -merge commits are exempt, and so are Dependabot's. The body carries the premise -and the trade, usually in a single paragraph. This tree keeps its detailed -reasoning in the comment next to the code, so a body retelling the mechanics -duplicates that comment and then goes stale on its own. +A commit needs a body: the hook and CI refuse a subject alone. Merge commits are +exempt, and so are Dependabot's. A `fixup!`, `squash!` or `amend!` subject is +refused at commit, at push and in CI alike: fold the change into the commit it +fixes, with `git rebase -i --autosquash` for an older one. The body carries the +premise and the trade, usually in a single paragraph. This tree keeps its +detailed reasoning in the comment next to the code, so a body retelling the +mechanics duplicates that comment and then goes stale on its own. The hook rejects the shape that does it outright: a body line opening with `How:`, `Step:`, `Steps:`, `Changes:` or an `Implementation:` heading. Whether what is left says anything is the part only a reviewer can judge. diff --git a/Cargo.lock b/Cargo.lock index d729469d6..22c788ef9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -825,6 +825,10 @@ dependencies = [ "digest", ] +[[package]] +name = "hook-check" +version = "0.0.0" + [[package]] name = "http" version = "1.5.0" diff --git a/Cargo.toml b/Cargo.toml index 7d19a1e8d..9b24aa21b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,6 +96,14 @@ tokio = { version = "1.54.1", features = ["test-util"] } name = "audio-latency" test = true +# scripts/hook-check holds every `cargo build` to the git hooks being in +# effect; its build.rs says why it is a member of its own. `vendor/` holds path +# dependencies, not members. +[workspace] +members = ["scripts/hook-check"] +default-members = [".", "scripts/hook-check"] +exclude = ["vendor"] + # Release archives distribute the executable, not a debugging target. Cargo # already omits debug information in this profile; omit linker symbols too. # diff --git a/build.rs b/build.rs index c83d7ee60..5873d7dbf 100644 --- a/build.rs +++ b/build.rs @@ -1,5 +1,5 @@ -//! Makes `rust-embed` see a changed asset tree, records the commit being -//! built, and installs the git hooks. +//! Makes `rust-embed` see a changed asset tree and records the commit being +//! built. //! //! The derive expands to one `include_bytes!` per file, and rustc records //! those in dep-info, so *editing* an embedded asset already rebuilds. Files @@ -19,7 +19,6 @@ use std::process::Command; fn main() { println!("cargo:rerun-if-changed=web"); set_build_commit(); - install_git_hooks(); } /// 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) { fn git_path(root: &std::path::Path, name: &str) -> Option { git_output(root, &["rev-parse", "--git-path", name]).map(|path| root.join(path.trim())) } - -/// Installs a wrapper for each `scripts/git-*.sh` in the hooks directory of a -/// checkout, unless they are already in effect. -/// -/// A hook nobody installed is a hook nobody runs, and the first person to find -/// that out is whoever reads a commit message the rules would have caught. A -/// build is the one step every contributor takes, so it is where installation -/// belongs; `make hooks` stays for anyone who wants it on its own. `cargo -/// check`, clippy and rust-analyzer share this script's run, so whichever comes -/// first in a fresh clone installs. -/// -/// Never fails the build. A checkout can be read-only, a hooks directory can -/// belong to somebody else's tooling, and a Windows machine may have no `sh`. -/// None of that is a reason to refuse to compile, so a failure is a warning -/// and the build goes on. -fn install_git_hooks() { - let root = std::env::var_os("CARGO_MANIFEST_DIR").map(std::path::PathBuf::from); - let Some(root) = root else { return }; - - // A worktree has a `.git` file rather than a directory, and both mean the - // same thing here: this is a checkout, so it has hooks worth installing. A - // packaged crate has neither. - if !root.join(".git").exists() { - return; - } - - // Watched so that a changed installer, or a deleted hook, reruns this. - // Without the second Cargo would keep the build script fresh and a removed - // hook would stay removed until something else forced a rerun. The first - // build after a fresh clone pays one extra rebuild, because installing the - // hooks is itself a change to the directory being watched. - // - // Not the whole of scripts/, though adding a hook there is the one change - // this does not notice. That directory holds every gate script in the - // repository and each edit to one would rebuild the binary; `make hooks` - // covers the rare case at no standing cost. - // - // A checkout without the installer has nothing to run, and a watch on a - // path that does not exist would rerun this script on every build. - if !root.join("scripts/install-git-hooks.sh").is_file() { - return; - } - println!("cargo:rerun-if-changed=scripts/install-git-hooks.sh"); - run_hook_installer(&root); - - // Asked of git rather than assumed to be `.git/hooks`: a linked worktree - // keeps its hooks with the common directory, and core.hooksPath moves them - // anywhere. After the install rather than before, because a clone made with - // an empty template has no hooks directory until the installer makes one, - // and a directory that was not there to watch is never watched. - if let Some(hooks) = git_path(&root, "hooks") - && hooks.is_dir() - { - println!("cargo:rerun-if-changed={}", hooks.display()); - } -} - -fn run_hook_installer(root: &std::path::Path) { - let installer = root.join("scripts/install-git-hooks.sh"); - let run = |args: &[&str]| { - Command::new("sh") - .arg(&installer) - .args(args) - .current_dir(root) - .output() - }; - - // Installed already, which is every build after the first: the check writes - // nothing, so it leaves the watched directory alone and says nothing. The - // installer stays the one judge of what installed means. - // - // No `sh` at all is the other quiet case, in practice a Git for Windows - // install that did not put its shell on PATH. The release job builds a - // Windows target on every push, and a warning nobody can act on teaches - // people to skim them. - match run(&["--check"]) { - Ok(output) if output.status.success() => return, - Err(error) if error.kind() == std::io::ErrorKind::NotFound => return, - _ => {} - } - - // Cargo swallows a build script's stdout, which is right for the OK and - // HOOK lines. The installer writes what needs a person to stderr instead: a - // hook it refused to overwrite, a core.hooksPath shared with other - // repositories, and how to fix each. That reaches the terminal line for - // line or nothing does. - let reason = match run(&[]) { - Ok(output) if output.status.success() => return, - Ok(output) => String::from_utf8_lossy(&output.stderr).into_owned(), - Err(error) => error.to_string(), - }; - for line in reason.lines().filter(|line| !line.trim().is_empty()) { - println!("cargo::warning=git hooks: {line}"); - } - println!("cargo::warning=git hooks are not all in effect; run make hooks"); -} diff --git a/docs/development.md b/docs/development.md index 7a30f92e1..446c38a2f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -252,18 +252,27 @@ repository is reported as `SHARED` and nothing is written there. A checkout from before the scripts existed has nothing to run and commits as before. `./scripts/install-git-hooks.sh --check` runs the verification alone. -`build.rs` runs that check whenever the build script reruns and the install -only when it fails. The script runs on the first `cargo build`, `cargo check` -or rust-analyzer pass in a clone or worktree, and again when the installer or -the hooks directory changes, so a deleted hook comes back on the next build. -A refusal reaches the terminal as a Cargo warning and never fails the build. - -The hooks run the `scripts/` of the checkout they fire in, as `build.rs` does -on a build, so review a branch before committing on it as you would before -building it. No script of ours runs while a merge is concluded: by the time -its commit-msg or prepare-commit-msg fires the incoming tree is written, and -running it would make `git pull` execute what it pulled, so the wrapper skips -it, and there is no pre-merge-commit hook. CI checks a pull request's content. +Every `cargo build`, `cargo check`, `cargo run` and rust-analyzer pass in a +clone or worktree runs the install through `scripts/hook-check`, a +workspace member whose build script reruns on every build, so a deleted or +non-executable hook and a `core.hooksPath` set in a global config are all +caught on the next one. Anything the install cannot settle, a `FOREIGN` hook, a +`SHARED` hooks directory or no `sh` to run it with, fails the build with the +installer's own instructions. `CODETRIAL_SKIP_HOOKS=1` skips the check: CI sets +it, since it replays every commit message anyway, and so can a checkout that +cannot take hooks. It is also what keeps `make uninstall-hooks` in effect, since +the next build otherwise puts the hooks back. `cargo test` leaves the check +out, since it would link an empty test binary for it each time, and so does a +command that names its targets: `-p codetrial`, `--bin`, `--test` or +`cargo install --path .`. + +The hooks run the `scripts/` of the checkout they fire in, as +`scripts/hook-check` does on a build, so review a branch before committing on it +as you would before building it. No script of ours runs while a merge is +concluded: by the time its commit-msg or prepare-commit-msg fires the incoming +tree is written, and running it would make `git pull` execute what it pulled, so +the wrapper skips it, and there is no pre-merge-commit hook. CI checks a pull +request's content. The pre-commit hook runs ESLint, `ruff check` and `shellcheck`, then `scripts/indent.sh --staged`, over a checkout of the index, so an unstaged edit diff --git a/scripts/git-commit-msg.sh b/scripts/git-commit-msg.sh index 65c8a9f87..f331d0dc0 100755 --- a/scripts/git-commit-msg.sh +++ b/scripts/git-commit-msg.sh @@ -20,7 +20,8 @@ if [ "${1:-}" = "--rules" ]; then 2. Keep the subject within 50 characters, and say more than one word 3. Capitalize the subject; no trailing period, no backticks 4. Use the imperative mood: "Fix", not "Fixed" or "Fixes" -5. No conventional-commit prefix: not "fix:", not "feat(web):" +5. No conventional-commit prefix: not "fix:", not "feat(web):", + and no "fixup!", "squash!" or "amend!": fold those before committing 6. Wrap the body at 72 characters 7. Write a body saying what and why; the diff says how @@ -62,10 +63,15 @@ error() failed=1 } -# A fixup lands in the commit it names, and a merge subject is git's wording, -# not the author's. Judging either is judging a message nobody wrote. +# An autosquash marker is a note to a rebase that has not happened yet, and the +# subject behind it is a copy of another commit's: the change belongs in that +# commit before this one exists. A merge subject is git's wording, not the +# author's, and judging it is judging a message nobody wrote. case "$subject" in - "fixup! "* | "squash! "* | "amend! "*) exit 0 ;; + "fixup! "* | "squash! "* | "amend! "*) + error "fold this into the commit it fixes: git commit --amend for HEAD, git rebase -i --autosquash for an older one" + exit 1 + ;; "Merge branch "* | "Merge branches "* | "Merge tag "* | "Merge commit "* | \ "Merge pull request "* | "Merge remote-tracking branch "*) exit 0 ;; esac @@ -96,9 +102,9 @@ case "$subject" in *.) error "subject must not end with a period" ;; esac -# The subject completes "this commit will ...", so a past tense or a gerund in -# the first word is the whole tell. Checking one word catches nearly all of it -# and never argues with a legitimate "Fix the parser" style subject. +# The subject completes "this commit will ...", so a past tense, a gerund or a +# third person in the first word is the whole tell. Named rather than matched by +# suffix, which would also refuse "Alias", "Seed" and "Bring". first_word=$(printf '%s' "$subject" | sed 's/[[:space:]].*//' | tr '[:upper:]' '[:lower:]') case "$first_word" in added | adds | adding | adjusted | adjusts | adjusting | allowed | allows | \ @@ -114,7 +120,13 @@ case "$first_word" in removes | removing | renamed | renames | renaming | replaced | replaces | \ replacing | reverted | reverts | reverting | tested | tests | testing | \ tidied | tidies | tidying | updated | updates | updating | used | uses | \ - using) + using | wired | wires | wiring | lets | letting | built | builds | \ + building | wrote | writes | writing | ran | runs | running | kept | \ + keeps | keeping | sent | sends | sending | brought | brings | bringing | \ + took | takes | taking | gave | gives | giving | got | gets | getting | \ + began | begins | beginning | chose | chooses | choosing | met | meets | \ + meeting | held | holds | holding | told | tells | telling | thought | \ + thinks | thinking) error "use the imperative mood: \"$first_word\" describes what you did" ;; esac diff --git a/scripts/hook-check/Cargo.toml b/scripts/hook-check/Cargo.toml new file mode 100644 index 000000000..7df6fbe7a --- /dev/null +++ b/scripts/hook-check/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "hook-check" +version = "0.0.0" +edition = "2024" +publish = false + +# Nothing to compile but the build script, which is the whole of this crate. +[lib] +path = "lib.rs" +test = false +doctest = false diff --git a/scripts/hook-check/build.rs b/scripts/hook-check/build.rs new file mode 100644 index 000000000..423157998 --- /dev/null +++ b/scripts/hook-check/build.rs @@ -0,0 +1,66 @@ +//! Holds every `cargo build` of a checkout to its git hooks being in effect, +//! installing them when they are not and failing the build when that does not +//! work. +//! +//! A crate of its own, which the server does not depend on, so that this script +//! can rerun on every build: Cargo notices a change by a file's mtime, and +//! neither a hook losing its executable bit nor a `core.hooksPath` set in a +//! global config touches one. Rerunning a build script recompiles its package, +//! which for this empty one costs a fraction of a second and for the server +//! would cost a full rebuild. + +use std::path::PathBuf; +use std::process::{Command, exit}; + +fn main() { + // A path that never exists is stale on every build, which is the point. + println!("cargo:rerun-if-changed=.rerun-on-every-build"); + + // For CI, which commits nothing and replays every message anyway, and for a + // checkout that cannot take hooks at all, such as a read-only mount. Only + // `1`, as documented: a `0` or `false` meant to keep the check would + // otherwise turn it off. + if std::env::var_os("CODETRIAL_SKIP_HOOKS").is_some_and(|value| value == "1") { + return; + } + let Some(root) = checkout() else { return }; + + // The installer is the one judge of what in effect means: present, current, + // ours and executable, wherever core.hooksPath points today. It rewrites + // only what is missing or stale, so a healthy checkout costs a read. Cargo + // swallows a build script's stdout, which is right for the OK lines, and + // shows its stderr when the script fails: there the installer says what it + // refused, a hook of the user's or a shared hooks directory, and the fix. + let installer = root.join("scripts/install-git-hooks.sh"); + let reason = match Command::new("sh") + .arg(&installer) + .current_dir(&root) + .output() + { + Ok(output) if output.status.success() => return, + Ok(output) => String::from_utf8_lossy(&output.stderr).into_owned(), + Err(error) => format!("cannot run {}: {error}\n", installer.display()), + }; + eprint!("{reason}"); + eprintln!("git hooks are not all in effect; settle the above and build again."); + eprintln!("Where hooks cannot be installed, CODETRIAL_SKIP_HOOKS=1 skips this."); + exit(1); +} + +/// The checkout this crate sits in, two levels up, when git agrees it is the +/// top of a work tree. git skips a stray or half-copied `.git` and keeps +/// searching upward, and a tree unpacked inside some other repository must not +/// write hooks into that one, so an empty prefix is the test. A source archive +/// has no repository, and git refuses. So does a git that is missing, refused +/// by safe.directory or facing a broken `.git`, and that is a skip too: hooks +/// guard commits, and a git that cannot run here makes none. +fn checkout() -> Option { + let manifest = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR")?); + let root = manifest.parent()?.parent()?.to_path_buf(); + let output = Command::new("git") + .args(["rev-parse", "--show-prefix"]) + .current_dir(&root) + .output() + .ok()?; + (output.status.success() && output.stdout.trim_ascii().is_empty()).then_some(root) +} diff --git a/scripts/hook-check/lib.rs b/scripts/hook-check/lib.rs new file mode 100644 index 000000000..b39613cb1 --- /dev/null +++ b/scripts/hook-check/lib.rs @@ -0,0 +1 @@ +//! Empty on purpose: build.rs beside this file is the check. diff --git a/scripts/indent.sh b/scripts/indent.sh index db6507951..efa844f20 100755 --- a/scripts/indent.sh +++ b/scripts/indent.sh @@ -109,7 +109,7 @@ list() done } -rust=$(list 'build.rs' 'src/*.rs' 'tests/*.rs') +rust=$(list 'build.rs' 'src/*.rs' 'tests/*.rs' 'scripts/*.rs') shell=$(list '*.sh') # tests/ as well as scripts/: the Python under tests/ drives the recording and @@ -250,15 +250,17 @@ if [ "$mode" = check ]; then work=$(mktemp -d "$notes/tree.XXXXXX") || exit 2 # One tar pipe rather than a mkdir and a cp per file: 81 files was 162 - # process spawns and most of this gate's wall clock. Cargo.toml because - # `cargo fmt` reads the manifest to find the crate. A setting the tree does - # not have is left out, or tar would refuse the whole copy over it. + # process spawns and most of this gate's wall clock. Every manifest, because + # `cargo fmt` reads them to find the workspace and each member's files. A + # setting the tree does not have is left out, or tar would refuse the whole + # copy over it. copy=$files for setting in $configs; do [ -f "$setting" ] && copy="$copy $setting" done - printf '%s\n' "$copy" Cargo.toml \ + manifests=$(list 'Cargo.toml' '*/Cargo.toml') + printf '%s\n' "$copy" "$manifests" \ | tar -cf - -T - \ | (cd "$work" && tar -xf -) || exit 2 fi diff --git a/scripts/install-git-hooks.sh b/scripts/install-git-hooks.sh index 39330a660..3e5ea8b33 100755 --- a/scripts/install-git-hooks.sh +++ b/scripts/install-git-hooks.sh @@ -6,8 +6,8 @@ # invoked it instead of naming one by path. # # Exit 0 means every hook is in effect, not merely that this script ran. --check -# asks the same question without writing anything, and build.rs asks it on every -# build-script rerun. +# asks the same question without writing anything, and scripts/hook-check asks +# it on every build. set -u @@ -35,8 +35,18 @@ abs() *) printf '%s/%s\n' "$ROOT" "$1" ;; esac } -hooks=$(abs "$(git rev-parse --git-path hooks)") || exit 1 -common=$(abs "$(git rev-parse --git-common-dir)") || exit 1 + +# One git for both answers, split without another process: hook-check runs this +# on every build. +paths=$(git rev-parse --git-path hooks --git-common-dir) || exit 1 +{ + IFS= read -r hooks + IFS= read -r common +} << EOF +$paths +EOF +hooks=$(abs "$hooks") +common=$(abs "$common") # The wrapper says it is ours on its second line, so a later install can tell it # from a hook somebody wrote and rewrite it when its text changes, rather than @@ -129,7 +139,7 @@ foreign() # wrapper that lost it reads as installed while nothing runs. # # Progress goes to stdout and anything a person has to act on to stderr, so -# build.rs can pass the second on as it stands and drop the first. +# scripts/hook-check can pass the second on as it stands and drop the first. verify() { status=0 @@ -180,6 +190,13 @@ if [ "$mode" = uninstall ]; then exit 0 fi +# Every hook in effect already, which is every build after the first: nothing to +# write, and one pass of verify says so. +if report=$(verify 2> /dev/null); then + printf '%s\n' "$report" + exit 0 +fi + # core.hooksPath can send every repository's hooks to one shared directory, # usually from a global config. Writing there would install these wrappers for # repositories that have no scripts/ to run, so this only reports. diff --git a/scripts/test-git-hooks.sh b/scripts/test-git-hooks.sh index e7c05de96..ae0eb0ff9 100755 --- a/scripts/test-git-hooks.sh +++ b/scripts/test-git-hooks.sh @@ -250,7 +250,20 @@ expect 0 "a message that follows the rules" \ One paragraph saying why this exists, wrapped inside the limit the hook enforces." expect 0 "the rules can be printed" sh "$ROOT/scripts/git-commit-msg.sh" --rules -expect 0 "a fixup is left alone" message "fixup! Add the scratch repository" +# Each with a body, so the marker is the only thing the rules can refuse. +body=" + +One paragraph saying why this exists." +for marker in fixup squash amend; do + expect 1 "a $marker! subject" message "$marker! Add the scratch repository$body" + contains "the $marker! refusal names the fold" "$work/last.out" "git commit --amend" +done +for verb in Wiring Wires Lets Wrote Builds; do + expect 1 "the non-imperative $verb" message "$verb the scratch repository$body" +done +for verb in Seed Alias Bring; do + expect 0 "the imperative $verb" message "$verb the scratch repository$body" +done expect 0 "a merge subject is left alone" message "Merge branch 'topic' into main" expect 1 "a lowercase subject" message "add the scratch repository" expect 1 "a past-tense subject" message "Added the scratch repository" diff --git a/tests/test_test_gate.py b/tests/test_test_gate.py index 3ecf6f8f5..6d31abab6 100644 --- a/tests/test_test_gate.py +++ b/tests/test_test_gate.py @@ -747,5 +747,120 @@ def test_unknown_lane_fails_before_running_any_gate(self): self.assertIn("usage:", result.stderr) +class BuildHooksTests(unittest.TestCase): + # The check is skipped where CI sets the switch, which is also where this + # suite runs, so each case clears it and builds with git configs of its own. + # RUSTFLAGS too: the workflow's mold flag names a linker this lane does not + # install, and an empty crate has no use for it. + def env(self, base): + env = dict(os.environ) + env.pop("CODETRIAL_SKIP_HOOKS", None) + return env | { + "RUSTFLAGS": "", + "GIT_CONFIG_GLOBAL": str(base / "gitconfig"), + "GIT_CONFIG_NOSYSTEM": "1", + "CARGO_TARGET_DIR": str(base / "target"), + } + + def test_every_cargo_build_repairs_or_refuses_the_hooks(self): + if not shutil.which("cargo"): + self.skipTest("build hook check needs cargo") + with tempfile.TemporaryDirectory() as tmp: + base = Path(tmp).resolve() + repo = base / "repo" + env = self.env(base) + shutil.copytree(ROOT / "scripts/hook-check", repo / "scripts/hook-check") + for source in (ROOT / "scripts").glob("git-*.sh"): + shutil.copy(source, repo / "scripts") + shutil.copy(ROOT / "scripts/install-git-hooks.sh", repo / "scripts") + (repo / "Cargo.toml").write_text( + '[workspace]\nmembers = ["scripts/hook-check"]\nresolver = "3"\n' + ) + subprocess.run(["git", "init", "-q"], cwd=repo, env=env, check=True) + + def build(check=True, skip=None): + extra = {} if skip is None else {"CODETRIAL_SKIP_HOOKS": skip} + result = subprocess.run( + ["cargo", "build", "--offline"], + cwd=repo, + env=env | extra, + capture_output=True, + text=True, + ) + if check: + self.assertEqual(result.returncode, 0, result.stderr) + return result + + build() + hook = repo / ".git/hooks/commit-msg" + + # Neither change moves an mtime Cargo watches; the next build + # still puts each right. + hook.chmod(0o644) + gone = repo / ".git/hooks/pre-commit" + gone.unlink() + build() + self.assertTrue(os.access(hook, os.X_OK)) + self.assertTrue(os.access(gone, os.X_OK)) + + (base / "gitconfig").write_text(f"[core]\n\thooksPath = {base}/shared\n") + refused = build(check=False) + self.assertNotEqual(refused.returncode, 0) + self.assertIn("SHARED", refused.stderr) + self.assertIn("CODETRIAL_SKIP_HOOKS=1", refused.stderr) + build(skip="1") + # Set but empty is how an unset export in a script reads; it skips + # nothing. + self.assertNotEqual(build(check=False, skip="").returncode, 0) + + def test_build_installs_only_in_its_own_checkout(self): + if not shutil.which("rustc"): + self.skipTest("build hook check needs rustc") + with tempfile.TemporaryDirectory() as tmp: + base = Path(tmp).resolve() + binary = base / "build-script" + subprocess.run( + [ + "rustc", + "--edition=2024", + str(ROOT / "scripts/hook-check/build.rs"), + "-o", + str(binary), + ], + check=True, + capture_output=True, + ) + env = self.env(base) + repo = base / "repo" + repo.mkdir() + subprocess.run(["git", "init", "-q", str(repo)], env=env, check=True) + + def installs(root): + crate = root / "scripts/hook-check" + crate.mkdir(parents=True) + (root / "scripts/install-git-hooks.sh").write_text( + '#!/bin/sh\nprintf installed > "$PWD/installed"\n' + ) + subprocess.run( + [str(binary)], + cwd=crate, + env=env | {"CARGO_MANIFEST_DIR": str(crate)}, + check=True, + capture_output=True, + ) + return (root / "installed").exists() + + self.assertTrue(installs(repo)) + # git skips a `.git` it cannot read and finds the outer repository, + # whose hooks are not this crate's to install. + stray = repo / "stray" + (stray / ".git").mkdir(parents=True) + self.assertFalse(installs(stray)) + broken = repo / "broken" + broken.mkdir() + (broken / ".git").write_text("gitdir: missing\n") + self.assertFalse(installs(broken)) + + if __name__ == "__main__": unittest.main()