Relax error when using uv add with `UV_GIT_LFS` set (#17127)

## Summary

Closes https://github.com/astral-sh/uv/issues/17083

Previously having `UV_GIT_LFS` set would cause an error when adding a
non-git requirement such as ```error: `requirement` did not resolve to a
Git repository, but a Git extension (`--lfs`) was provided.```

## Test Plan

Additional test has been added.
This commit is contained in:
samypr100 2025-12-15 09:26:14 -05:00 committed by GitHub
parent d20948bec2
commit a768a9d111
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 133 additions and 48 deletions

View File

@ -4161,7 +4161,7 @@ pub struct AddArgs {
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())]
#[arg(long)]
pub lfs: bool,
/// Extras to enable for the dependency.
@ -5139,7 +5139,7 @@ pub struct ToolRunArgs {
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())]
#[arg(long)]
pub lfs: bool,
/// The Python interpreter to use to build the run environment.
@ -5290,7 +5290,7 @@ pub struct ToolInstallArgs {
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())]
#[arg(long)]
pub lfs: bool,
/// The Python interpreter to use to build the tool environment.

View File

@ -94,3 +94,32 @@ wheels/
# Virtual environments
.venv
";
/// Setting for Git LFS (Large File Storage) support.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum GitLfsSetting {
/// Git LFS is disabled (default).
#[default]
Disabled,
/// Git LFS is enabled. Tracks whether it came from an environment variable.
Enabled { from_env: bool },
}
impl GitLfsSetting {
pub fn new(from_arg: Option<bool>, from_env: Option<bool>) -> Self {
match (from_arg, from_env) {
(Some(true), _) => Self::Enabled { from_env: false },
(_, Some(true)) => Self::Enabled { from_env: true },
_ => Self::Disabled,
}
}
}
impl From<GitLfsSetting> for Option<bool> {
fn from(setting: GitLfsSetting) -> Self {
match setting {
GitLfsSetting::Enabled { .. } => Some(true),
GitLfsSetting::Disabled => None,
}
}
}

View File

@ -590,6 +590,7 @@ pub struct EnvironmentOptions {
pub python_install_registry: Option<bool>,
pub install_mirrors: PythonInstallMirrors,
pub log_context: Option<bool>,
pub lfs: Option<bool>,
pub http_timeout: Duration,
pub http_retries: u32,
pub upload_http_timeout: Duration,
@ -636,6 +637,7 @@ impl EnvironmentOptions {
)?,
},
log_context: parse_boolish_environment_variable(EnvVars::UV_LOG_CONTEXT)?,
lfs: parse_boolish_environment_variable(EnvVars::UV_GIT_LFS)?,
upload_http_timeout: parse_integer_environment_variable(
EnvVars::UV_UPLOAD_HTTP_TIMEOUT,
)?

View File

