diff --git a/.oxlintrc.json b/.oxlintrc.json index ad8ec3d..267aba2 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -41,7 +41,7 @@ "categories": { "correctness": "error", "suspicious": "warn", - "pedantic": "off", + "pedantic": "warn", "perf": "warn", "restriction": "off", "style": "off" diff --git a/perf/bench.ts b/perf/bench.ts index c37b6ca..687e19f 100644 --- a/perf/bench.ts +++ b/perf/bench.ts @@ -69,11 +69,11 @@ try { const samplesMs: number[] = []; try { - for (let _ = 0; _ < config.warmups; _ += 1) { + for (let warmupIndex = 0; warmupIndex < config.warmups; warmupIndex += 1) { await runtime.clone(); } - for (let _ = 0; _ < config.iterations; _ += 1) { + for (let iterationIndex = 0; iterationIndex < config.iterations; iterationIndex += 1) { const startedAt = performance.now(); await runtime.clone(); samplesMs.push(performance.now() - startedAt); diff --git a/src/bin.ts b/src/bin.ts index 173a754..cd8612e 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -106,7 +106,7 @@ async function promptForSource(): Promise { suggest: (input: string, promptChoices: Choice[]) => promptChoices.filter(({ value }) => fuzzysearch(input, value)), type: 'autocomplete', - } as any; + }; return enquirer.prompt([ sourcePrompt, @@ -121,7 +121,7 @@ async function promptForSource(): Promise { name: 'cache', type: 'toggle', }, - ] as any); + ]); } async function confirmOverwrite(): Promise { @@ -131,7 +131,7 @@ async function confirmOverwrite(): Promise { name: 'force', type: 'toggle', }, - ] as any); + ]); return force; } diff --git a/src/git-client.ts b/src/git-client.ts index 050ad1e..2808aa1 100644 --- a/src/git-client.ts +++ b/src/git-client.ts @@ -117,7 +117,7 @@ function mapRemoteRef(refName: string, refHash: string): Ref { }; } -function mapServerRef(serverRef: any): Ref | undefined { +function mapServerRef(serverRef: { hash?: string; name?: string; oid?: string; ref?: string }): Ref | undefined { const refName = String(serverRef.ref || serverRef.name || ''); const refHash = String(serverRef.oid || serverRef.hash || ''); diff --git a/src/utils.ts b/src/utils.ts index 84b8c76..835b26a 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -64,7 +64,7 @@ export function fetch(url: string, dest: string, proxy?: string): Promise if (proxy) { const parsedUrl = URL.parse(url); options = { - agent: Agent(proxy) as unknown as import('node:http').Agent, + agent: Agent(proxy) as import('node:http').Agent, hostname: parsedUrl.host, path: parsedUrl.path, }; diff --git a/test/helpers.ts b/test/helpers.ts index 134f1e9..0e0ba98 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -24,9 +24,9 @@ export function compareDirToExpected(dir, files) { export function createMockGit(stubs = {}) { const calls = []; - const resolveStub = async (call, ...args) => { + const resolveStub = (call, ...args) => { if (!Object.hasOwn(stubs, call)) { - return Promise.reject(new Error(`Unexpected git call: ${call}`)); + throw new Error(`Unexpected git call: ${call}`); } const stub = stubs[call]; @@ -34,16 +34,16 @@ export function createMockGit(stubs = {}) { return stub(...args); } - return Promise.resolve(stub); + return stub; }; - const fetchRefs = async (repo) => { + const fetchRefs = (repo) => { const call = `fetchRefs ${getGitUrl(repo)}`; calls.push(call); return resolveStub(call, repo); }; - const clone = async (repo, dest, ref, transport) => { + const clone = (repo, dest, ref, transport) => { const call = `clone ${getGitUrl(repo, transport)} ${dest}${ref ? ` ${ref}` : ''}`; calls.push(call); return resolveStub(call, repo, dest, ref, transport); @@ -61,7 +61,7 @@ export function createMockFetch(steps) { const response = steps[step++]; if (!response) { - return Promise.reject(new Error('No mock fetch step configured')); + throw new Error('No mock fetch step configured'); } if (response.status >= 300 && response.status < 400) { @@ -69,15 +69,13 @@ export function createMockFetch(steps) { } if (response.status >= 400) { - return Promise.reject( + throw ( response.error || { code: response.code || response.status, message: response.message || 'mock fetch error', - }, + } ); } - - return Promise.resolve(); }; return { calls, fn }; @@ -90,11 +88,10 @@ export function createCopyFetch(sourceFile) { const copyWithRetry = (destination, attempt = 1) => { try { fs.copyFileSync(sourceFile, destination); - return Promise.resolve(); } catch (error) { const isRetryable = error?.code === 'EPERM' || error?.code === 'EBUSY'; if (!isRetryable || attempt === maxAttempts) { - return Promise.reject(error); + throw error; } return delay(25 * attempt).then(() => copyWithRetry(destination, attempt + 1)); @@ -104,8 +101,6 @@ export function createCopyFetch(sourceFile) { const fn = async (url, file, proxy) => { calls.push({ file, proxy, url }); await copyWithRetry(file); - - return Promise.resolve(); }; return { calls, fn }; diff --git a/test/integration/runner.ts b/test/integration/runner.ts index 6ee03b2..673590d 100644 --- a/test/integration/runner.ts +++ b/test/integration/runner.ts @@ -57,7 +57,7 @@ function createReleaseRunner() { const { command, args } = getReleaseCommand(); return { - async clone(source: string, dest: string) { + clone(source: string, dest: string) { const result = child_process.spawnSync(command, [...args, source, dest], { encoding: 'utf8', env: process.env, diff --git a/test/unit/bin.test.ts b/test/unit/bin.test.ts index 9d32005..fd39f81 100644 --- a/test/unit/bin.test.ts +++ b/test/unit/bin.test.ts @@ -45,6 +45,26 @@ import enquirer from 'enquirer'; const mockDegit = vi.mocked(degit); const mockPrompt = vi.mocked(enquirer.prompt); +type MockDegitInstance = ReturnType; + +function createMockDegit( + cloneImpl: () => Promise, + handlers?: Record void>, +): MockDegitInstance { + const instance = { + clone: vi.fn(cloneImpl), + on(event: string, handler: (event: { message: string }) => void) { + if (handlers) { + handlers[event] = handler; + } + + return instance; + }, + }; + + return instance as MockDegitInstance; +} + async function waitForCondition(fn, timeoutMs = 3000, startedAt = Date.now()) { if (fn()) { return; @@ -54,22 +74,20 @@ async function waitForCondition(fn, timeoutMs = 3000, startedAt = Date.now()) { assert.fail('timeout waiting for condition'); } - await new Promise((r) => setTimeout(r, 5)); + await new Promise((resolve) => { + setTimeout(resolve, 5); + }); return waitForCondition(fn, timeoutMs, startedAt); } function mockEventClone(eventName, message) { - const handlers = {}; - mockDegit.mockReturnValue({ - clone: vi.fn().mockImplementation(() => { - handlers[eventName]({ message }); + const handlers: Record void> = {}; + mockDegit.mockReturnValue( + createMockDegit(() => { + handlers[eventName]?.({ message }); return Promise.resolve(); - }), - on: vi.fn(function on(ev, fn) { - handlers[ev] = fn; - return this; - }), - } as never); + }, handlers), + ); return handlers; } @@ -78,12 +96,9 @@ async function withCloneFailure( error: Error, assertions: (exitSpy: ReturnType, errSpy: ReturnType) => void, ) { - mockDegit.mockReturnValue({ - clone: vi.fn().mockReturnValue(Promise.reject(error)), - on: vi.fn().mockReturnThis(), - } as never); + mockDegit.mockReturnValue(createMockDegit(() => Promise.reject(error))); - const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never); const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); try { @@ -97,7 +112,6 @@ async function withCloneFailure( } } -describe('degit bin', () => { const binTmp = '.tmp/bin-suite'; const repoRoot = process.cwd(); const rootBin = path.join(repoRoot, 'degit'); @@ -107,34 +121,32 @@ describe('degit bin', () => { fs.rmSync(interactiveBase, { force: true, recursive: true }); } - beforeEach(async () => { - await rimraf(binTmp); - clearInteractiveFixtures(); - vi.clearAllMocks(); - mockDegit.mockReturnValue({ - clone: vi.fn().mockResolvedValue(undefined), - on: vi.fn().mockReturnThis(), - } as never); - }); - afterEach(async () => { - await rimraf(binTmp); - clearInteractiveFixtures(); - }); +beforeEach(async () => { + await rimraf(binTmp); + clearInteractiveFixtures(); + vi.clearAllMocks(); + mockDegit.mockReturnValue(createMockDegit(() => Promise.resolve())); +}); - it('runs the built root bin when --help is executed', () => { - const result = child_process.spawnSync('node', [rootBin, '--help'], { - env: { - ...process.env, - VITEST: '', - }, - encoding: 'utf8', - }); - const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; - assert.ok(output.length > 0); - assert.ok(output.includes('degit')); +afterEach(async () => { + await rimraf(binTmp); + clearInteractiveFixtures(); +}); + +it('runs the built root bin when --help is executed', () => { + const result = child_process.spawnSync('node', [rootBin, '--help'], { + env: { + ...process.env, + VITEST: '', + }, + encoding: 'utf8', }); + const output = `${result.stdout ?? ''}${result.stderr ?? ''}`; + assert.ok(output.length > 0); + assert.ok(output.includes('degit')); +}); - it('writes help to stdout when argv includes --help', async () => { +it('writes help to stdout when argv includes --help', async () => { const chunks: string[] = []; const orig = process.stdout.write.bind(process.stdout); process.stdout.write = ((chunk, enc, cb) => { @@ -158,7 +170,7 @@ describe('degit bin', () => { await main(['node', 'bin', 'user/repo', 'out', '-f']); assert.equal(mockDegit.mock.calls.length, 1); assert.equal(mockDegit.mock.calls[0][0], 'user/repo'); - assert.equal((mockDegit.mock.calls[0][1] as any).force, true); + assert.equal(mockDegit.mock.calls[0][1]?.force, true); const instance = mockDegit.mock.results[0].value; assert.equal(instance.clone.mock.calls[0][0], 'out'); }); @@ -180,7 +192,7 @@ describe('degit bin', () => { JSON.stringify({ main: '2026-01-01T00:00:00.000Z' }), ); - mockPrompt.mockImplementation(async (questions) => { + mockPrompt.mockImplementation((questions) => { const srcQuestion = ( questions as Array<{ name?: string; choices?: Array<{ value: string }> }> ).find((question) => question.name === 'src'); @@ -269,10 +281,7 @@ describe('degit bin', () => { }); it('forwards explicit git mode when argv passes --mode=git', async () => { - mockDegit.mockReturnValue({ - clone: vi.fn().mockResolvedValue(undefined), - on: vi.fn().mockReturnThis(), - } as never); + mockDegit.mockReturnValue(createMockDegit(() => Promise.resolve())); const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { await main(['node', 'bin', 'a/b', 'dest', '--mode=git']); @@ -284,4 +293,3 @@ describe('degit bin', () => { warnSpy.mockRestore(); } }); -}); diff --git a/test/unit/git-client.test.ts b/test/unit/git-client.test.ts index 3842135..bebe446 100644 --- a/test/unit/git-client.test.ts +++ b/test/unit/git-client.test.ts @@ -1,5 +1,4 @@ import assert from 'node:assert'; -import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; import { vi } from 'vitest'; @@ -58,16 +57,37 @@ const httpsRepo = { user: 'gitlab-org', } as const; -function createSpawnProcess() { - const child = new EventEmitter() as EventEmitter & { - stderr: PassThrough; - stdout: PassThrough; - }; +type SpawnEvent = 'close' | 'error'; + +type SpawnProcess = { + emit(event: SpawnEvent, value: unknown): boolean; + once(event: SpawnEvent, handler: (value: unknown) => void): void; + stderr: PassThrough; + stdout: PassThrough; +}; + +function createSpawnProcess(): SpawnProcess { + const handlers = new Map void>>(); - child.stdout = new PassThrough(); - child.stderr = new PassThrough(); + return { + emit(event: SpawnEvent, value: unknown) { + const eventHandlers = handlers.get(event); + if (!eventHandlers) { + return false; + } - return child; + handlers.delete(event); + eventHandlers.forEach((handler) => handler(value)); + return true; + }, + once(event: SpawnEvent, handler: (value: unknown) => void) { + const eventHandlers = handlers.get(event) ?? []; + eventHandlers.push(handler); + handlers.set(event, eventHandlers); + }, + stderr: new PassThrough(), + stdout: new PassThrough(), + }; } function writeChunkedLines(child: ReturnType, lines: string[]) { @@ -78,48 +98,47 @@ function writeChunkedLines(child: ReturnType, lines: } } -describe('git client', () => { - beforeEach(() => { - execFileMock.mockClear(); - spawnMock.mockClear(); - getRemoteInfo2Mock.mockReset(); - listServerRefsMock.mockReset(); - spawnMock.mockImplementation((command: string, args: string[]) => { - if (command === 'git' && args[0] === 'ls-remote') { - const child = createSpawnProcess(); - queueMicrotask(() => { - child.emit( - 'error', - Object.assign(new Error(`spawn ${command} ENOENT`), { - code: 'ENOENT', - syscall: `spawn ${command}`, - }), - ); - }); - - return child; - } - - throw new Error(`Unexpected spawn call: ${command} ${args.join(' ')}`); - }); - execFileMock.mockImplementation( - (command: string, args: string[], callback: (error?: unknown) => void) => { - if (command === 'git' && args[0] === 'clone') { - callback(); - return; - } - - callback( +beforeEach(() => { + execFileMock.mockClear(); + spawnMock.mockClear(); + getRemoteInfo2Mock.mockReset(); + listServerRefsMock.mockReset(); + spawnMock.mockImplementation((command: string, args: string[]) => { + if (command === 'git' && args[0] === 'ls-remote') { + const child = createSpawnProcess(); + queueMicrotask(() => { + child.emit( + 'error', Object.assign(new Error(`spawn ${command} ENOENT`), { code: 'ENOENT', syscall: `spawn ${command}`, }), ); - }, - ); + }); + + return child; + } + + throw new Error(`Unexpected spawn call: ${command} ${args.join(' ')}`); }); + execFileMock.mockImplementation( + (command: string, args: string[], callback: (error?: unknown) => void) => { + if (command === 'git' && args[0] === 'clone') { + callback(); + return; + } - it('falls back to protocol v1 discovery when protocol v2 ref listing fails for https repos', async () => { + callback( + Object.assign(new Error(`spawn ${command} ENOENT`), { + code: 'ENOENT', + syscall: `spawn ${command}`, + }), + ); + }, + ); +}); + +it('falls back to protocol v1 discovery when protocol v2 ref listing fails for https repos', async () => { listServerRefsMock.mockRejectedValueOnce( Object.assign(new Error('HTTP Error: 422 Unprocessable Entity'), { code: 'HttpError', @@ -145,7 +164,7 @@ describe('git client', () => { assert.equal(getRemoteInfo2Mock.mock.calls[0][0].protocolVersion, 1); }); - it('reads chunked ls-remote output when fetching refs over ssh', async () => { +it('reads chunked ls-remote output when fetching refs over ssh', async () => { spawnMock.mockImplementationOnce(() => { const child = createSpawnProcess(); queueMicrotask(() => { @@ -184,7 +203,7 @@ describe('git client', () => { }); }); - it('reports a missing git binary when fetching refs over ssh', async () => { +it('reports a missing git binary when fetching refs over ssh', async () => { await assert.rejects( createGitClient().fetchRefs(sshRepo), (error: any) => @@ -192,7 +211,7 @@ describe('git client', () => { ); }); - it('reports a missing git binary when cloning over ssh', async () => { +it('reports a missing git binary when cloning over ssh', async () => { execFileMock.mockImplementationOnce( (command: string, _args: string[], callback: (error?: unknown) => void) => { callback( @@ -211,7 +230,7 @@ describe('git client', () => { ); }); - it('uses a shallow clone when cloning over ssh', async () => { +it('uses a shallow clone when cloning over ssh', async () => { await createGitClient().clone(sshRepo, '.tmp/git-client-test'); assert.equal(execFileMock.mock.calls[0][0], 'git'); @@ -222,5 +241,4 @@ describe('git client', () => { sshRepo.ssh, '.tmp/git-client-test', ]); - }); }); diff --git a/test/unit/index.test.ts b/test/unit/index.test.ts index 8416dba..f267484 100644 --- a/test/unit/index.test.ts +++ b/test/unit/index.test.ts @@ -130,7 +130,7 @@ async function createArchiveWithFileFixture(rootName, relativePath, contents) { return archiveFile; } -async function createArchiveWithGitLfsPointerFixture(rootName) { +function createArchiveWithGitLfsPointerFixture(rootName) { return createArchiveWithFileFixture( rootName, 'packages/app/asset.bin', @@ -190,12 +190,11 @@ async function cloneAndExpectTarContent(test, archiveFile, dest, expectedPath, e assert.deepEqual(gitMock.calls, [`fetchRefs ${test.url}`]); } -function clearArchiveCache(test, _hash) { +function clearArchiveCache(test) { const archiveDir = path.join(base, test.site, test.user, test.name); fs.rmSync(archiveDir, { force: true, recursive: true }); } -describe('degit index', () => { const indexTmp = '.tmp/index-suite'; beforeEach(async () => await rimraf(indexTmp)); @@ -231,262 +230,265 @@ describe('degit index', () => { assert.equal(repo.ssh, 'ssh://git@github.com/Rich-Harris/degit-test-repo'); }); - it('throws UNSUPPORTED_HOST when the host prefix is not supported', () => { - assert.throws( - () => { - degit('codeberg:Rich-Harris/degit-test-repo'); - }, - (err: any) => err && err.code === 'UNSUPPORTED_HOST', - ); - }); - }); - - describe('tar mode fetch failures', () => { - providerCases.forEach((test) => { - it(`falls back to git clone using the source transport when redirect leads to 403 for ${test.site}`, async () => { - clearArchiveCache(test, refsHash); - const fetch = createMockFetch([ - { location: test.redirectUrl, status: 302 }, - { code: 403, message: 'Forbidden', status: 403 }, - ]); - const gitMock = createMockGit({ - [`fetchRefs ${test.url}`]: gitRefs, - [`clone ${test.url} .tmp/index-suite/test-repo HEAD`]: '', - }); - - await degit(test.publicSrc, { - git: gitMock.fn, - fetch: fetch.fn, - }).clone('.tmp/index-suite/test-repo'); - - assert.equal(fetch.calls.length, 2); - assert.equal(fetch.calls[0].url, test.archiveUrl(refsHash)); - assert.equal(fetch.calls[1].url, test.redirectUrl); - assert.deepEqual(gitMock.calls, [ - `fetchRefs ${test.url}`, - `clone ${test.url} .tmp/index-suite/test-repo HEAD`, - ]); - }); - }); - - it('falls back to git clone after a repeated extraction failure for github', async () => { - const test = providerCases[0]; - const dest = '.tmp/index-suite/test-repo'; - const archiveDir = path.join(base, test.site, test.user, test.name); - const corruptArchive = path.join('.tmp/index-suite', 'corrupt-archive.tar.gz'); - clearArchiveCache(test, refsHash); - fs.mkdirSync('.tmp/index-suite', { recursive: true }); - fs.mkdirSync(archiveDir, { recursive: true }); - fs.writeFileSync(path.join(archiveDir, `${refsHash}.tar.gz`), 'not a tarball'); - fs.writeFileSync(corruptArchive, 'not a tarball'); - const fetch = createCopyFetch(corruptArchive); - const gitMock = createMockGit({ - [`fetchRefs ${test.url}`]: gitRefs, - [`clone ${test.url} ${dest} HEAD`]: '', - }); - - await degit(test.publicSrc, { - git: gitMock.fn, - fetch: fetch.fn, - }).clone(dest); - - assert.equal(fetch.calls.length, 1); - assert.equal(fetch.calls[0].url, test.archiveUrl(refsHash)); - assert.deepEqual(gitMock.calls, [ - `fetchRefs ${test.url}`, - `clone ${test.url} ${dest} HEAD`, - ]); - }); - }); - - describe('tar mode HEAD fallback', () => { - providerCases.forEach((test) => { - it(`uses the default branch hash when HEAD is missing for ${test.site}`, async () => { - clearArchiveCache(test, refsHash); - const fetch = createCopyFetch( - await createArchiveFixture(`degit-test-repo-${refsHash}`), - ); - const gitMock = createMockGit({ - [`fetchRefs ${test.url}`]: branchRefs, - }); - - await degit(test.publicSrc, { - git: gitMock.fn, - fetch: fetch.fn, - }).clone('.tmp/index-suite/test-repo'); - - compareDirToExpected('.tmp/index-suite/test-repo', { - packages: '', - 'packages/app': '', - 'packages/app/index.js': 'export default 1\n', - 'packages/app/lib': '', - 'packages/app/lib/nested.txt': 'nested\n', - 'packages/ignored.txt': 'ignored\n', - }); - assert.equal(fetch.calls.length, 1); - assert.equal(fetch.calls[0].url, test.archiveUrl(refsHash)); - assert.deepEqual(gitMock.calls, [`fetchRefs ${test.url}`]); - }); - }); - - it('throws MISSING_REF when no refs are returned for HEAD', async () => { - const test = providerCases[1]; - const dest = '.tmp/index-suite/empty-refs'; - const gitMock = createMockGit({ - [`fetchRefs ${test.url}`]: [], - }); - - await assert.rejects( - async () => await degit(test.publicSrc, { git: gitMock.fn }).clone(dest), - (err: any) => err && err.code === 'MISSING_REF', - ); - }); - - it('uses the git backend for ssh sources when mode is git', async () => { - const dest = '.tmp/index-suite/ssh-git-mode'; - const gitMock = createMockGit({ - [`fetchRefs ssh://git@github.com/Rich-Harris/degit-test-repo`]: gitRefs, - [`clone ssh://git@github.com/Rich-Harris/degit-test-repo ${dest} ${refsHash}`]: '', - }); - - await degit('git@github.com:Rich-Harris/degit-test-repo', { - git: gitMock.fn, - mode: 'git', - }).clone(dest); - - assert.deepEqual(gitMock.calls, [ - 'fetchRefs ssh://git@github.com/Rich-Harris/degit-test-repo', - `clone ssh://git@github.com/Rich-Harris/degit-test-repo ${dest} ${refsHash}`, - ]); - }); - }); - - describe('tar mode extraction', () => { - providerCases.forEach((test) => { - it(`extracts a nested subdirectory when cloning a nested path for ${test.site}`, async () => { - const dest = '.tmp/index-suite/test-repo'; - clearArchiveCache(test, refsHash); - const archiveFile = await createArchiveFixture(`degit-test-repo-${refsHash}`); - const fetch = createCopyFetch(archiveFile); - const gitMock = createMockGit({ - [`fetchRefs ${test.url}`]: gitRefs, - }); - - await degit(`${test.publicSrc}/packages/app`, { - git: gitMock.fn, - fetch: fetch.fn, - }).clone(dest); - - compareDirToExpected(dest, { - 'index.js': 'export default 1\n', - lib: '', - 'lib/nested.txt': 'nested\n', - }); - assert.equal(fetch.calls[0].url, test.archiveUrl(refsHash)); - }); - }); - - providerCases.forEach((test) => { - it(`redownloads the tarball when the cached archive is corrupted for ${test.site}`, async () => { - const dest = '.tmp/index-suite/test-repo'; - const archiveDir = path.join(base, test.site, test.user, test.name); - clearArchiveCache(test, refsHash); - const archiveFile = await createArchiveFixture(`degit-test-repo-${refsHash}`); - fs.mkdirSync(archiveDir, { recursive: true }); - fs.writeFileSync(path.join(archiveDir, `${refsHash}.tar.gz`), 'not a tarball'); - const fetch = createCopyFetch(archiveFile); - const gitMock = createMockGit({ - [`fetchRefs ${test.url}`]: gitRefs, - }); - - await degit(test.publicSrc, { - git: gitMock.fn, - fetch: fetch.fn, - }).clone(dest); - - compareDirToExpected(dest, { - packages: '', - 'packages/app': '', - 'packages/app/index.js': 'export default 1\n', - 'packages/app/lib': '', - 'packages/app/lib/nested.txt': 'nested\n', - 'packages/ignored.txt': 'ignored\n', - }); - assert.equal(fetch.calls.length, 1); - assert.equal(fetch.calls[0].url, test.archiveUrl(refsHash)); - }); - }); - }); - - describe('explicit git mode', () => { - providerCases.forEach((test) => { - it(`uses the git backend immediately when mode is git for ${test.site}`, async () => { - const dest = '.tmp/index-suite/test-repo'; - const gitMock = createMockGit({ - [`fetchRefs ${test.url}`]: gitRefs, - [`clone ${test.url} ${dest} ${refsHash}`]: '', - }); - const warnings: string[] = []; - const emitter = degit(test.publicSrc, { - git: gitMock.fn, - mode: 'git', - }); - - emitter.on('warn', (event) => warnings.push(event.message)); - - await emitter.clone(dest); - - assert.deepEqual(warnings, []); - assert.deepEqual(gitMock.calls, [ - `fetchRefs ${test.url}`, - `clone ${test.url} ${dest} ${refsHash}`, - ]); - }); - }); - - providerCases.forEach((test) => { - it(`does not fall back when a file merely quotes a pointer snippet for ${test.site}`, async () => { - const dest = '.tmp/index-suite/test-repo'; - clearArchiveCache(test, refsHash); - const archiveFile = await createArchiveFixture(`degit-test-repo-${refsHash}`); - await cloneAndExpectTarContent( - test, - archiveFile, - dest, - 'packages/app/index.js', - 'export default 1\n', - ); - }); - }); - - providerCases.forEach((test) => { - it(`falls back to git clone when the tarball contains git-lfs pointers for ${test.site}`, async () => { - const dest = '.tmp/index-suite/test-repo'; - clearArchiveCache(test, refsHash); - const archiveFile = await createArchiveWithGitLfsPointerFixture( - `degit-test-repo-${refsHash}`, - ); - await cloneAndExpectGitFallback(test, archiveFile, dest); - }); - }); - - it('uses the git backend on Windows when mode is git', async () => { - const test = providerCases[0]; - const dest = '.tmp/index-suite/windows-git-mode'; - const gitMock = createMockGit({ - [`fetchRefs ${test.url}`]: gitRefs, - [`clone ${test.url} ${dest} ${refsHash}`]: '', - }); - const warnings: string[] = []; - - const emitter = degit(test.publicSrc, { - git: gitMock.fn, - mode: 'git', - platform: 'win32', - }); - - emitter.on('warn', (event) => warnings.push(event.message)); - + describe('tar mode HEAD fallback', () => { + providerCases.forEach((test) => { + it(`uses the default branch hash when HEAD is missing for ${test.site}`, async () => { + clearArchiveCache(test, refsHash); + const fetch = createCopyFetch( + await createArchiveFixture(`degit-test-repo-${refsHash}`), + ); + const gitMock = createMockGit({ + [`fetchRefs ${test.url}`]: branchRefs, + }); + + await degit(test.publicSrc, { + git: gitMock.fn, + fetch: fetch.fn, + }).clone('.tmp/index-suite/test-repo'); + + compareDirToExpected('.tmp/index-suite/test-repo', { + packages: '', + 'packages/app': '', + 'packages/app/index.js': 'export default 1\n', + 'packages/app/lib': '', + 'packages/app/lib/nested.txt': 'nested\n', + 'packages/ignored.txt': 'ignored\n', + }); + assert.equal(fetch.calls.length, 1); + assert.equal(fetch.calls[0].url, test.archiveUrl(refsHash)); + assert.deepEqual(gitMock.calls, [`fetchRefs ${test.url}`]); + }); + }); + + it('throws MISSING_REF when no refs are returned for HEAD', async () => { + const test = providerCases[1]; + const dest = '.tmp/index-suite/empty-refs'; + const gitMock = createMockGit({ + [`fetchRefs ${test.url}`]: [], + }); + + await assert.rejects( + async () => await degit(test.publicSrc, { git: gitMock.fn }).clone(dest), + (err: any) => err && err.code === 'MISSING_REF', + ); + }); + + it('uses the git backend for ssh sources when mode is git', async () => { + const dest = '.tmp/index-suite/ssh-git-mode'; + const gitMock = createMockGit({ + [`fetchRefs ssh://git@github.com/Rich-Harris/degit-test-repo`]: gitRefs, + [`clone ssh://git@github.com/Rich-Harris/degit-test-repo ${dest} ${refsHash}`]: '', + }); + + await degit('git@github.com:Rich-Harris/degit-test-repo', { + git: gitMock.fn, + mode: 'git', + }).clone(dest); + + assert.deepEqual(gitMock.calls, [ + 'fetchRefs ssh://git@github.com/Rich-Harris/degit-test-repo', + `clone ssh://git@github.com/Rich-Harris/degit-test-repo ${dest} ${refsHash}`, + ]); + }); + }); + + describe('tar mode extraction', () => { + providerCases.forEach((test) => { + it(`extracts a nested subdirectory when cloning a nested path for ${test.site}`, async () => { + const dest = '.tmp/index-suite/test-repo'; + clearArchiveCache(test, refsHash); + const archiveFile = await createArchiveFixture(`degit-test-repo-${refsHash}`); + const fetch = createCopyFetch(archiveFile); + const gitMock = createMockGit({ + [`fetchRefs ${test.url}`]: gitRefs, + }); + + await degit(`${test.publicSrc}/packages/app`, { + git: gitMock.fn, + fetch: fetch.fn, + }).clone(dest); + + compareDirToExpected(dest, { + 'index.js': 'export default 1\n', + lib: '', + 'lib/nested.txt': 'nested\n', + }); + assert.equal(fetch.calls[0].url, test.archiveUrl(refsHash)); + }); + }); + + providerCases.forEach((test) => { + it(`redownloads the tarball when the cached archive is corrupted for ${test.site}`, async () => { + const dest = '.tmp/index-suite/test-repo'; + const archiveDir = path.join(base, test.site, test.user, test.name); + clearArchiveCache(test, refsHash); + const archiveFile = await createArchiveFixture(`degit-test-repo-${refsHash}`); + fs.mkdirSync(archiveDir, { recursive: true }); + fs.writeFileSync(path.join(archiveDir, `${refsHash}.tar.gz`), 'not a tarball'); + const fetch = createCopyFetch(archiveFile); + const gitMock = createMockGit({ + [`fetchRefs ${test.url}`]: gitRefs, + }); + + await degit(test.publicSrc, { + git: gitMock.fn, + fetch: fetch.fn, + }).clone(dest); + + compareDirToExpected(dest, { + packages: '', + 'packages/app': '', + 'packages/app/index.js': 'export default 1\n', + 'packages/app/lib': '', + 'packages/app/lib/nested.txt': 'nested\n', + 'packages/ignored.txt': 'ignored\n', + }); + assert.equal(fetch.calls.length, 1); + assert.equal(fetch.calls[0].url, test.archiveUrl(refsHash)); + }); + }); + }); + + describe('explicit git mode', () => { + providerCases.forEach((test) => { + it(`uses the git backend immediately when mode is git for ${test.site}`, async () => { + const dest = '.tmp/index-suite/test-repo'; + const gitMock = createMockGit({ + [`fetchRefs ${test.url}`]: gitRefs, + [`clone ${test.url} ${dest} ${refsHash}`]: '', + }); + const warnings: string[] = []; + const emitter = degit(test.publicSrc, { + git: gitMock.fn, + mode: 'git', + }); + + emitter.on('warn', (event) => warnings.push(event.message)); + + await emitter.clone(dest); + + assert.deepEqual(warnings, []); + assert.deepEqual(gitMock.calls, [ + `fetchRefs ${test.url}`, + `clone ${test.url} ${dest} ${refsHash}`, + ]); + }); + }); + + providerCases.forEach((test) => { + it(`does not fall back when a file merely quotes a pointer snippet for ${test.site}`, async () => { + const dest = '.tmp/index-suite/test-repo'; + clearArchiveCache(test, refsHash); + const archiveFile = await createArchiveFixture(`degit-test-repo-${refsHash}`); + await cloneAndExpectTarContent( + test, + archiveFile, + dest, + 'packages/app/index.js', + 'export default 1\n', + ); + }); + }); + + providerCases.forEach((test) => { + it(`falls back to git clone when the tarball contains git-lfs pointers for ${test.site}`, async () => { + const dest = '.tmp/index-suite/test-repo'; + clearArchiveCache(test, refsHash); + const archiveFile = await createArchiveWithGitLfsPointerFixture( + `degit-test-repo-${refsHash}`, + ); + await cloneAndExpectGitFallback(test, archiveFile, dest); + }); + }); + + it('uses the git backend on Windows when mode is git', async () => { + const test = providerCases[0]; + const dest = '.tmp/index-suite/windows-git-mode'; + const gitMock = createMockGit({ + [`fetchRefs ${test.url}`]: gitRefs, + [`clone ${test.url} ${dest} ${refsHash}`]: '', + }); + const warnings: string[] = []; + + const emitter = degit(test.publicSrc, { + git: gitMock.fn, + mode: 'git', + platform: 'win32', + }); + + emitter.on('warn', (event) => warnings.push(event.message)); + + await emitter.clone(dest); + + assert.deepEqual(warnings, []); + assert.deepEqual(gitMock.calls, [ + `fetchRefs ${test.url}`, + `clone ${test.url} ${dest} ${refsHash}`, + ]); + }); + + it('rejects with DEST_NOT_EMPTY when destination has files and force is false', async () => { + fs.mkdirSync('.tmp/index-suite/ne', { recursive: true }); + fs.writeFileSync('.tmp/index-suite/ne/x', '1'); + await assert.rejects( + async () => await degit('Rich-Harris/degit-test-repo').clone('.tmp/index-suite/ne'), + (err: any) => err && err.code === 'DEST_NOT_EMPTY', + ); + }); + + it('throws when mode is not a supported value', () => { + assert.throws( + () => degit('Rich-Harris/degit-test-repo', { mode: 'svn' }), + /Valid modes are/, + ); + }); + + it('removes nested directories recursively from the destination', () => { + const dest = fs.mkdtempSync(path.join(process.cwd(), 'remove-')); + + try { + fs.mkdirSync(path.join(dest, 'nested', 'child'), { recursive: true }); + fs.writeFileSync(path.join(dest, 'nested', 'child', 'file.txt'), 'nested\n'); + fs.writeFileSync(path.join(dest, 'flat.txt'), 'flat\n'); + + const emitter = degit('Rich-Harris/degit-test-repo'); + emitter.remove(dest, { files: ['nested', 'flat.txt'] }); + + assert.equal(fs.existsSync(path.join(dest, 'nested')), false); + assert.equal(fs.existsSync(path.join(dest, 'flat.txt')), false); + } finally { + fs.rmSync(dest, { force: true, recursive: true }); + } + }); + + it('warns and skips paths that escape the destination when removing files', () => { + const workspace = fs.mkdtempSync(path.join(process.cwd(), 'remove-')); + const dest = path.join(workspace, 'dest'); + const sibling = path.join(workspace, 'sibling'); + const warnings: string[] = []; + + try { + fs.mkdirSync(dest, { recursive: true }); + fs.mkdirSync(sibling, { recursive: true }); + fs.writeFileSync(path.join(sibling, 'secret.txt'), 'secret\n'); + + const emitter = degit('Rich-Harris/degit-test-repo'); + emitter.on('warn', (event) => warnings.push(event.message)); + + emitter.remove(dest, { files: ['../sibling'] }); + + assert.equal(fs.existsSync(path.join(sibling, 'secret.txt')), true); + assert.equal(warnings.length, 1); + assert.match( + warnings[0], + /action wants to remove .*outside the destination, skipping/, + ); + assert.match(warnings[0], /\.\.\/sibling/); + } finally { + fs.rmSync(workspace, { force: true, recursive: true }); + } + }); + }); await emitter.clone(dest); assert.deepEqual(warnings, []); @@ -558,4 +560,3 @@ describe('degit index', () => { } }); }); -}); diff --git a/test/unit/lazy-git.test.ts b/test/unit/lazy-git.test.ts index 3136d75..4c5b26f 100644 --- a/test/unit/lazy-git.test.ts +++ b/test/unit/lazy-git.test.ts @@ -5,10 +5,10 @@ import assert from 'node:assert'; import { vi } from 'vitest'; let gitClientLoaded = false; -const fetchRefs = vi.fn(async () => [ +const fetchRefs = vi.fn(() => [ { hash: '0123456789abcdef0123456789abcdef0123456789', type: 'HEAD' }, ]); -const clone = vi.fn(async () => {}); +const clone = vi.fn(() => {}); vi.mock('../../src/git-client.js', () => { gitClientLoaded = true; diff --git a/test/unit/utils.test.ts b/test/unit/utils.test.ts index cb125bf..24e2b48 100644 --- a/test/unit/utils.test.ts +++ b/test/unit/utils.test.ts @@ -49,7 +49,9 @@ describe('resolveBase', () => { path.join('C:/Users/user/AppData/Local', 'degit'), ); }); +}); +describe('stashFiles and unstashFiles', () => { it('stashes and unstashes nested directories with degit.json excluded from restore', () => { const root = fs.mkdtempSync(path.join(process.cwd(), 'stash-')); const cacheDir = path.join(root, 'cache'); @@ -86,7 +88,9 @@ describe('resolveBase', () => { fs.rmSync(root, { force: true, recursive: true }); } }); +}); +describe('fetch', () => { it('resumes redirect responses when following a redirected archive fetch', async () => { const createWriteStreamSpy = vi.spyOn(fs, 'createWriteStream').mockReturnValue({ on(event: string, handler: () => void) {