Skip to content

Commit b34deec

Browse files
committed
feat: support --lfs in uv add
1 parent ebfd6de commit b34deec

File tree

7 files changed

+144
-5
lines changed

7 files changed

+144
-5
lines changed

crates/uv-cli/src/lib.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3882,6 +3882,10 @@ pub struct AddArgs {
38823882
#[arg(long, group = "git-ref", action = clap::ArgAction::Set)]
38833883
pub branch: Option<String>,
38843884

3885+
/// Whether to use Git LFS when adding a dependency from Git.
3886+
#[arg(long, env = EnvVars::UV_GIT_LFS, value_parser = clap::builder::BoolishValueParser::new())]
3887+
pub lfs: bool,
3888+
38853889
/// Extras to enable for the dependency.
38863890
///
38873891
/// May be provided more than once.

crates/uv-workspace/src/pyproject.rs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1549,6 +1549,10 @@ pub enum SourceError {
15491549
"`{0}` did not resolve to a Git repository, but a Git reference (`--branch {1}`) was provided."
15501550
)]
15511551
UnusedBranch(String, String),
1552+
#[error(
1553+
"`{0}` did not resolve to a Git repository, but a Git extension (`--lfs`) was provided."
1554+
)]
1555+
UnusedLfs(String),
15521556
#[error(
15531557
"`{0}` did not resolve to a local directory, but the `--editable` flag was provided. Editable installs are only supported for local directories."
15541558
)]
@@ -1578,12 +1582,13 @@ impl Source {
15781582
rev: Option<String>,
15791583
tag: Option<String>,
15801584
branch: Option<String>,
1585+
lfs: Option<bool>,
15811586
root: &Path,
15821587
existing_sources: Option<&BTreeMap<PackageName, Sources>>,
15831588
) -> Result<Option<Self>, SourceError> {
15841589
// If the user specified a Git reference for a non-Git source, try existing Git sources before erroring.
15851590
if !matches!(source, RequirementSource::Git { .. })
1586-
&& (branch.is_some() || tag.is_some() || rev.is_some())
1591+
&& (branch.is_some() || tag.is_some() || rev.is_some() || lfs.is_some())
15871592
{
15881593
if let Some(sources) = existing_sources {
15891594
if let Some(package_sources) = sources.get(name) {
@@ -1603,7 +1608,7 @@ impl Source {
16031608
rev,
16041609
tag,
16051610
branch,
1606-
lfs: None,
1611+
lfs,
16071612
marker: *marker,
16081613
extra: extra.clone(),
16091614
group: group.clone(),
@@ -1621,6 +1626,9 @@ impl Source {
16211626
if let Some(branch) = branch {
16221627
return Err(SourceError::UnusedBranch(name.to_string(), branch));
16231628
}
1629+
if let Some(true) = lfs {
1630+
return Err(SourceError::UnusedLfs(name.to_string()));
1631+
}
16241632
}
16251633

16261634
// If we resolved a non-path source, and user specified an `--editable` flag, error.
@@ -1713,7 +1721,7 @@ impl Source {
17131721
rev: rev.cloned(),
17141722
tag,
17151723
branch,
1716-
lfs: None,
1724+
lfs,
17171725
git: git.repository().clone(),
17181726
subdirectory: subdirectory.map(PortablePathBuf::from),
17191727
marker: MarkerTree::TRUE,
@@ -1725,7 +1733,7 @@ impl Source {
17251733
rev,
17261734
tag,
17271735
branch,
1728-
lfs: None,
1736+
lfs,
17291737
git: git.repository().clone(),
17301738
subdirectory: subdirectory.map(PortablePathBuf::from),
17311739
marker: MarkerTree::TRUE,

crates/uv/src/commands/project/add.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ pub(crate) async fn add(
8383
rev: Option<String>,
8484
tag: Option<String>,
8585
branch: Option<String>,
86+
lfs: Option<bool>,
8687
extras_of_dependency: Vec<ExtraName>,
8788
package: Option<PackageName>,
8889
python: Option<String>,
@@ -373,6 +374,7 @@ pub(crate) async fn add(
373374
rev.as_deref(),
374375
tag.as_deref(),
375376
branch.as_deref(),
377+
lfs,
376378
marker,
377379
)
378380
})
@@ -640,6 +642,7 @@ pub(crate) async fn add(
640642
rev.as_deref(),
641643
tag.as_deref(),
642644
branch.as_deref(),
645+
lfs,
643646
&extras_of_dependency,
644647
index,
645648
&mut toml,
@@ -771,6 +774,7 @@ fn edits(
771774
rev: Option<&str>,
772775
tag: Option<&str>,
773776
branch: Option<&str>,
777+
lfs: Option<bool>,
774778
extras: &[ExtraName],
775779
index: Option<&IndexName>,
776780
toml: &mut PyProjectTomlMut,
@@ -801,6 +805,7 @@ fn edits(
801805
rev.map(ToString::to_string),
802806
tag.map(ToString::to_string),
803807
branch.map(ToString::to_string),
808+
lfs,
804809
script_dir,
805810
existing_sources,
806811
)?
@@ -825,6 +830,7 @@ fn edits(
825830
rev.map(ToString::to_string),
826831
tag.map(ToString::to_string),
827832
branch.map(ToString::to_string),
833+
lfs,
828834
project.root(),
829835
existing_sources,
830836
)?
@@ -1183,6 +1189,7 @@ fn augment_requirement(
11831189
rev: Option<&str>,
11841190
tag: Option<&str>,
11851191
branch: Option<&str>,
1192+
lfs: Option<bool>,
11861193
marker: Option<MarkerTree>,
11871194
) -> UnresolvedRequirement {
11881195
match requirement {
@@ -1209,6 +1216,11 @@ fn augment_requirement(
12091216
} else {
12101217
git
12111218
};
1219+
let git = if let Some(lfs) = lfs {
1220+
git.with_lfs(lfs.into())
1221+
} else {
1222+
git
1223+
};
12121224
RequirementSource::Git {
12131225
git,
12141226
subdirectory,
@@ -1262,6 +1274,7 @@ fn resolve_requirement(
12621274
rev: Option<String>,
12631275
tag: Option<String>,
12641276
branch: Option<String>,
1277+
lfs: Option<bool>,
12651278
root: &Path,
12661279
existing_sources: Option<&BTreeMap<PackageName, Sources>>,
12671280
) -> Result<(uv_pep508::Requirement, Option<Source>), anyhow::Error> {
@@ -1274,6 +1287,7 @@ fn resolve_requirement(
12741287
rev,
12751288
tag,
12761289
branch,
1290+
lfs,
12771291
root,
12781292
existing_sources,
12791293
);

crates/uv/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2074,6 +2074,7 @@ async fn run_project(
20742074
args.rev,
20752075
args.tag,
20762076
args.branch,
2077+
args.lfs,
20772078
args.extras,
20782079
args.package,
20792080
args.python,

crates/uv/src/settings.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1469,6 +1469,7 @@ pub(crate) struct AddSettings {
14691469
pub(crate) rev: Option<String>,
14701470
pub(crate) tag: Option<String>,
14711471
pub(crate) branch: Option<String>,
1472+
pub(crate) lfs: Option<bool>,
14721473
pub(crate) package: Option<PackageName>,
14731474
pub(crate) script: Option<PathBuf>,
14741475
pub(crate) python: Option<String>,
@@ -1506,6 +1507,7 @@ impl AddSettings {
15061507
rev,
15071508
tag,
15081509
branch,
1510+
lfs,
15091511
no_sync,
15101512
locked,
15111513
frozen,
@@ -1600,6 +1602,7 @@ impl AddSettings {
16001602
.unwrap_or_default();
16011603

16021604
let bounds = bounds.or(filesystem.as_ref().and_then(|fs| fs.add.add_bounds));
1605+
let lfs = lfs.then_some(true);
16031606

16041607
Self {
16051608
locked,
@@ -1619,6 +1622,7 @@ impl AddSettings {
16191622
rev,
16201623
tag,
16211624
branch,
1625+
lfs,
16221626
package,
16231627
script,
16241628
python: python.and_then(Maybe::into_option),

crates/uv/tests/it/edit.rs

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -630,6 +630,16 @@ fn add_git_error() -> Result<()> {
630630
error: `flask` did not resolve to a Git repository, but a Git reference (`--branch 0.0.1`) was provided.
631631
"###);
632632

633+
// Request lfs without a Git source.
634+
uv_snapshot!(context.filters(), context.add().arg("flask").arg("--lfs"), @r###"
635+
success: false
636+
exit_code: 2
637+
----- stdout -----
638+
639+
----- stderr -----
640+
error: `flask` did not resolve to a Git repository, but a Git extension (`--lfs`) was provided.
641+
"###);
642+
633643
Ok(())
634644
}
635645

@@ -662,6 +672,103 @@ fn add_git_branch() -> Result<()> {
662672
Ok(())
663673
}
664674

675+
#[test]
676+
#[cfg(feature = "git")]
677+
fn add_git_lfs() -> Result<()> {
678+
let context = TestContext::new("3.13");
679+
680+
let pyproject_toml = context.temp_dir.child("pyproject.toml");
681+
pyproject_toml.write_str(indoc! {r#"
682+
[project]
683+
name = "project"
684+
version = "0.1.0"
685+
requires-python = ">=3.13"
686+
dependencies = []
687+
"#})?;
688+
689+
uv_snapshot!(context.filters(), context.add().arg("test-lfs-repo @ git+https://github.com/samypr100/test-lfs-repo").arg("--rev").arg("657500f0703dc173ac5d68dfa1d7e8c985c84424").arg("--lfs"), @r"
690+
success: true
691+
exit_code: 0
692+
----- stdout -----
693+
694+
----- stderr -----
695+
Resolved 2 packages in [TIME]
696+
Prepared 1 package in [TIME]
697+
Installed 1 package in [TIME]
698+
+ test-lfs-repo==0.1.0 (from git+https://github.com/samypr100/test-lfs-repo@657500f0703dc173ac5d68dfa1d7e8c985c84424#lfs=true)
699+
");
700+
701+
let pyproject_toml = context.read("pyproject.toml");
702+
703+
insta::with_settings!({
704+
filters => context.filters(),
705+
}, {
706+
assert_snapshot!(
707+
pyproject_toml, @r#"
708+
[project]
709+
name = "project"
710+
version = "0.1.0"
711+
requires-python = ">=3.13"
712+
dependencies = [
713+
"test-lfs-repo",
714+
]
715+
716+
[tool.uv.sources]
717+
test-lfs-repo = { git = "https://github.com/samypr100/test-lfs-repo", rev = "657500f0703dc173ac5d68dfa1d7e8c985c84424", lfs = true }
718+
"#
719+
);
720+
});
721+
722+
let lock = context.read("uv.lock");
723+
724+
insta::with_settings!({
725+
filters => context.filters(),
726+
}, {
727+
assert_snapshot!(
728+
lock, @r#"
729+
version = 1
730+
revision = 3
731+
requires-python = ">=3.13"
732+
733+
[options]
734+
exclude-newer = "2024-03-25T00:00:00Z"
735+
736+
[[package]]
737+
name = "project"
738+
version = "0.1.0"
739+
source = { virtual = "." }
740+
dependencies = [
741+
{ name = "test-lfs-repo" },
742+
]
743+
744+
[package.metadata]
745+
requires-dist = [{ name = "test-lfs-repo", git = "https://github.com/samypr100/test-lfs-repo?lfs=true&rev=657500f0703dc173ac5d68dfa1d7e8c985c84424" }]
746+
747+
[[package]]
748+
name = "test-lfs-repo"
749+
version = "0.1.0"
750+
source = { git = "https://github.com/samypr100/test-lfs-repo?lfs=true&rev=657500f0703dc173ac5d68dfa1d7e8c985c84424#657500f0703dc173ac5d68dfa1d7e8c985c84424" }
751+
"#
752+
);
753+
});
754+
755+
uv_snapshot!(context.filters(), context.add().arg("test-lfs-repo @ git+https://github.com/samypr100/test-lfs-repo").arg("--rev").arg("4e82e85f6a8b8825d614ea23c550af55b2b7738c").arg("--lfs"), @r"
756+
success: true
757+
exit_code: 0
758+
----- stdout -----
759+
760+
----- stderr -----
761+
Resolved 2 packages in [TIME]
762+
Prepared 1 package in [TIME]
763+
Uninstalled 1 package in [TIME]
764+
Installed 1 package in [TIME]
765+
- test-lfs-repo==0.1.0 (from git+https://github.com/samypr100/test-lfs-repo@657500f0703dc173ac5d68dfa1d7e8c985c84424#lfs=true)
766+
+ test-lfs-repo==0.1.0 (from git+https://github.com/samypr100/test-lfs-repo@4e82e85f6a8b8825d614ea23c550af55b2b7738c#lfs=true)
767+
");
768+
769+
Ok(())
770+
}
771+
665772
/// Add a Git requirement using the `--raw-sources` API.
666773
#[test]
667774
#[cfg(feature = "git")]

docs/reference/cli.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -865,7 +865,8 @@ uv add [OPTIONS] <PACKAGES|--requirements <REQUIREMENTS>>
865865
<ul>
866866
<li><code>disabled</code>: Do not use keyring for credential lookup</li>
867867
<li><code>subprocess</code>: Use the <code>keyring</code> command for credential lookup</li>
868-
</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>
868+
</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>
869+
</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>
869870
<p>Defaults to <code>clone</code> (also known as Copy-on-Write) on macOS, and <code>hardlink</code> on Linux and Windows.</p>
870871
<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>
871872
<p>May also be set with the <code>UV_LINK_MODE</code> environment variable.</p><p>Possible values:</p>

0 commit comments

Comments
 (0)