diff --git a/src/__tests__/clone.test.ts b/src/__tests__/clone.test.ts index 24cda5f..77e8f92 100644 --- a/src/__tests__/clone.test.ts +++ b/src/__tests__/clone.test.ts @@ -20,11 +20,13 @@ vi.mock('node:fs', () => ({ mkdtempSync: vi.fn(), writeFileSync: vi.fn(), existsSync: vi.fn(), + rmSync: vi.fn(), }, mkdirSync: vi.fn(), mkdtempSync: vi.fn(), writeFileSync: vi.fn(), existsSync: vi.fn(), + rmSync: vi.fn(), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ @@ -817,3 +819,130 @@ describe('autoFetch: true — fetch, rev-parse origin/, reset --hard', ( expect(resetCalls[0]).toEqual(['reset', '--hard', 'abc123def456']); }); }); + +describe('shallow clone fallback', () => { + function setupShallowCloneMock(options: { + shallowError: boolean; + otherError?: string; + }): { cloneCalls: string[][] } { + const cloneCalls: string[][] = []; + + mockExecFileSync.mockImplementation((_cmd, args) => { + const argsArr = args as string[]; + + // git rev-parse --abbrev-ref HEAD + if (argsArr[0] === 'rev-parse' && argsArr[1] === '--abbrev-ref' && argsArr[2] === 'HEAD') { + return 'main\n'; + } + + // git clone + if (argsArr[0] === 'clone') { + cloneCalls.push([...argsArr]); + const hasReference = argsArr.includes('--reference'); + + if (hasReference && options.shallowError) { + const err = new Error('clone failed'); + (err as unknown as { stderr: Buffer }).stderr = Buffer.from('fatal: reference repository is shallow'); + throw err; + } + + if (hasReference && options.otherError) { + const err = new Error('clone failed'); + (err as unknown as { stderr: Buffer }).stderr = Buffer.from(options.otherError); + throw err; + } + + return Buffer.from(''); + } + + // git remote remove origin + if (argsArr[0] === 'remote' && argsArr[1] === 'remove') { + return Buffer.from(''); + } + + // git config --local (reading from source repo) + if (argsArr[0] === 'config' && argsArr[1] === '--local') { + throw new Error('not set'); + } + + // git config (writing to clone) + if (argsArr[0] === 'config') { + return Buffer.from(''); + } + + // git rev-parse --verify (branchExists) + if (argsArr[0] === 'rev-parse' && argsArr[1] === '--verify') { + throw new Error('branch not found'); + } + + // git checkout -b + if (argsArr[0] === 'checkout') { + return Buffer.from(''); + } + + return Buffer.from(''); + }); + + return { cloneCalls }; + } + + it('should fall back to clone without --reference when reference repository is shallow', () => { + const { cloneCalls } = setupShallowCloneMock({ shallowError: true }); + + createSharedClone('/project', { + worktree: '/tmp/shallow-test', + taskSlug: 'shallow-fallback', + }); + + // Two clone attempts: first with --reference, then without + expect(cloneCalls).toHaveLength(2); + + // First attempt includes --reference and --dissociate + expect(cloneCalls[0]).toContain('--reference'); + expect(cloneCalls[0]).toContain('--dissociate'); + + // Second attempt (fallback) does not include --reference or --dissociate + expect(cloneCalls[1]).not.toContain('--reference'); + expect(cloneCalls[1]).not.toContain('--dissociate'); + + // Both attempts target the same clone path + expect(cloneCalls[0][cloneCalls[0].length - 1]).toBe('/tmp/shallow-test'); + expect(cloneCalls[1][cloneCalls[1].length - 1]).toBe('/tmp/shallow-test'); + + // Fallback was logged + expect(mockLogInfo).toHaveBeenCalledWith( + 'Reference repository is shallow, retrying clone without --reference', + expect.objectContaining({ referenceRepo: expect.any(String) }), + ); + }); + + it('should not fall back on non-shallow clone errors', () => { + setupShallowCloneMock({ + shallowError: false, + otherError: 'fatal: repository does not exist', + }); + + expect(() => { + createSharedClone('/project', { + worktree: '/tmp/other-error-test', + taskSlug: 'other-error', + }); + }).toThrow('clone failed'); + }); + + it('should attempt --reference --dissociate clone first', () => { + const { cloneCalls } = setupShallowCloneMock({ shallowError: false }); + + createSharedClone('/project', { + worktree: '/tmp/reference-first-test', + taskSlug: 'reference-first', + }); + + // Only one clone call (successful on first attempt) + expect(cloneCalls).toHaveLength(1); + + // First (and only) attempt includes --reference and --dissociate + expect(cloneCalls[0]).toContain('--reference'); + expect(cloneCalls[0]).toContain('--dissociate'); + }); +}); diff --git a/src/infra/task/clone.ts b/src/infra/task/clone.ts index b26c9da..a46c900 100644 --- a/src/infra/task/clone.ts +++ b/src/infra/task/clone.ts @@ -230,17 +230,33 @@ export class CloneManager { fs.mkdirSync(path.dirname(clonePath), { recursive: true }); - const cloneArgs = ['clone', '--reference', referenceRepo, '--dissociate']; - cloneArgs.push(...cloneSubmoduleOptions.args); + const commonArgs: string[] = [...cloneSubmoduleOptions.args]; if (branch) { - cloneArgs.push('--branch', branch); + commonArgs.push('--branch', branch); } - cloneArgs.push(projectDir, clonePath); + commonArgs.push(projectDir, clonePath); - execFileSync('git', cloneArgs, { - cwd: projectDir, - stdio: 'pipe', - }); + const referenceCloneArgs = ['clone', '--reference', referenceRepo, '--dissociate', ...commonArgs]; + const fallbackCloneArgs = ['clone', ...commonArgs]; + + try { + execFileSync('git', referenceCloneArgs, { + cwd: projectDir, + stdio: 'pipe', + }); + } catch (err) { + const stderr = ((err as { stderr?: Buffer }).stderr ?? Buffer.alloc(0)).toString(); + if (stderr.includes('reference repository is shallow')) { + log.info('Reference repository is shallow, retrying clone without --reference', { referenceRepo }); + try { fs.rmSync(clonePath, { recursive: true, force: true }); } catch (e) { log.debug('Failed to cleanup partial clone before retry', { clonePath, error: String(e) }); } + execFileSync('git', fallbackCloneArgs, { + cwd: projectDir, + stdio: 'pipe', + }); + } else { + throw err; + } + } execFileSync('git', ['remote', 'remove', 'origin'], { cwd: clonePath,