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}