diff --git a/crates/pixi/tests/integration_rust/common/builders.rs b/crates/pixi/tests/integration_rust/common/builders.rs index ad737970bf..74ce9af086 100644 --- a/crates/pixi/tests/integration_rust/common/builders.rs +++ b/crates/pixi/tests/integration_rust/common/builders.rs @@ -621,6 +621,17 @@ pub struct LockBuilder { pub args: lock::Args, } +impl LockBuilder { + pub fn with_dry_run(mut self, dry_run: bool) -> Self { + self.args.dry_run = dry_run; + self + } + pub fn with_check(mut self, check: bool) -> Self { + self.args.check = check; + self + } +} + impl IntoFuture for LockBuilder { type Output = miette::Result<()>; type IntoFuture = Pin + 'static>>; diff --git a/crates/pixi/tests/integration_rust/common/mod.rs b/crates/pixi/tests/integration_rust/common/mod.rs index 9bfd856367..274537cc32 100644 --- a/crates/pixi/tests/integration_rust/common/mod.rs +++ b/crates/pixi/tests/integration_rust/common/mod.rs @@ -718,6 +718,7 @@ impl PixiControl { no_install_config: NoInstallConfig { no_install: false }, check: false, json: false, + dry_run: false, }, } } diff --git a/crates/pixi/tests/integration_rust/lock_tests.rs b/crates/pixi/tests/integration_rust/lock_tests.rs new file mode 100644 index 0000000000..f6dd2e9412 --- /dev/null +++ b/crates/pixi/tests/integration_rust/lock_tests.rs @@ -0,0 +1,147 @@ +use crate::common::{LockFileExt, PixiControl}; +use pixi_test_utils::{MockRepoData, Package}; +use rattler_conda_types::Platform; +use tempfile::TempDir; + +/// Test that `pixi lock --dry-run` doesn't modify the lock file on disk +#[tokio::test] +async fn test_lock_dry_run_doesnt_modify_lockfile() { + // Create a mock package database + let mut package_database = MockRepoData::default(); + + // Add mock packages + package_database.add_package( + Package::build("python", "3.11.0") + .with_subdir(Platform::current()) + .finish(), + ); + package_database.add_package( + Package::build("numpy", "1.24.0") + .with_subdir(Platform::current()) + .finish(), + ); + + // Write the repodata to disk + let channel_dir = TempDir::new().unwrap(); + package_database + .write_repodata(channel_dir.path()) + .await + .unwrap(); + + // Create a new pixi project using our local channel + let pixi = PixiControl::new().unwrap(); + pixi.init() + .with_local_channel(channel_dir.path()) + .await + .unwrap(); + + // Add a dependency to create an initial lock file + pixi.add("python").await.unwrap(); + + // Get the original lock file + let original_lock_file = pixi.lock_file().await.unwrap(); + let platform = Platform::current(); + + // Verify python is in the original lock file + assert!( + original_lock_file.contains_conda_package("default", platform, "python"), + "python should be in the initial lock file" + ); + + // Add another dependency to the manifest without updating the lock file + let manifest_content = pixi.manifest_contents().unwrap(); + let updated_manifest = + manifest_content.replace("[dependencies]", "[dependencies]\nnumpy = \"*\""); + pixi.update_manifest(&updated_manifest).unwrap(); + + // Run `pixi lock --dry-run` + pixi.lock().with_dry_run(true).await.unwrap(); + + // Verify the lock file was NOT modified + let lock_after_dry_run = pixi.lock_file().await.unwrap(); + + assert!( + lock_after_dry_run.contains_conda_package("default", platform, "python"), + "python should still be in lock file after --dry-run" + ); + + assert!( + !lock_after_dry_run.contains_conda_package("default", platform, "numpy"), + "numpy should NOT be in lock file after --dry-run" + ); + + // Now run without --dry-run to actually update the lock file + pixi.lock().await.unwrap(); + + // Verify the lock file WAS modified this time + let lock_after_normal = pixi.lock_file().await.unwrap(); + + assert!( + lock_after_normal.contains_conda_package("default", platform, "python"), + "python should still be in lock file" + ); + + assert!( + lock_after_normal.contains_conda_package("default", platform, "numpy"), + "numpy should NOW be in lock file after normal lock" + ); +} + +/// Test that `pixi lock --dry-run` implies `--no-install` +#[tokio::test] +async fn test_lock_dry_run_implies_no_install() { + // Create a mock package database + let mut package_database = MockRepoData::default(); + + // Add mock packages + package_database.add_package( + Package::build("python", "3.11.0") + .with_subdir(Platform::current()) + .finish(), + ); + package_database.add_package( + Package::build("numpy", "1.24.0") + .with_subdir(Platform::current()) + .finish(), + ); + + // Write the repodata to disk + let channel_dir = TempDir::new().unwrap(); + package_database + .write_repodata(channel_dir.path()) + .await + .unwrap(); + + // Create a new pixi project using our local channel + let pixi = PixiControl::new().unwrap(); + pixi.init() + .with_local_channel(channel_dir.path()) + .await + .unwrap(); + + // Add a dependency + pixi.add("python").await.unwrap(); + + // Get the environment path + let env_path = pixi.default_env_path().unwrap(); + + // Remove the environment directory if it exists + if env_path.exists() { + fs_err::remove_dir_all(&env_path).unwrap(); + } + + // Add another dependency to manifest + let manifest_content = pixi.manifest_contents().unwrap(); + let updated_manifest = + manifest_content.replace("[dependencies]", "[dependencies]\nnumpy = \"*\""); + pixi.update_manifest(&updated_manifest).unwrap(); + + // Run `pixi lock --dry-run` + pixi.lock().with_dry_run(true).await.unwrap(); + + // Environment should NOT have been created + assert!( + !env_path.exists(), + "Environment should not be created with --dry-run" + ); +} diff --git a/crates/pixi/tests/integration_rust/main.rs b/crates/pixi/tests/integration_rust/main.rs index 09e1283662..aac9f0062b 100644 --- a/crates/pixi/tests/integration_rust/main.rs +++ b/crates/pixi/tests/integration_rust/main.rs @@ -8,6 +8,7 @@ mod global_tests; mod init_tests; mod install_filter_tests; mod install_tests; +mod lock_tests; mod project_tests; mod pypi_tests; mod search_tests; diff --git a/crates/pixi_cli/src/lock.rs b/crates/pixi_cli/src/lock.rs index 174435433e..ead9846ec9 100644 --- a/crates/pixi_cli/src/lock.rs +++ b/crates/pixi_cli/src/lock.rs @@ -29,6 +29,11 @@ pub struct Args { /// If yes, exit with a non-zero code. #[clap(long)] pub check: bool, + + ///Compute the lock file without writing to disk. + /// Implies --no-install + #[clap(long)] + pub dry_run: bool, } pub async fn execute(args: Args) -> miette::Result<()> { @@ -47,8 +52,12 @@ pub async fn execute(args: Args) -> miette::Result<()> { let original_lock_file = workspace.load_lock_file().await?.into_lock_file_or_empty(); let (LockFileDerivedData { lock_file, .. }, lock_updated) = workspace .update_lock_file(UpdateLockFileOptions { - lock_file_usage: LockFileUsage::Update, - no_install: args.no_install_config.no_install, + lock_file_usage: if args.dry_run { + LockFileUsage::DryRun + } else { + LockFileUsage::Update + }, + no_install: args.no_install_config.no_install || args.dry_run, max_concurrent_solves: workspace.config().max_concurrent_solves(), }) .await?; @@ -62,6 +71,21 @@ pub async fn execute(args: Args) -> miette::Result<()> { let json_diff = LockFileJsonDiff::new(Some(workspace.named_environments()), diff); let json = serde_json::to_string_pretty(&json_diff).expect("failed to convert to json"); println!("{json}"); + } else if args.dry_run { + if !diff.is_empty() { + eprintln!( + "{}Dry-run: lock-file would be updated (not written to disk)", + console::style(console::Emoji("i ", "i ")).blue() + ); + diff.print() + .into_diagnostic() + .context("failed to print lock-file diff")?; + } else { + eprintln!( + "{}Dry-run:lock file would not change", + console::style(console::Emoji("i ", "i ")).blue() + ); + } } else if lock_updated { eprintln!( "{}Updated lock-file", diff --git a/crates/pixi_core/src/environment/mod.rs b/crates/pixi_core/src/environment/mod.rs index ab463d1d57..7cb2f69c7d 100644 --- a/crates/pixi_core/src/environment/mod.rs +++ b/crates/pixi_core/src/environment/mod.rs @@ -461,6 +461,8 @@ pub enum LockFileUsage { Locked, /// Don't update the lock-file and don't check if it is out of date Frozen, + /// Don't update the lock-file, but don't check if it is out of date + DryRun, } impl LockFileUsage { @@ -472,7 +474,7 @@ impl LockFileUsage { /// Returns true if the lock-file should be checked if it is out of date. pub(crate) fn should_check_if_out_of_date(self) -> bool { match self { - LockFileUsage::Update | LockFileUsage::Locked => true, + LockFileUsage::Update | LockFileUsage::Locked | LockFileUsage::DryRun => true, LockFileUsage::Frozen => false, } } diff --git a/crates/pixi_core/src/lock_file/update.rs b/crates/pixi_core/src/lock_file/update.rs index 91d7fc37f4..abcce33531 100644 --- a/crates/pixi_core/src/lock_file/update.rs +++ b/crates/pixi_core/src/lock_file/update.rs @@ -340,7 +340,10 @@ impl Workspace { .await?; // Write the lock-file to disk - lock_file_derived_data.write_to_disk()?; + + if options.lock_file_usage != LockFileUsage::DryRun { + lock_file_derived_data.write_to_disk()?; + } Ok((lock_file_derived_data, true)) } diff --git a/docs/reference/cli/pixi/lock.md b/docs/reference/cli/pixi/lock.md index cd6fe1614f..efa457a5ce 100644 --- a/docs/reference/cli/pixi/lock.md +++ b/docs/reference/cli/pixi/lock.md @@ -18,6 +18,8 @@ pixi lock [OPTIONS] : Output the changes in JSON format - `--check` : Check if any changes have been made to the lock file. If yes, exit with a non-zero code +- `--dry-run` +: Compute the lock file without writing to disk. Implies --no-install ## Update Options - `--no-install`