Skip to content
4 changes: 2 additions & 2 deletions src/cli/actions/remoteAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export const createTempDirectory = async (): Promise<string> => {
export const cloneRepository = async (
url: string,
directory: string,
branch?: string,
remoteBranch?: string,
deps = {
execGitShallowClone,
},
Expand All @@ -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}`);
}
Expand Down
42 changes: 40 additions & 2 deletions src/core/file/gitCommand.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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`);
Comment thread
yamadashy marked this conversation as resolved.
} catch (err: unknown) {
// git fetch --depth 1 origin <short SHA> 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);
Comment thread
yamadashy marked this conversation as resolved.

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 });
};
96 changes: 83 additions & 13 deletions tests/core/file/gitCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
});
});
});