diff --git a/.changeset/tender-dolphins-explode.md b/.changeset/tender-dolphins-explode.md new file mode 100644 index 0000000000..7cd944b992 --- /dev/null +++ b/.changeset/tender-dolphins-explode.md @@ -0,0 +1,17 @@ +--- +"@pnpm/resolver-base": minor +"@pnpm/git-resolver": minor +"@pnpm/prepare-package": minor +"@pnpm/git-fetcher": minor +"@pnpm/tarball-fetcher": minor +"pnpm": minor +--- + +It is now possible to install only a subdirectory from a Git repository. + +For example, `pnpm add github:user/repo#path:packages/foo` will add a dependency from the `packages/foo` subdirectory. + +This new parameter may be combined with other supported parameters separated by `&`. For instance, the next command will install the same package from the `dev` branch: `pnpm add github:user/repo#dev&path:packages/bar`. + +Related issue: [#4765](https://github.com/pnpm/pnpm/issues/4765). +Related PR: [#7487](https://github.com/pnpm/pnpm/pull/7487). diff --git a/exec/prepare-package/package.json b/exec/prepare-package/package.json index c05d0f0c89..50373d3458 100644 --- a/exec/prepare-package/package.json +++ b/exec/prepare-package/package.json @@ -29,6 +29,7 @@ }, "homepage": "https://github.com/pnpm/pnpm/blob/main/exec/prepare-package#readme", "dependencies": { + "@pnpm/error": "workspace:*", "@pnpm/lifecycle": "workspace:*", "@pnpm/read-package-json": "workspace:*", "@pnpm/types": "workspace:*", diff --git a/exec/prepare-package/src/index.ts b/exec/prepare-package/src/index.ts index c4ef706610..739d3608b0 100644 --- a/exec/prepare-package/src/index.ts +++ b/exec/prepare-package/src/index.ts @@ -1,5 +1,6 @@ import fs from 'fs' import path from 'path' +import { PnpmError } from '@pnpm/error' import { runLifecycleHook, type RunLifecycleHookOptions } from '@pnpm/lifecycle' import { safeReadPackageJsonFromDir } from '@pnpm/read-package-json' import { type PackageManifest } from '@pnpm/types' @@ -22,11 +23,12 @@ export interface PreparePackageOptions { unsafePerm?: boolean } -export async function preparePackage (opts: PreparePackageOptions, pkgDir: string): Promise { +export async function preparePackage (opts: PreparePackageOptions, gitRootDir: string, subDir: string): Promise<{ shouldBeBuilt: boolean, pkgDir: string }> { + const pkgDir = safeJoinPath(gitRootDir, subDir) const manifest = await safeReadPackageJsonFromDir(pkgDir) - if (manifest?.scripts == null || !packageShouldBeBuilt(manifest, pkgDir)) return false - if (opts.ignoreScripts) return true - const pm = (await preferredPM(pkgDir))?.name ?? 'npm' + if (manifest?.scripts == null || !packageShouldBeBuilt(manifest, pkgDir)) return { shouldBeBuilt: false, pkgDir } + if (opts.ignoreScripts) return { shouldBeBuilt: true, pkgDir } + const pm = (await preferredPM(gitRootDir))?.name ?? 'npm' const execOpts: RunLifecycleHookOptions = { depPath: `${manifest.name}@${manifest.version}`, pkgRoot: pkgDir, @@ -50,7 +52,7 @@ export async function preparePackage (opts: PreparePackageOptions, pkgDir: strin throw err } await rimraf(path.join(pkgDir, 'node_modules')) - return true + return { shouldBeBuilt: true, pkgDir } } function packageShouldBeBuilt (manifest: PackageManifest, pkgDir: string): boolean { @@ -62,3 +64,16 @@ function packageShouldBeBuilt (manifest: PackageManifest, pkgDir: string): boole const mainFile = manifest.main ?? 'index.js' return !fs.existsSync(path.join(pkgDir, mainFile)) } + +function safeJoinPath (root: string, sub: string) { + const joined = path.join(root, sub) + // prevent the dir traversal attack + const relative = path.relative(root, joined) + if (relative.startsWith('..')) { + throw new PnpmError('INVALID_PATH', `Path "${sub}" should be a sub directory`) + } + if (!fs.existsSync(joined) || !fs.lstatSync(joined).isDirectory()) { + throw new PnpmError('INVALID_PATH', `Path "${sub}" is not a directory`) + } + return joined +} diff --git a/exec/prepare-package/test/__fixtures__/has-prepublish-script-in-workspace/package.json b/exec/prepare-package/test/__fixtures__/has-prepublish-script-in-workspace/package.json new file mode 100644 index 0000000000..d6a67ff736 --- /dev/null +++ b/exec/prepare-package/test/__fixtures__/has-prepublish-script-in-workspace/package.json @@ -0,0 +1,4 @@ +{ + "name": "has-prepublish-script-in-workspace", + "version": "1.0.0" +} diff --git a/exec/prepare-package/test/__fixtures__/has-prepublish-script-in-workspace/packages/foo/package.json b/exec/prepare-package/test/__fixtures__/has-prepublish-script-in-workspace/packages/foo/package.json new file mode 100644 index 0000000000..3e515b5e3b --- /dev/null +++ b/exec/prepare-package/test/__fixtures__/has-prepublish-script-in-workspace/packages/foo/package.json @@ -0,0 +1,7 @@ +{ + "name": "foo", + "version": "1.0.0", + "scripts": { + "prepublish": "node -e \"console.log('prepublish')\" | test-ipc-server-client ../../test.sock" + } +} diff --git a/exec/prepare-package/test/__fixtures__/has-prepublish-script-in-workspace/pnpm-lock.yaml b/exec/prepare-package/test/__fixtures__/has-prepublish-script-in-workspace/pnpm-lock.yaml new file mode 100644 index 0000000000..e69de29bb2 diff --git a/exec/prepare-package/test/__fixtures__/has-prepublish-script-in-workspace/pnpm-workspace.yaml b/exec/prepare-package/test/__fixtures__/has-prepublish-script-in-workspace/pnpm-workspace.yaml new file mode 100644 index 0000000000..18ec407efc --- /dev/null +++ b/exec/prepare-package/test/__fixtures__/has-prepublish-script-in-workspace/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/exec/prepare-package/test/index.ts b/exec/prepare-package/test/index.ts index c822c57a33..00cd09211b 100644 --- a/exec/prepare-package/test/index.ts +++ b/exec/prepare-package/test/index.ts @@ -10,7 +10,7 @@ test('prepare package runs the prepublish script', async () => { const tmp = tempDir() await using server = await createTestIpcServer(path.join(tmp, 'test.sock')) f.copy('has-prepublish-script', tmp) - await preparePackage({ rawConfig: {} }, tmp) + await preparePackage({ rawConfig: {} }, tmp, '') expect(server.getLines()).toStrictEqual([ 'prepublish', ]) @@ -20,7 +20,17 @@ test('prepare package does not run the prepublish script if the main file is pre const tmp = tempDir() await using server = await createTestIpcServer(path.join(tmp, 'test.sock')) f.copy('has-prepublish-script-and-main-file', tmp) - await preparePackage({ rawConfig: {} }, tmp) + await preparePackage({ rawConfig: {} }, tmp, '') + expect(server.getLines()).toStrictEqual([ + 'prepublish', + ]) +}) + +test('prepare package runs the prepublish script in the sub folder if pkgDir is present', async () => { + const tmp = tempDir() + await using server = await createTestIpcServer(path.join(tmp, 'test.sock')) + f.copy('has-prepublish-script-in-workspace', tmp) + await preparePackage({ rawConfig: {} }, tmp, 'packages/foo') expect(server.getLines()).toStrictEqual([ 'prepublish', ]) diff --git a/exec/prepare-package/tsconfig.json b/exec/prepare-package/tsconfig.json index 55b5cac4f5..d50520d96f 100644 --- a/exec/prepare-package/tsconfig.json +++ b/exec/prepare-package/tsconfig.json @@ -18,6 +18,9 @@ { "path": "../../__utils__/test-ipc-server" }, + { + "path": "../../packages/error" + }, { "path": "../../packages/types" }, diff --git a/fetching/git-fetcher/src/index.ts b/fetching/git-fetcher/src/index.ts index 74d24dfd5e..54d8ba3950 100644 --- a/fetching/git-fetcher/src/index.ts +++ b/fetching/git-fetcher/src/index.ts @@ -33,9 +33,11 @@ export function createGitFetcher (createOpts: CreateGitFetcherOptions) { await execGit(['clone', resolution.repo, tempLocation]) } await execGit(['checkout', resolution.commit], { cwd: tempLocation }) + let pkgDir: string try { - const shouldBeBuilt = await preparePkg(tempLocation) - if (ignoreScripts && shouldBeBuilt) { + const prepareResult = await preparePkg(tempLocation, resolution.path ?? '') + pkgDir = prepareResult.pkgDir + if (ignoreScripts && prepareResult.shouldBeBuilt) { globalWarn(`The git-hosted package fetched from "${resolution.repo}" has to be built but the build scripts were ignored.`) } } catch (err: any) { // eslint-disable-line @@ -49,7 +51,7 @@ export function createGitFetcher (createOpts: CreateGitFetcherOptions) { // the linking of files to the store is in progress. return addFilesFromDir({ cafsDir: cafs.cafsDir, - dir: tempLocation, + dir: pkgDir, filesIndexFile: opts.filesIndexFile, readManifest: opts.readManifest, pkg: opts.pkg, diff --git a/fetching/git-fetcher/test/index.ts b/fetching/git-fetcher/test/index.ts index 91ba54a40d..b40db32a53 100644 --- a/fetching/git-fetcher/test/index.ts +++ b/fetching/git-fetcher/test/index.ts @@ -47,6 +47,66 @@ test('fetch', async () => { expect(manifest?.name).toEqual('is-positive') }) +test('fetch a package from Git sub folder', async () => { + const cafsDir = tempy.directory() + const fetch = createGitFetcher({ rawConfig: {} }).git + const { filesIndex } = await fetch( + createCafsStore(cafsDir), + { + commit: '2b42a57a945f19f8ffab8ecbd2021fdc2c58ee22', + repo: 'https://github.com/RexSkz/test-git-subfolder-fetch.git', + path: '/packages/simple-react-app', + type: 'git', + }, + { + filesIndexFile: path.join(cafsDir, 'index.json'), + } + ) + expect(filesIndex[`public${path.sep}index.html`]).toBeTruthy() +}) + +test('prevent directory traversal attack when using Git sub folder', async () => { + const cafsDir = tempy.directory() + const fetch = createGitFetcher({ rawConfig: {} }).git + const repo = 'https://github.com/RexSkz/test-git-subfolder-fetch.git' + const pkgDir = '../../etc' + await expect( + fetch( + createCafsStore(cafsDir), + { + commit: '2b42a57a945f19f8ffab8ecbd2021fdc2c58ee22', + repo, + path: pkgDir, + type: 'git', + }, + { + filesIndexFile: path.join(cafsDir, 'index.json'), + } + ) + ).rejects.toThrow(`Failed to prepare git-hosted package fetched from "${repo}": Path "${pkgDir}" should be a sub directory`) +}) + +test('prevent directory traversal attack when using Git sub folder', async () => { + const cafsDir = tempy.directory() + const fetch = createGitFetcher({ rawConfig: {} }).git + const repo = 'https://github.com/RexSkz/test-git-subfolder-fetch.git' + const pkgDir = 'not/exists' + await expect( + fetch( + createCafsStore(cafsDir), + { + commit: '2b42a57a945f19f8ffab8ecbd2021fdc2c58ee22', + repo, + path: pkgDir, + type: 'git', + }, + { + filesIndexFile: path.join(cafsDir, 'index.json'), + } + ) + ).rejects.toThrow(`Failed to prepare git-hosted package fetched from "${repo}": Path "${pkgDir}" is not a directory`) +}) + test('fetch a package from Git that has a prepare script', async () => { const cafsDir = tempy.directory() const fetch = createGitFetcher({ rawConfig: {} }).git diff --git a/fetching/tarball-fetcher/src/gitHostedTarballFetcher.ts b/fetching/tarball-fetcher/src/gitHostedTarballFetcher.ts index a0b0226a4d..0b18eacb46 100644 --- a/fetching/tarball-fetcher/src/gitHostedTarballFetcher.ts +++ b/fetching/tarball-fetcher/src/gitHostedTarballFetcher.ts @@ -3,6 +3,7 @@ import { type FetchFunction, type FetchOptions } from '@pnpm/fetcher-base' import type { Cafs } from '@pnpm/cafs-types' import { globalWarn } from '@pnpm/logger' import { preparePackage } from '@pnpm/prepare-package' +import { type DependencyManifest } from '@pnpm/types' import { addFilesFromDir } from '@pnpm/worker' import renameOverwrite from 'rename-overwrite' import { fastPathTemp as pathTemp } from 'path-temp' @@ -11,6 +12,7 @@ interface Resolution { integrity?: string registry?: string tarball: string + path?: string } export interface CreateGitHostedTarballFetcher { @@ -21,19 +23,20 @@ export interface CreateGitHostedTarballFetcher { export function createGitHostedTarballFetcher (fetchRemoteTarball: FetchFunction, fetcherOpts: CreateGitHostedTarballFetcher): FetchFunction { const fetch = async (cafs: Cafs, resolution: Resolution, opts: FetchOptions) => { - // This solution is not perfect but inside the fetcher we don't currently know the location - // of the built and non-built index files. - const nonBuiltIndexFile = fetcherOpts.ignoreScripts ? opts.filesIndexFile : pathTemp(opts.filesIndexFile) + const tempIndexFile = pathTemp(opts.filesIndexFile) const { filesIndex, manifest } = await fetchRemoteTarball(cafs, resolution, { ...opts, - filesIndexFile: nonBuiltIndexFile, + filesIndexFile: tempIndexFile, }) try { - const prepareResult = await prepareGitHostedPkg(filesIndex as Record, cafs, nonBuiltIndexFile, opts.filesIndexFile, fetcherOpts, opts) + const prepareResult = await prepareGitHostedPkg(filesIndex as Record, cafs, tempIndexFile, opts.filesIndexFile, fetcherOpts, opts, resolution) if (prepareResult.ignoredBuild) { globalWarn(`The git-hosted package fetched from "${resolution.tarball}" has to be built but the build scripts were ignored.`) } - return { filesIndex: prepareResult.filesIndex, manifest } + return { + filesIndex: prepareResult.filesIndex, + manifest: prepareResult.manifest ?? manifest, + } } catch (err: any) { // eslint-disable-line err.message = `Failed to prepare git-hosted package fetched from "${resolution.tarball}": ${err.message}` throw err @@ -43,14 +46,21 @@ export function createGitHostedTarballFetcher (fetchRemoteTarball: FetchFunction return fetch as FetchFunction } +interface PrepareGitHostedPkgResult { + filesIndex: Record + manifest?: DependencyManifest + ignoredBuild: boolean +} + async function prepareGitHostedPkg ( filesIndex: Record, cafs: Cafs, filesIndexFileNonBuilt: string, filesIndexFile: string, opts: CreateGitHostedTarballFetcher, - fetcherOpts: FetchOptions -) { + fetcherOpts: FetchOptions, + resolution: Resolution +): Promise { const tempLocation = await cafs.tempDir() cafs.importPackage(tempLocation, { filesResponse: { @@ -59,20 +69,22 @@ async function prepareGitHostedPkg ( }, force: true, }) - const shouldBeBuilt = await preparePackage(opts, tempLocation) - if (!shouldBeBuilt) { - if (filesIndexFileNonBuilt !== filesIndexFile) { - await renameOverwrite(filesIndexFileNonBuilt, filesIndexFile) + const { shouldBeBuilt, pkgDir } = await preparePackage(opts, tempLocation, resolution.path ?? '') + if (!resolution.path) { + if (!shouldBeBuilt) { + if (filesIndexFileNonBuilt !== filesIndexFile) { + await renameOverwrite(filesIndexFileNonBuilt, filesIndexFile) + } + return { + filesIndex, + ignoredBuild: false, + } } - return { - filesIndex, - ignoredBuild: false, - } - } - if (opts.ignoreScripts) { - return { - filesIndex, - ignoredBuild: true, + if (opts.ignoreScripts) { + return { + filesIndex, + ignoredBuild: true, + } } } try { @@ -85,9 +97,10 @@ async function prepareGitHostedPkg ( return { ...await addFilesFromDir({ cafsDir: cafs.cafsDir, - dir: tempLocation, + dir: pkgDir, filesIndexFile, pkg: fetcherOpts.pkg, + readManifest: fetcherOpts.readManifest, }), ignoredBuild: false, } diff --git a/fetching/tarball-fetcher/test/fetch.ts b/fetching/tarball-fetcher/test/fetch.ts index 12a658177c..5dfb4c42d1 100644 --- a/fetching/tarball-fetcher/test/fetch.ts +++ b/fetching/tarball-fetcher/test/fetch.ts @@ -513,3 +513,78 @@ test('when extracting files with the same name, pick the last ones', async () => expect(pkgJson.name).toBe('pkg2') expect(manifest?.name).toBe('pkg2') }) + +test('use the subfolder when path is present', async () => { + process.chdir(tempy.directory()) + + const resolution = { + tarball: 'https://codeload.github.com/RexSkz/test-git-subfolder-fetch/tar.gz/2b42a57a945f19f8ffab8ecbd2021fdc2c58ee22', + path: '/packages/simple-react-app', + } + + const fetch = createTarballFetcher(fetchFromRegistry, getAuthHeader, { + ignoreScripts: true, + rawConfig: {}, + retry: { + maxTimeout: 100, + minTimeout: 0, + retries: 1, + }, + }) + const { filesIndex } = await fetch.gitHostedTarball(cafs, resolution, { + filesIndexFile, + lockfileDir: process.cwd(), + pkg: {}, + }) + + expect(filesIndex).toHaveProperty(['package.json']) + expect(filesIndex).not.toHaveProperty(['lerna.json']) +}) + +test('prevent directory traversal attack when path is present', async () => { + process.chdir(tempy.directory()) + + const tarball = 'https://codeload.github.com/RexSkz/test-git-subfolder-fetch/tar.gz/2b42a57a945f19f8ffab8ecbd2021fdc2c58ee22' + const path = '../../etc' + const resolution = { tarball, path } + + const fetch = createTarballFetcher(fetchFromRegistry, getAuthHeader, { + ignoreScripts: true, + rawConfig: {}, + retry: { + maxTimeout: 100, + minTimeout: 0, + retries: 1, + }, + }) + + await expect(() => fetch.gitHostedTarball(cafs, resolution, { + filesIndexFile, + lockfileDir: process.cwd(), + pkg: {}, + })).rejects.toThrow(`Failed to prepare git-hosted package fetched from "${tarball}": Path "${path}" should be a sub directory`) +}) + +test('fail when path is not exists', async () => { + process.chdir(tempy.directory()) + + const tarball = 'https://codeload.github.com/RexSkz/test-git-subfolder-fetch/tar.gz/2b42a57a945f19f8ffab8ecbd2021fdc2c58ee22' + const path = '/not-exists' + const resolution = { tarball, path } + + const fetch = createTarballFetcher(fetchFromRegistry, getAuthHeader, { + ignoreScripts: true, + rawConfig: {}, + retry: { + maxTimeout: 100, + minTimeout: 0, + retries: 1, + }, + }) + + await expect(() => fetch.gitHostedTarball(cafs, resolution, { + filesIndexFile, + lockfileDir: process.cwd(), + pkg: {}, + })).rejects.toThrow(`Failed to prepare git-hosted package fetched from "${tarball}": Path "${path}" is not a directory`) +}) diff --git a/pkg-manager/core/test/install/fromRepo.ts b/pkg-manager/core/test/install/fromRepo.ts index e1e6372d6d..e7a703b053 100644 --- a/pkg-manager/core/test/install/fromRepo.ts +++ b/pkg-manager/core/test/install/fromRepo.ts @@ -326,3 +326,20 @@ test('git-hosted repository is not added to the store if it fails to be built', addDependenciesToPackage({}, ['pnpm-e2e/prepare-script-fails'], await testDefaults()) ).rejects.toThrow() }) + +test('from subdirectories of a git repo', async () => { + const project = prepareEmpty() + + const manifest = await addDependenciesToPackage({}, [ + 'github:RexSkz/test-git-subfolder-fetch#path:/packages/simple-react-app', + 'github:RexSkz/test-git-subfolder-fetch#path:/packages/simple-express-server', + ], await testDefaults()) + + await project.has('@my-namespace/simple-react-app') + await project.has('@my-namespace/simple-express-server') + + expect(manifest.dependencies).toStrictEqual({ + '@my-namespace/simple-express-server': 'github:RexSkz/test-git-subfolder-fetch#path:/packages/simple-express-server', + '@my-namespace/simple-react-app': 'github:RexSkz/test-git-subfolder-fetch#path:/packages/simple-react-app', + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee7779de07..866f9b3158 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1416,6 +1416,9 @@ importers: exec/prepare-package: dependencies: + '@pnpm/error': + specifier: workspace:* + version: link:../../packages/error '@pnpm/lifecycle': specifier: workspace:* version: link:../lifecycle diff --git a/resolving/git-resolver/src/index.ts b/resolving/git-resolver/src/index.ts index 0e3fcc565e..9445b9530a 100644 --- a/resolving/git-resolver/src/index.ts +++ b/resolving/git-resolver/src/index.ts @@ -1,4 +1,4 @@ -import { type ResolveResult } from '@pnpm/resolver-base' +import { type TarballResolution, type GitResolution, type ResolveResult } from '@pnpm/resolver-base' import git from 'graceful-git' import semver from 'semver' import { parsePref, type HostedPackageSpec } from './parsePref' @@ -29,7 +29,7 @@ export function createGitResolver ( const tarball = hosted.tarball?.() if (tarball) { - resolution = { tarball } + resolution = { tarball } as TarballResolution } } @@ -38,14 +38,18 @@ export function createGitResolver ( commit, repo: parsedSpec.fetchSpec, type: 'git', - } as ({ type: string } & object) + } as GitResolution + } + + if (parsedSpec.path) { + resolution.path = parsedSpec.path } return { id: parsedSpec.fetchSpec .replace(/^.*:\/\/(git@)?/, '') .replace(/:/g, '+') - .replace(/\.git$/, '') + '/' + commit, + .replace(/\.git$/, '') + '/' + commit + (resolution.path ? `#path:${resolution.path}` : ''), normalizedPref: parsedSpec.normalizedPref, resolution, resolvedVia: 'git-repository', diff --git a/resolving/git-resolver/src/parsePref.ts b/resolving/git-resolver/src/parsePref.ts index c6d96b7b8d..c9076cdb50 100644 --- a/resolving/git-resolver/src/parsePref.ts +++ b/resolving/git-resolver/src/parsePref.ts @@ -17,6 +17,7 @@ export interface HostedPackageSpec { normalizedPref: string gitCommittish: string | null gitRange?: string + path?: string } const gitProtocols = new Set([ @@ -43,11 +44,11 @@ export async function parsePref (pref: string): Promise 1) ? decodeURIComponent(url.hash.slice(1)) : null + const hash = (url.hash?.length > 1) ? decodeURIComponent(url.hash.slice(1)) : null return { fetchSpec: urlToFetchSpec(url), normalizedPref: pref, - ...setGitCommittish(committish), + ...parseGitParams(hash), } } return null @@ -87,7 +88,7 @@ async function fromHostedGit (hosted: any): Promise { // esli tarball: undefined, }, normalizedPref: `git+${httpsUrl}`, - ...setGitCommittish(hosted.committish), + ...parseGitParams(hosted.committish), } } else { try { @@ -121,7 +122,7 @@ async function fromHostedGit (hosted: any): Promise { // esli tarball: hosted.tarball, }, normalizedPref: hosted.shortcut(), - ...setGitCommittish(hosted.committish), + ...parseGitParams(hosted.committish), } } @@ -143,14 +144,25 @@ async function accessRepository (repository: string) { } } -function setGitCommittish (committish: string | null) { - if (committish !== null && committish.length >= 7 && committish.slice(0, 7) === 'semver:') { - return { - gitCommittish: null, - gitRange: committish.slice(7), +type GitParsedParams = Pick + +function parseGitParams (committish: string | null): GitParsedParams { + const result: GitParsedParams = { gitCommittish: null } + if (!committish) { + return result + } + + const params = committish.split('&') + for (const param of params) { + if (param.length >= 7 && param.slice(0, 7) === 'semver:') { + result.gitRange = param.slice(7) + } else if (param.slice(0, 5) === 'path:') { + result.path = param.slice(5) + } else { + result.gitCommittish = param } } - return { gitCommittish: committish } + return result } // handle SCP-like URLs diff --git a/resolving/git-resolver/test/index.ts b/resolving/git-resolver/test/index.ts index eb0265f77f..7d48553ae7 100644 --- a/resolving/git-resolver/test/index.ts +++ b/resolving/git-resolver/test/index.ts @@ -165,6 +165,32 @@ test.skip('resolveFromGit() with range semver (v-prefixed tag)', async () => { }) }) +test('resolveFromGit() with sub folder', async () => { + const resolveResult = await resolveFromGit({ pref: 'github:RexSkz/test-git-subfolder-fetch.git#path:/packages/simple-react-app' }) + expect(resolveResult).toStrictEqual({ + id: 'github.com/RexSkz/test-git-subfolder-fetch/2b42a57a945f19f8ffab8ecbd2021fdc2c58ee22#path:/packages/simple-react-app', + normalizedPref: 'github:RexSkz/test-git-subfolder-fetch#path:/packages/simple-react-app', + resolution: { + tarball: 'https://codeload.github.com/RexSkz/test-git-subfolder-fetch/tar.gz/2b42a57a945f19f8ffab8ecbd2021fdc2c58ee22', + path: '/packages/simple-react-app', + }, + resolvedVia: 'git-repository', + }) +}) + +test('resolveFromGit() with both sub folder and branch', async () => { + const resolveResult = await resolveFromGit({ pref: 'github:RexSkz/test-git-subfolder-fetch.git#beta&path:/packages/simple-react-app' }) + expect(resolveResult).toStrictEqual({ + id: 'github.com/RexSkz/test-git-subfolder-fetch/777e8a3e78cc89bbf41fb3fd9f6cf922d5463313#path:/packages/simple-react-app', + normalizedPref: 'github:RexSkz/test-git-subfolder-fetch#beta&path:/packages/simple-react-app', + resolution: { + tarball: 'https://codeload.github.com/RexSkz/test-git-subfolder-fetch/tar.gz/777e8a3e78cc89bbf41fb3fd9f6cf922d5463313', + path: '/packages/simple-react-app', + }, + resolvedVia: 'git-repository', + }) +}) + test('resolveFromGit() fails when ref not found', async () => { await expect( resolveFromGit({ pref: 'zkochan/is-negative#bad-ref' }) diff --git a/resolving/git-resolver/test/parsePref.test.ts b/resolving/git-resolver/test/parsePref.test.ts index 14d00265a9..a7f280ba9d 100644 --- a/resolving/git-resolver/test/parsePref.test.ts +++ b/resolving/git-resolver/test/parsePref.test.ts @@ -5,10 +5,42 @@ test.each([ ['ssh://username:password@example.com:repo/@foo.git', 'ssh://username:password@example.com/repo/@foo.git'], ['ssh://username:password@example.com:22/repo/@foo.git', 'ssh://username:password@example.com:22/repo/@foo.git'], ['ssh://username:password@example.com:22repo/@foo.git', 'ssh://username:password@example.com/22repo/@foo.git'], + ['ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b', 'ssh://username:password@example.com:22/repo/@foo.git'], + ['ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b&dev', 'ssh://username:password@example.com:22/repo/@foo.git'], ['git+ssh://username:password@example.com:repo.git', 'ssh://username:password@example.com/repo.git'], ['git+ssh://username:password@example.com:repo/@foo.git', 'ssh://username:password@example.com/repo/@foo.git'], ['git+ssh://username:password@example.com:22/repo/@foo.git', 'ssh://username:password@example.com:22/repo/@foo.git'], + ['git+ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b', 'ssh://username:password@example.com:22/repo/@foo.git'], + ['git+ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b&dev', 'ssh://username:password@example.com:22/repo/@foo.git'], ])('the right colon is escaped in %s', async (input, output) => { const parsed = await parsePref(input) expect(parsed?.fetchSpec).toBe(output) }) + +test.each([ + ['ssh://username:password@example.com:repo.git#path:/a/@b', '/a/@b'], + ['ssh://username:password@example.com:repo/@foo.git#path:/a/@b', '/a/@b'], + ['ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b', '/a/@b'], + ['ssh://username:password@example.com:22repo/@foo.git#path:/a/@b', '/a/@b'], + ['ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b', '/a/@b'], + ['ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b&dev', '/a/@b'], + ['git+ssh://username:password@example.com:repo.git#path:/a/@b', '/a/@b'], + ['git+ssh://username:password@example.com:repo/@foo.git#path:/a/@b', '/a/@b'], + ['git+ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b', '/a/@b'], + ['git+ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b', '/a/@b'], + ['git+ssh://username:password@example.com:22/repo/@foo.git#path:/a/@b&dev', '/a/@b'], + ['ssh://username:password@example.com:repo.git', undefined], + ['ssh://username:password@example.com:repo/@foo.git', undefined], + ['ssh://username:password@example.com:22/repo/@foo.git', undefined], + ['ssh://username:password@example.com:22repo/@foo.git', undefined], + ['ssh://username:password@example.com:22/repo/@foo.git', undefined], + ['ssh://username:password@example.com:22/repo/@foo.git#dev', undefined], + ['git+ssh://username:password@example.com:repo.git', undefined], + ['git+ssh://username:password@example.com:repo/@foo.git', undefined], + ['git+ssh://username:password@example.com:22/repo/@foo.git', undefined], + ['git+ssh://username:password@example.com:22/repo/@foo.git', undefined], + ['git+ssh://username:password@example.com:22/repo/@foo.git#dev', undefined], +])('the path of %s should be %s', async (input, output) => { + const parsed = await parsePref(input) + expect(parsed?.path).toBe(output) +}) diff --git a/resolving/resolver-base/src/index.ts b/resolving/resolver-base/src/index.ts index a49cff36fe..a93e949da2 100644 --- a/resolving/resolver-base/src/index.ts +++ b/resolving/resolver-base/src/index.ts @@ -7,6 +7,7 @@ export interface TarballResolution { type?: undefined tarball: string integrity?: string + path?: string } /** @@ -20,6 +21,7 @@ export interface DirectoryResolution { export interface GitResolution { commit: string repo: string + path?: string type: 'git' }