diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 6cc6922c38660..f7f1f5b434bf4 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -604,6 +604,39 @@ impl Lock { } } } + + // Normalize fork markers by round-tripping through simplify/complexify. + // + // During lockfile deserialization, `SimplifiedMarkerTree::into_marker` + // folds `requires-python` constraints back into the marker tree. For + // example, with `requires-python = ">=3.12"`, the resolver produces + // `sys_platform != 'win32'` but deserialization yields + // `python_full_version >= '3.12' and sys_platform != 'win32'`. + // + // Without this normalization, the `PartialEq` comparison between a + // freshly-resolved lock and one read from disk can report false + // differences (e.g., `--dry-run` showing changes when there are none). + let fork_markers = fork_markers + .into_iter() + .map(|marker| { + let complexified = SimplifiedMarkerTree::new(&requires_python, marker.combined()) + .into_marker(&requires_python); + UniversalMarker::from_combined(complexified) + }) + .collect(); + for package in &mut packages { + package.fork_markers = package + .fork_markers + .iter() + .map(|marker| { + let complexified = + SimplifiedMarkerTree::new(&requires_python, marker.combined()) + .into_marker(&requires_python); + UniversalMarker::from_combined(complexified) + }) + .collect(); + } + let lock = Self { version, revision, diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index 22cdd752bc275..fe9aa57badfff 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -23670,6 +23670,62 @@ fn lock_dry_run_noop() -> Result<()> { Ok(()) } +/// Regression test for . +/// +/// When a lockfile is deserialized, fork markers go through +/// `SimplifiedMarkerTree::into_marker(&requires_python)`, which folds +/// `requires-python` constraints back into the marker tree. For example, with +/// `requires-python = ">=3.12"`, the resolver produces fork markers like +/// `sys_platform != 'win32'`, but after the simplify/complexify round-trip +/// during deserialization the same marker becomes +/// `python_full_version >= '3.12' and sys_platform != 'win32'`. +/// +/// Both are semantically equivalent given the project's `requires-python` +/// bound, but `PartialEq` on the `Lock` struct sees them as different, +/// causing `--dry-run --upgrade` to falsely report "Lockfile changes detected" +/// when the lockfile was actually up-to-date. +#[test] +fn lock_dry_run_upgrade_multi_version() -> Result<()> { + let context = uv_test::test_context!("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = [ + "markupsafe<2 ; sys_platform != 'win32'", + "markupsafe==2.0.0 ; sys_platform == 'win32'", + ] + "#, + )?; + + // Create the initial lockfile. + uv_snapshot!(context.filters(), context.lock(), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + "); + + // Dry-run with --upgrade should report no changes. + uv_snapshot!(context.filters(), context.lock().arg("--dry-run").arg("-U"), @" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 3 packages in [TIME] + No lockfile changes detected + "); + + Ok(()) +} + #[test] fn lock_group_include() -> Result<()> { let context = uv_test::test_context!("3.12");