diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9fcaaa030d..5e4f7841b4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -221,33 +221,7 @@ jobs: use_container: false runs_on: '["self-hosted","linux","qcs"]' self_hosted_owner: rcore-os - command: | - set -eux - git_cmd() { - git -c safe.directory="${GITHUB_WORKSPACE}" "$@" - } - ensure_since_ref() { - ref="$1" - git_cmd cat-file -e "${ref}^{commit}" 2>/dev/null && return 0 - git_cmd fetch --no-tags --depth=1 origin "${ref}" || true - git_cmd cat-file -e "${ref}^{commit}" 2>/dev/null - } - - if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then - cargo xtask sync-lint --since "${{ github.event.pull_request.base.sha }}" - elif [ "${GITHUB_EVENT_NAME}" = "push" ]; then - before="${{ github.event.before }}" - if [ "${before}" = "0000000000000000000000000000000000000000" ]; then - cargo xtask sync-lint - elif ensure_since_ref "${before}"; then - cargo xtask sync-lint --since "${before}" - else - echo "::notice::before commit ${before} is unavailable; running full sync-lint" - cargo xtask sync-lint - fi - else - cargo xtask sync-lint - fi + command: cargo xtask sync-lint --since "${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}" cache_key: "" container_image: base fetch_depth: full @@ -304,33 +278,7 @@ jobs: use_container: false runs_on: '["self-hosted","linux","qcs"]' self_hosted_owner: rcore-os - command: | - set -eux - git_cmd() { - git -c safe.directory="${GITHUB_WORKSPACE}" "$@" - } - ensure_since_ref() { - ref="$1" - git_cmd cat-file -e "${ref}^{commit}" 2>/dev/null && return 0 - git_cmd fetch --no-tags --depth=1 origin "${ref}" || true - git_cmd cat-file -e "${ref}^{commit}" 2>/dev/null - } - - if [ "${GITHUB_EVENT_NAME}" = "pull_request" ]; then - cargo xtask clippy --since "${{ github.event.pull_request.base.sha }}" - elif [ "${GITHUB_EVENT_NAME}" = "push" ]; then - before="${{ github.event.before }}" - if [ "${before}" = "0000000000000000000000000000000000000000" ]; then - cargo xtask clippy - elif ensure_since_ref "${before}"; then - cargo xtask clippy --since "${before}" - else - echo "::notice::before commit ${before} is unavailable; running full clippy" - cargo xtask clippy - fi - else - cargo xtask clippy - fi + command: cargo xtask clippy --since "${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || github.event.before }}" cache_key: "" container_image: base fetch_depth: full diff --git a/.github/workflows/starry-apps.yml b/.github/workflows/starry-apps.yml index 850ede6ae6..c3afca900e 100644 --- a/.github/workflows/starry-apps.yml +++ b/.github/workflows/starry-apps.yml @@ -18,6 +18,33 @@ concurrency: cancel-in-progress: false jobs: + scheduled_clippy_all: + name: Scheduled clippy all + runs-on: ubuntu-latest + container: + image: ghcr.io/rcore-os/tgoskits-container:latest + credentials: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Restore Rust cache + uses: Swatinem/rust-cache@v2 + with: + shared-key: starry-apps-clippy-all + save-if: ${{ github.event_name == 'schedule' }} + + - name: Show Rust version + run: rustc --version + + - name: Run clippy all + shell: bash + run: | + set -euxo pipefail + cargo xtask clippy --all + starry_apps: name: ${{ matrix.name }} runs-on: ubuntu-latest diff --git a/CLAUDE.md b/CLAUDE.md index c0ebc4cddc..51b5188805 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ cargo xtask starry qemu --arch aarch64 cargo xtask axvisor qemu --arch aarch64 # Linting -cargo xtask clippy # against scripts/test/clippy_crates.csv whitelist +cargo xtask clippy # all workspace crates, no-deps by default; prints timing cargo xtask clippy --package # specific crate cargo xtask clippy --since # only changed crates cargo xtask clippy --all # all workspace crates @@ -90,18 +90,17 @@ Board-level platform crates: `axplat-dyn` (dynamic dispatch), `somehal`, `riscv6 ## CI - **fmt**: `cargo fmt --all -- --check` -- **clippy**: `cargo xtask clippy --since ` (incremental for PRs) +- **clippy**: `cargo xtask clippy --since ` (incremental for PRs; prints start, finish, and elapsed time) - **sync-lint**: `cargo xtask sync-lint --since ` - **std tests**: `cargo xtask test` - **QEMU tests**: ArceOS/StarryOS/Axvisor across all 4 architectures - **Board tests**: self-hosted runners for OrangePi-5-Plus, RDK-S100 -- Crate lists: `scripts/test/clippy_crates.csv`, `scripts/test/std_crates.csv` +- Std test crate list: `scripts/test/std_crates.csv` ## Conventions - PR titles: Conventional Commits `type(scope): content`, e.g. `feat(axbuild): add board test flow`, `fix(starry-process): correct tty cleanup` - PR titles in English, bodies in Chinese - Do not silence clippy warnings with `allow`; fix the root cause -- After modifying a crate, if it passes clippy but is missing from `scripts/test/clippy_crates.csv`, add it - Do not add agent/AI branding or signatures to commits/PRs - Read and strictly follow all conventions in AGENTS.md diff --git a/apps/starry/mosquitto/prebuild.sh b/apps/starry/mosquitto/prebuild.sh index e68f4adab1..82096c95ca 100755 --- a/apps/starry/mosquitto/prebuild.sh +++ b/apps/starry/mosquitto/prebuild.sh @@ -2,7 +2,7 @@ set -euo pipefail app_dir="${STARRY_APP_DIR:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)}" -base_rootfs="${STARRY_BASE_ROOTFS:-}" +base_rootfs="${STARRY_ROOTFS:-${STARRY_BASE_ROOTFS:-}}" staging_root="${STARRY_STAGING_ROOT:-}" overlay_dir="${STARRY_OVERLAY_DIR:-}" apk_cache="${STARRY_WORKSPACE:-$(cd "$app_dir/../../.." && pwd)}/target/mosquitto-apk-cache" @@ -28,14 +28,13 @@ ensure_host_packages() { return fi - if ! command -v apt-get >/dev/null 2>&1; then - echo "error: missing required host packages and apt-get is unavailable: ${missing[*]}" >&2 + if ! command -v apk >/dev/null 2>&1; then + echo "error: missing required host packages and apk is unavailable: ${missing[*]}" >&2 exit 1 fi echo "installing missing host packages: ${missing[*]}" - apt-get update - apt-get install -y --no-install-recommends "${missing[@]}" + apk add --no-cache "${missing[@]}" } extract_base_rootfs() { @@ -141,7 +140,7 @@ connection_messages true EOF } -require_env STARRY_BASE_ROOTFS "$base_rootfs" +require_env STARRY_ROOTFS "$base_rootfs" require_env STARRY_STAGING_ROOT "$staging_root" require_env STARRY_OVERLAY_DIR "$overlay_dir" diff --git a/scripts/axbuild/src/clippy.rs b/scripts/axbuild/src/clippy.rs index b68608e82b..cb05c8def3 100644 --- a/scripts/axbuild/src/clippy.rs +++ b/scripts/axbuild/src/clippy.rs @@ -1,11 +1,13 @@ use std::{ collections::{BTreeSet, HashMap, HashSet}, path::{Path, PathBuf}, + time::{Duration, Instant}, }; use anyhow::{Context, bail}; use ax_config_gen::read_config_string; use cargo_metadata::{Metadata, Package}; +use chrono::Local; use serde_json::Value; use crate::support::process::run_cargo_status_with_env; @@ -47,8 +49,14 @@ const CLIPPY_TARGET_ALIASES: &[(&str, &str)] = &[ pub(crate) fn run_workspace_clippy_command(args: &crate::ClippyArgs) -> anyhow::Result<()> { validate_clippy_args(args)?; + let started_at = Local::now(); + let timer = Instant::now(); + println!( + "clippy started at: {}", + started_at.format("%Y-%m-%d %H:%M:%S %z") + ); let workspace_manifest = crate::context::workspace_manifest_path()?; - let metadata = if args.since.is_some() && args.packages.is_empty() && !args.all { + let metadata = if clippy_metadata_needs_deps(args) { crate::context::workspace_metadata_root_manifest_with_deps(&workspace_manifest) } else { crate::context::workspace_metadata_root_manifest(&workspace_manifest) @@ -67,6 +75,7 @@ pub(crate) fn run_workspace_clippy_command(args: &crate::ClippyArgs) -> anyhow:: "no clippy packages selected from {}; skipping", workspace_root.display() ); + print_clippy_timing(timer.elapsed()); return Ok(()); } let checks = expand_clippy_checks(&packages, &metadata)?; @@ -79,8 +88,15 @@ pub(crate) fn run_workspace_clippy_command(args: &crate::ClippyArgs) -> anyhow:: ); let mut runner = ProcessCargoRunner; - let report = run_clippy_checks(&mut runner, &workspace_root, &checks)?; + let report = match run_clippy_checks(&mut runner, &workspace_root, &checks) { + Ok(report) => report, + Err(err) => { + print_clippy_timing(timer.elapsed()); + return Err(err); + } + }; print_report_summary(&report); + print_clippy_timing(timer.elapsed()); if report.failed_packages().is_empty() { println!("all clippy checks passed"); @@ -94,6 +110,38 @@ pub(crate) fn run_workspace_clippy_command(args: &crate::ClippyArgs) -> anyhow:: ) } +fn print_clippy_timing(elapsed: Duration) { + let finished_at = Local::now(); + println!( + "clippy finished at: {}", + finished_at.format("%Y-%m-%d %H:%M:%S %z") + ); + println!("clippy elapsed: {}", format_elapsed(elapsed)); +} + +fn format_elapsed(elapsed: Duration) -> String { + let secs = elapsed.as_secs(); + let millis = elapsed.subsec_millis(); + if secs == 0 { + return format!("{}ms", millis); + } + + let hours = secs / 3600; + let minutes = (secs % 3600) / 60; + let seconds = secs % 60; + if hours > 0 { + format!("{}h {}m {}s", hours, minutes, seconds) + } else if minutes > 0 { + format!("{}m {}s", minutes, seconds) + } else { + format!("{}s", seconds) + } +} + +fn clippy_metadata_needs_deps(args: &crate::ClippyArgs) -> bool { + args.since.is_some() && args.packages.is_empty() && !args.all +} + fn validate_clippy_args(args: &crate::ClippyArgs) -> anyhow::Result<()> { if args.since.is_some() && !args.packages.is_empty() { bail!("`--since` cannot be combined with `--package`; choose one package selection mode"); @@ -116,24 +164,33 @@ fn workspace_packages(metadata: &Metadata) -> Vec { packages } +#[derive(Debug, Clone)] +struct SelectedClippyPackage { + package: Package, + deps_mode: ClippyDepsMode, +} + fn resolve_requested_packages( args: &crate::ClippyArgs, workspace_root: &Path, metadata: &Metadata, all_packages: &[Package], -) -> anyhow::Result> { +) -> anyhow::Result> { let package_lookup: HashMap<_, _> = all_packages .iter() .map(|pkg| (pkg.name.as_str(), pkg.clone())) .collect(); let known_packages: HashSet<_> = all_packages.iter().map(|pkg| pkg.name.as_str()).collect(); - let package_names = if !args.packages.is_empty() { + let selections: Vec<(String, ClippyDepsMode)> = if !args.packages.is_empty() { validate_requested_packages(&args.packages, &known_packages)? + .into_iter() + .map(|package| (package, ClippyDepsMode::NoDeps)) + .collect() } else if args.all { all_packages .iter() - .map(|pkg| pkg.name.to_string()) + .map(|pkg| (pkg.name.to_string(), ClippyDepsMode::NoDeps)) .collect() } else if let Some(since) = args.since.as_deref() { match crate::support::git::select_incremental_packages( @@ -142,12 +199,20 @@ fn resolve_requested_packages( all_packages, since, )? { - crate::support::git::IncrementalPackageSelection::Packages(packages) => { + crate::support::git::IncrementalPackageSelection::Packages { changed, affected } => { + let selections = + incremental_clippy_selections(changed, affected, metadata, all_packages); + let changed_count = selections + .iter() + .filter(|(_, mode)| matches!(mode, ClippyDepsMode::NoDeps)) + .count(); println!( - "incremental clippy since `{since}` selected {} package(s)", - packages.len() + "incremental clippy since `{since}` selected {} changed package(s) and {} \ + dependent top-level package(s)", + changed_count, + selections.len() - changed_count ); - packages + selections } crate::support::git::IncrementalPackageSelection::Full { reason } => { println!( @@ -155,28 +220,78 @@ fn resolve_requested_packages( ); all_packages .iter() - .map(|pkg| pkg.name.to_string()) + .map(|pkg| (pkg.name.to_string(), ClippyDepsMode::NoDeps)) .collect() } } } else { all_packages .iter() - .map(|pkg| pkg.name.to_string()) + .map(|pkg| (pkg.name.to_string(), ClippyDepsMode::NoDeps)) .collect() }; - package_names + selections .into_iter() - .map(|package| { - package_lookup + .map(|(package, deps_mode)| { + let package = package_lookup .get(package.as_str()) .cloned() - .ok_or_else(|| anyhow::anyhow!("workspace package `{package}` not found")) + .ok_or_else(|| anyhow::anyhow!("workspace package `{package}` not found"))?; + Ok(SelectedClippyPackage { package, deps_mode }) }) .collect() } +/// Build the incremental clippy selection from a `--since` diff. +/// +/// Changed crates are linted with `--no-deps` (their own code, full feature +/// matrix). The runnable top-level frontier of the affected set is linted +/// *with* deps: `cargo clippy -p ` lints every workspace member in that +/// crate's dependency subtree (cargo does not cap-lints path/workspace deps), so +/// one with-deps run covers the whole affected subtree below it. +/// +/// The frontier is computed over `affected \ skipped`: an unsupported crate +/// (e.g. `axvisor`) cannot run through this flow and can be the *only* route +/// into part of the affected subtree, so removing it first re-promotes the +/// crates it would otherwise orphan to their own runnable roots. Skipped crates +/// are kept in `changed` on purpose: `skip_unsupported_packages` drops them +/// later with a consistent skip message. +fn incremental_clippy_selections( + changed: Vec, + affected: Vec, + metadata: &Metadata, + all_packages: &[Package], +) -> Vec<(String, ClippyDepsMode)> { + let skipped = all_packages + .iter() + .filter(|package| clippy_skip_reason(package).is_some()) + .map(|package| package.name.as_str()) + .collect::>(); + let changed_set = changed.iter().cloned().collect::>(); + + let runnable_affected = affected + .into_iter() + .filter(|package| !skipped.contains(package.as_str())) + .collect::>(); + let integration = crate::support::git::top_level_affected_workspace_packages( + metadata, + all_packages, + &runnable_affected, + ); + + changed + .into_iter() + .map(|package| (package, ClippyDepsMode::NoDeps)) + .chain( + integration + .into_iter() + .filter(|package| !changed_set.contains(package)) + .map(|package| (package, ClippyDepsMode::WithDeps)), + ) + .collect() +} + fn validate_requested_packages( requested: &[String], known_packages: &HashSet<&str>, @@ -203,10 +318,17 @@ enum ClippyCheckKind { Feature(String), } +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +enum ClippyDepsMode { + NoDeps, + WithDeps, +} + #[derive(Debug, Clone, PartialEq, Eq, Hash)] struct ClippyCheck { package: String, kind: ClippyCheckKind, + deps_mode: ClippyDepsMode, target: Option, env: Vec<(String, String)>, axconfig_override: Option, @@ -262,6 +384,9 @@ impl ClippyCheck { feature.clone(), ], }; + if matches!(self.deps_mode, ClippyDepsMode::NoDeps) { + args.insert(1, "--no-deps".into()); + } if let Some(target) = &self.target { args.extend(["--target".into(), target.clone()]); } @@ -334,12 +459,15 @@ fn clippy_skip_reason(package: &Package) -> Option<&str> { .find_map(|(name, reason)| (package.name == *name).then_some(*reason)) } -fn skip_unsupported_packages(packages: Vec) -> Vec { +fn skip_unsupported_packages(packages: Vec) -> Vec { packages .into_iter() .filter(|package| { - if let Some(reason) = clippy_skip_reason(package) { - println!("skipping clippy for package `{}`: {reason}", package.name); + if let Some(reason) = clippy_skip_reason(&package.package) { + println!( + "skipping clippy for package `{}`: {reason}", + package.package.name + ); false } else { true @@ -447,13 +575,14 @@ fn arceos_rust_clippy_env(metadata: &Metadata) -> anyhow::Result anyhow::Result> { let mut checks = Vec::new(); let workspace_root = metadata.workspace_root.clone().into_std_path_buf(); - for package in packages { + for selected in packages { + let package = &selected.package; let features: BTreeSet<_> = package .features .keys() @@ -474,6 +603,7 @@ fn expand_clippy_checks( checks.push(ClippyCheck { package: package.name.to_string(), kind: ClippyCheckKind::Base, + deps_mode: selected.deps_mode.clone(), target: target.clone(), env: env.clone(), axconfig_override: None, @@ -492,6 +622,7 @@ fn expand_clippy_checks( checks.push(ClippyCheck { package: package.name.to_string(), kind: ClippyCheckKind::Feature(feature.clone()), + deps_mode: selected.deps_mode.clone(), target: target.clone(), env: with_axconfig_env_override(env.clone(), axconfig_override.as_ref()), axconfig_override, @@ -549,11 +680,7 @@ impl ClippyRunReport { } } -fn run_clippy_checks( - runner: &mut R, - workspace_root: &Path, - checks: &[ClippyCheck], -) -> anyhow::Result { +fn planned_clippy_report(checks: &[ClippyCheck]) -> ClippyRunReport { let mut packages = Vec::new(); let mut package_indexes = HashMap::new(); @@ -566,24 +693,56 @@ fn run_clippy_checks( package_indexes.insert(check.package.clone(), index); } - let mut passed_checks = 0; + ClippyRunReport { + total_checks: checks.len(), + passed_checks: 0, + packages, + } +} + +fn print_clippy_check_plan(workspace_root: &Path, index: usize, total: usize, check: &ClippyCheck) { + let args = check.cargo_args(); + println!("[{}/{}] {}", index + 1, total, check.label()); + if check.env.is_empty() { + println!( + " cd {} && cargo {}", + workspace_root.display(), + args.join(" ") + ); + } else { + println!( + " cd {} && {} cargo {}", + workspace_root.display(), + check.env_prefix(), + args.join(" ") + ); + } +} + +fn run_clippy_checks( + runner: &mut R, + workspace_root: &Path, + checks: &[ClippyCheck], +) -> anyhow::Result { + let mut report = planned_clippy_report(checks); + let package_indexes = report + .packages + .iter() + .enumerate() + .map(|(index, package)| (package.package.clone(), index)) + .collect::>(); for (index, check) in checks.iter().enumerate() { - let args = check.cargo_args(); - println!("[{}/{}] {}", index + 1, checks.len(), check.label()); - if check.env.is_empty() { - println!(" cargo {}", args.join(" ")); - } else { - println!(" {} cargo {}", check.env_prefix(), args.join(" ")); - } + print_clippy_check_plan(workspace_root, index, checks.len(), check); - let success = runner.run_clippy(workspace_root, check)?; let package_index = package_indexes[check.package.as_str()]; - let package_report = &mut packages[package_index]; + let package_report = &mut report.packages[package_index]; package_report.total_checks += 1; + let success = runner.run_clippy(workspace_root, check)?; + if success { - passed_checks += 1; + report.passed_checks += 1; println!("ok: {}", check.label()); } else { package_report.failed_checks.push(check.label()); @@ -595,11 +754,7 @@ fn run_clippy_checks( } } - Ok(ClippyRunReport { - total_checks: checks.len(), - passed_checks, - packages, - }) + Ok(report) } fn print_report_summary(report: &ClippyRunReport) { @@ -878,6 +1033,46 @@ mod tests { serde_json::from_value(value).unwrap() } + fn metadata_with_resolve(packages: Vec, deps: &[(&str, &[&str])]) -> Metadata { + let members = packages + .iter() + .map(|package| package.id.repr.as_str()) + .collect::>(); + let ids = packages + .iter() + .map(|package| (package.name.as_str(), package.id.repr.as_str())) + .collect::>(); + let nodes = deps + .iter() + .map(|(name, deps)| { + serde_json::json!({ + "id": ids[name], + "dependencies": deps.iter().map(|dep| ids[dep]).collect::>(), + "deps": deps.iter().map(|dep| { + serde_json::json!({ + "name": dep, + "pkg": ids[dep], + "dep_kinds": [{ "kind": null, "target": null }] + }) + }).collect::>(), + "features": [] + }) + }) + .collect::>(); + let value = serde_json::json!({ + "packages": packages, + "workspace_members": members, + "workspace_default_members": members, + "resolve": { "nodes": nodes, "root": null }, + "target_directory": "/tmp/target", + "version": 1, + "workspace_root": "/tmp/ws", + "metadata": null, + }); + + serde_json::from_value(value).unwrap() + } + fn metadata_for_packages(packages: &[Package]) -> Metadata { let members = packages .iter() @@ -887,7 +1082,15 @@ mod tests { } fn expand(packages: &[Package]) -> Vec { - expand_clippy_checks(packages, &metadata_for_packages(packages)) + let selected = packages + .iter() + .cloned() + .map(|package| SelectedClippyPackage { + package, + deps_mode: ClippyDepsMode::NoDeps, + }) + .collect::>(); + expand_clippy_checks(&selected, &metadata_for_packages(packages)) .expect("test package clippy checks should expand") } @@ -981,7 +1184,7 @@ mod tests { assert_eq!( resolved .iter() - .map(|pkg| pkg.name.as_str()) + .map(|pkg| pkg.package.name.as_str()) .collect::>(), vec!["alpha", "beta"] ); @@ -1011,7 +1214,7 @@ mod tests { assert_eq!( resolved .iter() - .map(|pkg| pkg.name.as_str()) + .map(|pkg| pkg.package.name.as_str()) .collect::>(), vec!["beta"] ); @@ -1069,6 +1272,7 @@ mod tests { ClippyCheck { package: "alpha".into(), kind: ClippyCheckKind::Base, + deps_mode: ClippyDepsMode::NoDeps, target: None, env: Vec::new(), axconfig_override: None, @@ -1076,6 +1280,7 @@ mod tests { ClippyCheck { package: "alpha".into(), kind: ClippyCheckKind::Feature("feat-a".into()), + deps_mode: ClippyDepsMode::NoDeps, target: None, env: Vec::new(), axconfig_override: None, @@ -1083,6 +1288,7 @@ mod tests { ClippyCheck { package: "alpha".into(), kind: ClippyCheckKind::Feature("feat-b".into()), + deps_mode: ClippyDepsMode::NoDeps, target: None, env: Vec::new(), axconfig_override: None, @@ -1125,6 +1331,207 @@ mod tests { ); } + #[test] + fn incremental_selection_keeps_runnable_top_levels_when_some_are_skipped() { + let packages = vec![ + pkg("alpha", "alpha 0.1.0 (path+file:///tmp/alpha)", &[], None), + pkg("axvm", "axvm 0.1.0 (path+file:///tmp/axvm)", &[], None), + pkg( + "axvisor", + "axvisor 0.1.0 (path+file:///tmp/axvisor)", + &[], + None, + ), + pkg("app", "app 0.1.0 (path+file:///tmp/app)", &[], None), + ]; + let metadata = metadata_with_resolve( + packages.clone(), + &[ + ("alpha", &[]), + ("axvm", &["alpha"]), + ("axvisor", &["axvm"]), + ("app", &["axvm"]), + ], + ); + + let selected = incremental_clippy_selections( + vec!["alpha".into()], + vec![ + "alpha".into(), + "axvm".into(), + "axvisor".into(), + "app".into(), + ], + &metadata, + &packages, + ); + + assert_eq!( + selected, + vec![ + ("alpha".into(), ClippyDepsMode::NoDeps), + ("app".into(), ClippyDepsMode::WithDeps), + ] + ); + } + + #[test] + fn incremental_selection_falls_back_when_all_top_levels_are_skipped() { + let packages = vec![ + pkg("alpha", "alpha 0.1.0 (path+file:///tmp/alpha)", &[], None), + pkg("axvm", "axvm 0.1.0 (path+file:///tmp/axvm)", &[], None), + pkg( + "axvisor", + "axvisor 0.1.0 (path+file:///tmp/axvisor)", + &[], + None, + ), + ]; + let metadata = metadata_with_resolve( + packages.clone(), + &[("alpha", &[]), ("axvm", &["alpha"]), ("axvisor", &["axvm"])], + ); + + let selected = incremental_clippy_selections( + vec!["alpha".into()], + vec!["alpha".into(), "axvm".into(), "axvisor".into()], + &metadata, + &packages, + ); + + assert_eq!( + selected, + vec![ + ("alpha".into(), ClippyDepsMode::NoDeps), + ("axvm".into(), ClippyDepsMode::WithDeps), + ] + ); + } + + #[test] + fn incremental_selection_recomputes_frontier_around_skipped_top_level() { + // `shared` is depended on by both a runnable top-level (`app`) and the + // skipped top-level (`axvisor`). `axvm` sits only under `axvisor`, so + // merely dropping skipped top-levels would leave `axvm` unlinted. The + // frontier must be recomputed over `affected \ skipped` so `axvm` is + // re-promoted to a runnable with-deps root. + let packages = vec![ + pkg( + "shared", + "shared 0.1.0 (path+file:///tmp/shared)", + &[], + None, + ), + pkg("app", "app 0.1.0 (path+file:///tmp/app)", &[], None), + pkg("axvm", "axvm 0.1.0 (path+file:///tmp/axvm)", &[], None), + pkg( + "axvisor", + "axvisor 0.1.0 (path+file:///tmp/axvisor)", + &[], + None, + ), + ]; + let metadata = metadata_with_resolve( + packages.clone(), + &[ + ("shared", &[]), + ("app", &["shared"]), + ("axvm", &["shared"]), + ("axvisor", &["axvm"]), + ], + ); + + let selected = incremental_clippy_selections( + vec!["shared".into()], + vec![ + "app".into(), + "axvm".into(), + "axvisor".into(), + "shared".into(), + ], + &metadata, + &packages, + ); + + assert_eq!( + selected, + vec![ + ("shared".into(), ClippyDepsMode::NoDeps), + ("app".into(), ClippyDepsMode::WithDeps), + ("axvm".into(), ClippyDepsMode::WithDeps), + ] + ); + } + + #[test] + fn incremental_selection_uses_natural_frontier_when_nothing_is_skipped() { + let packages = vec![ + pkg("alpha", "alpha 0.1.0 (path+file:///tmp/alpha)", &[], None), + pkg("beta", "beta 0.1.0 (path+file:///tmp/beta)", &[], None), + pkg("gamma", "gamma 0.1.0 (path+file:///tmp/gamma)", &[], None), + ]; + let metadata = metadata_with_resolve( + packages.clone(), + &[("alpha", &[]), ("beta", &["alpha"]), ("gamma", &["beta"])], + ); + + let selected = incremental_clippy_selections( + vec!["alpha".into()], + vec!["alpha".into(), "beta".into(), "gamma".into()], + &metadata, + &packages, + ); + + assert_eq!( + selected, + vec![ + ("alpha".into(), ClippyDepsMode::NoDeps), + ("gamma".into(), ClippyDepsMode::WithDeps), + ] + ); + } + + #[test] + fn incremental_selection_keeps_changed_unsupported_crate_for_shared_skip_handling() { + // Editing an unsupported crate's own source (e.g. `axvisor`) keeps it in + // the `changed` selection instead of dropping it here; the shared + // `skip_unsupported_packages` pass then removes it and prints the skip + // message, matching `--all`/default behaviour. + let packages = vec![pkg( + "axvisor", + "axvisor 0.1.0 (path+file:///tmp/axvisor)", + &[], + None, + )]; + let metadata = metadata_with_resolve(packages.clone(), &[("axvisor", &[])]); + + let selected = incremental_clippy_selections( + vec!["axvisor".into()], + vec!["axvisor".into()], + &metadata, + &packages, + ); + + assert_eq!(selected, vec![("axvisor".into(), ClippyDepsMode::NoDeps)]); + } + + #[test] + fn with_deps_check_omits_no_deps_flag() { + let check = ClippyCheck { + package: "alpha".into(), + kind: ClippyCheckKind::Base, + deps_mode: ClippyDepsMode::WithDeps, + target: None, + env: Vec::new(), + axconfig_override: None, + }; + + assert_eq!( + check.cargo_args(), + vec!["clippy", "-p", "alpha", "--", "-D", "warnings"] + ); + } + #[test] fn package_without_features_yields_only_base_check() { let checks = expand(&[pkg( @@ -1139,6 +1546,7 @@ mod tests { vec![ClippyCheck { package: "alpha".into(), kind: ClippyCheckKind::Base, + deps_mode: ClippyDepsMode::NoDeps, target: None, env: Vec::new(), axconfig_override: None, @@ -1158,12 +1566,13 @@ mod tests { assert_eq!(checks.len(), 3); assert_eq!( checks[0].cargo_args(), - vec!["clippy", "-p", "alpha", "--", "-D", "warnings"] + vec!["clippy", "--no-deps", "-p", "alpha", "--", "-D", "warnings"] ); assert_eq!( checks[1].cargo_args(), vec![ "clippy", + "--no-deps", "-p", "alpha", "--no-default-features", @@ -1178,6 +1587,7 @@ mod tests { checks[2].cargo_args(), vec![ "clippy", + "--no-deps", "-p", "alpha", "--no-default-features", @@ -1204,6 +1614,7 @@ mod tests { checks[0].cargo_args(), vec![ "clippy", + "--no-deps", "-p", "alpha", "--target", @@ -1217,6 +1628,7 @@ mod tests { checks[1].cargo_args(), vec![ "clippy", + "--no-deps", "-p", "alpha", "--no-default-features", @@ -1254,6 +1666,7 @@ mod tests { checks[0].cargo_args(), vec![ "clippy", + "--no-deps", "-p", "alpha", "--target", @@ -1319,7 +1732,7 @@ mod tests { assert!(docs_rs_targets(&package).is_empty()); assert_eq!( expand(&[package])[0].cargo_args(), - vec!["clippy", "-p", "alpha", "--", "-D", "warnings"] + vec!["clippy", "--no-deps", "-p", "alpha", "--", "-D", "warnings"] ); } @@ -1335,12 +1748,19 @@ mod tests { ), ]; + let packages = packages + .into_iter() + .map(|package| SelectedClippyPackage { + package, + deps_mode: ClippyDepsMode::NoDeps, + }) + .collect(); let filtered = skip_unsupported_packages(packages); assert_eq!( filtered .iter() - .map(|package| package.name.as_str()) + .map(|package| package.package.name.as_str()) .collect::>(), vec!["alpha"] ); @@ -1442,7 +1862,14 @@ mod tests { .cloned() .expect("arceos-rust package should be in workspace metadata"); - let checks = expand_clippy_checks(&[package], &metadata).unwrap(); + let checks = expand_clippy_checks( + &[SelectedClippyPackage { + package, + deps_mode: ClippyDepsMode::NoDeps, + }], + &metadata, + ) + .unwrap(); assert!( checks[0].env.iter().any(|(key, value)| { @@ -1463,6 +1890,7 @@ mod tests { ClippyCheck { package: "alpha".into(), kind: ClippyCheckKind::Base, + deps_mode: ClippyDepsMode::NoDeps, target: None, env: Vec::new(), axconfig_override: None, @@ -1470,6 +1898,7 @@ mod tests { ClippyCheck { package: "alpha".into(), kind: ClippyCheckKind::Feature("feat-a".into()), + deps_mode: ClippyDepsMode::NoDeps, target: None, env: Vec::new(), axconfig_override: None, @@ -1477,6 +1906,7 @@ mod tests { ClippyCheck { package: "beta".into(), kind: ClippyCheckKind::Base, + deps_mode: ClippyDepsMode::NoDeps, target: None, env: Vec::new(), axconfig_override: None, @@ -1525,4 +1955,12 @@ mod tests { assert_eq!(report.failed_packages(), vec!["alpha"]); assert_eq!(report.passed_packages(), vec!["beta"]); } + + #[test] + fn elapsed_format_uses_largest_needed_units() { + assert_eq!(format_elapsed(Duration::from_millis(250)), "250ms"); + assert_eq!(format_elapsed(Duration::from_secs(42)), "42s"); + assert_eq!(format_elapsed(Duration::from_secs(125)), "2m 5s"); + assert_eq!(format_elapsed(Duration::from_secs(3661)), "1h 1m 1s"); + } } diff --git a/scripts/axbuild/src/support/git.rs b/scripts/axbuild/src/support/git.rs index 6c462e6b8b..ae69290431 100644 --- a/scripts/axbuild/src/support/git.rs +++ b/scripts/axbuild/src/support/git.rs @@ -15,8 +15,13 @@ const WORKSPACE_DEPENDENCIES_TABLE: &str = "dependencies"; #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum IncrementalPackageSelection { - Packages(Vec), - Full { reason: String }, + Packages { + changed: Vec, + affected: Vec, + }, + Full { + reason: String, + }, } pub(crate) fn select_incremental_packages( @@ -57,7 +62,14 @@ pub(crate) fn changed_paths_since( ) -> anyhow::Result> { ensure_git_work_tree(workspace_root)?; - let range = format!("{since}..HEAD"); + let diff_base = resolve_since_diff_base(workspace_root, since)?; + + // Three-dot `...HEAD` diffs against the merge-base, so it captures + // only what this branch changed since it forked from `base`. Two-dot would + // also surface commits made on the base side after the fork point, which + // over-selects packages and can spuriously trip the global-input full + // fallback (e.g. a toolchain bump that landed on the base branch). + let range = format!("{diff_base}...HEAD"); let output = Command::new("git") .args(git_safe_directory_args(workspace_root)) .arg("-C") @@ -86,6 +98,111 @@ pub(crate) fn changed_paths_since( .collect()) } +fn resolve_since_diff_base(workspace_root: &Path, since: &str) -> anyhow::Result { + if since.is_empty() || since == "0000000000000000000000000000000000000000" { + bail!("since ref is empty or zero"); + } + + let since_commit = git_commit_for_ref(workspace_root, since) + .with_context(|| format!("failed to resolve `{since}` to a commit"))?; + if git_ref_is_ancestor_of_head(workspace_root, &since_commit)? { + println!("using input ref `{since}` (`{since_commit}`) as incremental diff base"); + return Ok(since_commit); + } + + let merge_base = git_merge_base_with_head(workspace_root, &since_commit) + .with_context(|| format!("failed to find merge-base between `{since}` and HEAD"))?; + println!( + "input ref `{since}` (`{since_commit}`) is not an ancestor of HEAD; using merge-base \ + `{merge_base}` as incremental diff base" + ); + Ok(merge_base) +} + +fn git_commit_for_ref(workspace_root: &Path, git_ref: &str) -> anyhow::Result { + let commit_ref = format!("{git_ref}^{{commit}}"); + let output = Command::new("git") + .args(git_safe_directory_args(workspace_root)) + .arg("-C") + .arg(workspace_root) + .args(["rev-parse", "--verify", commit_ref.as_str()]) + .output() + .with_context(|| format!("failed to resolve `{git_ref}` to a commit"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + bail!( + "git rev-parse exited with status {}{}", + output.status, + if stderr.is_empty() { + String::new() + } else { + format!(": {stderr}") + } + ); + } + + let commit = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if commit.is_empty() { + bail!("git rev-parse returned an empty commit for `{git_ref}`"); + } + Ok(commit) +} + +fn git_ref_is_ancestor_of_head(workspace_root: &Path, git_ref: &str) -> anyhow::Result { + let output = Command::new("git") + .args(git_safe_directory_args(workspace_root)) + .arg("-C") + .arg(workspace_root) + .args(["merge-base", "--is-ancestor", git_ref, "HEAD"]) + .output() + .with_context(|| format!("failed to check whether `{git_ref}` is an ancestor of HEAD"))?; + + match output.status.code() { + Some(0) => Ok(true), + Some(1) => Ok(false), + _ => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + bail!( + "git merge-base --is-ancestor exited with status {}{}", + output.status, + if stderr.is_empty() { + String::new() + } else { + format!(": {stderr}") + } + ) + } + } +} + +fn git_merge_base_with_head(workspace_root: &Path, git_ref: &str) -> anyhow::Result { + let output = Command::new("git") + .args(git_safe_directory_args(workspace_root)) + .arg("-C") + .arg(workspace_root) + .args(["merge-base", git_ref, "HEAD"]) + .output() + .with_context(|| format!("failed to run git merge-base for `{git_ref}` and HEAD"))?; + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + bail!( + "git merge-base exited with status {}{}", + output.status, + if stderr.is_empty() { + String::new() + } else { + format!(": {stderr}") + } + ); + } + + let merge_base = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if merge_base.is_empty() { + bail!("git merge-base returned an empty base for `{git_ref}` and HEAD"); + } + Ok(merge_base) +} + fn ensure_git_work_tree(workspace_root: &Path) -> anyhow::Result<()> { let output = Command::new("git") .args(git_safe_directory_args(workspace_root)) @@ -171,7 +288,11 @@ where }; let affected = affected_workspace_packages(metadata, workspace_packages, &changed_packages); - Ok(IncrementalPackageSelection::Packages(affected)) + + Ok(IncrementalPackageSelection::Packages { + changed: changed_packages.into_iter().collect(), + affected: affected.into_iter().collect(), + }) } enum ChangedPackages { @@ -451,9 +572,9 @@ fn affected_workspace_packages( metadata: &Metadata, workspace_packages: &[Package], changed_packages: &BTreeSet, -) -> Vec { +) -> BTreeSet { if changed_packages.is_empty() { - return Vec::new(); + return BTreeSet::new(); } let workspace_members: BTreeSet<_> = workspace_packages @@ -508,6 +629,103 @@ fn affected_workspace_packages( .collect() } +pub(crate) fn top_level_affected_workspace_packages( + metadata: &Metadata, + workspace_packages: &[Package], + affected: &BTreeSet, +) -> Vec { + if affected.is_empty() { + return Vec::new(); + } + + let workspace_members = workspace_packages + .iter() + .map(|package| package.id.clone()) + .collect::>(); + let id_to_name = workspace_packages + .iter() + .map(|package| (package.id.clone(), package.name.to_string())) + .collect::>(); + let name_to_id = id_to_name + .iter() + .map(|(id, name)| (name.clone(), id.clone())) + .collect::>(); + let affected_ids = affected + .iter() + .filter_map(|name| name_to_id.get(name).cloned()) + .collect::>(); + + let Some(resolve) = &metadata.resolve else { + return affected.iter().cloned().collect(); + }; + + // Forward dependency edges restricted to the affected set, plus the affected + // crates that some other affected crate depends on. + let mut affected_deps = BTreeMap::>::new(); + let mut depended_on_by_affected = BTreeSet::new(); + for node in &resolve.nodes { + if !workspace_members.contains(&node.id) || !affected_ids.contains(&node.id) { + continue; + } + let deps = node + .deps + .iter() + .map(|dep| dep.pkg.clone()) + .filter(|pkg| affected_ids.contains(pkg)) + .collect::>(); + for pkg in &deps { + depended_on_by_affected.insert(pkg.clone()); + } + affected_deps.insert(node.id.clone(), deps); + } + + // Maximal crates (nothing in `affected` depends on them) cover the whole + // affected set via their with-deps run — as long as the graph is a DAG. A + // dependency cycle (only reachable through dev-dependencies) makes every + // member "depended on", so a cycle sitting at the top would be dropped from + // the frontier and silently left unlinted. Guarantee coverage instead: walk + // the forward closure of the roots and promote any still-uncovered crate to + // a root until every affected crate is reachable. + let mut roots = affected_ids + .difference(&depended_on_by_affected) + .cloned() + .collect::>(); + let mut covered = BTreeSet::new(); + for root in &roots { + extend_coverage(&affected_deps, root, &mut covered); + } + for id in &affected_ids { + if !covered.contains(id) { + roots.push(id.clone()); + extend_coverage(&affected_deps, id, &mut covered); + } + } + + roots.sort(); + roots + .into_iter() + .filter_map(|id| id_to_name.get(&id).cloned()) + .collect() +} + +/// Mark `start` and every affected crate reachable from it (via the restricted +/// `affected_deps` edges) as covered. Cycle-safe: the `covered` set doubles as +/// the visited set. +fn extend_coverage( + affected_deps: &BTreeMap>, + start: &PackageId, + covered: &mut BTreeSet, +) { + let mut stack = vec![start.clone()]; + while let Some(id) = stack.pop() { + if covered.insert(id.clone()) + && let Some(deps) = affected_deps.get(&id) + { + stack.extend(deps.iter().cloned()); + } + } +} + #[cfg(test)] mod tests { use std::{collections::HashMap, path::Path}; @@ -628,6 +846,107 @@ mod tests { (root, metadata, workspace_packages) } + fn run_git(root: &Path, args: &[&str]) { + let output = Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + } + + fn git_stdout(root: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .arg("-C") + .arg(root) + .args(args) + .output() + .unwrap(); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).unwrap().trim().to_string() + } + + #[test] + fn since_tag_resolves_to_commit() { + let root = tempfile::tempdir().unwrap(); + run_git(root.path(), &["init"]); + run_git(root.path(), &["config", "user.email", "test@example.com"]); + run_git(root.path(), &["config", "user.name", "Test User"]); + std::fs::write(root.path().join("file.txt"), "base\n").unwrap(); + run_git(root.path(), &["add", "file.txt"]); + run_git(root.path(), &["commit", "-m", "base"]); + let base = git_stdout(root.path(), &["rev-parse", "HEAD"]); + run_git(root.path(), &["tag", "base-tag"]); + + std::fs::write(root.path().join("file.txt"), "head\n").unwrap(); + run_git(root.path(), &["commit", "-am", "head"]); + + assert_eq!( + resolve_since_diff_base(root.path(), "base-tag").unwrap(), + base + ); + } + + #[test] + fn since_ref_that_is_not_head_ancestor_resolves_to_merge_base() { + let root = tempfile::tempdir().unwrap(); + run_git(root.path(), &["init"]); + run_git(root.path(), &["config", "user.email", "test@example.com"]); + run_git(root.path(), &["config", "user.name", "Test User"]); + std::fs::write(root.path().join("file.txt"), "base\n").unwrap(); + run_git(root.path(), &["add", "file.txt"]); + run_git(root.path(), &["commit", "-m", "base"]); + let merge_base = git_stdout(root.path(), &["rev-parse", "HEAD"]); + + run_git(root.path(), &["checkout", "-b", "feature"]); + std::fs::write(root.path().join("feature.txt"), "feature\n").unwrap(); + run_git(root.path(), &["add", "feature.txt"]); + run_git(root.path(), &["commit", "-m", "feature"]); + + run_git(root.path(), &["checkout", "-b", "main", &merge_base]); + std::fs::write(root.path().join("main.txt"), "main\n").unwrap(); + run_git(root.path(), &["add", "main.txt"]); + run_git(root.path(), &["commit", "-m", "main"]); + + run_git(root.path(), &["checkout", "feature"]); + + assert_eq!( + resolve_since_diff_base(root.path(), "main").unwrap(), + merge_base + ); + } + + #[test] + fn changed_top_level_crate_affected_set_is_only_itself() { + let (root, metadata, workspace_packages) = test_workspace(); + let selected = select_incremental_packages_for_paths( + root.path(), + &metadata, + &workspace_packages, + [PathBuf::from("crates/gamma/src/lib.rs")], + ) + .unwrap(); + + assert_eq!( + selected, + IncrementalPackageSelection::Packages { + changed: vec!["gamma".into()], + affected: vec!["gamma".into()], + } + ); + } + #[test] fn changed_crate_selects_reverse_dependencies() { let (root, metadata, workspace_packages) = test_workspace(); @@ -641,11 +960,10 @@ mod tests { assert_eq!( selected, - IncrementalPackageSelection::Packages(vec![ - "alpha".into(), - "beta".into(), - "gamma".into() - ]) + IncrementalPackageSelection::Packages { + changed: vec!["alpha".into()], + affected: vec!["alpha".into(), "beta".into(), "gamma".into()], + } ); } @@ -662,10 +980,64 @@ mod tests { assert_eq!( selected, - IncrementalPackageSelection::Packages(vec!["beta".into(), "gamma".into()]) + IncrementalPackageSelection::Packages { + changed: vec!["beta".into()], + affected: vec!["beta".into(), "gamma".into()], + } ); } + #[test] + fn top_level_frontier_covers_a_dependency_cycle_at_the_top() { + // `a` and `b` form a cycle (only reachable through dev-dependencies) and + // sit at the top of the affected set. The bare "maximal element" rule + // drops both; the coverage guarantee must still promote one as a root so + // the whole cycle is linted with-deps. + let root = tempfile::tempdir().unwrap(); + let ru = root.path().display().to_string(); + let a = format!("a 0.1.0 (path+file://{ru}/crates/a)"); + let b = format!("b 0.1.0 (path+file://{ru}/crates/b)"); + let leaf = format!("leaf 0.1.0 (path+file://{ru}/crates/leaf)"); + let dep = |name: &str, pkg: &str| { + serde_json::json!({ + "name": name, + "pkg": pkg, + "dep_kinds": [{ "kind": null, "target": null }] + }) + }; + let value = serde_json::json!({ + "packages": [ + package(root.path(), "a", &["b", "leaf"]), + package(root.path(), "b", &["a", "leaf"]), + package(root.path(), "leaf", &[]), + ], + "workspace_members": [a, b, leaf], + "workspace_default_members": [a, b, leaf], + "resolve": { + "nodes": [ + { "id": a, "dependencies": [b, leaf], "deps": [dep("b", &b), dep("leaf", &leaf)], "features": [] }, + { "id": b, "dependencies": [a, leaf], "deps": [dep("a", &a), dep("leaf", &leaf)], "features": [] }, + { "id": leaf, "dependencies": [], "deps": [], "features": [] }, + ], + "root": null + }, + "target_directory": root.path().join("target"), + "version": 1, + "workspace_root": root.path(), + "metadata": null, + }); + let metadata: Metadata = serde_json::from_value(value).unwrap(); + let packages = metadata.packages.clone(); + + let affected = BTreeSet::from(["a".to_string(), "b".to_string(), "leaf".to_string()]); + let frontier = top_level_affected_workspace_packages(&metadata, &packages, &affected); + + // One cycle representative is promoted; its with-deps run covers the whole + // cycle plus `leaf`. The bare maximal-element rule would return an empty + // frontier and silently skip `a`/`b`. + assert_eq!(frontier, vec!["a".to_string()]); + } + #[test] fn no_changes_selects_no_packages() { let (root, metadata, workspace_packages) = test_workspace(); @@ -677,7 +1049,13 @@ mod tests { ) .unwrap(); - assert_eq!(selected, IncrementalPackageSelection::Packages(Vec::new())); + assert_eq!( + selected, + IncrementalPackageSelection::Packages { + changed: Vec::new(), + affected: Vec::new(), + } + ); } #[test] @@ -716,7 +1094,10 @@ mod tests { assert_eq!( selected, - IncrementalPackageSelection::Packages(vec!["beta".into(), "gamma".into()]) + IncrementalPackageSelection::Packages { + changed: vec!["beta".into()], + affected: vec!["beta".into(), "gamma".into()], + } ); } @@ -783,7 +1164,10 @@ mod tests { assert_eq!( selected, - IncrementalPackageSelection::Packages(vec!["beta".into(), "gamma".into()]) + IncrementalPackageSelection::Packages { + changed: vec!["beta".into()], + affected: vec!["beta".into(), "gamma".into()], + } ); } @@ -863,7 +1247,13 @@ mod tests { ) .unwrap(); - assert_eq!(selected, IncrementalPackageSelection::Packages(Vec::new())); + assert_eq!( + selected, + IncrementalPackageSelection::Packages { + changed: Vec::new(), + affected: Vec::new(), + } + ); } #[test] @@ -882,7 +1272,10 @@ mod tests { assert_eq!( selected, - IncrementalPackageSelection::Packages(vec!["beta".into(), "gamma".into()]) + IncrementalPackageSelection::Packages { + changed: vec!["beta".into()], + affected: vec!["beta".into(), "gamma".into()], + } ); } }