diff --git a/crates/pixi_cli/src/install.rs b/crates/pixi_cli/src/install.rs index 52f1e7ebce..436e062a0f 100644 --- a/crates/pixi_cli/src/install.rs +++ b/crates/pixi_cli/src/install.rs @@ -57,9 +57,9 @@ pub struct Args { #[arg(long)] pub skip_with_deps: Option>, - /// Install and build only this package and its dependencies + /// Install and build only these package(s) and their dependencies. Can be passed multiple times. #[arg(long)] - pub only: Option, + pub only: Option>, } const SKIP_CUTOFF: usize = 5; @@ -97,7 +97,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { let filter = InstallFilter::new() .skip_direct(args.skip.clone().unwrap_or_default()) .skip_with_deps(args.skip_with_deps.clone().unwrap_or_default()) - .target_package(args.only.clone()); + .target_packages(args.only.clone().unwrap_or_default()); // Update the prefixes by installing all packages let (lock_file, _) = get_update_lock_file_and_prefixes( @@ -121,7 +121,9 @@ pub async fn execute(args: Args) -> miette::Result<()> { // Message what's installed let mut message = console::style(console::Emoji("✔ ", "")).green().to_string(); - let skip_opts = args.skip.is_some() || args.skip_with_deps.is_some() || args.only.is_some(); + let skip_opts = args.skip.is_some() + || args.skip_with_deps.is_some() + || args.only.as_ref().is_some_and(|v| !v.is_empty()); if installed_envs.len() == 1 { write!( @@ -142,7 +144,7 @@ pub async fn execute(args: Args) -> miette::Result<()> { let num_retained = names.retained.len(); // When only is set, also print the number of packages that will be installed - if args.only.is_some() { + if args.only.as_ref().is_some_and(|v| !v.is_empty()) { write!(&mut message, ", including {} packages", num_retained).unwrap(); } diff --git a/crates/pixi_core/src/environment/mod.rs b/crates/pixi_core/src/environment/mod.rs index c8e36f53cc..a2be6bf6df 100644 --- a/crates/pixi_core/src/environment/mod.rs +++ b/crates/pixi_core/src/environment/mod.rs @@ -478,8 +478,8 @@ pub struct InstallFilter { pub skip_direct: Vec, /// Packages to skip together with their dependencies (hard stop) pub skip_with_deps: Vec, - /// Target a single package (and its deps) to install - pub target_package: Option, + /// Target one or more packages (and their deps) to install; empty means no targeting + pub target_packages: Vec, } impl InstallFilter { @@ -497,8 +497,8 @@ impl InstallFilter { self } - pub fn target_package(mut self, package: Option) -> Self { - self.target_package = package; + pub fn target_packages(mut self, packages: impl Into>) -> Self { + self.target_packages = packages.into(); self } } diff --git a/crates/pixi_core/src/lock_file/install_subset.rs b/crates/pixi_core/src/lock_file/install_subset.rs index b76b0cad15..c4955f0b8c 100644 --- a/crates/pixi_core/src/lock_file/install_subset.rs +++ b/crates/pixi_core/src/lock_file/install_subset.rs @@ -1,3 +1,5 @@ +use itertools::Itertools; +use miette::Diagnostic; use rattler_lock::LockedPackageRef; use std::collections::{HashMap, HashSet, VecDeque}; @@ -83,7 +85,7 @@ impl<'a> From> for PackageNode { /// - `skip_direct` (passthrough): drop only the node; continue traversal through /// its dependencies so they can still be kept if reachable. /// -/// When `target_package` is set, the target acts as the sole root of traversal. +/// When `target_packages` are set, the targets act as the roots of traversal. /// The result returns both the packages to install/process and to ignore, /// preserving the original package order. /// @@ -101,8 +103,15 @@ pub struct InstallSubset<'a> { skip_with_deps: &'a [String], /// Packages to skip directly but traverse through (passthrough) skip_direct: &'a [String], - /// What package should be targeted directly (zooming in) - target_package: Option<&'a str>, + /// Which packages should be targeted directly (zooming in); empty means no targeting + target_packages: &'a [String], +} + +#[derive(thiserror::Error, Debug, Diagnostic)] +pub enum InstallSubsetError { + #[error("the following `--only` packages do not exist: {}", .0.iter().map(|s| format!("'{}'", s)).join(", "))] + #[diagnostic(help("try finding the correct package with `pixi list`"))] + TargetPackagesDoNotExist(Vec), } impl<'a> InstallSubset<'a> { @@ -110,12 +119,12 @@ impl<'a> InstallSubset<'a> { pub fn new( skip_with_deps: &'a [String], skip_direct: &'a [String], - target_package: Option<&'a str>, + target_packages: &'a [String], ) -> Self { Self { skip_with_deps, skip_direct, - target_package, + target_packages, } } @@ -124,7 +133,7 @@ impl<'a> InstallSubset<'a> { /// Both traversals run in O(V+E) time on the constructed graph. /// Algorithm overview: /// - Convert the input packages to a compact graph representation. - /// - If a `target_package` is provided: run a BFS starting at that target, + /// - If `target_packages` are provided: run a BFS starting at those targets, /// short-circuiting at `skip_with_deps` and not including nodes in `skip_direct`. /// - Else (skip-mode): find original graph roots (indegree 0) and run a BFS /// from those roots, again not traversing into `skip_with_deps`, and exclude @@ -132,46 +141,60 @@ impl<'a> InstallSubset<'a> { pub fn filter<'lock>( &self, packages: Option> + 'lock>, - ) -> FilteredPackages<'lock> { + ) -> Result, InstallSubsetError> { // Handle None packages let Some(packages) = packages else { - return FilteredPackages::new(Vec::new(), Vec::new()); + return Ok(FilteredPackages::new(Vec::new(), Vec::new())); }; let all_packages: Vec<_> = packages.into_iter().collect(); - let filtered_packages = match self.target_package { - Some(target) => { - // Target mode: Collect target + dependencies with skip short-circuiting - let reach = Self::build_reachability(&all_packages); - let required = reach.collect_target_dependencies( - target, - // This is the stop set, because we just short-circuit getting dependencies - self.skip_with_deps, - // This is the pasthrough set, because we are basically edge-joining - self.skip_direct, - ); - - // Map what we get back - let to_process: Vec<_> = all_packages - .iter() - .filter(|pkg| required.contains(pkg.name())) - .copied() - .collect(); - let to_ignore: Vec<_> = all_packages - .iter() - .filter(|pkg| !required.contains(pkg.name())) - .copied() - .collect(); - FilteredPackages::new(to_process, to_ignore) - } - None => { - // Skip mode: Apply stop/passthrough rules from original roots - self.filter_with_skips(&all_packages) + // Check if any packages do not match + let mut non_matched_targets: HashSet<_> = + self.target_packages.iter().map(AsRef::as_ref).collect(); + for package in &all_packages { + if non_matched_targets.contains(package.name()) { + non_matched_targets.remove(package.name()); } + } + if !non_matched_targets.is_empty() { + return Err(InstallSubsetError::TargetPackagesDoNotExist( + non_matched_targets + .iter() + .map(ToString::to_string) + .collect_vec(), + )); + } + + let filtered_packages = if !self.target_packages.is_empty() { + // Target mode: Collect targets + dependencies with skip short-circuiting + let reach = Self::build_reachability(&all_packages); + let required = reach.collect_targets_dependencies( + self.target_packages, + // This is the stop set, because we just short-circuit getting dependencies + self.skip_with_deps, + // This is the passthrough set, because we are basically edge-joining + self.skip_direct, + ); + + // Map what we get back + let to_process: Vec<_> = all_packages + .iter() + .filter(|pkg| required.contains(pkg.name())) + .copied() + .collect(); + let to_ignore: Vec<_> = all_packages + .iter() + .filter(|pkg| !required.contains(pkg.name())) + .copied() + .collect(); + FilteredPackages::new(to_process, to_ignore) + } else { + // Skip mode: Apply stop/passthrough rules from original roots + self.filter_with_skips(&all_packages) }; - filtered_packages + Ok(filtered_packages) } /// Filter out skip packages and only those dependencies that are no longer @@ -291,8 +314,8 @@ impl PackageReachability { } } - /// Collect target package and all its dependencies, excluding specified packages. - /// Collect all packages reachable from `target` under skip rules. + /// Collect target package(s) and all their dependencies, excluding specified packages. + /// Collect all packages reachable from `targets` under skip rules. /// /// Semantics: /// - `stop_set` (skip-with-deps): do not include the node and do not traverse @@ -302,9 +325,9 @@ impl PackageReachability { /// /// Implementation details: /// - Uses index-based BFS over `edges` with boolean bitsets for membership tests - pub(crate) fn collect_target_dependencies( + pub(crate) fn collect_targets_dependencies( &self, - target: &str, + targets: &[String], stop_set: &[String], passthrough_set: &[String], ) -> HashSet { @@ -323,15 +346,18 @@ impl PackageReachability { } } - // BFS over target's dependency tree with exclusions + // BFS over targets' dependency trees with exclusions let mut included = vec![false; self.nodes.len()]; let mut seen = vec![false; self.nodes.len()]; let mut queue = VecDeque::new(); - // Start from the target if it exists; otherwise nothing is required. - if let Some(&start) = self.name_to_index.get(target) { - queue.push_back(start); - } else { + // Start from all provided targets that exist; if none exist, nothing is required. + for target in targets { + if let Some(&start) = self.name_to_index.get(target) { + queue.push_back(start); + } + } + if queue.is_empty() { return HashSet::new(); } @@ -517,7 +543,7 @@ mod tests { node("D", &["C"]), ]; let dc = PackageReachability::new(nodes); - let required = dc.collect_target_dependencies("A", &["B".to_string()], &[]); + let required = dc.collect_targets_dependencies(&["A".to_string()], &["B".to_string()], &[]); assert!(required.contains("A")); assert!(!required.contains("B")); @@ -546,12 +572,75 @@ mod tests { node("D", &["C"]), ]; let dc = PackageReachability::new(nodes); - let required = dc.collect_target_dependencies("A", &[], &["B".to_string()]); + let required = dc.collect_targets_dependencies(&["A".to_string()], &[], &["B".to_string()]); assert!(required.contains("A")); assert!(!required.contains("B")); assert!(required.contains("C")); } + #[test] + fn multiple_targets_union_dependencies() { + // Graph: A -> X, B -> Y, X -> Z, Y -> Z + // Targets: A and B should include A, B, X, Y, Z + let nodes = vec![ + node("A", &["X"]), + node("B", &["Y"]), + node("X", &["Z"]), + node("Y", &["Z"]), + node("Z", &[]), + ]; + let dc = PackageReachability::new(nodes); + let required = + dc.collect_targets_dependencies(&["A".to_string(), "B".to_string()], &[], &[]); + for n in ["A", "B", "X", "Y", "Z"] { + assert!(required.contains(n), "expected to contain {}", n); + } + } + + #[test] + fn multiple_targets_respect_passthrough_skips() { + // A -> B, C -> D; skip_direct = B + // Targets A, C => include A, C, D; exclude B + let nodes = vec![ + node("A", &["B"]), + node("B", &[]), + node("C", &["D"]), + node("D", &[]), + ]; + let dc = PackageReachability::new(nodes); + let required = dc.collect_targets_dependencies( + &["A".to_string(), "C".to_string()], + &[], + &["B".to_string()], + ); + assert!(required.contains("A")); + assert!(required.contains("C")); + assert!(required.contains("D")); + assert!(!required.contains("B")); + } + + #[test] + fn multiple_targets_respect_stop_skips() { + // A -> B, C -> D; skip_with_deps = B + // Targets A, C => include A (but not B), include C and D + let nodes = vec![ + node("A", &["B"]), + node("B", &[]), + node("C", &["D"]), + node("D", &[]), + ]; + let dc = PackageReachability::new(nodes); + let required = dc.collect_targets_dependencies( + &["A".to_string(), "C".to_string()], + &["B".to_string()], + &[], + ); + assert!(required.contains("A")); + assert!(required.contains("C")); + assert!(required.contains("D")); + assert!(!required.contains("B")); + } + #[test] fn diamond_graph_retains_shared_dep() { // A -> B, A -> C, B -> D, C -> D @@ -568,6 +657,16 @@ mod tests { ); } + #[test] + fn skipped_is_same_as_target() { + // A -> B, A -> C, we both target and skip A, in our current implementation the skipped takes precedence over only + let nodes = vec![node("A", &["B", "C"]), node("B", &[]), node("C", &[])]; + + let dc = PackageReachability::new(nodes); + let kept = dc.collect_targets_dependencies(&["A".to_string()], &["A".to_string()], &[]); + assert!(kept.is_empty()); + } + #[test] fn with_deps_overrides_direct_when_both_present() { // A -> B -> C; if B in both stop and passthrough, stop wins and C diff --git a/crates/pixi_core/src/lock_file/update.rs b/crates/pixi_core/src/lock_file/update.rs index 7ea9239762..a0f0779a90 100644 --- a/crates/pixi_core/src/lock_file/update.rs +++ b/crates/pixi_core/src/lock_file/update.rs @@ -457,9 +457,9 @@ impl<'p> LockFileDerivedData<'p> { let subset = InstallSubset::new( &filter.skip_with_deps, &filter.skip_direct, - filter.target_package.as_deref(), + &filter.target_packages, ); - let result = subset.filter(locked_env.packages(platform)); + let result = subset.filter(locked_env.packages(platform))?; let packages = result.install; let ignored = result.ignore; @@ -690,9 +690,9 @@ impl<'p> LockFileDerivedData<'p> { let subset = InstallSubset::new( &filter.skip_with_deps, &filter.skip_direct, - filter.target_package.as_deref(), + &filter.target_packages, ); - let filtered = subset.filter(locked_env.packages(platform)); + let filtered = subset.filter(locked_env.packages(platform))?; // Map to names, dedupe and sort for stable output. let retained = filtered diff --git a/docs/reference/cli/pixi/install.md b/docs/reference/cli/pixi/install.md index ae912da8b1..6a8b197577 100644 --- a/docs/reference/cli/pixi/install.md +++ b/docs/reference/cli/pixi/install.md @@ -24,7 +24,8 @@ pixi install [OPTIONS] : Skip a package and its entire dependency subtree. This performs a hard exclusion: the package and its dependencies are not installed unless reachable from another non-skipped root
May be provided more than once. - `--only ` -: Install and build only this package and its dependencies +: Install and build only these package(s) and their dependencies. Can be passed multiple times +
May be provided more than once. ## Config Options - `--auth-file ` diff --git a/tests/integration_rust/common/builders.rs b/tests/integration_rust/common/builders.rs index 52732eb10a..d0c0721542 100644 --- a/tests/integration_rust/common/builders.rs +++ b/tests/integration_rust/common/builders.rs @@ -469,8 +469,8 @@ impl InstallBuilder { self.args.skip_with_deps = Some(names); self } - pub fn with_only_package(mut self, pkg: impl Into) -> Self { - self.args.only = Some(pkg.into()); + pub fn with_only_package(mut self, pkg: Vec) -> Self { + self.args.only = Some(pkg); self } } diff --git a/tests/integration_rust/install_filter_tests.rs b/tests/integration_rust/install_filter_tests.rs index 91c84700f4..ad6d988665 100644 --- a/tests/integration_rust/install_filter_tests.rs +++ b/tests/integration_rust/install_filter_tests.rs @@ -132,7 +132,7 @@ async fn install_filter_target_package_zoom_in() { .update_lock_file(UpdateLockFileOptions::default()) .await .unwrap(); - let filter = InstallFilter::new().target_package(Some("a".to_string())); + let filter = InstallFilter::new().target_packages(vec!["a".to_string()]); let skipped = derived .get_filtered_package_names(&env, &filter) .unwrap() @@ -154,7 +154,7 @@ async fn install_filter_target_with_skip_with_deps_stop() { .await .unwrap(); let filter = InstallFilter::new() - .target_package(Some("a".to_string())) + .target_packages(vec!["a".to_string()]) .skip_with_deps(vec!["c".to_string()]); let skipped = derived .get_filtered_package_names(&env, &filter)