diff --git a/src/utils/git.ts b/src/utils/git.ts index ac3ef684f..72c8b1640 100644 --- a/src/utils/git.ts +++ b/src/utils/git.ts @@ -84,25 +84,33 @@ export async function getLocalGitInfo(rootDir: string): Promise( + (node, segment) => (typeof node === 'object' ? node[segment] : undefined), + refs, + ) + return typeof resolved === 'string' ? resolved : undefined +} + export async function getGitRemoteHash(url: string, ref?: GitRefType): Promise { try { const remote = await git.getRemoteInfo({ http: gitHttp, url }) if (ref) { if (ref.branch) { - const headRef = remote.refs.heads![ref.branch] - return headRef + return resolveRemoteRef(remote.refs.heads as RemoteRefs, ref.branch) } if (ref.tag) { - const tagsRef = remote.refs.tags![ref.tag] - return tagsRef + return resolveRemoteRef(remote.refs.tags as RemoteRefs, ref.tag) } } else { // default to the HEAD ref provided by the server const head = remote.HEAD!.replace('refs/heads/', '') - const headRef = remote.refs.heads![head] - return headRef + return resolveRemoteRef(remote.refs.heads as RemoteRefs, head) } } catch { diff --git a/src/utils/source.ts b/src/utils/source.ts index bb14face2..ae22b71d5 100644 --- a/src/utils/source.ts +++ b/src/utils/source.ts @@ -3,6 +3,7 @@ import { createReadStream } from 'node:fs' import { join, normalize } from 'pathe' import { withLeadingSlash, withoutTrailingSlash } from 'ufo' import { glob } from 'tinyglobby' +import { hash } from 'ohash' import type { CollectionSource, ResolvedCollectionSource } from '../types/collection' import { downloadGitRepository } from './git' import { logger } from './dev' @@ -80,7 +81,6 @@ export function defineGitSource(source: CollectionSource): ResolvedCollectionSou const repository = source?.repository && gitUrlParse(source.repository.url) if (repository) { const { source: gitSource, owner, name } = repository - resolvedSource.cwd = join(rootDir, '.data', 'content', `${gitSource}-${owner}-${name}-${repository.ref || 'main'}`) let ref: object | undefined @@ -88,6 +88,15 @@ export function defineGitSource(source: CollectionSource): ResolvedCollectionSou throw new Error('Cannot specify both branch and tag for git repository. Please specify one of `branch` or `tag`.') } + const resolvedRef = source.repository.branch || source.repository.tag || repository.ref || 'main' + // refs may contain `/` (`release/v1`), which would nest or escape the cache directory; + // a hash suffix keeps escaped refs distinct from each other + const refKey = /^[\w.-]+$/.test(resolvedRef) + ? resolvedRef + : `${resolvedRef.replace(/[^\w.-]+/g, '-')}-${hash(resolvedRef).slice(0, 8)}` + const refPrefix = source.repository.tag ? 'tag-' : '' + resolvedSource.cwd = join(rootDir, '.data', 'content', `${gitSource}-${owner}-${name}-${refPrefix}${refKey}`) + if (source.repository.branch) ref = { branch: source.repository.branch } if (source.repository.tag) ref = { tag: source.repository.tag } diff --git a/test/unit/defineGitSource.test.ts b/test/unit/defineGitSource.test.ts new file mode 100644 index 000000000..49c38c6bf --- /dev/null +++ b/test/unit/defineGitSource.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it, vi } from 'vitest' +import { defineGitSource } from '../../src/utils/source' + +vi.mock('../../src/utils/git', () => ({ + downloadGitRepository: vi.fn(), +})) + +async function resolveCwd(repository: string | Record) { + const source = defineGitSource({ include: 'docs/**', repository } as never) + await source.prepare!({ rootDir: '/root' } as never) + return source.cwd +} + +describe('defineGitSource', () => { + it('keys the checkout directory on the resolved ref', async () => { + expect(await resolveCwd('https://github.com/nuxt/cli/tree/main')) + .toBe('/root/.data/content/github.com-nuxt-cli-main') + expect(await resolveCwd({ url: 'https://github.com/nuxt/cli', tag: 'v3.37.0' })) + .toBe('/root/.data/content/github.com-nuxt-cli-tag-v3.37.0') + expect(await resolveCwd({ url: 'https://github.com/nuxt/cli', branch: 'dev' })) + .toBe('/root/.data/content/github.com-nuxt-cli-dev') + }) + + it('distinguishes a branch from a tag of the same name', async () => { + const branch = await resolveCwd({ url: 'https://github.com/nuxt/cli', branch: 'v3.37.0' }) + const tag = await resolveCwd({ url: 'https://github.com/nuxt/cli', tag: 'v3.37.0' }) + expect(branch).not.toBe(tag) + }) + + it('sanitises slashes in the ref without collapsing distinct refs', async () => { + const slashed = await resolveCwd({ url: 'https://github.com/nuxt/cli', branch: 'release/v4.0.0' }) + const dashed = await resolveCwd({ url: 'https://github.com/nuxt/cli', branch: 'release-v4.0.0' }) + + expect(slashed).toMatch(/^\/root\/\.data\/content\/github\.com-nuxt-cli-release-v4\.0\.0-/) + expect(dashed).toBe('/root/.data/content/github.com-nuxt-cli-release-v4.0.0') + expect(slashed).not.toBe(dashed) + + const escaped = await resolveCwd({ url: 'https://github.com/nuxt/cli', branch: '../../escape' }) + expect(escaped.split('/').slice(0, -1).join('/')).toBe('/root/.data/content') + }) + + it('defaults to main when no ref is given', async () => { + expect(await resolveCwd('https://github.com/nuxt/cli')) + .toBe('/root/.data/content/github.com-nuxt-cli-main') + }) +}) diff --git a/test/unit/git/nestedRefs.test.ts b/test/unit/git/nestedRefs.test.ts new file mode 100644 index 000000000..194be7a03 --- /dev/null +++ b/test/unit/git/nestedRefs.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test, vi } from 'vitest' + +vi.mock('isomorphic-git', () => ({ + default: { + getRemoteInfo: vi.fn(async () => ({ + HEAD: 'refs/heads/main', + refs: { + heads: { + main: 'aaa', + release: { 'v4.0.0': 'bbb' }, + }, + tags: { + 'v1.0': { beta: 'ccc' }, + }, + }, + })), + }, +})) + +const { getGitRemoteHash } = await import('../../../src/utils/git') + +describe('getGitRemoteHash with nested refs', () => { + test('resolves a branch name containing a slash', async () => { + const url = 'https://github.com/nuxt/content' + const ref = { branch: 'release/v4.0.0' } + + expect(await getGitRemoteHash(url, ref)).toBe('bbb') + expect(await getGitRemoteHash(url, ref)).toBe('bbb') + }) + + test('resolves a tag name containing a slash', async () => { + expect(await getGitRemoteHash('https://github.com/nuxt/content', { tag: 'v1.0/beta' })).toBe('ccc') + }) + + test('does not return an object for a partial ref', async () => { + expect(await getGitRemoteHash('https://github.com/nuxt/content', { branch: 'release' })).toBeUndefined() + }) +})