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")); +}