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
2 changes: 1 addition & 1 deletion TASK_CACHE_PARITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ outside this tracker.
external dependencies
- [x] Allow affected selection to feed existing task patterns, dependency expansion, scheduling, and
caching
- [ ] Show why each project and task was considered affected
- [x] Show why each project and task was considered affected
- [ ] Add machine-readable affected-project and affected-task output

## Local cache parity
Expand Down
4 changes: 4 additions & 0 deletions docs/cli/run.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ Run matching tasks only for projects affected by Git changes
Git base revision for --affected
Defaults to MISE_AFFECTED_BASE, CI metadata, or HEAD~1

### `--affected-explain`

Explain why projects and tasks were selected by --affected

### `--affected-head <REV>`

Git head revision for --affected
Expand Down
4 changes: 4 additions & 0 deletions docs/cli/tasks/run.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ Run matching tasks only for projects affected by Git changes
Git base revision for --affected
Defaults to MISE_AFFECTED_BASE, CI metadata, or HEAD~1

### `--affected-explain`

Explain why projects and tasks were selected by --affected

### `--affected-head <REV>`

Git head revision for --affected
Expand Down
29 changes: 29 additions & 0 deletions docs/tasks/monorepo.md
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,35 @@ The JSON output includes the same information in each project's `provenance`,
other tooling can distinguish, for example, a `turbo.json` output declaration from a root mise task
default.

### Affected Tasks

Use `mise run --affected <task-pattern>` to run a task only in projects changed between two Git
revisions. mise selects projects that own changed paths, then follows reverse project dependencies
so downstream projects are included. Workspace-global paths and `task_config.global_inputs` select
the whole workspace. Providers may narrow lockfile changes to the projects whose external
dependencies changed.

```bash
# Compare HEAD to its first parent and run affected build tasks
mise run --affected build

# Inspect the selection while preserving normal dry-run behavior
mise run --affected --affected-explain --dry-run build

# Compare explicit revisions
mise run --affected --affected-base origin/main --affected-head HEAD test
```

`--affected-explain` lists each selected project and its cause: an owned changed path, a
workspace-global path, a provider-attributed lockfile, or a dependency on another affected project.
It also lists the task-pattern matches associated with those projects. Normal task dependencies are
expanded afterward, so a selected task can still run a required prerequisite from an unchanged
project.

The revision defaults are `HEAD~1` and `HEAD` locally. `MISE_AFFECTED_BASE` and
`MISE_AFFECTED_HEAD` override them. GitHub Actions and GitLab merge-request metadata provide CI
defaults when those variables are not set; explicit CLI options take highest precedence.

### Cargo Workspace Discovery

The Cargo provider discovers packages when the root `Cargo.toml` contains a `[workspace]` table.
Expand Down
11 changes: 9 additions & 2 deletions e2e/tasks/test_task_affected
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,12 @@ git commit -qm initial
echo changed >>crates/core/src/lib.rs
git add .
git commit -qm core-change
mise run --affected --jobs 1 build
output=$(mise run --affected --affected-explain --jobs 1 build)
assert_contains "echo \"$output\"" "Affected projects (HEAD~1...HEAD):"
assert_contains "echo \"$output\"" "changed path: crates/core/src/lib.rs"
assert_contains "echo \"$output\"" "depends on affected project: cargo:core"
assert_contains "echo \"$output\"" "Affected tasks:"
assert_contains "echo \"$output\"" "affected project: cargo:app"
assert "sort affected.log" $'app\ncore\nother-prepare'

: >affected.log
Expand Down Expand Up @@ -145,5 +150,7 @@ sed -i 's/1\.0\.0/2.0.0/g' pnpm-lock.yaml
git add pnpm-lock.yaml
git commit -qm lockfile-change

