From 9ba1e356e89dffcb2f4396ff279b65d0477d1570 Mon Sep 17 00:00:00 2001 From: Michael Kraus Date: Sat, 31 Oct 2026 19:58:22 +0100 Subject: [PATCH 1/4] Update the git hooks: one Julia process per hook (harness githooks) Co-Authored-By: Claude Opus 5.5 --- .githooks/pre-commit | 217 +------------------------------ .githooks/pre-commit.jl | 277 ++++++++++++++++++++++++++++++++++++++++ .githooks/pre-push | 64 +--------- .githooks/pre-push.jl | 108 ++++++++++++++++ 4 files changed, 395 insertions(+), 271 deletions(-) create mode 100644 .githooks/pre-commit.jl create mode 100644 .githooks/pre-push.jl diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 905a2bf31..ef7ad39f6 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -4,218 +4,11 @@ # Installed into each repository's .githooks/ by harness githooks — edit the canonical # copy and re-run the installer, never the copy in a repository. # -# Stages 1–3 run on STAGED .jl files only. A whole-tree JuliaFormatter run is not safe to assume -# terminates: one died inside SciML's `nest!` after 62 minutes of CPU. -# -# 0. test-layout.jl --check BLOCKS on a commit that touches test/, any file type -# 1. JuliaFormatter --check BLOCKS formatting is mechanical and always fixable -# 2. fatou lint WARNS see below -# 2.5 unicode NFC BLOCKS the tree is NFC, so a non-NFC file is a regression -# 3. package load BLOCKS catches a syntax error or a broken include -# -# `fatou lint` deliberately never blocks. It produces findings that are wrong. -# `unused-import` does not follow `include`, so it flags load-bearing imports in every module -# file; `docstring-argument-mismatch` cannot see that `f(::Type{T}, s)` binds `T`. -# -# Both of those can be silenced one at a time. `# fatou-ignore ` on its own line, directly -# above the finding, silences it. A trailing comment on the same line does nothing, and -# `fatou.toml` still rejects `per-file-ignores`. -# -# `parse-error` is what keeps this stage advisory. It is severity `error`, it CANNOT be -# suppressed, and all 37 findings measured across 32 packages are false. Fatou rejects two -# constructs that Julia parses: a parenthesised macro call whose body is a `for` block, as in -# `(@allocated for ... end) ÷ n`, and a `const` whose assignment is on the next line. - -# No `set -u`: macOS's own bash is 3.2, where `"${arr[@]}"` on an empty array is an -# unbound-variable error rather than an empty expansion. -set -o pipefail - -red=$'\033[0;31m'; yellow=$'\033[0;33m'; green=$'\033[0;32m'; dim=$'\033[2m'; nc=$'\033[0m' - -root=$(git rev-parse --show-toplevel) || exit 1 -cd "$root" || exit 1 - -# Staged Julia files that still exist (A/C/M, not deleted). -# -# Read in a loop rather than with `mapfile`. There IS a bash 5 at /opt/homebrew/bin/bash and -# `/usr/bin/env bash` finds it from an interactive shell — but a hook inherits the PATH of -# whatever ran git, and an IDE, a GUI client or a launchd job need not have /opt/homebrew/bin on -# it. Fall back to macOS's bash 3.2 and `mapfile` is *not found*, which leaves the array empty -# and returns 0: the hook would report "0 staged files" and wave every commit through. This loop -# costs two lines and cannot do that. -# -# Same reason the command substitution is checked: if `git diff` itself fails, an empty result -# must not be read as "nothing staged". Fail closed. -if ! staged_raw=$(git diff --cached --name-only --diff-filter=ACM -- '*.jl'); then - printf '\n%spre-commit: could not list staged files.%s Refusing to pass silently.\n\n' \ - "$red" "$nc" - exit 1 -fi - -status=0 - -# ---------------------------------------------------------------- 0. test layout (blocking) -# A commit that touches test/ — any file, a deletion too — must leave the tests in the layout of -# the test convention. `test-layout.jl` is installed beside this hook by harness githooks; a -# missing copy blocks rather than skips, because a stage that skips itself reports "clean" when -# it checked nothing. It checks the working tree, not the index. -if ! test_raw=$(git diff --cached --name-only -- test/); then - printf '\n%spre-commit: could not list staged files.%s Refusing to pass silently.\n\n' \ - "$red" "$nc" - exit 1 -fi -if [ -n "$test_raw" ]; then - if [ ! -f .githooks/test-layout.jl ]; then - printf '%stest layout: .githooks/test-layout.jl is missing%s — re-run harness githooks --apply.\n' \ - "$red" "$nc" - status=1 - elif layout_out=$(julia --startup-file=no .githooks/test-layout.jl --check "$root" 2>&1); then - printf '%stest layout: ok%s\n' "$green" "$nc" - else - printf '%stest layout: FAILED%s — the tests are not in the layout of the convention:\n' \ - "$red" "$nc" - printf '%s\n' "$layout_out" | sed 's/^/ /' - status=1 - fi -fi - -staged=() -while IFS= read -r f; do - [ -n "$f" ] && staged+=("$f") -done <<< "$staged_raw" - -if [ ${#staged[@]} -eq 0 ]; then - [ $status -ne 0 ] && printf '\n%spre-commit failed.%s Use --no-verify only if you know why.\n\n' "$red" "$nc" - exit $status -fi - -printf '\n%spre-commit%s %d staged .jl file(s)\n' "$dim" "$nc" "${#staged[@]}" - -# ---------------------------------------------------------------- 1. formatting (blocking) -# Honours this repository's own .JuliaFormatter.toml (style = "sciml" everywhere). A hook -# cannot reach an MCP server, so this calls JuliaFormatter directly rather than through -# Kaimon's format_code — same engine, different entry point. -# Checked one file at a time. `format(ARGS)` returns a single Bool for the whole batch, so the -# message could only ever name every staged file — measured 2026-09-05, a commit of seven files -# with two offenders reported all seven, and finding the real two by hand cost a second rejected -# commit. Per-file costs nothing: the batch call formats each file individually anyway, and -# `.JuliaFormatter.toml` is discovered by searching upward from the file either way. -if ! julia --startup-file=no -e ' - let env = get(ENV, "RESEARCH_HARNESS_JULIA", joinpath(homedir(), ".local", "share", "research-harness", "julia")) - pushfirst!(LOAD_PATH, env) - end - using JuliaFormatter - bad = filter(f -> !format(f; overwrite = false), ARGS) - isempty(bad) || @error "not formatted:\n" * join(bad, "\n") - exit(isempty(bad) ? 0 : 1) - ' "${staged[@]}"; then - printf '%sformatting: FAILED%s — run JuliaFormatter over the files above, then re-stage.\n' \ - "$red" "$nc" - status=1 -else - printf '%sformatting: ok%s\n' "$green" "$nc" -fi - -# ---------------------------------------------------------------- 2. lint (advisory only) -# fatou writes its findings AND its summary to stderr, not stdout, and exits 1 when it has any. -# Capturing stdout alone left `lint_out` empty on every commit — measured 2026-09-07, a file with -# 7 findings reported "clean" — so this stage was vacuous in all 38 repositories from the day it -# was added. The verdict comes from the exit status; with findings there is no summary line, so -# every captured line is one finding. -# -# Time-boxed. `fatou lint` does not always terminate: on a generated source file of 25 MB and -# 91 625 lines, two runs burned a core each for six and a half hours before anyone noticed. -# An advisory stage that hangs is worse than one that fails, because a hung commit looks like -# nothing happening rather than like a refusal. 60 s is ~600x the measured whole-tree time of a -# large package. -# -# `timeout` is coreutils and is not on a stock macOS, so fall back to running bare rather than -# skipping the stage. -if command -v fatou >/dev/null 2>&1; then - if command -v timeout >/dev/null 2>&1; then - fatou_lint() { timeout -k 5 60 fatou "$@"; } - else - fatou_lint() { fatou "$@"; } - fi - - lint_out=$(fatou_lint lint --force-exclude --output concise "${staged[@]}" 2>&1) - lint_rc=$? - - if [ "$lint_rc" -eq 0 ]; then - printf '%sfatou lint: clean%s\n' "$green" "$nc" - elif [ "$lint_rc" -ge 124 ]; then - # 124 = timed out, 137 = killed after -k. Not a finding, and not a reason to block. - printf '%sfatou lint: timed out after 60s — stage skipped, not a failure%s\n' "$yellow" "$nc" - printf ' A staged file is pathological for fatou.\n' - else - n=$(printf '%s\n' "$lint_out" | grep -c . || true) - printf '%sfatou lint: %s finding(s) — advisory, not blocking%s\n' "$yellow" "$n" "$nc" - printf '%s\n' "$lint_out" | sed 's/^/ /' - fi -fi +# The launcher of pre-commit.jl, which harness githooks installs beside it and which holds the +# stages. One Julia process runs them all; only the load test starts a child. -# ---------------------------------------------------------------- 2.5 unicode NFC (blocking) -# Sources here are NFC-normalised: `ū`, `ḡ`, `ṗ`, `ẋ` as a single codepoint rather than a base -# letter plus a combining mark. Julia's parser normalises identifiers to NFC, so an NFD file -# compiles to byte-identical symbols and nothing about dispatch changes — but every byte-matching -# tool (grep, ripgrep, an editor search, an agent's string replacement, Documenter's doctest -# comparison) then fails to match a pattern typed in NFC, silently. -# -# Blocking, because the invariant holds tree-wide: every tracked file in Packages/ and -# Experiments/ is NFC, so anything this stage reports is a regression rather than a backlog. -# -# The invariant is `s == Unicode.normalize(s, :NFC)`, never the absence of combining marks: -# `q̇`, `v̄` and `f̄` have no precomposed codepoint and stay two codepoints under NFC. -# -# The check is inlined rather than calling ~/Research/Harness/githooks/nfc.jl, for the same -# reason stages 1 and 3 inline theirs: a hook that skips itself because a path outside the -# repository is missing reports "clean" when it checked nothing. Use `nfc.jl --apply` to fix what -# this reports. -if ! nfc_out=$(julia --startup-file=no -e ' - using Unicode - bad = filter(ARGS) do f - s = read(f, String) - isvalid(s) && s != Unicode.normalize(s, :NFC) - end - isempty(bad) || println(join(bad, "\n")) - exit(isempty(bad) ? 0 : 1) - ' "${staged[@]}" 2>&1); then - n=$(printf '%s\n' "$nfc_out" | grep -c . || true) - printf '%sunicode: %s file(s) not NFC%s\n' "$red" "$n" "$nc" - printf '%s\n' "$nfc_out" | sed 's/^/ /' - printf ' %sfix: julia --startup-file=no ~/Research/Harness/githooks/nfc.jl --apply %s\n' \ - "$dim" "$nc" - status=1 -else - printf '%sunicode: NFC%s\n' "$green" "$nc" -fi - -# ---------------------------------------------------------------- 3. load test (blocking) -# Only for an actual package: an experiment repository has a Project.toml without a name. -pkg=$(sed -n 's/^name[[:space:]]*=[[:space:]]*"\(.*\)"/\1/p' Project.toml 2>/dev/null | head -1) - -# The output is captured from the one run, not produced by a second one. It is also filtered -# rather than tailed: for the failure this stage exists to catch, the cause — `ERROR: LoadError` -# and the file:line — is at the TOP, and `tail -20` kept backtrace frames 15-18 and the -# "Failed to precompile" summary while discarding it. Measured 2026-09-05: every session that -# hit this had to re-run Julia by hand to learn why. Dropping the ` [n] frame` and ` @ Module` -# lines is what those sessions did; it leaves the message and its location in the first lines. -if [ -n "$pkg" ]; then - if load_out=$(julia --startup-file=no --project=. -e "using $pkg" 2>&1); then - printf '%sloads: ok%s\n' "$green" "$nc" - else - printf '%sloads: FAILED%s — `using %s` errors:\n' "$red" "$nc" "$pkg" - printf '%s\n' "$load_out" \ - | grep -vE '^[[:space:]]*\[[0-9]+\][[:space:]]|^[[:space:]]*@ ' \ - | head -20 | sed 's/^/ /' - status=1 - fi -fi - -if [ $status -ne 0 ]; then - printf '\n%spre-commit failed.%s Use --no-verify only if you know why.\n\n' "$red" "$nc" +if ! command -v julia >/dev/null 2>&1; then + printf 'pre-commit: julia is not on the PATH.\n' >&2 exit 1 fi - -printf '\n' -exit 0 +exec julia --startup-file=no "$(dirname "$0")/pre-commit.jl" "$@" diff --git a/.githooks/pre-commit.jl b/.githooks/pre-commit.jl new file mode 100644 index 000000000..1ed9d3d0e --- /dev/null +++ b/.githooks/pre-commit.jl @@ -0,0 +1,277 @@ +# The stages of the shared pre-commit hook. Canonical copy: githooks/pre-commit.jl of the +# harness. Installed beside its launcher, `pre-commit`, into each repository's .githooks/ by +# harness githooks — edit the canonical copy and re-run the installer, never the copy in a +# repository. +# +# One Julia process runs every stage except the load test, which runs in a child process: +# +# 0. test layout BLOCKS on a commit that touches test/, any file type +# 1. JuliaFormatter BLOCKS formatting is mechanical and always fixable +# 2. fatou lint WARNS see `lint` +# 2.5 unicode NFC BLOCKS the tree is NFC, so a non-NFC file is a regression +# 3. package load BLOCKS catches a syntax error or a broken include +# +# Stages 1–3 run on STAGED .jl files only. A whole-tree JuliaFormatter run is not sure to end: +# one has died inside SciML's `nest!` after 62 minutes of CPU. +# +# Included rather than run as the program file, this file defines its functions and runs nothing. + +using Unicode + +const RED, YELLOW, GREEN, DIM, NC = "\e[0;31m", "\e[0;33m", "\e[0;32m", "\e[2m", "\e[0m" + +"The `lines` joined, each indented by four spaces." +indent(lines) = join((" " * l for l in lines), "\n") + +""" +The paths that `git diff --cached --name-only` lists with `args`, or `nothing` when git fails. +An empty list from a git that failed would read as "nothing staged" and pass every commit, so +the caller refuses on `nothing`. +""" +function staged(args...) + out = IOBuffer() + p = run(pipeline(ignorestatus(`git diff --cached --name-only $args`); stdout = out)) + success(p) || return nothing + return filter(!isempty, split(String(take!(out)), '\n')) +end + +""" +Run `cmd` with its stdout and stderr in one stream. After `seconds`, send it SIGTERM, and +SIGKILL 5 s later if it still runs. Returns the exit code, or `nothing` when the time ran out, +and the output. +""" +function run_boxed(cmd, seconds) + out = Pipe() + p = run(pipeline(ignorestatus(cmd); stdin = devnull, stdout = out, stderr = out); wait = false) + close(out.in) + reader = @async read(out, String) + timedout = Ref(false) + stop = Timer(seconds) do _ + timedout[] = true + kill(p) + Timer(5) do _ + process_running(p) && kill(p, Base.SIGKILL) + end + end + wait(p) + close(stop) + text = fetch(reader) + return timedout[] ? nothing : p.exitcode, text +end + +""" +Stage 0. A commit that touches test/ — any file, a deletion too — must leave the tests in the +layout of the test convention. `test-layout.jl` is installed beside this program by harness +githooks; a missing copy blocks rather than skips, because a stage that skips itself reports +"clean" when it checked nothing. It checks the working tree, not the index. + +The script goes into a module of its own and its `violations` runs here: its command-line block +calls `exit`, and runs only when the script is the program file. +""" +function test_layout(root) + script = joinpath(root, ".githooks", "test-layout.jl") + if !isfile(script) + println("$(RED)test layout: .githooks/test-layout.jl is missing$(NC) — re-run harness githooks --apply.") + return false + end + found = try + layout = Module(:TestLayout) + Base.include(layout, script) + @invokelatest layout.violations(root) + catch e + [sprint(showerror, e)] + end + if isempty(found) + println("$(GREEN)test layout: ok$(NC)") + return true + end + println("$(RED)test layout: FAILED$(NC) — the tests are not in the layout of the convention:") + println(indent(split(join(found, "\n"), '\n'))) + return false +end + +""" +Stage 1. Honours this repository's own .JuliaFormatter.toml. A hook cannot reach an MCP +server, so this calls JuliaFormatter directly, from the harness Julia environment. + +Checked one file at a time: `format(files)` returns a single Bool for the whole batch, so its +message could only name every staged file. Per file costs nothing: the batch call formats each +file on its own anyway, and `.JuliaFormatter.toml` is found by searching upward from the file +either way. +""" +function formatting(files) + bad = try + env = get(ENV, "RESEARCH_HARNESS_JULIA", + joinpath(homedir(), ".local", "share", "research-harness", "julia")) + pushfirst!(LOAD_PATH, env) + id = Base.identify_package("JuliaFormatter") + id === nothing && + error("JuliaFormatter is in no environment of the load path; $env is first") + format = Base.require(id).format + filter(f -> !Base.invokelatest(format, f; overwrite = false), files) + catch e + println(stderr, sprint(showerror, e)) + files + end + if isempty(bad) + println("$(GREEN)formatting: ok$(NC)") + return true + end + println(stderr, "not formatted:\n", join(bad, "\n")) + println("$(RED)formatting: FAILED$(NC) — run JuliaFormatter over the files above, then re-stage.") + return false +end + +""" +Stage 2, advisory. `fatou lint` never blocks, because it produces findings that are wrong. +`unused-import` does not follow `include`, so it flags load-bearing imports in every module +file; `docstring-argument-mismatch` cannot see that `f(::Type{T}, s)` binds `T`. Both can be +silenced one at a time: `# fatou-ignore ` on its own line, directly above the finding, +silences it. A trailing comment on the same line does nothing, and `fatou.toml` rejects +`per-file-ignores`. + +`parse-error` is what keeps this stage advisory. It is severity `error`, it cannot be +suppressed, and all 37 findings measured across 32 packages are false. Fatou rejects two +constructs that Julia parses: a parenthesised macro call whose body is a `for` block, as in +`(@allocated for ... end) ÷ n`, and a `const` whose assignment is on the next line. + +fatou writes its findings and its summary to stderr, not stdout, and exits 1 when it has any, +so the verdict comes from the exit status and both streams are read. With findings there is no +summary line, so every line is one finding. + +Time-boxed: `fatou lint` does not always end. On a generated source file of 25 MB and 91 625 +lines, two runs each burned a core for six and a half hours. An advisory stage that hangs is +worse than one that fails, because a hung commit looks like nothing happening rather than like +a refusal. 60 s is about 600 times the measured whole-tree time of a large package. +""" +function lint(files) + if Sys.which("fatou") === nothing + println("$(YELLOW)fatou lint: skipped — fatou is not on the PATH$(NC)") + return + end + code, out = run_boxed(`fatou lint --force-exclude --output concise $files`, 60) + if code === nothing + println("$(YELLOW)fatou lint: timed out after 60s — stage skipped, not a failure$(NC)") + println(" A staged file is pathological for fatou.") + elseif code == 0 + println("$(GREEN)fatou lint: clean$(NC)") + else + findings = filter(!isempty, split(out, '\n')) + println("$(YELLOW)fatou lint: $(length(findings)) finding(s) — advisory, not blocking$(NC)") + isempty(findings) || println(indent(findings)) + end +end + +""" +Stage 2.5. Sources here are NFC-normalised: `ū`, `ḡ`, `ṗ`, `ẋ` as a single codepoint rather +than a base letter plus a combining mark. Julia's parser normalises identifiers to NFC, so an +NFD file compiles to byte-identical symbols and nothing about dispatch changes — but every +byte-matching tool (grep, ripgrep, an editor search, an agent's string replacement, +Documenter's doctest comparison) then fails to match a pattern typed in NFC, silently. + +Blocking, because the invariant holds tree-wide: every tracked file in Packages/ and +Experiments/ is NFC, so anything this stage reports is a regression rather than a backlog. + +The invariant is `s == Unicode.normalize(s, :NFC)`, never the absence of combining marks: +`q̇`, `v̄` and `f̄` have no precomposed codepoint and stay two codepoints under NFC. + +The check is here rather than a call of ~/Research/Harness/githooks/nfc.jl, as the other stages +are: a hook that skips itself because a path outside the repository is missing reports "clean" +when it checked nothing. `nfc.jl --apply` fixes what this reports. +""" +function nfc(files) + bad = filter(files) do f + s = read(f, String) + isvalid(s) && s != Unicode.normalize(s, :NFC) + end + if isempty(bad) + println("$(GREEN)unicode: NFC$(NC)") + return true + end + println("$(RED)unicode: $(length(bad)) file(s) not NFC$(NC)") + println(indent(bad)) + println(" $(DIM)fix: julia --startup-file=no ~/Research/Harness/githooks/nfc.jl --apply $(NC)") + return false +end + +"The `name` of `Project.toml` in the current directory, or `nothing` for none." +function package_name() + isfile("Project.toml") || return nothing + for line in eachline("Project.toml") + m = match(r"^name\s*=\s*\"(.*)\"", line) + m === nothing || return m[1] + end + return nothing +end + +""" +Stage 3, only for an actual package: an experiment repository has a Project.toml without a name. + +The package loads in a child process started with `Base.julia_cmd()`, the binary of this +process, in the repository's own environment: JuliaFormatter and most packages share +PrecompileTools and Preferences, and in one process the one loaded second gets the first one's +versions. + +The output is captured from the one run and filtered rather than tailed: for the failure this +stage exists to catch, the cause — `ERROR: LoadError` and the file:line — is at the top, and a +tail keeps backtrace frames and the "Failed to precompile" summary while it drops the cause. +Dropping the ` [n] frame` and ` @ Module` lines leaves the message and its location in the +first lines. +""" +function load(pkg) + out = IOBuffer() + cmd = `$(Base.julia_cmd()) --startup-file=no --project=. -e "using $pkg"` + p = run(pipeline(ignorestatus(cmd); stdin = devnull, stdout = out, stderr = out)) + if success(p) + println("$(GREEN)loads: ok$(NC)") + return true + end + println("$(RED)loads: FAILED$(NC) — `using $pkg` errors:") + lines = filter(l -> !occursin(r"^\s*\[\d+\]\s|^\s*@ ", l), split(String(take!(out)), '\n')) + println(indent(first(lines, 20))) + return false +end + +function refuse_unlisted() + println("\n$(RED)pre-commit: could not list staged files.$(NC) Refusing to pass silently.\n") + exit(1) +end + +function main() + root = try + readchomp(`git rev-parse --show-toplevel`) + catch + exit(1) + end + cd(root) + + jl = staged("--diff-filter=ACM", "--", "*.jl") + jl === nothing && refuse_unlisted() + tests = staged("--", "test/") + tests === nothing && refuse_unlisted() + + ok = isempty(tests) || test_layout(root) + if isempty(jl) + ok || + println("\n$(RED)pre-commit failed.$(NC) Use --no-verify only if you know why.\n") + exit(ok ? 0 : 1) + end + + println("\n$(DIM)pre-commit$(NC) $(length(jl)) staged .jl file(s)") + ok &= formatting(jl) + lint(jl) + ok &= nfc(jl) + pkg = package_name() + pkg === nothing || (ok &= load(pkg)) + + if !ok + println("\n$(RED)pre-commit failed.$(NC) Use --no-verify only if you know why.\n") + exit(1) + end + println() + exit(0) +end + +if abspath(PROGRAM_FILE) == @__FILE__ + main() +end diff --git a/.githooks/pre-push b/.githooks/pre-push index 1d0007a72..9959774ee 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -4,65 +4,11 @@ # Installed into each repository's .githooks/ by harness githooks — edit the canonical # copy and re-run the installer, never the copy in a repository. # -# Runs the full test suite, but only when pushing to main/master. The hooks this replaces ran -# the suite on every push to every branch, which is what made a push take 10-30 minutes with no -# output and look exactly like a network hang. A topic branch does not need the gate; CI has it. -# -# --check-bounds=auto matters. Up to Julia 1.12, `Pkg.test` runs the tests with -# `--check-bounds=yes` whatever the calling process uses, which forces recompilation of every -# dependency into a separate cache and diverges from what CI and a normal session use. That -# divergence is the reason a test suite must not run concurrently with another heavy Julia job over -# the same packages. So the flag goes to the test process through `julia_args`, as well as to the -# calling process. -# -# If you kill this hook, kill its Julia child too: an orphaned test process survives, and such -# processes accumulate over days. - -# No `set -u`: macOS ships bash 3.2, whose handling of unset expansions differs from bash 4+. -set -o pipefail - -red=$'\033[0;31m'; green=$'\033[0;32m'; dim=$'\033[2m'; nc=$'\033[0m' +# The launcher of pre-push.jl, which harness githooks installs beside it and which holds the +# gate and the suite. git's ref lines reach it on stdin. -root=$(git rev-parse --show-toplevel) -cd "$root" || exit 1 - -# git feeds " " on stdin, one line per ref. -gated=0 -while read -r _local_ref _local_sha remote_ref _remote_sha; do - case "$remote_ref" in - refs/heads/main|refs/heads/master) gated=1 ;; - esac -done - -if [ "$gated" -eq 0 ]; then - printf '\n%spre-push%s not main/master — skipping the suite, CI will run it.\n\n' \ - "$dim" "$nc" - exit 0 -fi - -pkg=$(sed -n 's/^name[[:space:]]*=[[:space:]]*"\(.*\)"/\1/p' Project.toml 2>/dev/null | head -1) -reponame=$(basename "$root") - -printf '\n%spre-push%s pushing to main/master — running the full suite for %s.\n' \ - "$dim" "$nc" "${pkg:-$reponame}" -printf '%sThis takes 10-30 minutes and prints nothing until it finishes. That is not a hang.%s\n\n' \ - "$dim" "$nc" - -if [ -n "${pkg:-}" ]; then - suite=(julia --startup-file=no --check-bounds=auto --project=. \ - -e "using Pkg; Pkg.test(\"$pkg\"; julia_args = \`--check-bounds=auto\`)") -elif [ -f test/runtests.jl ]; then - # Not a package — run its runtests.jl directly. - suite=(julia --startup-file=no --check-bounds=auto --project=. test/runtests.jl) -else - printf '%sno package name and no test/runtests.jl — nothing to run.%s\n\n' "$dim" "$nc" - exit 0 -fi - -if ! "${suite[@]}"; then - printf '\n%sERROR — tests must pass before pushing to main.%s\n\n' "$red" "$nc" +if ! command -v julia >/dev/null 2>&1; then + printf 'pre-push: julia is not on the PATH.\n' >&2 exit 1 fi - -printf '\n%sSuite passed.%s\n\n' "$green" "$nc" -exit 0 +exec julia --startup-file=no "$(dirname "$0")/pre-push.jl" "$@" diff --git a/.githooks/pre-push.jl b/.githooks/pre-push.jl new file mode 100644 index 000000000..0af716e97 --- /dev/null +++ b/.githooks/pre-push.jl @@ -0,0 +1,108 @@ +# The gate and the suite of the shared pre-push hook. Canonical copy: githooks/pre-push.jl of +# the harness. Installed beside its launcher, `pre-push`, into each repository's .githooks/ by +# harness githooks — edit the canonical copy and re-run the installer, never the copy in a +# repository. +# +# Runs the full test suite, but only when pushing to main/master. A suite on every push to every +# branch makes a push take 10-30 minutes with no output, which looks exactly like a network +# hang. A topic branch does not need the gate; CI has it. + +const RED, GREEN, DIM, NC = "\e[0;31m", "\e[0;32m", "\e[2m", "\e[0m" + +""" +Whether a ref line from git — ` `, one per ref +— pushes to `refs/heads/main` or `refs/heads/master`. +""" +function gated(line) + fields = split(line) + return length(fields) >= 3 && fields[3] in ("refs/heads/main", "refs/heads/master") +end + +"The `name` of `Project.toml` in the current directory, or `nothing` for none." +function package_name() + isfile("Project.toml") || return nothing + for line in eachline("Project.toml") + m = match(r"^name\s*=\s*\"(.*)\"", line) + m === nothing || return m[1] + end + return nothing +end + +""" +The suite's command, or `nothing` when there is nothing to run. + +--check-bounds=auto matters. Up to Julia 1.12, `Pkg.test` runs the tests with +`--check-bounds=yes` whatever the calling process uses, which forces recompilation of every +dependency into a separate cache and diverges from what CI and a normal session use. That +divergence is the reason a test suite must not run concurrently with another heavy Julia job +over the same packages. So the flag goes to the test process through `julia_args`, as well as +to the process that calls `Pkg.test`. +""" +function suite(pkg) + julia = `$(Base.julia_cmd()) --startup-file=no --check-bounds=auto --project=.` + pkg === nothing || + return `$julia -e "using Pkg; Pkg.test(\"$pkg\"; julia_args = \`--check-bounds=auto\`)"` + isfile(joinpath("test", "runtests.jl")) && return `$julia test/runtests.jl` # not a package + return nothing +end + +""" +Run `cmd` in a process group of its own and wait for it; return whether it passed. + +An interrupt of this program goes on to the whole group, the suite and the test process that +`Pkg.test` starts, and the program waits for the suite before it exits: a test process that +outlives its hook survives as an orphan, and such processes accumulate over days. +""" +function run_suite(cmd) + flush(stdout) + p = run( + pipeline(ignorestatus(Cmd(cmd; detach = true)); stdin = devnull, stdout, stderr); + wait = false) + try + wait(p) + catch e + e isa InterruptException || rethrow() + ccall(:kill, Cint, (Cint, Cint), -getpid(p), Base.SIGINT) + wait(p) + println("\n$(RED)pre-push: interrupted; the suite has stopped.$(NC)\n") + exit(130) + end + return success(p) +end + +function main() + Base.exit_on_sigint(false) + root = try + readchomp(`git rev-parse --show-toplevel`) + catch + exit(1) + end + cd(root) + + # Every line is read, so git never writes to a closed pipe. + if !any(gated, readlines(stdin)) + println("\n$(DIM)pre-push$(NC) not main/master — skipping the suite, CI will run it.\n") + exit(0) + end + + pkg = package_name() + println("\n$(DIM)pre-push$(NC) pushing to main/master — running the full suite for ", + something(pkg, basename(root)), ".") + println("$(DIM)This takes 10-30 minutes and prints nothing until it finishes. That is not a hang.$(NC)\n") + + cmd = suite(pkg) + if cmd === nothing + println("$(DIM)no package name and no test/runtests.jl — nothing to run.$(NC)\n") + exit(0) + end + if !run_suite(cmd) + println("\n$(RED)ERROR — tests must pass before pushing to main.$(NC)\n") + exit(1) + end + println("\n$(GREEN)Suite passed.$(NC)\n") + exit(0) +end + +if abspath(PROGRAM_FILE) == @__FILE__ + main() +end From 35ff32ca03f48be653a45300a03bd034c22bde65 Mon Sep 17 00:00:00 2001 From: Michael Kraus Date: Sat, 31 Oct 2026 22:14:50 +0100 Subject: [PATCH 2/4] Update the git hooks: need Julia only where a check runs (harness githooks) Co-Authored-By: Claude Opus 5.5 --- .githooks/pre-commit | 8 ++++++++ .githooks/pre-commit.jl | 8 ++++---- .githooks/pre-push | 14 ++++++++++++-- 3 files changed, 24 insertions(+), 6 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index ef7ad39f6..1a3c16448 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -8,6 +8,14 @@ # stages. One Julia process runs them all; only the load test starts a child. if ! command -v julia >/dev/null 2>&1; then + # A commit with no staged .jl file and no change below test/ runs no stage, so it needs no + # julia. A git that cannot list the staged files refuses, as pre-commit.jl does. + if ! jl=$(git diff --cached --name-only --diff-filter=ACMR -- '*.jl') || + ! tests=$(git diff --cached --name-only -- test/); then + printf 'pre-commit: could not list staged files.\n' >&2 + exit 1 + fi + [ -z "$jl$tests" ] && exit 0 printf 'pre-commit: julia is not on the PATH.\n' >&2 exit 1 fi diff --git a/.githooks/pre-commit.jl b/.githooks/pre-commit.jl index 1ed9d3d0e..9ec805f1d 100644 --- a/.githooks/pre-commit.jl +++ b/.githooks/pre-commit.jl @@ -30,9 +30,9 @@ the caller refuses on `nothing`. """ function staged(args...) out = IOBuffer() - p = run(pipeline(ignorestatus(`git diff --cached --name-only $args`); stdout = out)) + p = run(pipeline(ignorestatus(`git diff --cached --name-only -z $args`); stdout = out)) success(p) || return nothing - return filter(!isempty, split(String(take!(out)), '\n')) + return filter(!isempty, split(String(take!(out)), '\0')) end """ @@ -108,7 +108,7 @@ function formatting(files) id === nothing && error("JuliaFormatter is in no environment of the load path; $env is first") format = Base.require(id).format - filter(f -> !Base.invokelatest(format, f; overwrite = false), files) + filter(f -> !Base.invokelatest(format, f; overwrite = false, throw_on_error = true), files) catch e println(stderr, sprint(showerror, e)) files @@ -245,7 +245,7 @@ function main() end cd(root) - jl = staged("--diff-filter=ACM", "--", "*.jl") + jl = staged("--diff-filter=ACMR", "--", "*.jl") jl === nothing && refuse_unlisted() tests = staged("--", "test/") tests === nothing && refuse_unlisted() diff --git a/.githooks/pre-push b/.githooks/pre-push index 9959774ee..e6b680cb9 100755 --- a/.githooks/pre-push +++ b/.githooks/pre-push @@ -8,7 +8,17 @@ # gate and the suite. git's ref lines reach it on stdin. if ! command -v julia >/dev/null 2>&1; then - printf 'pre-push: julia is not on the PATH.\n' >&2 - exit 1 + # A push that updates no main or master runs no suite, so it needs no julia. The remote ref + # is the third field of each line, as in pre-push.jl's gate. + while read -r _ _ remote_ref _; do + case $remote_ref in + refs/heads/main | refs/heads/master) + printf 'pre-push: julia is not on the PATH.\n' >&2 + exit 1 + ;; + esac + done + printf '\npre-push not main/master — skipping the suite, CI will run it.\n\n' + exit 0 fi exec julia --startup-file=no "$(dirname "$0")/pre-push.jl" "$@" From 1affd4837c85ea7d458503188ae702855afc348b Mon Sep 17 00:00:00 2001 From: Michael Kraus Date: Sat, 31 Oct 2026 23:06:51 +0100 Subject: [PATCH 3/4] Update the git hooks: need JuliaFormatter 2.14, check type changes (harness githooks) Co-Authored-By: Claude Opus 5.5 --- .githooks/pre-commit | 2 +- .githooks/pre-commit.jl | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.githooks/pre-commit b/.githooks/pre-commit index 1a3c16448..9fc822cfe 100755 --- a/.githooks/pre-commit +++ b/.githooks/pre-commit @@ -10,7 +10,7 @@ if ! command -v julia >/dev/null 2>&1; then # A commit with no staged .jl file and no change below test/ runs no stage, so it needs no # julia. A git that cannot list the staged files refuses, as pre-commit.jl does. - if ! jl=$(git diff --cached --name-only --diff-filter=ACMR -- '*.jl') || + if ! jl=$(git diff --cached --name-only --diff-filter=ACMRT -- '*.jl') || ! tests=$(git diff --cached --name-only -- test/); then printf 'pre-commit: could not list staged files.\n' >&2 exit 1 diff --git a/.githooks/pre-commit.jl b/.githooks/pre-commit.jl index 9ec805f1d..31b8eb403 100644 --- a/.githooks/pre-commit.jl +++ b/.githooks/pre-commit.jl @@ -107,7 +107,11 @@ function formatting(files) id = Base.identify_package("JuliaFormatter") id === nothing && error("JuliaFormatter is in no environment of the load path; $env is first") - format = Base.require(id).format + formatter = Base.require(id) + version = pkgversion(formatter) + version !== nothing && version >= v"2.14" || + error("JuliaFormatter $version is older than 2.14, which the hook needs; $env pins it") + format = formatter.format filter(f -> !Base.invokelatest(format, f; overwrite = false, throw_on_error = true), files) catch e println(stderr, sprint(showerror, e)) @@ -245,7 +249,7 @@ function main() end cd(root) - jl = staged("--diff-filter=ACMR", "--", "*.jl") + jl = staged("--diff-filter=ACMRT", "--", "*.jl") jl === nothing && refuse_unlisted() tests = staged("--", "test/") tests === nothing && refuse_unlisted() From a98deefc806afef2750e1218a532cd6b2f46b1cb Mon Sep 17 00:00:00 2001 From: Michael Kraus Date: Sun, 1 Nov 2026 12:04:46 +0100 Subject: [PATCH 4/4] Update the git hooks: skip the lint stage when fatou cannot start (harness githooks) Co-Authored-By: Claude Opus 5.5 --- .githooks/pre-commit.jl | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.githooks/pre-commit.jl b/.githooks/pre-commit.jl index 31b8eb403..f83090756 100644 --- a/.githooks/pre-commit.jl +++ b/.githooks/pre-commit.jl @@ -153,7 +153,16 @@ function lint(files) println("$(YELLOW)fatou lint: skipped — fatou is not on the PATH$(NC)") return end - code, out = run_boxed(`fatou lint --force-exclude --output concise $files`, 60) + # A fatou on the PATH that the system cannot start, such as a script whose interpreter is + # missing, makes the start throw; the stage is advisory, so it is skipped. + code, out = try + run_boxed(`fatou lint --force-exclude --output concise $files`, 60) + catch e + e isa Base.IOError || rethrow() + println("$(YELLOW)fatou lint: could not start — stage skipped, not a failure$(NC)") + println(indent(split(sprint(showerror, e), '\n'))) + return + end if code === nothing println("$(YELLOW)fatou lint: timed out after 60s — stage skipped, not a failure$(NC)") println(" A staged file is pathological for fatou.")