diff --git a/crates/pixi_cli/src/global/update.rs b/crates/pixi_cli/src/global/update.rs index 5f3d85fcbb..164c3c7cb0 100644 --- a/crates/pixi_cli/src/global/update.rs +++ b/crates/pixi_cli/src/global/update.rs @@ -3,9 +3,10 @@ use clap::Parser; use fancy_display::FancyDisplay; use pixi_config::{Config, ConfigCli}; use pixi_global::StateChanges; -use pixi_global::common::check_all_exposed; +use pixi_global::common::{EnvironmentUpdate, InstallChange, check_all_exposed}; use pixi_global::project::ExposedType; -use pixi_global::{EnvironmentName, Project}; +use pixi_global::{EnvironmentName, Project, StateChange}; +use serde::Serialize; /// Updates environments in the global environment. #[derive(Parser, Debug, Clone)] @@ -13,10 +14,109 @@ pub struct Args { /// Specifies the environments that are to be updated. environments: Option>, + /// Don't actually update any environment. + #[clap(short = 'n', long)] + pub dry_run: bool, + + /// Output the changes in JSON format. + #[clap(long)] + pub json: bool, + #[clap(flatten)] config: ConfigCli, } +/// JSON representation of a package change in a global environment update +#[derive(Serialize, Clone, Debug)] +pub struct JsonPackageChange { + name: String, + #[serde(skip_serializing_if = "Option::is_none")] + before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + after: Option, + change_type: String, +} + +/// JSON representation of environment changes during global update +#[derive(Serialize, Clone, Debug)] +pub struct JsonEnvironmentUpdate { + environment: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + package_changes: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + exposed_changes: Vec, + status: String, +} + +/// JSON output for global update command +#[derive(Serialize, Clone, Debug)] +pub struct GlobalUpdateJsonOutput { + version: usize, + #[serde(skip_serializing_if = "Vec::is_empty")] + environment_updates: Vec, +} + +/// Custom reporting for dry-run mode +fn report_dry_run_environment_update( + env_name: &EnvironmentName, + environment_update: &EnvironmentUpdate, +) { + if environment_update.is_empty() { + return; + } + + // Get the package changes + let changes = environment_update.changes(); + let env_dependencies = environment_update.current_packages(); + + // Separate top-level changes (similar to StateChanges::report_update_changes) + let mut top_level_changes: Vec<_> = changes + .iter() + .filter(|(package_name, change)| { + env_dependencies.contains(package_name) && !change.is_transitive() + }) + .collect(); + + top_level_changes.sort_by(|(name1, _), (name2, _)| name1.cmp(name2)); + + match top_level_changes.len().cmp(&1) { + std::cmp::Ordering::Equal => { + let (package, install_change) = top_level_changes[0]; + let changes = console::style(package.as_normalized()).green(); + let version_string = install_change + .version_fancy_display() + .map(|version| format!("={version}")) + .unwrap_or_default(); + + eprintln!( + "{}Would update package {}{} in environment {}.", + console::style(console::Emoji("✔ ", "")).green(), + changes, + version_string, + env_name.fancy_display() + ); + } + std::cmp::Ordering::Greater => { + eprintln!( + "{}Would update packages in environment {}:", + console::style(console::Emoji("✔ ", "")).green(), + env_name.fancy_display() + ); + for (package, install_change) in top_level_changes { + let package_fancy = console::style(package.as_normalized()).green(); + let change_fancy = install_change + .version_fancy_display() + .map(|version| format!(" {version}")) + .unwrap_or_default(); + eprintln!(" - {package_fancy}{change_fancy}"); + } + } + std::cmp::Ordering::Less => { + // No packages to update (len == 0) + } + } +} + pub async fn execute(args: Args) -> miette::Result<()> { let config = Config::with_cli_config(&args.config); let project_original = pixi_global::Project::discover_or_create() @@ -26,90 +126,246 @@ pub async fn execute(args: Args) -> miette::Result<()> { async fn apply_changes( env_name: &EnvironmentName, project: &mut Project, - ) -> miette::Result { + dry_run: bool, + json_output: bool, + ) -> miette::Result<(StateChanges, Option)> { let mut state_changes = StateChanges::default(); - // If the environment isn't up-to-date our executable detection afterwards will not work - let require_reinstall = if !project.environment_in_sync_internal(env_name, true).await? { - let environment_update = project.install_environment(env_name).await?; - state_changes.insert_change( - env_name, - pixi_global::StateChange::UpdatedEnvironment(environment_update), - ); - false - } else { - true - }; - // See what executables were installed prior to update - let env_binaries = project.executables_of_direct_dependencies(env_name).await?; + let should_check_for_updates = true; - // Get the exposed binaries from mapping - let exposed_mapping_binaries = &project - .environment(env_name) - .ok_or_else(|| miette::miette!("Environment {} not found", env_name.fancy_display()))? - .exposed; + let mut dry_run_environment_update = None; - // Check if they were all auto-exposed, or if the user manually exposed a subset of them - let expose_type = if check_all_exposed(&env_binaries, exposed_mapping_binaries) { - ExposedType::All + // Determine the expose type BEFORE any updates + let expose_type = if !dry_run && !json_output { + let environment = project.environment(env_name).ok_or_else(|| { + miette::miette!("Environment {} not found", env_name.fancy_display()) + })?; + + if let Ok(env_binaries) = project.executables_of_direct_dependencies(env_name).await { + if check_all_exposed(&env_binaries, &environment.exposed) { + Some(ExposedType::All) + } else { + // user manually configured, don't modify + None + } + } else if environment.exposed.is_empty() { + Some(ExposedType::All) + } else { + // has existing exposure config, don't modify + None + } } else { - ExposedType::Nothing + None }; - // Reinstall the environment - if require_reinstall { - let environment_update = project.install_environment(env_name).await?; + if should_check_for_updates { + if dry_run || json_output { + // dry-run mode: performs solving only + let environment_update = project.solve_for_dry_run(env_name).await?; - state_changes.insert_change( - env_name, - pixi_global::StateChange::UpdatedEnvironment(environment_update), - ); + // Only add to state changes if there are actual changes + if !environment_update.is_empty() { + dry_run_environment_update = Some(environment_update.clone()); + state_changes.insert_change( + env_name, + StateChange::UpdatedEnvironment(environment_update), + ); + } + } else { + // Normal mode: actually install + let environment_update = project.install_environment(env_name).await?; + state_changes.insert_change( + env_name, + StateChange::UpdatedEnvironment(environment_update), + ); + } } - // Sync executables exposed names with the manifest - project.sync_exposed_names(env_name, expose_type).await?; - // Expose or prune executables of the new environment - state_changes |= project - .expose_executables_from_environment(env_name) - .await?; + if !dry_run && !json_output { + // Always prune invalid/outdated mappings + project + .sync_exposed_names(env_name, ExposedType::Nothing) + .await?; + + if let Some(expose_type) = expose_type { + // When auto-exposing, add new binaries to the manifest + project.sync_exposed_names(env_name, expose_type).await?; + } - // Sync completions - state_changes |= project.sync_completions(env_name).await?; + // Expose or prune executables of the new environment (always) + state_changes |= project + .expose_executables_from_environment(env_name) + .await?; - Ok(state_changes) + // Sync completions (always) + state_changes |= project.sync_completions(env_name).await?; + } + + Ok((state_changes, dry_run_environment_update)) } // Update all environments if the user did not specify any let env_names = match args.environments { Some(env_names) => env_names, None => { - // prune old environments and completions - let state_changes = project_original.prune_old_environments().await?; - state_changes.report(); - #[cfg(unix)] - { - let completions_dir = pixi_global::completions::CompletionsDir::from_env().await?; - completions_dir.prune_old_completions()?; + if !args.dry_run { + // prune old environments and completions in non-dry-run mode + let state_changes = project_original.prune_old_environments().await?; + state_changes.report(); + #[cfg(unix)] + { + let completions_dir = + pixi_global::completions::CompletionsDir::from_env().await?; + completions_dir.prune_old_completions()?; + } } project_original.environments().keys().cloned().collect() } }; - // Apply changes to each environment, only revert changes if an error occurs + // Apply changes to each environment let mut last_updated_project = project_original; + let mut all_state_changes = Vec::new(); + let mut all_environment_updates = Vec::new(); for env_name in env_names { let mut project = last_updated_project.clone(); - match apply_changes(&env_name, &mut project).await { - Ok(state_changes) => state_changes.report(), + match apply_changes(&env_name, &mut project, args.dry_run, args.json).await { + Ok((state_changes, dry_run_env_update)) => { + // Collect changes for final summary or JSON output + all_state_changes.push((env_name.clone(), state_changes.clone())); + all_environment_updates.push((env_name.clone(), dry_run_env_update.clone())); + + // Report immediately if not in JSON mode + if !args.json { + if args.dry_run { + // custom messaging for dry-run mode + if state_changes.has_changed() { + eprintln!( + "{}Would update environment {}:", + console::style(console::Emoji("🔍 ", "")).yellow(), + env_name.fancy_display() + ); + if let Some(env_update) = dry_run_env_update { + report_dry_run_environment_update(&env_name, &env_update); + } + } + } else { + // Normal mode: use standard reporting + state_changes.report(); + } + } + } Err(err) => { - revert_environment_after_error(&env_name, &last_updated_project).await?; + if !args.dry_run && !args.json { + revert_environment_after_error(&env_name, &last_updated_project).await?; + } return Err(err); } } - last_updated_project = project; + + // update project state if not in dry-run mode and not in JSON mode + if !args.dry_run && !args.json { + last_updated_project = project; + } + } + + // Output final results + if args.json { + output_json_results(all_environment_updates)?; + } else if args.dry_run { + let total_changed = all_state_changes + .iter() + .filter(|(_, changes)| changes.has_changed()) + .count(); + + if total_changed == 0 { + eprintln!( + "{}No environments need updating.", + console::style(console::Emoji("✔ ", "")).green() + ); + } else { + eprintln!( + "{}Dry-run complete. {} environment(s) would be updated. No changes were made.", + console::style(console::Emoji("✔ ", "")).green(), + total_changed + ); + } + } + + if !args.dry_run && !args.json { + last_updated_project.manifest.save().await?; } - last_updated_project.manifest.save().await?; + + Ok(()) +} + +/// Convert environment updates to JSON output format +fn output_json_results( + all_environment_updates: Vec<(EnvironmentName, Option)>, +) -> miette::Result<()> { + let mut environment_updates = Vec::new(); + + for (env_name, env_update) in all_environment_updates { + let mut package_changes = Vec::new(); + let exposed_changes = Vec::new(); + let mut status = "unchanged".to_string(); + + if let Some(env_update) = env_update { + if !env_update.is_empty() { + status = "updated".to_string(); + + // Extract real package changes from EnvironmentUpdate + for (package_name, install_change) in env_update.changes() { + let (before, after, change_type) = match install_change { + InstallChange::Installed(version) => { + (None, Some(version.to_string()), "installed".to_string()) + } + InstallChange::Upgraded(old_version, new_version) => ( + Some(old_version.to_string()), + Some(new_version.to_string()), + "upgraded".to_string(), + ), + InstallChange::TransitiveUpgraded(old_version, new_version) => ( + Some(old_version.to_string()), + Some(new_version.to_string()), + "transitive_upgraded".to_string(), + ), + InstallChange::Reinstalled(old_version, new_version) => ( + Some(old_version.to_string()), + Some(new_version.to_string()), + "reinstalled".to_string(), + ), + InstallChange::Removed => (None, None, "removed".to_string()), + }; + + package_changes.push(JsonPackageChange { + name: package_name.as_normalized().to_string(), + before, + after, + change_type, + }); + } + } + } + + environment_updates.push(JsonEnvironmentUpdate { + environment: env_name.to_string(), + package_changes, + exposed_changes, + status, + }); + } + + let json_output = GlobalUpdateJsonOutput { + version: 1, + environment_updates, + }; + + let json_string = serde_json::to_string_pretty(&json_output) + .map_err(|e| miette::miette!("Failed to serialize JSON output: {}", e))?; + + println!("{}", json_string); Ok(()) } diff --git a/crates/pixi_global/src/common.rs b/crates/pixi_global/src/common.rs index c32e96f5bd..325fa86bbf 100644 --- a/crates/pixi_global/src/common.rs +++ b/crates/pixi_global/src/common.rs @@ -382,7 +382,7 @@ pub enum StateChange { } #[must_use] -#[derive(Debug, Default)] +#[derive(Debug, Default, Clone)] pub struct StateChanges { changes: HashMap>, } diff --git a/crates/pixi_global/src/project/manifest.rs b/crates/pixi_global/src/project/manifest.rs index 6a2c8dc285..576433c759 100644 --- a/crates/pixi_global/src/project/manifest.rs +++ b/crates/pixi_global/src/project/manifest.rs @@ -639,6 +639,7 @@ impl FromStr for Mapping { pub enum ExposedType { #[default] All, + #[allow(dead_code)] Nothing, Ignore(Vec), Mappings(Vec), diff --git a/crates/pixi_global/src/project/mod.rs b/crates/pixi_global/src/project/mod.rs index fabec55c99..2247f70f17 100644 --- a/crates/pixi_global/src/project/mod.rs +++ b/crates/pixi_global/src/project/mod.rs @@ -627,6 +627,168 @@ impl Project { Ok(EnvironmentUpdate::new(install_changes, dependencies_names)) } + /// Performs only the solving step to determine what would change in dry-run mode + /// This extracts the solving logic from install_environment without the installation + pub async fn solve_for_dry_run( + &self, + env_name: &EnvironmentName, + ) -> miette::Result { + use crate::common::{EnvironmentUpdate, InstallChange}; + use miette::{IntoDiagnostic, WrapErr}; + use pixi_command_dispatcher::{BuildEnvironment, PixiEnvironmentSpec}; + use pixi_spec_containers::DependencyMap; + use rattler_conda_types::{GenericVirtualPackage, Platform}; + use rattler_virtual_packages::{VirtualPackage, VirtualPackageOverrides}; + use std::collections::HashMap; + + let environment = self + .environment(env_name) + .ok_or_else(|| miette::miette!("Environment {} not found", env_name.fancy_display()))?; + + let channels = environment + .channels() + .into_iter() + .map(|channel| channel.clone().into_channel(self.global_channel_config())) + .collect::, _>>() + .into_diagnostic()?; + + let platform = environment.platform.unwrap_or_else(Platform::current); + + // For update operations, use "*" (any version) to find latest available packages + let mut pixi_specs = DependencyMap::default(); + let mut dependencies_names = Vec::new(); + + for (name, _spec) in &environment.dependencies.specs { + let any_version_spec = pixi_spec::PixiSpec::default(); + pixi_specs.insert(name.clone(), any_version_spec); + dependencies_names.push(name.clone()); + } + + let command_dispatcher = self.command_dispatcher().map_err(|e| { + miette::miette!( + "Cannot access command dispatcher for dry-run solving: {}", + e + ) + })?; + + let virtual_packages = if platform + .only_platform() + .map(|p| p == Platform::current().only_platform().unwrap_or("")) + .unwrap_or(false) + { + VirtualPackage::detect(&VirtualPackageOverrides::default()) + .into_diagnostic() + .wrap_err(format!( + "Failed to determine virtual packages for environment {}", + env_name.fancy_display() + ))? + .iter() + .cloned() + .map(GenericVirtualPackage::from) + .collect() + } else { + vec![] + }; + + let channels = channels + .into_iter() + .map(|channel| channel.base_url.clone()) + .collect::>(); + + let build_environment = BuildEnvironment::simple(platform, virtual_packages); + let solve_spec = PixiEnvironmentSpec { + name: Some(env_name.to_string()), + dependencies: pixi_specs, + build_environment: build_environment.clone(), + channels: channels.clone(), + channel_config: self.global_channel_config().clone(), + ..Default::default() + }; + + // SOLVE ONLY + let pixi_records = command_dispatcher + .solve_pixi_environment(solve_spec) + .await?; + + // Compare with current installed packages to detect changes + let prefix = self.environment_prefix(env_name).await?; + let current_records = prefix.find_installed_packages().unwrap_or_default(); + + // Calculate what would change + let install_changes = if current_records.is_empty() && !pixi_records.is_empty() { + // Environment doesn't exist yet + pixi_records + .into_iter() + .map(|record| { + ( + record.package_record().name.clone(), + InstallChange::Installed(record.package_record().version.clone().into()), + ) + }) + .collect() + } else { + // Compare current vs solved packages + let current_packages: HashMap<_, _> = current_records + .iter() + .map(|r| { + ( + r.repodata_record.package_record.name.clone(), + &r.repodata_record.package_record.version, + ) + }) + .collect(); + + let mut changes = HashMap::new(); + + // Check for upgrades and new packages + for record in &pixi_records { + let name = &record.package_record().name; + let new_version = &record.package_record().version; + + if let Some(current_version) = current_packages.get(name) { + let current_version_converted: rattler_conda_types::Version = + (*current_version).clone().into(); + let new_version_converted: rattler_conda_types::Version = + new_version.clone().into(); + if current_version_converted != new_version_converted { + changes.insert( + name.clone(), + InstallChange::Upgraded( + current_version_converted, + new_version_converted, + ), + ); + } + } else { + changes.insert( + name.clone(), + InstallChange::Installed(new_version.clone().into()), + ); + } + } + + // Check for removed packages + for (name, _version) in current_packages { + if !pixi_records.iter().any(|r| r.package_record().name == name) { + changes.insert(name.clone(), InstallChange::Removed); + } + } + + changes + }; + + // Filter to only include changes to top-level dependencies (packages user explicitly installed) + let filtered_changes: HashMap<_, _> = install_changes + .into_iter() + .filter(|(package_name, _change)| { + // Only keep changes for packages that are in the user's dependency list + dependencies_names.contains(package_name) + }) + .collect(); + + Ok(EnvironmentUpdate::new(filtered_changes, dependencies_names)) + } + /// Remove an environment from the manifest and the global installation. pub async fn remove_environment( &mut self, @@ -1308,7 +1470,7 @@ impl Project { } /// Returns the command dispatcher for this project. - fn command_dispatcher(&self) -> Result<&CommandDispatcher, CommandDispatcherError> { + pub fn command_dispatcher(&self) -> Result<&CommandDispatcher, CommandDispatcherError> { const BUILD_DIR: &str = "bld"; self.command_dispatcher.get_or_try_init(|| { diff --git a/crates/pixi_global/src/project/parsed_manifest.rs b/crates/pixi_global/src/project/parsed_manifest.rs index e2739a8475..d862826f80 100644 --- a/crates/pixi_global/src/project/parsed_manifest.rs +++ b/crates/pixi_global/src/project/parsed_manifest.rs @@ -342,7 +342,7 @@ impl ParsedEnvironment { } /// Returns the channels associated with this environment. - pub(crate) fn channels(&self) -> IndexSet<&NamedChannelOrUrl> { + pub fn channels(&self) -> IndexSet<&NamedChannelOrUrl> { PrioritizedChannel::sort_channels_by_priority(&self.channels).collect() } diff --git a/docs/reference/cli/pixi/global/update.md b/docs/reference/cli/pixi/global/update.md index 8f46da796e..7ac51b0718 100644 --- a/docs/reference/cli/pixi/global/update.md +++ b/docs/reference/cli/pixi/global/update.md @@ -16,6 +16,12 @@ pixi global update [OPTIONS] [ENVIRONMENTS]... : Specifies the environments that are to be updated
May be provided more than once. +## Options +- `--dry-run (-n)` +: Don't actually update any environment +- `--json` +: Output the changes in JSON format + ## Config Options - `--auth-file ` : Path to the file containing the authentication token diff --git a/docs/reference/cli/pixi/global/update_extender b/docs/reference/cli/pixi/global/update_extender index 5a6514a76d..e6937de155 100644 --- a/docs/reference/cli/pixi/global/update_extender +++ b/docs/reference/cli/pixi/global/update_extender @@ -6,6 +6,12 @@ pixi global update pixi global update pixi-pack pixi global update bat rattler-build +# Show what would be updated without making changes +pixi global update --dry-run +pixi global update --dry-run pixi-pack +# Output changes in JSON format +pixi global update --json +pixi global update --json pixi-pack ``` --8<-- [end:example]