diff --git a/crates/uv-distribution/src/metadata/lowering.rs b/crates/uv-distribution/src/metadata/lowering.rs index 3c4e30180cb0..4fb0103c13fd 100644 --- a/crates/uv-distribution/src/metadata/lowering.rs +++ b/crates/uv-distribution/src/metadata/lowering.rs @@ -12,7 +12,7 @@ use uv_distribution_types::{Index, IndexLocations, IndexName, Origin}; use uv_git::GitReference; use uv_normalize::{ExtraName, GroupName, PackageName}; use uv_pep440::VersionSpecifiers; -use uv_pep508::{MarkerTree, VerbatimUrl, VersionOrUrl}; +use uv_pep508::{looks_like_git_repository, MarkerTree, VerbatimUrl, VersionOrUrl}; use uv_pypi_types::{ ConflictItem, ParsedUrlError, Requirement, RequirementSource, VerbatimParsedUrl, }; @@ -200,7 +200,8 @@ impl LoweredRequirement { marker, .. } => { - let source = url_source(url, subdirectory.map(PathBuf::from))?; + let source = + url_source(&requirement, url, subdirectory.map(PathBuf::from))?; (source, marker) } Source::Path { @@ -436,7 +437,8 @@ impl LoweredRequirement { marker, .. } => { - let source = url_source(url, subdirectory.map(PathBuf::from))?; + let source = + url_source(&requirement, url, subdirectory.map(PathBuf::from))?; (source, marker) } Source::Path { @@ -531,6 +533,8 @@ pub enum LoweringError { InvalidVerbatimUrl(#[from] uv_pep508::VerbatimUrlError), #[error("Fragments are not allowed in URLs: `{0}`")] ForbiddenFragment(Url), + #[error("`{0}` is associated with a URL source, but references a Git repository. Consider using a Git source instead (e.g., `{0} = {{ git = \"{1}\" }}`)")] + MissingGitSource(PackageName, Url), #[error("`workspace = false` is not yet supported")] WorkspaceFalse, #[error("Editable must refer to a local directory, not a file: `{0}`")] @@ -605,7 +609,11 @@ fn git_source( } /// Convert a URL source into a [`RequirementSource`]. -fn url_source(url: Url, subdirectory: Option) -> Result { +fn url_source( + requirement: &uv_pep508::Requirement, + url: Url, + subdirectory: Option, +) -> Result { let mut verbatim_url = url.clone(); if verbatim_url.fragment().is_some() { return Err(LoweringError::ForbiddenFragment(url)); @@ -617,8 +625,18 @@ fn url_source(url: Url, subdirectory: Option) -> Result ext, + Err(..) if looks_like_git_repository(&url) => { + return Err(LoweringError::MissingGitSource( + requirement.name.clone(), + url.clone(), + )) + } + Err(err) => { + return Err(ParsedUrlError::MissingExtensionUrl(url.to_string(), err).into()); + } + }; let verbatim_url = VerbatimUrl::from_url(verbatim_url); Ok(RequirementSource::Url { diff --git a/crates/uv-pep508/src/lib.rs b/crates/uv-pep508/src/lib.rs index 98e4a4e5c574..8981410b3ee6 100644 --- a/crates/uv-pep508/src/lib.rs +++ b/crates/uv-pep508/src/lib.rs @@ -42,7 +42,8 @@ pub use uv_normalize::{ExtraName, InvalidNameError, PackageName}; pub use uv_pep440; use uv_pep440::{VersionSpecifier, VersionSpecifiers}; pub use verbatim_url::{ - expand_env_vars, split_scheme, strip_host, Scheme, VerbatimUrl, VerbatimUrlError, + expand_env_vars, looks_like_git_repository, split_scheme, strip_host, Scheme, VerbatimUrl, + VerbatimUrlError, }; mod cursor; diff --git a/crates/uv-pep508/src/verbatim_url.rs b/crates/uv-pep508/src/verbatim_url.rs index 3c98b33128c1..73b06158a916 100644 --- a/crates/uv-pep508/src/verbatim_url.rs +++ b/crates/uv-pep508/src/verbatim_url.rs @@ -396,6 +396,19 @@ pub fn strip_host(path: &str) -> &str { path } +/// Returns `true` if a URL looks like a reference to a Git repository (e.g., `https://github.com/user/repo.git`). +pub fn looks_like_git_repository(url: &Url) -> bool { + matches!( + url.host_str(), + Some("github.com" | "gitlab.com" | "bitbucket.org") + ) && Path::new(url.path()) + .extension() + .map_or(true, |ext| ext.eq_ignore_ascii_case("git")) + && url + .path_segments() + .map_or(false, |segments| segments.count() == 2) +} + /// Split the fragment from a URL. /// /// For example, given `file:///home/ferris/project/scripts#hash=somehash`, returns @@ -582,4 +595,52 @@ mod tests { (Cow::Borrowed(Path::new("")), None) ); } + + #[test] + fn git_repository() { + let url = Url::parse("https://github.com/user/repo.git").unwrap(); + assert!(looks_like_git_repository(&url)); + + let url = Url::parse("https://gitlab.com/user/repo.git").unwrap(); + assert!(looks_like_git_repository(&url)); + + let url = Url::parse("https://bitbucket.org/user/repo.git").unwrap(); + assert!(looks_like_git_repository(&url)); + + let url = Url::parse("https://github.com/user/repo").unwrap(); + assert!(looks_like_git_repository(&url)); + + let url = Url::parse("https://example.com/user/repo.git").unwrap(); + assert!(!looks_like_git_repository(&url)); + + let url = Url::parse("https://github.com/user").unwrap(); + assert!(!looks_like_git_repository(&url)); + + let url = Url::parse("https://github.com/user/repo.zip").unwrap(); + assert!(!looks_like_git_repository(&url)); + + let url = Url::parse("https://github.com/").unwrap(); + assert!(!looks_like_git_repository(&url)); + + let url = Url::parse("").unwrap_err(); + assert_eq!(url.to_string(), "relative URL without a base"); + + let url = Url::parse("github.com/user/repo.git").unwrap_err(); + assert_eq!(url.to_string(), "relative URL without a base"); + + let url = Url::parse("https://github.com/user/repo/extra.git").unwrap(); + assert!(!looks_like_git_repository(&url)); + + let url = Url::parse("https://github.com/user/repo.GIT").unwrap(); + assert!(looks_like_git_repository(&url)); + + let url = Url::parse("https://github.com/user/repo.git?foo=bar").unwrap(); + assert!(looks_like_git_repository(&url)); + + let url = Url::parse("https://github.com/user/repo.git#readme").unwrap(); + assert!(looks_like_git_repository(&url)); + + let url = Url::parse("https://github.com/pypa/pip/archive/1.3.1.zip#sha1=da9234ee9982d4bbb3c72346a6de940a148ea686").unwrap(); + assert!(!looks_like_git_repository(&url)); + } } diff --git a/crates/uv-pypi-types/src/parsed_url.rs b/crates/uv-pypi-types/src/parsed_url.rs index ecc1031b5655..4f4088de288a 100644 --- a/crates/uv-pypi-types/src/parsed_url.rs +++ b/crates/uv-pypi-types/src/parsed_url.rs @@ -1,10 +1,14 @@ use std::fmt::{Display, Formatter}; use std::path::{Path, PathBuf}; + use thiserror::Error; use url::{ParseError, Url}; + use uv_distribution_filename::{DistExtension, ExtensionError}; use uv_git::{GitReference, GitSha, GitUrl, OidParseError}; -use uv_pep508::{Pep508Url, UnnamedRequirementUrl, VerbatimUrl, VerbatimUrlError}; +use uv_pep508::{ + looks_like_git_repository, Pep508Url, UnnamedRequirementUrl, VerbatimUrl, VerbatimUrlError, +}; use crate::{ArchiveInfo, DirInfo, DirectUrl, VcsInfo, VcsKind}; @@ -24,6 +28,8 @@ pub enum ParsedUrlError { UrlParse(String, #[source] ParseError), #[error(transparent)] VerbatimUrl(#[from] VerbatimUrlError), + #[error("Direct URL (`{0}`) references a Git repository, but is missing the `git+` prefix (e.g., `git+{0}`)")] + MissingGitPrefix(String), #[error("Expected direct URL (`{0}`) to end in a supported file extension: {1}")] MissingExtensionUrl(String, ExtensionError), #[error("Expected path (`{0}`) to end in a supported file extension: {1}")] @@ -303,8 +309,13 @@ impl TryFrom for ParsedArchiveUrl { fn try_from(url: Url) -> Result { let subdirectory = get_subdirectory(&url); - let ext = DistExtension::from_path(url.path()) - .map_err(|err| ParsedUrlError::MissingExtensionUrl(url.to_string(), err))?; + let ext = match DistExtension::from_path(url.path()) { + Ok(ext) => ext, + Err(..) if looks_like_git_repository(&url) => { + return Err(ParsedUrlError::MissingGitPrefix(url.to_string())) + } + Err(err) => return Err(ParsedUrlError::MissingExtensionUrl(url.to_string(), err)), + }; Ok(Self { url, subdirectory, diff --git a/crates/uv/tests/it/lock.rs b/crates/uv/tests/it/lock.rs index dd5f9d291157..c6a4f4c26500 100644 --- a/crates/uv/tests/it/lock.rs +++ b/crates/uv/tests/it/lock.rs @@ -20087,3 +20087,39 @@ fn lock_self_marker_incompatible() -> Result<()> { Ok(()) } + +#[test] +fn lock_missing_git_prefix() -> Result<()> { + let context = TestContext::new("3.12"); + + let pyproject_toml = context.temp_dir.child("pyproject.toml"); + pyproject_toml.write_str( + r#" + [project] + name = "project" + version = "0.1.0" + requires-python = ">=3.12" + dependencies = ["workspace-in-root-test"] + + [tool.uv.sources] + workspace-in-root-test = { url = "https://github.com/astral-sh/workspace-in-root-test" } + + [build-system] + requires = ["setuptools>=42"] + build-backend = "setuptools.build_meta" + "#, + )?; + + uv_snapshot!(context.filters(), context.lock(), @r###" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to build `project @ file://[TEMP_DIR]/` + ├─▶ Failed to parse entry: `workspace-in-root-test` + ╰─▶ `workspace-in-root-test` is associated with a URL source, but references a Git repository. Consider using a Git source instead (e.g., `workspace-in-root-test = { git = "https://github.com/astral-sh/workspace-in-root-test" }`) + "###); + + Ok(()) +} diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index 952bc46cce50..f7f090ec7a9c 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -7590,6 +7590,29 @@ fn build_tag() { "###); } +#[test] +fn missing_git_prefix() -> Result<()> { + let context = TestContext::new("3.12"); + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.touch()?; + + uv_snapshot!(context.pip_install() + .arg("workspace-in-root-test @ https://github.com/astral-sh/workspace-in-root-test"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to parse: `workspace-in-root-test @ https://github.com/astral-sh/workspace-in-root-test` + Caused by: Direct URL (`https://github.com/astral-sh/workspace-in-root-test`) references a Git repository, but is missing the `git+` prefix (e.g., `git+https://github.com/astral-sh/workspace-in-root-test`) + workspace-in-root-test @ https://github.com/astral-sh/workspace-in-root-test + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + "### + ); + + Ok(()) +} + #[test] fn missing_subdirectory_git() -> Result<()> { let context = TestContext::new("3.12"); @@ -7597,13 +7620,13 @@ fn missing_subdirectory_git() -> Result<()> { requirements_txt.touch()?; uv_snapshot!(context.pip_install() - .arg("source-distribution @ git+https://github.com/astral-sh/workspace-in-root-test#subdirectory=missing"), @r###" + .arg("workspace-in-root-test @ git+https://github.com/astral-sh/workspace-in-root-test#subdirectory=missing"), @r###" success: false exit_code: 1 ----- stdout ----- ----- stderr ----- - × Failed to download and build `source-distribution @ git+https://github.com/astral-sh/workspace-in-root-test#subdirectory=missing` + × Failed to download and build `workspace-in-root-test @ git+https://github.com/astral-sh/workspace-in-root-test#subdirectory=missing` ╰─▶ The source distribution `git+https://github.com/astral-sh/workspace-in-root-test#subdirectory=missing` has no subdirectory `missing` "### );