From beaa04601966ab847246976f02a818bcdbb0fe1c Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 23 Aug 2026 09:20:27 +0000 Subject: [PATCH 1/3] fix: key remote source checkout dir on resolved git ref --- src/utils/source.ts | 4 +++- test/unit/defineGitSource.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 test/unit/defineGitSource.test.ts diff --git a/src/utils/source.ts b/src/utils/source.ts index bb14face2..45a587f2a 100644 --- a/src/utils/source.ts +++ b/src/utils/source.ts @@ -80,7 +80,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 +87,9 @@ 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' + resolvedSource.cwd = join(rootDir, '.data', 'content', `${gitSource}-${owner}-${name}-${resolvedRef}`) + 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..fe2767324 --- /dev/null +++ b/test/unit/defineGitSource.test.ts @@ -0,0 +1,28 @@ +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-v3.37.0') + expect(await resolveCwd({ url: 'https://github.com/nuxt/cli', branch: 'dev' })) + .toBe('/root/.data/content/github.com-nuxt-cli-dev') + }) + + 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') + }) +}) From e530337b0878586c34974edb4af786fb3543804f Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 23 Aug 2026 09:22:09 +0000 Subject: [PATCH 2/3] fix: resolve nested remote refs when hashing git sources --- src/utils/git.ts | 20 +++++++++++----- src/utils/source.ts | 4 +++- test/unit/defineGitSource.test.ts | 7 ++++++ test/unit/git/nestedRefs.test.ts | 38 +++++++++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 test/unit/git/nestedRefs.test.ts 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 45a587f2a..dcf85b402 100644 --- a/src/utils/source.ts +++ b/src/utils/source.ts @@ -88,7 +88,9 @@ export function defineGitSource(source: CollectionSource): ResolvedCollectionSou } const resolvedRef = source.repository.branch || source.repository.tag || repository.ref || 'main' - resolvedSource.cwd = join(rootDir, '.data', 'content', `${gitSource}-${owner}-${name}-${resolvedRef}`) + // refs may contain `/` (`release/v1`) which would nest or escape the cache directory + const refKey = resolvedRef.replace(/[^\w.-]+/g, '-') + resolvedSource.cwd = join(rootDir, '.data', 'content', `${gitSource}-${owner}-${name}-${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 index fe2767324..8b506eedc 100644 --- a/test/unit/defineGitSource.test.ts +++ b/test/unit/defineGitSource.test.ts @@ -21,6 +21,13 @@ describe('defineGitSource', () => { .toBe('/root/.data/content/github.com-nuxt-cli-dev') }) + it('sanitises slashes in the ref', async () => { + expect(await resolveCwd({ url: 'https://github.com/nuxt/cli', branch: 'release/v4.0.0' })) + .toBe('/root/.data/content/github.com-nuxt-cli-release-v4.0.0') + expect(await resolveCwd({ url: 'https://github.com/nuxt/cli', branch: '../../escape' })) + .toBe('/root/.data/content/github.com-nuxt-cli-..-..-escape') + }) + 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() + }) +}) From 23822f7cfc2e953c97e13b735b9cb6e8bcbfe441 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 23 Aug 2026 09:49:52 +0000 Subject: [PATCH 3/3] fix: keep escaped and tag refs distinct in cache key --- src/utils/source.ts | 11 ++++++++--- test/unit/defineGitSource.test.ts | 23 +++++++++++++++++------ 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/utils/source.ts b/src/utils/source.ts index dcf85b402..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' @@ -88,9 +89,13 @@ export function defineGitSource(source: CollectionSource): ResolvedCollectionSou } const resolvedRef = source.repository.branch || source.repository.tag || repository.ref || 'main' - // refs may contain `/` (`release/v1`) which would nest or escape the cache directory - const refKey = resolvedRef.replace(/[^\w.-]+/g, '-') - resolvedSource.cwd = join(rootDir, '.data', 'content', `${gitSource}-${owner}-${name}-${refKey}`) + // 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 index 8b506eedc..49c38c6bf 100644 --- a/test/unit/defineGitSource.test.ts +++ b/test/unit/defineGitSource.test.ts @@ -16,16 +16,27 @@ describe('defineGitSource', () => { 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-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('sanitises slashes in the ref', async () => { - expect(await resolveCwd({ url: 'https://github.com/nuxt/cli', branch: 'release/v4.0.0' })) - .toBe('/root/.data/content/github.com-nuxt-cli-release-v4.0.0') - expect(await resolveCwd({ url: 'https://github.com/nuxt/cli', branch: '../../escape' })) - .toBe('/root/.data/content/github.com-nuxt-cli-..-..-escape') + 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 () => {