diff --git a/e2e/tasks/test_task_non_executable_error b/e2e/tasks/test_task_non_executable_error new file mode 100644 index 00000000000..2eab794716c --- /dev/null +++ b/e2e/tasks/test_task_non_executable_error @@ -0,0 +1,36 @@ +#!/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" +assert_contains "mise tasks 2>&1" "chmod +x" + +# 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..da12e721867 100644 --- a/src/cli/tasks/ls.rs +++ b/src/cli/tasks/ls.rs @@ -1,9 +1,11 @@ use std::sync::Arc; -use crate::config::Config; +use crate::config::{self, Config}; +use crate::dirs; 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; @@ -105,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) @@ -122,6 +124,20 @@ impl TasksLs { // MISE_TASK_REMOTE_NO_CACHE env var is still respected if set TaskFetcher::new(false).fetch_tasks(&mut tasks).await?; + // 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); } else if self.usage { diff --git a/src/task/task_list.rs b/src/task/task_list.rs index 3d07b4cc98c..0f86d7eaf99 100644 --- a/src/task/task_list.rs +++ b/src/task/task_list.rs @@ -16,6 +16,28 @@ 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| { + let root = d.clone(); + walkdir::WalkDir::new(d) + .into_iter() + // 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()) + }) + .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) { @@ -123,6 +145,35 @@ 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 = find_non_executable_task_files(&includes); + 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\ + 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(" "), + ); + } + } + bail!( "no tasks defined in {}. Are you in a project directory?", display_path(dirs::CWD.clone().unwrap_or_default())