Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -317,7 +317,7 @@ jobs:
run: |
cargo nextest run \
--no-default-features \
--features python,python-managed,pypi,git,performance,crates-io,native-auth,apple-native \
--features python,python-managed,pypi,git,git-lfs,performance,crates-io,native-auth,apple-native \
--workspace \
--status-level skip --failure-output immediate-final --no-fail-fast -j 12 --final-status-level slow

Expand Down
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,13 @@ cargo test --package <package> --test <test> -- <test_name> -- --exact
cargo insta review
```

### Git and Git LFS

A subset of uv tests require both [Git](https://git-scm.com) and [Git LFS](https://git-lfs.com/) to
execute properly.

These tests can be disabled by turning off either `git` or `git-lfs` uv features.

### Local testing

You can invoke your development version of uv with `cargo run -- <args>`. For example:
Expand Down
3 changes: 3 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

115 changes: 109 additions & 6 deletions crates/uv-cache-key/src/canonical_url.rs
Original file line number Diff line number Diff line change
Expand Up @@ -139,8 +139,18 @@ impl std::fmt::Display for CanonicalUrl {
/// `https://github.com/pypa/package.git#subdirectory=pkg_b` would map to different
/// [`CanonicalUrl`] values, but the same [`RepositoryUrl`], since they map to the same
/// resource.
///
/// The additional information it holds should only be used to discriminate between
/// sources that hold the exact same commit in their canonical representation,
/// but may differ in the contents such as when Git LFS is enabled.
///
/// A different cache key will be computed when Git LFS is enabled.
/// When Git LFS is `false` or `None`, the cache key remains unchanged.
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
pub struct RepositoryUrl(DisplaySafeUrl);
pub struct RepositoryUrl {
repo_url: DisplaySafeUrl,
with_lfs: Option<bool>,
}

impl RepositoryUrl {
pub fn new(url: &DisplaySafeUrl) -> Self {
Expand All @@ -161,41 +171,56 @@ impl RepositoryUrl {
url.set_fragment(None);
url.set_query(None);

Self(url)
Self {
repo_url: url,
with_lfs: None,
}
}

pub fn parse(url: &str) -> Result<Self, DisplaySafeUrlError> {
Ok(Self::new(&DisplaySafeUrl::parse(url)?))
}

#[must_use]
pub fn with_lfs(mut self, lfs: Option<bool>) -> Self {
self.with_lfs = lfs;
self
}
}

impl CacheKey for RepositoryUrl {
fn cache_key(&self, state: &mut CacheKeyHasher) {
// `as_str` gives the serialisation of a url (which has a spec) and so insulates against
// possible changes in how the URL crate does hashing.
self.0.as_str().cache_key(state);
self.repo_url.as_str().cache_key(state);
if let Some(true) = self.with_lfs {
1u8.cache_key(state);
}
}
}

impl Hash for RepositoryUrl {
fn hash<H: Hasher>(&self, state: &mut H) {
// `as_str` gives the serialisation of a url (which has a spec) and so insulates against
// possible changes in how the URL crate does hashing.
self.0.as_str().hash(state);
self.repo_url.as_str().hash(state);
if let Some(true) = self.with_lfs {
1u8.hash(state);
}
}
}

impl Deref for RepositoryUrl {
type Target = Url;

fn deref(&self) -> &Self::Target {
&self.0
&self.repo_url
}
}

impl std::fmt::Display for RepositoryUrl {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f)
std::fmt::Display::fmt(&self.repo_url, f)
}
}

Expand Down Expand Up @@ -283,6 +308,14 @@ mod tests {
)?,
);

// Two URLs should _not_ be considered equal if they differ in Git LFS enablement.
assert_ne!(
CanonicalUrl::parse(
"git+https://github.com/pypa/sample-namespace-packages.git#lfs=true"
)?,
CanonicalUrl::parse("git+https://github.com/pypa/sample-namespace-packages.git")?,
);

// Two URLs should _not_ be considered equal if they request different commit tags.
assert_ne!(
CanonicalUrl::parse(
Expand Down Expand Up @@ -378,6 +411,76 @@ mod tests {
)?,
);

// Two URLs should be considered equal if they map to the same repository, even if they
// differ in Git LFS enablement.
assert_eq!(
RepositoryUrl::parse(
"git+https://github.com/pypa/sample-namespace-packages.git#lfs=true"
)?,
RepositoryUrl::parse("git+https://github.com/pypa/sample-namespace-packages.git")?,
);

Ok(())
}

