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
9 changes: 9 additions & 0 deletions e2e-win/task.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,15 @@ Write-Output "windows"
mise run filetask | Select -Last 1 | Should -Be 'mytask'
}

It 'executes a shebang task with bash' {
# Create a file task with a bash shebang and no extension
@"
#!/usr/bin/env bash
echo "from-bash"
"@ | Out-File -FilePath "tasks\shebangtask" -Encoding utf8NoBOM -NoNewline

Copilot AI Feb 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The -NoNewline flag will strip the trailing newline from the here-string, but the shebang line itself (line 59) should end with a newline character for proper parsing. Remove -NoNewline to ensure the file has correct line endings.

Suggested change
"@ | Out-File -FilePath "tasks\shebangtask" -Encoding utf8NoBOM -NoNewline
"@ | Out-File -FilePath "tasks\shebangtask" -Encoding utf8NoBOM

Copilot uses AI. Check for mistakes.
mise run shebangtask | Select -Last 1 | Should -Be 'from-bash'
}

It 'executes a task in pwsh' {
$env:MISE_WINDOWS_EXECUTABLE_EXTENSIONS = "ps1"
$env:MISE_WINDOWS_DEFAULT_FILE_SHELL_ARGS = "pwsh.exe"
Expand Down
22 changes: 22 additions & 0 deletions src/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,14 @@ pub fn is_executable(path: &Path) -> bool {

#[cfg(windows)]
pub fn is_executable(path: &Path) -> bool {
if has_known_executable_extension(path) {
return true;
}
has_shebang(path)
}

#[cfg(windows)]
pub fn has_known_executable_extension(path: &Path) -> bool {
path.extension().map_or(
Settings::get()
.windows_executable_extensions
Expand All @@ -489,6 +497,20 @@ pub fn is_executable(path: &Path) -> bool {
)
}

/// Check if a file starts with a shebang (#!).
/// Only reads the first 2 bytes to minimize I/O during task discovery.
#[cfg(windows)]
pub fn has_shebang(path: &Path) -> bool {
std::fs::File::open(path)
.and_then(|mut f| {
use std::io::Read;
let mut buf = [0u8; 2];
f.read_exact(&mut buf)?;
Ok(buf == *b"#!")
})
.unwrap_or(false)
}

#[cfg(unix)]
pub fn make_executable<P: AsRef<Path>>(path: P) -> Result<()> {
trace!("chmod +x {}", display_path(&path));
Expand Down
63 changes: 55 additions & 8 deletions src/task/task_executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,18 +490,13 @@ impl TaskExecutor {
args: &[String],
) -> Result<(String, Vec<String>)> {
let display = file.display().to_string();
if is_executable(file) && !Settings::get().use_file_shell_for_executable_tasks {
if cfg!(windows) && file.extension().is_some_and(|e| e == "ps1") {
let args = vec!["-File".to_string(), display]
.into_iter()
.chain(args.iter().cloned())
.collect_vec();
return Ok(("pwsh".to_string(), args));
}
if !Settings::get().use_file_shell_for_executable_tasks && can_execute_directly(file) {
return Ok((display, args.to_vec()));
}
let shell = task
.shell()
.or_else(|| shell_from_shebang(file))
.or_else(|| shell_from_extension(file))
.unwrap_or(Settings::get().default_file_shell()?);
trace!("using shell: {}", shell.join(" "));
let mut full_args = shell.clone();
Expand Down Expand Up @@ -856,3 +851,55 @@ impl TaskExecutor {
Ok(())
}
}

/// Check if a file can be executed directly by the OS without a shell wrapper.
/// On Unix, this checks the executable permission bit.
/// On Windows, this checks for a known executable extension (.bat, .ps1, etc.)
/// — shebang-only files need to be run through a shell.
fn can_execute_directly(path: &Path) -> bool {
#[cfg(windows)]
{
// .ps1 files need pwsh -File, they can't be executed directly
if path.extension().is_some_and(|e| e == "ps1") {
return false;
}
crate::file::has_known_executable_extension(path)
}
#[cfg(not(windows))]
{
is_executable(path)
}
}

/// Determine the shell from a file's extension.
/// e.g. `.ps1` → `["pwsh", "-File"]`
fn shell_from_extension(path: &Path) -> Option<Vec<String>> {
match path.extension()?.to_str()? {
"ps1" => Some(vec!["pwsh".to_string(), "-File".to_string()]),
_ => None,
}
}

/// Read the shebang from a file and parse it into a shell command.
/// e.g. `#!/usr/bin/env bash` → `["bash"]`
/// e.g. `#!/bin/bash` → `["/bin/bash"]`
fn shell_from_shebang(path: &Path) -> Option<Vec<String>> {
use std::io::{BufRead, BufReader};
let f = std::fs::File::open(path).ok()?;
let mut reader = BufReader::new(f);
let mut first_line = String::new();
reader.read_line(&mut first_line).ok()?;
let shebang = first_line.strip_prefix("#!")?;
let shebang = shebang.strip_prefix("/usr/bin/env -S").unwrap_or(shebang);
let shebang = shebang.strip_prefix("/usr/bin/env").unwrap_or(shebang);
let mut parts = shebang.split_whitespace();
let shell = parts.next()?;
Comment on lines +887 to +896

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The use of split_whitespace() for parsing the shebang can lead to incorrect behavior when arguments are quoted. For instance, a shebang like #!/usr/bin/env bash -c "echo hello" would be parsed incorrectly. It's better to use shell_words::split, which is already a dependency in this project and correctly handles quoted arguments.

    let mut parts = shell_words::split(shebang.trim()).ok()?;
    if parts.is_empty() {
        return None;
    }
    let shell = parts.remove(0);
    // On Windows, convert unix paths like /bin/bash to just the binary name
    let shell = if cfg!(windows) {
        shell.rsplit('/').next().unwrap_or(&shell).to_string()
    } else {
        shell
    };
    let args: Vec<String> = parts;
    Some(once(shell).chain(args).collect())

// On Windows, convert unix paths like /bin/bash to just the binary name
let shell = if cfg!(windows) {
shell.rsplit('/').next().unwrap_or(shell)
} else {
shell
};
let args: Vec<String> = parts.map(|s| s.to_string()).collect();
Some(once(shell.to_string()).chain(args).collect())

Copilot AI Feb 1, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The once function is used but not imported at the top of the file. Add use std::iter::once; to the imports section for clarity.

Copilot uses AI. Check for mistakes.
}
Loading