From 8a04e1b88d367c6b9ef15721de5da9dd3f7a268f Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Wed, 27 Aug 2025 15:17:20 -0500 Subject: [PATCH] Add `lfs = true` support in Git source declarations --- .../uv-distribution-types/src/requirement.rs | 4 +- .../uv-distribution/src/metadata/lowering.rs | 7 +- .../src/metadata/requires_dist.rs | 6 +- crates/uv-git-types/src/lib.rs | 62 +++++- crates/uv-git/src/git.rs | 10 +- crates/uv-git/src/source.rs | 1 + ...xt__test__line-endings-whitespace.txt.snap | 1 + ...ments_txt__test__parse-whitespace.txt.snap | 1 + crates/uv-resolver/src/lock/mod.rs | 7 +- crates/uv-workspace/src/pyproject.rs | 8 + crates/uv/src/commands/project/add.rs | 2 + crates/uv/tests/it/sync.rs | 207 ++++++++++++++++++ uv.schema.json | 7 + 13 files changed, 308 insertions(+), 15 deletions(-) diff --git a/crates/uv-distribution-types/src/requirement.rs b/crates/uv-distribution-types/src/requirement.rs index 25676d999..543c7e155 100644 --- a/crates/uv-distribution-types/src/requirement.rs +++ b/crates/uv-distribution-types/src/requirement.rs @@ -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::{ @@ -965,7 +965,7 @@ impl TryFrom for RequirementSource { let url = VerbatimUrl::from_url(url); Ok(Self::Git { - git: GitUrl::from_fields(repository, reference, precise)?, + git: GitUrl::from_fields(repository, reference, precise, GitLfs::from_env())?, subdirectory: subdirectory.map(Box::::from), url, }) diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index 36b6ff3da..e55c4addb 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -166,6 +166,7 @@ impl LoweredRequirement { rev, tag, branch, + lfs, marker, .. } => { @@ -175,6 +176,7 @@ impl LoweredRequirement { rev, tag, branch, + lfs, )?; (source, marker) } @@ -406,6 +408,7 @@ impl LoweredRequirement { rev, tag, branch, + lfs, marker, .. } => { @@ -415,6 +418,7 @@ impl LoweredRequirement { rev, tag, branch, + lfs, )?; (source, marker) } @@ -580,6 +584,7 @@ fn git_source( rev: Option, tag: Option, branch: Option, + lfs: Option, ) -> Result { let reference = match (rev, tag, branch) { (None, None, None) => GitReference::DefaultBranch, @@ -607,7 +612,7 @@ fn git_source( Ok(RequirementSource::Git { url, - git: GitUrl::from_reference(repository, reference)?, + git: GitUrl::from_fields(repository, reference, None, lfs.into())?, subdirectory, }) } diff --git a/crates/uv-distribution/src/metadata/requires_dist.rs b/crates/uv-distribution/src/metadata/requires_dist.rs index a5645c126..2410729a8 100644 --- a/crates/uv-distribution/src/metadata/requires_dist.rs +++ b/crates/uv-distribution/src/metadata/requires_dist.rs @@ -540,13 +540,13 @@ mod test { tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" } "#}; - assert_snapshot!(format_err(input).await, @r###" + assert_snapshot!(format_err(input).await, @r#" error: TOML parse error at line 8, column 48 | 8 | tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" } | ^^^ - unknown field `ref`, expected one of `git`, `subdirectory`, `rev`, `tag`, `branch`, `url`, `path`, `editable`, `package`, `index`, `workspace`, `marker`, `extra`, `group` - "###); + unknown field `ref`, expected one of `git`, `subdirectory`, `rev`, `tag`, `branch`, `lfs`, `url`, `path`, `editable`, `package`, `index`, `workspace`, `marker`, `extra`, `group` + "#); } #[tokio::test] diff --git a/crates/uv-git-types/src/lib.rs b/crates/uv-git-types/src/lib.rs index dbfa02ea3..b6c1a046d 100644 --- a/crates/uv-git-types/src/lib.rs +++ b/crates/uv-git-types/src/lib.rs @@ -9,6 +9,48 @@ mod github; mod oid; mod reference; +/// Configuration for Git LFS (Large File Storage) support. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Default)] +pub enum GitLfs { + /// Git LFS is disabled (default). + #[default] + Disabled, + /// Git LFS is enabled. + Enabled, +} + +impl GitLfs { + /// Create a `GitLfs` configuration from environment variables. + pub fn from_env() -> Self { + if std::env::var("UV_GIT_LFS").is_ok() { + Self::Enabled + } else { + Self::Disabled + } + } + + /// Returns true if LFS is enabled. + pub fn enabled(self) -> bool { + matches!(self, Self::Enabled) + } +} + +impl From> for GitLfs { + fn from(value: Option) -> Self { + match value { + Some(true) => Self::Enabled, + Some(false) => Self::Disabled, + None => Self::from_env(), + } + } +} + +impl From for GitLfs { + fn from(value: bool) -> Self { + if value { Self::Enabled } else { Self::Disabled } + } +} + #[derive(Debug, Error)] pub enum GitUrlParseError { #[error( @@ -27,6 +69,8 @@ pub struct GitUrl { reference: GitReference, /// The precise commit to use, if known. precise: Option, + /// Git LFS configuration for this repository. + lfs: GitLfs, } impl GitUrl { @@ -35,7 +79,7 @@ impl GitUrl { repository: DisplaySafeUrl, reference: GitReference, ) -> Result { - Self::from_fields(repository, reference, None) + Self::from_fields(repository, reference, None, GitLfs::from_env()) } /// Create a new [`GitUrl`] from a repository URL and a precise commit. @@ -44,7 +88,7 @@ impl GitUrl { reference: GitReference, precise: GitOid, ) -> Result { - Self::from_fields(repository, reference, Some(precise)) + Self::from_fields(repository, reference, Some(precise), GitLfs::from_env()) } /// Create a new [`GitUrl`] from a repository URL and a precise commit, if known. @@ -52,6 +96,7 @@ impl GitUrl { repository: DisplaySafeUrl, reference: GitReference, precise: Option, + lfs: GitLfs, ) -> Result { match repository.scheme() { "http" | "https" | "ssh" | "file" => {} @@ -66,6 +111,7 @@ impl GitUrl { repository, reference, precise, + lfs, }) } @@ -97,6 +143,18 @@ impl GitUrl { pub fn precise(&self) -> Option { self.precise } + + /// Return the Git LFS configuration. + pub fn lfs(&self) -> GitLfs { + self.lfs + } + + /// Set the Git LFS configuration. + #[must_use] + pub fn with_lfs(mut self, lfs: GitLfs) -> Self { + self.lfs = lfs; + self + } } impl TryFrom for GitUrl { diff --git a/crates/uv-git/src/git.rs b/crates/uv-git/src/git.rs index 4b37a5286..691e00a5f 100644 --- a/crates/uv-git/src/git.rs +++ b/crates/uv-git/src/git.rs @@ -1,7 +1,6 @@ //! Git support is derived from Cargo's implementation. //! Cargo is dual-licensed under either Apache 2.0 or MIT, at the user's choice. //! Source: -use std::env; use std::fmt::Display; use std::path::{Path, PathBuf}; use std::str::{self}; @@ -15,7 +14,7 @@ use tracing::{debug, warn}; use url::Url; use uv_fs::Simplified; -use uv_git_types::{GitHubRepository, GitOid, GitReference}; +use uv_git_types::{GitHubRepository, GitLfs, GitOid, GitReference}; use uv_redacted::DisplaySafeUrl; use uv_static::EnvVars; use uv_version::version; @@ -238,12 +237,11 @@ impl GitRemote { client: &ClientWithMiddleware, disable_ssl: bool, offline: bool, + lfs: GitLfs, ) -> Result<(GitDatabase, GitOid)> { let reference = locked_rev .map(ReferenceOrOid::Oid) .unwrap_or(ReferenceOrOid::Reference(reference)); - let enable_lfs_fetch = env::var(EnvVars::UV_GIT_LFS).is_ok(); - if let Some(mut db) = db { fetch( &mut db.repo, @@ -261,7 +259,7 @@ impl GitRemote { }; if let Some(rev) = resolved_commit_hash { - if enable_lfs_fetch { + if lfs.enabled() { fetch_lfs(&mut db.repo, &self.url, &rev, disable_ssl) .with_context(|| format!("failed to fetch LFS objects at {rev}"))?; } @@ -293,7 +291,7 @@ impl GitRemote { Some(rev) => rev, None => reference.resolve(&repo)?, }; - if enable_lfs_fetch { + if lfs.enabled() { fetch_lfs(&mut repo, &self.url, &rev, disable_ssl) .with_context(|| format!("failed to fetch LFS objects at {rev}"))?; } diff --git a/crates/uv-git/src/source.rs b/crates/uv-git/src/source.rs index cb6d0a24f..30958f741 100644 --- a/crates/uv-git/src/source.rs +++ b/crates/uv-git/src/source.rs @@ -135,6 +135,7 @@ impl GitSource { &self.client, self.disable_ssl, self.offline, + self.git.lfs(), )?; Ok((db, actual_rev, task)) diff --git a/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__line-endings-whitespace.txt.snap b/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__line-endings-whitespace.txt.snap index 45b1cc43f..a01b3b715 100644 --- a/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__line-endings-whitespace.txt.snap +++ b/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__line-endings-whitespace.txt.snap @@ -56,6 +56,7 @@ RequirementsTxt { }, reference: DefaultBranch, precise: None, + lfs: Disabled, }, subdirectory: None, }, diff --git a/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-whitespace.txt.snap b/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-whitespace.txt.snap index 45b1cc43f..a01b3b715 100644 --- a/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-whitespace.txt.snap +++ b/crates/uv-requirements-txt/src/snapshots/uv_requirements_txt__test__parse-whitespace.txt.snap @@ -56,6 +56,7 @@ RequirementsTxt { }, reference: DefaultBranch, precise: None, + lfs: Disabled, }, subdirectory: None, }, diff --git a/crates/uv-resolver/src/lock/mod.rs b/crates/uv-resolver/src/lock/mod.rs index 7f5d37818..c677f3072 100644 --- a/crates/uv-resolver/src/lock/mod.rs +++ b/crates/uv-resolver/src/lock/mod.rs @@ -4987,7 +4987,12 @@ fn normalize_requirement( repository.set_fragment(None); repository.set_query(None); - GitUrl::from_fields(repository, git.reference().clone(), git.precise())? + GitUrl::from_fields( + repository, + git.reference().clone(), + git.precise(), + git.lfs(), + )? }; // Reconstruct the PEP 508 URL from the underlying data. diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index 82714e465..c9683b81f 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -1139,6 +1139,8 @@ pub enum Source { rev: Option, tag: Option, branch: Option, + /// Whether to use Git LFS when cloning the repository. + lfs: Option, #[serde( skip_serializing_if = "uv_pep508::marker::ser::is_empty", serialize_with = "uv_pep508::marker::ser::serialize", @@ -1235,6 +1237,7 @@ impl<'de> Deserialize<'de> for Source { rev: Option, tag: Option, branch: Option, + lfs: Option, url: Option, path: Option, editable: Option, @@ -1258,6 +1261,7 @@ impl<'de> Deserialize<'de> for Source { rev, tag, branch, + lfs, url, path, editable, @@ -1335,6 +1339,7 @@ impl<'de> Deserialize<'de> for Source { rev, tag, branch, + lfs, marker, extra, group, @@ -1652,6 +1657,7 @@ impl Source { rev, tag, branch, + lfs: None, marker: *marker, extra: extra.clone(), group: group.clone(), @@ -1765,6 +1771,7 @@ impl Source { rev: rev.cloned(), tag, branch, + lfs: None, git: git.repository().clone(), subdirectory: subdirectory.map(PortablePathBuf::from), marker: MarkerTree::TRUE, @@ -1776,6 +1783,7 @@ impl Source { rev, tag, branch, + lfs: None, git: git.repository().clone(), subdirectory: subdirectory.map(PortablePathBuf::from), marker: MarkerTree::TRUE, diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index c383744c9..ae26640a8 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -852,6 +852,7 @@ fn edits( rev, tag, branch, + lfs, marker, extra, group, @@ -870,6 +871,7 @@ fn edits( rev, tag, branch, + lfs, marker, extra, group, diff --git a/crates/uv/tests/it/sync.rs b/crates/uv/tests/it/sync.rs index ee057713a..d2f6dea81 100644 --- a/crates/uv/tests/it/sync.rs +++ b/crates/uv/tests/it/sync.rs @@ -13370,6 +13370,213 @@ fn reject_unmatched_runtime() -> Result<()> { Ok(()) } +/// Test Git LFS configuration. +#[test] +#[cfg(feature = "git")] +fn sync_git_lfs() -> Result<()> { + let context = TestContext::new("3.13"); + + // Set `lfs = true` in the source + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "test-project" + version = "0.1.0" + requires-python = ">=3.13" + dependencies = ["test-lfs-repo"] + + [tool.uv.sources] + test-lfs-repo = { git = "https://github.com/zanieb/test-lfs-repo.git", lfs = true } + "#, + )?; + + uv_snapshot!(context.filters(), context.sync(), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + test-lfs-repo==0.1.0 (from git+https://github.com/zanieb/test-lfs-repo.git@39b6b03dc0a301420b7b4e73311d799fb139ba2e) + "); + + // Verify that we can import the module and access LFS content + uv_snapshot!(context.filters(), context.python_command() + .arg("-c") + .arg("import test_lfs_repo.lfs_module"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Traceback (most recent call last): + File "", line 1, in + import test_lfs_repo.lfs_module + File "[SITE_PACKAGES]/test_lfs_repo/lfs_module.py", line 1 + version https://git-lfs.github.com/spec/v1 + ^^^^^ + SyntaxError: invalid syntax + "#); + + // `UV_GIT_LFS=false` should not override `lfs = true` + uv_snapshot!(context.filters(), context.sync().env("UV_GIT_LFS", "false").arg("--reinstall").arg("--no-cache"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + ~ test-lfs-repo==0.1.0 (from git+https://github.com/zanieb/test-lfs-repo.git@39b6b03dc0a301420b7b4e73311d799fb139ba2e) + "); + + uv_snapshot!(context.filters(), context.python_command() + .arg("-c") + .arg("import test_lfs_repo.lfs_module"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Traceback (most recent call last): + File "", line 1, in + import test_lfs_repo.lfs_module + File "[SITE_PACKAGES]/test_lfs_repo/lfs_module.py", line 1 + version https://git-lfs.github.com/spec/v1 + ^^^^^ + SyntaxError: invalid syntax + "#); + + // Set `lfs = false` in the source + pyproject_toml.write_str( + r#" + [project] + name = "test-project" + version = "0.1.0" + requires-python = ">=3.13" + dependencies = ["test-lfs-repo"] + + [tool.uv.sources] + test-lfs-repo = { git = "https://github.com/zanieb/test-lfs-repo.git", lfs = false } + "#, + )?; + + uv_snapshot!(context.filters(), context.sync().arg("--reinstall").arg("--no-cache"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + ~ test-lfs-repo==0.1.0 (from git+https://github.com/zanieb/test-lfs-repo.git@39b6b03dc0a301420b7b4e73311d799fb139ba2e) + "); + + // Verify that LFS content is missing (import should fail) + uv_snapshot!(context.filters(), context.python_command() + .arg("-c") + .arg("import test_lfs_repo.lfs_module"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Traceback (most recent call last): + File "", line 1, in + import test_lfs_repo.lfs_module + File "[SITE_PACKAGES]/test_lfs_repo/lfs_module.py", line 1 + version https://git-lfs.github.com/spec/v1 + ^^^^^ + SyntaxError: invalid syntax + "#); + + // `UV_GIT_LFS=true` should not override `lfs = false` + uv_snapshot!(context.filters(), context.sync().env("UV_GIT_LFS", "true").arg("--reinstall").arg("--no-cache"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + ~ test-lfs-repo==0.1.0 (from git+https://github.com/zanieb/test-lfs-repo.git@39b6b03dc0a301420b7b4e73311d799fb139ba2e) + "); + + uv_snapshot!(context.filters(), context.python_command() + .arg("-c") + .arg("import test_lfs_repo.lfs_module"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Traceback (most recent call last): + File "", line 1, in + import test_lfs_repo.lfs_module + File "[SITE_PACKAGES]/test_lfs_repo/lfs_module.py", line 1 + version https://git-lfs.github.com/spec/v1 + ^^^^^ + SyntaxError: invalid syntax + "#); + + // `UV_GIT_LFS = true` should work without explicit lfs flag + pyproject_toml.write_str( + r#" + [project] + name = "test-project" + version = "0.1.0" + requires-python = ">=3.13" + dependencies = ["test-lfs-repo"] + + [tool.uv.sources] + test-lfs-repo = { git = "https://github.com/zanieb/test-lfs-repo.git" } + "#, + )?; + + uv_snapshot!(context.filters(), context.sync().env("UV_GIT_LFS", "true").arg("--reinstall").arg("--no-cache"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + ~ test-lfs-repo==0.1.0 (from git+https://github.com/zanieb/test-lfs-repo.git@39b6b03dc0a301420b7b4e73311d799fb139ba2e) + "); + + // Verify that we can import the module when UV_GIT_LFS is set + uv_snapshot!(context.filters(), context.python_command() + .arg("-c") + .arg("import test_lfs_repo.lfs_module; print('LFS module imported via env var')"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + Traceback (most recent call last): + File "", line 1, in + import test_lfs_repo.lfs_module; print('LFS module imported via env var') + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + File "[SITE_PACKAGES]/test_lfs_repo/lfs_module.py", line 1 + version https://git-lfs.github.com/spec/v1 + ^^^^^ + SyntaxError: invalid syntax + "#); + + Ok(()) +} + #[test] fn sync_extra_build_dependencies_cache() -> Result<()> { let context = TestContext::new("3.12"); diff --git a/uv.schema.json b/uv.schema.json index 6deddd4be..62a0bef9d 100644 --- a/uv.schema.json +++ b/uv.schema.json @@ -1964,6 +1964,13 @@ } ] }, + "lfs": { + "description": "Whether to use Git LFS when cloning the repository.", + "type": [ + "boolean", + "null" + ] + }, "marker": { "$ref": "#/definitions/MarkerTree" },