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.
This commit is contained in:
Charlie Marsh
2025-09-12 13:57:58 -04:00
committed by GitHub
parent bd8a9348bd
commit b1fbb524d2
4 changed files with 172 additions and 28 deletions
+5
View File
@@ -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)]
+8
View File
@@ -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()
+44 -22
View File
@@ -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<Option<&'lock Version>>,
BTreeSet<Option<&'lock Version>>,
BTreeSet<LockEventVersion<'lock>>,
BTreeSet<LockEventVersion<'lock>>,
),
Add(DryRun, PackageName, BTreeSet<Option<&'lock Version>>),
Remove(DryRun, PackageName, BTreeSet<Option<&'lock Version>>),
Add(DryRun, PackageName, BTreeSet<LockEventVersion<'lock>>),
Remove(DryRun, PackageName, BTreeSet<LockEventVersion<'lock>>),
}
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<Item = Self> {
// Identify the package-versions in the existing lockfile.
let mut existing_packages: FxHashMap<&PackageName, BTreeSet<Option<&Version>>> =
let mut existing_packages: FxHashMap<&PackageName, BTreeSet<LockEventVersion>> =
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<Option<&Version>>> =
let mut new_packages: FxHashMap<&PackageName, BTreeSet<LockEventVersion>> =
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::<Vec<_>>()
.join(", ");
let new_versions = new_versions
.iter()
.map(|version| format_version(*version))
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.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::<Vec<_>>()
.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::<Vec<_>>()
.join(", ");
+115 -6
View File
@@ -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: <https://github.com/astral-sh/uv/issues/9832#issuecomment-2539121761>
#[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");