mise run --affected --jobs 1 build
output=$(mise run --affected --affected-explain --jobs 1 build)
assert_contains "echo \"$output\"" "lockfile change: pnpm-lock.yaml"
assert_contains "echo \"$output\"" "depends on affected project: node:lib"
assert "sort affected.log" $'app\nlib'
6 changes: 6 additions & 0 deletions man/man1/mise.1
Original file line number Diff line number Diff line change
Expand Up @@ -3032,6 +3032,9 @@ Run matching tasks only for projects affected by Git changes
Git base revision for \-\-affected
Defaults to MISE_AFFECTED_BASE, CI metadata, or HEAD~1
.TP
\fB\-\-affected\-explain\fR
Explain why projects and tasks were selected by \-\-affected
.TP
\fB\-\-affected\-head\fR \fI<REV>\fR
Git head revision for \-\-affected
Defaults to MISE_AFFECTED_HEAD, CI metadata, or HEAD
Expand Down Expand Up @@ -3838,6 +3841,9 @@ Run matching tasks only for projects affected by Git changes
Git base revision for \-\-affected
Defaults to MISE_AFFECTED_BASE, CI metadata, or HEAD~1
.TP
\fB\-\-affected\-explain\fR
Explain why projects and tasks were selected by \-\-affected
.TP
\fB\-\-affected\-head\fR \fI<REV>\fR
Git head revision for \-\-affected
Defaults to MISE_AFFECTED_HEAD, CI metadata, or HEAD
Expand Down
2 changes: 2 additions & 0 deletions mise.usage.kdl
Original file line number Diff line number Diff line change
Expand Up @@ -3052,6 +3052,7 @@ Defaults to MISE_AFFECTED_BASE, CI metadata, or HEAD~1
"""# {
arg <REV>
}
flag --affected-explain help="Explain why projects and tasks were selected by --affected"
flag --affected-head help=#"""
Git head revision for --affected
Defaults to MISE_AFFECTED_HEAD, CI metadata, or HEAD
Expand Down Expand Up @@ -3900,6 +3901,7 @@ Defaults to MISE_AFFECTED_BASE, CI metadata, or HEAD~1
"""# {
arg <REV>
}
flag --affected-explain help="Explain why projects and tasks were selected by --affected"
flag --affected-head help=#"""
Git head revision for --affected
Defaults to MISE_AFFECTED_HEAD, CI metadata, or HEAD
Expand Down
1 change: 1 addition & 0 deletions src/cli/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -902,6 +902,7 @@ impl Bootstrap {
affected: false,
affected_base: None,
affected_head: None,
affected_explain: false,
cd: None,
continue_on_error: false,
dry_run: self.dry_run,
Expand Down
1 change: 1 addition & 0 deletions src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -778,6 +778,7 @@ impl Cli {
affected: false,
affected_base: None,
affected_head: None,
affected_explain: false,
cd: self.cd,
continue_on_error: self.continue_on_error,
dry_run: self.dry_run,
Expand Down
110 changes: 103 additions & 7 deletions src/cli/run.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use crate::errors::Error;
use std::collections::{BTreeSet, HashSet};
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::iter::once;
use std::path::PathBuf;
use std::sync::Arc;
Expand Down Expand Up @@ -83,6 +83,10 @@ pub struct Run {
#[clap(long, requires = "affected", value_name = "REV", verbatim_doc_comment)]
pub affected_base: Option<String>,

/// Explain why projects and tasks were selected by --affected
#[clap(long, requires = "affected", verbatim_doc_comment)]
pub affected_explain: bool,

/// Git head revision for --affected
/// Defaults to MISE_AFFECTED_HEAD, CI metadata, or HEAD
#[clap(long, requires = "affected", value_name = "REV", verbatim_doc_comment)]
Expand Down Expand Up @@ -288,6 +292,7 @@ async fn get_affected_task_list(
only: bool,
base: Option<&str>,
head: Option<&str>,
explain: bool,
) -> Result<Vec<Task>> {
Settings::get().ensure_experimental("affected tasks")?;
let workspace_root = config
Expand All @@ -304,7 +309,7 @@ async fn get_affected_task_list(
let uv = crate::task::workspace::uv::UvWorkspaceProvider;
let providers: [&dyn crate::task::workspace::WorkspaceProvider; 4] = [&cargo, &go, &node, &uv];
let mut regular_paths = BTreeSet::new();
let mut lockfile_projects = BTreeSet::new();
let mut lockfile_projects = BTreeMap::<PathBuf, BTreeSet<_>>::new();
let mut comparison_base: Option<String> = None;

for path in changed_paths {
Expand All @@ -331,15 +336,19 @@ async fn get_affected_task_list(
before.as_deref(),
after.as_deref(),
)? {
lockfile_projects.extend(projects);
lockfile_projects.entry(path).or_default().extend(projects);
}
}

let mut affected =
graph.affected_projects_for_paths(&workspace_root, regular_paths, &global_inputs)?;
affected.extend(graph.affected_projects(&lockfile_projects)?);
let affected = graph.affected_projects_for_changes(
&workspace_root,
regular_paths,
&global_inputs,
&lockfile_projects,
)?;
let affected_roots = affected
.iter()
.projects()
.map(|(id, _)| id)
.filter_map(|id| graph.get(id))
.map(|project| crate::file::desymlink_path(&workspace_root.join(&project.root)))
.collect::<BTreeSet<_>>();
Expand All @@ -356,9 +365,83 @@ async fn get_affected_task_list(
.map(crate::file::desymlink_path)
.is_some_and(|root| affected_roots.contains(&root))
});
if explain {
display_affected_explanation(&revisions, &workspace_root, &graph, &affected, &tasks)?;
}
Ok(tasks)
}

fn display_affected_explanation(
revisions: &crate::task::workspace::git::WorkspaceGitRevisions,
workspace_root: &std::path::Path,
graph: &crate::task::workspace::WorkspaceProjectGraph,
affected: &crate::task::workspace::AffectedProjects,
tasks: &[Task],
) -> Result<()> {
use crate::task::workspace::AffectedProjectReason;

miseprintln!(
"Affected projects ({}...{}):{}",
revisions.base,
revisions.head,
if affected.is_empty() { " none" } else { "" }
);
let mut projects_by_root = BTreeMap::<PathBuf, Vec<_>>::new();
for (id, reasons) in affected.projects() {
let project = graph.get(id).expect("affected project exists in graph");
miseprintln!(" {} ({})", id, display_affected_path(&project.root));
for reason in reasons {
match reason {
AffectedProjectReason::ChangedPath { path } => {
miseprintln!(" changed path: {}", display_affected_path(path));
}
AffectedProjectReason::GlobalPath { path } => {
miseprintln!(" workspace-global path: {}", display_affected_path(path));
}
AffectedProjectReason::Lockfile { path } => {
miseprintln!(" lockfile change: {}", display_affected_path(path));
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
AffectedProjectReason::Dependent { dependency } => {
miseprintln!(" depends on affected project: {dependency}");
}
}
}
projects_by_root
.entry(crate::file::desymlink_path(
&workspace_root.join(&project.root),
))
.or_default()
.push(id);
}

miseprintln!(
"Affected tasks:{}",
if tasks.is_empty() { " none" } else { "" }
);
for task in tasks {
miseprintln!(" {}", display_affected_text(&task.display_name));
if let Some(ids) = task
.config_root
.as_deref()
.map(crate::file::desymlink_path)
.and_then(|root| projects_by_root.get(&root))
{
for id in ids {
miseprintln!(" affected project: {id}");
}
}
}
Ok(())
}

fn display_affected_path(path: &std::path::Path) -> String {
display_affected_text(&path.to_string_lossy())
}

fn display_affected_text(text: &str) -> String {
text.escape_debug().to_string()
}

impl Run {
pub async fn run(mut self) -> Result<()> {
// Check help flags before doing any work
Expand Down Expand Up @@ -448,6 +531,7 @@ impl Run {
self.skip_deps,
self.affected_base.as_deref(),
self.affected_head.as_deref(),
self.affected_explain,
)
.await?
} else {
Expand Down Expand Up @@ -1298,4 +1382,16 @@ mod tests {
]
);
}

#[test]
fn affected_paths_escape_terminal_control_characters() {
assert_eq!(
display_affected_path(std::path::Path::new("src/\x1b[2J\nfile.rs")),
r"src/\u{1b}[2J\nfile.rs"
);
assert_eq!(
display_affected_text("//app:\x1b]8;;https://example.com\x1b\\build"),
r"//app:\u{1b}]8;;https://example.com\u{1b}\\build"
);
}
}
Loading