From 33ae5632880cc259d472da3c8cb55d29d3775144 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:36:57 +0000 Subject: [PATCH 1/5] fix(task): improve error message when task files are not executable When task files exist in mise-tasks/ but lack the executable bit (common when created on Windows), show a clear error with chmod +x instructions instead of the generic "no tasks defined" message. Closes https://github.com/jdx/mise/discussions/8614 Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/tasks/test_task_non_executable_error | 35 ++++++++++++++++++++ src/cli/tasks/ls.rs | 22 +++++++++++-- src/task/task_list.rs | 41 ++++++++++++++++++++++++ 3 files changed, 96 insertions(+), 2 deletions(-) create mode 100644 e2e/tasks/test_task_non_executable_error diff --git a/e2e/tasks/test_task_non_executable_error b/e2e/tasks/test_task_non_executable_error new file mode 100644 index 00000000000..2ef53147484 --- /dev/null +++ b/e2e/tasks/test_task_non_executable_error @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +# Test that non-executable files in mise-tasks/ give a helpful error message +# Issue: https://github.com/jdx/mise/discussions/8614 + +# Create a mise-tasks directory with non-executable files +mkdir -p mise-tasks +cat <<'EOF' >mise-tasks/build +#!/usr/bin/env bash +echo "building" +EOF +cat <<'EOF' >mise-tasks/test +#!/usr/bin/env bash +echo "testing" +EOF + +# Ensure files are NOT executable +chmod -x mise-tasks/build mise-tasks/test + +# Create a minimal mise.toml so the directory is recognized as a project +cat <mise.toml +EOF + +# Running a task should give a helpful error mentioning non-executable files +assert_fail_contains "mise run build" "non-executable" +assert_fail_contains "mise run build" "chmod +x" + +# mise tasks ls should warn about non-executable files +assert_contains "mise tasks 2>&1" "non-executable" + +# After making files executable, tasks should work +chmod +x mise-tasks/build mise-tasks/test +assert_contains "mise tasks" "build" +assert_contains "mise tasks" "test" +assert "mise run build" "building" diff --git a/src/cli/tasks/ls.rs b/src/cli/tasks/ls.rs index 699b4bd0cdc..9bd749f86dc 100644 --- a/src/cli/tasks/ls.rs +++ b/src/cli/tasks/ls.rs @@ -1,7 +1,8 @@ use std::sync::Arc; -use crate::config::Config; -use crate::file::display_rel_path; +use crate::config::{self, Config}; +use crate::dirs; +use crate::file::{self, display_rel_path}; use crate::task::Task; use crate::task::task_fetcher::TaskFetcher; use crate::ui::table::MiseTable; @@ -122,6 +123,23 @@ impl TasksLs { // MISE_TASK_REMOTE_NO_CACHE env var is still respected if set TaskFetcher::new(false).fetch_tasks(&mut tasks).await?; + if tasks.is_empty() && !cfg!(windows) { + if let Some(cwd) = &*dirs::CWD { + let includes = config::task_includes_for_dir(cwd, &config.config_files); + let has_non_exec = includes.iter().filter(|d| d.is_dir()).any(|d| { + walkdir::WalkDir::new(d) + .into_iter() + .filter_map(|e| e.ok()) + .any(|e| e.file_type().is_file() && !file::is_executable(e.path())) + }); + if has_non_exec { + warn!( + "no tasks found, but non-executable files exist in task directories.\nFiles must be executable to be detected as tasks. Run `chmod +x` on the task files to fix this." + ); + } + } + } + if self.complete { return self.complete(tasks); } else if self.usage { diff --git a/src/task/task_list.rs b/src/task/task_list.rs index 3d07b4cc98c..d7583721f7d 100644 --- a/src/task/task_list.rs +++ b/src/task/task_list.rs @@ -123,6 +123,47 @@ async fn err_no_task(config: &Config, name: &str) -> Result<()> { } } + // Check if there are non-executable files in task include directories + if !cfg!(windows) { + if let Some(cwd) = &*dirs::CWD { + let includes = config::task_includes_for_dir(cwd, &config.config_files); + let non_exec_files: Vec = includes + .iter() + .filter(|d| d.is_dir()) + .flat_map(|d| { + walkdir::WalkDir::new(d) + .into_iter() + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file()) + .map(|e| e.path().to_path_buf()) + .filter(|p| !file::is_executable(p)) + .collect::>() + }) + .collect(); + if !non_exec_files.is_empty() { + let dirs_with_files: Vec = includes + .iter() + .filter(|d| d.is_dir()) + .map(|d| display_path(d)) + .collect(); + bail!( + "no tasks defined in {}, but found {} non-executable file(s) in {}.\n\ + Files must be executable to be detected as tasks.\n\ + Run `chmod +x` on the task files to fix this, e.g.:\n chmod +x {}", + display_path(dirs::CWD.clone().unwrap_or_default()), + non_exec_files.len(), + dirs_with_files.join(", "), + non_exec_files + .iter() + .take(5) + .map(|p| display_path(p)) + .collect::>() + .join(" "), + ); + } + } + } + bail!( "no tasks defined in {}. Are you in a project directory?", display_path(dirs::CWD.clone().unwrap_or_default()) From 6526c3ff2322d29683078de7e0316bcb80c3c4c1 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:41:29 +0000 Subject: [PATCH 2/5] refactor(task): simplify non-executable file iterator chain Co-Authored-By: Claude Opus 4.6 (1M context) --- src/task/task_list.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/task/task_list.rs b/src/task/task_list.rs index d7583721f7d..abd53778837 100644 --- a/src/task/task_list.rs +++ b/src/task/task_list.rs @@ -134,10 +134,8 @@ async fn err_no_task(config: &Config, name: &str) -> Result<()> { walkdir::WalkDir::new(d) .into_iter() .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_file()) + .filter(|e| e.file_type().is_file() && !file::is_executable(e.path())) .map(|e| e.path().to_path_buf()) - .filter(|p| !file::is_executable(p)) - .collect::>() }) .collect(); if !non_exec_files.is_empty() { From 2422f4db0771bd491c6d12009fab14d7adf25b43 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:47:05 +0000 Subject: [PATCH 3/5] [autofix.ci] apply automated fixes --- src/cli/tasks/ls.rs | 5 ++--- src/task/task_list.rs | 9 ++++----- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/cli/tasks/ls.rs b/src/cli/tasks/ls.rs index 9bd749f86dc..43c9e8ee4e8 100644 --- a/src/cli/tasks/ls.rs +++ b/src/cli/tasks/ls.rs @@ -123,8 +123,8 @@ impl TasksLs { // MISE_TASK_REMOTE_NO_CACHE env var is still respected if set TaskFetcher::new(false).fetch_tasks(&mut tasks).await?; - if tasks.is_empty() && !cfg!(windows) { - if let Some(cwd) = &*dirs::CWD { + if tasks.is_empty() && !cfg!(windows) + && let Some(cwd) = &*dirs::CWD { let includes = config::task_includes_for_dir(cwd, &config.config_files); let has_non_exec = includes.iter().filter(|d| d.is_dir()).any(|d| { walkdir::WalkDir::new(d) @@ -138,7 +138,6 @@ impl TasksLs { ); } } - } if self.complete { return self.complete(tasks); diff --git a/src/task/task_list.rs b/src/task/task_list.rs index abd53778837..0cfe1fd6d03 100644 --- a/src/task/task_list.rs +++ b/src/task/task_list.rs @@ -124,8 +124,8 @@ async fn err_no_task(config: &Config, name: &str) -> Result<()> { } // Check if there are non-executable files in task include directories - if !cfg!(windows) { - if let Some(cwd) = &*dirs::CWD { + if !cfg!(windows) + && let Some(cwd) = &*dirs::CWD { let includes = config::task_includes_for_dir(cwd, &config.config_files); let non_exec_files: Vec = includes .iter() @@ -142,7 +142,7 @@ async fn err_no_task(config: &Config, name: &str) -> Result<()> { let dirs_with_files: Vec = includes .iter() .filter(|d| d.is_dir()) - .map(|d| display_path(d)) + .map(display_path) .collect(); bail!( "no tasks defined in {}, but found {} non-executable file(s) in {}.\n\ @@ -154,13 +154,12 @@ async fn err_no_task(config: &Config, name: &str) -> Result<()> { non_exec_files .iter() .take(5) - .map(|p| display_path(p)) + .map(display_path) .collect::>() .join(" "), ); } } - } bail!( "no tasks defined in {}. Are you in a project directory?", From 2523b697735978bedf8d241432a25d2c46a64cd3 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Sun, 22 Mar 2026 14:47:26 +0000 Subject: [PATCH 4/5] fix(task): skip hidden files and fix false warning on filtered tasks - Skip hidden files (e.g., .gitkeep, .DS_Store) when scanning for non-executable task files, matching the behavior of load_tasks_includes - Check unfiltered task count in `mise tasks ls` so the warning doesn't fire when tasks exist but are filtered by --hidden/--local/--global Co-Authored-By: Claude Opus 4.6 (1M context) --- src/cli/tasks/ls.rs | 35 +++++++++++----------- src/task/task_list.rs | 67 ++++++++++++++++++++++++------------------- 2 files changed, 55 insertions(+), 47 deletions(-) diff --git a/src/cli/tasks/ls.rs b/src/cli/tasks/ls.rs index 43c9e8ee4e8..da12e721867 100644 --- a/src/cli/tasks/ls.rs +++ b/src/cli/tasks/ls.rs @@ -2,9 +2,10 @@ use std::sync::Arc; use crate::config::{self, Config}; use crate::dirs; -use crate::file::{self, display_rel_path}; +use crate::file::display_rel_path; use crate::task::Task; use crate::task::task_fetcher::TaskFetcher; +use crate::task::task_list::find_non_executable_task_files; use crate::ui::table::MiseTable; use comfy_table::{Attribute, Cell, Row}; use eyre::Result; @@ -106,9 +107,9 @@ impl TasksLs { None }; - let tasks = config - .tasks_with_context(ctx.as_ref()) - .await? + let all_tasks = config.tasks_with_context(ctx.as_ref()).await?; + + let tasks = all_tasks .values() .filter(|t| self.hidden || !t.hide) .filter(|t| !self.local || !t.global) @@ -123,21 +124,19 @@ impl TasksLs { // MISE_TASK_REMOTE_NO_CACHE env var is still respected if set TaskFetcher::new(false).fetch_tasks(&mut tasks).await?; - if tasks.is_empty() && !cfg!(windows) - && let Some(cwd) = &*dirs::CWD { - let includes = config::task_includes_for_dir(cwd, &config.config_files); - let has_non_exec = includes.iter().filter(|d| d.is_dir()).any(|d| { - walkdir::WalkDir::new(d) - .into_iter() - .filter_map(|e| e.ok()) - .any(|e| e.file_type().is_file() && !file::is_executable(e.path())) - }); - if has_non_exec { - warn!( - "no tasks found, but non-executable files exist in task directories.\nFiles must be executable to be detected as tasks. Run `chmod +x` on the task files to fix this." - ); - } + // Warn about non-executable files only when there are truly no tasks at all + // (not just filtered out by --hidden/--local/--global) + if all_tasks.is_empty() + && !cfg!(windows) + && let Some(cwd) = &*dirs::CWD + { + let includes = config::task_includes_for_dir(cwd, &config.config_files); + if !find_non_executable_task_files(&includes).is_empty() { + warn!( + "no tasks found, but non-executable files exist in task directories.\nFiles must be executable to be detected as tasks. Run `chmod +x` on the task files to fix this." + ); } + } if self.complete { return self.complete(tasks); diff --git a/src/task/task_list.rs b/src/task/task_list.rs index 0cfe1fd6d03..450120b89ea 100644 --- a/src/task/task_list.rs +++ b/src/task/task_list.rs @@ -16,6 +16,24 @@ use std::iter::once; use std::path::PathBuf; use std::sync::Arc; +/// Find non-executable files in task include directories. +/// These are files that likely should be tasks but are missing the executable bit. +/// Skips hidden files (e.g., .gitkeep, .DS_Store) to match load_tasks_includes behavior. +pub fn find_non_executable_task_files(includes: &[PathBuf]) -> Vec { + includes + .iter() + .filter(|d| d.is_dir()) + .flat_map(|d| { + walkdir::WalkDir::new(d) + .into_iter() + .filter_entry(|e| !e.file_name().to_string_lossy().starts_with('.')) + .filter_map(|e| e.ok()) + .filter(|e| e.file_type().is_file() && !file::is_executable(e.path())) + .map(|e| e.path().to_path_buf()) + }) + .collect() +} + /// Split a task spec into name and args /// e.g., "task arg1 arg2" -> ("task", vec!["arg1", "arg2"]) pub fn split_task_spec(spec: &str) -> (&str, Vec) { @@ -125,41 +143,32 @@ async fn err_no_task(config: &Config, name: &str) -> Result<()> { // Check if there are non-executable files in task include directories if !cfg!(windows) - && let Some(cwd) = &*dirs::CWD { - let includes = config::task_includes_for_dir(cwd, &config.config_files); - let non_exec_files: Vec = includes + && let Some(cwd) = &*dirs::CWD + { + let includes = config::task_includes_for_dir(cwd, &config.config_files); + let non_exec_files = find_non_executable_task_files(&includes); + if !non_exec_files.is_empty() { + let dirs_with_files: Vec = includes .iter() .filter(|d| d.is_dir()) - .flat_map(|d| { - walkdir::WalkDir::new(d) - .into_iter() - .filter_map(|e| e.ok()) - .filter(|e| e.file_type().is_file() && !file::is_executable(e.path())) - .map(|e| e.path().to_path_buf()) - }) + .map(display_path) .collect(); - if !non_exec_files.is_empty() { - let dirs_with_files: Vec = includes - .iter() - .filter(|d| d.is_dir()) - .map(display_path) - .collect(); - bail!( - "no tasks defined in {}, but found {} non-executable file(s) in {}.\n\ + bail!( + "no tasks defined in {}, but found {} non-executable file(s) in {}.\n\ Files must be executable to be detected as tasks.\n\ Run `chmod +x` on the task files to fix this, e.g.:\n chmod +x {}", - display_path(dirs::CWD.clone().unwrap_or_default()), - non_exec_files.len(), - dirs_with_files.join(", "), - non_exec_files - .iter() - .take(5) - .map(display_path) - .collect::>() - .join(" "), - ); - } + display_path(dirs::CWD.clone().unwrap_or_default()), + non_exec_files.len(), + dirs_with_files.join(", "), + non_exec_files + .iter() + .take(5) + .map(display_path) + .collect::>() + .join(" "), + ); } + } bail!( "no tasks defined in {}. Are you in a project directory?", From a363a4052e02cf72a6110d4a45a2794372fbf051 Mon Sep 17 00:00:00 2001 From: jdx <216188+jdx@users.noreply.github.com> Date: Sun, 22 Mar 2026 15:09:14 +0000 Subject: [PATCH 5/5] fix(task): allow hidden root dirs like .mise-tasks in non-exec scan The filter_entry was skipping all entries starting with '.', including the WalkDir root itself. This meant .mise-tasks/ contents were never scanned. Match load_tasks_includes behavior by exempting the root. Also add chmod +x assertion to mise tasks warning test. Co-Authored-By: Claude Opus 4.6 (1M context) --- e2e/tasks/test_task_non_executable_error | 1 + src/task/task_list.rs | 6 +++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/e2e/tasks/test_task_non_executable_error b/e2e/tasks/test_task_non_executable_error index 2ef53147484..2eab794716c 100644 --- a/e2e/tasks/test_task_non_executable_error +++ b/e2e/tasks/test_task_non_executable_error @@ -27,6 +27,7 @@ assert_fail_contains "mise run build" "chmod +x" # mise tasks ls should warn about non-executable files assert_contains "mise tasks 2>&1" "non-executable" +assert_contains "mise tasks 2>&1" "chmod +x" # After making files executable, tasks should work chmod +x mise-tasks/build mise-tasks/test diff --git a/src/task/task_list.rs b/src/task/task_list.rs index 450120b89ea..0f86d7eaf99 100644 --- a/src/task/task_list.rs +++ b/src/task/task_list.rs @@ -24,9 +24,13 @@ pub fn find_non_executable_task_files(includes: &[PathBuf]) -> Vec { .iter() .filter(|d| d.is_dir()) .flat_map(|d| { + let root = d.clone(); walkdir::WalkDir::new(d) .into_iter() - .filter_entry(|e| !e.file_name().to_string_lossy().starts_with('.')) + // skip hidden directories, but allow the root itself to be hidden (e.g. .mise-tasks) + .filter_entry(move |e| { + e.path() == root || !e.file_name().to_string_lossy().starts_with('.') + }) .filter_map(|e| e.ok()) .filter(|e| e.file_type().is_file() && !file::is_executable(e.path())) .map(|e| e.path().to_path_buf())