From 73d0286fe08a747bac173d60192ecad1f9ae7e3d Mon Sep 17 00:00:00 2001 From: Vivien MALEZE Date: Tue, 6 Jan 2026 09:27:44 +0100 Subject: [PATCH 1/3] feat: allow to include tasks from git repositories --- docs/tasks/task-configuration.md | 36 +++++++ e2e/tasks/test_task_remote_git_includes | 138 ++++++++++++++++++++++++ schema/mise.json | 4 +- src/config/config_file/mod.rs | 2 +- src/config/mod.rs | 82 ++++++++++---- src/task/task_file_providers/mod.rs | 2 +- 6 files changed, 240 insertions(+), 24 deletions(-) create mode 100755 e2e/tasks/test_task_remote_git_includes diff --git a/docs/tasks/task-configuration.md b/docs/tasks/task-configuration.md index e77890665c..a970fb7745 100644 --- a/docs/tasks/task-configuration.md +++ b/docs/tasks/task-configuration.md @@ -479,6 +479,42 @@ run = "echo task4" If you want auto-completion/validation in included toml tasks files, you can use the following JSON schema: +#### Remote Git Includes + +You can include directories of tasks from git repositories using the `git::` URL syntax: + +::: code-group + +```mise-toml [ssh] +[task_config] +includes = [ + "git::ssh://git@github.com/myorg/shared-tasks.git//tasks?ref=v1.0.0" +] +``` + +```mise-toml [https] +[task_config] +includes = [ + "git::https://github.com/myorg/shared-tasks.git//tasks?ref=main" +] +``` + +::: + +URL format: `git:::////?` + +Required fields: + +- `protocol`: The git protocol (ssh or https). +- `url`: The git repository URL. +- `path`: The path to the directory in the repository. + +Optional fields: + +- `ref`: The git reference (branch, tag, commit). Defaults to the repository's default branch. + +The repository will be cloned and cached in `MISE_CACHE_DIR/remote-git-tasks-cache`. Tasks from the included directory will be loaded as if they were local file tasks. You can disable caching with `MISE_TASK_REMOTE_NO_CACHE=true` or the `--no-cache` flag. + ## Monorepo Support mise supports monorepo-style task organization with target path syntax. Enable it by setting `experimental_monorepo_root = true` in your root `mise.toml`. diff --git a/e2e/tasks/test_task_remote_git_includes b/e2e/tasks/test_task_remote_git_includes new file mode 100755 index 0000000000..dfe921556e --- /dev/null +++ b/e2e/tasks/test_task_remote_git_includes @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +################################################################################# +# Setup +################################################################################# + +# Clean up any previous server files +rm -f /tmp/mise_git_http_port /tmp/mise_git_http_ready /tmp/mise_git_http_info + +# Start local git HTTP server with OS-assigned port to avoid race conditions +python3 "${TEST_ROOT}/helpers/scripts/git_http_backend_server.py" 0 & +SERVER_PID=$! + +# Wait for server to be ready (with timeout) +wait_for_server() { + local max_attempts=30 # 30 seconds timeout + local attempt=1 + + while [ $attempt -le $max_attempts ]; do + if [ -f /tmp/mise_git_http_ready ] && [ -f /tmp/mise_git_http_port ]; then + return 0 + fi + sleep 1 + attempt=$((attempt + 1)) + done + + echo "ERROR: Git HTTP server failed to start within 30 seconds" + kill "$SERVER_PID" 2>/dev/null || true + exit 1 +} + +wait_for_server + +# Read the actual port from the file +SERVER_PORT=$(cat /tmp/mise_git_http_port) +LOCAL_GIT_URL="http://localhost:${SERVER_PORT}/repo.git" + +# Update cache directory paths for local server +REMOTE_TASKS_DIR="${MISE_CACHE_DIR}/remote-git-tasks-cache" + +cargo init --name hello_cargo + +# Ensure cleanup on exit +cleanup() { + kill "$SERVER_PID" 2>/dev/null || true + rm -f /tmp/mise_git_http_port /tmp/mise_git_http_ready /tmp/mise_git_http_info +} +trap cleanup EXIT + +################################################################################# +# Test remote git directory includes with no ref +################################################################################# + +cat <mise.toml +[task_config] +includes = [ + "git::${LOCAL_GIT_URL}//xtasks/lint" +] +EOF + +# The xtasks/lint directory contains multiple task files +# We should be able to see and run them +assert_contains "mise tasks" "ripgrep" +assert_succeed "mise run ripgrep" # Remote task from included directory should be downloaded +assert_directory_exists "${REMOTE_TASKS_DIR}" +assert_directory_not_empty "${REMOTE_TASKS_DIR}" + +mise cache clear # Clear cache to force redownload + +################################################################################# +# Test remote git directory includes with ref +################################################################################# + +cat <mise.toml +[task_config] +includes = [ + "git::${LOCAL_GIT_URL}//xtasks/lint?ref=v2025.1.17" +] +EOF + +assert_contains "mise tasks" "ripgrep" +assert_succeed "mise run ripgrep" # Remote task should be downloaded +assert_directory_exists "${REMOTE_TASKS_DIR}" +assert_directory_not_empty "${REMOTE_TASKS_DIR}" + +mise cache clear # Clear cache to force redownload + +################################################################################# +# Test remote git directory includes with no cache +################################################################################# + +cat <mise.toml +[task_config] +includes = [ + "git::${LOCAL_GIT_URL}//xtasks/lint" +] +EOF + +assert_succeed "MISE_TASK_REMOTE_NO_CACHE=true mise run ripgrep" # Remote task should be redownloaded +assert_directory_not_exists "${REMOTE_TASKS_DIR}" + +assert_succeed "mise run --no-cache ripgrep" # Remote task should be redownloaded +assert_directory_not_exists "${REMOTE_TASKS_DIR}" + +assert_succeed "mise run ripgrep" # Cache should be used +assert_directory_exists "${REMOTE_TASKS_DIR}" +assert_directory_not_empty "${REMOTE_TASKS_DIR}" + +mise cache clear # Clear cache to force redownload + +################################################################################# +# Test mixed local and remote includes +################################################################################# + +# Create a local tasks directory +mkdir -p local-tasks +cat <<'EOF' >local-tasks/local_task +#!/usr/bin/env bash +echo "Local task executed" +EOF +chmod +x local-tasks/local_task + +cat <mise.toml +[task_config] +includes = [ + "local-tasks", + "git::${LOCAL_GIT_URL}//xtasks/lint" +] +EOF + +# Both local and remote tasks should be available +assert_contains "mise tasks" "local_task" +assert_contains "mise tasks" "ripgrep" +assert_succeed "mise run local_task" +assert_succeed "mise run ripgrep" + +echo "All tests passed!" + diff --git a/schema/mise.json b/schema/mise.json index 1f25a8fcd4..1849091949 100644 --- a/schema/mise.json +++ b/schema/mise.json @@ -1764,9 +1764,9 @@ "type": "string" }, "includes": { - "description": "files/directories to include searching for tasks", + "description": "files/directories to include searching for tasks. Can be local paths or git repository URLs using git:: prefix (e.g., git::https://github.com/org/repo.git//path?ref=branch)", "items": { - "description": "file/directory root to include in task execution", + "description": "file/directory root to include in task execution. Supports local paths and git URLs (git::https://github.com/org/repo.git//path?ref=branch)", "type": "string" }, "type": "array" diff --git a/src/config/config_file/mod.rs b/src/config/config_file/mod.rs index 7d4291cb47..61087e4d05 100644 --- a/src/config/config_file/mod.rs +++ b/src/config/config_file/mod.rs @@ -566,7 +566,7 @@ impl Hash for dyn ConfigFile { #[derive(Clone, Debug, Default, Deserialize)] pub struct TaskConfig { - pub includes: Option>, + pub includes: Option>, pub dir: Option, } diff --git a/src/config/mod.rs b/src/config/mod.rs index 8afc19ee17..d0ae23a560 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -26,6 +26,7 @@ use crate::env::{MISE_DEFAULT_CONFIG_FILENAME, MISE_DEFAULT_TOOL_VERSIONS_FILENA use crate::file::display_path; use crate::shorthands::{Shorthands, get_shorthands}; use crate::task::Task; +use crate::task::task_file_providers::TaskFileProvidersBuilder; use crate::toolset::{ToolRequestSet, ToolRequestSetBuilder, ToolVersion, Toolset, install_state}; use crate::ui::style; use crate::{backend, dirs, env, file, lockfile, registry, runtime_symlinks, shims, timeout}; @@ -1438,13 +1439,13 @@ impl Debug for Config { } } -fn default_task_includes() -> Vec { +fn default_task_includes() -> Vec { vec![ - PathBuf::from("mise-tasks"), - PathBuf::from(".mise-tasks"), - PathBuf::from(".mise").join("tasks"), - PathBuf::from(".config").join("mise").join("tasks"), - PathBuf::from("mise").join("tasks"), + "mise-tasks".to_string(), + ".mise-tasks".to_string(), + ".mise/tasks".to_string(), + ".config/mise/tasks".to_string(), + "mise/tasks".to_string(), ] } @@ -1817,6 +1818,18 @@ async fn load_tasks_includes( } } +async fn resolve_git_url_to_path(git_url: &str) -> Result { + let no_cache = Settings::get().task_remote_no_cache.unwrap_or(false); + let task_file_providers = TaskFileProvidersBuilder::new() + .with_cache(!no_cache) + .build(); + + match task_file_providers.get_provider(git_url) { + Some(provider) => provider.get_local_path(git_url).await, + None => bail!("No provider found for git URL: {}", git_url), + } +} + async fn load_file_tasks( config: &Arc, cf: Arc, @@ -1826,16 +1839,18 @@ async fn load_file_tasks( .task_config() .includes .clone() - .unwrap_or_else(default_task_includes) - .into_iter() - .map(|p| cf.get_path().parent().unwrap().join(p)) - .collect::>(); + .unwrap_or_else(default_task_includes); + let mut tasks = vec![]; let config_root = Arc::new(config_root.to_path_buf()); - for p in includes { - let config_root = config_root.clone(); - let config = config.clone(); - tasks.extend(load_tasks_includes(&config, &p, &config_root).await?); + + for include in includes { + let path = if include.starts_with("git::") { + resolve_git_url_to_path(&include).await? + } else { + cf.get_path().parent().unwrap().join(&include) + }; + tasks.extend(load_tasks_includes(config, &path, &config_root).await?); } Ok(tasks) } @@ -1847,10 +1862,20 @@ pub fn task_includes_for_dir(dir: &Path, config_files: &ConfigMap) -> Vec>() - .into_iter() + .filter_map(|p| { + // Git URLs will be handled by load_file_tasks + if p.starts_with("git::") { + None + } else { + let path = PathBuf::from(p); + let resolved = if path.is_absolute() { path } else { dir.join(path) }; + if resolved.exists() { + Some(resolved) + } else { + None + } + } + }) .unique() .collect::>() } @@ -1861,15 +1886,32 @@ pub async fn load_tasks_in_dir( config_files: &ConfigMap, ) -> Result> { let configs = configs_at_root(dir, config_files); + + let git_includes: Vec = configs + .iter() + .rev() + .find_map(|cf| cf.task_config().includes.clone()) + .unwrap_or_default() + .into_iter() + .filter(|p| p.starts_with("git::")) + .collect(); + let mut config_tasks = vec![]; - for cf in configs { + for cf in &configs { let dir = dir.to_path_buf(); - config_tasks.extend(load_config_tasks(config, cf.clone(), &dir).await?); + config_tasks.extend(load_config_tasks(config, (*cf).clone(), &dir).await?); } + let mut file_tasks = vec![]; for p in task_includes_for_dir(dir, config_files) { file_tasks.extend(load_tasks_includes(config, &p, dir).await?); } + + for include in git_includes { + let resolved = resolve_git_url_to_path(&include).await?; + file_tasks.extend(load_tasks_includes(config, &resolved, dir).await?); + } + let mut tasks = file_tasks .into_iter() .chain(config_tasks) diff --git a/src/task/task_file_providers/mod.rs b/src/task/task_file_providers/mod.rs index e33c529cea..3ae8d560d0 100644 --- a/src/task/task_file_providers/mod.rs +++ b/src/task/task_file_providers/mod.rs @@ -10,7 +10,7 @@ use remote_task_git::RemoteTaskGitBuilder; use remote_task_http::RemoteTaskHttpBuilder; #[async_trait] -pub trait TaskFileProvider: Debug { +pub trait TaskFileProvider: Debug + Send + Sync { fn is_match(&self, file: &str) -> bool; async fn get_local_path(&self, file: &str) -> Result; } From bad8d210f5d1f710fce11d8636d56dc10f661007 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 6 Jan 2026 13:01:25 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- e2e/tasks/test_task_remote_git_includes | 1 - src/config/mod.rs | 22 +++++++++++++--------- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/e2e/tasks/test_task_remote_git_includes b/e2e/tasks/test_task_remote_git_includes index dfe921556e..537269d2c9 100755 --- a/e2e/tasks/test_task_remote_git_includes +++ b/e2e/tasks/test_task_remote_git_includes @@ -135,4 +135,3 @@ assert_succeed "mise run local_task" assert_succeed "mise run ripgrep" echo "All tests passed!" - diff --git a/src/config/mod.rs b/src/config/mod.rs index d0ae23a560..231020f50a 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1823,7 +1823,7 @@ async fn resolve_git_url_to_path(git_url: &str) -> Result { let task_file_providers = TaskFileProvidersBuilder::new() .with_cache(!no_cache) .build(); - + match task_file_providers.get_provider(git_url) { Some(provider) => provider.get_local_path(git_url).await, None => bail!("No provider found for git URL: {}", git_url), @@ -1840,10 +1840,10 @@ async fn load_file_tasks( .includes .clone() .unwrap_or_else(default_task_includes); - + let mut tasks = vec![]; let config_root = Arc::new(config_root.to_path_buf()); - + for include in includes { let path = if include.starts_with("git::") { resolve_git_url_to_path(&include).await? @@ -1868,7 +1868,11 @@ pub fn task_includes_for_dir(dir: &Path, config_files: &ConfigMap) -> Vec Result> { let configs = configs_at_root(dir, config_files); - + let git_includes: Vec = configs .iter() .rev() @@ -1895,23 +1899,23 @@ pub async fn load_tasks_in_dir( .into_iter() .filter(|p| p.starts_with("git::")) .collect(); - + let mut config_tasks = vec![]; for cf in &configs { let dir = dir.to_path_buf(); config_tasks.extend(load_config_tasks(config, (*cf).clone(), &dir).await?); } - + let mut file_tasks = vec![]; for p in task_includes_for_dir(dir, config_files) { file_tasks.extend(load_tasks_includes(config, &p, dir).await?); } - + for include in git_includes { let resolved = resolve_git_url_to_path(&include).await?; file_tasks.extend(load_tasks_includes(config, &resolved, dir).await?); } - + let mut tasks = file_tasks .into_iter() .chain(config_tasks) From 5e544d4837f014a544c43986345058525c80b656 Mon Sep 17 00:00:00 2001 From: Vivien MALEZE Date: Tue, 6 Jan 2026 14:30:12 +0100 Subject: [PATCH 3/3] fix e2e tests --- e2e/tasks/test_task_remote_git_includes | 3 --- 1 file changed, 3 deletions(-) diff --git a/e2e/tasks/test_task_remote_git_includes b/e2e/tasks/test_task_remote_git_includes index 537269d2c9..abbb9428d5 100755 --- a/e2e/tasks/test_task_remote_git_includes +++ b/e2e/tasks/test_task_remote_git_includes @@ -99,9 +99,6 @@ EOF assert_succeed "MISE_TASK_REMOTE_NO_CACHE=true mise run ripgrep" # Remote task should be redownloaded assert_directory_not_exists "${REMOTE_TASKS_DIR}" -assert_succeed "mise run --no-cache ripgrep" # Remote task should be redownloaded -assert_directory_not_exists "${REMOTE_TASKS_DIR}" - assert_succeed "mise run ripgrep" # Cache should be used assert_directory_exists "${REMOTE_TASKS_DIR}" assert_directory_not_empty "${REMOTE_TASKS_DIR}"