Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,25 @@ mise run my-bash-task
MISE_BASH_PATH = "C:/tools/msys64/usr/bin/bash.exe"
```

mise honors an **explicit** bash path as-is. If you set `shell` (in a task) or
`windows_default_inline_shell_args` to an absolute path such as
`C:/msys64/usr/bin/bash.exe -c`, mise uses exactly that binary — the
`MISE_BASH_PATH` override and the Git Bash / MSYS2 auto-detection apply only
when the shell is the bare name `bash`.

If your shell path contains spaces (e.g. `C:\Program Files\Git\bin\bash.exe`),
wrap the program in double quotes so the space is not treated as an argument
separator. On Windows, backslashes are treated literally, so they need no
escaping; forward slashes work too:

```toml
[tasks.build]
run = "echo hi"
shell = '"C:\Program Files\Git\bin\bash.exe" -c'
```

(On macOS/Linux, `shell` follows POSIX quoting rules instead.)

## mise isn't working when calling from tmux or another shell initialization script

`mise activate` will not update PATH until the shell prompt is displayed. So if you need to access a
Expand Down
4 changes: 2 additions & 2 deletions src/config/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -708,7 +708,7 @@ impl Settings {
} else {
&self.unix_default_inline_shell_args
};
Ok(shell_words::split(sa)?)
crate::path::split_shell_command(sa)
}

pub fn default_file_shell(&self) -> Result<Vec<String>> {
Expand All @@ -717,7 +717,7 @@ impl Settings {
} else {
&self.unix_default_file_shell_args
};
Ok(shell_words::split(sa)?)
crate::path::split_shell_command(sa)
}

pub fn os(&self) -> &str {
Expand Down
2 changes: 1 addition & 1 deletion src/hooks.rs
Original file line number Diff line number Diff line change
Expand Up @@ -426,7 +426,7 @@ async fn execute(
}
let shell = shell
.as_ref()
.map(|shell| shell_words::split(shell))
.map(|shell| crate::path::split_shell_command(shell))
.transpose()?
.unwrap_or(Settings::get().default_inline_shell()?);

Expand Down
151 changes: 151 additions & 0 deletions src/path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,85 @@ pub fn is_posix_shell_program(program: &Path) -> bool {
POSIX_SHELLS.iter().any(|name| *name == stem)
}

/// Split a configured shell *command string* (program + args) into argv,
/// honoring host conventions.
///
/// On Windows, backslashes are ordinary path characters (NOT escapes) and only
/// double-quoted spans group whitespace — matching how a Windows user expects
/// `C:\path\bash.exe` or `"C:\Program Files\..\bash.exe" -c` to parse. A `""`
/// inside a quoted span is a literal `"`; single quotes are literal characters
/// (cmd does not use them, and they can occur in paths). On Unix, defer to
/// `shell_words::split` for POSIX quoting/escaping.
///
/// Used for every configured shell string — a task's `shell`, hook and
/// `[[watch_files]]` shells, and the `*_default_*_shell_args` settings — so an
/// explicit shell path with spaces (when double-quoted) or with backslashes
/// reaches the spawn verbatim instead of being mangled. Returns `Err` only on
/// an unbalanced double quote (Windows) or a `shell_words` parse error (Unix).
pub fn split_shell_command(s: &str) -> eyre::Result<Vec<String>> {
#[cfg(windows)]
{
split_shell_command_windows(s)
}
#[cfg(not(windows))]
{
Ok(shell_words::split(s)?)
}
}

/// Windows `CommandLineToArgvW`-style splitter, narrowed to mise's needs:
/// double quotes group whitespace, `""` inside a quoted span is a literal `"`,
/// and backslash is a plain character (never an escape — so Windows paths
/// survive). Single quotes are literal. Errors only on an unterminated
/// double-quoted span.
#[cfg(windows)]
fn split_shell_command_windows(s: &str) -> eyre::Result<Vec<String>> {
let mut args: Vec<String> = Vec::new();
let mut cur = String::new();
let mut in_token = false;
let mut in_quotes = false;
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '"' {
in_token = true;
if in_quotes {
if chars.peek() == Some(&'"') {
// `""` inside a quoted span → a literal `"`.
cur.push('"');
chars.next();
} else {
in_quotes = false;
}
} else {
in_quotes = true;
}
} else if c.is_whitespace() && !in_quotes {
if in_token {
args.push(std::mem::take(&mut cur));
in_token = false;
}
} else {
in_token = true;
cur.push(c);
}
}
if in_quotes {
return Err(eyre::eyre!("unbalanced quote in shell command: {s}"));
}
if in_token {
args.push(cur);
}
Ok(args)
}

