From 19d7f18b980ee1dadb81626d13b532965a1243da Mon Sep 17 00:00:00 2001 From: Taku Kodma <79110363+risu729@users.noreply.github.com> Date: Tue, 2 Jun 2026 19:38:59 +1000 Subject: [PATCH 1/4] feat(hooks): add run_windows support --- docs/hooks.md | 15 +++- e2e/config/test_hooks_run_windows | 21 +++++ schema/mise.json | 13 ++- src/hooks.rs | 131 ++++++++++++++++++++++++++++-- 4 files changed, 170 insertions(+), 10 deletions(-) create mode 100644 e2e/config/test_hooks_run_windows diff --git a/docs/hooks.md b/docs/hooks.md index ed5b86e602..c1d50cf6ca 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -48,6 +48,19 @@ String hooks are shorthand for `run` hooks. Use a hook table when you need to se postinstall = { run = "echo 'installed'", shell = "bash -c" } ``` +Like tasks, inline hook tables may define a Windows-specific command with `run_windows`. +On Windows, mise uses `run_windows` when it is set; otherwise it uses `run`. On other +platforms, a hook with only `run_windows` is skipped. + +```toml +[settings] +unix_default_inline_shell_args = "bash -c" +windows_default_inline_shell_args = "pwsh -Command" + +[hooks] +postinstall = { run = "echo installed", run_windows = "Write-Output installed" } +``` + For `preinstall` and `postinstall`, `script = ...` is a legacy alias for `run = ...`. If a `shell` is also set on a `script`/`scripts` hook, mise warns that the shell is ignored and still runs the script with the default inline shell. Use `run = ...` with `shell = "bash -c"` to choose the inline shell command. The `script` alias for install hooks is deprecated. The `postinstall` hook receives a `MISE_INSTALLED_TOOLS` environment variable containing a JSON array of the tools that were just installed: @@ -149,7 +162,7 @@ Hooks are executed with the following environment variables set: Inline `run` hooks can be written as `{ run = "..." }` for any hook type. The string shorthand (`enter = "echo hi"`) is equivalent to `{ run = "echo hi" }`. -`run` must be a string. `run = ["echo one", "echo two"]` is not supported. +`run` and `run_windows` must be strings. `run = ["echo one", "echo two"]` is not supported. To run separate spawned inline commands, define multiple hooks. Each hook entry is a separate execution, so mise starts one subprocess per `run` entry: diff --git a/e2e/config/test_hooks_run_windows b/e2e/config/test_hooks_run_windows new file mode 100644 index 0000000000..97293f092b --- /dev/null +++ b/e2e/config/test_hooks_run_windows @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +cat <<'EOF' >mise.toml +[settings] +unix_default_inline_shell_args = "bash -c" + +[tools] +dummy = 'latest' + +[hooks] +preinstall = [ + { run_windows = 'echo PRE_WINDOWS_ONLY' }, + { run = 'echo PRE_UNIX', run_windows = 'echo PRE_WINDOWS' }, +] +EOF + +output=$(mise install 2>&1) + +assert_contains "echo '$output'" "PRE_UNIX" +assert_not_contains "echo '$output'" "PRE_WINDOWS" +assert_not_contains "echo '$output'" "PRE_WINDOWS_ONLY" diff --git a/schema/mise.json b/schema/mise.json index cbdf09561a..f159ce631f 100644 --- a/schema/mise.json +++ b/schema/mise.json @@ -2062,12 +2062,23 @@ "description": "inline hook command to run", "type": "string" }, + "run_windows": { + "description": "inline hook command to run on Windows", + "type": "string" + }, "shell": { "description": "inline shell command to use for run hooks, such as \"bash -c\"", "type": "string" } }, - "required": ["run"], + "anyOf": [ + { + "required": ["run"] + }, + { + "required": ["run_windows"] + } + ], "type": "object" }, "hook_script_value": { diff --git a/src/hooks.rs b/src/hooks.rs index 2a4ec558b9..18055c7ed4 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -73,7 +73,7 @@ pub enum HookDef { /// Simple run string: `enter = "echo hello"` RunString(String), /// Table with run: `enter = { run = "echo hello" }` - Run { run: String, shell: Option }, + Run(HookRunTable), /// Table with script and optional shell: `enter = { script = "echo hello", shell = "bash" }` ScriptTable { script: HookScripts, @@ -90,6 +90,40 @@ pub enum HookDef { Array(Vec), } +#[derive(Debug, Clone)] +pub struct HookRunTable { + run: Option, + run_windows: Option, + shell: Option, +} + +impl<'de> serde::Deserialize<'de> for HookRunTable { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + #[derive(serde::Deserialize)] + #[serde(deny_unknown_fields)] + struct Helper { + run: Option, + run_windows: Option, + shell: Option, + } + + let helper = ::deserialize(deserializer)?; + if helper.run.is_none() && helper.run_windows.is_none() { + return Err(serde::de::Error::custom( + "hook run table must define `run` or `run_windows`", + )); + } + Ok(Self { + run: helper.run, + run_windows: helper.run_windows, + shell: helper.shell, + }) + } +} + impl HookDef { /// Convert to a list of Hook structs with the given hook type pub fn into_hooks(self, hook_type: Hooks) -> Vec { @@ -97,18 +131,20 @@ impl HookDef { HookDef::RunString(script) => vec![Hook { hook: hook_type, action: HookAction::Run { - run: script, + run: Some(script), + run_windows: None, shell: None, legacy_script: false, ignored_shell: None, }, global: false, }], - HookDef::Run { run, shell } => vec![Hook { + HookDef::Run(table) => vec![Hook { hook: hook_type, action: HookAction::Run { - run, - shell, + run: table.run, + run_windows: table.run_windows, + shell: table.shell, legacy_script: false, ignored_shell: None, }, @@ -148,7 +184,8 @@ fn script_hook_action( HookAction::CurrentShell { script, shell } } (_, shell) => HookAction::Run { - run: script, + run: Some(script), + run_windows: None, shell: None, legacy_script, ignored_shell: shell, @@ -167,7 +204,8 @@ pub struct Hook { #[derive(Debug, Clone, Eq, PartialEq, Hash)] pub enum HookAction { Run { - run: String, + run: Option, + run_windows: Option, shell: Option, legacy_script: bool, ignored_shell: Option, @@ -189,11 +227,17 @@ impl Hook { match &mut self.action { HookAction::Run { run, + run_windows, shell, ignored_shell, .. } => { - *run = render(run)?; + if let Some(s) = run { + *s = render(s)?; + } + if let Some(s) = run_windows { + *s = render(s)?; + } if let Some(s) = shell { *s = render(s)?; } @@ -437,6 +481,7 @@ async fn execute( Settings::get().ensure_experimental("hooks")?; let HookAction::Run { run, + run_windows, shell, legacy_script, ignored_shell, @@ -459,6 +504,9 @@ async fn execute( hook_name ); } + let Some(run) = select_run(run, run_windows, cfg!(windows)) else { + return Ok(()); + }; let shell = shell .as_ref() .map(|shell| crate::path::split_shell_command(shell)) @@ -536,6 +584,18 @@ async fn execute( Ok(()) } +fn select_run<'a>( + run: &'a Option, + run_windows: &'a Option, + windows: bool, +) -> Option<&'a str> { + if windows { + run_windows.as_deref().or(run.as_deref()) + } else { + run.as_deref() + } +} + async fn execute_task( config: &Arc, ts: &Toolset, @@ -589,3 +649,58 @@ async fn execute_task( .run()?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use serde::Deserialize; + + #[derive(Deserialize)] + struct TestHook { + hook: HookDef, + } + + #[test] + fn run_table_supports_run_windows() { + let parsed: TestHook = toml::from_str( + r#" + hook = { run = "echo unix", run_windows = "echo windows", shell = "bash -c" } + "#, + ) + .unwrap(); + let hooks = parsed.hook.into_hooks(Hooks::Postinstall); + + assert_eq!(hooks.len(), 1); + match &hooks[0].action { + HookAction::Run { + run, + run_windows, + shell, + .. + } => { + assert_eq!(run.as_deref(), Some("echo unix")); + assert_eq!(run_windows.as_deref(), Some("echo windows")); + assert_eq!(shell.as_deref(), Some("bash -c")); + } + action => panic!("expected run hook, got {action:?}"), + } + } + + #[test] + fn select_run_uses_windows_override_only_on_windows() { + let run = Some("echo unix".to_string()); + let run_windows = Some("echo windows".to_string()); + + assert_eq!(select_run(&run, &run_windows, false), Some("echo unix")); + assert_eq!(select_run(&run, &run_windows, true), Some("echo windows")); + } + + #[test] + fn select_run_skips_windows_only_hook_on_unix() { + let run = None; + let run_windows = Some("echo windows".to_string()); + + assert_eq!(select_run(&run, &run_windows, false), None); + assert_eq!(select_run(&run, &run_windows, true), Some("echo windows")); + } +} From f7396e229611c46ce330d0136935c25975475fe6 Mon Sep 17 00:00:00 2001 From: Taku Kodma <79110363+risu729@users.noreply.github.com> Date: Tue, 2 Jun 2026 20:07:59 +1000 Subject: [PATCH 2/4] fix(hooks): skip inactive run_windows rendering --- src/hooks.rs | 44 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 10 deletions(-) diff --git a/src/hooks.rs b/src/hooks.rs index 18055c7ed4..306e08e175 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -232,17 +232,14 @@ impl Hook { ignored_shell, .. } => { - if let Some(s) = run { - *s = render(s)?; - } - if let Some(s) = run_windows { - *s = render(s)?; - } - if let Some(s) = shell { - *s = render(s)?; - } - if let Some(s) = ignored_shell { + if let Some(s) = select_run_mut(run, run_windows, cfg!(windows)) { *s = render(s)?; + if let Some(s) = shell { + *s = render(s)?; + } + if let Some(s) = ignored_shell { + *s = render(s)?; + } } } HookAction::CurrentShell { script, shell } => { @@ -596,6 +593,18 @@ fn select_run<'a>( } } +fn select_run_mut<'a>( + run: &'a mut Option, + run_windows: &'a mut Option, + windows: bool, +) -> Option<&'a mut String> { + if windows { + run_windows.as_mut().or(run.as_mut()) + } else { + run.as_mut() + } +} + async fn execute_task( config: &Arc, ts: &Toolset, @@ -703,4 +712,19 @@ mod tests { assert_eq!(select_run(&run, &run_windows, false), None); assert_eq!(select_run(&run, &run_windows, true), Some("echo windows")); } + + #[test] + fn select_run_mut_leaves_inactive_windows_template_unrendered() { + let mut run = Some("echo unix".to_string()); + let mut run_windows = Some("{{ exec(command='windows-only') }}".to_string()); + + let selected = select_run_mut(&mut run, &mut run_windows, false).unwrap(); + *selected = "rendered unix".to_string(); + + assert_eq!(run.as_deref(), Some("rendered unix")); + assert_eq!( + run_windows.as_deref(), + Some("{{ exec(command='windows-only') }}") + ); + } } From 1ad5e38eef86714ae5b954fd93b30f243940b513 Mon Sep 17 00:00:00 2001 From: Taku Kodma <79110363+risu729@users.noreply.github.com> Date: Fri, 5 Jun 2026 07:06:13 +1000 Subject: [PATCH 3/4] test(hooks): cover inactive run_windows templates --- e2e/config/test_hooks_run_windows | 1 + 1 file changed, 1 insertion(+) diff --git a/e2e/config/test_hooks_run_windows b/e2e/config/test_hooks_run_windows index 97293f092b..cc88533fa0 100644 --- a/e2e/config/test_hooks_run_windows +++ b/e2e/config/test_hooks_run_windows @@ -9,6 +9,7 @@ dummy = 'latest' [hooks] preinstall = [ + { run_windows = '{{ exec(command="mise-run-windows-template-should-not-execute") }}' }, { run_windows = 'echo PRE_WINDOWS_ONLY' }, { run = 'echo PRE_UNIX', run_windows = 'echo PRE_WINDOWS' }, ] From b924a876f063590469347645475ef86d8f4d64f1 Mon Sep 17 00:00:00 2001 From: Taku Kodma <79110363+risu729@users.noreply.github.com> Date: Sat, 6 Jun 2026 04:14:09 +1000 Subject: [PATCH 4/4] refactor(hooks): inline run_windows selection --- docs/hooks.md | 4 --- src/hooks.rs | 71 +++++++++------------------------------------------ 2 files changed, 12 insertions(+), 63 deletions(-) diff --git a/docs/hooks.md b/docs/hooks.md index c1d50cf6ca..3d76ebf5b8 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -53,10 +53,6 @@ On Windows, mise uses `run_windows` when it is set; otherwise it uses `run`. On platforms, a hook with only `run_windows` is skipped. ```toml -[settings] -unix_default_inline_shell_args = "bash -c" -windows_default_inline_shell_args = "pwsh -Command" - [hooks] postinstall = { run = "echo installed", run_windows = "Write-Output installed" } ``` diff --git a/src/hooks.rs b/src/hooks.rs index 306e08e175..7ce30b82d1 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -232,7 +232,12 @@ impl Hook { ignored_shell, .. } => { - if let Some(s) = select_run_mut(run, run_windows, cfg!(windows)) { + let run = if cfg!(windows) { + run_windows.as_mut().or(run.as_mut()) + } else { + run.as_mut() + }; + if let Some(s) = run { *s = render(s)?; if let Some(s) = shell { *s = render(s)?; @@ -501,7 +506,12 @@ async fn execute( hook_name ); } - let Some(run) = select_run(run, run_windows, cfg!(windows)) else { + let run = if cfg!(windows) { + run_windows.as_deref().or(run.as_deref()) + } else { + run.as_deref() + }; + let Some(run) = run else { return Ok(()); }; let shell = shell @@ -581,30 +591,6 @@ async fn execute( Ok(()) } -fn select_run<'a>( - run: &'a Option, - run_windows: &'a Option, - windows: bool, -) -> Option<&'a str> { - if windows { - run_windows.as_deref().or(run.as_deref()) - } else { - run.as_deref() - } -} - -fn select_run_mut<'a>( - run: &'a mut Option, - run_windows: &'a mut Option, - windows: bool, -) -> Option<&'a mut String> { - if windows { - run_windows.as_mut().or(run.as_mut()) - } else { - run.as_mut() - } -} - async fn execute_task( config: &Arc, ts: &Toolset, @@ -694,37 +680,4 @@ mod tests { action => panic!("expected run hook, got {action:?}"), } } - - #[test] - fn select_run_uses_windows_override_only_on_windows() { - let run = Some("echo unix".to_string()); - let run_windows = Some("echo windows".to_string()); - - assert_eq!(select_run(&run, &run_windows, false), Some("echo unix")); - assert_eq!(select_run(&run, &run_windows, true), Some("echo windows")); - } - - #[test] - fn select_run_skips_windows_only_hook_on_unix() { - let run = None; - let run_windows = Some("echo windows".to_string()); - - assert_eq!(select_run(&run, &run_windows, false), None); - assert_eq!(select_run(&run, &run_windows, true), Some("echo windows")); - } - - #[test] - fn select_run_mut_leaves_inactive_windows_template_unrendered() { - let mut run = Some("echo unix".to_string()); - let mut run_windows = Some("{{ exec(command='windows-only') }}".to_string()); - - let selected = select_run_mut(&mut run, &mut run_windows, false).unwrap(); - *selected = "rendered unix".to_string(); - - assert_eq!(run.as_deref(), Some("rendered unix")); - assert_eq!( - run_windows.as_deref(), - Some("{{ exec(command='windows-only') }}") - ); - } }