Support Git LFS with opt-in (#16143)

## Summary

Follow up to https://github.com/astral-sh/uv/pull/15563
Closes https://github.com/astral-sh/uv/issues/13485

This is a first-pass at adding support for conditional support for Git
LFS between git sources, initial feedback welcome.

e.g.
```
[tool.uv.sources]
test-lfs-repo = { git = "https://github.com/zanieb/test-lfs-repo.git", lfs = true }
```

For context previously a user had to set `UV_GIT_LFS` to have uv fetch
lfs objects on git sources. This env var was all or nothing, meaning you
must always have it set to get consistent behavior and it applied to all
git sources. If you fetched lfs objects at a revision and then turned
off lfs (or vice versa), the git db, corresponding checkout lfs
artifacts would not be updated properly. Similarly, when git source
distributions were built, there would be no distinction between sources
with lfs and without lfs. Hence, it could corrupt the git, sdist, and
archive caches.

In order to support some sources being LFS enabled and other not, this
PR adds a stateful layer roughly similar to how `subdirectory` works but
for `lfs` since the git database, the checkouts and the corresponding
caching layers needed to be LFS aware (requested vs installed). The
caches also had to isolated and treated entirely separate when handling
LFS sources.

Summary
* Adds `lfs = true` or `lfs = false` to git sources in pyproject.toml
* Added `lfs=true` query param / fragments to most relevant url structs
(not parsed as user input)
  * In the case of uv add / uv tool, `--lfs` is supported instead
* `UV_GIT_LFS` environment variable support is still functional for
non-project entrypoints (e.g. uv pip)
* `direct-url.json` now has an custom `git_lfs` entry under VcsInfo
(note, this is not in the spec currently -- see caveats).
* git database and checkouts have an different cache key as the sources
should be treated effectively different for the same rev.
* sdists cache also differ in the cache key of a built distribution if
it was built using LFS enabled revisions to distinguish between non-LFS
same revisions. This ensures the strong assumption for archive-v0 that
an unpacked revision "doesn't change sources" stays valid.

Caveats
* `pylock.toml` import support has not been added via git_lfs=true,
going through the spec it wasn't clear to me it's something we'd support
outside of the env var (for now).
* direct-url struct was modified by adding a non-standard `git_lfs`
field under VcsInfo which may be undersirable although the PEP 610 does
say `Additional fields that would be necessary to support such VCS
SHOULD be prefixed with the VCS command name` which could be interpret
this change as ok.
* There will be a slight lockfile and cache churn for users that use
`UV_GIT_LFS` as all git lockfile entries will get a `lfs=true` fragment.
The cache version does not need an update, but LFS sources will get
their own namespace under git-v0 and sdist-v9/git hence a cache-miss
will occur once but this can be sufficient to label this as breaking for
workflows always setting `UV_GIT_LFS`.

## Test Plan

Some initial tests were added. More tests likely to follow as we reach
consensus on a final approach.

For IT test, we may want to move to use a repo under astral namespace in
order to test lfs functionality.

Manual testing was done for common pathological cases like killing LFS
fetch mid-way, uninstalling LFS after installing an sdist with it and
reinstalling, fetching LFS artifacts in different commits, etc.

PSA: Please ignore the docker build failures as its related to depot
OIDC issues.

---------

Co-authored-by: Zanie Blue <contact@zanie.dev>
Co-authored-by: konstin <konstin@mailbox.org>
This commit is contained in:
samypr100 2025-12-02 07:23:51 -05:00 committed by GitHub
parent 5947fb0c83
commit fee7f9d093
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
45 changed files with 1978 additions and 85 deletions

View File

@ -317,7 +317,7 @@ jobs:
run: | run: |
cargo nextest run \ cargo nextest run \
--no-default-features \ --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 \ --workspace \
--status-level skip --failure-output immediate-final --no-fail-fast -j 12 --final-status-level slow --status-level skip --failure-output immediate-final --no-fail-fast -j 12 --final-status-level slow

View File

@ -86,6 +86,13 @@ cargo test --package <package> --test <test> -- <test_name> -- --exact
cargo insta review 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 ### Local testing
You can invoke your development version of uv with `cargo run -- <args>`. For example: You can invoke your development version of uv with `cargo run -- <args>`. For example:

3
Cargo.lock generated
View File

@ -6138,6 +6138,7 @@ dependencies = [
"cargo-util", "cargo-util",
"dashmap", "dashmap",
"fs-err", "fs-err",
"owo-colors",
"reqwest", "reqwest",
"thiserror 2.0.17", "thiserror 2.0.17",
"tokio", "tokio",
@ -6150,6 +6151,7 @@ dependencies = [
"uv-redacted", "uv-redacted",
"uv-static", "uv-static",
"uv-version", "uv-version",
"uv-warnings",
"which", "which",
] ]
@ -6162,6 +6164,7 @@ dependencies = [
"tracing", "tracing",
"url", "url",
"uv-redacted", "uv-redacted",
"uv-static",
] ]
[[package]] [[package]]

View File

@ -139,8 +139,18 @@ impl std::fmt::Display for CanonicalUrl {
/// `https://github.com/pypa/package.git#subdirectory=pkg_b` would map to different /// `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 /// [`CanonicalUrl`] values, but the same [`RepositoryUrl`], since they map to the same
/// resource. /// 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)] #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
pub struct RepositoryUrl(DisplaySafeUrl); pub struct RepositoryUrl {
repo_url: DisplaySafeUrl,
with_lfs: Option<bool>,
}
impl RepositoryUrl { impl RepositoryUrl {
pub fn new(url: &DisplaySafeUrl) -> Self { pub fn new(url: &DisplaySafeUrl) -> Self {
@ -161,19 +171,31 @@ impl RepositoryUrl {
url.set_fragment(None); url.set_fragment(None);
url.set_query(None); url.set_query(None);
Self(url) Self {
repo_url: url,
with_lfs: None,
}
} }
pub fn parse(url: &str) -> Result<Self, DisplaySafeUrlError> { pub fn parse(url: &str) -> Result<Self, DisplaySafeUrlError> {
Ok(Self::new(&DisplaySafeUrl::parse(url)?)) 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 { impl CacheKey for RepositoryUrl {
fn cache_key(&self, state: &mut CacheKeyHasher) { fn cache_key(&self, state: &mut CacheKeyHasher) {
// `as_str` gives the serialisation of a url (which has a spec) and so insulates against // `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. // 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);
}
} }
} }
@ -181,7 +203,10 @@ impl Hash for RepositoryUrl {
fn hash<H: Hasher>(&self, state: &mut H) { fn hash<H: Hasher>(&self, state: &mut H) {
// `as_str` gives the serialisation of a url (which has a spec) and so insulates against // `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. // 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);
}
} }
} }
@ -189,13 +214,13 @@ impl Deref for RepositoryUrl {
type Target = Url; type Target = Url;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
&self.0 &self.repo_url
} }
} }
impl std::fmt::Display for RepositoryUrl { impl std::fmt::Display for RepositoryUrl {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f) std::fmt::Display::fmt(&self.repo_url, f)
} }
} }
@ -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. // Two URLs should _not_ be considered equal if they request different commit tags.
assert_ne!( assert_ne!(
CanonicalUrl::parse( CanonicalUrl::parse(
@ -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(()) Ok(())
} }
} }

View File

