From b1fbb524d29476484ab5ba2ddf19c24157b3d277 Mon Sep 17 00:00:00 2001 From: Charlie Marsh Date: Fri, 12 Sep 2025 13:57:58 -0400 Subject: [PATCH] Include SHA when listing lockfile changes (#15817) ## Summary Right now, we only list changes if the _version_ differs. This PR takes the SHA into account. We may want to list changes to _any_ sources, but that gets more complicated (e.g., if the user swaps the index URL, we'd have to show _all_ changes to the index URL). Closes #15810. --- crates/uv-git-types/src/oid.rs | 5 + crates/uv-resolver/src/lock/mod.rs | 8 ++ crates/uv/src/commands/project/lock.rs | 66 +++++++++----- crates/uv/tests/it/lock.rs | 121 +++++++++++++++++++++++-- 4 files changed, 172 insertions(+), 28 deletions(-) diff --git a/crates/uv-git-types/src/oid.rs b/crates/uv-git-types/src/oid.rs index 372d608c3..00cf5a651 100644 --- a/crates/uv-git-types/src/oid.rs +++ b/crates/uv-git-types/src/oid.rs @@ -25,6 +25,11 @@ impl GitOid { pub fn as_short_str(&self) -> &str { &self.as_str()[..16] } + + /// Return a (very) truncated representation, i.e., the first 8 characters of the SHA. + pub fn as_tiny_str(&self) -> &str { + &self.as_str()[..8] + } } #[derive(Debug, Error, PartialEq)] diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index b9688a831..6a4633d4d 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -3058,6 +3058,14 @@ impl Package { self.id.version.as_ref() } + /// Returns the Git SHA of the package, if it is a Git source. + pub fn git_sha(&self) -> Option<&GitOid> { + match &self.id.source { + Source::Git(_, git) => Some(&git.precise), + _ => None, + } + } + /// Return the fork markers for this package, if any. pub fn fork_markers(&self) -> &[UniversalMarker] { self.fork_markers.as_slice() diff --git a/crates/uv/src/commands/project/lock.rs b/crates/uv/src/commands/project/lock.rs index 9ced95d6e..3dd6dfef8 100644 --- a/crates/uv/src/commands/project/lock.rs +++ b/crates/uv/src/commands/project/lock.rs @@ -22,6 +22,7 @@ use uv_distribution_types::{ Requirement, RequiresPython, UnresolvedRequirementSpecification, }; use uv_git::ResolvedRepositoryReference; +use uv_git_types::GitOid; use uv_normalize::{GroupName, PackageName}; use uv_pep440::Version; use uv_preview::{Preview, PreviewFeatures}; @@ -30,7 +31,7 @@ use uv_python::{Interpreter, PythonDownloads, PythonEnvironment, PythonPreferenc use uv_requirements::ExtrasResolver; use uv_requirements::upgrade::{LockedRequirements, read_lock_requirements}; use uv_resolver::{ - FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, PythonRequirement, + FlatIndex, InMemoryIndex, Lock, Options, OptionsBuilder, Package, PythonRequirement, ResolverEnvironment, ResolverManifest, SatisfiesResult, UniversalMarker, }; use uv_scripts::Pep723Script; @@ -1355,28 +1356,56 @@ impl ValidatedLock { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct LockEventVersion<'lock> { + /// The version of the package, or `None` if the package has a dynamic version. + version: Option<&'lock Version>, + /// The short Git SHA of the package, if it was installed from a Git repository. + sha: Option<&'lock str>, +} + +impl<'lock> From<&'lock Package> for LockEventVersion<'lock> { + fn from(value: &'lock Package) -> Self { + Self { + version: value.version(), + sha: value.git_sha().map(GitOid::as_tiny_str), + } + } +} + +impl std::fmt::Display for LockEventVersion<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match (self.version, self.sha) { + (Some(version), Some(sha)) => write!(f, "v{version} ({sha})"), + (Some(version), None) => write!(f, "v{version}"), + (None, Some(sha)) => write!(f, "(dynamic) ({sha})"), + (None, None) => write!(f, "(dynamic)"), + } + } +} + /// A modification to a lockfile. #[derive(Debug, Clone)] -pub(crate) enum LockEvent<'lock> { +enum LockEvent<'lock> { Update( DryRun, PackageName, - BTreeSet>, - BTreeSet>, + BTreeSet>, + BTreeSet>, ), - Add(DryRun, PackageName, BTreeSet>), - Remove(DryRun, PackageName, BTreeSet>), + Add(DryRun, PackageName, BTreeSet>), + Remove(DryRun, PackageName, BTreeSet>), } impl<'lock> LockEvent<'lock> { /// Detect the change events between an (optional) existing and updated lockfile. - pub(crate) fn detect_changes( + fn detect_changes( existing_lock: Option<&'lock Lock>, new_lock: &'lock Lock, dry_run: DryRun, ) -> impl Iterator { // Identify the package-versions in the existing lockfile. - let mut existing_packages: FxHashMap<&PackageName, BTreeSet>> = + let mut existing_packages: FxHashMap<&PackageName, BTreeSet> = if let Some(existing_lock) = existing_lock { existing_lock.packages().iter().fold( FxHashMap::with_capacity_and_hasher( @@ -1386,7 +1415,7 @@ impl<'lock> LockEvent<'lock> { |mut acc, package| { acc.entry(package.name()) .or_default() - .insert(package.version()); + .insert(LockEventVersion::from(package)); acc }, ) @@ -1395,13 +1424,13 @@ impl<'lock> LockEvent<'lock> { }; // Identify the package-versions in the updated lockfile. - let mut new_packages: FxHashMap<&PackageName, BTreeSet>> = + let mut new_packages: FxHashMap<&PackageName, BTreeSet> = new_lock.packages().iter().fold( FxHashMap::with_capacity_and_hasher(new_lock.packages().len(), FxBuildHasher), |mut acc, package| { acc.entry(package.name()) .or_default() - .insert(package.version()); + .insert(LockEventVersion::from(package)); acc }, ); @@ -1435,23 +1464,16 @@ impl<'lock> LockEvent<'lock> { impl std::fmt::Display for LockEvent<'_> { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - /// Format a version for inclusion in the upgrade report. - fn format_version(version: Option<&Version>) -> String { - version - .map(|version| format!("v{version}")) - .unwrap_or_else(|| "(dynamic)".to_string()) - } - match self { Self::Update(dry_run, name, existing_versions, new_versions) => { let existing_versions = existing_versions .iter() - .map(|version| format_version(*version)) + .map(std::string::ToString::to_string) .collect::>() .join(", "); let new_versions = new_versions .iter() - .map(|version| format_version(*version)) + .map(std::string::ToString::to_string) .collect::>() .join(", "); @@ -1470,7 +1492,7 @@ impl std::fmt::Display for LockEvent<'_> { Self::Add(dry_run, name, new_versions) => { let new_versions = new_versions .iter() - .map(|version| format_version(*version)) + .map(std::string::ToString::to_string) .collect::>() .join(", "); @@ -1485,7 +1507,7 @@ impl std::fmt::Display for LockEvent<'_> { Self::Remove(dry_run, name, existing_versions) => { let existing_versions = existing_versions .iter() - .map(|version| format_version(*version)) + .map(std::string::ToString::to_string) .collect::>() .join(", "); diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index b4aa52ea6..339a61578 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -407,14 +407,15 @@ fn lock_sdist_git() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Resolved 2 packages in [TIME] - "###); + Updated uv-public-pypackage v0.1.0 (0dacfd66) -> v0.1.0 (b270df1a) + "); let lock = context.read("uv.lock"); @@ -738,14 +739,15 @@ fn lock_sdist_git_pep508() -> Result<()> { "#, )?; - uv_snapshot!(context.filters(), context.lock(), @r###" + uv_snapshot!(context.filters(), context.lock(), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Resolved 2 packages in [TIME] - "###); + Updated uv-public-pypackage v0.1.0 (0dacfd66) -> v0.1.0 (b270df1a) + "); let lock = context.read("uv.lock"); @@ -4942,6 +4944,7 @@ fn lock_git_sha() -> Result<()> { ----- stderr ----- Resolved 2 packages in [TIME] + Updated uv-public-pypackage v0.1.0 (0dacfd66) -> v0.1.0 (b270df1a) "); let lock = context.read("uv.lock"); @@ -12937,14 +12940,15 @@ fn lock_mismatched_sources() -> Result<()> { }); // If we run with `--no-sources`, we should use the URL provided in `project.dependencies`. - uv_snapshot!(context.filters(), context.lock().arg("--no-sources"), @r###" + uv_snapshot!(context.filters(), context.lock().arg("--no-sources"), @r" success: true exit_code: 0 ----- stdout ----- ----- stderr ----- Resolved 2 packages in [TIME] - "###); + Updated uv-public-pypackage v0.1.0 (0dacfd66) -> v0.1.0 (b270df1a) + "); let lock = context.read("uv.lock"); @@ -31519,6 +31523,111 @@ fn lock_android() -> Result<()> { Ok(()) } +/// See: +#[test] +fn lock_git_change_log() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "foo" + version = "0.1.0" + requires-python = ">=3.12.0" + dependencies = [ + "typing-extensions", + ] + + [tool.uv.sources] + typing-extensions = { git = "https://github.com/python/typing_extensions" } + "#, + )?; + + // Write a stale commit. + context.temp_dir.child("uv.lock").write_str( + r#" + version = 1 + revision = 3 + requires-python = ">=3.12.0" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "foo" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "typing-extensions" }, + ] + + [package.metadata] + requires-dist = [{ name = "typing-extensions", git = "https://github.com/python/typing_extensions?rev=4f42e6bf0052129bc6dae5e71699a409652d2091" }] + + [[package]] + name = "typing-extensions" + version = "4.15.0" + source = { git = "https://github.com/python/typing_extensions?rev=4f42e6bf0052129bc6dae5e71699a409652d2091#4f42e6bf0052129bc6dae5e71699a409652d2091" } + "#, + )?; + + uv_snapshot!(context.filters(), context.lock().arg("--dry-run"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Update typing-extensions v4.15.0 (4f42e6bf) -> v4.15.0 (9215c953) + "); + + uv_snapshot!(context.filters(), context.lock(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Updated typing-extensions v4.15.0 (4f42e6bf) -> v4.15.0 (9215c953) + "); + + let lock = context.read("uv.lock"); + + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + lock, @r#" + version = 1 + revision = 3 + requires-python = ">=3.12.[X]" + + [options] + exclude-newer = "2024-03-25T00:00:00Z" + + [[package]] + name = "foo" + version = "0.1.0" + source = { virtual = "." } + dependencies = [ + { name = "typing-extensions" }, + ] + + [package.metadata] + requires-dist = [{ name = "typing-extensions", git = "https://github.com/python/typing_extensions" }] + + [[package]] + name = "typing-extensions" + version = "4.15.0" + source = { git = "https://github.com/python/typing_extensions#9215c953610ca4e4ce7ae840a0a804505da70a05" } + "# + ); + }); + + Ok(()) +} + #[test] fn lock_required_intersection() -> Result<()> { let context = TestContext::new("3.12");