diff --git a/crates/pixi_core/src/lock_file/resolve/pypi.rs b/crates/pixi_core/src/lock_file/resolve/pypi.rs index fddd4ce4c7..84aa8eb804 100644 --- a/crates/pixi_core/src/lock_file/resolve/pypi.rs +++ b/crates/pixi_core/src/lock_file/resolve/pypi.rs @@ -27,9 +27,10 @@ use pixi_record::{LockedGitUrl, PixiRecord}; use pixi_reporters::{UvReporter, UvReporterOptions}; use pixi_uv_conversions::{ ConversionError, as_uv_req, configure_insecure_hosts_for_tls_bypass, - convert_uv_requirements_to_pep508, into_pinned_git_spec, pypi_options_to_build_options, - pypi_options_to_index_locations, to_exclude_newer, to_index_strategy, to_normalize, - to_prerelease_mode, to_requirements, to_uv_normalize, to_uv_version, to_version_specifiers, + convert_uv_requirements_to_pep508, into_pinned_git_spec, into_pixi_reference, + pypi_options_to_build_options, pypi_options_to_index_locations, to_exclude_newer, + to_index_strategy, to_normalize, to_prerelease_mode, to_requirements, to_uv_normalize, + to_uv_version, to_version_specifiers, }; use pypi_modifiers::{ pypi_marker_env::determine_marker_environment, @@ -48,8 +49,8 @@ use uv_configuration::{Constraints, Overrides}; use uv_distribution::DistributionDatabase; use uv_distribution_types::{ BuiltDist, ConfigSettings, DependencyMetadata, Diagnostic, Dist, FileLocation, HashPolicy, - IndexCapabilities, IndexUrl, Name, RequirementSource, RequiresPython, Resolution, ResolvedDist, - SourceDist, ToUrlError, + IndexCapabilities, IndexUrl, Name, Requirement, RequirementSource, RequiresPython, Resolution, + ResolvedDist, SourceDist, ToUrlError, }; use uv_git_types::GitUrl; use uv_pep508::VerbatimUrl; @@ -355,6 +356,10 @@ pub async fn resolve_pypi( .collect::, _>>() .into_diagnostic()?; + // Clone requirements for later use in lock file generation + // We need this because requirements will be moved into the resolver + let requirements_for_locking = requirements.clone(); + // Determine the python interpreter that is installed as part of the conda // packages. let python_record = locked_pixi_records @@ -629,6 +634,8 @@ pub async fn resolve_pypi( let git_oid = uv_git_types::GitOid::from_str(&pinned_git_spec.source.commit.to_string())?; + // Construct the GitUrl with the reference (branch/tag) from the pinned_git_spec + // to preserve the branch information in the lock file let git_url = GitUrl::try_from(display_safe)?.with_precise(git_oid); let constraint_source = RequirementSource::Git { @@ -821,6 +828,7 @@ pub async fn resolve_pypi( &context.capabilities, context.concurrency.downloads, project_root, + &requirements_for_locking, ) .await?; @@ -943,6 +951,7 @@ fn get_url_or_path( } /// Create a vector of locked packages from a resolution +#[allow(clippy::too_many_arguments)] async fn lock_pypi_packages( conda_python_packages: CondaPythonPackages, pixi_build_dispatch: &LazyBuildDispatch<'_>, @@ -951,6 +960,7 @@ async fn lock_pypi_packages( index_capabilities: &IndexCapabilities, concurrent_downloads: usize, abs_project_root: &Path, + original_requirements: &[Requirement], ) -> miette::Result> { let mut locked_packages = LockedPypiPackages::with_capacity(resolution.len()); let database = @@ -1071,7 +1081,22 @@ async fn lock_pypi_packages( } SourceDist::Git(git) => { // convert resolved source dist into a pinned git spec - let pinned_git_spec = into_pinned_git_spec(git.clone()); + let mut pinned_git_spec = into_pinned_git_spec(git.clone()); + + // Look up the original requirement to get the git reference (branch/tag) + // It may have resolved the reference to just a commit, losing the branch/tag info + if let Some(original_req) = original_requirements + .iter() + .find(|r| &r.name == dist.name()) + && let RequirementSource::Git { + git: original_git, .. + } = &original_req.source + { + // Use the reference from the original requirement instead of what UV resolved + pinned_git_spec.source.reference = + into_pixi_reference(original_git.reference().clone()); + } + ( pinned_git_spec.into_locked_git_url().to_url().into(), hash, diff --git a/crates/pixi_core/src/lock_file/satisfiability/mod.rs b/crates/pixi_core/src/lock_file/satisfiability/mod.rs index 6dcf2d4da5..bdae37ebf2 100644 --- a/crates/pixi_core/src/lock_file/satisfiability/mod.rs +++ b/crates/pixi_core/src/lock_file/satisfiability/mod.rs @@ -46,7 +46,6 @@ use typed_path::Utf8TypedPathBuf; use url::Url; use uv_distribution_filename::{DistExtension, ExtensionError, SourceDistExtension}; use uv_distribution_types::{RequirementSource, RequiresPython}; -use uv_git_types::GitReference; use uv_pypi_types::ParsedUrlError; use super::{ @@ -970,11 +969,6 @@ pub(crate) fn pypi_satifisfies_requirement( } .into()); } - // If the spec does not specify a revision than any will do - // E.g `git.com/user/repo` is the same as `git.com/user/repo@adbdd` - if *reference == GitReference::DefaultBranch { - return Ok(()); - } if pinned_git_spec.source.subdirectory != subdirectory @@ -995,8 +989,9 @@ pub(crate) fn pypi_satifisfies_requirement( } .into()); } - // If the spec does specify a revision than the revision must match - // convert first to the same type + // The git reference (branch/tag/rev) must match exactly between + // the requirement and the lock file. This ensures that if a user + // adds or removes a branch specification, the lock file will update. let pixi_reference = into_pixi_reference(reference.clone()); if pinned_git_spec.source.reference == pixi_reference { @@ -2608,12 +2603,28 @@ mod tests { .unwrap(); // This should not pypi_satifisfies_requirement(&non_matching_spec, &locked_data, &project_root).unwrap_err(); - // Removing the rev from the Requirement should satisfy any revision + // Removing the rev from the Requirement now requires the lock file to also + // not have an explicit rev (must match exactly). This is a fix for issue #5185. let spec = pep508_requirement_to_uv_requirement( pep508_rs::Requirement::from_str("mypkg @ git+https://github.com/mypkg").unwrap(), ) .unwrap(); - pypi_satifisfies_requirement(&spec, &locked_data, &project_root).unwrap(); + // This should now fail because the lock file has an explicit rev but the spec doesn't + pypi_satifisfies_requirement(&spec, &locked_data, &project_root).unwrap_err(); + + // But if the lock file also doesn't have an explicit rev, it should match + let locked_data_no_rev = PypiPackageData { + name: "mypkg".parse().unwrap(), + version: Version::from_str("0.1.0").unwrap(), + location: "git+https://github.com/mypkg.git#29932f3915935d773dc8d52c292cadd81c81071d" + .parse() + .expect("failed to parse url"), + hash: None, + requires_dist: vec![], + requires_python: None, + editable: false, + }; + pypi_satifisfies_requirement(&spec, &locked_data_no_rev, &project_root).unwrap(); } // Currently this test is missing from `good_satisfiability`, so we test the