From 9b06d12555fd307652d673396e323537027ac882 Mon Sep 17 00:00:00 2001 From: calvin-archastro Date: Tue, 10 Nov 2026 17:40:33 -0700 Subject: [PATCH] feat(affected): replay a lane's plan from --plan-out with --plan-in; print phase timings The detect job's --matrix analysis can be written with --plan-out and reused by each lane with --plan-in when the key (SHAs, mode, flags, version, changed and dirty content, project and lane config) still matches. Otherwise affected analyses as before and says why. affected also prints one line of phase timings. Co-Authored-By: Claude Opus 5.5 --- README.md | 74 +++++ docs/ci/github-actions.md | 9 +- src/cli/commands.rs | 19 ++ src/cli/mod.rs | 2 + src/cli/phases.rs | 62 ++++ src/cli/plan_replay.rs | 679 ++++++++++++++++++++++++++++++++++++++ src/cli/skills.md | 4 + src/git/affected.rs | 5 + src/main.rs | 332 ++++++++++++++----- tests/plan_replay.rs | 516 +++++++++++++++++++++++++++++ tests/related.rs | 131 ++++++++ 11 files changed, 1747 insertions(+), 86 deletions(-) create mode 100644 src/cli/phases.rs create mode 100644 src/cli/plan_replay.rs create mode 100644 tests/plan_replay.rs diff --git a/README.md b/README.md index 6f06cafd..2fbe7ac1 100644 --- a/README.md +++ b/README.md @@ -1546,6 +1546,80 @@ aster affected test --base=main --related --lane "$LANE" [GitHub Actions](docs/ci/github-actions.md#fanning-lanes-out-as-a-job-matrix) has a worked workflow. +### Replaying a plan instead of repeating the analysis + +Under `--related` every job of a matrix repeats the source-level analysis the +plan job already did, which costs seconds to half a minute of a job's runner. +`--plan-out FILE` stores the plan, and `--plan-in FILE` runs one lane from it: + +```console +aster affected test --base=main --related --matrix --plan-out plan.json +aster affected test --base=main --related --lane "$LANE" --plan-in plan.json +``` + +`--plan-out` works with `--matrix` and `--dry-run`. The file holds every lane +planned: the `--dry-run --json` plan, with the narrowed command of each +target, and what execution needs (selected projects, command overrides). It +also holds the key it is valid for: the commits `--base` and `--head` (default +`HEAD`) resolve to, the mode (`related`, `dependents`, `changed` or `all`), the +requested target, `--dependents`, `--only-affected-files`, +`--warnings-as-errors`, `--lang`, a digest of the changed files after +`affected.ignore` (uncommitted and untracked files included, and without +`--head` their content, so an edit after the plan was made is a different +plan), a digest of the +discovered projects and their targets, and the Aster version. + +`--plan-in` replays a lane only when all of this holds, and otherwise logs one +line on stderr that says which check failed and runs the analysis as if the +flag were absent: + +```text +[aster] --plan-in plan.json: base differs (plan has "4f2c...", this run has "9a1e..."); analysing instead +``` + +- the file exists, parses and has the supported schema; +- its key equals this invocation's key (any field above); +- exactly one `--lane` is given, the plan has it, and the lane runs the same + target as now; +- every project the lane names is still discovered; +- under `--dry-run`, `--json` is set (the text plan is built from the + analysis). + +A replay prints the same `--json` plan a fresh analysis does, and runs the +same targets with the same commands. It does not carry anything the key does +not name: a plan is for one tree. Write it and replay it from checkouts of the +same commit with the same uncommitted state. A job that adds or edits files +before it runs `affected` changes the changed-file digest and falls back, +which is safe but pays for the analysis. That includes the plan file: keep it +outside the checkout (`$RUNNER_TEMP`) or under `.aster/`, which `affected` +never counts as a change. + +`--plan-in` and `--matrix` cannot be combined, nor can `--plan-in` and +`--plan-out`. + +### Where `affected` spends its time + +A run of `affected` that is not `--quiet` ends its planning with one stderr +line naming the phases, so a long gap before the first target can be +attributed: + +```text +[aster] affected phases: discover 1.1s, changes 0.6s, plan-in 0.0s, graph 0.0s, related 22.3s, select 0.2s, narrow 0.1s, quarantine 0.3s, total 24.6s +``` + +- `discover`: finding projects and targets; +- `changes`: reading the changed files from Git and applying `affected.ignore`; +- `plan-in`: reading and checking a stored plan (zero without `--plan-in`); +- `graph`: building and checking the project graph; +- `related`: the `--related` source analysis (zero without it, and under a + replay); +- `select`: lane selection and declared inputs, added up over the lanes; +- `narrow`: choosing narrowed commands, added up over the lanes; +- `quarantine`: loading the quarantine list, when results are recorded. + +A replay marks the line `(replayed plan)`. `--verbose` adds the breakdown of +`related`. + ### Running everything `--all` treats every project as changed: each lane selects every project it diff --git a/docs/ci/github-actions.md b/docs/ci/github-actions.md index 9fd3e7d8..55c7ba89 100644 --- a/docs/ci/github-actions.md +++ b/docs/ci/github-actions.md @@ -417,5 +417,12 @@ jobs: ``` The plan and test jobs pass the same flags, so a row exists exactly when its -lane has something to run. A lane-specific setup step reads a `matrix` field +lane has something to run. + +To skip the `--related` analysis in the test jobs, have the plan job write the +plan (`--matrix --plan-out plan.json`), upload it as an artifact, and pass the +downloaded file to each test job with `--plan-in plan.json`. A job whose +checkout, flags or Aster version differ from the plan job's logs why and +analyses as before. See +[Replaying a plan](../../README.md#replaying-a-plan-instead-of-repeating-the-analysis). A lane-specific setup step reads a `matrix` field (`if: matrix.browser`) rather than comparing lane names. diff --git a/src/cli/commands.rs b/src/cli/commands.rs index 2d86d72e..a4145e68 100644 --- a/src/cli/commands.rs +++ b/src/cli/commands.rs @@ -536,6 +536,25 @@ pub enum Commands { /// running `aster affected --lane `. Implies --dry-run. #[arg(long)] matrix: bool, + + /// Write the plan of every lane planned to FILE (needs --dry-run or --matrix) + /// + /// The file holds each lane's dry-run plan, with the commands that + /// narrow its targets, and the key it is valid for: base and head + /// commits, mode, requested target, flags, changed files and Aster + /// version. Replay a lane from it with --plan-in. + #[arg(long, value_name = "FILE", conflicts_with = "plan_in")] + plan_out: Option, + + /// Run one lane from a plan written by --plan-out, skipping the analysis + /// + /// Runs exactly what the analysis would select, using the stored + /// targets and narrowed commands. When the file is missing, cannot be + /// read, lacks the lane, or its key differs from this invocation, one + /// line on stderr says why and the analysis runs as usual. With + /// --dry-run, replays only under --json. + #[arg(long, value_name = "FILE", conflicts_with = "matrix")] + plan_in: Option, }, /// View logs from the last run diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 0173ba39..e278c52f 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,6 +6,8 @@ pub mod affected_files; pub mod cache_plan; pub mod commands; pub mod output; +pub mod phases; +pub mod plan_replay; pub mod run; mod skills; pub mod source_graph; diff --git a/src/cli/phases.rs b/src/cli/phases.rs new file mode 100644 index 00000000..586888d5 --- /dev/null +++ b/src/cli/phases.rs @@ -0,0 +1,62 @@ +//! Where an `aster affected` run spends its time before the first target +//! starts, as one stderr line. + +use std::time::{Duration, Instant}; + +/// Named durations in the order the phases ran. A name marked twice adds up. +pub struct Phases { + last: Instant, + items: Vec<(&'static str, Duration)>, +} + +impl Phases { + /// Start timing; `earlier` is time already spent (project discovery). + pub fn new(earlier: Duration) -> Phases { + Phases { + last: Instant::now(), + items: vec![("discover", earlier)], + } + } + + /// Charge the time since the previous mark to `name`. + pub fn mark(&mut self, name: &'static str) { + let spent = self.last.elapsed(); + self.last = Instant::now(); + if let Some(entry) = self.items.iter_mut().find(|(n, _)| *n == name) { + entry.1 += spent; + } else { + self.items.push((name, spent)); + } + } + + /// `affected phases: discover 0.4s, changes 0.1s, ... total 24.3s`. + pub fn line(&self, replayed: bool) -> String { + let total: Duration = self.items.iter().map(|(_, spent)| *spent).sum(); + let mut parts: Vec = self + .items + .iter() + .map(|(name, spent)| format!("{name} {:.1}s", spent.as_secs_f64())) + .collect(); + parts.push(format!("total {:.1}s", total.as_secs_f64())); + let how = if replayed { " (replayed plan)" } else { "" }; + format!("[aster] affected phases{how}: {}", parts.join(", ")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn phases_list_in_order_and_a_repeated_name_adds_up() { + let mut phases = Phases::new(Duration::from_millis(400)); + phases.mark("changes"); + phases.mark("narrow"); + phases.mark("narrow"); + let line = phases.line(false); + assert!(line.starts_with("[aster] affected phases: discover 0.4s, changes ")); + assert_eq!(line.matches("narrow").count(), 1, "{line}"); + assert!(line.contains(", total "), "{line}"); + assert!(phases.line(true).contains("(replayed plan)")); + } +} diff --git a/src/cli/plan_replay.rs b/src/cli/plan_replay.rs new file mode 100644 index 00000000..2c359d07 --- /dev/null +++ b/src/cli/plan_replay.rs @@ -0,0 +1,679 @@ +//! Stored `aster affected` plans: `--plan-out` writes the plan of every lane +//! a planning run produced, `--plan-in` executes one lane from it without +//! repeating the analysis. +//! +//! A CI pipeline plans once (`affected --matrix --plan-out plan.json`) and +//! each job replays its own lane (`affected --lane X --plan-in plan.json`). +//! A stored plan is only used when the [`PlanKey`] it was written under equals +//! the key of the replaying invocation. Any difference, and any problem +//! reading the file, makes the caller analyse as if `--plan-in` was absent: +//! a replay never runs a selection that analysis would not have chosen. + +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +use crate::cli::affected_files::AffectedCommands; +use crate::discovery::DiscoveredProject; +use crate::executor::CommandOverride; + +/// Version of the file layout. A file with another value is not replayed. +pub const SCHEMA: u32 = 1; + +/// Everything the selection depends on besides the lane. Two invocations +/// with equal keys, on the same commit, choose the same targets and commands. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PlanKey { + /// Aster that wrote the plan; its planning rules may differ in another. + pub aster_version: String, + /// Commit `--base` resolved to (`-` under `--all`). + pub base_sha: String, + /// Commit `--head` (default `HEAD`) resolved to (`-` under `--all`). + pub head_sha: String, + /// `--head` was given. Without it uncommitted changes are part of the + /// plan, and `changed_files_digest` is what pins them. + pub head_pinned: bool, + /// `all`, `related`, `dependents` or `changed`. + pub mode: String, + /// The requested target (a lane may run another one). + pub target: String, + pub dependents: bool, + pub only_affected_files: bool, + pub warnings_as_errors: bool, + pub lang: Vec, + /// sha256 of the sorted changed files after `affected.ignore`, with each + /// file's content on disk. + pub changed_files_digest: String, + /// sha256 of the uncommitted and untracked files with their content, so + /// a dirty tree never matches a plan, with or without `--head`. + pub dirty_digest: String, + /// sha256 of every project address with its targets (command and + /// dependencies), its config file's content and the root `aster.toml`, + /// which hold the lanes. + pub projects_digest: String, +} + +impl PlanKey { + /// The first field that differs from `current`, worded for a log line. + fn mismatch(&self, current: &PlanKey) -> Option { + fn differ( + name: &str, + stored: &T, + current: &T, + ) -> Option { + (stored != current) + .then(|| format!("{name} differs (plan has {stored:?}, this run has {current:?})")) + } + differ("aster version", &self.aster_version, ¤t.aster_version) + .or_else(|| differ("base", &self.base_sha, ¤t.base_sha)) + .or_else(|| differ("head", &self.head_sha, ¤t.head_sha)) + .or_else(|| differ("--head", &self.head_pinned, ¤t.head_pinned)) + .or_else(|| differ("mode", &self.mode, ¤t.mode)) + .or_else(|| differ("target", &self.target, ¤t.target)) + .or_else(|| differ("--dependents", &self.dependents, ¤t.dependents)) + .or_else(|| { + differ( + "--only-affected-files", + &self.only_affected_files, + ¤t.only_affected_files, + ) + }) + .or_else(|| { + differ( + "--warnings-as-errors", + &self.warnings_as_errors, + ¤t.warnings_as_errors, + ) + }) + .or_else(|| differ("--lang", &self.lang, ¤t.lang)) + .or_else(|| { + differ( + "changed files", + &self.changed_files_digest, + ¤t.changed_files_digest, + ) + }) + .or_else(|| differ("dirty files", &self.dirty_digest, ¤t.dirty_digest)) + .or_else(|| { + differ( + "discovered projects", + &self.projects_digest, + ¤t.projects_digest, + ) + }) + } +} + +/// What the invocation was asked, for [`PlanKey::new`]. +pub struct KeyInputs<'a> { + pub base_sha: String, + pub head_sha: String, + pub head_pinned: bool, + pub all: bool, + pub related: bool, + pub dependents: bool, + pub only_affected_files: bool, + pub warnings_as_errors: bool, + pub target: &'a str, + pub lang: &'a [String], + pub changed_files: &'a HashSet, + /// Uncommitted and untracked files. + pub dirty_files: &'a HashSet, + pub projects: &'a [DiscoveredProject], + /// Workspace root, where the changed files are read from. + pub root: &'a Path, +} + +impl PlanKey { + pub fn new(inputs: KeyInputs<'_>) -> PlanKey { + let mode = if inputs.all { + "all" + } else if inputs.related { + "related" + } else if inputs.dependents { + "dependents" + } else { + "changed" + }; + let with_content = |files: &HashSet| -> Vec { + let mut lines: Vec = files + .iter() + .map(|path| { + let name = path.to_string_lossy().into_owned(); + format!("{name}\0{}", file_digest(&inputs.root.join(path))) + }) + .collect(); + lines.sort(); + lines + }; + let mut changed = with_content(inputs.changed_files); + let dirty = with_content(inputs.dirty_files); + changed.sort(); + let mut projects: Vec = inputs + .projects + .iter() + .map(|project| { + let mut targets: Vec<&str> = project.targets.keys().map(String::as_str).collect(); + targets.sort(); + let mut detail = String::new(); + for name in &targets { + let target = &project.targets[*name]; + let mut depends_on = target.depends_on.clone(); + depends_on.sort(); + detail.push_str(&format!("{name}={:?}<{depends_on:?};", target.command)); + } + format!( + "//{}:{detail}:{}:{}", + project.relative_path.display(), + file_digest(&project.config_path), + file_digest(&inputs.root.join(&project.relative_path).join("aster.toml")), + ) + }) + .collect(); + projects.sort(); + projects.push(format!( + "root:{}", + file_digest(&inputs.root.join("aster.toml")) + )); + let mut lang = inputs.lang.to_vec(); + lang.sort(); + PlanKey { + aster_version: env!("CARGO_PKG_VERSION").to_string(), + base_sha: inputs.base_sha, + head_sha: inputs.head_sha, + head_pinned: inputs.head_pinned, + mode: mode.to_string(), + target: inputs.target.to_string(), + dependents: inputs.dependents, + only_affected_files: inputs.only_affected_files, + warnings_as_errors: inputs.warnings_as_errors, + lang, + changed_files_digest: digest_lines(&changed), + dirty_digest: digest_lines(&dirty), + projects_digest: digest_lines(&projects), + } + } +} + +/// sha256 of a file's content, or a marker when it cannot be read. +fn file_digest(path: &Path) -> String { + match std::fs::read(path) { + Ok(bytes) => crate::cache::Digest::of_bytes(&bytes).as_str().to_string(), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => "absent".to_string(), + Err(_) => "unreadable".to_string(), + } +} + +fn digest_lines(lines: &[String]) -> String { + let mut bytes = Vec::new(); + for line in lines { + bytes.extend_from_slice(line.as_bytes()); + bytes.push(0); + } + crate::cache::Digest::of_bytes(&bytes).as_str().to_string() +} + +/// A replacement command as stored. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StoredOverride { + Run(Vec), + Skip(String), +} + +/// One lane's plan: the dry-run JSON for display, and the selection the +/// executor needs to run it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct StoredLane { + /// The target the lane runs (its own, or the requested one). + pub target: String, + /// Projects selected before command planning, sorted. + pub affected: Vec, + /// Selected projects were non-empty but none defines the target. + pub missing_among_affected: bool, + /// Whether command planning ran (`--related`, `--only-affected-files`, + /// `--warnings-as-errors`). Without it the executor takes `affected`. + pub has_command_plan: bool, + /// Projects that run the requested target after command planning, sorted. + pub primary: Vec, + pub overrides: BTreeMap, + pub skipped: Vec, + pub notes: BTreeMap>, + /// The lane's `--dry-run --json` document. + pub plan: DryRunOutput, +} + +impl StoredLane { + pub fn new( + target: &str, + affected: &HashSet, + missing_among_affected: bool, + commands: Option<&AffectedCommands>, + plan: DryRunOutput, + ) -> StoredLane { + let sorted = |set: &HashSet| { + let mut list: Vec = set.iter().cloned().collect(); + list.sort(); + list + }; + StoredLane { + target: target.to_string(), + affected: sorted(affected), + missing_among_affected, + has_command_plan: commands.is_some(), + primary: commands.map(|c| sorted(&c.primary)).unwrap_or_default(), + overrides: commands + .map(|c| { + c.overrides + .iter() + .map(|(address, over)| { + let stored = match over { + CommandOverride::Run(commands) => { + StoredOverride::Run(commands.clone()) + } + CommandOverride::Skip(reason) => { + StoredOverride::Skip(reason.clone()) + } + }; + (address.clone(), stored) + }) + .collect() + }) + .unwrap_or_default(), + skipped: commands.map(|c| c.skipped.clone()).unwrap_or_default(), + notes: commands + .map(|c| { + c.notes + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }) + .unwrap_or_default(), + plan, + } + } + + pub fn affected_set(&self) -> HashSet { + self.affected.iter().cloned().collect() + } + + /// The command plan the lane was written from, when it had one. + pub fn commands(&self) -> Option { + self.has_command_plan.then(|| AffectedCommands { + overrides: self + .overrides + .iter() + .map(|(address, stored)| { + let over = match stored { + StoredOverride::Run(commands) => CommandOverride::Run(commands.clone()), + StoredOverride::Skip(reason) => CommandOverride::Skip(reason.clone()), + }; + (address.clone(), over) + }) + .collect::>(), + primary: self.primary.iter().cloned().collect(), + skipped: self.skipped.clone(), + notes: self + .notes + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + }) + } +} + +/// One target of a dry-run plan. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DryRunTarget { + pub address: String, + pub reason: String, + pub files: Vec, + /// Commands replacing the target's own command. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub commands: Vec, + /// How the target's command was chosen. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub selection: Vec, +} + +/// The `--dry-run --json` document of one lane. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct DryRunOutput { + pub target: String, + pub base: String, + pub head: Option, + pub targets: Vec, + pub count: usize, + /// Requested targets dropped by --only-affected-files. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub skipped: Vec, + /// Plugins of every project the plan runs a target in, plus the lane's + /// declared toolchains: what a runner must install. + pub toolchains: Vec, +} + +impl DryRunOutput { + /// A plan with nothing to run. + pub fn empty(target: &str, base: &str, head: Option<&str>) -> DryRunOutput { + DryRunOutput { + target: target.to_string(), + base: base.to_string(), + head: head.map(str::to_string), + targets: Vec::new(), + count: 0, + skipped: Vec::new(), + toolchains: Vec::new(), + } + } +} + +/// The file `--plan-out` writes. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PlanFile { + pub schema: u32, + pub key: PlanKey, + /// Lanes by name; the key of the unnamed lane is the empty string. + pub lanes: BTreeMap, +} + +/// Collects lanes while a planning run goes, then writes the file. +pub struct PlanRecorder { + path: PathBuf, + key: PlanKey, + lanes: BTreeMap, +} + +impl PlanRecorder { + pub fn new(path: &Path, key: PlanKey) -> PlanRecorder { + PlanRecorder { + path: path.to_path_buf(), + key, + lanes: BTreeMap::new(), + } + } + + pub fn record(&mut self, lane: &str, stored: StoredLane) { + self.lanes.insert(lane.to_string(), stored); + } + + /// Write the file through a temporary sibling so a reader never sees half + /// a plan. + pub fn write(&self) -> Result<()> { + let file = PlanFile { + schema: SCHEMA, + key: self.key.clone(), + lanes: self.lanes.clone(), + }; + let mut text = serde_json::to_string_pretty(&file)?; + text.push('\n'); + if let Some(parent) = self.path.parent().filter(|p| !p.as_os_str().is_empty()) { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + let mut temporary = self.path.clone().into_os_string(); + temporary.push(".tmp"); + let temporary = PathBuf::from(temporary); + std::fs::write(&temporary, text) + .with_context(|| format!("Failed to write {}", temporary.display()))?; + std::fs::rename(&temporary, &self.path) + .with_context(|| format!("Failed to write {}", self.path.display())) + } +} + +/// Read `path` and check it against this invocation. `Err` carries the one +/// line reason the plan cannot be used; the caller logs it and analyses. +/// +/// `lanes` are the requested lanes with the target each resolves to now. +pub fn load( + path: &Path, + current: &PlanKey, + lanes: &[(String, String)], + projects: &[DiscoveredProject], +) -> std::result::Result { + let text = std::fs::read_to_string(path).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + "file does not exist".to_string() + } else { + format!("cannot read it ({error})") + } + })?; + check(&text, current, lanes, projects) +} + +fn check( + text: &str, + current: &PlanKey, + lanes: &[(String, String)], + projects: &[DiscoveredProject], +) -> std::result::Result { + let file: PlanFile = + serde_json::from_str(text).map_err(|error| format!("cannot parse it ({error})"))?; + if file.schema != SCHEMA { + return Err(format!( + "schema {} is not the supported schema {SCHEMA}", + file.schema + )); + } + if let Some(reason) = file.key.mismatch(current) { + return Err(reason); + } + let known: HashSet = projects + .iter() + .map(|project| format!("//{}", project.relative_path.display())) + .collect(); + for (name, target) in lanes { + let Some(stored) = file.lanes.get(name) else { + let have: Vec<&str> = file.lanes.keys().map(String::as_str).collect(); + return Err(format!( + "lane '{name}' is not in the plan (it has: {})", + have.join(", ") + )); + }; + if &stored.target != target { + return Err(format!( + "lane '{name}' runs '{}' in the plan and '{target}' now", + stored.target + )); + } + if let Some(unknown) = stored + .affected + .iter() + .chain(&stored.primary) + .find(|address| !known.contains(*address)) + { + return Err(format!("lane '{name}' names {unknown}, which is not here")); + } + } + Ok(file) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key() -> PlanKey { + PlanKey { + aster_version: env!("CARGO_PKG_VERSION").to_string(), + base_sha: "a".repeat(40), + head_sha: "b".repeat(40), + head_pinned: false, + mode: "related".into(), + target: "test".into(), + dependents: false, + only_affected_files: false, + warnings_as_errors: false, + lang: vec![], + changed_files_digest: "c".into(), + dirty_digest: "e".into(), + projects_digest: "d".into(), + } + } + + fn commands() -> AffectedCommands { + AffectedCommands { + overrides: HashMap::from([ + ( + "//a:test".to_string(), + CommandOverride::Run(vec!["go test -run '^(TestX)$' ./...".into()]), + ), + ( + "//b:test".to_string(), + CommandOverride::Skip("no affected files".into()), + ), + ]), + primary: HashSet::from(["//a".to_string()]), + skipped: vec!["//b:test".into()], + notes: HashMap::from([("//a:test".to_string(), vec!["narrowed".to_string()])]), + } + } + + fn file_text(key: &PlanKey) -> String { + let affected = HashSet::from(["//a".to_string(), "//b".to_string()]); + let stored = StoredLane::new( + "test", + &affected, + false, + Some(&commands()), + DryRunOutput::empty("test", "main", None), + ); + serde_json::to_string(&PlanFile { + schema: SCHEMA, + key: key.clone(), + lanes: BTreeMap::from([("platform".to_string(), stored)]), + }) + .unwrap() + } + + fn lanes(name: &str) -> Vec<(String, String)> { + vec![(name.to_string(), "test".to_string())] + } + + #[test] + fn a_lane_gives_back_the_command_plan_it_was_written_from() { + let original = commands(); + let affected = HashSet::from(["//a".to_string()]); + let stored = StoredLane::new( + "test", + &affected, + false, + Some(&original), + DryRunOutput::empty("test", "main", None), + ); + let text = serde_json::to_string(&stored).unwrap(); + let back: StoredLane = serde_json::from_str(&text).unwrap(); + let restored = back.commands().unwrap(); + assert_eq!(restored.overrides, original.overrides); + assert_eq!(restored.primary, original.primary); + assert_eq!(restored.skipped, original.skipped); + assert_eq!(restored.notes, original.notes); + assert_eq!(back.affected_set(), affected); + } + + #[test] + fn a_lane_without_command_planning_stays_without() { + let stored = StoredLane::new( + "test", + &HashSet::new(), + false, + None, + DryRunOutput::empty("test", "main", None), + ); + assert!(stored.commands().is_none()); + } + + #[test] + fn a_plan_naming_projects_that_are_not_here_is_refused() { + let k = key(); + let file = check(&file_text(&k), &k, &lanes("platform"), &[]); + // The projects //a and //b are not discovered in this unit test. + assert!(file.unwrap_err().contains("not here")); + } + + #[test] + fn every_key_field_that_differs_is_named() { + let stored = key(); + let mut other = key(); + other.base_sha = "f".repeat(40); + assert!(stored.mismatch(&other).unwrap().starts_with("base differs")); + let mut other = key(); + other.head_sha = "f".repeat(40); + assert!(stored.mismatch(&other).unwrap().starts_with("head differs")); + let mut other = key(); + other.mode = "dependents".into(); + assert!(stored.mismatch(&other).unwrap().starts_with("mode differs")); + let mut other = key(); + other.aster_version = "0.0.0".into(); + assert!(stored + .mismatch(&other) + .unwrap() + .starts_with("aster version differs")); + let mut other = key(); + other.changed_files_digest = "x".into(); + assert!(stored + .mismatch(&other) + .unwrap() + .starts_with("changed files differs")); + assert!(stored.mismatch(&key()).is_none()); + } + + #[test] + fn unreadable_content_and_foreign_schemas_are_refused() { + let k = key(); + assert!(check("not json", &k, &lanes("platform"), &[]) + .unwrap_err() + .contains("cannot parse")); + let wrong = file_text(&k).replacen("\"schema\":1", "\"schema\":99", 1); + assert!(check(&wrong, &k, &lanes("platform"), &[]) + .unwrap_err() + .contains("schema 99")); + } + + #[test] + fn a_lane_missing_from_the_plan_is_refused_with_the_lanes_it_has() { + let k = key(); + let error = check(&file_text(&k), &k, &lanes("tooling"), &[]).unwrap_err(); + assert!( + error.contains("lane 'tooling' is not in the plan"), + "{error}" + ); + assert!(error.contains("platform"), "{error}"); + } + + #[test] + fn a_lane_that_runs_another_target_now_is_refused() { + let k = key(); + let requested = vec![("platform".to_string(), "test-ci".to_string())]; + let error = check(&file_text(&k), &k, &requested, &[]).unwrap_err(); + assert!(error.contains("runs 'test' in the plan"), "{error}"); + } + + #[test] + fn a_missing_file_is_reported_as_missing() { + let error = load(Path::new("/nonexistent/plan.json"), &key(), &[], &[]).unwrap_err(); + assert_eq!(error, "file does not exist"); + } + + #[test] + fn the_recorder_writes_a_file_the_loader_reads_back() { + let dir = tempfile::TempDir::new().unwrap(); + let path = dir.path().join("nested/plan.json"); + let mut recorder = PlanRecorder::new(&path, key()); + recorder.record( + "platform", + StoredLane::new( + "test", + &HashSet::new(), + false, + None, + DryRunOutput::empty("test", "main", None), + ), + ); + recorder.write().unwrap(); + let file = load(&path, &key(), &lanes("platform"), &[]).unwrap(); + assert_eq!(file.key, key()); + assert!(file.lanes.contains_key("platform")); + assert!(!dir.path().join("nested/plan.json.tmp").exists()); + } +} diff --git a/src/cli/skills.md b/src/cli/skills.md index c88d2f33..902b6d22 100644 --- a/src/cli/skills.md +++ b/src/cli/skills.md @@ -137,6 +137,10 @@ has work: `lane`, `name`, `target`, `toolchains`, plus the lane's `matrix` fields. Run each row with `aster affected test --base=main --related --lane `. `--all` treats every project as changed, for scheduled full runs. A target's `cache.workspace_include` files also select it when they change. +`--matrix --plan-out plan.json` stores every lane's plan and each job can run +`--lane --plan-in plan.json` to skip the analysis; a plan whose key +(commits, mode, flags, changed files, Aster version) differs, or a missing +lane or file, logs one line and analyses instead. `--only-affected-files` narrows targets that declare the `files_list` capability to each project's changed files: the requested target and any diff --git a/src/git/affected.rs b/src/git/affected.rs index 84135de0..57100165 100644 --- a/src/git/affected.rs +++ b/src/git/affected.rs @@ -66,6 +66,11 @@ impl AffectedDetector { .with_context(|| format!("Could not get commit for ref '{name}'")) } + /// The commit id `name` resolves to, as 40 hex characters. + pub fn resolve_commit(&self, name: &str, flag: &str) -> Result { + Ok(self.commit_of(name, flag)?.id().to_string()) + } + fn merge_base_id( &self, base_commit: &git2::Commit<'_>, diff --git a/src/main.rs b/src/main.rs index 1eb6d56f..2d2213a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -17,6 +17,10 @@ use aster::cli::affected_files::{ use aster::cli::commands::{ conventional_target_help_name, explicit_target_help_name, print_target_command_usage, }; +use aster::cli::phases::Phases; +use aster::cli::plan_replay::{ + self, DryRunOutput, DryRunTarget, KeyInputs, PlanFile, PlanKey, PlanRecorder, StoredLane, +}; use aster::cli::source_graph; use aster::cli::{ build_execution_output, build_execution_output_with_cache, check_reserved_target, @@ -542,8 +546,10 @@ fn run() -> Result<()> { let registry = PluginRegistry::with_all_plugins(); // Discover projects + let discovery_started = std::time::Instant::now(); let projects = discover_projects(&workspace_root, ®istry).context("Failed to discover projects")?; + let discovery_elapsed = discovery_started.elapsed(); if output_mode == OutputMode::Verbose { errln!("[aster] Discovered {} projects", projects.len()); @@ -929,10 +935,17 @@ fn run() -> Result<()> { lang, all, matrix: matrix_rows, + plan_out, + plan_in, } => { validate_lang_filter(&lang)?; // --matrix plans every lane and prints JSON only. let dry_run = dry_run || matrix_rows; + if plan_out.is_some() && !dry_run { + return Err(anyhow::anyhow!( + "--plan-out writes a plan without running it: add --dry-run or --matrix" + )); + } let output_mode = if matrix_rows { OutputMode::Json } else { @@ -944,6 +957,7 @@ fn run() -> Result<()> { // Create affected detector from workspace (requires git) let detector = AffectedDetector::new(&workspace_root)?; + let mut phases = Phases::new(discovery_elapsed); // Get changed files let changed_files = if all { @@ -970,6 +984,89 @@ fn run() -> Result<()> { .filter(|path| ignore.is_configured_ignore(path)) .count(); let changed_files = ignore.filter(changed_files); + phases.mark("changes"); + + // What a stored plan must have been made for to stand in for the + // analysis below. + let plan_key = if plan_out.is_some() || plan_in.is_some() { + let (base_sha, head_sha) = if all { + ("-".to_string(), "-".to_string()) + } else { + ( + detector.resolve_commit(&base, "base")?, + detector.resolve_commit(head.as_deref().unwrap_or("HEAD"), "head")?, + ) + }; + let dirty_files = ignore.filter(detector.uncommitted_changes()?); + Some(PlanKey::new(KeyInputs { + base_sha, + head_sha, + head_pinned: head.is_some(), + all, + related, + dependents, + only_affected_files, + warnings_as_errors, + target: &target, + lang: &lang, + changed_files: &changed_files, + dirty_files: &dirty_files, + projects: &projects, + root: &workspace_root, + })) + } else { + None + }; + let replay: Option = match (&plan_in, &plan_key) { + (Some(path), Some(key)) => { + let outcome = if lane.len() != 1 { + Err("a plan replays one lane; pass exactly one --lane".to_string()) + } else if dry_run + && output_mode != OutputMode::Json + && output_mode != OutputMode::Quiet + { + Err("a --dry-run replay prints JSON only; add --json".to_string()) + } else { + find_lane(&lane[0], &workspace_config.affected) + .map_err(|error| error.to_string()) + .and_then(|config| { + let lane_target = + config.target.clone().unwrap_or_else(|| target.clone()); + plan_replay::load( + path, + key, + &[(lane[0].clone(), lane_target)], + &projects, + ) + }) + }; + match outcome { + Ok(file) => { + if output_mode != OutputMode::Quiet { + errln!( + "[aster] --plan-in {}: replaying lane '{}', skipping the analysis", + path.display(), + lane[0] + ); + } + Some(file) + } + Err(reason) => { + errln!( + "[aster] --plan-in {}: {reason}; analysing instead", + path.display() + ); + None + } + } + } + _ => None, + }; + phases.mark("plan-in"); + let mut recorder = match (&plan_out, &plan_key) { + (Some(path), Some(key)) => Some(PlanRecorder::new(path, key.clone())), + _ => None, + }; if output_mode == OutputMode::Verbose { errln!("[aster] Found {} changed files", changed_files.len()); @@ -1004,13 +1101,15 @@ fn run() -> Result<()> { if let Some(cycle) = find_cycle(&graph) { return Err(anyhow::anyhow!("{cycle}")); } + phases.mark("graph"); // Keep a copy of directly affected for rationale tracking in dry-run let directly_affected_addrs = directly_affected.clone(); // Under --related the source graph decides which projects the - // change reaches and which of their tests observe it. - let related_analysis = if related { + // change reaches and which of their tests observe it. A replayed + // plan already holds that decision. + let related_analysis = if related && replay.is_none() { let mut changes = detector .changes(&base, head.as_deref()) .context("Failed to read the changed lines")?; @@ -1048,8 +1147,12 @@ fn run() -> Result<()> { None }; + phases.mark("related"); + // Expand with dependents if requested - let affected_addrs = if let Some(analysis) = &related_analysis { + let affected_addrs = if replay.is_some() { + HashSet::new() + } else if let Some(analysis) = &related_analysis { let reached: HashSet = analysis .projects .iter() @@ -1065,18 +1168,6 @@ fn run() -> Result<()> { directly_affected }; - #[derive(serde::Serialize)] - struct DryRunTarget { - address: String, - reason: String, - files: Vec, - /// Commands replacing the target's own command. - #[serde(skip_serializing_if = "Vec::is_empty")] - commands: Vec, - /// How the target's command was chosen. - #[serde(skip_serializing_if = "Vec::is_empty")] - selection: Vec, - } #[derive(serde::Serialize)] struct LaneRow { lane: String, @@ -1086,20 +1177,6 @@ fn run() -> Result<()> { #[serde(flatten)] matrix: toml::Table, } - #[derive(serde::Serialize)] - struct DryRunOutput { - target: String, - base: String, - head: Option, - targets: Vec, - count: usize, - /// Requested targets dropped by --only-affected-files. - #[serde(skip_serializing_if = "Vec::is_empty")] - skipped: Vec, - /// Plugins of every project the plan runs a target in, plus - /// the lane's declared toolchains: what a runner must install. - toolchains: Vec, - } // Several lanes are planned from the one analysis above; running // them is one invocation per lane, each on its own runner. let lane = if matrix_rows && lane.is_empty() { @@ -1143,60 +1220,70 @@ fn run() -> Result<()> { let lane_toolchains = lane_config .map(|config| config.toolchains.clone()) .unwrap_or_default(); + let stored = replay.as_ref().and_then(|file| file.lanes.get(lane_label)); let mut affected_addrs = reached.clone(); - - // A changed file the target declares as a workspace input - // selects its project, which then runs the target in full. - let declared_inputs = - select_by_declared_inputs(&target, &changed_files, &projects)?; - let declared_addrs: HashSet = declared_inputs.keys().cloned().collect(); - affected_addrs.extend(declared_addrs.iter().cloned()); + let mut declared_inputs = std::collections::BTreeMap::new(); + let mut declared_addrs: HashSet = HashSet::new(); let mut directly_affected_addrs = directly_affected_addrs.clone(); - directly_affected_addrs.extend(declared_addrs.iter().cloned()); - // Shard and wrapper projects follow the project whose tests - // they run. - if let Some(analysis) = &related_analysis { - affected_addrs.extend(related_proxies( - &projects, - &target, - analysis, - &PluginRegistry::with_all_plugins(), - &workspace_config.affected.related.wrappers, - )); - } + let missing_among_affected; + if let Some(stored) = stored { + // The plan holds the selection the analysis made. + affected_addrs = stored.affected_set(); + missing_among_affected = stored.missing_among_affected; + } else { + // A changed file the target declares as a workspace input + // selects its project, which then runs the target in full. + declared_inputs = + select_by_declared_inputs(&target, &changed_files, &projects)?; + declared_addrs = declared_inputs.keys().cloned().collect(); + affected_addrs.extend(declared_addrs.iter().cloned()); + directly_affected_addrs.extend(declared_addrs.iter().cloned()); + // Shard and wrapper projects follow the project whose tests + // they run. + if let Some(analysis) = &related_analysis { + affected_addrs.extend(related_proxies( + &projects, + &target, + analysis, + &PluginRegistry::with_all_plugins(), + &workspace_config.affected.related.wrappers, + )); + } - // Lane selection applies only to primary projects, after optional - // dependent expansion and before target dependency closure. - if let Some(lane_name) = lane { - affected_addrs = select_affected_lane( - lane_name, - &workspace_config.affected, - affected_addrs, - &projects, - )?; - if output_mode == OutputMode::Verbose { - errln!("[aster] Affected lane '{lane_name}': {affected_addrs:?}"); + // Lane selection applies only to primary projects, after optional + // dependent expansion and before target dependency closure. + if let Some(lane_name) = lane { + affected_addrs = select_affected_lane( + lane_name, + &workspace_config.affected, + affected_addrs, + &projects, + )?; + if output_mode == OutputMode::Verbose { + errln!("[aster] Affected lane '{lane_name}': {affected_addrs:?}"); + } } + // Every mode runs the target only where it is defined. Dependents, + // declared inputs, related proxies, and lane selection have already + // chosen this primary set. Target-dependency closure runs later, + // from the projects that remain, so a dependency target in another + // project is not dropped here. + let defines_target: HashSet = projects + .iter() + .filter(|project| project.targets.contains_key(&target)) + .map(|project| format!("//{}", project.relative_path.display())) + .collect(); + if defines_target.is_empty() { + return Err(anyhow::anyhow!("no project defines target '{target}'")); + } + let had_affected = !affected_addrs.is_empty(); + affected_addrs.retain(|addr| defines_target.contains(addr)); + // Lanes and --all already omitted projects that lack the target + // and keep their existing empty-selection message. + missing_among_affected = + had_affected && affected_addrs.is_empty() && lane.is_none() && !all; } - // Every mode runs the target only where it is defined. Dependents, - // declared inputs, related proxies, and lane selection have already - // chosen this primary set. Target-dependency closure runs later, - // from the projects that remain, so a dependency target in another - // project is not dropped here. - let defines_target: HashSet = projects - .iter() - .filter(|project| project.targets.contains_key(&target)) - .map(|project| format!("//{}", project.relative_path.display())) - .collect(); - if defines_target.is_empty() { - return Err(anyhow::anyhow!("no project defines target '{target}'")); - } - let had_affected = !affected_addrs.is_empty(); - affected_addrs.retain(|addr| defines_target.contains(addr)); - // Lanes and --all already omitted projects that lack the target - // and keep their existing empty-selection message. - let missing_among_affected = - had_affected && affected_addrs.is_empty() && lane.is_none() && !all; + phases.mark("select"); // Primary projects are the affected ones (only these run the requested target) let primary_addrs: HashSet = affected_addrs.iter().cloned().collect(); @@ -1212,6 +1299,27 @@ fn run() -> Result<()> { .collect(); if affected_projects.is_empty() { + if let Some(recorder) = recorder.as_mut() { + let empty = DryRunOutput { + target: target.clone(), + base: base.clone(), + head: head.clone(), + targets: Vec::new(), + count: 0, + skipped: Vec::new(), + toolchains: Vec::new(), + }; + recorder.record( + lane_label, + StoredLane::new( + &target, + &affected_addrs, + missing_among_affected, + None, + empty.clone(), + ), + ); + } if several_lanes { if output_mode == OutputMode::Json { let empty = DryRunOutput { @@ -1256,6 +1364,10 @@ fn run() -> Result<()> { outln!("No projects affected"); } } + if let Some(recorder) = &recorder { + recorder.write()?; + } + report_phases(&phases, replay.is_some(), output_mode); return Ok(()); } @@ -1298,7 +1410,9 @@ fn run() -> Result<()> { // Narrow targets to changed files and apply warnings-as-errors. // Under --dependents, a project whose dependency changed cannot be // narrowed to its own files. - let command_plan = if only_affected_files || related || warnings_as_errors { + let command_plan = if let Some(stored) = stored { + stored.commands() + } else if only_affected_files || related || warnings_as_errors { let registry = PluginRegistry::with_all_plugins(); // Under --dependents a project answers for a changed // project it depends on, which its own files do not @@ -1340,6 +1454,7 @@ fn run() -> Result<()> { } else { None }; + phases.mark("narrow"); let primary_addrs = command_plan .as_ref() .map(|plan| plan.primary.clone()) @@ -1350,6 +1465,19 @@ fn run() -> Result<()> { .map(|plan| &plan.notes) .unwrap_or(&no_notes); + // Dry run of a replayed lane: the plan is already stored. + if let (true, Some(stored)) = (dry_run, stored) { + if output_mode == OutputMode::Json { + let mut plan = stored.plan.clone(); + // Name the refs this invocation was given. + plan.base = base.clone(); + plan.head = head.clone(); + output_json(&plan)?; + } + report_phases(&phases, true, output_mode); + return Ok(()); + } + // Dry run: show the full execution graph with rationale if dry_run { // Build project map from ALL projects (same as executor would) @@ -1433,7 +1561,7 @@ fn run() -> Result<()> { "target dependency" }; - if output_mode == OutputMode::Json { + if output_mode == OutputMode::Json || recorder.is_some() { let mut all_targets = Vec::new(); for level in &levels { for target_addr in level { @@ -1493,12 +1621,28 @@ fn run() -> Result<()> { matrix: config.matrix.clone(), }); } - if several_lanes { + if let Some(recorder) = recorder.as_mut() { + recorder.record( + lane_label, + StoredLane::new( + &target, + &affected_addrs, + missing_among_affected, + command_plan.as_ref(), + output.clone(), + ), + ); + } + if output_mode != OutputMode::Json { + // Recorded only; the text below is what prints. + } else if several_lanes { lane_plans .insert(lane_label.to_string(), serde_json::to_value(&output)?); } else { output_json(&output)?; } + } + if output_mode == OutputMode::Json { } else if output_mode != OutputMode::Quiet { // Count targets by category let dep_count = targets_to_run @@ -1568,6 +1712,10 @@ fn run() -> Result<()> { if several_lanes { continue; } + if let Some(recorder) = &recorder { + recorder.write()?; + } + report_phases(&phases, false, output_mode); return Ok(()); } @@ -1579,6 +1727,12 @@ fn run() -> Result<()> { let run_record = test_results.as_ref().map(|_| { detect_run(&workspace_root, &target, lane, &|name| env::var(name).ok()) }); + let quarantine = quarantine_for( + &workspace_root, + test_results.is_some() || cli.retry_failed() > 0, + )?; + phases.mark("quarantine"); + report_phases(&phases, replay.is_some(), output_mode); let recorder = ResultsRecorder::new(test_results.clone(), run_record); let executor = Executor::with_all_options( &workspace_root, @@ -1591,10 +1745,7 @@ fn run() -> Result<()> { .with_retry_failed(cli.retry_failed()) .with_wrappers(&workspace_config.affected.related.wrappers) .with_no_replay(cli.no_replay) - .with_quarantine(quarantine_for( - &workspace_root, - test_results.is_some() || cli.retry_failed() > 0, - )?); + .with_quarantine(quarantine); let all_project_refs: Vec<_> = projects.iter().collect(); let results = if let Some(plan) = &command_plan { @@ -1650,6 +1801,10 @@ fn run() -> Result<()> { return Err(anyhow::anyhow!("{blocked} project(s) blocked")); } } + if let Some(recorder) = &recorder { + recorder.write()?; + } + report_phases(&phases, false, output_mode); if matrix_rows { output_json(&lane_rows)?; } else if several_lanes && output_mode == OutputMode::Json { @@ -2659,6 +2814,13 @@ fn print_srv_dataset(path: &str, target: &SrvTarget) -> Result<()> { /// The quarantine for a run, loaded once per invocation (a run over /// several lanes executes several times). Only a run that captures test /// results can use one. +/// The `affected` timing line, on stderr so JSON output stays clean. +fn report_phases(phases: &Phases, replayed: bool, output_mode: OutputMode) { + if output_mode != OutputMode::Quiet { + errln!("{}", phases.line(replayed)); + } +} + fn quarantine_for( workspace_root: &Path, capturing: bool, diff --git a/tests/plan_replay.rs b/tests/plan_replay.rs new file mode 100644 index 00000000..b64b35d7 --- /dev/null +++ b/tests/plan_replay.rs @@ -0,0 +1,516 @@ +#![allow(clippy::disallowed_macros)] // test diagnostics may use std print macros +//! `aster affected --plan-out` and `--plan-in`: a plan written by one run +//! stands in for the analysis of another, and only when it is for the same +//! change. + +use serde_json::Value; +use std::fs; +use std::path::PathBuf; +use std::process::{Command, Output}; +use tempfile::TempDir; + +/// Two projects, `a` and `b`, in lanes `first` and `second`. Each has a +/// `rec` target that appends its name to `ran.log`. +struct Workspace { + tmp: TempDir, + /// Outside the checkout, where the plan and the run log live: a file the + /// checkout does not track would count as a change. + out: TempDir, +} + +impl Workspace { + fn new() -> Self { + let ws = Workspace { + tmp: TempDir::new().unwrap(), + out: TempDir::new().unwrap(), + }; + ws.git(&["init", "-q", "-b", "main"]); + ws.git(&["config", "user.email", "t@example.com"]); + ws.git(&["config", "user.name", "t"]); + ws.write( + "aster.toml", + "[affected.lanes.first]\ninclude = [\"//a\"]\n\n[affected.lanes.second]\ninclude = [\"//b\"]\n", + ); + for name in ["a", "b"] { + ws.write( + &format!("{name}/package.json"), + &format!(r#"{{"name":"{name}","private":true}}"#), + ); + ws.write( + &format!("{name}/aster.toml"), + &format!( + "[targets.rec]\ncommand = \"sh -c 'echo {name} >> {}'\"\n", + ws.out.path().join("ran.log").display() + ), + ); + } + ws.git(&["add", "-A"]); + ws.git(&["commit", "-q", "-m", "initial"]); + // The change both lanes select. + ws.write("a/changed.txt", "x"); + ws.write("b/changed.txt", "x"); + ws + } + + fn root(&self) -> PathBuf { + self.tmp.path().to_path_buf() + } + + fn write(&self, path: &str, content: &str) { + let full = self.root().join(path); + fs::create_dir_all(full.parent().unwrap()).unwrap(); + fs::write(full, content).unwrap(); + } + + fn git(&self, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(self.root()) + .output() + .unwrap(); + assert!(output.status.success(), "git {args:?}: {output:?}"); + } + + fn aster(&self, args: &[&str]) -> Output { + Command::new(env!("CARGO_BIN_EXE_aster")) + .args(args) + .current_dir(self.root()) + .output() + .unwrap() + } + + fn plan_file(&self) -> String { + self.out + .path() + .join("plan.json") + .to_string_lossy() + .into_owned() + } + + /// Plan every lane into `plan.json`. + fn write_plan(&self) { + let output = self.aster(&[ + "--json", + "affected", + "rec", + "--base=HEAD", + "--matrix", + "--plan-out", + &self.plan_file(), + ]); + assert!(output.status.success(), "{output:?}"); + } + + fn ran(&self) -> Vec { + fs::read_to_string(self.out.path().join("ran.log")) + .unwrap_or_default() + .lines() + .map(str::to_string) + .collect() + } + + fn forget_runs(&self) { + let _ = fs::remove_file(self.out.path().join("ran.log")); + } +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn read_plan(ws: &Workspace) -> Value { + serde_json::from_str(&fs::read_to_string(ws.plan_file()).unwrap()).unwrap() +} + +#[test] +fn matrix_writes_every_lane_with_the_key_it_is_valid_for() { + let ws = Workspace::new(); + ws.write_plan(); + let plan = read_plan(&ws); + assert_eq!(plan["schema"], 1); + let lanes: Vec<&str> = plan["lanes"] + .as_object() + .unwrap() + .keys() + .map(String::as_str) + .collect(); + assert_eq!(lanes, ["first", "second"]); + let key = &plan["key"]; + assert_eq!(key["aster_version"], env!("CARGO_PKG_VERSION")); + assert_eq!(key["base_sha"].as_str().unwrap().len(), 40); + assert_eq!(key["head_sha"].as_str().unwrap().len(), 40); + assert_eq!(key["mode"], "changed"); + assert_eq!(key["target"], "rec"); + let first = &plan["lanes"]["first"]["plan"]; + assert_eq!(first["targets"][0]["address"], "//a:rec"); +} + +#[test] +fn a_lane_runs_from_the_plan_exactly_as_the_analysis_runs_it() { + let ws = Workspace::new(); + ws.write_plan(); + + let fresh = ws.aster(&["affected", "rec", "--base=HEAD", "--lane=first"]); + assert!(fresh.status.success(), "{fresh:?}"); + let fresh_ran = ws.ran(); + ws.forget_runs(); + + let replayed = ws.aster(&[ + "affected", + "rec", + "--base=HEAD", + "--lane=first", + "--plan-in", + &ws.plan_file(), + ]); + assert!(replayed.status.success(), "{replayed:?}"); + assert!( + stderr(&replayed).contains("replaying lane 'first'"), + "{}", + stderr(&replayed) + ); + assert_eq!(ws.ran(), fresh_ran); + assert_eq!(fresh_ran, ["a"], "lane first runs project a only"); +} + +#[test] +fn a_dry_run_replay_prints_the_json_the_analysis_prints() { + let ws = Workspace::new(); + ws.write_plan(); + for lane in ["first", "second"] { + let args = ["--json", "affected", "rec", "--base=HEAD", "--dry-run"]; + let lane_arg = format!("--lane={lane}"); + let fresh = ws.aster(&[&args[..], &[lane_arg.as_str()][..]].concat()); + let replayed = ws.aster( + &[ + &args[..], + &[lane_arg.as_str(), "--plan-in", &ws.plan_file()][..], + ] + .concat(), + ); + // One target per lane, so no ordering is left to differ. + assert_eq!( + String::from_utf8_lossy(&replayed.stdout), + String::from_utf8_lossy(&fresh.stdout), + "lane {lane}" + ); + assert!(stderr(&replayed).contains("replaying"), "lane {lane}"); + } +} + +/// A replay that cannot be trusted logs why and runs the analysis, which +/// then produces the same result as a run without `--plan-in`. +fn assert_falls_back(ws: &Workspace, plan: &str, extra: &[&str], reason: &str) { + let mut args = vec!["affected", "rec", "--lane=first"]; + args.extend(extra); + let without = ws.aster(&args); + assert!(without.status.success(), "{without:?}"); + let expected = ws.ran(); + ws.forget_runs(); + + args.extend(["--plan-in", plan]); + let with = ws.aster(&args); + let log = stderr(&with); + assert!(with.status.success(), "{log}"); + let lines: Vec<&str> = log.lines().filter(|l| l.contains("--plan-in")).collect(); + assert_eq!(lines.len(), 1, "one line about the plan: {log}"); + assert!(lines[0].contains(reason), "{reason:?} not in {log}"); + assert!(lines[0].contains("analysing instead"), "{log}"); + assert!(!log.contains("replaying lane"), "{log}"); + assert_eq!(ws.ran(), expected); + ws.forget_runs(); +} + +#[test] +fn a_missing_plan_file_falls_back() { + let ws = Workspace::new(); + assert_falls_back( + &ws, + "/nonexistent/plan.json", + &["--base=HEAD"], + "file does not exist", + ); +} + +#[test] +fn an_unreadable_plan_falls_back() { + let ws = Workspace::new(); + fs::write(ws.plan_file(), "{ not json").unwrap(); + assert_falls_back(&ws, &ws.plan_file(), &["--base=HEAD"], "cannot parse it"); + // A directory cannot be read as a file. + let dir = ws.out.path().to_string_lossy().into_owned(); + assert_falls_back(&ws, &dir, &["--base=HEAD"], "cannot read it"); +} + +#[test] +fn a_plan_from_another_aster_falls_back() { + let ws = Workspace::new(); + ws.write_plan(); + let text = fs::read_to_string(ws.plan_file()).unwrap(); + let version = format!("\"aster_version\": \"{}\"", env!("CARGO_PKG_VERSION")); + assert!(text.contains(&version)); + fs::write( + ws.plan_file(), + text.replace(&version, "\"aster_version\": \"0.0.1\""), + ) + .unwrap(); + assert_falls_back( + &ws, + &ws.plan_file(), + &["--base=HEAD"], + "aster version differs", + ); +} + +#[test] +fn a_plan_for_another_base_falls_back() { + let ws = Workspace::new(); + ws.git(&["add", "-A"]); + ws.git(&["commit", "-q", "-m", "second"]); + ws.write("a/more.txt", "x"); + // The plan is made against HEAD, the run asks for HEAD~1. + ws.write_plan(); + assert_falls_back(&ws, &ws.plan_file(), &["--base=HEAD~1"], "base differs"); +} + +#[test] +fn a_plan_for_another_head_falls_back() { + let ws = Workspace::new(); + ws.git(&["add", "-A"]); + ws.git(&["commit", "-q", "-m", "second"]); + ws.git(&["checkout", "-q", "-b", "other", "HEAD~1"]); + ws.write("a/other.txt", "x"); + ws.git(&["add", "-A"]); + ws.git(&["commit", "-q", "-m", "other"]); + ws.git(&["checkout", "-q", "main"]); + let plan = |head: &str| { + let output = ws.aster(&[ + "--json", + "affected", + "rec", + "--base=main~1", + &format!("--head={head}"), + "--matrix", + "--plan-out", + &ws.plan_file(), + ]); + assert!(output.status.success(), "{output:?}"); + }; + plan("main"); + assert_falls_back( + &ws, + &ws.plan_file(), + &["--base=main~1", "--head=other"], + "head differs", + ); +} + +#[test] +fn a_plan_for_another_mode_falls_back() { + let ws = Workspace::new(); + ws.write_plan(); + assert_falls_back( + &ws, + &ws.plan_file(), + &["--base=HEAD", "--dependents"], + "mode differs", + ); +} + +#[test] +fn a_plan_for_another_set_of_changed_files_falls_back() { + let ws = Workspace::new(); + ws.write_plan(); + ws.write("a/new-since-the-plan.txt", "x"); + assert_falls_back( + &ws, + &ws.plan_file(), + &["--base=HEAD"], + "changed files differs", + ); +} + +#[test] +fn a_lane_missing_from_the_plan_falls_back() { + let ws = Workspace::new(); + let planned = ws.aster(&[ + "--json", + "affected", + "rec", + "--base=HEAD", + "--lane=second", + "--dry-run", + "--plan-out", + &ws.plan_file(), + ]); + assert!(planned.status.success(), "{planned:?}"); + assert_eq!( + read_plan(&ws)["lanes"].as_object().unwrap().len(), + 1, + "only the planned lane is stored" + ); + assert_falls_back( + &ws, + &ws.plan_file(), + &["--base=HEAD"], + "lane 'first' is not in the plan (it has: second)", + ); +} + +#[test] +fn text_dry_run_and_several_lanes_analyse_instead_of_replaying() { + let ws = Workspace::new(); + ws.write_plan(); + let text = ws.aster(&[ + "affected", + "rec", + "--base=HEAD", + "--lane=first", + "--dry-run", + "--plan-in", + &ws.plan_file(), + ]); + assert!(text.status.success(), "{text:?}"); + assert!( + stderr(&text).contains("prints JSON only"), + "{}", + stderr(&text) + ); + let several = ws.aster(&[ + "--json", + "affected", + "rec", + "--base=HEAD", + "--lane=first", + "--lane=second", + "--dry-run", + "--plan-in", + &ws.plan_file(), + ]); + assert!(several.status.success(), "{several:?}"); + assert!( + stderr(&several).contains("exactly one --lane"), + "{}", + stderr(&several) + ); +} + +#[test] +fn plan_out_needs_a_dry_run_and_excludes_plan_in() { + let ws = Workspace::new(); + let output = ws.aster(&["affected", "rec", "--base=HEAD", "--plan-out", "p.json"]); + assert!(!output.status.success()); + assert!( + stderr(&output).contains("add --dry-run or --matrix"), + "{}", + stderr(&output) + ); + let both = ws.aster(&[ + "affected", + "rec", + "--dry-run", + "--plan-out", + "p.json", + "--plan-in", + "p.json", + ]); + assert!(!both.status.success()); + assert!(!ws.root().join("p.json").exists()); +} + +#[test] +fn a_plan_inside_the_checkout_counts_as_a_change_unless_it_is_under_dot_aster() { + let ws = Workspace::new(); + for (path, replays) in [("plan.json", false), (".aster/plan.json", true)] { + let plan = ws.root().join(path); + let planned = ws.aster(&[ + "--json", + "affected", + "rec", + "--base=HEAD", + "--matrix", + "--plan-out", + plan.to_str().unwrap(), + ]); + assert!(planned.status.success(), "{planned:?}"); + let replay = ws.aster(&[ + "affected", + "rec", + "--base=HEAD", + "--lane=first", + "--plan-in", + plan.to_str().unwrap(), + ]); + assert!(replay.status.success(), "{replay:?}"); + let log = stderr(&replay); + assert_eq!(log.contains("replaying lane"), replays, "{path}: {log}"); + if !replays { + assert!(log.contains("changed files differs"), "{path}: {log}"); + } + ws.forget_runs(); + } +} + +#[test] +fn every_run_of_affected_reports_where_its_time_went() { + let ws = Workspace::new(); + let output = ws.aster(&["affected", "rec", "--base=HEAD", "--lane=first"]); + let log = stderr(&output); + let line = log + .lines() + .find(|l| l.starts_with("[aster] affected phases:")) + .unwrap_or_else(|| panic!("no phases line: {log}")); + for phase in [ + "discover", + "changes", + "graph", + "related", + "narrow", + "quarantine", + "total", + ] { + assert!(line.contains(phase), "{phase} missing from {line}"); + } + let quiet = ws.aster(&["--quiet", "affected", "rec", "--base=HEAD", "--lane=first"]); + assert!(!stderr(&quiet).contains("affected phases")); +} + +#[test] +fn an_edited_lane_config_is_not_replayed_even_with_a_pinned_head() { + let ws = Workspace::new(); + ws.git(&["add", "-A"]); + ws.git(&["commit", "-q", "-m", "change"]); + let range = ["--base=HEAD~1", "--head=HEAD"]; + let mut plan_args = vec!["--json", "affected", "rec"]; + plan_args.extend(range); + plan_args.extend(["--matrix", "--plan-out"]); + let file = ws.plan_file(); + plan_args.push(&file); + assert!(ws.aster(&plan_args).status.success()); + + // An uncommitted edit moves lane second from //b to //a. The commits + // are unchanged, the selection is not. + let config = fs::read_to_string(ws.root().join("aster.toml")).unwrap(); + ws.write( + "aster.toml", + &config.replace( + "[affected.lanes.second]\ninclude = [\"//b\"]", + "[affected.lanes.second]\ninclude = [\"//a\"]", + ), + ); + let replay = ws.aster(&[ + "affected", + "rec", + "--base=HEAD~1", + "--head=HEAD", + "--lane=second", + "--plan-in", + &file, + ]); + let log = stderr(&replay); + assert!(replay.status.success(), "{log}"); + assert!(!log.contains("replaying lane"), "{log}"); + assert!(log.contains("analysing instead"), "{log}"); + // What analysis selects now: lane second is project a. + assert_eq!(ws.ran(), ["a"]); +} diff --git a/tests/related.rs b/tests/related.rs index 26eeb0fa..1d8e2f77 100644 --- a/tests/related.rs +++ b/tests/related.rs @@ -4512,3 +4512,134 @@ fn a_pnpm_lockfile_edit_runs_only_the_projects_whose_importers_it_changes() { assert!(plan.targets.contains_key("//pkg-a:test"), "{plan:#?}"); assert!(plan.targets.contains_key("//pkg-b:test"), "{plan:#?}"); } + +/// A dry-run plan with the order a run does not promise (targets within a +/// level, changed files) removed, so two plans compare equal when they +/// select the same targets with the same commands. +#[cfg(unix)] +fn normalized(stdout: &[u8]) -> Value { + let mut plan: Value = serde_json::from_slice(stdout).unwrap(); + if let Some(targets) = plan["targets"].as_array_mut() { + for target in targets.iter_mut() { + if let Some(files) = target["files"].as_array_mut() { + files.sort_by_key(|file| file.to_string()); + } + } + targets.sort_by_key(|target| target["address"].to_string()); + } + plan +} + +/// The Elixir fixture with a lane over `//shop` and a `test` target that +/// records the files it was given, with the source change `edit` makes. +#[cfg(unix)] +fn recording_shop() -> Workspace { + use std::os::unix::fs::PermissionsExt; + + let mut files = ELIXIR.to_vec(); + files.push(( + "shop/aster.toml", + "[targets.deps]\ncommand = \"true\"\n\n[targets.test]\ncommand = \"./record.sh {files}\"\ncapabilities = [\"files_list\"]\n", + )); + files.push(( + "shop/record.sh", + "#!/bin/sh\nprintf '%s\\n' \"$@\" > ran.txt\n", + )); + let ws = Workspace::with_config("[affected.lanes.shop]\ninclude = [\"//shop\"]\n", &files); + let script = ws.root().join("shop/record.sh"); + fs::set_permissions(&script, fs::Permissions::from_mode(0o755)).unwrap(); + ws.git(&["commit", "-qam", "executable"]); + ws.edit( + "shop/lib/shop/pricing.ex", + "Enum.sum(items)", + "Enum.sum(items) + 0", + ); + ws +} + +#[cfg(unix)] +#[test] +fn a_replayed_lane_plans_and_runs_what_the_analysis_does() { + let ws = recording_shop(); + // Under .aster/, which is never a change; elsewhere in the tree the + // plan file would be an untracked file and a change itself. + let plan_file = ws.root().join(".aster/plan.json"); + let plan_path = plan_file.to_str().unwrap(); + let related = ["--base=HEAD", "--related", "--no-cache", "--lane=shop"]; + let run = |extra: &[&str]| { + let mut args = vec!["--json", "affected", "test"]; + args.extend(related); + args.extend(extra); + let output = ws.aster(&args); + assert!( + output.status.success(), + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + output + }; + + // Plan once, writing the plan the analysis made. + let fresh = run(&["--dry-run", "--plan-out", plan_path]); + assert!(plan_file.exists()); + + // The replay prints the same plan, narrowed command included, and says + // so on stderr. + let replayed = run(&["--dry-run", "--plan-in", plan_path]); + assert_eq!( + normalized(&replayed.stdout), + normalized(&fresh.stdout), + "the replay prints the plan the analysis printed" + ); + let stderr = String::from_utf8_lossy(&replayed.stderr); + assert!(stderr.contains("replaying lane 'shop'"), "{stderr}"); + assert!(stderr.contains("(replayed plan)"), "{stderr}"); + assert!( + String::from_utf8_lossy(&fresh.stdout).contains("test/shop/cart_test.exs"), + "the plan holds the narrowed command" + ); + + // Running from the plan executes the narrowed command, not the whole + // suite. + run(&["--plan-in", plan_path]); + let ran = fs::read_to_string(ws.root().join("shop/ran.txt")).unwrap(); + assert_eq!(ran.trim(), "test/shop/cart_test.exs"); +} + +#[cfg(unix)] +#[test] +fn a_plan_made_for_another_change_is_not_replayed() { + let ws = recording_shop(); + // Under .aster/, which is never a change; elsewhere in the tree the + // plan file would be an untracked file and a change itself. + let plan_file = ws.root().join(".aster/plan.json"); + let plan_path = plan_file.to_str().unwrap(); + let args = |extra: &[&str]| { + let mut args = vec![ + "--json", + "affected", + "test", + "--base=HEAD", + "--related", + "--no-cache", + "--lane=shop", + ]; + args.extend(extra); + ws.aster(&args) + }; + assert!(args(&["--dry-run", "--plan-out", plan_path]) + .status + .success()); + + // A second source change after the plan: the stored selection no longer + // describes the tree, so the analysis runs and sees both changes. + ws.edit("shop/lib/shop/pricing.ex", "amount * 0.2", "amount * 0.25"); + let fresh = args(&["--dry-run"]); + let replay = args(&["--dry-run", "--plan-in", plan_path]); + assert_eq!(normalized(&fresh.stdout), normalized(&replay.stdout)); + let stderr = String::from_utf8_lossy(&replay.stderr); + assert!(stderr.contains("changed files differs"), "{stderr}"); + assert!(!stderr.contains("replaying lane"), "{stderr}"); + assert!(String::from_utf8_lossy(&replay.stdout).contains("tax_test.exs")); +}