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
36 changes: 36 additions & 0 deletions docs/tasks/task-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: <https://mise.jdx.dev/schema/mise-task.json>

#### Remote Git Includes <Badge type="warning" text="experimental" />

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::<protocol>://<url>//<path>?<ref>`

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 <Badge type="warning" text="experimental" />

mise supports monorepo-style task organization with target path syntax. Enable it by setting `experimental_monorepo_root = true` in your root `mise.toml`.
Expand Down
134 changes: 134 additions & 0 deletions e2e/tasks/test_task_remote_git_includes
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
#!/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 <<EOF >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 <<EOF >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 <<EOF >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 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 <<EOF >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!"
4 changes: 2 additions & 2 deletions schema/mise.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/config/config_file/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -566,7 +566,7 @@ impl Hash for dyn ConfigFile {

#[derive(Clone, Debug, Default, Deserialize)]
pub struct TaskConfig {
pub includes: Option<Vec<PathBuf>>,
pub includes: Option<Vec<String>>,
pub dir: Option<String>,
}

Expand Down
86 changes: 66 additions & 20 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -1438,13 +1439,13 @@ impl Debug for Config {
}
}

fn default_task_includes() -> Vec<PathBuf> {
fn default_task_includes() -> Vec<String> {
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(),
]
}

Expand Down Expand Up @@ -1817,6 +1818,18 @@ async fn load_tasks_includes(
}
}

async fn resolve_git_url_to_path(git_url: &str) -> Result<PathBuf> {
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<Config>,
cf: Arc<dyn ConfigFile>,
Expand All @@ -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::<Vec<_>>();
.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)
}
Expand All @@ -1847,10 +1862,24 @@ pub fn task_includes_for_dir(dir: &Path, config_files: &ConfigMap) -> Vec<PathBu
.find_map(|cf| cf.task_config().includes.clone())
.unwrap_or_else(default_task_includes)
.into_iter()
.map(|p| if p.is_absolute() { p } else { dir.join(p) })
.filter(|p| p.exists())
.collect::<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::<Vec<_>>()
}
Expand All @@ -1861,15 +1890,32 @@ pub async fn load_tasks_in_dir(
config_files: &ConfigMap,
) -> Result<Vec<Task>> {
let configs = configs_at_root(dir, config_files);

let git_includes: Vec<String> = 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)
Expand Down
2 changes: 1 addition & 1 deletion src/task/task_file_providers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf>;
}
Expand Down
Loading