@ -22,6 +22,7 @@ use serde::{Deserialize, Deserializer, Serialize};
use thiserror::Error;
use uv_build_backend::BuildBackendSettings;
use uv_configuration::GitLfsSetting;
use uv_distribution_types::{Index, IndexName, RequirementSource};
use uv_fs::{PortablePathBuf, relative_to};
use uv_git_types::GitReference;
@ -1613,13 +1614,16 @@ impl Source {
rev: Option<String>,
tag: Option<String>,
branch: Option<String>,
lfs: Option<bool>,
lfs: GitLfsSetting,
root: &Path,
existing_sources: Option<&BTreeMap<PackageName, Sources>>,
) -> Result<Option<Self>, SourceError> {
// If the user specified a Git reference for a non-Git source, try existing Git sources before erroring.
if !matches!(source, RequirementSource::Git { .. })
&& (branch.is_some() || tag.is_some() || rev.is_some() || lfs.is_some())
&& (branch.is_some()
|| tag.is_some()
|| rev.is_some()
|| matches!(lfs, GitLfsSetting::Enabled { .. }))
{
if let Some(sources) = existing_sources {
if let Some(package_sources) = sources.get(name) {
@ -1639,7 +1643,7 @@ impl Source {
rev,
tag,
branch,
lfs,
lfs: lfs.into(),
marker: *marker,
extra: extra.clone(),
group: group.clone(),
@ -1657,7 +1661,7 @@ impl Source {
if let Some(branch) = branch {
return Err(SourceError::UnusedBranch(name.to_string(), branch));
}
if let Some(true) = lfs {
if matches!(lfs, GitLfsSetting::Enabled { from_env: false }) {
return Err(SourceError::UnusedLfs(name.to_string()));
}
}
@ -1768,7 +1772,7 @@ impl Source {
rev: rev.cloned(),
tag,
branch,
lfs,
lfs: lfs.into(),
git: git.repository().clone(),
subdirectory: subdirectory.map(PortablePathBuf::from),
marker: MarkerTree::TRUE,
@ -1780,7 +1784,7 @@ impl Source {
rev,
tag,
branch,
lfs,
lfs: lfs.into(),
git: git.repository().clone(),
subdirectory: subdirectory.map(PortablePathBuf::from),
marker: MarkerTree::TRUE,

View File

@ -17,7 +17,8 @@ use uv_cache_key::RepositoryUrl;
use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder};
use uv_configuration::{
Concurrency, Constraints, DependencyGroups, DependencyGroupsWithDefaults, DevMode, DryRun,
ExtrasSpecification, ExtrasSpecificationWithDefaults, InstallOptions, SourceStrategy,
ExtrasSpecification, ExtrasSpecificationWithDefaults, GitLfsSetting, InstallOptions,
SourceStrategy,
};
use uv_dispatch::BuildDispatch;
use uv_distribution::{DistributionDatabase, LoweredExtraBuildDependencies};
@ -85,7 +86,7 @@ pub(crate) async fn add(
rev: Option<String>,
tag: Option<String>,
branch: Option<String>,
lfs: Option<bool>,
lfs: GitLfsSetting,
extras_of_dependency: Vec<ExtraName>,
package: Option<PackageName>,
python: Option<String>,
@ -377,7 +378,7 @@ pub(crate) async fn add(
rev.as_deref(),
tag.as_deref(),
branch.as_deref(),
lfs,
lfs.into(),
marker,
)
})
@ -797,7 +798,7 @@ fn edits(
rev: Option<&str>,
tag: Option<&str>,
branch: Option<&str>,
lfs: Option<bool>,
lfs: GitLfsSetting,
extras: &[ExtraName],
index: Option<&IndexName>,
toml: &mut PyProjectTomlMut,
@ -1231,7 +1232,7 @@ fn resolve_requirement(
rev: Option<String>,
tag: Option<String>,
branch: Option<String>,
lfs: Option<bool>,
lfs: GitLfsSetting,
root: &Path,
existing_sources: Option<&BTreeMap<PackageName, Sources>>,
) -> Result<(uv_pep508::Requirement, Option<Source>), anyhow::Error> {

View File

@ -12,8 +12,8 @@ use uv_cache::{Cache, CacheBucket};
use uv_cache_key::cache_digest;
use uv_client::{BaseClientBuilder, FlatIndexClient, RegistryClientBuilder};
use uv_configuration::{
Concurrency, Constraints, DependencyGroupsWithDefaults, DryRun, ExtrasSpecification, Reinstall,
TargetTriple, Upgrade,
Concurrency, Constraints, DependencyGroupsWithDefaults, DryRun, ExtrasSpecification,
GitLfsSetting, Reinstall, TargetTriple, Upgrade,
};
use uv_dispatch::{BuildDispatch, SharedState};
use uv_distribution::{DistributionDatabase, LoweredExtraBuildDependencies, LoweredRequirement};
@ -47,8 +47,7 @@ use uv_types::{BuildIsolation, EmptyInstalledPackages, HashStrategy};
use uv_virtualenv::remove_virtualenv;
use uv_warnings::{warn_user, warn_user_once};
use uv_workspace::dependency_groups::DependencyGroupError;
use uv_workspace::pyproject::ExtraBuildDependency;
use uv_workspace::pyproject::PyProjectToml;
use uv_workspace::pyproject::{ExtraBuildDependency, PyProjectToml};
use uv_workspace::{RequiresPythonSources, Workspace, WorkspaceCache};
use crate::commands::pip::loggers::{InstallLogger, ResolveLogger};
@ -1685,14 +1684,14 @@ pub(crate) async fn resolve_names(
workspace_cache: &WorkspaceCache,
printer: Printer,
preview: Preview,
lfs: Option<bool>,
lfs: GitLfsSetting,
) -> Result<Vec<Requirement>, uv_requirements::Error> {
// Partition the requirements into named and unnamed requirements.
let (mut requirements, unnamed): (Vec<_>, Vec<_>) = requirements
.into_iter()
.map(|spec| {
spec.requirement
.augment_requirement(None, None, None, lfs, None)
.augment_requirement(None, None, None, lfs.into(), None)
})
.partition_map(|requirement| match requirement {
UnresolvedRequirement::Named(requirement) => itertools::Either::Left(requirement),

View File

@ -9,7 +9,9 @@ use tracing::{debug, trace};
use uv_cache::{Cache, Refresh};
use uv_cache_info::Timestamp;
use uv_client::{BaseClientBuilder, RegistryClientBuilder};
use uv_configuration::{Concurrency, Constraints, DryRun, Reinstall, TargetTriple, Upgrade};
use uv_configuration::{
Concurrency, Constraints, DryRun, GitLfsSetting, Reinstall, TargetTriple, Upgrade,
};
use uv_distribution::LoweredExtraBuildDependencies;
use uv_distribution_types::{
ExtraBuildRequires, IndexCapabilities, NameRequirementSpecification, Requirement,
@ -58,7 +60,7 @@ pub(crate) async fn install(
excludes: &[RequirementsSource],
build_constraints: &[RequirementsSource],
entrypoints: &[PackageName],
lfs: Option<bool>,
lfs: GitLfsSetting,
python: Option<String>,
python_platform: Option<TargetTriple>,
install_mirrors: PythonInstallMirrors,

View File

@ -17,9 +17,7 @@ use uv_cache::{Cache, Refresh};
use uv_cache_info::Timestamp;
use uv_cli::ExternalCommand;
use uv_client::{BaseClientBuilder, RegistryClientBuilder};
use uv_configuration::Concurrency;
use uv_configuration::Constraints;
use uv_configuration::TargetTriple;
use uv_configuration::{Concurrency, Constraints, GitLfsSetting, TargetTriple};
use uv_distribution::LoweredExtraBuildDependencies;
use uv_distribution_types::InstalledDist;
use uv_distribution_types::{
@ -106,7 +104,7 @@ pub(crate) async fn run(
overrides: &[RequirementsSource],
build_constraints: &[RequirementsSource],
show_resolution: bool,
lfs: Option<bool>,
lfs: GitLfsSetting,
python: Option<String>,
python_platform: Option<TargetTriple>,
install_mirrors: PythonInstallMirrors,
@ -726,7 +724,7 @@ async fn get_or_create_environment(
settings: &ResolverInstallerSettings,
client_builder: &BaseClientBuilder<'_>,
isolated: bool,
lfs: Option<bool>,
lfs: GitLfsSetting,
python_preference: PythonPreference,
python_downloads: PythonDownloads,
installer_metadata: bool,

View File

@ -26,10 +26,10 @@ use uv_cli::{
use uv_client::Connectivity;
use uv_configuration::{
BuildIsolation, BuildOptions, Concurrency, DependencyGroups, DryRun, EditableMode, EnvFile,
ExportFormat, ExtrasSpecification, HashCheckingMode, IndexStrategy, InstallOptions,
KeyringProviderType, NoBinary, NoBuild, PipCompileFormat, ProjectBuildBackend, Reinstall,
RequiredVersion, SourceStrategy, TargetTriple, TrustedHost, TrustedPublishing, Upgrade,
VersionControlSystem,
ExportFormat, ExtrasSpecification, GitLfsSetting, HashCheckingMode, IndexStrategy,
InstallOptions, KeyringProviderType, NoBinary, NoBuild, PipCompileFormat, ProjectBuildBackend,
Reinstall, RequiredVersion, SourceStrategy, TargetTriple, TrustedHost, TrustedPublishing,
Upgrade, VersionControlSystem,
};
use uv_distribution_types::{
ConfigSettings, DependencyMetadata, ExtraBuildVariables, Index, IndexLocations, IndexUrl,
@ -547,7 +547,7 @@ pub(crate) struct ToolRunSettings {
pub(crate) build_constraints: Vec<PathBuf>,
pub(crate) isolated: bool,
pub(crate) show_resolution: bool,
pub(crate) lfs: Option<bool>,
pub(crate) lfs: GitLfsSetting,
pub(crate) python: Option<String>,
pub(crate) python_platform: Option<TargetTriple>,
pub(crate) install_mirrors: PythonInstallMirrors,
@ -630,7 +630,7 @@ impl ToolRunSettings {
.unwrap_or_default();
let settings = ResolverInstallerSettings::from(options.clone());
let lfs = lfs.then_some(true);
let lfs = GitLfsSetting::new(lfs.then_some(true), environment.lfs);
Self {
command,
@ -689,7 +689,7 @@ pub(crate) struct ToolInstallSettings {
pub(crate) overrides: Vec<PathBuf>,
pub(crate) excludes: Vec<PathBuf>,
pub(crate) build_constraints: Vec<PathBuf>,
pub(crate) lfs: Option<bool>,
pub(crate) lfs: GitLfsSetting,
pub(crate) python: Option<String>,
pub(crate) python_platform: Option<TargetTriple>,
pub(crate) refresh: Refresh,
@ -744,7 +744,7 @@ impl ToolInstallSettings {
.unwrap_or_default();
let settings = ResolverInstallerSettings::from(options.clone());
let lfs = lfs.then_some(true);
let lfs = GitLfsSetting::new(lfs.then_some(true), environment.lfs);
Self {
package,
@ -1570,7 +1570,7 @@ pub(crate) struct AddSettings {
pub(crate) rev: Option<String>,
pub(crate) tag: Option<String>,
pub(crate) branch: Option<String>,
pub(crate) lfs: Option<bool>,
pub(crate) lfs: GitLfsSetting,
pub(crate) package: Option<PackageName>,
pub(crate) script: Option<PathBuf>,
pub(crate) python: Option<String>,
@ -1713,7 +1713,7 @@ impl AddSettings {
.unwrap_or_default();
let bounds = bounds.or(filesystem.as_ref().and_then(|fs| fs.add.add_bounds));
let lfs = lfs.then_some(true);
let lfs = GitLfsSetting::new(lfs.then_some(true), environment.lfs);
Self {
lock_check: if locked {

View File

@ -631,16 +631,6 @@ fn add_git_error() -> Result<()> {
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(())
}
@ -14831,3 +14821,63 @@ fn add_no_install_project() -> Result<()> {
Ok(())
}
#[test]
#[cfg(feature = "git-lfs")]
fn add_git_lfs_error() -> 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 = []
"#})?;
// Request lfs (via arg) without a Git source.
uv_snapshot!(context.filters(), context.add().arg("typing-extensions").arg("--lfs"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: `typing-extensions` did not resolve to a Git repository, but a Git extension (`--lfs`) was provided.
"###);
// Request lfs (via env var) without a Git source.
uv_snapshot!(context.filters(), context.add().arg("typing-extensions").env(EnvVars::UV_GIT_LFS, "true"), @r"
success: true
exit_code: 0
----- stdout -----
----- stderr -----
Resolved 2 packages in [TIME]
Prepared 1 package in [TIME]
Installed 1 package in [TIME]
+ typing-extensions==4.10.0
");
// Request lfs (both arg and env var) without a Git source.
uv_snapshot!(context.filters(), context.add().arg("typing-extensions").env(EnvVars::UV_GIT_LFS, "true").arg("--lfs"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: `typing-extensions` did not resolve to a Git repository, but a Git extension (`--lfs`) was provided.
"###);
// Request lfs from arg and disable lfs from env var (should be ignored) without a Git source.
uv_snapshot!(context.filters(), context.add().arg("typing-extensions").env(EnvVars::UV_GIT_LFS, "false").arg("--lfs"), @r###"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: `typing-extensions` did not resolve to a Git repository, but a Git extension (`--lfs`) was provided.
"###);
Ok(())
}

View File

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