use super::common::{
fail, fixing_enabled, hl, ok, repo_root, restage, run as run_tool, run_quiet, staged_files,
warn, which, Restaged,
};
use crate::check::Outcome;
use crate::git;
use std::path::Path;
use std::process::{Command, Stdio};
pub const EXTS: &[&str] = &[".py", ".pyi"];
fn tool_runs(root: &str, argv: &[String]) -> bool {
let Some((p, rest)) = argv.split_first() else {
return false;
};
Command::new(p)
.args(rest)
.arg("--version")
.current_dir(root)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn main_worktree_venv(tool: &str) -> Option<String> {
let common = git::stdout(&["rev-parse", "--path-format=absolute", "--git-common-dir"])?;
let p = Path::new(&common).parent()?.join(".venv/bin").join(tool);
p.is_file().then(|| p.to_string_lossy().into_owned())
}
fn resolve_python_tool(root: &str, tool: &str) -> Option<(Vec<String>, bool)> {
if let Some(uv) = which("uv") {
let argv = vec![uv, "run".into(), "--no-sync".into(), tool.into()];
if tool_runs(root, &argv) {
return Some((argv, false));
}
}
let local = format!("{root}/.venv/bin/{tool}");
if Path::new(&local).is_file() {
return Some((vec![local], false));
}
if let Some(v) = main_worktree_venv(tool) {
return Some((vec![v], false));
}
if let Some(found) = which(tool) {
return Some((vec![found], false));
}
if let Some(uvx) = which("uvx") {
return Some((vec![uvx, tool.into()], true));
}
None
}
fn opts_in(root: &str, configs: &[&str], table: &str) -> bool {
if configs.iter().any(|c| Path::new(root).join(c).is_file()) {
return true;
}
std::fs::read_to_string(Path::new(root).join("pyproject.toml"))
.map(|t| t.lines().any(|l| l.trim_start().starts_with(table)))
.unwrap_or(false)
}
pub fn ruff(_args: &[std::ffi::OsString]) -> Outcome {
let files = staged_files(EXTS);
if files.is_empty() {
return Outcome::Passed;
}
let root = repo_root();
if !opts_in(&root, &["ruff.toml", ".ruff.toml"], "[tool.ruff") {
return Outcome::Passed;
}
let Some((argv, unpinned)) = resolve_python_tool(&root, "ruff") else {
warn("ruff config found but no ruff/uvx binary. Install ruff or uv.");
return Outcome::Unavailable;
};
if unpinned {
warn(&format!(
"No pinned ruff found (.venv); using {} (latest) — may flag issues the CI-pinned ruff doesn't.",
hl("uvx ruff")
));
}
let mut repaired = false;
if fixing_enabled() {
let _ = run_quiet(&root, &argv, &with_files(&["check", "--fix"], &files));
let _ = run_quiet(&root, &argv, &with_files(&["format"], &files));
match restage(&files) {
Restaged::Staged => repaired = true,
Restaged::Failed(stuck) => {
fail(&format!(
"ruff rewrote these files but {} failed — the index still holds the OLD \
content: {}",
hl("git add"),
stuck.join(", ")
));
return Outcome::Failed;
}
Restaged::Nothing => {}
}
}
let mut failed = false;
if !run_tool(&root, &argv, &with_files(&["check"], &files)) {
fail(&format!(
"Ruff lint issues. Run {}. Offenders above.",
hl("ruff check --fix")
));
failed = true;
}
if !run_tool(&root, &argv, &with_files(&["format", "--check"], &files)) {
fail(&format!(
"Ruff found unformatted files. Run {} on the files listed above.",
hl("ruff format")
));
failed = true;
}
if failed {
return Outcome::Failed;
}
if repaired {
ok("Ruff fixed and re-staged");
return Outcome::Fixed;
}
ok("Ruff passed");
Outcome::Passed
}
fn with_files(sub: &[&str], files: &[String]) -> Vec<String> {
let mut argv: Vec<String> = sub.iter().map(|s| (*s).to_string()).collect();
argv.push("--force-exclude".into());
argv.push("--".into());
argv.extend(files.iter().cloned());
argv
}
pub fn pyright(_args: &[std::ffi::OsString]) -> Outcome {
let files = staged_files(EXTS);
if files.is_empty() {
return Outcome::Passed;
}
let root = repo_root();
if !opts_in(
&root,
&["pyrightconfig.json", "pyrightconfig.jsonc"],
"[tool.pyright",
) {
return Outcome::Passed;
}
let Some((argv, _)) = resolve_python_tool(&root, "pyright") else {
warn("pyright config found but no pyright binary. Install pyright or uv.");
return Outcome::Unavailable;
};
let with_files: Vec<String> = files.iter().map(|f| format!("./{f}")).collect();
if !run_tool(&root, &argv, &with_files) {
fail("Pyright type errors. Please fix");
return Outcome::Failed;
}
ok("pyright passed");
Outcome::Passed
}