forked from 1b-agent/fieldcraft-upstream
xtask: affected --base REV and ci --affected REV test only what a change reaches
`cargo xtask affected` maps the changed files to workspace packages (by directory from cargo metadata; files outside every package to the packages whose sources name their top-level directory), adds every reverse dependency, and flags everything (manifest, lock file, toolchain, xtask, workflows), solver (the oracle's package or a package with #[ignore]d heavy tests) and wasm (a crate the wasm check builds). `cargo xtask ci --affected REV` runs the quick whole-tree gates, then clippy and the tests of the affected packages, and the wasm check only when flagged. Without the flag the gate is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
21072a23de
commit
b3098f8739
3 files changed
+453
-24
No files matched your search
@@ -0,0 +1,368 @@
|
||||
//! `cargo xtask affected --base <rev>`: which workspace crates a change touches, and which CI
|
||||
//! jobs it needs (docs/releasing.md, "What CI runs when").
|
||||
//!
|
||||
//! A changed file belongs to the package whose directory holds it (from `cargo metadata`). A
|
||||
//! file outside every package (an oracle case under `tests/`, an icon under `assets/`) belongs to
|
||||
//! each package whose Rust sources name its top-level directory as a path (`"../../tests/…"`,
|
||||
//! `"docs/features.md"`), the way they read it. The affected set is those packages and every
|
||||
//! workspace package that depends on one of them, through any kind of dependency.
|
||||
//!
|
||||
//! Flags:
|
||||
//! - `everything`: the workspace manifest, the lock file, the toolchain, xtask or the CI
|
||||
//! workflows changed, or a path under `crates/` or `apps/` belongs to no package: run it all.
|
||||
//! - `solver`: the affected set holds the oracle's package or a package with heavy tests
|
||||
//! (`#[ignore]`d, run by `cargo xtask test --heavy`); the heavy and oracle jobs run.
|
||||
//! - `wasm`: the affected set holds a crate the wasm check builds (below the apps, and the web app).
|
||||
//!
|
||||
//! The rules work on a small model ([`Workspace`]) so they are unit-tested without git or cargo.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::root;
|
||||
|
||||
/// The package whose `oracle` binary `cargo xtask oracle` runs.
|
||||
pub const ORACLE_PACKAGE: &str = "fieldcraft-testkit";
|
||||
|
||||
/// Paths whose change runs everything: they reach every crate or the gate itself. A path matches
|
||||
/// when it equals an entry or lies under an entry ending in `/`.
|
||||
const EVERYTHING: &[&str] =
|
||||
&["Cargo.toml", "Cargo.lock", "rust-toolchain", "rust-toolchain.toml", ".cargo/", "clippy.toml", "rustfmt.toml", "xtask/", ".github/"];
|
||||
|
||||
/// Directories that hold only packages: a file there outside every package is a crate being
|
||||
/// added, removed or moved, so the whole workspace runs.
|
||||
const PACKAGE_ROOTS: &[&str] = &["crates/", "apps/"];
|
||||
|
||||
/// A workspace package, as the rules see it.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Package {
|
||||
pub name: String,
|
||||
/// Its directory relative to the workspace root, with `/` separators and no trailing one.
|
||||
pub dir: String,
|
||||
/// The workspace packages it depends on (normal, dev and build).
|
||||
pub deps: Vec<String>,
|
||||
/// It has `#[ignore]`d (heavy) tests.
|
||||
pub heavy: bool,
|
||||
/// The top-level repository entries (`tests`, `assets`, `docs`, …) its Rust sources name.
|
||||
pub reads: BTreeSet<String>,
|
||||
/// The wasm check builds it.
|
||||
pub wasm: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Workspace {
|
||||
pub packages: Vec<Package>,
|
||||
}
|
||||
|
||||
/// What a change needs.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Plan {
|
||||
/// Affected packages, sorted. Every package when `everything`.
|
||||
pub crates: BTreeSet<String>,
|
||||
pub everything: bool,
|
||||
pub solver: bool,
|
||||
pub wasm: bool,
|
||||
/// Why `everything` is set: the first path that set it.
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
impl Workspace {
|
||||
fn owner(&self, file: &str) -> Option<&Package> {
|
||||
self.packages.iter().filter(|p| file.starts_with(&format!("{}/", p.dir))).max_by_key(|p| p.dir.len())
|
||||
}
|
||||
|
||||
/// The plan for a list of changed paths (relative to the workspace root, `/` separators).
|
||||
pub fn plan(&self, changed: &[String]) -> Plan {
|
||||
let mut plan = Plan::default();
|
||||
let mut direct = BTreeSet::new();
|
||||
for file in changed {
|
||||
if EVERYTHING.iter().any(|e| if e.ends_with('/') { file.starts_with(e) } else { file == e }) {
|
||||
plan.everything = true;
|
||||
plan.reason.get_or_insert_with(|| file.clone());
|
||||
continue;
|
||||
}
|
||||
if let Some(p) = self.owner(file) {
|
||||
direct.insert(p.name.clone());
|
||||
continue;
|
||||
}
|
||||
if PACKAGE_ROOTS.iter().any(|r| file.starts_with(r)) {
|
||||
plan.everything = true;
|
||||
plan.reason.get_or_insert_with(|| file.clone());
|
||||
continue;
|
||||
}
|
||||
let top = file.split('/').next().unwrap_or(file);
|
||||
direct.extend(self.packages.iter().filter(|p| p.reads.contains(top)).map(|p| p.name.clone()));
|
||||
}
|
||||
plan.crates = if plan.everything { self.packages.iter().map(|p| p.name.clone()).collect() } else { self.dependents(direct) };
|
||||
let selected = |p: &&Package| plan.crates.contains(&p.name);
|
||||
plan.solver = plan.everything || self.packages.iter().filter(selected).any(|p| p.heavy || p.name == ORACLE_PACKAGE);
|
||||
plan.wasm = plan.everything || self.packages.iter().filter(selected).any(|p| p.wasm);
|
||||
plan
|
||||
}
|
||||
|
||||
/// `set` and every package that depends on one in it, directly or not.
|
||||
fn dependents(&self, mut set: BTreeSet<String>) -> BTreeSet<String> {
|
||||
loop {
|
||||
let more: Vec<String> =
|
||||
self.packages.iter().filter(|p| !set.contains(&p.name) && p.deps.iter().any(|d| set.contains(d))).map(|p| p.name.clone()).collect();
|
||||
if more.is_empty() {
|
||||
return set;
|
||||
}
|
||||
set.extend(more);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The workspace from `cargo metadata` (the no-deps form [`crate::metadata`] returns), reading
|
||||
/// each package's Rust sources for heavy tests and the top-level paths they name.
|
||||
pub fn workspace(meta: &Value) -> Result<Workspace, String> {
|
||||
let root = root();
|
||||
let tops: Vec<String> = std::fs::read_dir(&root)
|
||||
.map_err(|e| format!("read {}: {e}", root.display()))?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.filter(|n| n != "target" && !n.starts_with("target-"))
|
||||
.collect();
|
||||
let pkgs = meta["packages"].as_array().ok_or("metadata: no packages array")?;
|
||||
let members: BTreeSet<&str> = pkgs.iter().filter_map(|p| p["name"].as_str()).collect();
|
||||
let mut packages = Vec::new();
|
||||
for p in pkgs {
|
||||
let name = p["name"].as_str().ok_or("metadata: package without name")?.to_owned();
|
||||
let manifest = Path::new(p["manifest_path"].as_str().ok_or("metadata: package without manifest_path")?);
|
||||
let dir = manifest.parent().ok_or("metadata: manifest without a directory")?;
|
||||
let rel = dir.strip_prefix(&root).map_err(|_| format!("{name}: {} is outside the workspace", dir.display()))?;
|
||||
let deps = p["dependencies"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|d| d["name"].as_str())
|
||||
.filter(|d| members.contains(d) && *d != name)
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
let mut sources = String::new();
|
||||
read_sources(dir, &mut sources)?;
|
||||
let reads = tops
|
||||
.iter()
|
||||
.filter(|t| [format!("\"{t}/"), format!("/{t}/"), format!("\"{t}\""), format!("/{t}\"")].iter().any(|n| sources.contains(n.as_str())))
|
||||
.cloned()
|
||||
.collect();
|
||||
packages.push(Package {
|
||||
heavy: sources.contains("#[ignore"),
|
||||
wasm: crate::wasm_crate(&name),
|
||||
name,
|
||||
dir: rel.to_string_lossy().replace('\\', "/"),
|
||||
deps,
|
||||
reads,
|
||||
});
|
||||
}
|
||||
Ok(Workspace { packages })
|
||||
}
|
||||
|
||||
/// Append every `.rs` file under `dir` (build output and hidden directories left out).
|
||||
fn read_sources(dir: &Path, out: &mut String) -> Result<(), String> {
|
||||
let entries = std::fs::read_dir(dir).map_err(|e| format!("read {}: {e}", dir.display()))?;
|
||||
for entry in entries {
|
||||
let entry = entry.map_err(|e| format!("read {}: {e}", dir.display()))?;
|
||||
let path = entry.path();
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let kind = entry.file_type().map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
if kind.is_dir() && !name.starts_with('.') && name != "target" {
|
||||
read_sources(&path, out)?;
|
||||
} else if kind.is_file() && name.ends_with(".rs") {
|
||||
out.push_str(&std::fs::read_to_string(&path).map_err(|e| format!("read {}: {e}", path.display()))?);
|
||||
out.push('\n');
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The files that differ between `base` and `head` (the working tree when `None`; renames count
|
||||
/// as a deletion and an addition, so both sides count).
|
||||
pub fn changed_files(base: &str, head: Option<&str>) -> Result<Vec<String>, String> {
|
||||
let git = |args: &[&str]| -> Result<String, String> {
|
||||
let out = Command::new("git").args(args).current_dir(root()).output().map_err(|e| format!("git: {e}"))?;
|
||||
if out.status.success() {
|
||||
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
||||
} else {
|
||||
Err(format!("git {}: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim()))
|
||||
}
|
||||
};
|
||||
for rev in std::iter::once(base).chain(head) {
|
||||
let commit = format!("{rev}^{{commit}}");
|
||||
git(&["rev-parse", "--verify", "--quiet", &commit]).map_err(|_| format!("`{rev}` is not a commit in this repository"))?;
|
||||
}
|
||||
let mut args = vec!["diff", "--name-only", "--no-renames", base];
|
||||
args.extend(head);
|
||||
let out = git(&args)?;
|
||||
Ok(out.lines().filter(|l| !l.is_empty()).map(str::to_owned).collect())
|
||||
}
|
||||
|
||||
/// The plan for the change since `base`.
|
||||
pub fn plan_since(base: &str, head: Option<&str>) -> Result<(Plan, usize), String> {
|
||||
let changed = changed_files(base, head)?;
|
||||
let ws = workspace(&crate::metadata()?)?;
|
||||
Ok((ws.plan(&changed), changed.len()))
|
||||
}
|
||||
|
||||
/// `cargo xtask affected --base <rev> [--head <rev>] [--github-output]`.
|
||||
pub fn run(args: &[&str]) -> Result<(), String> {
|
||||
let mut base = None;
|
||||
let mut head = None;
|
||||
let mut github = false;
|
||||
let mut it = args.iter();
|
||||
while let Some(a) = it.next() {
|
||||
match *a {
|
||||
"--base" => base = Some(*it.next().ok_or("--base needs a revision")?),
|
||||
"--head" => head = Some(*it.next().ok_or("--head needs a revision")?),
|
||||
"--github-output" => github = true,
|
||||
other => return Err(format!("affected: unknown argument `{other}`")),
|
||||
}
|
||||
}
|
||||
let base = base.ok_or("affected: --base <rev> is required")?;
|
||||
let (plan, files) = plan_since(base, head)?;
|
||||
print(&plan, base, files);
|
||||
if github {
|
||||
let path = std::env::var("GITHUB_OUTPUT").map_err(|_| "--github-output: GITHUB_OUTPUT is not set")?;
|
||||
let lines = format!("everything={}\nsolver={}\nwasm={}\ncrates={}\n", plan.everything, plan.solver, plan.wasm, plan.crates.len());
|
||||
use std::io::Write;
|
||||
std::fs::OpenOptions::new()
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.and_then(|mut f| f.write_all(lines.as_bytes()))
|
||||
.map_err(|e| format!("write {path}: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn print(plan: &Plan, base: &str, files: usize) {
|
||||
println!("affected since {base}: {files} changed file(s)");
|
||||
match &plan.reason {
|
||||
Some(r) => println!("everything: {r} changed"),
|
||||
None if plan.crates.is_empty() => println!("crates: none"),
|
||||
None => println!("crates ({}): {}", plan.crates.len(), plan.crates.iter().cloned().collect::<Vec<_>>().join(" ")),
|
||||
}
|
||||
println!("flags: everything={} solver={} wasm={}", plan.everything, plan.solver, plan.wasm);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn pkg(name: &str, dir: &str, deps: &[&str], heavy: bool, wasm: bool, reads: &[&str]) -> Package {
|
||||
Package {
|
||||
name: name.into(),
|
||||
dir: dir.into(),
|
||||
deps: deps.iter().map(|d| d.to_string()).collect(),
|
||||
heavy,
|
||||
reads: reads.iter().map(|r| r.to_string()).collect(),
|
||||
wasm,
|
||||
}
|
||||
}
|
||||
|
||||
/// model <- solver <- engine <- ui-egui <- app; testkit (the oracle) uses engine; ui-egui reads assets.
|
||||
fn ws() -> Workspace {
|
||||
Workspace {
|
||||
packages: vec![
|
||||
pkg("model", "crates/model", &[], false, true, &[]),
|
||||
pkg("solver", "crates/solver", &["model"], true, true, &[]),
|
||||
pkg("engine", "crates/engine", &["solver", "model"], false, true, &["docs"]),
|
||||
pkg("ui-egui", "crates/ui-egui", &["engine"], false, true, &["assets"]),
|
||||
pkg("app", "apps/app", &["ui-egui"], false, false, &["assets"]),
|
||||
pkg(ORACLE_PACKAGE, "crates/testkit", &["engine"], false, false, &["tests", "docs"]),
|
||||
pkg("xtask", "xtask", &[], true, false, &[]),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
fn plan(files: &[&str]) -> Plan {
|
||||
ws().plan(&files.iter().map(|f| f.to_string()).collect::<Vec<_>>())
|
||||
}
|
||||
|
||||
fn set(names: &[&str]) -> BTreeSet<String> {
|
||||
names.iter().map(|n| n.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_change_reaches_only_its_dependents() {
|
||||
let p = plan(&["crates/ui-egui/src/theme.rs"]);
|
||||
assert_eq!(p.crates, set(&["ui-egui", "app"]));
|
||||
assert!(!p.solver && !p.everything);
|
||||
assert!(p.wasm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_only_change_skips_wasm() {
|
||||
let p = plan(&["apps/app/src/main.rs"]);
|
||||
assert_eq!(p.crates, set(&["app"]));
|
||||
assert!(!p.wasm && !p.solver);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solver_change_reaches_the_oracle() {
|
||||
let p = plan(&["crates/solver/src/heat.rs"]);
|
||||
assert_eq!(p.crates, set(&["solver", "engine", "ui-egui", "app", ORACLE_PACKAGE]));
|
||||
assert!(p.solver && p.wasm && !p.everything);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn engine_change_runs_the_oracle_through_the_testkit() {
|
||||
let p = plan(&["crates/engine/src/lib.rs"]);
|
||||
assert!(p.crates.contains(ORACLE_PACKAGE));
|
||||
assert!(p.solver);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_file_workflow_and_xtask_run_everything() {
|
||||
for f in ["Cargo.lock", "Cargo.toml", ".github/workflows/ci.yml", "xtask/src/main.rs", "rust-toolchain.toml"] {
|
||||
let p = plan(&[f]);
|
||||
assert!(p.everything && p.solver && p.wasm, "{f}");
|
||||
assert_eq!(p.crates.len(), 7, "{f}");
|
||||
assert_eq!(p.reason.as_deref(), Some(f));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_crate_manifest_is_that_crate() {
|
||||
let p = plan(&["crates/model/Cargo.toml"]);
|
||||
assert!(!p.everything);
|
||||
assert!(p.crates.contains("model") && p.solver);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unowned_path_under_crates_runs_everything() {
|
||||
assert!(plan(&["crates/removed/src/lib.rs"]).everything);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_outside_packages_reach_the_packages_that_read_them() {
|
||||
let p = plan(&["tests/oracle/beam/recipe.json"]);
|
||||
assert_eq!(p.crates, set(&[ORACLE_PACKAGE]));
|
||||
assert!(p.solver && !p.wasm);
|
||||
let p = plan(&["assets/icons/move.svg"]);
|
||||
assert_eq!(p.crates, set(&["ui-egui", "app"]));
|
||||
assert!(!p.solver);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prose_reaches_nothing() {
|
||||
let p = plan(&["skills/meshing/SKILL.md", "README.md"]);
|
||||
assert!(p.crates.is_empty());
|
||||
assert!(!p.everything && !p.solver && !p.wasm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_directory_belongs_to_the_deepest_package() {
|
||||
let mut w = ws();
|
||||
w.packages.push(pkg("inner", "crates/engine/inner", &[], false, false, &[]));
|
||||
let p = w.plan(&["crates/engine/inner/src/lib.rs".to_string()]);
|
||||
assert_eq!(p.crates, set(&["inner"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_change_is_an_empty_plan() {
|
||||
assert_eq!(plan(&[]), Plan::default());
|
||||
}
|
||||
}
|
||||
+69
-17
@@ -5,6 +5,7 @@
|
||||
//! ATTRIBUTION.md.
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
mod affected;
|
||||
mod app_icon;
|
||||
mod assets;
|
||||
mod icons;
|
||||
@@ -42,10 +43,17 @@ commands:
|
||||
against their references and fixtures; write docs/oracle.md after a full run;
|
||||
--bless freezes a passing case's results in its fixture.json; --quick skips
|
||||
cases marked slow (release build)
|
||||
affected --base REV [--head REV] [--github-output]
|
||||
the workspace crates changed between REV and HEAD-REV (the working tree) and every crate that depends on them,
|
||||
and the flags CI plans its jobs with: everything (manifest, lock file,
|
||||
toolchain, xtask or workflows changed), solver (the heavy tests or the oracle
|
||||
are affected), wasm (a crate the wasm check builds is affected)
|
||||
wasm cargo check every library crate and the web app for wasm32-unknown-unknown
|
||||
ci fmt --check, assets, layers, clippy -D warnings, then the tests
|
||||
ci [--affected REV]
|
||||
fmt --check, assets, layers, clippy -D warnings, then the tests
|
||||
(not the heavy ones) with the wasm check alongside (stops at the first failing
|
||||
step)
|
||||
step); --affected REV lints and tests only the crates `affected --base REV`
|
||||
lists, and checks wasm only when it flags wasm
|
||||
";
|
||||
|
||||
fn main() -> ExitCode {
|
||||
@@ -65,12 +73,17 @@ fn main() -> ExitCode {
|
||||
let (cargo_args, test_args) = rest.iter().position(|a| *a == "--").map_or((rest, &[][..]), |i| (&rest[..i], &rest[i + 1..]));
|
||||
if heavy {
|
||||
let ignored: Vec<&str> = ["--ignored"].into_iter().chain(test_args.iter().copied()).collect();
|
||||
test::run(cargo_args, &ignored, vec![test::doc_tests(cargo_args, test_args)])
|
||||
test::run(&[], cargo_args, &ignored, vec![test::doc_tests(cargo_args, test_args)])
|
||||
} else {
|
||||
test::run(cargo_args, test_args, vec![])
|
||||
test::run(&[], cargo_args, test_args, vec![])
|
||||
}
|
||||
}
|
||||
Some("ci") => cmd_ci(),
|
||||
Some("affected") => affected::run(&rest),
|
||||
Some("ci") => match rest.as_slice() {
|
||||
[] => cmd_ci(),
|
||||
["--affected", base] => cmd_ci_affected(base),
|
||||
_ => Err(format!("ci: expected no arguments or `--affected REV`\n\n{USAGE}")),
|
||||
},
|
||||
Some("corpus") => {
|
||||
// A binary of the testkit, so xtask itself stays free of SolidCraft.
|
||||
let mut c = cargo();
|
||||
@@ -80,7 +93,7 @@ fn main() -> ExitCode {
|
||||
Some("oracle") => {
|
||||
// A binary of the testkit, which has the engine and SolidCraft scripts for geometry.
|
||||
let mut c = cargo();
|
||||
c.args(["run", "--release", "-p", "fieldcraft-testkit", "--bin", "oracle", "--"]).args(&rest);
|
||||
c.args(["run", "--release", "-p", affected::ORACLE_PACKAGE, "--bin", "oracle", "--"]).args(&rest);
|
||||
run(c, "oracle")
|
||||
}
|
||||
Some("wasm") => cmd_wasm(),
|
||||
@@ -171,15 +184,16 @@ fn cmd_wasm() -> Result<(), String> {
|
||||
/// The app that runs in the browser (apps/fieldcraft-web), checked with the layered crates.
|
||||
const WEB_APP: &str = "fieldcraft-web";
|
||||
|
||||
/// Whether the wasm check builds `pkg`: a layered crate or the web app.
|
||||
pub fn wasm_crate(pkg: &str) -> bool {
|
||||
pkg == WEB_APP || matches!(layers::classify(pkg), Some(layers::Class::Layer(_)))
|
||||
}
|
||||
|
||||
/// The wasm check and how many crates it covers. It uses the ci profile so the host build
|
||||
/// scripts and proc macros built for clippy and the tests are reused.
|
||||
fn wasm_check() -> Result<(Command, usize), String> {
|
||||
let crates = layers::from_metadata(&metadata()?)?;
|
||||
let set: Vec<String> = crates
|
||||
.iter()
|
||||
.filter(|c| c.name == WEB_APP || matches!(layers::classify(&c.name), Some(layers::Class::Layer(_))))
|
||||
.map(|c| c.name.clone())
|
||||
.collect();
|
||||
let set: Vec<String> = crates.iter().filter(|c| wasm_crate(&c.name)).map(|c| c.name.clone()).collect();
|
||||
if !set.iter().any(|c| c == WEB_APP) {
|
||||
return Err(format!("wasm: the web app `{WEB_APP}` is not in the workspace"));
|
||||
}
|
||||
@@ -195,16 +209,54 @@ fn wasm_check() -> Result<(Command, usize), String> {
|
||||
/// which leaves cargo's build lock free. The heavy tests and the doc tests are the CI heavy job's
|
||||
/// (`cargo xtask test --heavy`).
|
||||
fn cmd_ci() -> Result<(), String> {
|
||||
let mut c = cargo();
|
||||
c.args(["fmt", "--all", "--", "--check"]);
|
||||
run(c, "cargo fmt --check")?;
|
||||
assets::run(&root())?;
|
||||
cmd_layers()?;
|
||||
quick_gates()?;
|
||||
let mut c = cargo();
|
||||
c.args(["clippy", "--workspace", "--all-targets", "--profile", "ci", "--", "-D", "warnings"]);
|
||||
run(c, "cargo clippy -D warnings")?;
|
||||
let (wasm, n) = wasm_check()?;
|
||||
test::run(&[], &[], vec![(format!("wasm32-unknown-unknown check ({n} crates)"), wasm)])?;
|
||||
test::run(&[], &[], &[], vec![(format!("wasm32-unknown-unknown check ({n} crates)"), wasm)])?;
|
||||
eprintln!("ci: all gates passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The gates that look at the whole tree and take seconds: they run on every change.
|
||||
fn quick_gates() -> Result<(), String> {
|
||||
let mut c = cargo();
|
||||
c.args(["fmt", "--all", "--", "--check"]);
|
||||
run(c, "cargo fmt --check")?;
|
||||
assets::run(&root())?;
|
||||
cmd_layers()
|
||||
}
|
||||
|
||||
/// `ci --affected REV`: the quick gates, then clippy and the tests of the affected crates only, and
|
||||
/// the wasm check when an affected crate is built for wasm. A change that runs everything is the
|
||||
/// full gate.
|
||||
fn cmd_ci_affected(base: &str) -> Result<(), String> {
|
||||
let (plan, files) = affected::plan_since(base, None)?;
|
||||
affected::print(&plan, base, files);
|
||||
if plan.everything {
|
||||
return cmd_ci();
|
||||
}
|
||||
quick_gates()?;
|
||||
if plan.crates.is_empty() {
|
||||
eprintln!("ci: no crate affected; the quick gates passed");
|
||||
return Ok(());
|
||||
}
|
||||
let packages: Vec<&str> = plan.crates.iter().map(String::as_str).collect();
|
||||
let mut c = cargo();
|
||||
c.args(["clippy", "--all-targets", "--profile", "ci"]);
|
||||
for p in &packages {
|
||||
c.args(["-p", p]);
|
||||
}
|
||||
c.args(["--", "-D", "warnings"]);
|
||||
run(c, &format!("cargo clippy -D warnings ({} crates)", packages.len()))?;
|
||||
let side = if plan.wasm {
|
||||
let (wasm, n) = wasm_check()?;
|
||||
vec![(format!("wasm32-unknown-unknown check ({n} crates)"), wasm)]
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
test::run(&packages, &[], &[], side)?;
|
||||
eprintln!("ci: all gates passed for {} affected crate(s)", packages.len());
|
||||
Ok(())
|
||||
}
|
||||
+16
-7
@@ -15,11 +15,11 @@ struct TestBin {
|
||||
dir: String,
|
||||
}
|
||||
|
||||
/// Build the tests with `cargo_args` (e.g. `--locked`), run each binary with `test_args`, and run
|
||||
/// Build the tests of `packages` (every workspace package when empty) with `cargo_args` (e.g. `--locked`), run each binary with `test_args`, and run
|
||||
/// the `side` jobs one after another meanwhile. Each prints a line when it ends; a failure prints
|
||||
/// its whole output.
|
||||
pub fn run(cargo_args: &[&str], test_args: &[&str], side: Vec<(String, Command)>) -> Result<(), String> {
|
||||
let bins = build(cargo_args)?;
|
||||
pub fn run(packages: &[&str], cargo_args: &[&str], test_args: &[&str], side: Vec<(String, Command)>) -> Result<(), String> {
|
||||
let bins = build(packages, cargo_args)?;
|
||||
let start = Instant::now();
|
||||
let (tx, rx) = mpsc::channel::<(String, Result<String, String>, f64)>();
|
||||
let mut failed = Vec::new();
|
||||
@@ -66,11 +66,20 @@ fn finish(mut c: Command) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// `cargo test --workspace --profile ci --no-run`, and the executables it built.
|
||||
fn build(cargo_args: &[&str]) -> Result<Vec<TestBin>, String> {
|
||||
/// `cargo test --workspace --profile ci --no-run` (or `-p` each of `packages`), and the executables
|
||||
/// it built.
|
||||
fn build(packages: &[&str], cargo_args: &[&str]) -> Result<Vec<TestBin>, String> {
|
||||
let mut c = cargo();
|
||||
c.args(["test", "--workspace", "--profile", "ci", "--no-run", "--message-format=json-render-diagnostics"]).args(cargo_args);
|
||||
eprintln!("$ cargo test --workspace --profile ci --no-run");
|
||||
c.arg("test");
|
||||
if packages.is_empty() {
|
||||
c.arg("--workspace");
|
||||
}
|
||||
for p in packages {
|
||||
c.args(["-p", p]);
|
||||
}
|
||||
c.args(["--profile", "ci", "--no-run", "--message-format=json-render-diagnostics"]).args(cargo_args);
|
||||
let scope = if packages.is_empty() { "--workspace".to_string() } else { format!("{} packages", packages.len()) };
|
||||
eprintln!("$ cargo test {scope} --profile ci --no-run");
|
||||
let out = c.stderr(Stdio::inherit()).output().map_err(|e| format!("cargo test --no-run: failed to spawn: {e}"))?;
|
||||
if !out.status.success() {
|
||||
return Err(format!("cargo test --no-run: exited with {}", out.status));
|
||||
|
||||
Reference in new issue
Block a user