#[cfg(test)]
mod tests {
use super::*;

fn sv(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}

#[test]
fn test_windows_path_list_to_unix_basic() {
assert_eq!(windows_path_list_to_unix(r"C:\foo;D:\bar"), "/c/foo:/d/bar");
Expand Down Expand Up @@ -249,4 +324,80 @@ mod tests {
assert!(!is_posix_shell_program(Path::new("rustc")));
assert!(!is_posix_shell_program(Path::new("")));
}

#[test]
fn test_split_shell_command_bare_names() {
assert_eq!(split_shell_command("bash -c").unwrap(), sv(&["bash", "-c"]));
assert_eq!(split_shell_command("sh -c").unwrap(), sv(&["sh", "-c"]));
assert_eq!(
split_shell_command("sh -c -o errexit").unwrap(),
sv(&["sh", "-c", "-o", "errexit"])
);
}

#[test]
fn test_split_shell_command_empty() {
assert_eq!(split_shell_command("").unwrap(), sv(&[]));
assert_eq!(split_shell_command(" ").unwrap(), sv(&[]));
}

#[test]
fn test_split_shell_command_quoted_path_with_spaces() {
// A double-quoted path containing spaces is one token on both platforms.
assert_eq!(
split_shell_command("\"C:/Program Files/Git/bin/bash.exe\" -c").unwrap(),
sv(&["C:/Program Files/Git/bin/bash.exe", "-c"])
);
}

#[cfg(windows)]
#[test]
fn test_split_shell_command_windows_backslash_is_literal() {
// Backslash is a plain path char on Windows, not an escape.
assert_eq!(
split_shell_command(r"C:\msys64\usr\bin\bash.exe -c").unwrap(),
sv(&[r"C:\msys64\usr\bin\bash.exe", "-c"])
);
assert_eq!(
split_shell_command("\"C:\\Program Files\\Git\\bin\\bash.exe\" -c").unwrap(),
sv(&[r"C:\Program Files\Git\bin\bash.exe", "-c"])
);
}

#[cfg(windows)]
#[test]
fn test_split_shell_command_windows_unquoted_space_splits() {
// Documented ambiguity: an unquoted space splits even inside a path.
assert_eq!(
split_shell_command(r"C:/Program Files/Git/bin/bash.exe -c").unwrap(),
sv(&["C:/Program", "Files/Git/bin/bash.exe", "-c"])
);
}

#[cfg(windows)]
#[test]
fn test_split_shell_command_windows_double_quote_is_literal() {
// `""` inside a quoted span → a literal `"`.
assert_eq!(
split_shell_command("\"a\"\"b\" c").unwrap(),
sv(&["a\"b", "c"])
);
}

#[cfg(windows)]
#[test]
fn test_split_shell_command_windows_unbalanced_quote_errs() {
assert!(split_shell_command("\"unterminated").is_err());
}

#[cfg(not(windows))]
#[test]
fn test_split_shell_command_unix_posix_semantics() {
// Unix keeps shell_words (POSIX) behavior: backslash escapes, single quotes group.
assert_eq!(
split_shell_command(r"bash\ script -c").unwrap(),
sv(&["bash script", "-c"])
);
assert_eq!(split_shell_command("'a b' c").unwrap(), sv(&["a b", "c"]));
}
}
89 changes: 76 additions & 13 deletions src/task/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1230,19 +1230,20 @@ impl Task {
false
}

pub fn shell(&self) -> Option<Vec<String>> {
self.shell.as_ref().and_then(|shell| {
let shell_cmd = shell
.split_whitespace()
.map(|s| s.to_string())
.collect::<Vec<_>>();
if shell_cmd.is_empty() || shell_cmd[0].trim().is_empty() {
warn!("invalid shell '{shell}', expected '<program> <argument>' (e.g. sh -c)");
None
} else {
Some(shell_cmd)
}
})
pub fn shell(&self) -> eyre::Result<Option<Vec<String>>> {
let Some(shell) = self.shell.as_ref() else {
return Ok(None);
};
// A malformed explicit shell (e.g. an unbalanced quote in a path with
// spaces) must fail loudly rather than silently falling back to the
// default shell and running the task under the wrong interpreter.
let shell_cmd = crate::path::split_shell_command(shell)?;
if shell_cmd.is_empty() || shell_cmd[0].trim().is_empty() {
warn!("invalid shell '{shell}', expected '<program> <argument>' (e.g. sh -c)");
Ok(None)
} else {
Ok(Some(shell_cmd))
}
}

/// Overlay metadata from a `[tasks.<name>]` TOML block onto this task.
Expand Down Expand Up @@ -2353,6 +2354,68 @@ mod tests {
}
}

#[test]
fn test_shell_parses_and_validates() {
let mut task = Task {
shell: Some("bash -c".to_string()),
..Default::default()
};
assert_eq!(
task.shell().unwrap(),
Some(vec!["bash".to_string(), "-c".to_string()])
);

// Whitespace-only is invalid → Ok(None).
task.shell = Some(" ".to_string());
assert_eq!(task.shell().unwrap(), None);

// No shell configured → Ok(None).
task.shell = None;
assert_eq!(task.shell().unwrap(), None);
}

#[test]
fn test_shell_unbalanced_quote_fails_loudly() {
// A malformed explicit shell (unbalanced quote) must error, not silently
// fall back to the default shell and run under the wrong interpreter.
// Codex review of #9932.
let task = Task {
shell: Some("\"unterminated".to_string()),
..Default::default()
};
assert!(task.shell().is_err());
}

#[test]
#[cfg(windows)]
fn test_shell_parses_explicit_windows_path() {
// #9932: a backslash path stays one token, and a double-quoted path with
// spaces is one token. (POSIX parsing is covered in path.rs tests.)
let task = Task {
shell: Some(r"C:\msys64\usr\bin\bash.exe -c".to_string()),
..Default::default()
};
assert_eq!(
task.shell().unwrap(),
Some(vec![
r"C:\msys64\usr\bin\bash.exe".to_string(),
"-c".to_string()
])
);

let task = Task {
shell: Some("\"C:\\Program Files\\Git\\bin\\bash.exe\" -c".to_string()),
..Default::default()
};
assert_eq!(
task.shell().unwrap(),
Some(vec![
r"C:\Program Files\Git\bin\bash.exe".to_string(),
"-c".to_string()
])
);
}

// This test verifies that resolve_depends correctly uses self.depends_post
// instead of iterating through all tasks_to_run (which was the bug)
#[tokio::test]
Expand Down
Loading
Loading