Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions crates/pixi_cli/src/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,9 @@ pub struct Args {
#[arg(long)]
pub skip_with_deps: Option<Vec<String>>,

/// 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<String>,
pub only: Option<Vec<String>>,
}

const SKIP_CUTOFF: usize = 5;
Expand Down Expand Up @@ -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(
Expand All @@ -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!(
Expand All @@ -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();
}

Expand Down
8 changes: 4 additions & 4 deletions crates/pixi_core/src/environment/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,8 +478,8 @@ pub struct InstallFilter {
pub skip_direct: Vec<String>,
/// Packages to skip together with their dependencies (hard stop)
pub skip_with_deps: Vec<String>,
/// Target a single package (and its deps) to install
pub target_package: Option<String>,
/// Target one or more packages (and their deps) to install; empty means no targeting
pub target_packages: Vec<String>,
}

impl InstallFilter {
Expand All @@ -497,8 +497,8 @@ impl InstallFilter {
self
}

pub fn target_package(mut self, package: Option<String>) -> Self {
self.target_package = package;
pub fn target_packages(mut self, packages: impl Into<Vec<String>>) -> Self {
self.target_packages = packages.into();
self
}
}
Expand Down
195 changes: 147 additions & 48 deletions crates/pixi_core/src/lock_file/install_subset.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use itertools::Itertools;
use miette::Diagnostic;
use rattler_lock::LockedPackageRef;
use std::collections::{HashMap, HashSet, VecDeque};

Expand Down Expand Up @@ -83,7 +85,7 @@ impl<'a> From<LockedPackageRef<'a>> 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.
///
Expand All @@ -101,21 +103,28 @@ 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<String>),
}

impl<'a> InstallSubset<'a> {
/// Create a new package filter.
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,
}
}

Expand All @@ -124,54 +133,68 @@ 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
/// nodes in `skip_direct` from the final result.
pub fn filter<'lock>(
&self,
packages: Option<impl IntoIterator<Item = LockedPackageRef<'lock>> + 'lock>,
) -> FilteredPackages<'lock> {
) -> Result<FilteredPackages<'lock>, 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
Expand Down Expand Up @@ -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
Expand All @@ -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<String> {
Expand All @@ -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();
}

Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
8 changes: 4 additions & 4 deletions crates/pixi_core/src/lock_file/update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion docs/reference/cli/pixi/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<br>May be provided more than once.
- <a id="arg---only" href="#arg---only">`--only <ONLY>`</a>
: Install and build only this package and its dependencies
: Install and build only these package(s) and their dependencies. Can be passed multiple times
<br>May be provided more than once.

## Config Options
- <a id="arg---auth-file" href="#arg---auth-file">`--auth-file <AUTH_FILE>`</a>
Expand Down
Loading
Loading