Skip to main content

amont_runtime/hooks/
python_tools.rs

1//! pre-commit-ruff and pre-commit-pyright — the two Python hooks.
2//!
3//! Both are scoped: they only fire when the repo opts in (a ruff/pyright config
4//! or the matching `[tool.*]` table), so a JS repo never pulls a Python tool.
5//!
6//! Both prefer the repo's PINNED tool over an ambient latest, in the same order
7//! the shell versions established: `uv run --no-sync` (the lockfile-pinned one
8//! CI runs) → the worktree's .venv → the MAIN worktree's .venv (a linked
9//! worktree has none of its own) → PATH → uvx (unpinned LATEST, with a warning,
10//! because it flags issues the CI-pinned version does not — phantom failures).
11
12use super::common::{
13    fail, fixing_enabled, hl, ok, repo_root, restage, run as run_tool, run_quiet, staged_files,
14    warn, which, Restaged,
15};
16use crate::check::Outcome;
17use crate::git;
18use std::path::Path;
19use std::process::{Command, Stdio};
20
21/// The extensions both Python checks consume. Exported so `registry.rs`
22/// declares the scope from the same constant — see `lint_json_yaml::EXTS` for
23/// the drift this prevents.
24pub const EXTS: &[&str] = &[".py", ".pyi"];
25
26fn tool_runs(root: &str, argv: &[String]) -> bool {
27    let Some((p, rest)) = argv.split_first() else {
28        return false;
29    };
30    Command::new(p)
31        .args(rest)
32        .arg("--version")
33        .current_dir(root)
34        .stdin(Stdio::null())
35        .stdout(Stdio::null())
36        .stderr(Stdio::null())
37        .status()
38        .map(|s| s.success())
39        .unwrap_or(false)
40}
41
42fn main_worktree_venv(tool: &str) -> Option<String> {
43    let common = git::stdout(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
44    let p = Path::new(&common).parent()?.join(".venv/bin").join(tool);
45    p.is_file().then(|| p.to_string_lossy().into_owned())
46}
47
48/// Returns (argv, warned_about_unpinned).
49///
50/// Every branch yields a RESOLVED path, never a bare name. `Command::new` does
51/// no PATHEXT resolution, so a bare `uv`/`uvx`/`ruff` cannot execute
52/// `uv.exe`/`ruff.cmd` on Windows: the spawn fails with "program not found" and
53/// a `Severity::Block` check reports an installed tool as broken. That is the
54/// incident `common::program` was written for, and these were the last places
55/// still handing `Command` a bare name — three of them AFTER `which()` had
56/// already succeeded and thrown the answer away.
57fn resolve_python_tool(root: &str, tool: &str) -> Option<(Vec<String>, bool)> {
58    if let Some(uv) = which("uv") {
59        let argv = vec![uv, "run".into(), "--no-sync".into(), tool.into()];
60        if tool_runs(root, &argv) {
61            return Some((argv, false));
62        }
63    }
64    let local = format!("{root}/.venv/bin/{tool}");
65    if Path::new(&local).is_file() {
66        return Some((vec![local], false));
67    }
68    if let Some(v) = main_worktree_venv(tool) {
69        return Some((vec![v], false));
70    }
71    // The path `which` already resolved, rather than probing and discarding it.
72    if let Some(found) = which(tool) {
73        return Some((vec![found], false));
74    }
75    if let Some(uvx) = which("uvx") {
76        return Some((vec![uvx, tool.into()], true));
77    }
78    None
79}
80
81fn opts_in(root: &str, configs: &[&str], table: &str) -> bool {
82    if configs.iter().any(|c| Path::new(root).join(c).is_file()) {
83        return true;
84    }
85    std::fs::read_to_string(Path::new(root).join("pyproject.toml"))
86        .map(|t| t.lines().any(|l| l.trim_start().starts_with(table)))
87        .unwrap_or(false)
88}
89
90pub fn ruff(settings: &crate::config::Settings, _args: &[std::ffi::OsString]) -> Outcome {
91    let files = staged_files(EXTS);
92    if files.is_empty() {
93        return Outcome::Passed;
94    }
95    let root = repo_root();
96    if !opts_in(&root, &["ruff.toml", ".ruff.toml"], "[tool.ruff") {
97        return Outcome::Passed;
98    }
99    let Some((argv, unpinned)) = resolve_python_tool(&root, "ruff") else {
100        warn("ruff config found but no ruff/uvx binary. Install ruff or uv.");
101        return Outcome::Unavailable;
102    };
103    if unpinned {
104        warn(&format!(
105            "No pinned ruff found (.venv); using {} (latest) — may flag issues the CI-pinned ruff doesn't.",
106            hl("uvx ruff")
107        ));
108    }
109
110    // The registry has always declared `Fix::Rewrite` for this check, and
111    // `amont list --json` reported `"fix":"rewrite"` — which `agents_md`
112    // explicitly tells agents to trust — while no fixing code existed
113    // anywhere. Only prettier and the manifest's externals ever called
114    // `restage`. Rather than downgrade the declaration, the fixing is now
115    // real.
116    //
117    // Repair FIRST and QUIETLY, then let the check passes below decide. Ruff
118    // legitimately leaves findings it cannot fix, unlike `cargo fmt`, so the
119    // repair pass's own exit code says nothing about the verdict — and running
120    // it loudly would print the surviving offenders twice.
121    let mut repaired = false;
122    if fixing_enabled(settings) {
123        let _ = run_quiet(
124            settings,
125            &root,
126            &argv,
127            &with_files(&["check", "--fix"], &files),
128        );
129        let _ = run_quiet(settings, &root, &argv, &with_files(&["format"], &files));
130        match restage(&files) {
131            Restaged::Staged => repaired = true,
132            Restaged::Failed(stuck) => {
133                fail(&format!(
134                    "ruff rewrote these files but {} failed — the index still holds the OLD \
135                     content: {}",
136                    hl("git add"),
137                    stuck.join(", ")
138                ));
139                return Outcome::Failed;
140            }
141            Restaged::Nothing => {}
142        }
143    }
144
145    let mut failed = false;
146    if !run_tool(settings, &root, &argv, &with_files(&["check"], &files)) {
147        fail(&format!(
148            "Ruff lint issues. Run {}. Offenders above.",
149            hl("ruff check --fix")
150        ));
151        failed = true;
152    }
153    if !run_tool(
154        settings,
155        &root,
156        &argv,
157        &with_files(&["format", "--check"], &files),
158    ) {
159        fail(&format!(
160            "Ruff found unformatted files. Run {} on the files listed above.",
161            hl("ruff format")
162        ));
163        failed = true;
164    }
165
166    if failed {
167        // A repair that could not finish the job still blocks — and whatever it
168        // DID fix is already staged, so the next attempt starts from there.
169        return Outcome::Failed;
170    }
171    if repaired {
172        ok(settings, "Ruff fixed and re-staged");
173        return Outcome::Fixed;
174    }
175    // An unpinned ruff RAN, and its verdict was clean — that is a pass, not a
176    // gap. The caveat above is advice about which ruff spoke, not a claim that
177    // none did.
178    ok(settings, "Ruff passed");
179    Outcome::Passed
180}
181
182/// `<sub…> --force-exclude -- <files>`.
183///
184/// `--force-exclude` on every pass so ruff honours the project's `exclude`
185/// even though the paths are handed to it explicitly. `--` before the file
186/// list because a staged file named e.g. `-x.py` would otherwise be read as a
187/// flag by ruff's own parser.
188fn with_files(sub: &[&str], files: &[String]) -> Vec<String> {
189    let mut argv: Vec<String> = sub.iter().map(|s| (*s).to_string()).collect();
190    argv.push("--force-exclude".into());
191    argv.push("--".into());
192    argv.extend(files.iter().cloned());
193    argv
194}
195
196pub fn pyright(settings: &crate::config::Settings, _args: &[std::ffi::OsString]) -> Outcome {
197    let files = staged_files(EXTS);
198    if files.is_empty() {
199        return Outcome::Passed;
200    }
201    let root = repo_root();
202    if !opts_in(
203        &root,
204        &["pyrightconfig.json", "pyrightconfig.jsonc"],
205        "[tool.pyright",
206    ) {
207        return Outcome::Passed;
208    }
209    let Some((argv, _)) = resolve_python_tool(&root, "pyright") else {
210        warn("pyright config found but no pyright binary. Install pyright or uv.");
211        return Outcome::Unavailable;
212    };
213    // Scoped to the STAGED files, not the whole tree: fast enough for a
214    // pre-commit hook while catching the per-file errors that are the usual
215    // local-vs-CI gap. Pyright still resolves the whole workspace for imports,
216    // so inference is unchanged; only the reported set is scoped. CI's
217    // whole-tree run stays the authority for cross-file-only errors.
218    // PYRIGHT HAS NO `--` SEPARATOR, and this check used to pass one.
219    //
220    // Every other tool driven from this repository takes `--` to mean "flags
221    // are over" — ruff, prettier, yamllint, node, yq all do, and each has a
222    // regression test for a staged file named like a flag. Pyright does not.
223    // It has a hand-rolled argument parser that treats `--` as an ordinary
224    // path, so `pyright -- a.py` reports
225    //
226    //     File or directory ".../--" does not exist
227    //
228    // and exits 4. `run_tool` sees a non-zero status, and a check declared
229    // `Severity::Block` blocked the commit. Not on bad code — on ALL code. Any
230    // repository with a `pyrightconfig.json` or a `[tool.pyright]` table could
231    // not commit a `.py` file at all while pyright was installed, and the
232    // message it got named a file nobody had written.
233    //
234    // It survived because the check's only test asserted that it does NOTHING
235    // in a repo with no config, which is the one path that never reaches this
236    // line. `linters.rs` now has both halves — a type error must block, and
237    // clean code must not — and it was the second of those that caught this in
238    // its first run. A check that fails on everything satisfies "reports a type
239    // error" perfectly.
240    //
241    // `./` in place of `--` keeps the protection that was intended. `./-p.py`
242    // begins with a dot, so no argument parser can mistake it for a flag, and
243    // pyright resolves it against the working directory `run_tool` already
244    // sets. Git reports staged paths relative to the repository root with
245    // forward slashes on every platform, so this is safe to prepend blindly.
246    // `--warnings` first: exit non-zero on warning diagnostics too, not just
247    // errors — see lint_js for why an unread warn list is worse than a block.
248    let mut with_files: Vec<String> = vec!["--warnings".to_string()];
249    with_files.extend(files.iter().map(|f| format!("./{f}")));
250    if !run_tool(settings, &root, &argv, &with_files) {
251        fail("Pyright type errors. Please fix");
252        return Outcome::Failed;
253    }
254    ok(settings, "pyright passed");
255    Outcome::Passed
256}
257
258/// The Python test gate — `pre-push-pytest`, `cargo-test`'s shape for the
259/// third ecosystem. JS had a push-time suite from the start and Rust
260/// gained one; Python repositories declaring a pytest setup (a
261/// `pytest.ini` or a `conftest.py` — a bare `pyproject.toml` is not a
262/// promise to test) ran nothing at push until now.
263///
264/// Same contract as the other suites: runs against the PUSHED tree per
265/// ref (see `pushed_tree`), `Unavailable` — loudly, never green — when
266/// git will not answer or pytest is not installed, and fail-fast on a red
267/// suite.
268pub fn pytest(settings: &crate::config::Settings, refs: &[crate::pushrefs::PushRef]) -> Outcome {
269    let Some(root) = crate::git::stdout(&["rev-parse", "--show-toplevel"]) else {
270        super::common::warn("pytest: git would not answer — the gate did NOT run");
271        return Outcome::Unavailable;
272    };
273    let zero = crate::git::stdout(&["hash-object", "--stdin"])
274        .map(|h| "0".repeat(h.len()))
275        .unwrap_or_else(|| "0".repeat(40));
276    let mut ran_any = false;
277    for r in refs {
278        let changed = crate::pushrefs::changed_files_for(r, &zero);
279        if !changed.iter().any(|f| EXTS.iter().any(|e| f.ends_with(e))) {
280            continue; // this ref pushes no Python
281        }
282        // `resolve_python_tool`, like `ruff` and `pyright` — NOT a bare
283        // `which`, which is what this used to do and is the one thing that
284        // makes a Python gate useless.
285        //
286        // A project's tests import the project. A `pytest` found on `PATH`
287        // belongs to whatever interpreter happens to be first there, which in
288        // a `src/` layout cannot see the package at all: the gate reported
289        // `ModuleNotFoundError` — as a test FAILURE, blocking the push — in a
290        // repository whose suite passes 1296 tests under `uv run pytest`. The
291        // resolver tries `uv run --no-sync` first, then `.venv/bin/`, and only
292        // then falls back to `PATH`.
293        let Some((argv, _)) = resolve_python_tool(&root, "pytest") else {
294            super::common::warn(
295                "Python changed but pytest is not installed — the gate did NOT run",
296            );
297            return Outcome::Unavailable;
298        };
299        // Where THIS ref's suite runs decides what it is answering about —
300        // the pushed commits, not whatever is open in the editor.
301        let (where_, _guard) = crate::pushed_tree::where_to_run(settings, &r.local_oid, &root);
302        let mut cmd = std::process::Command::new(&argv[0]);
303        cmd.args(&argv[1..]);
304        cmd.current_dir(&where_).stdin(std::process::Stdio::null());
305        super::common::strip_git_env(&mut cmd);
306        if !super::common::bounded_success(settings, &mut cmd, "pytest") {
307            super::common::fail("Python tests failed. Push aborted.");
308            return Outcome::Failed;
309        }
310        ran_any = true;
311    }
312    if ran_any {
313        super::common::ok(settings, "Python tests passed");
314    }
315    Outcome::Passed
316}