@ -15,7 +15,7 @@ pub enum WheelCache<'a> {
Path(&'a DisplaySafeUrl), Path(&'a DisplaySafeUrl),
/// An editable dependency, which we key by URL. /// An editable dependency, which we key by URL.
Editable(&'a DisplaySafeUrl), 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 /// Note that this variant only exists for source distributions; wheels can't be delivered
/// through Git. /// through Git.

View File

@ -4104,6 +4104,10 @@ pub struct AddArgs {
#[arg(long, group = "git-ref", action = clap::ArgAction::Set)] #[arg(long, group = "git-ref", action = clap::ArgAction::Set)]
pub branch: Option<String>, 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. /// Extras to enable for the dependency.
/// ///
/// May be provided more than once. /// May be provided more than once.
@ -5070,6 +5074,10 @@ pub struct ToolRunArgs {
#[command(flatten)] #[command(flatten)]
pub refresh: RefreshArgs, 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. /// The Python interpreter to use to build the run environment.
/// ///
/// See `uv help python` for details on Python discovery and supported request formats. /// See `uv help python` for details on Python discovery and supported request formats.
@ -5217,6 +5225,10 @@ pub struct ToolInstallArgs {
#[arg(long)] #[arg(long)]
pub force: bool, 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. /// The Python interpreter to use to build the tool environment.
/// ///
/// See `uv help python` for details on Python discovery and supported request formats. /// See `uv help python` for details on Python discovery and supported request formats.

View File

@ -7,7 +7,7 @@ use thiserror::Error;
use uv_cache_key::{CacheKey, CacheKeyHasher}; use uv_cache_key::{CacheKey, CacheKeyHasher};
use uv_distribution_filename::DistExtension; use uv_distribution_filename::DistExtension;
use uv_fs::{CWD, PortablePath, PortablePathBuf, relative_to}; 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_normalize::{ExtraName, GroupName, PackageName};
use uv_pep440::VersionSpecifiers; use uv_pep440::VersionSpecifiers;
use uv_pep508::{ use uv_pep508::{
@ -350,6 +350,13 @@ impl Display for Requirement {
if let Some(subdirectory) = subdirectory { if let Some(subdirectory) = subdirectory {
writeln!(f, "#subdirectory={}", subdirectory.display())?; writeln!(f, "#subdirectory={}", subdirectory.display())?;
} }
if git.lfs().enabled() {
writeln!(
f,
"{}lfs=true",
if subdirectory.is_some() { "&" } else { "#" }
)?;
}
} }
RequirementSource::Path { url, .. } => { RequirementSource::Path { url, .. } => {
write!(f, " @ {url}")?; write!(f, " @ {url}")?;
@ -436,6 +443,9 @@ impl CacheKey for Requirement {
} else { } else {
0u8.cache_key(state); 0u8.cache_key(state);
} }
if git.lfs().enabled() {
1u8.cache_key(state);
}
url.cache_key(state); url.cache_key(state);
} }
RequirementSource::Path { RequirementSource::Path {
@ -765,6 +775,13 @@ impl Display for RequirementSource {
if let Some(subdirectory) = subdirectory { if let Some(subdirectory) = subdirectory {
writeln!(f, "#subdirectory={}", subdirectory.display())?; writeln!(f, "#subdirectory={}", subdirectory.display())?;
} }
if git.lfs().enabled() {
writeln!(
f,
"{}lfs=true",
if subdirectory.is_some() { "&" } else { "#" }
)?;
}
} }
Self::Path { url, .. } => { Self::Path { url, .. } => {
write!(f, "{url}")?; write!(f, "{url}")?;
@ -856,6 +873,11 @@ impl From<RequirementSource> for RequirementSourceWire {
.append_pair("subdirectory", &subdirectory); .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. // Put the requested reference in the query.
match git.reference() { match git.reference() {
GitReference::Branch(branch) => { GitReference::Branch(branch) => {
@ -932,6 +954,7 @@ impl TryFrom<RequirementSourceWire> for RequirementSource {
let mut reference = GitReference::DefaultBranch; let mut reference = GitReference::DefaultBranch;
let mut subdirectory: Option<PortablePathBuf> = None; let mut subdirectory: Option<PortablePathBuf> = None;
let mut lfs = GitLfs::Disabled;
for (key, val) in repository.query_pairs() { for (key, val) in repository.query_pairs() {
match &*key { match &*key {
"tag" => reference = GitReference::Tag(val.into_owned()), "tag" => reference = GitReference::Tag(val.into_owned()),
@ -940,6 +963,7 @@ impl TryFrom<RequirementSourceWire> for RequirementSource {
"subdirectory" => { "subdirectory" => {
subdirectory = Some(PortablePathBuf::from(val.as_ref())); subdirectory = Some(PortablePathBuf::from(val.as_ref()));
} }
"lfs" => lfs = GitLfs::from(val.eq_ignore_ascii_case("true")),
_ => {} _ => {}
} }
} }
@ -959,13 +983,22 @@ impl TryFrom<RequirementSourceWire> for RequirementSource {
let path = format!("{}@{}", url.path(), rev); let path = format!("{}@{}", url.path(), rev);
url.set_path(&path); url.set_path(&path);
} }
let mut frags: Vec<String> = Vec::new();
if let Some(subdirectory) = subdirectory.as_ref() { 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); let url = VerbatimUrl::from_url(url);
Ok(Self::Git { Ok(Self::Git {
git: GitUrl::from_fields(repository, reference, precise)?, git: GitUrl::from_fields(repository, reference, precise, lfs)?,
subdirectory: subdirectory.map(Box::<Path>::from), subdirectory: subdirectory.map(Box::<Path>::from),
url, url,
}) })

View File

@ -1,7 +1,7 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use uv_git_types::GitReference; use uv_git_types::{GitLfs, GitReference};
use uv_normalize::ExtraName; use uv_normalize::ExtraName;
use uv_pep508::{MarkerEnvironment, MarkerTree, UnnamedRequirement}; use uv_pep508::{MarkerEnvironment, MarkerTree, UnnamedRequirement};
use uv_pypi_types::{Hashes, ParsedUrl}; use uv_pypi_types::{Hashes, ParsedUrl};
@ -75,6 +75,7 @@ impl UnresolvedRequirement {
rev: Option<&str>, rev: Option<&str>,
tag: Option<&str>, tag: Option<&str>,
branch: Option<&str>, branch: Option<&str>,
lfs: Option<bool>,
marker: Option<MarkerTree>, marker: Option<MarkerTree>,
) -> Self { ) -> Self {
#[allow(clippy::manual_map)] #[allow(clippy::manual_map)]
@ -107,6 +108,11 @@ impl UnresolvedRequirement {
} else { } else {
git git
}; };
let git = if let Some(lfs) = lfs {
git.with_lfs(GitLfs::from(lfs))
} else {
git
};
RequirementSource::Git { RequirementSource::Git {
git, git,
subdirectory, subdirectory,
@ -129,6 +135,9 @@ impl UnresolvedRequirement {
if let Some(git_reference) = git_reference { if let Some(git_reference) = git_reference {
git.url = git.url.with_reference(git_reference); git.url = git.url.with_reference(git_reference);
} }
if let Some(lfs) = lfs {
git.url = git.url.with_lfs(GitLfs::from(lfs));
}
VerbatimParsedUrl { VerbatimParsedUrl {
parsed_url: ParsedUrl::Git(git), parsed_url: ParsedUrl::Git(git),
verbatim: requirement.url.verbatim, verbatim: requirement.url.verbatim,

View File

@ -9,6 +9,7 @@ use uv_client::WrappedReqwestError;
use uv_distribution_filename::WheelFilenameError; use uv_distribution_filename::WheelFilenameError;
use uv_distribution_types::{InstalledDist, InstalledDistError, IsBuildBackendError}; use uv_distribution_types::{InstalledDist, InstalledDistError, IsBuildBackendError};
use uv_fs::Simplified; use uv_fs::Simplified;
use uv_git::GitError;
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_pep440::{Version, VersionSpecifiers}; use uv_pep440::{Version, VersionSpecifiers};
use uv_pypi_types::{HashAlgorithm, HashDigest}; use uv_pypi_types::{HashAlgorithm, HashDigest};
@ -88,6 +89,8 @@ pub enum Error {
MissingPkgInfo, MissingPkgInfo,
#[error("The source distribution `{}` has no subdirectory `{}`", _0, _1.display())] #[error("The source distribution `{}` has no subdirectory `{}`", _0, _1.display())]
MissingSubdirectory(DisplaySafeUrl, PathBuf), MissingSubdirectory(DisplaySafeUrl, PathBuf),
#[error("The source distribution `{0}` is missing Git LFS artifacts.")]
MissingGitLfsArtifacts(DisplaySafeUrl, #[source] GitError),
#[error("Failed to extract static metadata from `PKG-INFO`")] #[error("Failed to extract static metadata from `PKG-INFO`")]
PkgInfo(#[source] uv_pypi_types::MetadataError), PkgInfo(#[source] uv_pypi_types::MetadataError),
#[error("Failed to extract metadata from `requires.txt`")] #[error("Failed to extract metadata from `requires.txt`")]

View File

@ -9,7 +9,7 @@ use uv_distribution_filename::DistExtension;
use uv_distribution_types::{ use uv_distribution_types::{
Index, IndexLocations, IndexMetadata, IndexName, Origin, Requirement, RequirementSource, Index, IndexLocations, IndexMetadata, IndexName, Origin, Requirement, RequirementSource,
}; };
use uv_git_types::{GitReference, GitUrl, GitUrlParseError}; use uv_git_types::{GitLfs, GitReference, GitUrl, GitUrlParseError};
use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_normalize::{ExtraName, GroupName, PackageName};
use uv_pep440::VersionSpecifiers; use uv_pep440::VersionSpecifiers;
use uv_pep508::{MarkerTree, VerbatimUrl, VersionOrUrl, looks_like_git_repository}; use uv_pep508::{MarkerTree, VerbatimUrl, VersionOrUrl, looks_like_git_repository};
@ -166,6 +166,7 @@ impl LoweredRequirement {
rev, rev,
tag, tag,
branch, branch,
lfs,
marker, marker,
.. ..
} => { } => {
@ -175,6 +176,7 @@ impl LoweredRequirement {
rev, rev,
tag, tag,
branch, branch,
lfs,
)?; )?;
(source, marker) (source, marker)
} }
@ -407,6 +409,7 @@ impl LoweredRequirement {
rev, rev,
tag, tag,
branch, branch,
lfs,
marker, marker,
.. ..
} => { } => {
@ -416,6 +419,7 @@ impl LoweredRequirement {
rev, rev,
tag, tag,
branch, branch,
lfs,
)?; )?;
(source, marker) (source, marker)
} }
@ -580,6 +584,7 @@ fn git_source(
rev: Option<String>, rev: Option<String>,
tag: Option<String>, tag: Option<String>,
branch: Option<String>, branch: Option<String>,
lfs: Option<bool>,
) -> Result<RequirementSource, LoweringError> { ) -> Result<RequirementSource, LoweringError> {
let reference = match (rev, tag, branch) { let reference = match (rev, tag, branch) {
(None, None, None) => GitReference::DefaultBranch, (None, None, None) => GitReference::DefaultBranch,
@ -595,19 +600,32 @@ fn git_source(
let path = format!("{}@{}", url.path(), rev); let path = format!("{}@{}", url.path(), rev);
url.set_path(&path); url.set_path(&path);
} }
let mut frags: Vec<String> = Vec::new();
if let Some(subdirectory) = subdirectory.as_ref() { if let Some(subdirectory) = subdirectory.as_ref() {
let subdirectory = subdirectory let subdirectory = subdirectory
.to_str() .to_str()
.ok_or_else(|| LoweringError::NonUtf8Path(subdirectory.to_path_buf()))?; .ok_or_else(|| LoweringError::NonUtf8Path(subdirectory.to_path_buf()))?;
url.set_fragment(Some(&format!("subdirectory={subdirectory}"))); frags.push(format!("subdirectory={subdirectory}"));
} }
// Loads Git LFS Enablement according to priority.
// First: lfs = true, lfs = false from pyproject.toml
// Second: UV_GIT_LFS from environment
let lfs = GitLfs::from(lfs);
// 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); let url = VerbatimUrl::from_url(url);
let repository = git.clone(); let repository = git.clone();
Ok(RequirementSource::Git { Ok(RequirementSource::Git {
url, url,
git: GitUrl::from_reference(repository, reference)?, git: GitUrl::from_fields(repository, reference, None, lfs)?,
subdirectory, subdirectory,
}) })
} }

View File

@ -542,13 +542,13 @@ mod test {
tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" } 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 error: TOML parse error at line 8, column 48
| |
8 | tqdm = { git = "https://github.com/tqdm/tqdm", ref = "baaaaaab" } 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] #[tokio::test]

View File

@ -37,6 +37,7 @@ use uv_distribution_types::{
}; };
use uv_extract::hash::Hasher; use uv_extract::hash::Hasher;
use uv_fs::{rename_with_retry, write_atomic}; use uv_fs::{rename_with_retry, write_atomic};
use uv_git::{GIT_LFS, GitError};
use uv_git_types::{GitHubRepository, GitOid}; use uv_git_types::{GitHubRepository, GitOid};
use uv_metadata::read_archive_metadata; use uv_metadata::read_archive_metadata;
use uv_normalize::PackageName; use uv_normalize::PackageName;
@ -1561,6 +1562,20 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
} }
} }
// Validate that LFS artifacts were fully initialized
if resource.git.lfs().enabled() && !fetch.lfs_ready() {
if GIT_LFS.is_err() {
return Err(Error::MissingGitLfsArtifacts(
resource.url.to_url(),
GitError::GitLfsNotFound,
));
}
return Err(Error::MissingGitLfsArtifacts(
resource.url.to_url(),
GitError::GitLfsNotConfigured,
));
}
let git_sha = fetch.git().precise().expect("Exact commit after checkout"); let git_sha = fetch.git().precise().expect("Exact commit after checkout");
let cache_shard = self.build_context.cache().shard( let cache_shard = self.build_context.cache().shard(
CacheBucket::SourceDistributions, CacheBucket::SourceDistributions,
@ -1761,6 +1776,20 @@ impl<'a, T: BuildContext> SourceDistributionBuilder<'a, T> {
} }
} }
// Validate that LFS artifacts were fully initialized
if resource.git.lfs().enabled() && !fetch.lfs_ready() {
if GIT_LFS.is_err() {
return Err(Error::MissingGitLfsArtifacts(
resource.url.to_url(),
GitError::GitLfsNotFound,
));
}
return Err(Error::MissingGitLfsArtifacts(
resource.url.to_url(),
GitError::GitLfsNotConfigured,
));
}
let git_sha = fetch.git().precise().expect("Exact commit after checkout"); let git_sha = fetch.git().precise().expect("Exact commit after checkout");
let cache_shard = self.build_context.cache().shard( let cache_shard = self.build_context.cache().shard(
CacheBucket::SourceDistributions, CacheBucket::SourceDistributions,

View File

@ -17,6 +17,7 @@ workspace = true
[dependencies] [dependencies]
uv-redacted = { workspace = true } uv-redacted = { workspace = true }
uv-static = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }

View File

@ -1,14 +1,76 @@
pub use crate::github::GitHubRepository; pub use crate::github::GitHubRepository;
pub use crate::oid::{GitOid, OidParseError}; pub use crate::oid::{GitOid, OidParseError};
pub use crate::reference::GitReference; pub use crate::reference::GitReference;
use std::sync::LazyLock;
use thiserror::Error; use thiserror::Error;
use uv_redacted::DisplaySafeUrl; use uv_redacted::DisplaySafeUrl;
use uv_static::EnvVars;
mod github; mod github;
mod oid; mod oid;
mod reference; mod reference;
/// Initialize [`GitLfs`] mode from `UV_GIT_LFS` environment.
pub static UV_GIT_LFS: LazyLock<GitLfs> = LazyLock::new(|| {
// TODO(konsti): Parse this in `EnvironmentOptions`.
if std::env::var_os(EnvVars::UV_GIT_LFS)
.and_then(|v| v.to_str().map(str::to_lowercase))
.is_some_and(|v| matches!(v.as_str(), "y" | "yes" | "t" | "true" | "on" | "1"))
{
GitLfs::Enabled
} else {
GitLfs::Disabled
}
});
/// 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 {
*UV_GIT_LFS
}
/// Returns true if LFS is enabled.
pub fn enabled(self) -> bool {
matches!(self, Self::Enabled)
}
}
impl From<Option<bool>> for GitLfs {
fn from(value: Option<bool>) -> Self {
match value {
Some(true) => Self::Enabled,
Some(false) => Self::Disabled,
None => Self::from_env(),
}
}
}
impl From<bool> for GitLfs {
fn from(value: bool) -> Self {
if value { Self::Enabled } else { Self::Disabled }
}
}
impl std::fmt::Display for GitLfs {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Enabled => write!(f, "enabled"),
Self::Disabled => write!(f, "disabled"),
}
}
}
#[derive(Debug, Error)] #[derive(Debug, Error)]
pub enum GitUrlParseError { pub enum GitUrlParseError {
#[error( #[error(
@ -27,6 +89,8 @@ pub struct GitUrl {
reference: GitReference, reference: GitReference,
/// The precise commit to use, if known. /// The precise commit to use, if known.
precise: Option<GitOid>, precise: Option<GitOid>,
/// Git LFS configuration for this repository.
lfs: GitLfs,
} }
impl GitUrl { impl GitUrl {
@ -34,8 +98,9 @@ impl GitUrl {
pub fn from_reference( pub fn from_reference(
repository: DisplaySafeUrl, repository: DisplaySafeUrl,
reference: GitReference, reference: GitReference,
lfs: GitLfs,
) -> Result<Self, GitUrlParseError> { ) -> Result<Self, GitUrlParseError> {
Self::from_fields(repository, reference, None) Self::from_fields(repository, reference, None, lfs)
} }
/// Create a new [`GitUrl`] from a repository URL and a precise commit. /// Create a new [`GitUrl`] from a repository URL and a precise commit.
@ -43,8 +108,9 @@ impl GitUrl {
repository: DisplaySafeUrl, repository: DisplaySafeUrl,
reference: GitReference, reference: GitReference,
precise: GitOid, precise: GitOid,
lfs: GitLfs,
) -> Result<Self, GitUrlParseError> { ) -> Result<Self, GitUrlParseError> {
Self::from_fields(repository, reference, Some(precise)) Self::from_fields(repository, reference, Some(precise), lfs)
} }
/// Create a new [`GitUrl`] from a repository URL and a precise commit, if known. /// Create a new [`GitUrl`] from a repository URL and a precise commit, if known.
@ -52,6 +118,7 @@ impl GitUrl {
repository: DisplaySafeUrl, repository: DisplaySafeUrl,
reference: GitReference, reference: GitReference,
precise: Option<GitOid>, precise: Option<GitOid>,
lfs: GitLfs,
) -> Result<Self, GitUrlParseError> { ) -> Result<Self, GitUrlParseError> {
match repository.scheme() { match repository.scheme() {
"http" | "https" | "ssh" | "file" => {} "http" | "https" | "ssh" | "file" => {}
@ -66,6 +133,7 @@ impl GitUrl {
repository, repository,
reference, reference,
precise, precise,
lfs,
}) })
} }
@ -97,6 +165,18 @@ impl GitUrl {
pub fn precise(&self) -> Option<GitOid> { pub fn precise(&self) -> Option<GitOid> {
self.precise 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<DisplaySafeUrl> for GitUrl { impl TryFrom<DisplaySafeUrl> for GitUrl {
@ -120,7 +200,8 @@ impl TryFrom<DisplaySafeUrl> for GitUrl {
url.set_path(&prefix); url.set_path(&prefix);
} }
Self::from_reference(url, reference) // TODO(samypr100): GitLfs::from_env() for now unless we want to support parsing lfs=true
Self::from_reference(url, reference, GitLfs::from_env())
} }
} }

View File

@ -23,11 +23,13 @@ uv-git-types = { workspace = true }
uv-redacted = { workspace = true } uv-redacted = { workspace = true }
uv-static = { workspace = true } uv-static = { workspace = true }
uv-version = { workspace = true } uv-version = { workspace = true }
uv-warnings = { workspace = true }
anyhow = { workspace = true } anyhow = { workspace = true }
cargo-util = { workspace = true } cargo-util = { workspace = true }
dashmap = { workspace = true } dashmap = { workspace = true }
fs-err = { workspace = true, features = ["tokio"] } fs-err = { workspace = true, features = ["tokio"] }
owo-colors = { workspace = true }
reqwest = { workspace = true, features = ["blocking"] } reqwest = { workspace = true, features = ["blocking"] }
reqwest-middleware = { workspace = true } reqwest-middleware = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }

View File

@ -6,15 +6,17 @@ use std::path::{Path, PathBuf};
use std::str::{self}; use std::str::{self};
use std::sync::LazyLock; use std::sync::LazyLock;
use anyhow::{Context, Result}; use anyhow::{Context, Result, anyhow};
use cargo_util::{ProcessBuilder, paths}; use cargo_util::{ProcessBuilder, paths};
use tracing::{debug, warn}; use owo_colors::OwoColorize;
use tracing::{debug, instrument, warn};
use url::Url; use url::Url;
use uv_fs::Simplified; use uv_fs::Simplified;
use uv_git_types::{GitOid, GitReference}; use uv_git_types::{GitOid, GitReference};
use uv_redacted::DisplaySafeUrl; use uv_redacted::DisplaySafeUrl;
use uv_static::EnvVars; use uv_static::EnvVars;
use uv_warnings::warn_user_once;
/// A file indicates that if present, `git reset` has been done and a repo /// A file indicates that if present, `git reset` has been done and a repo
/// checkout is ready to go. See [`GitCheckout::reset`] for why we need this. /// checkout is ready to go. See [`GitCheckout::reset`] for why we need this.
@ -24,6 +26,10 @@ const CHECKOUT_READY_LOCK: &str = ".ok";
pub enum GitError { pub enum GitError {
#[error("Git executable not found. Ensure that Git is installed and available.")] #[error("Git executable not found. Ensure that Git is installed and available.")]
GitNotFound, GitNotFound,
#[error("Git LFS extension not found. Ensure that Git LFS is installed and available.")]
GitLfsNotFound,
#[error("Is Git LFS configured? Run `{}` to initialize Git LFS.", "git lfs install".green())]
GitLfsNotConfigured,
#[error(transparent)] #[error(transparent)]
Other(#[from] which::Error), Other(#[from] which::Error),
#[error( #[error(
@ -137,6 +143,8 @@ pub(crate) struct GitRemote {
pub(crate) struct GitDatabase { pub(crate) struct GitDatabase {
/// Underlying Git repository instance for this database. /// Underlying Git repository instance for this database.
repo: GitRepository, repo: GitRepository,
/// Git LFS artifacts have been initialized (if requested).
lfs_ready: Option<bool>,
} }
/// A local checkout of a particular revision from a [`GitRepository`]. /// A local checkout of a particular revision from a [`GitRepository`].
@ -145,6 +153,8 @@ pub(crate) struct GitCheckout {
revision: GitOid, revision: GitOid,
/// Underlying Git repository instance for this checkout. /// Underlying Git repository instance for this checkout.
repo: GitRepository, repo: GitRepository,
/// Git LFS artifacts have been initialized (if requested).
lfs_ready: Option<bool>,
} }
/// A local Git repository. /// A local Git repository.
@ -198,6 +208,43 @@ impl GitRepository {
result.truncate(result.trim_end().len()); result.truncate(result.trim_end().len());
Ok(result.parse()?) Ok(result.parse()?)
} }
/// Verifies LFS artifacts have been initialized for a given `refname`.
#[instrument(skip_all, fields(path = %self.path.user_display(), refname = %refname))]
fn lfs_fsck_objects(&self, refname: &str) -> bool {
let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
lfs.clone()
} else {
warn!("Git LFS is not available, skipping LFS fetch");
return false;
};
// Requires Git LFS 3.x (2021 release)
let result = cmd
.arg("fsck")
.arg("--objects")
.arg(refname)
.cwd(&self.path)
.exec_with_output();
match result {
Ok(_) => true,
Err(err) => {
let lfs_error = err.to_string();
if lfs_error.contains("unknown flag: --objects") {
warn_user_once!(
"Skipping Git LFS validation as Git LFS extension is outdated. \
Upgrade to `git-lfs>=3.0.2` or manually verify git-lfs objects were \
properly fetched after the current operation finishes."
);
true
} else {
debug!("Git LFS validation failed: {err}");
false
}
}
}
}
} }
impl GitRemote { impl GitRemote {
@ -231,12 +278,11 @@ impl GitRemote {
locked_rev: Option<GitOid>, locked_rev: Option<GitOid>,
disable_ssl: bool, disable_ssl: bool,
offline: bool, offline: bool,
with_lfs: bool,
) -> Result<(GitDatabase, GitOid)> { ) -> Result<(GitDatabase, GitOid)> {
let reference = locked_rev let reference = locked_rev
.map(ReferenceOrOid::Oid) .map(ReferenceOrOid::Oid)
.unwrap_or(ReferenceOrOid::Reference(reference)); .unwrap_or(ReferenceOrOid::Reference(reference));
let enable_lfs_fetch = std::env::var(EnvVars::UV_GIT_LFS).is_ok();
if let Some(mut db) = db { if let Some(mut db) = db {
fetch(&mut db.repo, &self.url, reference, disable_ssl, offline) fetch(&mut db.repo, &self.url, reference, disable_ssl, offline)
.with_context(|| format!("failed to fetch into: {}", into.user_display()))?; .with_context(|| format!("failed to fetch into: {}", into.user_display()))?;
@ -247,9 +293,10 @@ impl GitRemote {
}; };
if let Some(rev) = resolved_commit_hash { if let Some(rev) = resolved_commit_hash {
if enable_lfs_fetch { if with_lfs {
fetch_lfs(&mut db.repo, &self.url, &rev, disable_ssl) let lfs_ready = fetch_lfs(&mut db.repo, &self.url, &rev, disable_ssl)
.with_context(|| format!("failed to fetch LFS objects at {rev}"))?; .with_context(|| format!("failed to fetch LFS objects at {rev}"))?;
db = db.with_lfs_ready(Some(lfs_ready));
} }
return Ok((db, rev)); return Ok((db, rev));
} }
@ -272,19 +319,24 @@ impl GitRemote {
Some(rev) => rev, Some(rev) => rev,
None => reference.resolve(&repo)?, None => reference.resolve(&repo)?,
}; };
if enable_lfs_fetch { let lfs_ready = with_lfs
fetch_lfs(&mut repo, &self.url, &rev, disable_ssl) .then(|| {
.with_context(|| format!("failed to fetch LFS objects at {rev}"))?; fetch_lfs(&mut repo, &self.url, &rev, disable_ssl)
} .with_context(|| format!("failed to fetch LFS objects at {rev}"))
})
.transpose()?;
Ok((GitDatabase { repo }, rev)) Ok((GitDatabase { repo, lfs_ready }, rev))
} }
/// Creates a [`GitDatabase`] of this remote at `db_path`. /// Creates a [`GitDatabase`] of this remote at `db_path`.
#[allow(clippy::unused_self)] #[allow(clippy::unused_self)]
pub(crate) fn db_at(&self, db_path: &Path) -> Result<GitDatabase> { pub(crate) fn db_at(&self, db_path: &Path) -> Result<GitDatabase> {
let repo = GitRepository::open(db_path)?; let repo = GitRepository::open(db_path)?;
Ok(GitDatabase { repo }) Ok(GitDatabase {
repo,
lfs_ready: None,
})
} }
} }
@ -300,7 +352,7 @@ impl GitDatabase {
.map(|repo| GitCheckout::new(rev, repo)) .map(|repo| GitCheckout::new(rev, repo))
.filter(GitCheckout::is_fresh) .filter(GitCheckout::is_fresh)
{ {
Some(co) => co, Some(co) => co.with_lfs_ready(self.lfs_ready),
None => GitCheckout::clone_into(destination, self, rev)?, None => GitCheckout::clone_into(destination, self, rev)?,
}; };
Ok(checkout) Ok(checkout)
@ -324,6 +376,18 @@ impl GitDatabase {
pub(crate) fn contains(&self, oid: GitOid) -> bool { pub(crate) fn contains(&self, oid: GitOid) -> bool {
self.repo.rev_parse(&format!("{oid}^0")).is_ok() self.repo.rev_parse(&format!("{oid}^0")).is_ok()
} }
/// Checks if `oid` contains necessary LFS artifacts in this database.
pub(crate) fn contains_lfs_artifacts(&self, oid: GitOid) -> bool {
self.repo.lfs_fsck_objects(&format!("{oid}^0"))
}
/// Set the Git LFS validation state (if any).
#[must_use]
pub(crate) fn with_lfs_ready(mut self, lfs: Option<bool>) -> Self {
self.lfs_ready = lfs;
self
}
} }
impl GitCheckout { impl GitCheckout {
@ -332,7 +396,11 @@ impl GitCheckout {
/// ///
/// * The `repo` will be the checked out Git repository. /// * The `repo` will be the checked out Git repository.
fn new(revision: GitOid, repo: GitRepository) -> Self { fn new(revision: GitOid, repo: GitRepository) -> Self {
Self { revision, repo } Self {
revision,
repo,
lfs_ready: None,
}
} }
/// Clone a repo for a `revision` into a local path from a `database`. /// Clone a repo for a `revision` into a local path from a `database`.
@ -372,8 +440,8 @@ impl GitCheckout {
let repo = GitRepository::open(into)?; let repo = GitRepository::open(into)?;
let checkout = Self::new(revision, repo); let checkout = Self::new(revision, repo);
checkout.reset()?; let lfs_ready = checkout.reset(database.lfs_ready)?;
Ok(checkout) Ok(checkout.with_lfs_ready(lfs_ready))
} }
/// Checks if the `HEAD` of this checkout points to the expected revision. /// Checks if the `HEAD` of this checkout points to the expected revision.
@ -387,22 +455,39 @@ impl GitCheckout {
} }
} }
/// Indicates Git LFS artifacts have been initialized (when requested).
pub(crate) fn lfs_ready(&self) -> Option<bool> {
self.lfs_ready
}
/// Set the Git LFS validation state (if any).
#[must_use]
pub(crate) fn with_lfs_ready(mut self, lfs: Option<bool>) -> Self {
self.lfs_ready = lfs;
self
}
/// This performs `git reset --hard` to the revision of this checkout, with /// This performs `git reset --hard` to the revision of this checkout, with
/// additional interrupt protection by a dummy file [`CHECKOUT_READY_LOCK`]. /// additional interrupt protection by a dummy file [`CHECKOUT_READY_LOCK`].
/// ///
/// If we're interrupted while performing a `git reset` (e.g., we die /// If we're interrupted while performing a `git reset` (e.g., we die
/// because of a signal) Cargo needs to be sure to try to check out this /// because of a signal) uv needs to be sure to try to check out this
/// repo again on the next go-round. /// repo again on the next go-round.
/// ///
/// To enable this we have a dummy file in our checkout, [`.cargo-ok`], /// To enable this we have a dummy file in our checkout, [`.ok`],
/// which if present means that the repo has been successfully reset and is /// which if present means that the repo has been successfully reset and is
/// ready to go. Hence if we start to do a reset, we make sure this file /// ready to go. Hence, if we start to do a reset, we make sure this file
/// *doesn't* exist, and then once we're done we create the file. /// *doesn't* exist, and then once we're done we create the file.
/// ///
/// [`.cargo-ok`]: CHECKOUT_READY_LOCK /// [`.ok`]: CHECKOUT_READY_LOCK
fn reset(&self) -> Result<()> { fn reset(&self, with_lfs: Option<bool>) -> Result<Option<bool>> {
let ok_file = self.repo.path.join(CHECKOUT_READY_LOCK); let ok_file = self.repo.path.join(CHECKOUT_READY_LOCK);
let _ = paths::remove_file(&ok_file); let _ = paths::remove_file(&ok_file);
// We want to skip smudge if lfs was disabled for the repository
// as smudge filters can trigger on a reset even if lfs artifacts
// were not originally "fetched".
let lfs_skip_smudge = if with_lfs == Some(true) { "0" } else { "1" };
debug!("Reset {} to {}", self.repo.path.display(), self.revision); debug!("Reset {} to {}", self.repo.path.display(), self.revision);
// Perform the hard reset. // Perform the hard reset.
@ -410,6 +495,7 @@ impl GitCheckout {
.arg("reset") .arg("reset")
.arg("--hard") .arg("--hard")
.arg(self.revision.as_str()) .arg(self.revision.as_str())
.env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
.cwd(&self.repo.path) .cwd(&self.repo.path)
.exec_with_output()?; .exec_with_output()?;
@ -419,12 +505,27 @@ impl GitCheckout {
.arg("update") .arg("update")
.arg("--recursive") .arg("--recursive")
.arg("--init") .arg("--init")
.env(EnvVars::GIT_LFS_SKIP_SMUDGE, lfs_skip_smudge)
.cwd(&self.repo.path) .cwd(&self.repo.path)
.exec_with_output() .exec_with_output()
.map(drop)?; .map(drop)?;
paths::create(ok_file)?; // Validate Git LFS objects (if needed) after the reset.
Ok(()) // See `fetch_lfs` why we do this.
let lfs_validation = match with_lfs {
None => None,
Some(false) => Some(false),
Some(true) => Some(self.repo.lfs_fsck_objects(self.revision.as_str())),
};
// The .ok file should be written when the reset is successful.
// When Git LFS is enabled, the objects must also be fetched and
// validated successfully as part of the corresponding db.
if with_lfs.is_none() || lfs_validation == Some(true) {
paths::create(ok_file)?;
}
Ok(lfs_validation)
} }
} }
@ -643,7 +744,17 @@ fn fetch_with_cli(
/// ///
/// Returns an error if Git LFS isn't available. /// Returns an error if Git LFS isn't available.
/// Caching the command allows us to only check if LFS is installed once. /// Caching the command allows us to only check if LFS is installed once.
static GIT_LFS: LazyLock<Result<ProcessBuilder>> = LazyLock::new(|| { ///
/// We also support a helper private environment variable to allow
/// controlling the LFS extension from being loaded for testing purposes.
/// Once installed, Git will always load `git-lfs` as a built-in alias
/// which takes priority over loading from `PATH` which prevents us
/// from shadowing the extension with other means.
pub static GIT_LFS: LazyLock<Result<ProcessBuilder>> = LazyLock::new(|| {
if std::env::var_os(EnvVars::UV_INTERNAL__TEST_LFS_DISABLED).is_some() {
return Err(anyhow!("Git LFS extension has been forcefully disabled."));
}
let mut cmd = ProcessBuilder::new(GIT.as_ref()?); let mut cmd = ProcessBuilder::new(GIT.as_ref()?);
cmd.arg("lfs"); cmd.arg("lfs");
@ -658,14 +769,14 @@ fn fetch_lfs(
url: &Url, url: &Url,
revision: &GitOid, revision: &GitOid,
disable_ssl: bool, disable_ssl: bool,
) -> Result<()> { ) -> Result<bool> {
let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() { let mut cmd = if let Ok(lfs) = GIT_LFS.as_ref() {
debug!("Fetching Git LFS objects"); debug!("Fetching Git LFS objects");
lfs.clone() lfs.clone()
} else { } else {
// Since this feature is opt-in, warn if not available // Since this feature is opt-in, warn if not available
warn!("Git LFS is not available, skipping LFS fetch"); warn!("Git LFS is not available, skipping LFS fetch");
return Ok(()); return Ok(false);
}; };
if disable_ssl { if disable_ssl {
@ -682,10 +793,23 @@ fn fetch_lfs(
.env_remove(EnvVars::GIT_INDEX_FILE) .env_remove(EnvVars::GIT_INDEX_FILE)
.env_remove(EnvVars::GIT_OBJECT_DIRECTORY) .env_remove(EnvVars::GIT_OBJECT_DIRECTORY)
.env_remove(EnvVars::GIT_ALTERNATE_OBJECT_DIRECTORIES) .env_remove(EnvVars::GIT_ALTERNATE_OBJECT_DIRECTORIES)
// We should not support requesting LFS artifacts with skip smudge being set.
// While this may not be necessary, it's added to avoid any potential future issues.
.env_remove(EnvVars::GIT_LFS_SKIP_SMUDGE)
.cwd(&repo.path); .cwd(&repo.path);
cmd.exec_with_output()?; cmd.exec_with_output()?;
Ok(())
// We now validate the Git LFS objects explicitly (if supported). This is
// needed to avoid issues with Git LFS not being installed or configured
// on the system and giving the wrong impression to the user that Git LFS
// objects were initialized correctly when installation finishes.
// We may want to allow the user to skip validation in the future via
// UV_GIT_LFS_NO_VALIDATION environment variable on rare cases where
// validation costs outweigh the benefit.
let validation_result = repo.lfs_fsck_objects(revision.as_str());
Ok(validation_result)
} }
/// Whether `rev` is a shorter hash of `oid`. /// Whether `rev` is a shorter hash of `oid`.

View File

@ -1,5 +1,5 @@
pub use crate::credentials::{GIT_STORE, store_credentials_from_url}; pub use crate::credentials::{GIT_STORE, store_credentials_from_url};
pub use crate::git::GIT; pub use crate::git::{GIT, GIT_LFS, GitError};
pub use crate::resolver::{ pub use crate::resolver::{
GitResolver, GitResolverError, RepositoryReference, ResolvedRepositoryReference, GitResolver, GitResolverError, RepositoryReference, ResolvedRepositoryReference,
}; };

View File

@ -63,6 +63,8 @@ impl GitSource {
/// Fetch the underlying Git repository at the given revision. /// Fetch the underlying Git repository at the given revision.
#[instrument(skip(self), fields(repository = %self.git.repository(), rev = ?self.git.precise()))] #[instrument(skip(self), fields(repository = %self.git.repository(), rev = ?self.git.precise()))]
pub fn fetch(self) -> Result<Fetch> { pub fn fetch(self) -> Result<Fetch> {
let lfs_requested = self.git.lfs().enabled();
// Compute the canonical URL for the repository. // Compute the canonical URL for the repository.
let canonical = RepositoryUrl::new(self.git.repository()); let canonical = RepositoryUrl::new(self.git.repository());
@ -85,24 +87,37 @@ impl GitSource {
// If we have a locked revision, and we have a pre-existing database which has that // If we have a locked revision, and we have a pre-existing database which has that
// revision, then no update needs to happen. // revision, then no update needs to happen.
// When requested, we also check if LFS artifacts have been fetched and validated.
if let (Some(rev), Some(db)) = (self.git.precise(), &maybe_db) { if let (Some(rev), Some(db)) = (self.git.precise(), &maybe_db) {
if db.contains(rev) { if db.contains(rev) && (!lfs_requested || db.contains_lfs_artifacts(rev)) {
debug!("Using existing Git source `{}`", self.git.repository()); debug!("Using existing Git source `{}`", self.git.repository());
return Ok((maybe_db.unwrap(), rev, None)); return Ok((
maybe_db
.unwrap()
.with_lfs_ready(lfs_requested.then_some(true)),
rev,
None,
));
} }
} }
// If the revision isn't locked, but it looks like it might be an exact commit hash, // If the revision isn't locked, but it looks like it might be an exact commit hash,
// and we do have a pre-existing database, then check whether it is, in fact, a commit // and we do have a pre-existing database, then check whether it is, in fact, a commit
// hash. If so, treat it like it's locked. // hash. If so, treat it like it's locked.
// When requested, we also check if LFS artifacts have been fetched and validated.
if let Some(db) = &maybe_db { if let Some(db) = &maybe_db {
if let GitReference::BranchOrTagOrCommit(maybe_commit) = self.git.reference() { if let GitReference::BranchOrTagOrCommit(maybe_commit) = self.git.reference() {
if let Ok(oid) = maybe_commit.parse::<GitOid>() { if let Ok(oid) = maybe_commit.parse::<GitOid>() {
if db.contains(oid) { if db.contains(oid) && (!lfs_requested || db.contains_lfs_artifacts(oid)) {
// This reference is an exact commit. Treat it like it's // This reference is an exact commit. Treat it like it's locked.
// locked.
debug!("Using existing Git source `{}`", self.git.repository()); debug!("Using existing Git source `{}`", self.git.repository());
return Ok((maybe_db.unwrap(), oid, None)); return Ok((
maybe_db
.unwrap()
.with_lfs_ready(lfs_requested.then_some(true)),
oid,
None,
));
} }
} }
} }
@ -125,6 +140,7 @@ impl GitSource {
self.git.precise(), self.git.precise(),
self.disable_ssl, self.disable_ssl,
self.offline, self.offline,
lfs_requested,
)?; )?;
Ok((db, actual_rev, task)) Ok((db, actual_rev, task))
@ -134,16 +150,25 @@ impl GitSource {
// path length limit on Windows. // path length limit on Windows.
let short_id = db.to_short_id(actual_rev)?; let short_id = db.to_short_id(actual_rev)?;
// Check out `actual_rev` from the database to a scoped location on the // Compute the canonical URL for the repository checkout.
// filesystem. This will use hard links and such to ideally make the let canonical = canonical.with_lfs(Some(lfs_requested));
// checkout operation here pretty fast. // Recompute the checkout hash when Git LFS is enabled as we want
// to distinctly differentiate between LFS vs non-LFS source trees.
let ident = if lfs_requested {
cache_digest(&canonical)
} else {
ident
};
let checkout_path = self let checkout_path = self
.cache .cache
.join("checkouts") .join("checkouts")
.join(&ident) .join(&ident)
.join(short_id.as_str()); .join(short_id.as_str());
db.copy_to(actual_rev, &checkout_path)?; // Check out `actual_rev` from the database to a scoped location on the
// filesystem. This will use hard links and such to ideally make the
// checkout operation here pretty fast.
let checkout = db.copy_to(actual_rev, &checkout_path)?;
// Report the checkout operation to the reporter. // Report the checkout operation to the reporter.
if let Some(task) = maybe_task { if let Some(task) = maybe_task {
@ -155,6 +180,7 @@ impl GitSource {
Ok(Fetch { Ok(Fetch {
git: self.git.with_precise(actual_rev), git: self.git.with_precise(actual_rev),
path: checkout_path, path: checkout_path,
lfs_ready: checkout.lfs_ready().unwrap_or(false),
}) })
} }
} }
@ -164,6 +190,8 @@ pub struct Fetch {
git: GitUrl, git: GitUrl,
/// The path to the checked out repository. /// The path to the checked out repository.
path: PathBuf, path: PathBuf,
/// Git LFS artifacts have been initialized (if requested).
lfs_ready: bool,
} }
impl Fetch { impl Fetch {
@ -175,6 +203,10 @@ impl Fetch {
&self.path &self.path
} }
pub fn lfs_ready(&self) -> &bool {
&self.lfs_ready
}
pub fn into_git(self) -> GitUrl { pub fn into_git(self) -> GitUrl {
self.git self.git
} }

View File

@ -13,7 +13,7 @@ use uv_distribution_types::{
ExtraBuildVariables, InstalledDirectUrlDist, InstalledDist, InstalledDistKind, ExtraBuildVariables, InstalledDirectUrlDist, InstalledDist, InstalledDistKind,
PackageConfigSettings, RequirementSource, PackageConfigSettings, RequirementSource,
}; };
use uv_git_types::GitOid; use uv_git_types::{GitLfs, GitOid};
use uv_normalize::PackageName; use uv_normalize::PackageName;
use uv_platform_tags::{IncompatibleTag, TagCompatibility, Tags}; use uv_platform_tags::{IncompatibleTag, TagCompatibility, Tags};
use uv_pypi_types::{DirInfo, DirectUrl, VcsInfo, VcsKind}; use uv_pypi_types::{DirInfo, DirectUrl, VcsInfo, VcsKind};
@ -173,6 +173,7 @@ impl RequirementSatisfaction {
vcs: VcsKind::Git, vcs: VcsKind::Git,
requested_revision: _, requested_revision: _,
commit_id: installed_precise, commit_id: installed_precise,
git_lfs: installed_git_lfs,
}, },
subdirectory: installed_subdirectory, subdirectory: installed_subdirectory,
} = direct_url.as_ref() } = direct_url.as_ref()
@ -188,6 +189,16 @@ impl RequirementSatisfaction {
return Self::Mismatch; return Self::Mismatch;
} }
let requested_git_lfs = requested_git.lfs();
let installed_git_lfs = installed_git_lfs.map(GitLfs::from).unwrap_or_default();
if requested_git_lfs != installed_git_lfs {
debug!(
"Git LFS mismatch: {} (installed) vs. {} (requested)",
installed_git_lfs, requested_git_lfs,
);
return Self::Mismatch;
}
if !RepositoryUrl::parse(installed_url).is_ok_and(|installed_url| { if !RepositoryUrl::parse(installed_url).is_ok_and(|installed_url| {
installed_url == RepositoryUrl::new(requested_git.repository()) installed_url == RepositoryUrl::new(requested_git.repository())
}) { }) {

View File

@ -36,7 +36,7 @@ pub enum DirectUrl {
}, },
/// The direct URL is path to a VCS repository. For example: /// The direct URL is path to a VCS repository. For example:
/// ```json /// ```json
/// {"url": "https://github.com/pallets/flask.git", "vcs_info": {"commit_id": "8d9519df093864ff90ca446d4af2dc8facd3c542", "vcs": "git"}} /// {"url": "https://github.com/pallets/flask.git", "vcs_info": {"commit_id": "8d9519df093864ff90ca446d4af2dc8facd3c542", "vcs": "git", "git_lfs": true }}
/// ``` /// ```
VcsUrl { VcsUrl {
url: String, url: String,
@ -70,6 +70,8 @@ pub struct VcsInfo {
pub commit_id: Option<String>, pub commit_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub requested_revision: Option<String>, pub requested_revision: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub git_lfs: Option<bool>, // Prefix lfs with VcsKind::Git per PEP 610
} }
#[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derive(Debug, Copy, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
@ -132,8 +134,16 @@ impl TryFrom<&DirectUrl> for DisplaySafeUrl {
let path = format!("{}@{requested_revision}", url.path()); let path = format!("{}@{requested_revision}", url.path());
url.set_path(&path); url.set_path(&path);
} }
let mut frags: Vec<String> = Vec::new();
if let Some(subdirectory) = subdirectory { if let Some(subdirectory) = subdirectory {
url.set_fragment(Some(&format!("subdirectory={}", subdirectory.display()))); frags.push(format!("subdirectory={}", subdirectory.display()));
}
// Displays nicely that lfs was used
if let Some(true) = vcs_info.git_lfs {
frags.push("lfs=true".to_string());
}
if !frags.is_empty() {
url.set_fragment(Some(&frags.join("&")));
} }
Ok(url) Ok(url)
} }

View File

@ -261,6 +261,9 @@ impl ParsedDirectoryUrl {
/// A Git repository URL. /// A Git repository URL.
/// ///
/// Explicit `lfs = true` or `--lfs` should be used to enable Git LFS support as
/// we do not support implicit parsing of the `lfs=true` url fragments for now.
///
/// Examples: /// Examples:
/// * `git+https://git.example.com/MyProject.git` /// * `git+https://git.example.com/MyProject.git`
/// * `git+https://git.example.com/MyProject.git@v1.0#egg=pkg&subdirectory=pkg_dir` /// * `git+https://git.example.com/MyProject.git@v1.0#egg=pkg&subdirectory=pkg_dir`
@ -485,6 +488,7 @@ impl From<&ParsedGitUrl> for DirectUrl {
vcs: VcsKind::Git, vcs: VcsKind::Git,
commit_id: value.url.precise().as_ref().map(ToString::to_string), commit_id: value.url.precise().as_ref().map(ToString::to_string),
requested_revision: value.url.reference().as_str().map(ToString::to_string), requested_revision: value.url.reference().as_str().map(ToString::to_string),
git_lfs: value.url.lfs().enabled().then_some(true),
}, },
subdirectory: value.subdirectory.clone(), subdirectory: value.subdirectory.clone(),
} }
@ -526,10 +530,19 @@ impl From<ParsedArchiveUrl> for DisplaySafeUrl {
impl From<ParsedGitUrl> for DisplaySafeUrl { impl From<ParsedGitUrl> for DisplaySafeUrl {
fn from(value: ParsedGitUrl) -> Self { fn from(value: ParsedGitUrl) -> Self {
let lfs = value.url.lfs().enabled();
let mut url = Self::parse(&format!("{}{}", "git+", Self::from(value.url).as_str())) let mut url = Self::parse(&format!("{}{}", "git+", Self::from(value.url).as_str()))
.expect("Git URL is invalid"); .expect("Git URL is invalid");
let mut frags: Vec<String> = Vec::new();
if let Some(subdirectory) = value.subdirectory { if let Some(subdirectory) = value.subdirectory {
url.set_fragment(Some(&format!("subdirectory={}", subdirectory.display()))); frags.push(format!("subdirectory={}", subdirectory.display()));
}
// Displays nicely that lfs is used
if lfs {
frags.push("lfs=true".to_string());
}
if !frags.is_empty() {
url.set_fragment(Some(&frags.join("&")));
} }
url url
} }
@ -563,6 +576,13 @@ mod tests {
let actual = DisplaySafeUrl::from(ParsedUrl::try_from(expected.clone())?); let actual = DisplaySafeUrl::from(ParsedUrl::try_from(expected.clone())?);
assert_eq!(expected, actual); assert_eq!(expected, actual);
// We do not support implicit parsing of the `lfs=true` url fragments for now
let expected = DisplaySafeUrl::parse(
"git+https://github.com/pallets/flask.git#subdirectory=pkg_dir&lfs=true",
)?;
let actual = DisplaySafeUrl::from(ParsedUrl::try_from(expected.clone())?);
assert_ne!(expected, actual);
// TODO(charlie): Preserve other fragments. // TODO(charlie): Preserve other fragments.
let expected = DisplaySafeUrl::parse( let expected = DisplaySafeUrl::parse(
"git+https://github.com/pallets/flask.git#egg=flask&subdirectory=pkg_dir", "git+https://github.com/pallets/flask.git#egg=flask&subdirectory=pkg_dir",

View File

@ -56,6 +56,7 @@ RequirementsTxt {
}, },
reference: DefaultBranch, reference: DefaultBranch,
precise: None, precise: None,
lfs: Disabled,
}, },
subdirectory: None, subdirectory: None,
}, },

View File

@ -56,6 +56,7 @@ RequirementsTxt {
}, },
reference: DefaultBranch, reference: DefaultBranch,
precise: None, precise: None,
lfs: Disabled,
}, },
subdirectory: None, subdirectory: None,
}, },

View File

@ -28,7 +28,7 @@ use uv_distribution_types::{
}; };
use uv_fs::{PortablePathBuf, relative_to}; use uv_fs::{PortablePathBuf, relative_to};
use uv_git::{RepositoryReference, ResolvedRepositoryReference}; use uv_git::{RepositoryReference, ResolvedRepositoryReference};
use uv_git_types::{GitOid, GitReference, GitUrl, GitUrlParseError}; use uv_git_types::{GitLfs, GitOid, GitReference, GitUrl, GitUrlParseError};
use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_normalize::{ExtraName, GroupName, PackageName};
use uv_pep440::Version; use uv_pep440::Version;
use uv_pep508::{MarkerEnvironment, MarkerTree, VerbatimUrl}; use uv_pep508::{MarkerEnvironment, MarkerTree, VerbatimUrl};
@ -1437,7 +1437,8 @@ impl PylockTomlVcs {
.unwrap_or_else(|| GitReference::BranchOrTagOrCommit(self.commit_id.to_string())); .unwrap_or_else(|| GitReference::BranchOrTagOrCommit(self.commit_id.to_string()));
let precise = self.commit_id; let precise = self.commit_id;
GitUrl::from_commit(url, reference, precise)? // TODO(samypr100): GitLfs::from_env() as pylock.toml spec doesn't specify how to label LFS support
GitUrl::from_commit(url, reference, precise, GitLfs::from_env())?
}; };
// Reconstruct the PEP 508-compatible URL from the `GitSource`. // Reconstruct the PEP 508-compatible URL from the `GitSource`.

View File

@ -91,6 +91,7 @@ impl std::fmt::Display for RequirementsTxtExport<'_> {
url, url,
GitReference::from(git.kind.clone()), GitReference::from(git.kind.clone()),
git.precise, git.precise,
git.lfs,
) )
.expect("Internal Git URLs must have supported schemes"); .expect("Internal Git URLs must have supported schemes");

View File

@ -33,7 +33,7 @@ use uv_distribution_types::{
}; };
use uv_fs::{PortablePath, PortablePathBuf, relative_to}; use uv_fs::{PortablePath, PortablePathBuf, relative_to};
use uv_git::{RepositoryReference, ResolvedRepositoryReference}; use uv_git::{RepositoryReference, ResolvedRepositoryReference};
use uv_git_types::{GitOid, GitReference, GitUrl, GitUrlParseError}; use uv_git_types::{GitLfs, GitOid, GitReference, GitUrl, GitUrlParseError};
use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_normalize::{ExtraName, GroupName, PackageName};
use uv_pep440::Version; use uv_pep440::Version;
use uv_pep508::{MarkerEnvironment, MarkerTree, VerbatimUrl, VerbatimUrlError, split_scheme}; use uv_pep508::{MarkerEnvironment, MarkerTree, VerbatimUrl, VerbatimUrlError, split_scheme};
@ -2758,8 +2758,12 @@ impl Package {
url.set_query(None); url.set_query(None);
// Reconstruct the `GitUrl` from the `GitSource`. // Reconstruct the `GitUrl` from the `GitSource`.
let git_url = let git_url = GitUrl::from_commit(
GitUrl::from_commit(url, GitReference::from(git.kind.clone()), git.precise)?; url,
GitReference::from(git.kind.clone()),
git.precise,
git.lfs,
)?;
// Reconstruct the PEP 508-compatible URL from the `GitSource`. // Reconstruct the PEP 508-compatible URL from the `GitSource`.
let url = DisplaySafeUrl::from(ParsedGitUrl { let url = DisplaySafeUrl::from(ParsedGitUrl {
@ -3621,6 +3625,7 @@ impl Source {
panic!("Git distribution is missing a precise hash: {git_dist}") panic!("Git distribution is missing a precise hash: {git_dist}")
}), }),
subdirectory: git_dist.subdirectory.clone(), subdirectory: git_dist.subdirectory.clone(),
lfs: git_dist.git.lfs(),
}, },
) )
} }
@ -3936,6 +3941,7 @@ struct GitSource {
precise: GitOid, precise: GitOid,
subdirectory: Option<Box<Path>>, subdirectory: Option<Box<Path>>,
kind: GitSourceKind, kind: GitSourceKind,
lfs: GitLfs,
} }
/// An error that occurs when a source string could not be parsed. /// An error that occurs when a source string could not be parsed.
@ -3951,15 +3957,18 @@ impl GitSource {
fn from_url(url: &Url) -> Result<Self, GitSourceError> { fn from_url(url: &Url) -> Result<Self, GitSourceError> {
let mut kind = GitSourceKind::DefaultBranch; let mut kind = GitSourceKind::DefaultBranch;
let mut subdirectory = None; let mut subdirectory = None;
let mut lfs = GitLfs::Disabled;
for (key, val) in url.query_pairs() { for (key, val) in url.query_pairs() {
match &*key { match &*key {
"tag" => kind = GitSourceKind::Tag(val.into_owned()), "tag" => kind = GitSourceKind::Tag(val.into_owned()),
"branch" => kind = GitSourceKind::Branch(val.into_owned()), "branch" => kind = GitSourceKind::Branch(val.into_owned()),
"rev" => kind = GitSourceKind::Rev(val.into_owned()), "rev" => kind = GitSourceKind::Rev(val.into_owned()),
"subdirectory" => subdirectory = Some(PortablePathBuf::from(val.as_ref()).into()), "subdirectory" => subdirectory = Some(PortablePathBuf::from(val.as_ref()).into()),
"lfs" => lfs = GitLfs::from(val.eq_ignore_ascii_case("true")),
_ => {} _ => {}
} }
} }
let precise = GitOid::from_str(url.fragment().ok_or(GitSourceError::MissingSha)?) let precise = GitOid::from_str(url.fragment().ok_or(GitSourceError::MissingSha)?)
.map_err(|_| GitSourceError::InvalidSha)?; .map_err(|_| GitSourceError::InvalidSha)?;
@ -3967,6 +3976,7 @@ impl GitSource {
precise, precise,
subdirectory, subdirectory,
kind, kind,
lfs,
}) })
} }
} }
@ -4355,6 +4365,11 @@ fn locked_git_url(git_dist: &GitSourceDist) -> DisplaySafeUrl {
.append_pair("subdirectory", &subdirectory); .append_pair("subdirectory", &subdirectory);
} }
// Put lfs=true in the package source git url only when explicitly enabled.
if git_dist.git.lfs().enabled() {
url.query_pairs_mut().append_pair("lfs", "true");
}
// Put the requested reference in the query. // Put the requested reference in the query.
match git_dist.git.reference() { match git_dist.git.reference() {
GitReference::Branch(branch) => { GitReference::Branch(branch) => {
@ -5105,7 +5120,12 @@ fn normalize_requirement(
repository.set_fragment(None); repository.set_fragment(None);
repository.set_query(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. // Reconstruct the PEP 508 URL from the underlying data.

View File

@ -558,6 +558,11 @@ impl EnvVars {
#[attr_added_in("0.8.0")] #[attr_added_in("0.8.0")]
pub const UV_INTERNAL__TEST_PYTHON_MANAGED: &'static str = "UV_INTERNAL__TEST_PYTHON_MANAGED"; pub const UV_INTERNAL__TEST_PYTHON_MANAGED: &'static str = "UV_INTERNAL__TEST_PYTHON_MANAGED";
/// Used to force ignoring Git LFS commands as `git-lfs` detection cannot be overridden via PATH.
#[attr_hidden]
#[attr_added_in("next release")]
pub const UV_INTERNAL__TEST_LFS_DISABLED: &'static str = "UV_INTERNAL__TEST_LFS_DISABLED";
/// Path to system-level configuration directory on Unix systems. /// Path to system-level configuration directory on Unix systems.
#[attr_added_in("0.4.26")] #[attr_added_in("0.4.26")]
pub const XDG_CONFIG_DIRS: &'static str = "XDG_CONFIG_DIRS"; pub const XDG_CONFIG_DIRS: &'static str = "XDG_CONFIG_DIRS";
@ -809,6 +814,16 @@ impl EnvVars {
#[attr_added_in("0.6.4")] #[attr_added_in("0.6.4")]
pub const GIT_TERMINAL_PROMPT: &'static str = "GIT_TERMINAL_PROMPT"; pub const GIT_TERMINAL_PROMPT: &'static str = "GIT_TERMINAL_PROMPT";
/// Skip Smudge LFS Filter.
#[attr_hidden]
#[attr_added_in("next release")]
pub const GIT_LFS_SKIP_SMUDGE: &'static str = "GIT_LFS_SKIP_SMUDGE";
/// Used in tests to set the user global git config location.
#[attr_hidden]
#[attr_added_in("next release")]
pub const GIT_CONFIG_GLOBAL: &'static str = "GIT_CONFIG_GLOBAL";
/// Used in tests for better git isolation. /// Used in tests for better git isolation.
/// ///
/// For example, we run some tests in ~/.local/share/uv/tests. /// For example, we run some tests in ~/.local/share/uv/tests.

View File

@ -1122,6 +1122,8 @@ pub enum Source {
rev: Option<String>, rev: Option<String>,
tag: Option<String>, tag: Option<String>,
branch: Option<String>, branch: Option<String>,
/// Whether to use Git LFS when cloning the repository.
lfs: Option<bool>,
#[serde( #[serde(
skip_serializing_if = "uv_pep508::marker::ser::is_empty", skip_serializing_if = "uv_pep508::marker::ser::is_empty",
serialize_with = "uv_pep508::marker::ser::serialize", serialize_with = "uv_pep508::marker::ser::serialize",
@ -1220,6 +1222,7 @@ impl<'de> Deserialize<'de> for Source {
rev: Option<String>, rev: Option<String>,
tag: Option<String>, tag: Option<String>,
branch: Option<String>, branch: Option<String>,
lfs: Option<bool>,
url: Option<DisplaySafeUrl>, url: Option<DisplaySafeUrl>,
path: Option<PortablePathBuf>, path: Option<PortablePathBuf>,
editable: Option<bool>, editable: Option<bool>,
@ -1243,6 +1246,7 @@ impl<'de> Deserialize<'de> for Source {
rev, rev,
tag, tag,
branch, branch,
lfs,
url, url,
path, path,
editable, editable,
@ -1320,6 +1324,7 @@ impl<'de> Deserialize<'de> for Source {
rev, rev,
tag, tag,
branch, branch,
lfs,
marker, marker,
extra, extra,
group, group,
@ -1575,6 +1580,10 @@ pub enum SourceError {
"`{0}` did not resolve to a Git repository, but a Git reference (`--branch {1}`) was provided." "`{0}` did not resolve to a Git repository, but a Git reference (`--branch {1}`) was provided."
)] )]
UnusedBranch(String, String), UnusedBranch(String, String),
#[error(
"`{0}` did not resolve to a Git repository, but a Git extension (`--lfs`) was provided."
)]
UnusedLfs(String),
#[error( #[error(
"`{0}` did not resolve to a local directory, but the `--editable` flag was provided. Editable installs are only supported for local directories." "`{0}` did not resolve to a local directory, but the `--editable` flag was provided. Editable installs are only supported for local directories."
)] )]
@ -1604,12 +1613,13 @@ impl Source {
rev: Option<String>, rev: Option<String>,
tag: Option<String>, tag: Option<String>,
branch: Option<String>, branch: Option<String>,
lfs: Option<bool>,
root: &Path, root: &Path,
existing_sources: Option<&BTreeMap<PackageName, Sources>>, existing_sources: Option<&BTreeMap<PackageName, Sources>>,
) -> Result<Option<Self>, SourceError> { ) -> Result<Option<Self>, SourceError> {
// If the user specified a Git reference for a non-Git source, try existing Git sources before erroring. // If the user specified a Git reference for a non-Git source, try existing Git sources before erroring.
if !matches!(source, RequirementSource::Git { .. }) if !matches!(source, RequirementSource::Git { .. })
&& (branch.is_some() || tag.is_some() || rev.is_some()) && (branch.is_some() || tag.is_some() || rev.is_some() || lfs.is_some())
{ {
if let Some(sources) = existing_sources { if let Some(sources) = existing_sources {
if let Some(package_sources) = sources.get(name) { if let Some(package_sources) = sources.get(name) {
@ -1629,6 +1639,7 @@ impl Source {
rev, rev,
tag, tag,
branch, branch,
lfs,
marker: *marker, marker: *marker,
extra: extra.clone(), extra: extra.clone(),
group: group.clone(), group: group.clone(),
@ -1646,6 +1657,9 @@ impl Source {
if let Some(branch) = branch { if let Some(branch) = branch {
return Err(SourceError::UnusedBranch(name.to_string(), branch)); return Err(SourceError::UnusedBranch(name.to_string(), branch));
} }
if let Some(true) = lfs {
return Err(SourceError::UnusedLfs(name.to_string()));
}
} }
// If we resolved a non-path source, and user specified an `--editable` flag, error. // If we resolved a non-path source, and user specified an `--editable` flag, error.
@ -1754,6 +1768,7 @@ impl Source {
rev: rev.cloned(), rev: rev.cloned(),
tag, tag,
branch, branch,
lfs,
git: git.repository().clone(), git: git.repository().clone(),
subdirectory: subdirectory.map(PortablePathBuf::from), subdirectory: subdirectory.map(PortablePathBuf::from),
marker: MarkerTree::TRUE, marker: MarkerTree::TRUE,
@ -1765,6 +1780,7 @@ impl Source {
rev, rev,
tag, tag,
branch, branch,
lfs,
git: git.repository().clone(), git: git.repository().clone(),
subdirectory: subdirectory.map(PortablePathBuf::from), subdirectory: subdirectory.map(PortablePathBuf::from),
marker: MarkerTree::TRUE, marker: MarkerTree::TRUE,

View File

@ -178,6 +178,7 @@ tracing-durations-export = ["dep:tracing-durations-export", "uv-resolver/tracing
default-tests = [ default-tests = [
"crates-io", "crates-io",
"git", "git",
"git-lfs",
"pypi", "pypi",
"r2", "r2",
"python", "python",
@ -190,6 +191,8 @@ default-tests = [
crates-io = [] crates-io = []
# Introduces a testing dependency on Git. # Introduces a testing dependency on Git.
git = [] git = []
# Introduces a testing dependency on Git LFS.
git-lfs = ["git"]
# Introduces a testing dependency on PyPI. # Introduces a testing dependency on PyPI.
pypi = [] pypi = []
# Introduces a testing dependency on R2. # Introduces a testing dependency on R2.

View File

@ -85,6 +85,7 @@ pub(crate) async fn add(
rev: Option<String>, rev: Option<String>,
tag: Option<String>, tag: Option<String>,
branch: Option<String>, branch: Option<String>,
lfs: Option<bool>,
extras_of_dependency: Vec<ExtraName>, extras_of_dependency: Vec<ExtraName>,
package: Option<PackageName>, package: Option<PackageName>,
python: Option<String>, python: Option<String>,
@ -376,6 +377,7 @@ pub(crate) async fn add(
rev.as_deref(), rev.as_deref(),
tag.as_deref(), tag.as_deref(),
branch.as_deref(), branch.as_deref(),
lfs,
marker, marker,
) )
}) })
@ -643,6 +645,7 @@ pub(crate) async fn add(
rev.as_deref(), rev.as_deref(),
tag.as_deref(), tag.as_deref(),
branch.as_deref(), branch.as_deref(),
lfs,
&extras_of_dependency, &extras_of_dependency,
index, index,
&mut toml, &mut toml,
@ -795,6 +798,7 @@ fn edits(
rev: Option<&str>, rev: Option<&str>,
tag: Option<&str>, tag: Option<&str>,
branch: Option<&str>, branch: Option<&str>,
lfs: Option<bool>,
extras: &[ExtraName], extras: &[ExtraName],
index: Option<&IndexName>, index: Option<&IndexName>,
toml: &mut PyProjectTomlMut, toml: &mut PyProjectTomlMut,
@ -825,6 +829,7 @@ fn edits(
rev.map(ToString::to_string), rev.map(ToString::to_string),
tag.map(ToString::to_string), tag.map(ToString::to_string),
branch.map(ToString::to_string), branch.map(ToString::to_string),
lfs,
script_dir, script_dir,
existing_sources, existing_sources,
)? )?
@ -849,6 +854,7 @@ fn edits(
rev.map(ToString::to_string), rev.map(ToString::to_string),
tag.map(ToString::to_string), tag.map(ToString::to_string),
branch.map(ToString::to_string), branch.map(ToString::to_string),
lfs,
project.root(), project.root(),
existing_sources, existing_sources,
)? )?
@ -867,6 +873,7 @@ fn edits(
rev, rev,
tag, tag,
branch, branch,
lfs,
marker, marker,
extra, extra,
group, group,
@ -885,6 +892,7 @@ fn edits(
rev, rev,
tag, tag,
branch, branch,
lfs,
marker, marker,
extra, extra,
group, group,
@ -1219,6 +1227,7 @@ fn resolve_requirement(
rev: Option<String>, rev: Option<String>,
tag: Option<String>, tag: Option<String>,
branch: Option<String>, branch: Option<String>,
lfs: Option<bool>,
root: &Path, root: &Path,
existing_sources: Option<&BTreeMap<PackageName, Sources>>, existing_sources: Option<&BTreeMap<PackageName, Sources>>,
) -> Result<(uv_pep508::Requirement, Option<Source>), anyhow::Error> { ) -> Result<(uv_pep508::Requirement, Option<Source>), anyhow::Error> {
@ -1231,6 +1240,7 @@ fn resolve_requirement(
rev, rev,
tag, tag,
branch, branch,
lfs,
root, root,
existing_sources, existing_sources,
); );

View File

@ -1678,17 +1678,19 @@ pub(crate) async fn resolve_names(
workspace_cache: &WorkspaceCache, workspace_cache: &WorkspaceCache,
printer: Printer, printer: Printer,
preview: Preview, preview: Preview,
lfs: Option<bool>,
) -> Result<Vec<Requirement>, uv_requirements::Error> { ) -> Result<Vec<Requirement>, uv_requirements::Error> {
// Partition the requirements into named and unnamed requirements. // Partition the requirements into named and unnamed requirements.
let (mut requirements, unnamed): (Vec<_>, Vec<_>) = let (mut requirements, unnamed): (Vec<_>, Vec<_>) = requirements
requirements .into_iter()
.into_iter() .map(|spec| {
.partition_map(|spec| match spec.requirement { spec.requirement
UnresolvedRequirement::Named(requirement) => itertools::Either::Left(requirement), .augment_requirement(None, None, None, lfs, None)
UnresolvedRequirement::Unnamed(requirement) => { })
itertools::Either::Right(requirement) .partition_map(|requirement| match requirement {
} UnresolvedRequirement::Named(requirement) => itertools::Either::Left(requirement),
}); UnresolvedRequirement::Unnamed(requirement) => itertools::Either::Right(requirement),
});
// Short-circuit if there are no unnamed requirements. // Short-circuit if there are no unnamed requirements.
if unnamed.is_empty() { if unnamed.is_empty() {

View File

@ -56,6 +56,7 @@ pub(crate) async fn install(
excludes: &[RequirementsSource], excludes: &[RequirementsSource],
build_constraints: &[RequirementsSource], build_constraints: &[RequirementsSource],
entrypoints: &[PackageName], entrypoints: &[PackageName],
lfs: Option<bool>,
python: Option<String>, python: Option<String>,
python_platform: Option<TargetTriple>, python_platform: Option<TargetTriple>,
install_mirrors: PythonInstallMirrors, install_mirrors: PythonInstallMirrors,
@ -148,6 +149,7 @@ pub(crate) async fn install(
&workspace_cache, &workspace_cache,
printer, printer,
preview, preview,
lfs,
) )
.await? .await?
.pop() .pop()
@ -274,6 +276,7 @@ pub(crate) async fn install(
&workspace_cache, &workspace_cache,
printer, printer,
preview, preview,
lfs,
) )
.await?, .await?,
); );
@ -299,6 +302,7 @@ pub(crate) async fn install(
&workspace_cache, &workspace_cache,
printer, printer,
preview, preview,
lfs,
) )
.await?; .await?;

View File

@ -90,6 +90,7 @@ pub(crate) async fn run(
overrides: &[RequirementsSource], overrides: &[RequirementsSource],
build_constraints: &[RequirementsSource], build_constraints: &[RequirementsSource],
show_resolution: bool, show_resolution: bool,
lfs: Option<bool>,
python: Option<String>, python: Option<String>,
python_platform: Option<TargetTriple>, python_platform: Option<TargetTriple>,
install_mirrors: PythonInstallMirrors, install_mirrors: PythonInstallMirrors,
@ -276,6 +277,7 @@ pub(crate) async fn run(
&settings, &settings,
&client_builder, &client_builder,
isolated, isolated,
lfs,
python_preference, python_preference,
python_downloads, python_downloads,
installer_metadata, installer_metadata,
@ -691,6 +693,7 @@ async fn get_or_create_environment(
settings: &ResolverInstallerSettings, settings: &ResolverInstallerSettings,
client_builder: &BaseClientBuilder<'_>, client_builder: &BaseClientBuilder<'_>,
isolated: bool, isolated: bool,
lfs: Option<bool>,
python_preference: PythonPreference, python_preference: PythonPreference,
python_downloads: PythonDownloads, python_downloads: PythonDownloads,
installer_metadata: bool, installer_metadata: bool,
@ -803,6 +806,7 @@ async fn get_or_create_environment(
&workspace_cache, &workspace_cache,
printer, printer,
preview, preview,
lfs,
) )
.await? .await?
.pop() .pop()
@ -900,6 +904,7 @@ async fn get_or_create_environment(
&workspace_cache, &workspace_cache,
printer, printer,
preview, preview,
lfs,
) )
.await?, .await?,
); );
@ -926,6 +931,7 @@ async fn get_or_create_environment(
&workspace_cache, &workspace_cache,
printer, printer,
preview, preview,
lfs,
) )
.await?; .await?;

View File

@ -1347,6 +1347,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
&overrides, &overrides,
&build_constraints, &build_constraints,
args.show_resolution || globals.verbose > 0, args.show_resolution || globals.verbose > 0,
args.lfs,
args.python, args.python,
args.python_platform, args.python_platform,
args.install_mirrors, args.install_mirrors,
@ -1442,6 +1443,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
&excludes, &excludes,
&build_constraints, &build_constraints,
&entrypoints, &entrypoints,
args.lfs,
args.python, args.python,
args.python_platform, args.python_platform,
args.install_mirrors, args.install_mirrors,
@ -2173,6 +2175,7 @@ async fn run_project(
args.rev, args.rev,
args.tag, args.tag,
args.branch, args.branch,
args.lfs,
args.extras, args.extras,
args.package, args.package,
args.python, args.python,

View File

@ -547,6 +547,7 @@ pub(crate) struct ToolRunSettings {
pub(crate) build_constraints: Vec<PathBuf>, pub(crate) build_constraints: Vec<PathBuf>,
pub(crate) isolated: bool, pub(crate) isolated: bool,
pub(crate) show_resolution: bool, pub(crate) show_resolution: bool,
pub(crate) lfs: Option<bool>,
pub(crate) python: Option<String>, pub(crate) python: Option<String>,
pub(crate) python_platform: Option<TargetTriple>, pub(crate) python_platform: Option<TargetTriple>,
pub(crate) install_mirrors: PythonInstallMirrors, pub(crate) install_mirrors: PythonInstallMirrors,
@ -582,6 +583,7 @@ impl ToolRunSettings {
installer, installer,
build, build,
refresh, refresh,
lfs,
python, python,
python_platform, python_platform,
generate_shell_completion: _, generate_shell_completion: _,
@ -628,6 +630,7 @@ impl ToolRunSettings {
.unwrap_or_default(); .unwrap_or_default();
let settings = ResolverInstallerSettings::from(options.clone()); let settings = ResolverInstallerSettings::from(options.clone());
let lfs = lfs.then_some(true);
Self { Self {
command, command,
@ -658,6 +661,7 @@ impl ToolRunSettings {
.collect(), .collect(),
isolated, isolated,
show_resolution, show_resolution,
lfs,
python: python.and_then(Maybe::into_option), python: python.and_then(Maybe::into_option),
python_platform, python_platform,
refresh: Refresh::from(refresh), refresh: Refresh::from(refresh),
@ -685,6 +689,7 @@ pub(crate) struct ToolInstallSettings {
pub(crate) overrides: Vec<PathBuf>, pub(crate) overrides: Vec<PathBuf>,
pub(crate) excludes: Vec<PathBuf>, pub(crate) excludes: Vec<PathBuf>,
pub(crate) build_constraints: Vec<PathBuf>, pub(crate) build_constraints: Vec<PathBuf>,
pub(crate) lfs: Option<bool>,
pub(crate) python: Option<String>, pub(crate) python: Option<String>,
pub(crate) python_platform: Option<TargetTriple>, pub(crate) python_platform: Option<TargetTriple>,
pub(crate) refresh: Refresh, pub(crate) refresh: Refresh,
@ -715,6 +720,7 @@ impl ToolInstallSettings {
overrides, overrides,
excludes, excludes,
build_constraints, build_constraints,
lfs,
installer, installer,
force, force,
build, build,
@ -738,6 +744,7 @@ impl ToolInstallSettings {
.unwrap_or_default(); .unwrap_or_default();
let settings = ResolverInstallerSettings::from(options.clone()); let settings = ResolverInstallerSettings::from(options.clone());
let lfs = lfs.then_some(true);
Self { Self {
package, package,
@ -774,6 +781,7 @@ impl ToolInstallSettings {
.into_iter() .into_iter()
.filter_map(Maybe::into_option) .filter_map(Maybe::into_option)
.collect(), .collect(),
lfs,
python: python.and_then(Maybe::into_option), python: python.and_then(Maybe::into_option),
python_platform, python_platform,
force, force,
@ -1562,6 +1570,7 @@ pub(crate) struct AddSettings {
pub(crate) rev: Option<String>, pub(crate) rev: Option<String>,
pub(crate) tag: Option<String>, pub(crate) tag: Option<String>,
pub(crate) branch: Option<String>, pub(crate) branch: Option<String>,
pub(crate) lfs: Option<bool>,
pub(crate) package: Option<PackageName>, pub(crate) package: Option<PackageName>,
pub(crate) script: Option<PathBuf>, pub(crate) script: Option<PathBuf>,
pub(crate) python: Option<String>, pub(crate) python: Option<String>,
@ -1604,6 +1613,7 @@ impl AddSettings {
rev, rev,
tag, tag,
branch, branch,
lfs,
no_sync, no_sync,
locked, locked,
frozen, frozen,
@ -1703,6 +1713,7 @@ impl AddSettings {
.unwrap_or_default(); .unwrap_or_default();
let bounds = bounds.or(filesystem.as_ref().and_then(|fs| fs.add.add_bounds)); let bounds = bounds.or(filesystem.as_ref().and_then(|fs| fs.add.add_bounds));
let lfs = lfs.then_some(true);
Self { Self {
lock_check: if locked { lock_check: if locked {
@ -1726,6 +1737,7 @@ impl AddSettings {
rev, rev,
tag, tag,
branch, branch,
lfs,
package, package,
script, script,
python: python.and_then(Maybe::into_option), python: python.and_then(Maybe::into_option),

View File

@ -17,8 +17,7 @@ use assert_fs::fixture::{
}; };
use base64::{Engine, prelude::BASE64_STANDARD as base64}; use base64::{Engine, prelude::BASE64_STANDARD as base64};
use futures::StreamExt; use futures::StreamExt;
use indoc::formatdoc; use indoc::{formatdoc, indoc};
use indoc::indoc;
use itertools::Itertools; use itertools::Itertools;
use predicates::prelude::predicate; use predicates::prelude::predicate;
use regex::Regex; use regex::Regex;
@ -908,6 +907,31 @@ impl TestContext {
Ok(()) Ok(())
} }
/// Setup Git LFS Filters
///
/// You can find the default filters in <https://github.com/git-lfs/git-lfs/blob/v3.7.1/lfs/attribute.go#L66-L71>
/// We set required to true to get a full stacktrace when these commands fail.
pub fn with_git_lfs_config(mut self) -> Self {
let git_lfs_config = self.root.child(".gitconfig");
git_lfs_config
.write_str(indoc! {r#"
[filter "lfs"]
clean = git-lfs clean -- %f
smudge = git-lfs smudge -- %f
process = git-lfs filter-process
required = true
"#})
.expect("Failed to setup `git-lfs` filters");
// Its possible your system config can cause conflicts with the Git LFS tests.
// In such cases, add self.extra_env.push(("GIT_CONFIG_NOSYSTEM".into(), "1".into()));
self.extra_env.push((
EnvVars::GIT_CONFIG_GLOBAL.into(),
git_lfs_config.as_os_str().into(),
));
self
}
/// Shared behaviour for almost all test commands. /// Shared behaviour for almost all test commands.
/// ///
/// * Use a temporary cache directory /// * Use a temporary cache directory

View File

@ -15,9 +15,10 @@ use indoc::{formatdoc, indoc};
use insta::assert_snapshot; use insta::assert_snapshot;
use std::path::Path; use std::path::Path;
use url::Url; use url::Url;
use uv_fs::Simplified;
use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method}; use wiremock::{Mock, MockServer, ResponseTemplate, matchers::method};
use uv_cache_key::{RepositoryUrl, cache_digest};
use uv_fs::Simplified;
use uv_static::EnvVars; use uv_static::EnvVars;
use crate::common::{TestContext, packse_index_url, uv_snapshot, venv_bin_path}; use crate::common::{TestContext, packse_index_url, uv_snapshot, venv_bin_path};
@ -630,6 +631,16 @@ fn add_git_error() -> Result<()> {
error: `flask` did not resolve to a Git repository, but a Git reference (`--branch 0.0.1`) was provided. error: `flask` did not resolve to a Git repository, but a Git reference (`--branch 0.0.1`) was provided.
"###); "###);
// Request lfs without a Git source.
uv_snapshot!(context.filters(), context.add().arg("flask").arg("--lfs"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: `flask` did not resolve to a Git repository, but a Git extension (`--lfs`) was provided.
"###);
Ok(()) Ok(())
} }
@ -662,6 +673,236 @@ fn add_git_branch() -> Result<()> {
Ok(()) Ok(())
} }
#[test]
#[cfg(feature = "git-lfs")]
fn add_git_lfs() -> Result<()> {
let context = TestContext::new("3.13").with_git_lfs_config();
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(indoc! {r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = []
"#})?;
// Gather cache locations
let git_cache = context.cache_dir.child("git-v0");
let git_checkouts = git_cache.child("checkouts");
let git_db = git_cache.child("db");
let repo_url = RepositoryUrl::parse("https://github.com/astral-sh/test-lfs-repo")?;
let lfs_db_bucket_objects = git_db
.child(cache_digest(&repo_url))
.child(".git")
.child("lfs");
let ok_checkout_file = git_checkouts
.child(cache_digest(&repo_url.with_lfs(Some(true))))
.child("657500f")
.child(".ok");
uv_snapshot!(context.filters(), context.add()
.arg("--no-cache")
.arg("test-lfs-repo @ git+https://github.com/astral-sh/test-lfs-repo")
.arg("--rev").arg("657500f0703dc173ac5d68dfa1d7e8c985c84424")
.arg("--lfs"), @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/astral-sh/test-lfs-repo@657500f0703dc173ac5d68dfa1d7e8c985c84424#lfs=true)
");
let pyproject_toml = context.read("pyproject.toml");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
pyproject_toml, @r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [
"test-lfs-repo",
]
[tool.uv.sources]
test-lfs-repo = { git = "https://github.com/astral-sh/test-lfs-repo", rev = "657500f0703dc173ac5d68dfa1d7e8c985c84424", lfs = true }
"#
);
});
let lock = context.read("uv.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 3
requires-python = ">=3.13"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "test-lfs-repo" },
]
[package.metadata]
requires-dist = [{ name = "test-lfs-repo", git = "https://github.com/astral-sh/test-lfs-repo?lfs=true&rev=657500f0703dc173ac5d68dfa1d7e8c985c84424" }]
[[package]]
name = "test-lfs-repo"
version = "0.1.0"
source = { git = "https://github.com/astral-sh/test-lfs-repo?lfs=true&rev=657500f0703dc173ac5d68dfa1d7e8c985c84424#657500f0703dc173ac5d68dfa1d7e8c985c84424" }
"#
);
});
// Change revision as an unnamed requirement
uv_snapshot!(context.filters(), context.add()
.arg("--no-cache")
.arg("git+https://github.com/astral-sh/test-lfs-repo")
.arg("--rev").arg("4e82e85f6a8b8825d614ea23c550af55b2b7738c")
.arg("--lfs"), @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/astral-sh/test-lfs-repo@657500f0703dc173ac5d68dfa1d7e8c985c84424#lfs=true)
+ test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@4e82e85f6a8b8825d614ea23c550af55b2b7738c#lfs=true)
");
// Test LFS not found scenario resulting in an incomplete fetch cache
// The filters below will remove any boilerplate before what we actually want to match.
// They help handle slightly different output in uv-distribution/src/source/mod.rs between
// calls to `git` and `git_metadata` functions which don't have guaranteed execution order.
// In addition, we can get different error codes depending on where the failure occurs,
// although we know the error code cannot be 0.
let mut filters = context.filters();
filters.push((r"exit_code: -?[1-9]\d*", "exit_code: [ERROR_CODE]"));
filters.push((
"(?s)(----- stderr -----).*?The source distribution `[^`]+` is missing Git LFS artifacts.*",
"$1\n[PREFIX]The source distribution `[DISTRIBUTION]` is missing Git LFS artifacts",
));
uv_snapshot!(filters, context.add()
.env(EnvVars::UV_INTERNAL__TEST_LFS_DISABLED, "1")
.arg("git+https://github.com/astral-sh/test-lfs-repo")
.arg("--rev").arg("657500f0703dc173ac5d68dfa1d7e8c985c84424")
.arg("--lfs"), @r"
success: false
exit_code: [ERROR_CODE]
----- stdout -----
----- stderr -----
[PREFIX]The source distribution `[DISTRIBUTION]` is missing Git LFS artifacts
");
// There should be no .ok entry as LFS operations failed
assert!(!ok_checkout_file.exists(), "Found unexpected .ok file.");
// Test LFS recovery from an incomplete fetch cache
uv_snapshot!(context.filters(), context.add()
.arg("git+https://github.com/astral-sh/test-lfs-repo")
.arg("--rev").arg("657500f0703dc173ac5d68dfa1d7e8c985c84424")
.arg("--lfs"), @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/astral-sh/test-lfs-repo@4e82e85f6a8b8825d614ea23c550af55b2b7738c#lfs=true)
+ test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@657500f0703dc173ac5d68dfa1d7e8c985c84424#lfs=true)
");
// 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: true
exit_code: 0
----- stdout -----
----- stderr -----
"#);
// Now let's delete some of the LFS entries from our db...
fs_err::remove_file(&ok_checkout_file)?;
fs_err::remove_dir_all(&lfs_db_bucket_objects)?;
// Test LFS recovery from an incomplete db and non-fresh checkout
uv_snapshot!(context.filters(), context.add()
.arg("git+https://github.com/astral-sh/test-lfs-repo")
.arg("--rev").arg("657500f0703dc173ac5d68dfa1d7e8c985c84424")
.arg("--reinstall")
.arg("--lfs"), @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/astral-sh/test-lfs-repo@657500f0703dc173ac5d68dfa1d7e8c985c84424#lfs=true)
");
// 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: true
exit_code: 0
----- stdout -----
----- stderr -----
"#);
// Verify our db and checkout recovered
assert!(ok_checkout_file.exists());
assert!(lfs_db_bucket_objects.exists());
// Exercise the sdist cache
uv_snapshot!(context.filters(), context.add()
.arg("git+https://github.com/astral-sh/test-lfs-repo")
.arg("--rev").arg("657500f0703dc173ac5d68dfa1d7e8c985c84424")
.arg("--lfs"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Audited 1 package in [TIME]
");
Ok(())
}
/// Add a Git requirement using the `--raw-sources` API. /// Add a Git requirement using the `--raw-sources` API.
#[test] #[test]
#[cfg(feature = "git")] #[cfg(feature = "git")]

View File

@ -3530,6 +3530,7 @@ fn resolve_tool() -> anyhow::Result<()> {
overrides: [], overrides: [],
excludes: [], excludes: [],
build_constraints: [], build_constraints: [],
lfs: None,
python: None, python: None,
python_platform: None, python_platform: None,
refresh: None( refresh: None(

View File

@ -13832,6 +13832,438 @@ fn reject_unmatched_runtime() -> Result<()> {
Ok(()) Ok(())
} }
/// Test Git LFS configuration.
#[test]
#[cfg(feature = "git-lfs")]
fn sync_git_lfs() -> Result<()> {
let context = TestContext::new("3.13").with_git_lfs_config();
let pyproject_toml = context.temp_dir.child("pyproject.toml");
// Set `lfs = true` 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/astral-sh/test-lfs-repo.git", rev = "657500f0703dc173ac5d68dfa1d7e8c985c84424", lfs = true }
"#,
)?;
uv_snapshot!(context.filters(), context.sync().env_remove(EnvVars::UV_GIT_LFS), @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/astral-sh/test-lfs-repo.git@657500f0703dc173ac5d68dfa1d7e8c985c84424#lfs=true)
");
// 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: true
exit_code: 0
----- stdout -----
----- stderr -----
"#);
let lock = context.read("uv.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 3
requires-python = ">=3.13"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "test-lfs-repo"
version = "0.1.0"
source = { git = "https://github.com/astral-sh/test-lfs-repo.git?lfs=true&rev=657500f0703dc173ac5d68dfa1d7e8c985c84424#657500f0703dc173ac5d68dfa1d7e8c985c84424" }
[[package]]
name = "test-project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "test-lfs-repo" },
]
[package.metadata]
requires-dist = [{ name = "test-lfs-repo", git = "https://github.com/astral-sh/test-lfs-repo.git?lfs=true&rev=657500f0703dc173ac5d68dfa1d7e8c985c84424" }]
"#
);
});
// `UV_GIT_LFS=false` should not override `lfs = true`
uv_snapshot!(context.filters(), context.sync().env(EnvVars::UV_GIT_LFS, "false").arg("--reinstall"), @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/astral-sh/test-lfs-repo.git@657500f0703dc173ac5d68dfa1d7e8c985c84424#lfs=true)
");
uv_snapshot!(context.filters(), context.python_command()
.arg("-c")
.arg("import test_lfs_repo.lfs_module"), @r#"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
"#);
// 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/astral-sh/test-lfs-repo.git", rev = "657500f0703dc173ac5d68dfa1d7e8c985c84424", lfs = false }
"#,
)?;
uv_snapshot!(context.filters(), context.sync().env_remove(EnvVars::UV_GIT_LFS).arg("--reinstall"), @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/astral-sh/test-lfs-repo.git@657500f0703dc173ac5d68dfa1d7e8c985c84424#lfs=true)
+ test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@657500f0703dc173ac5d68dfa1d7e8c985c84424)
");
// 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 "<string>", line 1, in <module>
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(EnvVars::UV_GIT_LFS, "true").arg("--reinstall"), @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/astral-sh/test-lfs-repo.git@657500f0703dc173ac5d68dfa1d7e8c985c84424)
");
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 "<string>", line 1, in <module>
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
"#);
let lock = context.read("uv.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 3
requires-python = ">=3.13"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "test-lfs-repo"
version = "0.1.0"
source = { git = "https://github.com/astral-sh/test-lfs-repo.git?rev=657500f0703dc173ac5d68dfa1d7e8c985c84424#657500f0703dc173ac5d68dfa1d7e8c985c84424" }
[[package]]
name = "test-project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "test-lfs-repo" },
]
[package.metadata]
requires-dist = [{ name = "test-lfs-repo", git = "https://github.com/astral-sh/test-lfs-repo.git?rev=657500f0703dc173ac5d68dfa1d7e8c985c84424" }]
"#
);
});
// `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/astral-sh/test-lfs-repo.git", rev = "657500f0703dc173ac5d68dfa1d7e8c985c84424" }
"#,
)?;
uv_snapshot!(context.filters(), context.sync().env(EnvVars::UV_GIT_LFS, "true").arg("--reinstall"), @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/astral-sh/test-lfs-repo.git@657500f0703dc173ac5d68dfa1d7e8c985c84424)
+ test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@657500f0703dc173ac5d68dfa1d7e8c985c84424#lfs=true)
");
// 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: true
exit_code: 0
----- stdout -----
LFS module imported via env var
----- stderr -----
"#);
// Cache should be primed with non-LFS sources
uv_snapshot!(context.filters(), context.sync().env_remove(EnvVars::UV_GIT_LFS).arg("--reinstall"), @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/astral-sh/test-lfs-repo.git@657500f0703dc173ac5d68dfa1d7e8c985c84424#lfs=true)
+ test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@657500f0703dc173ac5d68dfa1d7e8c985c84424)
");
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 "<string>", line 1, in <module>
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
"#);
// Cache should be primed with LFS sources
uv_snapshot!(context.filters(), context.sync().env(EnvVars::UV_GIT_LFS, "true").arg("--reinstall"), @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/astral-sh/test-lfs-repo.git@657500f0703dc173ac5d68dfa1d7e8c985c84424)
+ test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@657500f0703dc173ac5d68dfa1d7e8c985c84424#lfs=true)
");
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: true
exit_code: 0
----- stdout -----
LFS module imported via env var
----- stderr -----
"#);
// Cache should hit non-LFS sources
uv_snapshot!(context.filters(), context.sync().env_remove(EnvVars::UV_GIT_LFS).arg("--reinstall"), @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/astral-sh/test-lfs-repo.git@657500f0703dc173ac5d68dfa1d7e8c985c84424#lfs=true)
+ test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@657500f0703dc173ac5d68dfa1d7e8c985c84424)
");
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 "<string>", line 1, in <module>
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
"#);
let lock = context.read("uv.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 3
requires-python = ">=3.13"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "test-lfs-repo"
version = "0.1.0"
source = { git = "https://github.com/astral-sh/test-lfs-repo.git?rev=657500f0703dc173ac5d68dfa1d7e8c985c84424#657500f0703dc173ac5d68dfa1d7e8c985c84424" }
[[package]]
name = "test-project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "test-lfs-repo" },
]
[package.metadata]
requires-dist = [{ name = "test-lfs-repo", git = "https://github.com/astral-sh/test-lfs-repo.git?rev=657500f0703dc173ac5d68dfa1d7e8c985c84424" }]
"#
);
});
// Cache should hit LFS sources
uv_snapshot!(context.filters(), context.sync().env(EnvVars::UV_GIT_LFS, "true").arg("--reinstall"), @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/astral-sh/test-lfs-repo.git@657500f0703dc173ac5d68dfa1d7e8c985c84424)
+ test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo.git@657500f0703dc173ac5d68dfa1d7e8c985c84424#lfs=true)
");
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: true
exit_code: 0
----- stdout -----
LFS module imported via env var
----- stderr -----
"#);
let lock = context.read("uv.lock");
insta::with_settings!({
filters => context.filters(),
}, {
assert_snapshot!(
lock, @r#"
version = 1
revision = 3
requires-python = ">=3.13"
[options]
exclude-newer = "2024-03-25T00:00:00Z"
[[package]]
name = "test-lfs-repo"
version = "0.1.0"
source = { git = "https://github.com/astral-sh/test-lfs-repo.git?lfs=true&rev=657500f0703dc173ac5d68dfa1d7e8c985c84424#657500f0703dc173ac5d68dfa1d7e8c985c84424" }
[[package]]
name = "test-project"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "test-lfs-repo" },
]
[package.metadata]
requires-dist = [{ name = "test-lfs-repo", git = "https://github.com/astral-sh/test-lfs-repo.git?lfs=true&rev=657500f0703dc173ac5d68dfa1d7e8c985c84424" }]
"#
);
});
Ok(())
}
#[test] #[test]
fn match_runtime_optional() -> Result<()> { fn match_runtime_optional() -> Result<()> {
let context = TestContext::new("3.12").with_exclude_newer("2025-01-01T00:00Z"); let context = TestContext::new("3.12").with_exclude_newer("2025-01-01T00:00Z");

View File

@ -1,3 +1,4 @@
use std::collections::BTreeSet;
use std::process::Command; use std::process::Command;
use anyhow::Result; use anyhow::Result;
@ -1817,6 +1818,297 @@ fn tool_install_unnamed_package() {
"###); "###);
} }
/// Test installing a tool with a Git requirement.
#[test]
#[cfg(feature = "git")]
fn tool_install_git() {
let context = TestContext::new("3.12").with_filtered_exe_suffix();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
let mut paths = BTreeSet::new();
// Avoid removing `git` from PATH
let git_path = which::which("git")
.expect("Failed to find `git` executable.")
.parent()
.expect("Failed to find `git` executable directory.")
.to_path_buf();
paths.insert(bin_dir.to_path_buf());
paths.insert(git_path);
// Git Submodule in macos seems to rely on `sed`.
if cfg!(target_os = "macos") {
let sed_path = which::which("sed")
.expect("Failed to find `sed` executable.")
.parent()
.expect("Failed to find `sed` executable directory.")
.to_path_buf();
paths.insert(sed_path);
}
let path = std::env::join_paths(paths).unwrap();
// Unnamed Git Install
uv_snapshot!(context.filters(), context.tool_install()
.arg("git+https://github.com/psf/black@24.2.0")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, path.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 6 packages in [TIME]
Prepared 4 packages in [TIME]
Installed 6 packages in [TIME]
+ black==24.2.0 (from git+https://github.com/psf/black@6fdf8a4af28071ed1d079c01122b34c5d587207a)
+ click==8.1.7
+ mypy-extensions==1.0.0
+ packaging==24.0
+ pathspec==0.12.1
+ platformdirs==4.2.0
Installed 2 executables: black, blackd
");
tool_dir.child("black").assert(predicate::path::is_dir());
tool_dir
.child("black")
.child("uv-receipt.toml")
.assert(predicate::path::exists());
let executable = bin_dir.child(format!("black{}", std::env::consts::EXE_SUFFIX));
assert!(executable.exists());
fs_err::remove_dir_all(&bin_dir).expect("Failed to remove bin dir.");
fs_err::remove_dir_all(&tool_dir).expect("Failed to remove tool dir.");
// Named Git Install
uv_snapshot!(context.filters(), context.tool_install()
.arg("black @ git+https://github.com/psf/black@24.2.0")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, path.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 6 packages in [TIME]
Installed 6 packages in [TIME]
+ black==24.2.0 (from git+https://github.com/psf/black@6fdf8a4af28071ed1d079c01122b34c5d587207a)
+ click==8.1.7
+ mypy-extensions==1.0.0
+ packaging==24.0
+ pathspec==0.12.1
+ platformdirs==4.2.0
Installed 2 executables: black, blackd
");
tool_dir.child("black").assert(predicate::path::is_dir());
tool_dir
.child("black")
.child("uv-receipt.toml")
.assert(predicate::path::exists());
let executable = bin_dir.child(format!("black{}", std::env::consts::EXE_SUFFIX));
assert!(executable.exists());
}
/// Test installing a tool with a Git LFS enabled requirement.
#[test]
#[cfg(feature = "git-lfs")]
fn tool_install_git_lfs() {
let context = TestContext::new("3.13")
.with_filtered_exe_suffix()
.with_git_lfs_config();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
let mut paths = BTreeSet::new();
// Avoid removing `git` or `git-lfs` from PATH
let git_path = which::which("git")
.expect("Failed to find `git` executable.")
.parent()
.expect("Failed to find `git` executable directory.")
.to_path_buf();
let git_lfs_path = which::which("git-lfs")
.expect("Failed to find `git-lfs` executable.")
.parent()
.expect("Failed to find `git-lfs` executable directory.")
.to_path_buf();
paths.insert(bin_dir.to_path_buf());
paths.insert(git_path);
paths.insert(git_lfs_path);
// Git LFS filter-process in macos seems to rely on `sh`.
// Git Submodule in macos seems to rely on `sed`.
if cfg!(target_os = "macos") {
for bin_path in ["sh", "sed"].into_iter().map(|name| {
which::which(name)
.unwrap_or_else(|_| panic!("Failed to find `{name}` executable."))
.parent()
.unwrap_or_else(|| panic!("Failed to find `{name}` executable directory."))
.to_path_buf()
}) {
paths.insert(bin_path);
}
}
let path = std::env::join_paths(paths).unwrap();
// Verify a successful LFS request
uv_snapshot!(context.filters(), context.tool_install()
.arg("--lfs")
.arg("test-lfs-repo @ git+https://github.com/astral-sh/test-lfs-repo@c6d77ab63d91104f32ab5e5ae2943f4d26ff875f")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, path.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 1 package in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@c6d77ab63d91104f32ab5e5ae2943f4d26ff875f#lfs=true)
Installed 2 executables: test-lfs-repo, test-lfs-repo-assets
");
tool_dir
.child("test-lfs-repo")
.assert(predicate::path::is_dir());
tool_dir
.child("test-lfs-repo")
.child("uv-receipt.toml")
.assert(predicate::path::exists());
let executable = bin_dir.child(format!("test-lfs-repo{}", std::env::consts::EXE_SUFFIX));
assert!(executable.exists());
insta::with_settings!({
filters => context.filters(),
}, {
// We should have a tool receipt
assert_snapshot!(fs_err::read_to_string(tool_dir.join("test-lfs-repo").join("uv-receipt.toml")).unwrap(), @r#"
[tool]
requirements = [{ name = "test-lfs-repo", git = "https://github.com/astral-sh/test-lfs-repo?lfs=true&rev=c6d77ab63d91104f32ab5e5ae2943f4d26ff875f" }]
entrypoints = [
{ name = "test-lfs-repo", install-path = "[TEMP_DIR]/bin/test-lfs-repo", from = "test-lfs-repo" },
{ name = "test-lfs-repo-assets", install-path = "[TEMP_DIR]/bin/test-lfs-repo-assets", from = "test-lfs-repo" },
]
[tool.options]
exclude-newer = "2024-03-25T00:00:00Z"
"#);
});
uv_snapshot!(context.filters(), Command::new("test-lfs-repo").env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
Hello from test-lfs-repo!
----- stderr -----
"###);
uv_snapshot!(context.filters(), Command::new("test-lfs-repo-assets").env(EnvVars::PATH, bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
Hello from test-lfs-repo! LFS_TEST=True ANOTHER_LFS_TEST=True
----- stderr -----
"###);
// Attempt to install when LFS artifacts are missing and LFS is requested.
// The filters below will remove any boilerplate before what we actually want to match.
// They help handle slightly different output in uv-distribution/src/source/mod.rs between
// calls to `git` and `git_metadata` functions which don't have guaranteed execution order.
// In addition, we can get different error codes depending on where the failure occurs,
// although we know the error code cannot be 0.
let mut filters = context.filters();
filters.push((r"exit_code: -?[1-9]\d*", "exit_code: [ERROR_CODE]"));
filters.push((
"(?s)(----- stderr -----).*?The source distribution `[^`]+` is missing Git LFS artifacts.*",
"$1\n[PREFIX]The source distribution `[DISTRIBUTION]` is missing Git LFS artifacts",
));
uv_snapshot!(filters, context.tool_install()
.arg("--reinstall")
.arg("--lfs")
.arg("test-lfs-repo @ git+https://github.com/astral-sh/test-lfs-repo@c6d77ab63d91104f32ab5e5ae2943f4d26ff875f")
.env(EnvVars::UV_INTERNAL__TEST_LFS_DISABLED, "1")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, path.as_os_str()), @r"
success: false
exit_code: [ERROR_CODE]
----- stdout -----
----- stderr -----
[PREFIX]The source distribution `[DISTRIBUTION]` is missing Git LFS artifacts
");
// Attempt to install when LFS artifacts are missing but LFS was not requested.
uv_snapshot!(context.filters(), context.tool_install()
.arg("test-lfs-repo @ git+https://github.com/astral-sh/test-lfs-repo@c6d77ab63d91104f32ab5e5ae2943f4d26ff875f")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str())
.env(EnvVars::PATH, path.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 1 package 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/astral-sh/test-lfs-repo@c6d77ab63d91104f32ab5e5ae2943f4d26ff875f#lfs=true)
+ test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@c6d77ab63d91104f32ab5e5ae2943f4d26ff875f)
Installed 2 executables: test-lfs-repo, test-lfs-repo-assets
");
#[cfg(not(windows))]
uv_snapshot!(context.filters(), Command::new("test-lfs-repo-assets").env(EnvVars::PATH, bin_dir.as_os_str()), @r#"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Traceback (most recent call last):
File "[TEMP_DIR]/bin/test-lfs-repo-assets", line 10, in <module>
sys.exit(main_lfs())
~~~~~~~~^^
File "[TEMP_DIR]/tools/test-lfs-repo/[PYTHON-LIB]/site-packages/test_lfs_repo/__init__.py", line 5, in main_lfs
from .lfs_module import LFS_TEST
File "[TEMP_DIR]/tools/test-lfs-repo/[PYTHON-LIB]/site-packages/test_lfs_repo/lfs_module.py", line 1
version https://git-lfs.github.com/spec/v1
^^^^^
SyntaxError: invalid syntax
"#);
#[cfg(windows)]
uv_snapshot!(context.filters(), Command::new("test-lfs-repo-assets").env(EnvVars::PATH, bin_dir.as_os_str()), @r#"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Traceback (most recent call last):
File "<frozen runpy>", line 198, in _run_module_as_main
File "<frozen runpy>", line 88, in _run_code
File "[TEMP_DIR]/bin/test-lfs-repo-assets/__main__.py", line 10, in <module>
sys.exit(main_lfs())
~~~~~~~~^^
File "[TEMP_DIR]/tools/test-lfs-repo/[PYTHON-LIB]/site-packages/test_lfs_repo/__init__.py", line 5, in main_lfs
from .lfs_module import LFS_TEST
File "[TEMP_DIR]/tools/test-lfs-repo/[PYTHON-LIB]/site-packages/test_lfs_repo/lfs_module.py", line 1
version https://git-lfs.github.com/spec/v1
^^^^^
SyntaxError: invalid syntax
"#);
}
/// Test installing a tool with a bare URL requirement using `--from`, where the URL and the package /// Test installing a tool with a bare URL requirement using `--from`, where the URL and the package
/// name conflict. /// name conflict.
#[test] #[test]

View File

@ -931,6 +931,269 @@ fn tool_run_url() {
"###); "###);
} }
/// Test running a tool with a Git requirement.
#[test]
#[cfg(feature = "git")]
fn tool_run_git() {
let context = TestContext::new("3.12").with_filtered_counts();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
uv_snapshot!(context.filters(), context.tool_run()
.arg("git+https://github.com/psf/black@24.2.0")
.arg("--version")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
black, 24.2.0 (compiled: no)
Python (CPython) 3.12.[X]
----- stderr -----
Resolved [N] packages in [TIME]
Prepared [N] packages in [TIME]
Installed [N] packages in [TIME]
+ black==24.2.0 (from git+https://github.com/psf/black@6fdf8a4af28071ed1d079c01122b34c5d587207a)
+ click==8.1.7
+ mypy-extensions==1.0.0
+ packaging==24.0
+ pathspec==0.12.1
+ platformdirs==4.2.0
"###);
uv_snapshot!(context.filters(), context.tool_run()
.arg("black @ git+https://github.com/psf/black@24.2.0")
.arg("--version")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
black, 24.2.0 (compiled: no)
Python (CPython) 3.12.[X]
----- stderr -----
Resolved [N] packages in [TIME]
"###);
// Clear the cache.
fs_err::remove_dir_all(&context.cache_dir).expect("Failed to remove cache dir.");
uv_snapshot!(context.filters(), context.tool_run()
.arg("--from")
.arg("git+https://github.com/psf/black@24.2.0")
.arg("black")
.arg("--version")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
black, 24.2.0 (compiled: no)
Python (CPython) 3.12.[X]
----- stderr -----
Resolved [N] packages in [TIME]
Prepared [N] packages in [TIME]
Installed [N] packages in [TIME]
+ black==24.2.0 (from git+https://github.com/psf/black@6fdf8a4af28071ed1d079c01122b34c5d587207a)
+ click==8.1.7
+ mypy-extensions==1.0.0
+ packaging==24.0
+ pathspec==0.12.1
+ platformdirs==4.2.0
");
uv_snapshot!(context.filters(), context.tool_run()
.arg("--from")
.arg("black @ git+https://github.com/psf/black@24.2.0")
.arg("black")
.arg("--version")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
black, 24.2.0 (compiled: no)
Python (CPython) 3.12.[X]
----- stderr -----
Resolved [N] packages in [TIME]
"###);
}
/// Test running a tool with a Git LFS enabled requirement.
#[test]
#[cfg(feature = "git-lfs")]
fn tool_run_git_lfs() {
let context = TestContext::new("3.13")
.with_filtered_counts()
.with_filtered_exe_suffix()
.with_git_lfs_config();
let tool_dir = context.temp_dir.child("tools");
let bin_dir = context.temp_dir.child("bin");
uv_snapshot!(context.filters(), context.tool_run()
.arg("--lfs")
.arg("git+https://github.com/astral-sh/test-lfs-repo@c6d77ab63d91104f32ab5e5ae2943f4d26ff875f")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
Hello from test-lfs-repo!
----- stderr -----
Resolved [N] packages in [TIME]
Prepared [N] packages in [TIME]
Installed [N] packages in [TIME]
+ test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@c6d77ab63d91104f32ab5e5ae2943f4d26ff875f#lfs=true)
"###);
uv_snapshot!(context.filters(), context.tool_run()
.arg("--lfs")
.arg("test-lfs-repo @ git+https://github.com/astral-sh/test-lfs-repo@c6d77ab63d91104f32ab5e5ae2943f4d26ff875f")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r###"
success: true
exit_code: 0
----- stdout -----
Hello from test-lfs-repo!
----- stderr -----
Resolved [N] packages in [TIME]
"###);
// Clear the cache.
fs_err::remove_dir_all(&context.cache_dir).expect("Failed to remove cache dir.");
uv_snapshot!(context.filters(), context.tool_run()
.arg("--from")
.arg("git+https://github.com/astral-sh/test-lfs-repo@c6d77ab63d91104f32ab5e5ae2943f4d26ff875f")
.arg("--lfs")
.arg("test-lfs-repo-assets")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
Hello from test-lfs-repo! LFS_TEST=True ANOTHER_LFS_TEST=True
----- stderr -----
Resolved [N] packages in [TIME]
Prepared [N] packages in [TIME]
Installed [N] packages in [TIME]
+ test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@c6d77ab63d91104f32ab5e5ae2943f4d26ff875f#lfs=true)
");
uv_snapshot!(context.filters(), context.tool_run()
.arg("--from")
.arg("test-lfs-repo @ git+https://github.com/astral-sh/test-lfs-repo@c6d77ab63d91104f32ab5e5ae2943f4d26ff875f")
.arg("--lfs")
.arg("test-lfs-repo-assets")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r"
success: true
exit_code: 0
----- stdout -----
Hello from test-lfs-repo! LFS_TEST=True ANOTHER_LFS_TEST=True
----- stderr -----
Resolved [N] packages in [TIME]
");
// Clear the cache.
fs_err::remove_dir_all(&context.cache_dir).expect("Failed to remove cache dir.");
// Attempt to run when LFS artifacts are missing and LFS is requested.
// The filters below will remove any boilerplate before what we actually want to match.
// They help handle slightly different output in uv-distribution/src/source/mod.rs between
// calls to `git` and `git_metadata` functions which don't have guaranteed execution order.
// In addition, we can get different error codes depending on where the failure occurs,
// although we know the error code cannot be 0.
let mut filters = context.filters();
filters.push((r"exit_code: -?[1-9]\d*", "exit_code: [ERROR_CODE]"));
filters.push((
"(?s)(----- stderr -----).*?The source distribution `[^`]+` is missing Git LFS artifacts.*",
"$1\n[PREFIX]The source distribution `[DISTRIBUTION]` is missing Git LFS artifacts",
));
uv_snapshot!(filters, context.tool_run()
.arg("--lfs")
.arg("test-lfs-repo @ git+https://github.com/astral-sh/test-lfs-repo@c6d77ab63d91104f32ab5e5ae2943f4d26ff875f")
.env(EnvVars::UV_INTERNAL__TEST_LFS_DISABLED, "1")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r"
success: false
exit_code: [ERROR_CODE]
----- stdout -----
----- stderr -----
[PREFIX]The source distribution `[DISTRIBUTION]` is missing Git LFS artifacts
");
// Attempt to run when LFS artifacts are missing but LFS was not requested.
#[cfg(not(windows))]
uv_snapshot!(context.filters(), context.tool_run()
.arg("--from")
.arg("test-lfs-repo @ git+https://github.com/astral-sh/test-lfs-repo@c6d77ab63d91104f32ab5e5ae2943f4d26ff875f")
.arg("test-lfs-repo-assets")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r#"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Resolved [N] packages in [TIME]
Prepared [N] packages in [TIME]
Installed [N] packages in [TIME]
+ test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@c6d77ab63d91104f32ab5e5ae2943f4d26ff875f)
Traceback (most recent call last):
File "[CACHE_DIR]/archive-v0/[HASH]/bin/test-lfs-repo-assets", line 12, in <module>
sys.exit(main_lfs())
~~~~~~~~^^
File "[CACHE_DIR]/archive-v0/[HASH]/[PYTHON-LIB]/site-packages/test_lfs_repo/__init__.py", line 5, in main_lfs
from .lfs_module import LFS_TEST
File "[CACHE_DIR]/archive-v0/[HASH]/[PYTHON-LIB]/site-packages/test_lfs_repo/lfs_module.py", line 1
version https://git-lfs.github.com/spec/v1
^^^^^
SyntaxError: invalid syntax
"#);
#[cfg(windows)]
uv_snapshot!(context.filters(), context.tool_run()
.arg("--from")
.arg("test-lfs-repo @ git+https://github.com/astral-sh/test-lfs-repo@c6d77ab63d91104f32ab5e5ae2943f4d26ff875f")
.arg("test-lfs-repo-assets")
.env(EnvVars::UV_TOOL_DIR, tool_dir.as_os_str())
.env(EnvVars::XDG_BIN_HOME, bin_dir.as_os_str()), @r#"
success: false
exit_code: 1
----- stdout -----
----- stderr -----
Resolved [N] packages in [TIME]
Prepared [N] packages in [TIME]
Installed [N] packages in [TIME]
+ test-lfs-repo==0.1.0 (from git+https://github.com/astral-sh/test-lfs-repo@c6d77ab63d91104f32ab5e5ae2943f4d26ff875f)
Traceback (most recent call last):
File "<frozen runpy>", line 198, in _run_module_as_main
File "<frozen runpy>", line 88, in _run_code
File "[CACHE_DIR]/archive-v0/[HASH]/Scripts/test-lfs-repo-assets/__main__.py", line 10, in <module>
sys.exit(main_lfs())
~~~~~~~~^^
File "[CACHE_DIR]/archive-v0/[HASH]/[PYTHON-LIB]/site-packages/test_lfs_repo/__init__.py", line 5, in main_lfs
from .lfs_module import LFS_TEST
File "[CACHE_DIR]/archive-v0/[HASH]/[PYTHON-LIB]/site-packages/test_lfs_repo/lfs_module.py", line 1
version https://git-lfs.github.com/spec/v1
^^^^^
SyntaxError: invalid syntax
"#);
}
/// Read requirements from a `requirements.txt` file. /// Read requirements from a `requirements.txt` file.
#[test] #[test]
fn tool_run_requirements_txt() { fn tool_run_requirements_txt() {

View File

@ -350,6 +350,31 @@ dependencies = ["langchain"]
langchain = { git = "https://github.com/langchain-ai/langchain", subdirectory = "libs/langchain" } langchain = { git = "https://github.com/langchain-ai/langchain", subdirectory = "libs/langchain" }
``` ```
Support for [Git LFS](https://git-lfs.com) is also configurable per source. By default, Git LFS
objects will not be fetched.
```console
$ uv add --lfs git+https://github.com/astral-sh/lfs-cowsay
```
```toml title="pyproject.toml"
[project]
dependencies = ["lfs-cowsay"]
[tool.uv.sources]
lfs-cowsay = { git = "https://github.com/astral-sh/lfs-cowsay", lfs = true }
```
- When `lfs = true`, uv will always fetch LFS objects for this Git source.
- When `lfs = false`, uv will never fetch LFS objects for this Git source.
- When omitted, the `UV_GIT_LFS` environment variable is used for all Git sources without an
explicit `lfs` configuration.
!!! important
Ensure Git LFS is installed and configured on your system before attempting to install sources
using Git LFS, otherwise a build failure can occur.
### URL ### URL
To add a URL source, provide a `https://` URL to either a wheel (ending in `.whl`) or a source To add a URL source, provide a `https://` URL to either a wheel (ending in `.whl`) or a source

View File

@ -142,6 +142,12 @@ Or even a specific commit:
$ uvx --from git+https://github.com/httpie/cli@2843b87 httpie $ uvx --from git+https://github.com/httpie/cli@2843b87 httpie
``` ```
Or with [Git LFS](https://git-lfs.com) support:
```console
$ uvx --lfs --from git+https://github.com/astral-sh/lfs-cowsay lfs-cowsay
```
## Commands with plugins ## Commands with plugins
Additional dependencies can be included, e.g., to include `mkdocs-material` when running `mkdocs`: Additional dependencies can be included, e.g., to include `mkdocs-material` when running `mkdocs`:
@ -207,6 +213,12 @@ And, similarly, for package sources:
$ uv tool install git+https://github.com/httpie/cli $ uv tool install git+https://github.com/httpie/cli
``` ```
Or package sources with [Git LFS](https://git-lfs.com):
```console
$ uv tool install --lfs git+https://github.com/astral-sh/lfs-cowsay
```
As with `uvx`, installations can include additional packages: As with `uvx`, installations can include additional packages:
```console ```console

View File

@ -865,7 +865,8 @@ uv add [OPTIONS] <PACKAGES|--requirements <REQUIREMENTS>>
<ul> <ul>
<li><code>disabled</code>: Do not use keyring for credential lookup</li> <li><code>disabled</code>: Do not use keyring for credential lookup</li>
<li><code>subprocess</code>: Use the <code>keyring</code> command for credential lookup</li> <li><code>subprocess</code>: Use the <code>keyring</code> command for credential lookup</li>
</ul></dd><dt id="uv-add--link-mode"><a href="#uv-add--link-mode"><code>--link-mode</code></a> <i>link-mode</i></dt><dd><p>The method to use when installing packages from the global cache.</p> </ul></dd><dt id="uv-add--lfs"><a href="#uv-add--lfs"><code>--lfs</code></a></dt><dd><p>Whether to use Git LFS when adding a dependency from Git</p>
<p>May also be set with the <code>UV_GIT_LFS</code> environment variable.</p></dd><dt id="uv-add--link-mode"><a href="#uv-add--link-mode"><code>--link-mode</code></a> <i>link-mode</i></dt><dd><p>The method to use when installing packages from the global cache.</p>
<p>Defaults to <code>clone</code> (also known as Copy-on-Write) on macOS, and <code>hardlink</code> on Linux and Windows.</p> <p>Defaults to <code>clone</code> (also known as Copy-on-Write) on macOS, and <code>hardlink</code> on Linux and Windows.</p>
<p>WARNING: The use of symlink link mode is discouraged, as they create tight coupling between the cache and the target environment. For example, clearing the cache (<code>uv cache clean</code>) will break all installed packages by way of removing the underlying source files. Use symlinks with caution.</p> <p>WARNING: The use of symlink link mode is discouraged, as they create tight coupling between the cache and the target environment. For example, clearing the cache (<code>uv cache clean</code>) will break all installed packages by way of removing the underlying source files. Use symlinks with caution.</p>
<p>May also be set with the <code>UV_LINK_MODE</code> environment variable.</p><p>Possible values:</p> <p>May also be set with the <code>UV_LINK_MODE</code> environment variable.</p><p>Possible values:</p>
@ -2436,7 +2437,8 @@ uv tool run [OPTIONS] [COMMAND]
<ul> <ul>
<li><code>disabled</code>: Do not use keyring for credential lookup</li> <li><code>disabled</code>: Do not use keyring for credential lookup</li>
<li><code>subprocess</code>: Use the <code>keyring</code> command for credential lookup</li> <li><code>subprocess</code>: Use the <code>keyring</code> command for credential lookup</li>
</ul></dd><dt id="uv-tool-run--link-mode"><a href="#uv-tool-run--link-mode"><code>--link-mode</code></a> <i>link-mode</i></dt><dd><p>The method to use when installing packages from the global cache.</p> </ul></dd><dt id="uv-tool-run--lfs"><a href="#uv-tool-run--lfs"><code>--lfs</code></a></dt><dd><p>Whether to use Git LFS when adding a dependency from Git</p>
<p>May also be set with the <code>UV_GIT_LFS</code> environment variable.</p></dd><dt id="uv-tool-run--link-mode"><a href="#uv-tool-run--link-mode"><code>--link-mode</code></a> <i>link-mode</i></dt><dd><p>The method to use when installing packages from the global cache.</p>
<p>Defaults to <code>clone</code> (also known as Copy-on-Write) on macOS, and <code>hardlink</code> on Linux and Windows.</p> <p>Defaults to <code>clone</code> (also known as Copy-on-Write) on macOS, and <code>hardlink</code> on Linux and Windows.</p>
<p>WARNING: The use of symlink link mode is discouraged, as they create tight coupling between the cache and the target environment. For example, clearing the cache (<code>uv cache clean</code>) will break all installed packages by way of removing the underlying source files. Use symlinks with caution.</p> <p>WARNING: The use of symlink link mode is discouraged, as they create tight coupling between the cache and the target environment. For example, clearing the cache (<code>uv cache clean</code>) will break all installed packages by way of removing the underlying source files. Use symlinks with caution.</p>
<p>May also be set with the <code>UV_LINK_MODE</code> environment variable.</p><p>Possible values:</p> <p>May also be set with the <code>UV_LINK_MODE</code> environment variable.</p><p>Possible values:</p>
@ -2667,7 +2669,8 @@ uv tool install [OPTIONS] <PACKAGE>
<ul> <ul>
<li><code>disabled</code>: Do not use keyring for credential lookup</li> <li><code>disabled</code>: Do not use keyring for credential lookup</li>
<li><code>subprocess</code>: Use the <code>keyring</code> command for credential lookup</li> <li><code>subprocess</code>: Use the <code>keyring</code> command for credential lookup</li>
</ul></dd><dt id="uv-tool-install--link-mode"><a href="#uv-tool-install--link-mode"><code>--link-mode</code></a> <i>link-mode</i></dt><dd><p>The method to use when installing packages from the global cache.</p> </ul></dd><dt id="uv-tool-install--lfs"><a href="#uv-tool-install--lfs"><code>--lfs</code></a></dt><dd><p>Whether to use Git LFS when adding a dependency from Git</p>
<p>May also be set with the <code>UV_GIT_LFS</code> environment variable.</p></dd><dt id="uv-tool-install--link-mode"><a href="#uv-tool-install--link-mode"><code>--link-mode</code></a> <i>link-mode</i></dt><dd><p>The method to use when installing packages from the global cache.</p>
<p>Defaults to <code>clone</code> (also known as Copy-on-Write) on macOS, and <code>hardlink</code> on Linux and Windows.</p> <p>Defaults to <code>clone</code> (also known as Copy-on-Write) on macOS, and <code>hardlink</code> on Linux and Windows.</p>
<p>WARNING: The use of symlink link mode is discouraged, as they create tight coupling between the cache and the target environment. For example, clearing the cache (<code>uv cache clean</code>) will break all installed packages by way of removing the underlying source files. Use symlinks with caution.</p> <p>WARNING: The use of symlink link mode is discouraged, as they create tight coupling between the cache and the target environment. For example, clearing the cache (<code>uv cache clean</code>) will break all installed packages by way of removing the underlying source files. Use symlinks with caution.</p>
<p>May also be set with the <code>UV_LINK_MODE</code> environment variable.</p><p>Possible values:</p> <p>May also be set with the <code>UV_LINK_MODE</code> environment variable.</p><p>Possible values:</p>

7
uv.schema.json generated
View File

@ -1965,6 +1965,13 @@
} }
] ]
}, },
"lfs": {
"description": "Whether to use Git LFS when cloning the repository.",
"type": [
"boolean",
"null"
]
},
"marker": { "marker": {
"$ref": "#/definitions/MarkerTree" "$ref": "#/definitions/MarkerTree"
}, },