#[test]
fn repository_url_with_lfs() -> Result<(), DisplaySafeUrlError> {
let mut hasher = CacheKeyHasher::new();
RepositoryUrl::parse("https://example.com/pypa/sample-namespace-packages.git@2.0.0")?
.cache_key(&mut hasher);
let repo_url_basic = hasher.finish();

let mut hasher = CacheKeyHasher::new();
RepositoryUrl::parse(
"https://user:foo@example.com/pypa/sample-namespace-packages.git@2.0.0#foo=bar",
)?
.cache_key(&mut hasher);
let repo_url_with_fragments = hasher.finish();

assert_eq!(
repo_url_basic, repo_url_with_fragments,
"repository urls should have the exact cache keys as fragments are removed",
);

let mut hasher = CacheKeyHasher::new();
RepositoryUrl::parse(
"https://user:foo@example.com/pypa/sample-namespace-packages.git@2.0.0#foo=bar",
)?
.with_lfs(None)
.cache_key(&mut hasher);
let git_url_with_fragments = hasher.finish();

assert_eq!(
repo_url_with_fragments, git_url_with_fragments,
"both structs should have the exact cache keys as fragments are still removed",
);

let mut hasher = CacheKeyHasher::new();
RepositoryUrl::parse(
"https://user:foo@example.com/pypa/sample-namespace-packages.git@2.0.0#foo=bar",
)?
.with_lfs(Some(false))
.cache_key(&mut hasher);
let git_url_with_fragments_and_lfs_false = hasher.finish();

assert_eq!(
git_url_with_fragments, git_url_with_fragments_and_lfs_false,
"both structs should have the exact cache keys as lfs false should not influence them",
);

let mut hasher = CacheKeyHasher::new();
RepositoryUrl::parse(
"https://user:foo@example.com/pypa/sample-namespace-packages.git@2.0.0#foo=bar",
)?
.with_lfs(Some(true))
.cache_key(&mut hasher);
let git_url_with_fragments_and_lfs_true = hasher.finish();

assert_ne!(
git_url_with_fragments, git_url_with_fragments_and_lfs_true,
"both structs should have different cache keys as one has Git LFS enabled",
);

Ok(())
}
}
2 changes: 1 addition & 1 deletion crates/uv-cache/src/wheel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ pub enum WheelCache<'a> {
Path(&'a DisplaySafeUrl),
/// An editable dependency, which we key by URL.
Editable(&'a DisplaySafeUrl),
/// A Git dependency, which we key by URL and SHA.
/// A Git dependency, which we key by URL (including LFS state), SHA.
///
/// Note that this variant only exists for source distributions; wheels can't be delivered
/// through Git.
Expand Down
12 changes: 12 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4104,6 +4104,10 @@ pub struct AddArgs {
#[arg(long, group = "git-ref", action = clap::ArgAction::Set)]
pub branch: Option<String>,

/// Whether to use Git LFS when adding a dependency from Git.
#[arg(long, env = EnvVars::UV_GIT_LFS, value_parser = clap::builder::BoolishValueParser::new())]
pub lfs: bool,

/// Extras to enable for the dependency.
///
/// May be provided more than once.
Expand Down Expand Up @@ -5070,6 +5074,10 @@ pub struct ToolRunArgs {
#[command(flatten)]
pub refresh: RefreshArgs,

/// Whether to use Git LFS when adding a dependency from Git.
#[arg(long, env = EnvVars::UV_GIT_LFS, value_parser = clap::builder::BoolishValueParser::new())]
pub lfs: bool,

/// The Python interpreter to use to build the run environment.
///
/// See `uv help python` for details on Python discovery and supported request formats.
Expand Down Expand Up @@ -5217,6 +5225,10 @@ pub struct ToolInstallArgs {
#[arg(long)]
pub force: bool,

/// Whether to use Git LFS when adding a dependency from Git.
#[arg(long, env = EnvVars::UV_GIT_LFS, value_parser = clap::builder::BoolishValueParser::new())]
pub lfs: bool,

/// The Python interpreter to use to build the tool environment.
///
/// See `uv help python` for details on Python discovery and supported request formats.
Expand Down
39 changes: 36 additions & 3 deletions crates/uv-distribution-types/src/requirement.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use thiserror::Error;
use uv_cache_key::{CacheKey, CacheKeyHasher};
use uv_distribution_filename::DistExtension;
use uv_fs::{CWD, PortablePath, PortablePathBuf, relative_to};
use uv_git_types::{GitOid, GitReference, GitUrl, GitUrlParseError, OidParseError};
use uv_git_types::{GitLfs, GitOid, GitReference, GitUrl, GitUrlParseError, OidParseError};
use uv_normalize::{ExtraName, GroupName, PackageName};
use uv_pep440::VersionSpecifiers;
use uv_pep508::{
Expand Down Expand Up @@ -350,6 +350,13 @@ impl Display for Requirement {
if let Some(subdirectory) = subdirectory {
writeln!(f, "#subdirectory={}", subdirectory.display())?;
}
if git.lfs().enabled() {
writeln!(
f,
"{}lfs=true",
if subdirectory.is_some() { "&" } else { "#" }
)?;
}
}
RequirementSource::Path { url, .. } => {
write!(f, " @ {url}")?;
Expand Down Expand Up @@ -436,6 +443,9 @@ impl CacheKey for Requirement {
} else {
0u8.cache_key(state);
}
if git.lfs().enabled() {
1u8.cache_key(state);
}
url.cache_key(state);
}
RequirementSource::Path {
Expand Down Expand Up @@ -765,6 +775,13 @@ impl Display for RequirementSource {
if let Some(subdirectory) = subdirectory {
writeln!(f, "#subdirectory={}", subdirectory.display())?;
}
if git.lfs().enabled() {
writeln!(
f,
"{}lfs=true",
if subdirectory.is_some() { "&" } else { "#" }
)?;
}
}
Self::Path { url, .. } => {
write!(f, "{url}")?;
Expand Down Expand Up @@ -856,6 +873,11 @@ impl From<RequirementSource> for RequirementSourceWire {
.append_pair("subdirectory", &subdirectory);
}

// Persist lfs=true in the distribution metadata only when explicitly enabled.
if git.lfs().enabled() {
url.query_pairs_mut().append_pair("lfs", "true");
}

// Put the requested reference in the query.
match git.reference() {
GitReference::Branch(branch) => {
Expand Down Expand Up @@ -932,6 +954,7 @@ impl TryFrom<RequirementSourceWire> for RequirementSource {

let mut reference = GitReference::DefaultBranch;
let mut subdirectory: Option<PortablePathBuf> = None;
let mut lfs = GitLfs::Disabled;
for (key, val) in repository.query_pairs() {
match &*key {
"tag" => reference = GitReference::Tag(val.into_owned()),
Expand All @@ -940,6 +963,7 @@ impl TryFrom<RequirementSourceWire> for RequirementSource {
"subdirectory" => {
subdirectory = Some(PortablePathBuf::from(val.as_ref()));
}
"lfs" => lfs = GitLfs::from(val.eq_ignore_ascii_case("true")),
_ => {}
}
}
Expand All @@ -959,13 +983,22 @@ impl TryFrom<RequirementSourceWire> for RequirementSource {
let path = format!("{}@{}", url.path(), rev);
url.set_path(&path);
}
let mut frags: Vec<String> = Vec::new();
if let Some(subdirectory) = subdirectory.as_ref() {
url.set_fragment(Some(&format!("subdirectory={subdirectory}")));
frags.push(format!("subdirectory={subdirectory}"));
}
// Preserve that we're using Git LFS in the Verbatim Url representations
if lfs.enabled() {
frags.push("lfs=true".to_string());
}
if !frags.is_empty() {
url.set_fragment(Some(&frags.join("&")));
}

let url = VerbatimUrl::from_url(url);

Ok(Self::Git {
git: GitUrl::from_fields(repository, reference, precise)?,
git: GitUrl::from_fields(repository, reference, precise, lfs)?,
subdirectory: subdirectory.map(Box::<Path>::from),
url,
})
Expand Down
Loading
Loading