fix: fallback to normal clone when reference repository is shallow (#376) (#409)

In shallow clone environments (e.g., DevContainer), `git clone --reference`
fails because Git cannot use a shallow repository as a reference. Add fallback
logic that detects the "reference repository is shallow" error and retries
without `--reference --dissociate`.

Co-authored-by: kimura <2023lmi-student009@la-study.com>
This commit is contained in:
souki-kimura 2026-02-28 13:01:18 +09:00 committed by GitHub
parent e77cb50ac1
commit ac4cb9c8a5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 153 additions and 8 deletions

View File

@ -20,11 +20,13 @@ vi.mock('node:fs', () => ({
mkdtempSync: vi.fn(), mkdtempSync: vi.fn(),
writeFileSync: vi.fn(), writeFileSync: vi.fn(),
existsSync: vi.fn(), existsSync: vi.fn(),
rmSync: vi.fn(),
}, },
mkdirSync: vi.fn(), mkdirSync: vi.fn(),
mkdtempSync: vi.fn(), mkdtempSync: vi.fn(),
writeFileSync: vi.fn(), writeFileSync: vi.fn(),
existsSync: vi.fn(), existsSync: vi.fn(),
rmSync: vi.fn(),
})); }));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
@ -817,3 +819,130 @@ describe('autoFetch: true — fetch, rev-parse origin/<branch>, reset --hard', (
expect(resetCalls[0]).toEqual(['reset', '--hard', 'abc123def456']); 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 <key> <value> (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');
});
});

View File

@ -230,17 +230,33 @@ export class CloneManager {
fs.mkdirSync(path.dirname(clonePath), { recursive: true }); fs.mkdirSync(path.dirname(clonePath), { recursive: true });
const cloneArgs = ['clone', '--reference', referenceRepo, '--dissociate']; const commonArgs: string[] = [...cloneSubmoduleOptions.args];
cloneArgs.push(...cloneSubmoduleOptions.args);
if (branch) { if (branch) {
cloneArgs.push('--branch', branch); commonArgs.push('--branch', branch);
} }
cloneArgs.push(projectDir, clonePath); commonArgs.push(projectDir, clonePath);
execFileSync('git', cloneArgs, { const referenceCloneArgs = ['clone', '--reference', referenceRepo, '--dissociate', ...commonArgs];
const fallbackCloneArgs = ['clone', ...commonArgs];
try {
execFileSync('git', referenceCloneArgs, {
cwd: projectDir, cwd: projectDir,
stdio: 'pipe', 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'], { execFileSync('git', ['remote', 'remove', 'origin'], {
cwd: clonePath, cwd: clonePath,