From 70257b53d45c13686be26115c4e88ccbdf2014d3 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 16:21:19 +0000 Subject: [PATCH 01/21] =?UTF-8?q?chore:=20remove=20lint:pre-commit=20task?= =?UTF-8?q?=20=E2=80=94=20use=20FLINT=5FFAST=5FONLY=20env=20var=20instead?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-push hooks wanting fast-only mode should set FLINT_FAST_ONLY=1 when calling lint:fix rather than relying on a dedicated task. Signed-off-by: Gregor Zeitlinger --- mise.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/mise.toml b/mise.toml index 8742528d..ea8a90a8 100644 --- a/mise.toml +++ b/mise.toml @@ -37,10 +37,6 @@ run = "cargo run -q -- run" description = "Auto-fix lint issues" run = "cargo run -q -- run --fix" -[tasks."lint:pre-commit"] -description = "Fast auto-fix lint pass (skips slow checks like renovate) — intended for pre-commit/pre-push hooks" -run = "cargo run -q -- run --fix --fast-only" - [tasks.build] description = "Build the project" run = "cargo build" From 2113c5486de05302cab2d4e2f9b13cc6bd54f032 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 16:21:26 +0000 Subject: [PATCH 02/21] fix(windows): replace self-executing JAR heuristic with explicit registry flag The previous code detected ktlint's self-executing JAR by checking for a '#!' header and file size >1MB. Replace with an explicit .java_jar() flag in the Check registry: when set, the binary is located in PATH and invoked as 'java -jar ' rather than via cmd.exe. This removes the size-based heuristic and makes the intent clear at the registry level. find_executable_in_path collapses back into the original find_pe_binary (MZ-only detection) plus a new find_file_in_path helper used only for the JAR case. Signed-off-by: Gregor Zeitlinger --- src/linters/lychee.rs | 2 +- src/linters/mod.rs | 85 +++++++++++++++++------------------- src/linters/renovate_deps.rs | 15 ++++--- src/registry.rs | 13 ++++++ src/runner.rs | 24 +++++++--- 5 files changed, 80 insertions(+), 59 deletions(-) diff --git a/src/linters/lychee.rs b/src/linters/lychee.rs index f8afd4ac..efaddb74 100644 --- a/src/linters/lychee.rs +++ b/src/linters/lychee.rs @@ -142,7 +142,7 @@ async fn run_lychee_cmd( let mut stdout = format!("==> {description}\n").into_bytes(); - let result = super::spawn_command(&argv) + let result = super::spawn_command(&argv, false) .current_dir(project_root) .stdin(Stdio::null()) .output() diff --git a/src/linters/mod.rs b/src/linters/mod.rs index af3330e9..2faa6fac 100644 --- a/src/linters/mod.rs +++ b/src/linters/mod.rs @@ -5,26 +5,26 @@ pub mod renovate_deps; /// Build a [`tokio::process::Command`] for the given argv. /// /// On Windows, mise shims are `.cmd` files that cannot be spawned directly -/// via `CreateProcessW`. However, some tools (e.g. ktlint) are native PE -/// binaries without a `.exe` extension that also cannot run via cmd.exe -/// (the shim fails). We check for a PE header (MZ magic) to distinguish: -/// - PE binary without extension → execute directly by full path -/// - Everything else → route through `cmd.exe /C` to handle `.cmd` shims -pub fn spawn_command(argv: &[String]) -> tokio::process::Command { +/// via `CreateProcessW`. Some tools are native PE binaries without a `.exe` +/// extension that also cannot run via cmd.exe (the shim fails) — we detect +/// these via MZ magic and execute them directly. +/// +/// Some tools are self-executing JARs (e.g. ktlint) that cmd.exe cannot run +/// at all. When `windows_java_jar` is true, the binary is resolved to its +/// full path and invoked as `java -jar `. +pub fn spawn_command(argv: &[String], windows_java_jar: bool) -> tokio::process::Command { #[cfg(windows)] { - match find_executable_in_path(&argv[0]) { - Some(WinBinary::Pe(path)) => { - let mut cmd = tokio::process::Command::new(path); - cmd.args(&argv[1..]); - return cmd; - } - Some(WinBinary::Jar(path)) => { + if windows_java_jar { + if let Some(path) = find_file_in_path(&argv[0]) { let mut cmd = tokio::process::Command::new("java"); cmd.arg("-jar").arg(path).args(&argv[1..]); return cmd; } - None => {} + } else if let Some(path) = find_pe_binary(&argv[0]) { + let mut cmd = tokio::process::Command::new(path); + cmd.args(&argv[1..]); + return cmd; } let mut cmd = tokio::process::Command::new("cmd.exe"); cmd.arg("/C").args(argv); @@ -32,27 +32,18 @@ pub fn spawn_command(argv: &[String]) -> tokio::process::Command { } #[cfg(not(windows))] { + let _ = windows_java_jar; let mut cmd = tokio::process::Command::new(&argv[0]); cmd.args(&argv[1..]); cmd } } -/// What kind of executable was found in PATH on Windows. -#[cfg(windows)] -enum WinBinary { - /// Native PE binary (MZ magic) — execute directly. - Pe(std::path::PathBuf), - /// Self-executing JAR (starts with `#!` and is large) — run via `java -jar`. - Jar(std::path::PathBuf), -} - /// On Windows, look for `binary` (exact name, no extension) in each PATH -/// directory and classify it: -/// - MZ magic → native PE, run directly -/// - `#!` magic + large file (>1 MB) → self-executing JAR (e.g. ktlint), run via `java -jar` +/// directory. If found and it starts with the PE magic bytes `MZ`, return +/// its full path so it can be executed directly via `CreateProcessW`. #[cfg(windows)] -fn find_executable_in_path(binary: &str) -> Option { +fn find_pe_binary(binary: &str) -> Option { use std::io::Read; let path_var = std::env::var("PATH").ok()?; for dir in std::env::split_paths(&path_var) { @@ -60,30 +51,32 @@ fn find_executable_in_path(binary: &str) -> Option { if !candidate.is_file() { continue; } - let mut buf = [0u8; 2]; - let read = std::fs::File::open(&candidate) - .and_then(|mut f| f.read(&mut buf).map(|n| n)) - .unwrap_or(0); - if read < 2 { - continue; - } - if buf == [b'M', b'Z'] { - return Some(WinBinary::Pe(candidate)); - } - if buf == [b'#', b'!'] { - // Self-executing JAR: shell script header prepended to a JAR. - // A real script would be tiny; a self-executing JAR is many MB. - if std::fs::metadata(&candidate) - .map(|m| m.len() > 1_000_000) - .unwrap_or(false) - { - return Some(WinBinary::Jar(candidate)); - } + let is_pe = std::fs::File::open(&candidate) + .and_then(|mut f| { + let mut buf = [0u8; 2]; + f.read_exact(&mut buf)?; + Ok(buf == [b'M', b'Z']) + }) + .unwrap_or(false); + if is_pe { + return Some(candidate); } } None } +/// On Windows, return the full path of `binary` from PATH without inspecting +/// its contents. Used for self-executing JARs where the caller already knows +/// the invocation style (i.e. `windows_java_jar` is set in the registry). +#[cfg(windows)] +fn find_file_in_path(binary: &str) -> Option { + let path_var = std::env::var("PATH").ok()?; + std::env::split_paths(&path_var).find_map(|dir| { + let candidate = dir.join(binary); + candidate.is_file().then_some(candidate) + }) +} + /// Output from a single linter run. pub struct LinterOutput { pub ok: bool, diff --git a/src/linters/renovate_deps.rs b/src/linters/renovate_deps.rs index b60e7043..95492585 100644 --- a/src/linters/renovate_deps.rs +++ b/src/linters/renovate_deps.rs @@ -120,12 +120,15 @@ async fn run_renovate(project_root: &Path, config_path: &Path) -> anyhow::Result env.push(("GITHUB_COM_TOKEN".into(), token)); } - let out = super::spawn_command(&[ - "renovate".to_string(), - "--platform=local".to_string(), - "--require-config=ignored".to_string(), - "--dry-run=extract".to_string(), - ]) + let out = super::spawn_command( + &[ + "renovate".to_string(), + "--platform=local".to_string(), + "--require-config=ignored".to_string(), + "--dry-run=extract".to_string(), + ], + false, + ) .current_dir(project_root) .envs(env) .stdin(Stdio::null()) diff --git a/src/registry.rs b/src/registry.rs index 67a5f537..9d6c2c9a 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -85,6 +85,9 @@ pub struct Check { /// (e.g. `"clippy,rustfmt"` for the `rust` toolchain). Produces an inline-table /// entry: `rust = { version = "latest", components = "clippy,rustfmt" }`. pub mise_install_components: Option<&'static str>, + /// On Windows, the binary is a self-executing JAR that cannot be run directly + /// or via cmd.exe — invoke as `java -jar ` instead. + pub windows_java_jar: bool, pub kind: CheckKind, /// Binary name format when the backend installs with a versioned name (e.g. `"shfmt_{version}"` /// → `"shfmt_v3.12.0"`). `{version}` is replaced with the version declared in mise.toml. @@ -166,6 +169,7 @@ impl Check { full_fix_cmd: "", scope, }, + windows_java_jar: false, versioned_bin_fmt: None, desc: "", docs: "", @@ -187,6 +191,7 @@ impl Check { activate_unconditionally: false, category: Category::Default, mise_install_components: None, + windows_java_jar: false, kind: CheckKind::Special(kind), versioned_bin_fmt: None, desc: "", @@ -245,6 +250,13 @@ impl Check { self } + /// On Windows, invoke this binary via `java -jar ` rather than directly. + /// Use for self-executing JARs (e.g. ktlint) that cannot be run via cmd.exe. + pub fn java_jar(mut self) -> Self { + self.windows_java_jar = true; + self + } + /// Mark as slow — skipped when `--fast-only` is passed; `comprehensive` init profile only. pub fn slow(mut self) -> Self { self.category = Category::Slow; @@ -539,6 +551,7 @@ fn check_ktlint() -> Check { "ktlint --format --log-level=error {ROOT}", ) .mise_tool("github:pinterest/ktlint") + .java_jar() .formatter() .desc("Lint and format Kotlin code") .lang() diff --git a/src/runner.rs b/src/runner.rs index fc25fddf..50df8e55 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -30,6 +30,7 @@ enum PreparedCheck { Invocations { name: String, argv_list: Vec>, + windows_java_jar: bool, }, Links { name: String, @@ -62,9 +63,11 @@ impl PreparedCheck { let name = self.name().to_string(); let start = Instant::now(); let out: LinterOutput = match self { - Self::Invocations { argv_list, .. } => { - run_invocations(&name, &argv_list, project_root).await - } + Self::Invocations { + argv_list, + windows_java_jar, + .. + } => run_invocations(&name, &argv_list, windows_java_jar, project_root).await, Self::Links { cfg, file_list, @@ -184,7 +187,11 @@ fn prepare( if argv_list.is_empty() { return None; } - Some(PreparedCheck::Invocations { name, argv_list }) + Some(PreparedCheck::Invocations { + name, + argv_list, + windows_java_jar: check.windows_java_jar, + }) } CheckKind::Special(SpecialKind::Links) => Some(PreparedCheck::Links { name, @@ -400,7 +407,12 @@ fn inject_config(mut argv: Vec, config_args: &[String]) -> Vec { /// Runs all invocations for one check. /// Never prints — callers decide when and whether to flush output. -async fn run_invocations(name: &str, invocations: &[Vec], root: &Path) -> LinterOutput { +async fn run_invocations( + name: &str, + invocations: &[Vec], + windows_java_jar: bool, + root: &Path, +) -> LinterOutput { let mut all_ok = true; let mut combined_stdout = Vec::new(); let mut combined_stderr = Vec::new(); @@ -409,7 +421,7 @@ async fn run_invocations(name: &str, invocations: &[Vec], root: &Path) - if argv.is_empty() { continue; } - let result = crate::linters::spawn_command(argv) + let result = crate::linters::spawn_command(argv, windows_java_jar) .current_dir(root) .stdin(Stdio::null()) .output() From 43d69c23b4c182722c30b9c1f51884e60ee60cc4 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 16:35:25 +0000 Subject: [PATCH 03/21] fix: rename .java_jar() to .windows_java_jar(); fix missing field in test helper Signed-off-by: Gregor Zeitlinger --- src/registry.rs | 6 +++--- src/runner.rs | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index 9d6c2c9a..1770b61b 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -251,8 +251,8 @@ impl Check { } /// On Windows, invoke this binary via `java -jar ` rather than directly. - /// Use for self-executing JARs (e.g. ktlint) that cannot be run via cmd.exe. - pub fn java_jar(mut self) -> Self { + /// Use for self-executing JARs (e.g. ktlint) that cmd.exe cannot run. + pub fn windows_java_jar(mut self) -> Self { self.windows_java_jar = true; self } @@ -551,7 +551,7 @@ fn check_ktlint() -> Check { "ktlint --format --log-level=error {ROOT}", ) .mise_tool("github:pinterest/ktlint") - .java_jar() + .windows_java_jar() .formatter() .desc("Lint and format Kotlin code") .lang() diff --git a/src/runner.rs b/src/runner.rs index 50df8e55..d59ffd76 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -687,6 +687,7 @@ mod tests { activate_unconditionally: false, category: Category::Default, mise_install_components: None, + windows_java_jar: false, kind: CheckKind::Template { check_cmd: "run-it", fix_cmd: "", From 7cc234329ebe96673f1f2a31fecdcfff3bfa0a1a Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 16:39:25 +0000 Subject: [PATCH 04/21] feat: bail on obsolete mise.toml tool keys during flint run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add find_obsolete_key() that checks mise_tools for superseded keys and returns the first violation. Called at the start of flint run — fails fast with a migration hint before any linters are executed. Complements the existing flint init migration path: init auto-fixes obsolete keys; run catches them if init hasn't been run yet. Signed-off-by: Gregor Zeitlinger --- src/main.rs | 5 +++++ src/registry.rs | 24 ++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/main.rs b/src/main.rs index 340710b9..a4033156 100644 --- a/src/main.rs +++ b/src/main.rs @@ -182,6 +182,11 @@ async fn run( // --fast-only filter (skipped when linters are named explicitly). // mise guarantees declared tools are on PATH, so no PATH check needed. let mise_tools = registry::read_mise_tools(project_root); + if let Some((old, new)) = registry::find_obsolete_key(&mise_tools) { + eprintln!("flint: obsolete tool key in mise.toml: {old:?}"); + eprintln!(" Replace it with: {new:?}"); + std::process::exit(1); + } let active: Vec<®istry::Check> = { let mut out = vec![]; for c in checks { diff --git a/src/registry.rs b/src/registry.rs index 1770b61b..ed3fe313 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -662,6 +662,15 @@ pub const OBSOLETE_KEYS: &[(&str, &str)] = &[ ("npm:markdownlint-cli", "npm:markdownlint-cli2"), ]; +/// Checks whether any obsolete tool keys are present in `mise_tools`. +/// Returns the first violation found as `(obsolete_key, replacement_key)`. +pub fn find_obsolete_key(mise_tools: &HashMap) -> Option<(&'static str, &'static str)> { + OBSOLETE_KEYS + .iter() + .find(|(old, _)| mise_tools.contains_key(*old)) + .copied() +} + /// Reads `[tools]` from the consuming repo's mise.toml and returns a map of /// tool name → declared version string. /// @@ -781,6 +790,21 @@ fn coerce_version(s: &str) -> Option { mod tests { use super::*; + #[test] + fn find_obsolete_key_detects_superseded_keys() { + let mut tools = HashMap::new(); + tools.insert("npm:markdownlint-cli".to_string(), "0.39.0".to_string()); + let result = find_obsolete_key(&tools); + assert_eq!(result, Some(("npm:markdownlint-cli", "npm:markdownlint-cli2"))); + } + + #[test] + fn find_obsolete_key_returns_none_for_clean_tools() { + let mut tools = HashMap::new(); + tools.insert("npm:markdownlint-cli2".to_string(), "0.17.2".to_string()); + assert_eq!(find_obsolete_key(&tools), None); + } + /// If any entry for a bin_name declares a version_range, every entry for that /// bin_name must declare one. A mix of ranged and unranged entries for the same /// binary is ambiguous — it would be impossible to guarantee exactly one activates. From b1051b081eabfc44fe5f4b1e990e0ef14b29f01a Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 16:45:32 +0000 Subject: [PATCH 05/21] feat: add flint update command to migrate obsolete mise.toml tool keys flint update replaces obsolete tool keys with their modern equivalents, preserving the existing version. Non-interactive counterpart to flint init's migration path. flint run now suggests running it when an obsolete key is found. Updated README to document the new command and the error/fix flow. Signed-off-by: Gregor Zeitlinger --- README.md | 10 ++++++ src/init/generation.rs | 71 ++++++++++++++++++++++++++++++++++++++++++ src/init/mod.rs | 2 +- src/main.rs | 17 ++++++++-- 4 files changed, 97 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 259f1b65..77f39271 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,7 @@ run = "flint run --fix" ```text flint run [OPTIONS] [LINTERS...] +flint update flint linters flint version ``` @@ -178,6 +179,15 @@ flint run shellcheck shfmt # run only shellcheck and shfmt flint run --fix prettier # fix only prettier ``` +`flint update` applies non-interactive migrations to `mise.toml` — replaces obsolete +tool keys with their modern equivalents, preserving the declared version. Run it when +`flint run` reports an obsolete key error: + +```text +flint: obsolete tool key in mise.toml: "npm:markdownlint-cli" (replaced by "npm:markdownlint-cli2") + Run `flint update` to apply the migration automatically. +``` + `flint linters` shows every check with its status: ```text diff --git a/src/init/generation.rs b/src/init/generation.rs index 5229e539..e63e2283 100644 --- a/src/init/generation.rs +++ b/src/init/generation.rs @@ -118,6 +118,39 @@ fn pin_tool_via_mise(project_root: &Path, key: &str) -> bool { after != before && parse_tool_keys(&after).contains(key) } +/// Replaces obsolete tool keys in mise.toml with their modern equivalents, +/// preserving the existing version value. Returns the list of replacements made +/// as `(old_key, new_key)` pairs. No-ops if the file doesn't exist or has no +/// obsolete keys. +pub fn replace_obsolete_keys( + project_root: &Path, + obsolete: &[(&str, &str)], +) -> Result> { + let path = project_root.join("mise.toml"); + let content = match std::fs::read_to_string(&path) { + Ok(c) => c, + Err(_) => return Ok(vec![]), + }; + let mut doc: toml_edit::DocumentMut = content + .parse() + .context("failed to parse mise.toml")?; + + let mut replaced = vec![]; + if let Some(tools) = doc.get_mut("tools").and_then(|t| t.as_table_mut()) { + for &(old_key, new_key) in obsolete { + if let Some(value) = tools.remove(old_key) { + tools.insert(new_key, value); + replaced.push((old_key.to_string(), new_key.to_string())); + } + } + } + + if !replaced.is_empty() { + std::fs::write(&path, doc.to_string()).context("failed to write mise.toml")?; + } + Ok(replaced) +} + pub(super) fn apply_changes( path: &Path, current_content: &str, @@ -631,6 +664,44 @@ pub(super) fn maybe_install_hook(project_root: &Path, yes: bool) -> Result<()> { Ok(()) } +#[cfg(test)] +mod replace_obsolete_tests { + use super::replace_obsolete_keys; + + #[test] + fn replaces_old_key_preserving_version() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mise.toml"); + std::fs::write( + &path, + "[tools]\n\"npm:markdownlint-cli\" = \"0.39.0\"\n", + ) + .unwrap(); + let replaced = + replace_obsolete_keys(dir.path(), &[("npm:markdownlint-cli", "npm:markdownlint-cli2")]) + .unwrap(); + assert_eq!( + replaced, + vec![("npm:markdownlint-cli".to_string(), "npm:markdownlint-cli2".to_string())] + ); + let result = std::fs::read_to_string(&path).unwrap(); + assert!(result.contains("npm:markdownlint-cli2"), "new key written: {result}"); + assert!(!result.contains("\"npm:markdownlint-cli\""), "old key removed: {result}"); + assert!(result.contains("0.39.0"), "version preserved: {result}"); + } + + #[test] + fn noop_when_no_obsolete_keys() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("mise.toml"); + std::fs::write(&path, "[tools]\n\"npm:markdownlint-cli2\" = \"0.17.2\"\n").unwrap(); + let replaced = + replace_obsolete_keys(dir.path(), &[("npm:markdownlint-cli", "npm:markdownlint-cli2")]) + .unwrap(); + assert!(replaced.is_empty()); + } +} + #[cfg(test)] mod v1_removal_tests { use super::remove_v1_tasks; diff --git a/src/init/mod.rs b/src/init/mod.rs index c098338b..61686b9a 100644 --- a/src/init/mod.rs +++ b/src/init/mod.rs @@ -7,7 +7,7 @@ use std::path::Path; use crate::registry::{Category, Check, builtin}; mod detection; -mod generation; +pub mod generation; mod ui; use detection::{ diff --git a/src/main.rs b/src/main.rs index a4033156..a2d620fe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -28,6 +28,8 @@ enum SubCommand { Linters(LintersArgs), /// Set up linters in mise.toml for this project. Init(InitArgs), + /// Apply non-interactive migrations to mise.toml (replace obsolete tool keys). + Update, /// Manage git hooks. Hook(HookArgs), /// Display the flint version. @@ -140,6 +142,17 @@ async fn main() -> Result<()> { SubCommand::Init(args) => { init::run(&project_root, args.profile, args.yes)?; } + SubCommand::Update => { + let replaced = + init::generation::replace_obsolete_keys(&project_root, registry::OBSOLETE_KEYS)?; + if replaced.is_empty() { + println!("flint: mise.toml is up to date"); + } else { + for (old, new) in &replaced { + println!(" replaced {old:?} → {new:?}"); + } + } + } SubCommand::Hook(args) => match args.command { HookCommand::Install => hook::install(&project_root)?, }, @@ -183,8 +196,8 @@ async fn run( // mise guarantees declared tools are on PATH, so no PATH check needed. let mise_tools = registry::read_mise_tools(project_root); if let Some((old, new)) = registry::find_obsolete_key(&mise_tools) { - eprintln!("flint: obsolete tool key in mise.toml: {old:?}"); - eprintln!(" Replace it with: {new:?}"); + eprintln!("flint: obsolete tool key in mise.toml: {old:?} (replaced by {new:?})"); + eprintln!(" Run `flint update` to apply the migration automatically."); std::process::exit(1); } let active: Vec<®istry::Check> = { From 473a5b1101e6ed145e4c7b2ea872bd9db4a5c2c8 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 16:59:37 +0000 Subject: [PATCH 06/21] style: apply cargo-fmt formatting Signed-off-by: Gregor Zeitlinger --- src/init/generation.rs | 37 ++++++++++++++++++++++--------------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/src/init/generation.rs b/src/init/generation.rs index e63e2283..86e84e6b 100644 --- a/src/init/generation.rs +++ b/src/init/generation.rs @@ -131,9 +131,7 @@ pub fn replace_obsolete_keys( Ok(c) => c, Err(_) => return Ok(vec![]), }; - let mut doc: toml_edit::DocumentMut = content - .parse() - .context("failed to parse mise.toml")?; + let mut doc: toml_edit::DocumentMut = content.parse().context("failed to parse mise.toml")?; let mut replaced = vec![]; if let Some(tools) = doc.get_mut("tools").and_then(|t| t.as_table_mut()) { @@ -672,21 +670,28 @@ mod replace_obsolete_tests { fn replaces_old_key_preserving_version() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("mise.toml"); - std::fs::write( - &path, - "[tools]\n\"npm:markdownlint-cli\" = \"0.39.0\"\n", + std::fs::write(&path, "[tools]\n\"npm:markdownlint-cli\" = \"0.39.0\"\n").unwrap(); + let replaced = replace_obsolete_keys( + dir.path(), + &[("npm:markdownlint-cli", "npm:markdownlint-cli2")], ) .unwrap(); - let replaced = - replace_obsolete_keys(dir.path(), &[("npm:markdownlint-cli", "npm:markdownlint-cli2")]) - .unwrap(); assert_eq!( replaced, - vec![("npm:markdownlint-cli".to_string(), "npm:markdownlint-cli2".to_string())] + vec![( + "npm:markdownlint-cli".to_string(), + "npm:markdownlint-cli2".to_string() + )] ); let result = std::fs::read_to_string(&path).unwrap(); - assert!(result.contains("npm:markdownlint-cli2"), "new key written: {result}"); - assert!(!result.contains("\"npm:markdownlint-cli\""), "old key removed: {result}"); + assert!( + result.contains("npm:markdownlint-cli2"), + "new key written: {result}" + ); + assert!( + !result.contains("\"npm:markdownlint-cli\""), + "old key removed: {result}" + ); assert!(result.contains("0.39.0"), "version preserved: {result}"); } @@ -695,9 +700,11 @@ mod replace_obsolete_tests { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("mise.toml"); std::fs::write(&path, "[tools]\n\"npm:markdownlint-cli2\" = \"0.17.2\"\n").unwrap(); - let replaced = - replace_obsolete_keys(dir.path(), &[("npm:markdownlint-cli", "npm:markdownlint-cli2")]) - .unwrap(); + let replaced = replace_obsolete_keys( + dir.path(), + &[("npm:markdownlint-cli", "npm:markdownlint-cli2")], + ) + .unwrap(); assert!(replaced.is_empty()); } } From d8b28418349ad0a87da0415bc8aec71f8a1a248d Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 17:34:57 +0000 Subject: [PATCH 07/21] test: add e2e cases for version, hook install, and update commands Signed-off-by: Gregor Zeitlinger --- src/registry.rs | 9 +++++++-- tests/cases/general/hook-install/test.toml | 11 +++++++++++ tests/cases/general/update-no-op/files/mise.toml | 3 +++ tests/cases/general/update-no-op/test.toml | 4 ++++ .../cases/general/update-obsolete-key/files/mise.toml | 3 +++ tests/cases/general/update-obsolete-key/test.toml | 11 +++++++++++ tests/cases/general/version/test.toml | 4 ++++ 7 files changed, 43 insertions(+), 2 deletions(-) create mode 100644 tests/cases/general/hook-install/test.toml create mode 100644 tests/cases/general/update-no-op/files/mise.toml create mode 100644 tests/cases/general/update-no-op/test.toml create mode 100644 tests/cases/general/update-obsolete-key/files/mise.toml create mode 100644 tests/cases/general/update-obsolete-key/test.toml create mode 100644 tests/cases/general/version/test.toml diff --git a/src/registry.rs b/src/registry.rs index ed3fe313..19bd6035 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -664,7 +664,9 @@ pub const OBSOLETE_KEYS: &[(&str, &str)] = &[ /// Checks whether any obsolete tool keys are present in `mise_tools`. /// Returns the first violation found as `(obsolete_key, replacement_key)`. -pub fn find_obsolete_key(mise_tools: &HashMap) -> Option<(&'static str, &'static str)> { +pub fn find_obsolete_key( + mise_tools: &HashMap, +) -> Option<(&'static str, &'static str)> { OBSOLETE_KEYS .iter() .find(|(old, _)| mise_tools.contains_key(*old)) @@ -795,7 +797,10 @@ mod tests { let mut tools = HashMap::new(); tools.insert("npm:markdownlint-cli".to_string(), "0.39.0".to_string()); let result = find_obsolete_key(&tools); - assert_eq!(result, Some(("npm:markdownlint-cli", "npm:markdownlint-cli2"))); + assert_eq!( + result, + Some(("npm:markdownlint-cli", "npm:markdownlint-cli2")) + ); } #[test] diff --git a/tests/cases/general/hook-install/test.toml b/tests/cases/general/hook-install/test.toml new file mode 100644 index 00000000..00ba0295 --- /dev/null +++ b/tests/cases/general/hook-install/test.toml @@ -0,0 +1,11 @@ +[expected] +args = "hook install" +exit = 0 +stdout = "installed pre-commit hook (.git/hooks/pre-commit)\n" + +[expected.files] +".git/hooks/pre-commit" = ''' +#!/bin/sh +# Installed by flint — run `flint hook install` to reinstall +mise exec -- flint run --fix --fast-only +''' diff --git a/tests/cases/general/update-no-op/files/mise.toml b/tests/cases/general/update-no-op/files/mise.toml new file mode 100644 index 00000000..85506538 --- /dev/null +++ b/tests/cases/general/update-no-op/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +"npm:markdownlint-cli2" = "0.17.2" +shellcheck = "v0.11.0" diff --git a/tests/cases/general/update-no-op/test.toml b/tests/cases/general/update-no-op/test.toml new file mode 100644 index 00000000..99610329 --- /dev/null +++ b/tests/cases/general/update-no-op/test.toml @@ -0,0 +1,4 @@ +[expected] +args = "update" +exit = 0 +stdout = "flint: mise.toml is up to date\n" diff --git a/tests/cases/general/update-obsolete-key/files/mise.toml b/tests/cases/general/update-obsolete-key/files/mise.toml new file mode 100644 index 00000000..117d0a98 --- /dev/null +++ b/tests/cases/general/update-obsolete-key/files/mise.toml @@ -0,0 +1,3 @@ +[tools] +"npm:markdownlint-cli" = "0.39.0" +shellcheck = "v0.11.0" diff --git a/tests/cases/general/update-obsolete-key/test.toml b/tests/cases/general/update-obsolete-key/test.toml new file mode 100644 index 00000000..232ba9da --- /dev/null +++ b/tests/cases/general/update-obsolete-key/test.toml @@ -0,0 +1,11 @@ +[expected] +args = "update" +exit = 0 +stdout = ' replaced "npm:markdownlint-cli" → "npm:markdownlint-cli2"\n' + +[expected.files] +"mise.toml" = ''' +[tools] +shellcheck = "v0.11.0" +"npm:markdownlint-cli2" = "0.39.0" +''' diff --git a/tests/cases/general/version/test.toml b/tests/cases/general/version/test.toml new file mode 100644 index 00000000..3a44bb04 --- /dev/null +++ b/tests/cases/general/version/test.toml @@ -0,0 +1,4 @@ +[expected] +args = "version" +exit = 0 +stdout = 'flint 0.20.0-alpha.1\n' From 3ea05cdbc36ea12385dd1bc2d675df41da891815 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 17:35:28 +0000 Subject: [PATCH 08/21] docs: add 'Why not Husky?' section Signed-off-by: Gregor Zeitlinger --- README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/README.md b/README.md index 77f39271..79a6a15a 100644 --- a/README.md +++ b/README.md @@ -575,6 +575,13 @@ a second inventory of the same tools in `.pre-commit-config.yaml`, with its own versioning and install lifecycle. That's friction without benefit for repos that are already mise-first. +### Why not Husky? + +Husky manages git hooks for Node.js projects and requires `npm install` to activate. +Repos that aren't Node-first still need a `package.json` and a dev dependency just to +run hooks. `flint hook install` writes a single shell script directly to `.git/hooks/` +with no install step and no language runtime dependency. + ### Why not Spotless (or other Maven formatter plugins)? Spotless runs `google-java-format` as a Maven build phase, which means format From 8af68e73779bc1b209f947ea0990f841e471c2a3 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 17:58:46 +0000 Subject: [PATCH 09/21] fix(tests): add missing files/ dirs; fix TOML string escapes for newlines Signed-off-by: Gregor Zeitlinger --- tests/cases/general/hook-install/files/.gitkeep | 0 tests/cases/general/update-obsolete-key/test.toml | 2 +- tests/cases/general/version/files/.gitkeep | 0 tests/cases/general/version/test.toml | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 tests/cases/general/hook-install/files/.gitkeep create mode 100644 tests/cases/general/version/files/.gitkeep diff --git a/tests/cases/general/hook-install/files/.gitkeep b/tests/cases/general/hook-install/files/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/general/update-obsolete-key/test.toml b/tests/cases/general/update-obsolete-key/test.toml index 232ba9da..adf98eff 100644 --- a/tests/cases/general/update-obsolete-key/test.toml +++ b/tests/cases/general/update-obsolete-key/test.toml @@ -1,7 +1,7 @@ [expected] args = "update" exit = 0 -stdout = ' replaced "npm:markdownlint-cli" → "npm:markdownlint-cli2"\n' +stdout = " replaced \"npm:markdownlint-cli\" → \"npm:markdownlint-cli2\"\n" [expected.files] "mise.toml" = ''' diff --git a/tests/cases/general/version/files/.gitkeep b/tests/cases/general/version/files/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/tests/cases/general/version/test.toml b/tests/cases/general/version/test.toml index 3a44bb04..ed9543b6 100644 --- a/tests/cases/general/version/test.toml +++ b/tests/cases/general/version/test.toml @@ -1,4 +1,4 @@ [expected] args = "version" exit = 0 -stdout = 'flint 0.20.0-alpha.1\n' +stdout = "flint 0.20.0-alpha.1\n" From f8360ad712c4f649d4393c4c76f97d1369655ede Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 18:40:08 +0000 Subject: [PATCH 10/21] chore: set version to 0.20.0, switch release-please to rust type Drops the -alpha.1 pre-release suffix now that v2 is ready to ship. Updates release-please-config to release-type: rust so future releases automatically update Cargo.toml, and resets the manifest from the old bash v1 version (0.9.2) to 0.20.0. Signed-off-by: Gregor Zeitlinger --- .github/config/.release-please-manifest.json | 2 +- .github/config/release-please-config.json | 2 +- Cargo.lock | 2 +- Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/config/.release-please-manifest.json b/.github/config/.release-please-manifest.json index 02dba1b9..2a932b72 100644 --- a/.github/config/.release-please-manifest.json +++ b/.github/config/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.9.2" + ".": "0.20.0" } diff --git a/.github/config/release-please-config.json b/.github/config/release-please-config.json index 1f0c4e0d..9fc35461 100644 --- a/.github/config/release-please-config.json +++ b/.github/config/release-please-config.json @@ -1,6 +1,6 @@ { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", - "release-type": "simple", + "release-type": "rust", "pull-request-footer": "> [!IMPORTANT]\n> Close and reopen this PR to trigger CI checks.", "packages": { ".": { diff --git a/Cargo.lock b/Cargo.lock index 7c52da51..64923949 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -225,7 +225,7 @@ dependencies = [ [[package]] name = "flint" -version = "0.20.0-alpha.1" +version = "0.20.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 2b9f5967..a7ced909 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "flint" -version = "0.20.0-alpha.1" +version = "0.20.0" edition = "2024" description = "mise-native lint orchestrator" license = "Apache-2.0" From 803f96e514d401ee4162ceb56b8e35668d0c9d9b Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Fri, 10 Apr 2026 18:41:31 +0000 Subject: [PATCH 11/21] chore: fix release-please manifest to 0.19.0 (next release is 0.20.0) Signed-off-by: Gregor Zeitlinger --- .github/config/.release-please-manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/config/.release-please-manifest.json b/.github/config/.release-please-manifest.json index 2a932b72..e272d081 100644 --- a/.github/config/.release-please-manifest.json +++ b/.github/config/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.20.0" + ".": "0.19.0" } From fed6ad209676d5271b3b37c1b98af9b3a0563ce7 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 13 Apr 2026 10:07:20 +0000 Subject: [PATCH 12/21] ci: update mise to v2026.4.10, add Windows SHA256 Signed-off-by: Gregor Zeitlinger --- .github/workflows/test.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 023cbbaf..4d87d88d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -17,14 +17,14 @@ jobs: matrix: include: - os: ubuntu-24.04 - mise_version: v2026.4.1 - mise_sha256: c597fa1e4da76d1ea1967111d150a6a655ca51a72f4cd17fdc584be2b9eaa8bd + mise_version: v2026.4.10 + mise_sha256: 78e91794c9139ab787c9a4de5e9e63a56d65b16bce60912884cb09f7114f7275 - os: macos-15 - mise_version: v2026.4.1 - mise_sha256: c85b387148d478dec754ded31d01798e2f4e4e9448f75682dcc6bb7c16c9a4f5 + mise_version: v2026.4.10 + mise_sha256: dd36283b3418070e1606a2e80839577a8d895f02c1df0d23e424e7104efac81c - os: windows-2025 - mise_version: v2026.4.1 - mise_sha256: "" # not published for .exe — https://github.com/jdx/mise/pull/8997 + mise_version: v2026.4.10 + mise_sha256: 2df0ce5b1f42502a4895888a0fe7aae4cf6d1959d2dbb62f29204773cff3d457 permissions: contents: read From d758de579f7cebb9fd6830a983722889ffeb41cb Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 13 Apr 2026 10:09:45 +0000 Subject: [PATCH 13/21] fix: correct mise SHA256 hashes, address Copilot review comments Signed-off-by: Gregor Zeitlinger --- .github/workflows/test.yml | 4 ++-- src/init/generation.rs | 3 ++- src/init/mod.rs | 2 +- tests/cases/general/version/test.toml | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4d87d88d..a6c45852 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -18,10 +18,10 @@ jobs: include: - os: ubuntu-24.04 mise_version: v2026.4.10 - mise_sha256: 78e91794c9139ab787c9a4de5e9e63a56d65b16bce60912884cb09f7114f7275 + mise_sha256: 84636e19a0e5001d7499f58ae5a868cec8f6ba4f52f9028680bb7cd802564229 - os: macos-15 mise_version: v2026.4.10 - mise_sha256: dd36283b3418070e1606a2e80839577a8d895f02c1df0d23e424e7104efac81c + mise_sha256: e09f5ae83369d3c6d44572e9f2de0bf9454718e23ccb41a4138f8f88d28cbb31 - os: windows-2025 mise_version: v2026.4.10 mise_sha256: 2df0ce5b1f42502a4895888a0fe7aae4cf6d1959d2dbb62f29204773cff3d457 diff --git a/src/init/generation.rs b/src/init/generation.rs index 86e84e6b..4bbfc4b4 100644 --- a/src/init/generation.rs +++ b/src/init/generation.rs @@ -129,7 +129,8 @@ pub fn replace_obsolete_keys( let path = project_root.join("mise.toml"); let content = match std::fs::read_to_string(&path) { Ok(c) => c, - Err(_) => return Ok(vec![]), + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(vec![]), + Err(e) => return Err(e).with_context(|| format!("failed to read {}", path.display())), }; let mut doc: toml_edit::DocumentMut = content.parse().context("failed to parse mise.toml")?; diff --git a/src/init/mod.rs b/src/init/mod.rs index 61686b9a..cfb5eef3 100644 --- a/src/init/mod.rs +++ b/src/init/mod.rs @@ -7,7 +7,7 @@ use std::path::Path; use crate::registry::{Category, Check, builtin}; mod detection; -pub mod generation; +pub(crate) mod generation; mod ui; use detection::{ diff --git a/tests/cases/general/version/test.toml b/tests/cases/general/version/test.toml index ed9543b6..a8777bc5 100644 --- a/tests/cases/general/version/test.toml +++ b/tests/cases/general/version/test.toml @@ -1,4 +1,4 @@ [expected] args = "version" exit = 0 -stdout = "flint 0.20.0-alpha.1\n" +stdout = "flint 0.20.0\n" From 2cfb54c2f66f482198dd78855484aeacfb5f8d60 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 13 Apr 2026 10:18:53 +0000 Subject: [PATCH 14/21] fix: gate versioned_bin substitution to Windows only On Linux/macOS, ubi renames the downloaded binary to the plain tool name (e.g. shfmt, not shfmt_v3.12.0), so resolve_bin_name was looking for a binary that doesn't exist. The versioned filename is a Windows-only ubi behaviour. Signed-off-by: Gregor Zeitlinger --- src/registry.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index 19bd6035..61c8bc6e 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -750,10 +750,12 @@ pub fn check_active(check: &Check, mise_tools: &HashMap) -> bool } /// Returns the binary name to use for this check given the active mise tools. -/// When `versioned_bin_fmt` is set, the version from mise.toml is substituted +/// When `versioned_bin_fmt` is set on Windows, the version from mise.toml is substituted /// into the format string (e.g. `"shfmt_{version}"` + `"v3.12.0"` → `"shfmt_v3.12.0"`). -/// Falls back to `check.bin_name` for standard installations. +/// On non-Windows platforms ubi renames the downloaded binary to the plain tool name, so +/// `versioned_bin_fmt` is ignored and `check.bin_name` is returned directly. pub fn resolve_bin_name(check: &Check, mise_tools: &HashMap) -> String { + #[cfg(windows)] if let Some(fmt) = check.versioned_bin_fmt { let key = check.mise_tool_name.unwrap_or(check.bin_name); if let Some(version) = mise_tools.get(key) { From dc14d628714fedb1c2b8c4bcfd4fd810aad775bd Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 13 Apr 2026 10:19:26 +0000 Subject: [PATCH 15/21] style: fix unused variable warning for _mise_tools on non-Windows Signed-off-by: Gregor Zeitlinger --- src/registry.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index 61c8bc6e..2da90911 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -754,11 +754,11 @@ pub fn check_active(check: &Check, mise_tools: &HashMap) -> bool /// into the format string (e.g. `"shfmt_{version}"` + `"v3.12.0"` → `"shfmt_v3.12.0"`). /// On non-Windows platforms ubi renames the downloaded binary to the plain tool name, so /// `versioned_bin_fmt` is ignored and `check.bin_name` is returned directly. -pub fn resolve_bin_name(check: &Check, mise_tools: &HashMap) -> String { +pub fn resolve_bin_name(check: &Check, _mise_tools: &HashMap) -> String { #[cfg(windows)] if let Some(fmt) = check.versioned_bin_fmt { let key = check.mise_tool_name.unwrap_or(check.bin_name); - if let Some(version) = mise_tools.get(key) { + if let Some(version) = _mise_tools.get(key) { return fmt.replace("{version}", version); } } From dea0bfd465c80b61408b8e55aa3b42c4a2983d65 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 13 Apr 2026 10:43:27 +0000 Subject: [PATCH 16/21] fix: handle mise file-mode bash shims on Windows; bust stale tool cache When mise-shim.exe is absent, mise falls back to 'file' mode: each shim is an extensionless bash script (#!/bin/bash) that calls 'mise exec'. cmd.exe cannot execute these (no .cmd/.exe extension), so detect them via the #! magic bytes and invoke via bash instead. Also increments cache_key_prefix to force a fresh tool installation and clear the stale 3.13.1 entry that Renovate left behind. Signed-off-by: Gregor Zeitlinger --- .github/workflows/test.yml | 1 + src/linters/mod.rs | 47 ++++++++++++++++++++++++++++++++++---- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a6c45852..81d18912 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -41,6 +41,7 @@ jobs: with: version: ${{ matrix.mise_version }} sha256: ${{ matrix.mise_sha256 }} + cache_key_prefix: mise-v2 - name: Install Rust lint components run: rustup component add clippy rustfmt diff --git a/src/linters/mod.rs b/src/linters/mod.rs index 2faa6fac..7b81766e 100644 --- a/src/linters/mod.rs +++ b/src/linters/mod.rs @@ -4,10 +4,15 @@ pub mod renovate_deps; /// Build a [`tokio::process::Command`] for the given argv. /// -/// On Windows, mise shims are `.cmd` files that cannot be spawned directly -/// via `CreateProcessW`. Some tools are native PE binaries without a `.exe` -/// extension that also cannot run via cmd.exe (the shim fails) — we detect -/// these via MZ magic and execute them directly. +/// On Windows, mise shims come in several forms depending on whether +/// `mise-shim.exe` is available: +/// +/// - **exe mode**: `.exe` is a copy of `mise-shim.exe` — detected as PE +/// (MZ magic) and spawned directly via `CreateProcessW`. +/// - **file mode** (fallback when `mise-shim.exe` is absent): `` is an +/// extensionless bash script (`#!/bin/bash`) that calls `mise exec`. cmd.exe +/// cannot execute these, so we invoke them via `bash`. +/// - **`.cmd` shims** (older mise behaviour): routed through `cmd.exe /C`. /// /// Some tools are self-executing JARs (e.g. ktlint) that cmd.exe cannot run /// at all. When `windows_java_jar` is true, the binary is resolved to its @@ -22,10 +27,18 @@ pub fn spawn_command(argv: &[String], windows_java_jar: bool) -> tokio::process: return cmd; } } else if let Some(path) = find_pe_binary(&argv[0]) { + // Native PE binary (exe-mode shim or unextensioned binary). let mut cmd = tokio::process::Command::new(path); cmd.args(&argv[1..]); return cmd; + } else if let Some(path) = find_bash_shim(&argv[0]) { + // File-mode mise shim: an extensionless bash script. + // Git Bash (bash.exe) is available on all Windows CI runners. + let mut cmd = tokio::process::Command::new("bash"); + cmd.arg(path).args(&argv[1..]); + return cmd; } + // Fall back to cmd.exe for .cmd shims (older mise behaviour). let mut cmd = tokio::process::Command::new("cmd.exe"); cmd.arg("/C").args(argv); cmd @@ -65,6 +78,32 @@ fn find_pe_binary(binary: &str) -> Option { None } +/// On Windows, look for `binary` in PATH and return its full path if it looks +/// like a bash script (starts with `#!`). Used to detect mise "file" mode +/// shims, which are extensionless `#!/bin/bash` scripts that must be invoked +/// via `bash` rather than `cmd.exe`. +#[cfg(windows)] +fn find_bash_shim(binary: &str) -> Option { + use std::io::Read; + let path_var = std::env::var("PATH").ok()?; + for dir in std::env::split_paths(&path_var) { + let candidate = dir.join(binary); + if !candidate.is_file() { + continue; + } + let is_bash = std::fs::File::open(&candidate) + .and_then(|mut f| { + let mut buf = [0u8; 2]; + f.read_exact(&mut buf).map(|_| buf == [b'#', b'!']) + }) + .unwrap_or(false); + if is_bash { + return Some(candidate); + } + } + None +} + /// On Windows, return the full path of `binary` from PATH without inspecting /// its contents. Used for self-executing JARs where the caller already knows /// the invocation style (i.e. `windows_java_jar` is set in the registry). From 901fee8c13f84fa3865b89e21a2ee413ad9e6c65 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 13 Apr 2026 10:52:12 +0000 Subject: [PATCH 17/21] fix: restore versioned_bin resolution on all platforms ubi (mise's github: backend) preserves the version suffix in the binary name on all platforms, not just Windows. Reverting the #[cfg(windows)] gate that caused Linux to look for 'shfmt' instead of 'shfmt_v3.12.0'. Signed-off-by: Gregor Zeitlinger --- src/registry.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index 2da90911..53261269 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -750,15 +750,14 @@ pub fn check_active(check: &Check, mise_tools: &HashMap) -> bool } /// Returns the binary name to use for this check given the active mise tools. -/// When `versioned_bin_fmt` is set on Windows, the version from mise.toml is substituted +/// When `versioned_bin_fmt` is set, the version from mise.toml is substituted /// into the format string (e.g. `"shfmt_{version}"` + `"v3.12.0"` → `"shfmt_v3.12.0"`). -/// On non-Windows platforms ubi renames the downloaded binary to the plain tool name, so -/// `versioned_bin_fmt` is ignored and `check.bin_name` is returned directly. -pub fn resolve_bin_name(check: &Check, _mise_tools: &HashMap) -> String { - #[cfg(windows)] +/// This is needed because ubi (used by mise's github: backend) installs binaries +/// with the version suffix preserved in the filename on all platforms. +pub fn resolve_bin_name(check: &Check, mise_tools: &HashMap) -> String { if let Some(fmt) = check.versioned_bin_fmt { let key = check.mise_tool_name.unwrap_or(check.bin_name); - if let Some(version) = _mise_tools.get(key) { + if let Some(version) = mise_tools.get(key) { return fmt.replace("{version}", version); } } From 66717bb7acf62741afb06e2df992a27392b9eeae Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 13 Apr 2026 11:00:28 +0000 Subject: [PATCH 18/21] fix: detect .exe binaries in find_pe_binary on Windows Cargo-installed tools (e.g. xmllint from cargo:xmloxide) use the .exe extension. Without this, find_pe_binary missed them and find_bash_shim picked up a stray xmllint bash/WSL wrapper instead, causing WSL errors. Signed-off-by: Gregor Zeitlinger --- src/linters/mod.rs | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/src/linters/mod.rs b/src/linters/mod.rs index 7b81766e..f8d6a838 100644 --- a/src/linters/mod.rs +++ b/src/linters/mod.rs @@ -52,27 +52,31 @@ pub fn spawn_command(argv: &[String], windows_java_jar: bool) -> tokio::process: } } -/// On Windows, look for `binary` (exact name, no extension) in each PATH -/// directory. If found and it starts with the PE magic bytes `MZ`, return -/// its full path so it can be executed directly via `CreateProcessW`. +/// On Windows, look for `binary` (exact name or with `.exe`) in each PATH +/// directory. If found as a PE binary (MZ magic), return its full path so it +/// can be executed directly via `CreateProcessW`. Checking `.exe` handles +/// native Windows binaries installed by cargo, which always use that extension. #[cfg(windows)] fn find_pe_binary(binary: &str) -> Option { use std::io::Read; let path_var = std::env::var("PATH").ok()?; + let exe = format!("{}.exe", binary); for dir in std::env::split_paths(&path_var) { - let candidate = dir.join(binary); - if !candidate.is_file() { - continue; - } - let is_pe = std::fs::File::open(&candidate) - .and_then(|mut f| { - let mut buf = [0u8; 2]; - f.read_exact(&mut buf)?; - Ok(buf == [b'M', b'Z']) - }) - .unwrap_or(false); - if is_pe { - return Some(candidate); + for name in [binary, exe.as_str()] { + let candidate = dir.join(name); + if !candidate.is_file() { + continue; + } + let is_pe = std::fs::File::open(&candidate) + .and_then(|mut f| { + let mut buf = [0u8; 2]; + f.read_exact(&mut buf)?; + Ok(buf == [b'M', b'Z']) + }) + .unwrap_or(false); + if is_pe { + return Some(candidate); + } } } None From ac5952e09620233c67d466a08fa34e9bea36351a Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 13 Apr 2026 11:06:14 +0000 Subject: [PATCH 19/21] refactor: remove find_bash_shim; fix belongs in mise-action The bash shim workaround is the wrong layer: it's fragile (picks up stray WSL wrappers), and the root cause is that mise-action doesn't install mise-shim.exe, causing mise to fall back to extensionless bash scripts. Filed upstream: jdx/mise-action should provide mise-shim.exe so shims are proper .exe files that cmd.exe handles natively. Signed-off-by: Gregor Zeitlinger --- src/linters/mod.rs | 56 +++++++++------------------------------------- 1 file changed, 10 insertions(+), 46 deletions(-) diff --git a/src/linters/mod.rs b/src/linters/mod.rs index f8d6a838..b43df0dd 100644 --- a/src/linters/mod.rs +++ b/src/linters/mod.rs @@ -4,19 +4,15 @@ pub mod renovate_deps; /// Build a [`tokio::process::Command`] for the given argv. /// -/// On Windows, mise shims come in several forms depending on whether -/// `mise-shim.exe` is available: +/// On Windows, mise shims are `.exe` files (copies of `mise-shim.exe`) when +/// `mise-shim.exe` is available — the normal case. Some tools are also +/// installed as native PE binaries without a `.exe` extension; we detect +/// both via MZ magic and spawn them directly via `CreateProcessW`. +/// Everything else (`.cmd` shims, older mise) routes through `cmd.exe /C`. /// -/// - **exe mode**: `.exe` is a copy of `mise-shim.exe` — detected as PE -/// (MZ magic) and spawned directly via `CreateProcessW`. -/// - **file mode** (fallback when `mise-shim.exe` is absent): `` is an -/// extensionless bash script (`#!/bin/bash`) that calls `mise exec`. cmd.exe -/// cannot execute these, so we invoke them via `bash`. -/// - **`.cmd` shims** (older mise behaviour): routed through `cmd.exe /C`. -/// -/// Some tools are self-executing JARs (e.g. ktlint) that cmd.exe cannot run -/// at all. When `windows_java_jar` is true, the binary is resolved to its -/// full path and invoked as `java -jar `. +/// Self-executing JARs (e.g. ktlint) cannot run via cmd.exe at all. +/// When `windows_java_jar` is true the binary is resolved to its full path +/// and invoked as `java -jar `. pub fn spawn_command(argv: &[String], windows_java_jar: bool) -> tokio::process::Command { #[cfg(windows)] { @@ -27,18 +23,12 @@ pub fn spawn_command(argv: &[String], windows_java_jar: bool) -> tokio::process: return cmd; } } else if let Some(path) = find_pe_binary(&argv[0]) { - // Native PE binary (exe-mode shim or unextensioned binary). + // PE binary (exe-mode shim or extensionless native binary). let mut cmd = tokio::process::Command::new(path); cmd.args(&argv[1..]); return cmd; - } else if let Some(path) = find_bash_shim(&argv[0]) { - // File-mode mise shim: an extensionless bash script. - // Git Bash (bash.exe) is available on all Windows CI runners. - let mut cmd = tokio::process::Command::new("bash"); - cmd.arg(path).args(&argv[1..]); - return cmd; } - // Fall back to cmd.exe for .cmd shims (older mise behaviour). + // .cmd shims and anything else: let cmd.exe resolve the name. let mut cmd = tokio::process::Command::new("cmd.exe"); cmd.arg("/C").args(argv); cmd @@ -82,32 +72,6 @@ fn find_pe_binary(binary: &str) -> Option { None } -/// On Windows, look for `binary` in PATH and return its full path if it looks -/// like a bash script (starts with `#!`). Used to detect mise "file" mode -/// shims, which are extensionless `#!/bin/bash` scripts that must be invoked -/// via `bash` rather than `cmd.exe`. -#[cfg(windows)] -fn find_bash_shim(binary: &str) -> Option { - use std::io::Read; - let path_var = std::env::var("PATH").ok()?; - for dir in std::env::split_paths(&path_var) { - let candidate = dir.join(binary); - if !candidate.is_file() { - continue; - } - let is_bash = std::fs::File::open(&candidate) - .and_then(|mut f| { - let mut buf = [0u8; 2]; - f.read_exact(&mut buf).map(|_| buf == [b'#', b'!']) - }) - .unwrap_or(false); - if is_bash { - return Some(candidate); - } - } - None -} - /// On Windows, return the full path of `binary` from PATH without inspecting /// its contents. Used for self-executing JARs where the caller already knows /// the invocation style (i.e. `windows_java_jar` is set in the registry). From ae5ff250cf17241f02271946a4ca57822d9e8c19 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 13 Apr 2026 11:09:05 +0000 Subject: [PATCH 20/21] refactor: restore spawn_command to match main's structure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only difference from main: windows_java_jar explicit flag instead of the size-based JAR heuristic. No .exe check, no bash shim — cmd.exe handles those cases correctly on its own. Signed-off-by: Gregor Zeitlinger --- src/linters/mod.rs | 49 +++++++++++++++++++++------------------------- 1 file changed, 22 insertions(+), 27 deletions(-) diff --git a/src/linters/mod.rs b/src/linters/mod.rs index b43df0dd..88356234 100644 --- a/src/linters/mod.rs +++ b/src/linters/mod.rs @@ -4,11 +4,12 @@ pub mod renovate_deps; /// Build a [`tokio::process::Command`] for the given argv. /// -/// On Windows, mise shims are `.exe` files (copies of `mise-shim.exe`) when -/// `mise-shim.exe` is available — the normal case. Some tools are also -/// installed as native PE binaries without a `.exe` extension; we detect -/// both via MZ magic and spawn them directly via `CreateProcessW`. -/// Everything else (`.cmd` shims, older mise) routes through `cmd.exe /C`. +/// On Windows, mise shims are `.cmd` files that cannot be spawned directly +/// via `CreateProcessW`. However, some tools (e.g. ktlint) are native PE +/// binaries without a `.exe` extension that also cannot run via cmd.exe +/// (the shim fails). We check for a PE header (MZ magic) to distinguish: +/// - PE binary without extension → execute directly by full path +/// - Everything else → route through `cmd.exe /C` to handle `.cmd` shims /// /// Self-executing JARs (e.g. ktlint) cannot run via cmd.exe at all. /// When `windows_java_jar` is true the binary is resolved to its full path @@ -23,12 +24,10 @@ pub fn spawn_command(argv: &[String], windows_java_jar: bool) -> tokio::process: return cmd; } } else if let Some(path) = find_pe_binary(&argv[0]) { - // PE binary (exe-mode shim or extensionless native binary). let mut cmd = tokio::process::Command::new(path); cmd.args(&argv[1..]); return cmd; } - // .cmd shims and anything else: let cmd.exe resolve the name. let mut cmd = tokio::process::Command::new("cmd.exe"); cmd.arg("/C").args(argv); cmd @@ -42,31 +41,27 @@ pub fn spawn_command(argv: &[String], windows_java_jar: bool) -> tokio::process: } } -/// On Windows, look for `binary` (exact name or with `.exe`) in each PATH -/// directory. If found as a PE binary (MZ magic), return its full path so it -/// can be executed directly via `CreateProcessW`. Checking `.exe` handles -/// native Windows binaries installed by cargo, which always use that extension. +/// On Windows, look for `binary` (exact name, no extension) in each PATH +/// directory. If found and it starts with the PE magic bytes `MZ`, return +/// its full path so it can be executed directly via `CreateProcessW`. #[cfg(windows)] fn find_pe_binary(binary: &str) -> Option { use std::io::Read; let path_var = std::env::var("PATH").ok()?; - let exe = format!("{}.exe", binary); for dir in std::env::split_paths(&path_var) { - for name in [binary, exe.as_str()] { - let candidate = dir.join(name); - if !candidate.is_file() { - continue; - } - let is_pe = std::fs::File::open(&candidate) - .and_then(|mut f| { - let mut buf = [0u8; 2]; - f.read_exact(&mut buf)?; - Ok(buf == [b'M', b'Z']) - }) - .unwrap_or(false); - if is_pe { - return Some(candidate); - } + let candidate = dir.join(binary); + if !candidate.is_file() { + continue; + } + let is_pe = std::fs::File::open(&candidate) + .and_then(|mut f| { + let mut buf = [0u8; 2]; + f.read_exact(&mut buf)?; + Ok(buf == [b'M', b'Z']) + }) + .unwrap_or(false); + if is_pe { + return Some(candidate); } } None From 27fcd355fc57e698ef87963fc94aa1aba5523935 Mon Sep 17 00:00:00 2001 From: Gregor Zeitlinger Date: Mon, 13 Apr 2026 13:44:28 +0000 Subject: [PATCH 21/21] fix(registry): accurate github-backend comment, add ubi obsolete keys Signed-off-by: Gregor Zeitlinger --- src/registry.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index 53261269..cc673f36 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -660,6 +660,13 @@ pub const OBSOLETE_KEYS: &[(&str, &str)] = &[ // markdownlint-cli was superseded by markdownlint-cli2 (actively maintained, // faster, supports the same config files). flint only supports the cli2 variant. ("npm:markdownlint-cli", "npm:markdownlint-cli2"), + // ubi: was deprecated in mise; the github: backend is the modern replacement. + // Repos that adopted flint before this change may still have ubi: keys. + ( + "ubi:google/google-java-format", + "github:google/google-java-format", + ), + ("ubi:pinterest/ktlint", "github:pinterest/ktlint"), ]; /// Checks whether any obsolete tool keys are present in `mise_tools`. @@ -752,8 +759,10 @@ pub fn check_active(check: &Check, mise_tools: &HashMap) -> bool /// Returns the binary name to use for this check given the active mise tools. /// When `versioned_bin_fmt` is set, the version from mise.toml is substituted /// into the format string (e.g. `"shfmt_{version}"` + `"v3.12.0"` → `"shfmt_v3.12.0"`). -/// This is needed because ubi (used by mise's github: backend) installs binaries -/// with the version suffix preserved in the filename on all platforms. +/// This is needed for shfmt because mise's `github:` backend preserves the version +/// suffix in the installed binary name. The backend's binary-name cleaning logic matches +/// binaries against the repo name (e.g. `"mvdan/sh"`), so it cannot map `"shfmt"` → +/// `"mvdan/sh"` and leaves the name as `"shfmt_v3.12.0"` rather than stripping it. pub fn resolve_bin_name(check: &Check, mise_tools: &HashMap) -> String { if let Some(fmt) = check.versioned_bin_fmt { let key = check.mise_tool_name.unwrap_or(check.bin_name);