diff --git a/src/cli/actions/remoteAction.ts b/src/cli/actions/remoteAction.ts index fe29fc95d..5dd1e0362 100644 --- a/src/cli/actions/remoteAction.ts +++ b/src/cli/actions/remoteAction.ts @@ -73,7 +73,7 @@ export const createTempDirectory = async (): Promise => { export const cloneRepository = async ( url: string, directory: string, - branch?: string, + remoteBranch?: string, deps = { execGitShallowClone, }, @@ -82,7 +82,7 @@ export const cloneRepository = async ( logger.log(''); try { - await deps.execGitShallowClone(url, directory, branch); + await deps.execGitShallowClone(url, directory, remoteBranch); } catch (error) { throw new RepomixError(`Failed to clone repository: ${(error as Error).message}`); } diff --git a/src/core/file/gitCommand.ts b/src/core/file/gitCommand.ts index b8801ee7b..aeeda1279 100644 --- a/src/core/file/gitCommand.ts +++ b/src/core/file/gitCommand.ts @@ -1,4 +1,6 @@ import { exec } from 'node:child_process'; +import fs from 'node:fs/promises'; +import path from 'node:path'; import { promisify } from 'node:util'; import { logger } from '../../shared/logger.js'; @@ -21,10 +23,46 @@ export const isGitInstalled = async ( export const execGitShallowClone = async ( url: string, directory: string, - branch?: string, + remoteBranch?: string, deps = { execAsync, }, ) => { - await deps.execAsync(`git clone --depth 1 ${branch ? `-b ${branch} ` : ''}${url} ${directory}`); + if (remoteBranch) { + await deps.execAsync(`git -C ${directory} init`); + await deps.execAsync(`git -C ${directory} remote add origin ${url}`); + try { + await deps.execAsync(`git -C ${directory} fetch --depth 1 origin ${remoteBranch}`); + await deps.execAsync(`git -C ${directory} checkout FETCH_HEAD`); + } catch (err: unknown) { + // git fetch --depth 1 origin always throws "couldn't find remote ref" error + const isRefNotfoundError = + err instanceof Error && err.message.includes(`couldn't find remote ref ${remoteBranch}`); + + if (!isRefNotfoundError) { + // Rethrow error as nothing else we can do + throw err; + } + + // Short SHA detection - matches a hexadecimal string of 4 to 39 characters + // If the string matches this regex, it MIGHT be a short SHA + // If the string doesn't match, it is DEFINITELY NOT a short SHA + const isNotShortSHA = !remoteBranch.match(/^[0-9a-f]{4,39}$/i); + + if (isNotShortSHA) { + // Rethrow error as nothing else we can do + throw err; + } + + // Maybe the error is due to a short SHA, let's try again + // Can't use --depth 1 here as we need to fetch the specific commit + await deps.execAsync(`git -C ${directory} fetch origin`); + await deps.execAsync(`git -C ${directory} checkout ${remoteBranch}`); + } + } else { + await deps.execAsync(`git clone --depth 1 ${url} ${directory}`); + } + + // Clean up .git directory + await fs.rm(path.join(directory, '.git'), { recursive: true, force: true }); }; diff --git a/tests/core/file/gitCommand.test.ts b/tests/core/file/gitCommand.test.ts index 1864390bd..899924af3 100644 --- a/tests/core/file/gitCommand.test.ts +++ b/tests/core/file/gitCommand.test.ts @@ -40,39 +40,109 @@ describe('gitCommand', () => { }); describe('execGitShallowClone', () => { - test('should execute git clone with correct parameters', async () => { + test('should execute without branch option if not specified by user', async () => { const mockExecAsync = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }); const url = 'https://github.com/user/repo.git'; const directory = '/tmp/repo'; - const branch = 'master'; + const remoteBranch = undefined; - await execGitShallowClone(url, directory, branch, { execAsync: mockExecAsync }); + await execGitShallowClone(url, directory, remoteBranch, { execAsync: mockExecAsync }); - expect(mockExecAsync).toHaveBeenCalledWith(`git clone --depth 1 -b ${branch} ${url} ${directory}`); + expect(mockExecAsync).toHaveBeenCalledWith(`git clone --depth 1 ${url} ${directory}`); }); - test('should throw error when git clone fails', async () => { const mockExecAsync = vi.fn().mockRejectedValue(new Error('Authentication failed')); const url = 'https://github.com/user/repo.git'; const directory = '/tmp/repo'; - const branch = 'master'; + const remoteBranch = undefined; - await expect(execGitShallowClone(url, directory, branch, { execAsync: mockExecAsync })).rejects.toThrow( + await expect(execGitShallowClone(url, directory, remoteBranch, { execAsync: mockExecAsync })).rejects.toThrow( 'Authentication failed', ); - expect(mockExecAsync).toHaveBeenCalledWith(`git clone --depth 1 -b ${branch} ${url} ${directory}`); + expect(mockExecAsync).toHaveBeenCalledWith(`git clone --depth 1 ${url} ${directory}`); }); - - test('should execute without branch option if not specified by user', async () => { + test('should execute commands correctly when branch is specified', async () => { const mockExecAsync = vi.fn().mockResolvedValue({ stdout: '', stderr: '' }); + const url = 'https://github.com/user/repo.git'; const directory = '/tmp/repo'; - const branch = undefined; + const remoteBranch = 'main'; - await execGitShallowClone(url, directory, branch, { execAsync: mockExecAsync }); + await execGitShallowClone(url, directory, remoteBranch, { execAsync: mockExecAsync }); - expect(mockExecAsync).toHaveBeenCalledWith(`git clone --depth 1 ${url} ${directory}`); + expect(mockExecAsync).toHaveBeenCalledTimes(4); + expect(mockExecAsync).toHaveBeenNthCalledWith(1, `git -C ${directory} init`); + expect(mockExecAsync).toHaveBeenNthCalledWith(2, `git -C ${directory} remote add origin ${url}`); + expect(mockExecAsync).toHaveBeenNthCalledWith(3, `git -C ${directory} fetch --depth 1 origin ${remoteBranch}`); + expect(mockExecAsync).toHaveBeenNthCalledWith(4, `git -C ${directory} checkout FETCH_HEAD`); + }); + + test('should throw error when git fetch fails', async () => { + const mockExecAsync = vi + .fn() + .mockResolvedValueOnce('Success on first call') + .mockResolvedValueOnce('Success on second call') + .mockRejectedValueOnce(new Error('Authentication failed')); + + const url = 'https://github.com/user/repo.git'; + const directory = '/tmp/repo'; + const remoteBranch = 'b188a6cb39b512a9c6da7235b880af42c78ccd0d'; + + await expect(execGitShallowClone(url, directory, remoteBranch, { execAsync: mockExecAsync })).rejects.toThrow( + 'Authentication failed', + ); + expect(mockExecAsync).toHaveBeenCalledTimes(3); + expect(mockExecAsync).toHaveBeenNthCalledWith(1, `git -C ${directory} init`); + expect(mockExecAsync).toHaveBeenNthCalledWith(2, `git -C ${directory} remote add origin ${url}`); + expect(mockExecAsync).toHaveBeenLastCalledWith(`git -C ${directory} fetch --depth 1 origin ${remoteBranch}`); + // The fourth call (e.g., git checkout) won't be made due to the error on the third call + }); + + test('should handle short SHA correctly', async () => { + const url = 'https://github.com/user/repo.git'; + const directory = '/tmp/repo'; + const shortSha = 'ce9b621'; + const mockExecAsync = vi + .fn() + .mockResolvedValueOnce('Success on first call') + .mockResolvedValueOnce('Success on second call') + .mockRejectedValueOnce( + new Error( + `Command failed: git fetch --depth 1 origin ${shortSha}\nfatal: couldn't find remote ref ${shortSha}`, + ), + ); + + await execGitShallowClone(url, directory, shortSha, { execAsync: mockExecAsync }); + + expect(mockExecAsync).toHaveBeenCalledTimes(5); + expect(mockExecAsync).toHaveBeenNthCalledWith(1, `git -C ${directory} init`); + expect(mockExecAsync).toHaveBeenNthCalledWith(2, `git -C ${directory} remote add origin ${url}`); + expect(mockExecAsync).toHaveBeenNthCalledWith(3, `git -C ${directory} fetch --depth 1 origin ${shortSha}`); + expect(mockExecAsync).toHaveBeenNthCalledWith(4, `git -C ${directory} fetch origin`); + expect(mockExecAsync).toHaveBeenLastCalledWith(`git -C ${directory} checkout ${shortSha}`); + }); + + test("should throw error when remote ref is not found, and it's not due to short SHA", async () => { + const url = 'https://github.com/user/repo.git'; + const directory = '/tmp/repo'; + const remoteBranch = 'b188a6cb39b512a9c6da7235b880af42c78ccd0d'; + const errMessage = `Command failed: git fetch --depth 1 origin ${remoteBranch}\nfatal: couldn't find remote ref ${remoteBranch}`; + + const mockExecAsync = vi + .fn() + .mockResolvedValueOnce('Success on first call') + .mockResolvedValueOnce('Success on second call') + .mockRejectedValueOnce(new Error(errMessage)); + + await expect(execGitShallowClone(url, directory, remoteBranch, { execAsync: mockExecAsync })).rejects.toThrow( + errMessage, + ); + expect(mockExecAsync).toHaveBeenCalledTimes(3); + expect(mockExecAsync).toHaveBeenNthCalledWith(1, `git -C ${directory} init`); + expect(mockExecAsync).toHaveBeenNthCalledWith(2, `git -C ${directory} remote add origin ${url}`); + expect(mockExecAsync).toHaveBeenLastCalledWith(`git -C ${directory} fetch --depth 1 origin ${remoteBranch}`); + // The fourth call (e.g., git checkout) won't be made due to the error on the third call }); }); });