diff --git a/crates/uv-workspace/src/pyproject.rs b/crates/uv-workspace/src/pyproject.rs index d4300493cd976..85c2f5e71c132 100644 --- a/crates/uv-workspace/src/pyproject.rs +++ b/crates/uv-workspace/src/pyproject.rs @@ -1378,9 +1378,38 @@ impl Source { tag: Option, branch: Option, root: &Path, + existing_sources: Option<&BTreeMap>, ) -> Result, SourceError> { - // If we resolved to a non-Git source, and the user specified a Git reference, error. - if !matches!(source, RequirementSource::Git { .. }) { + // 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()) + { + if let Some(sources) = existing_sources { + if let Some(package_sources) = sources.get(name) { + for existing_source in package_sources.iter() { + if let Source::Git { + git, + subdirectory, + marker, + extra, + group, + .. + } = existing_source + { + return Ok(Some(Source::Git { + git: git.clone(), + subdirectory: subdirectory.clone(), + rev, + tag, + branch, + marker: *marker, + extra: extra.clone(), + group: group.clone(), + })); + } + } + } + } if let Some(rev) = rev { return Err(SourceError::UnusedRev(name.to_string(), rev)); } diff --git a/crates/uv/src/commands/project/add.rs b/crates/uv/src/commands/project/add.rs index 8b96364e53975..41601b1ede868 100644 --- a/crates/uv/src/commands/project/add.rs +++ b/crates/uv/src/commands/project/add.rs @@ -40,7 +40,7 @@ use uv_scripts::{Pep723ItemRef, Pep723Metadata, Pep723Script}; use uv_settings::PythonInstallMirrors; use uv_types::{BuildIsolation, HashStrategy}; use uv_warnings::warn_user_once; -use uv_workspace::pyproject::{DependencyType, Source, SourceError}; +use uv_workspace::pyproject::{DependencyType, Source, SourceError, Sources, ToolUvSources}; use uv_workspace::pyproject_mut::{ArrayEdit, DependencyTarget, PyProjectTomlMut}; use uv_workspace::{DiscoveryOptions, VirtualProject, Workspace, WorkspaceCache}; @@ -454,6 +454,8 @@ pub(crate) async fn add( AddTarget::Script(ref script, _) => { let script_path = std::path::absolute(&script.path)?; let script_dir = script_path.parent().expect("script path has no parent"); + + let existing_sources = Some(script.sources()); resolve_requirement( requirement, false, @@ -463,9 +465,17 @@ pub(crate) async fn add( tag.clone(), branch.clone(), script_dir, + existing_sources, )? } AddTarget::Project(ref project, _) => { + let existing_sources = project + .pyproject_toml() + .tool + .as_ref() + .and_then(|tool| tool.uv.as_ref()) + .and_then(|uv| uv.sources.as_ref()) + .map(ToolUvSources::inner); let workspace = project .workspace() .packages() @@ -479,6 +489,7 @@ pub(crate) async fn add( tag.clone(), branch.clone(), project.root(), + existing_sources, )? } }; @@ -1010,6 +1021,7 @@ fn resolve_requirement( tag: Option, branch: Option, root: &Path, + existing_sources: Option<&BTreeMap>, ) -> Result<(uv_pep508::Requirement, Option), anyhow::Error> { let result = Source::from_requirement( &requirement.name, @@ -1021,6 +1033,7 @@ fn resolve_requirement( tag, branch, root, + existing_sources, ); let source = match result { diff --git a/crates/uv/tests/it/edit.rs b/crates/uv/tests/it/edit.rs index 1f360e9f9c635..b41c2cdac817c 100644 --- a/crates/uv/tests/it/edit.rs +++ b/crates/uv/tests/it/edit.rs @@ -3064,6 +3064,166 @@ fn add_non_normalized_source() -> Result<()> { Ok(()) } +/// Test updating an existing Git reference with branch/tag/rev options without re- specifying the +/// URL. +#[test] +#[cfg(feature = "git")] +fn add_update_git_reference_project() -> Result<()> { + let context = TestContext::new("3.12"); + + 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.12" + dependencies = [] + "#})?; + + uv_snapshot!(context.filters(), context.add().arg("https://github.com/astral-test/uv-public-pypackage.git"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Prepared 1 package in [TIME] + Installed 1 package in [TIME] + + uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389) + "); + + uv_snapshot!(context.filters(), context.add().arg("uv-public-pypackage").arg("--tag=0.0.1"), @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] + - uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389) + + uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@0dacfd662c64cb4ceb16e6cf65a157a8b715b979) + "); + + uv_snapshot!(context.filters(), context.add().arg("uv-public-pypackage").arg("--branch=main"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Resolved 2 packages in [TIME] + Uninstalled 1 package in [TIME] + Installed 1 package in [TIME] + - uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@0dacfd662c64cb4ceb16e6cf65a157a8b715b979) + + uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389) + "); + + uv_snapshot!(context.filters(), context.add().arg("uv-public-pypackage").arg("--rev=2005223fcad0e2c06daf2e14b93b790604868e1e"), @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] + - uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@b270df1a2fb5d012294e9aaf05e7e0bab1e6a389) + + uv-public-pypackage==0.1.0 (from git+https://github.com/astral-test/uv-public-pypackage.git@2005223fcad0e2c06daf2e14b93b790604868e1e) + "); + + Ok(()) +} + +/// Test updating an existing Git reference with branch/tag/rev options without re-specifying the +/// URL in a script. +#[test] +#[cfg(feature = "git")] +fn add_update_git_reference_script() -> Result<()> { + let context = TestContext::new("3.12"); + let script = context.temp_dir.child("script.py"); + script.write_str(indoc! { + r#" + # /// script + # requires-python = ">=3.11" + # dependencies = [ ] + # /// + + import time + time.sleep(5) + "# + })?; + + uv_snapshot!(context.filters(), context.add().arg("--script=script.py").arg("https://github.com/astral-test/uv-public-pypackage.git"), + @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Updated `script.py` + "### + ); + + let script_content = context.read("script.py"); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + script_content, @r###" + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "uv-public-pypackage", + # ] + # + # [tool.uv.sources] + # uv-public-pypackage = { git = "https://github.com/astral-test/uv-public-pypackage.git" } + # /// + + import time + time.sleep(5) + "### + ); + }); + + uv_snapshot!(context.filters(), context.add().arg("--script=script.py").arg("uv-public-pypackage").arg("--branch=test-branch"), + @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Updated `script.py` + "### + ); + + let script_content = context.read("script.py"); + insta::with_settings!({ + filters => context.filters(), + }, { + assert_snapshot!( + script_content, @r###" + # /// script + # requires-python = ">=3.11" + # dependencies = [ + # "uv-public-pypackage", + # ] + # + # [tool.uv.sources] + # uv-public-pypackage = { git = "https://github.com/astral-test/uv-public-pypackage.git", branch = "test-branch" } + # /// + + import time + time.sleep(5) + "### + ); + }); + + Ok(()) +} + /// If a source defined in `tool.uv.sources` but its name is not normalized, `uv remove` should /// remove the source. #[test]