diff --git a/.changeset/fix-git-url-detection.md b/.changeset/fix-git-url-detection.md new file mode 100644 index 0000000000..a62f61af4a --- /dev/null +++ b/.changeset/fix-git-url-detection.md @@ -0,0 +1,17 @@ +--- +"@pnpm/git-resolver": minor +"@pnpm/tarball-resolver": minor +"@pnpm/default-resolver": minor +"pnpm": patch +--- + +Support plain `http://` and `https://` URLs ending with `.git` as git repository dependencies. + +Previously, URLs like `https://gitea.example.org/user/repo.git#commit` were not recognized as git repositories because they lacked the `git+` prefix (e.g., `git+https://`). This caused issues when installing dependencies from self-hosted git servers like Gitea or Forgejo that don't provide tarball downloads. + +Changes: +- The git resolver now runs before the tarball resolver, ensuring git URLs are handled by the correct resolver +- The git resolver now recognizes plain `http://` and `https://` URLs ending in `.git` as git repositories +- Removed the `isRepository` check from the tarball resolver since it's no longer needed with the new resolver order + +Fixes #10468 diff --git a/cspell.json b/cspell.json index 4929c13c6c..80b8f297c7 100644 --- a/cspell.json +++ b/cspell.json @@ -81,12 +81,14 @@ "fnumber", "foobarqar", "foofoo", + "forgejo", "fsevents", "gabor", "gcttmf", "getattr", "ghsa", "ghsas", + "gitea", "globalconfig", "globstar", "gruntfile", diff --git a/resolving/default-resolver/src/index.ts b/resolving/default-resolver/src/index.ts index c8211d2118..691534050f 100644 --- a/resolving/default-resolver/src/index.ts +++ b/resolving/default-resolver/src/index.ts @@ -108,8 +108,8 @@ export function createResolver ( await resolveFromNpm(wantedDependency, opts as ResolveFromNpmOptions) ?? await resolveFromJsr(wantedDependency, opts as ResolveFromNpmOptions) ?? (wantedDependency.bareSpecifier && ( - await resolveFromTarball(fetchFromRegistry, wantedDependency as { bareSpecifier: string }) ?? await resolveFromGit(wantedDependency as { bareSpecifier: string }, opts) ?? + await resolveFromTarball(fetchFromRegistry, wantedDependency as { bareSpecifier: string }) ?? await _resolveFromLocal(wantedDependency as { bareSpecifier: string }, opts) )) ?? await _resolveNodeRuntime(wantedDependency, opts) ?? diff --git a/resolving/git-resolver/src/parseBareSpecifier.ts b/resolving/git-resolver/src/parseBareSpecifier.ts index 12adaa9029..0704a00ade 100644 --- a/resolving/git-resolver/src/parseBareSpecifier.ts +++ b/resolving/git-resolver/src/parseBareSpecifier.ts @@ -40,7 +40,11 @@ export function parseBareSpecifier (bareSpecifier: string, opts: AgentOptions): const colonsPos = bareSpecifier.indexOf(':') if (colonsPos === -1) return null const protocol = bareSpecifier.slice(0, colonsPos) - if (protocol && gitProtocols.has(protocol.toLocaleLowerCase())) { + + // Also detect http/https URLs ending in .git as git repositories + const isGitUrl = gitProtocols.has(protocol.toLocaleLowerCase()) || + ((protocol === 'http' || protocol === 'https') && /\.git(?:#|$)/.test(bareSpecifier)) + if (protocol && isGitUrl) { const correctBareSpecifier = correctUrl(bareSpecifier) const url = new URL(correctBareSpecifier) if (!url?.protocol) return null diff --git a/resolving/git-resolver/test/parsePref.test.ts b/resolving/git-resolver/test/parsePref.test.ts index ad38ed224f..12af531464 100644 --- a/resolving/git-resolver/test/parsePref.test.ts +++ b/resolving/git-resolver/test/parsePref.test.ts @@ -52,3 +52,24 @@ test.each([ const parsed = await parseBareSpecifier(input, {})?.() expect(parsed?.fetchSpec).toBe(output) }) + +// Test for https:// URLs ending in .git (issue #10468) +test.each([ + ['https://gitea.osmocom.org/ttcn3/highlightjs-ttcn3.git', 'https://gitea.osmocom.org/ttcn3/highlightjs-ttcn3.git'], + ['https://gitea.osmocom.org/ttcn3/highlightjs-ttcn3.git#6daccff309fca1e7561a43984d42fa4f829ce06d', 'https://gitea.osmocom.org/ttcn3/highlightjs-ttcn3.git'], + ['http://example.com/repo.git', 'http://example.com/repo.git'], + ['http://example.com/repo.git#main', 'http://example.com/repo.git'], +])('plain http/https URLs ending in .git should be recognized: %s', async (input, output) => { + const parsed = await parseBareSpecifier(input, {})?.() + expect(parsed?.fetchSpec).toBe(output) +}) + +// Ensure non-.git https URLs are not recognized as git repos +test.each([ + ['https://example.com/package.tar.gz'], + ['https://example.com/package.tgz'], + ['https://example.com/file'], +])('plain http/https URLs not ending in .git should not be recognized: %s', async (input) => { + const parsed = parseBareSpecifier(input, {}) + expect(parsed).toBeNull() +}) diff --git a/resolving/tarball-resolver/src/index.ts b/resolving/tarball-resolver/src/index.ts index 2e0922d792..ce226cb8da 100644 --- a/resolving/tarball-resolver/src/index.ts +++ b/resolving/tarball-resolver/src/index.ts @@ -15,8 +15,6 @@ export async function resolveFromTarball ( return null } - if (isRepository(wantedDependency.bareSpecifier)) return null - // The URL is normalized to remove the port if it is the default port of the protocol. const normalizedBareSpecifier = new URL(wantedDependency.bareSpecifier).toString() let resolvedUrl: string @@ -38,22 +36,3 @@ export async function resolveFromTarball ( resolvedVia: 'url', } } - -const GIT_HOSTERS = new Set([ - 'github.com', - 'gitlab.com', - 'bitbucket.org', -]) - -function isRepository (bareSpecifier: string): boolean { - const url = new URL(bareSpecifier) - if (url.hash && url.hash.includes('/')) { - url.hash = encodeURIComponent(url.hash.substring(1)) - bareSpecifier = url.href - } - if (bareSpecifier.endsWith('/')) { - bareSpecifier = bareSpecifier.slice(0, -1) - } - const parts = bareSpecifier.split('/') - return (parts.length === 5 && GIT_HOSTERS.has(parts[2])) -} diff --git a/resolving/tarball-resolver/test/index.ts b/resolving/tarball-resolver/test/index.ts index f624036b7a..7d23cf891e 100644 --- a/resolving/tarball-resolver/test/index.ts +++ b/resolving/tarball-resolver/test/index.ts @@ -107,20 +107,3 @@ test('tarballs from GitHub (is-negative)', async () => { resolvedVia: 'url', }) }) - -test('ignore direct URLs to repositories', async () => { - expect(await resolveFromTarball({ bareSpecifier: 'https://github.com/foo/bar' })).toBeNull() - expect(await resolveFromTarball({ bareSpecifier: 'https://github.com/foo/bar/' })).toBeNull() - expect(await resolveFromTarball({ bareSpecifier: 'https://gitlab.com/foo/bar' })).toBeNull() - expect(await resolveFromTarball({ bareSpecifier: 'https://bitbucket.org/foo/bar' })).toBeNull() -}) - -test('ignore slash in hash', async () => { - // expect resolve from git. - let hash = 'path:/packages/simple-react-app' - expect(await resolveFromTarball({ bareSpecifier: `RexSkz/test-git-subdir-fetch#${hash}` })).toBeNull() - expect(await resolveFromTarball({ bareSpecifier: `RexSkz/test-git-subdir-fetch#${encodeURIComponent(hash)}` })).toBeNull() - hash = 'heads/canary' - expect(await resolveFromTarball({ bareSpecifier: `zkochan/is-negative#${hash}` })).toBeNull() - expect(await resolveFromTarball({ bareSpecifier: `zkochan/is-negative#${encodeURIComponent(hash)}` })).toBeNull() -})