feat: improve git URL detection to recognize plain HTTP/HTTPS URLs

Improve git URL detection to recognize plain HTTP/HTTPS URLs
ending in `.git` and prioritize git resolver over tarball resolver.

close #10468
This commit is contained in:
Zoltan Kochan
2026-01-16 19:37:16 +01:00
parent 29a3151b60
commit ec7c5d7d1a
7 changed files with 46 additions and 40 deletions

View File

@@ -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

View File

@@ -81,12 +81,14 @@
"fnumber",
"foobarqar",
"foofoo",
"forgejo",
"fsevents",
"gabor",
"gcttmf",
"getattr",
"ghsa",
"ghsas",
"gitea",
"globalconfig",
"globstar",
"gruntfile",

View File

@@ -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) ??

View File

@@ -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

View File

@@ -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()
})

View File

@@ -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]))
}

View File

@@ -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()
})