diff --git a/src/cli/actions/remoteAction.ts b/src/cli/actions/remoteAction.ts index 1831251a4..46617ff25 100644 --- a/src/cli/actions/remoteAction.ts +++ b/src/cli/actions/remoteAction.ts @@ -2,7 +2,7 @@ import * as fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import pc from 'picocolors'; -import { execGitShallowClone, isGitInstalled } from '../../core/git/gitCommand.js'; +import { execGitShallowClone, getRemoteRefs, isGitInstalled } from '../../core/git/gitCommand.js'; import { parseRemoteValue } from '../../core/git/gitRemoteParse.js'; import { RepomixError } from '../../shared/errorHandle.js'; import { logger } from '../../shared/logger.js'; @@ -16,6 +16,7 @@ export const runRemoteAction = async ( deps = { isGitInstalled, execGitShallowClone, + getRemoteRefs, runDefaultAction, }, ): Promise => { @@ -23,7 +24,18 @@ export const runRemoteAction = async ( throw new RepomixError('Git is not installed or not in the system PATH.'); } - const parsedFields = parseRemoteValue(repoUrl); + // Get remote refs + let refs: string[] = []; + try { + refs = await deps.getRemoteRefs(parseRemoteValue(repoUrl).repoUrl); + logger.trace(`Retrieved ${refs.length} refs from remote repository`); + } catch (error) { + logger.trace('Failed to get remote refs, proceeding without them:', (error as Error).message); + } + + // Parse the remote URL with the refs information + const parsedFields = parseRemoteValue(repoUrl, refs); + const spinner = new Spinner('Cloning repository...', cliOptions); const tempDirPath = await createTempDirectory(); let result: DefaultActionRunnerResult; diff --git a/src/core/git/gitCommand.ts b/src/core/git/gitCommand.ts index b12d80c6c..717f7608f 100644 --- a/src/core/git/gitCommand.ts +++ b/src/core/git/gitCommand.ts @@ -120,24 +120,49 @@ export const isGitInstalled = async ( } }; -export const execGitShallowClone = async ( +export const getRemoteRefs = async ( url: string, - directory: string, - remoteBranch?: string, deps = { execFileAsync, }, -) => { - if (url.includes('--upload-pack') || url.includes('--config') || url.includes('--exec')) { - throw new RepomixError(`Invalid repository URL. URL contains potentially dangerous parameters: ${url}`); - } +): Promise => { + validateGitUrl(url); - // Check if the URL is valid try { - new URL(url); + const result = await deps.execFileAsync('git', ['ls-remote', '--heads', '--tags', url]); + + // Extract ref names from the output + // Format is: hash\tref_name + const refs = result.stdout + .split('\n') + .filter(Boolean) + .map((line) => { + // Skip the hash part and extract only the ref name + const parts = line.split('\t'); + if (parts.length < 2) return ''; + + // Remove 'refs/heads/' or 'refs/tags/' prefix + return parts[1].replace(/^refs\/(heads|tags)\//, ''); + }) + .filter(Boolean); + + logger.trace(`Found ${refs.length} refs in repository: ${url}`); + return refs; } catch (error) { - throw new RepomixError(`Invalid repository URL. Please provide a valid URL. url: ${url}`); + logger.trace('Failed to get remote refs:', (error as Error).message); + throw new RepomixError(`Failed to get remote refs: ${(error as Error).message}`); } +}; + +export const execGitShallowClone = async ( + url: string, + directory: string, + remoteBranch?: string, + deps = { + execFileAsync, + }, +) => { + validateGitUrl(url); if (remoteBranch) { await deps.execFileAsync('git', ['-C', directory, 'init']); @@ -177,3 +202,26 @@ export const execGitShallowClone = async ( // Clean up .git directory await fs.rm(path.join(directory, '.git'), { recursive: true, force: true }); }; + +/** + * Validates a Git URL for security and format + * @throws {RepomixError} If the URL is invalid or contains potentially dangerous parameters + */ +const validateGitUrl = (url: string): void => { + if (url.includes('--upload-pack') || url.includes('--config') || url.includes('--exec')) { + throw new RepomixError(`Invalid repository URL. URL contains potentially dangerous parameters: ${url}`); + } + + // Check if the URL starts with git@ or https:// + if (!(url.startsWith('git@') || url.startsWith('https://'))) { + throw new RepomixError(`Invalid URL protocol for '${url}'. URL must start with 'git@' or 'https://'`); + } + + try { + if (url.startsWith('https://')) { + new URL(url); + } + } catch (error) { + throw new RepomixError(`Invalid repository URL. Please provide a valid URL: ${url}`); + } +}; diff --git a/src/core/git/gitRemoteParse.ts b/src/core/git/gitRemoteParse.ts index 7b00299cd..61aa5c93b 100644 --- a/src/core/git/gitRemoteParse.ts +++ b/src/core/git/gitRemoteParse.ts @@ -13,7 +13,10 @@ export const isValidShorthand = (remoteValue: string): boolean => { return validShorthandRegex.test(remoteValue); }; -export const parseRemoteValue = (remoteValue: string): { repoUrl: string; remoteBranch: string | undefined } => { +export const parseRemoteValue = ( + remoteValue: string, + refs: string[] = [], +): { repoUrl: string; remoteBranch: string | undefined } => { if (isValidShorthand(remoteValue)) { logger.trace(`Formatting GitHub shorthand: ${remoteValue}`); return { @@ -23,7 +26,7 @@ export const parseRemoteValue = (remoteValue: string): { repoUrl: string; remote } try { - const parsedFields = gitUrlParse(remoteValue) as IGitUrl; + const parsedFields = gitUrlParse(remoteValue, refs) as IGitUrl; // This will make parsedFields.toString() automatically append '.git' to the returned url parsedFields.git_suffix = true; @@ -40,7 +43,7 @@ export const parseRemoteValue = (remoteValue: string): { repoUrl: string; remote if (parsedFields.ref) { return { repoUrl: repoUrl, - remoteBranch: parsedFields.filepath ? `${parsedFields.ref}/${parsedFields.filepath}` : parsedFields.ref, + remoteBranch: parsedFields.ref, }; } @@ -60,9 +63,9 @@ export const parseRemoteValue = (remoteValue: string): { repoUrl: string; remote } }; -export const isValidRemoteValue = (remoteValue: string): boolean => { +export const isValidRemoteValue = (remoteValue: string, refs: string[] = []): boolean => { try { - parseRemoteValue(remoteValue); + parseRemoteValue(remoteValue, refs); return true; } catch (error) { return false; diff --git a/src/types/git-url-parse.d.ts b/src/types/git-url-parse.d.ts new file mode 100644 index 000000000..75e5598d7 --- /dev/null +++ b/src/types/git-url-parse.d.ts @@ -0,0 +1,55 @@ +/** + * Type definition extension for git-url-parse library + * + * This file exists because the git-url-parse library's built-in type definitions + * are incomplete. The library supports a second 'refs' parameter for the gitUrlParse + * function, which is documented in the library's README but not included in its + * type definitions. + * + * Without this type definition extension, we would need to use @ts-ignore when + * calling gitUrlParse with the refs parameter, which reduces type safety and + * makes the code harder to maintain. + * + * This file uses TypeScript's module augmentation feature to extend the existing + * type definitions without modifying the original library code. + */ + +declare module 'git-url-parse' { + import gitUp = require('git-up'); + + namespace gitUrlParse { + interface GitUrl extends gitUp.ParsedUrl { + /** The Git provider (e.g. `"github.com"`). */ + source: string; + /** The repository owner. */ + owner: string; + /** The repository name. */ + name: string; + /** The repository ref (e.g., "master" or "dev"). */ + ref: string; + /** A filepath relative to the repository root. */ + filepath: string; + /** The type of filepath in the url ("blob" or "tree"). */ + filepathtype: string; + /** The owner and name values in the `owner/name` format. */ + full_name: string; + /** The organization the owner belongs to. This is CloudForge specific. */ + organization: string; + /** Whether to add the `.git` suffix or not. */ + git_suffix?: boolean | undefined; + toString(type?: string): string; + } + + function stringify(url: GitUrl, type?: string): string; + } + + /** + * Parses a Git url. + * @param url The Git url to parse. + * @param refs An array of strings representing the refs. This is helpful for URLs with branches containing slashes. + * @returns The GitUrl object containing parsed information. + */ + function gitUrlParse(url: string, refs?: string[]): gitUrlParse.GitUrl; + + export = gitUrlParse; +} diff --git a/tests/cli/actions/remoteAction.test.ts b/tests/cli/actions/remoteAction.test.ts index fb1db488d..6109ffa5d 100644 --- a/tests/cli/actions/remoteAction.test.ts +++ b/tests/cli/actions/remoteAction.test.ts @@ -32,6 +32,7 @@ describe('remoteAction functions', () => { execGitShallowClone: async (url: string, directory: string) => { await fs.writeFile(path.join(directory, 'README.md'), 'Hello, world!'); }, + getRemoteRefs: async () => Promise.resolve(['main']), runDefaultAction: async () => { return { packResult: { diff --git a/tests/core/git/gitCommand.test.ts b/tests/core/git/gitCommand.test.ts index 0e4a06c98..a349315d4 100644 --- a/tests/core/git/gitCommand.test.ts +++ b/tests/core/git/gitCommand.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { execGitShallowClone, getFileChangeCount, + getRemoteRefs, getWorkTreeDiff, isGitInstalled, isGitRepository, @@ -333,4 +334,62 @@ file2.ts expect(mockFileExecAsync).not.toHaveBeenCalled(); }); + + describe('getRemoteRefs', () => { + test('should return refs when URL is valid', async () => { + const mockOutput = ` +a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6\trefs/heads/main +b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7\trefs/heads/develop +c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8\trefs/tags/v1.0.0 +`.trim(); + const mockFileExecAsync = vi.fn().mockResolvedValue({ stdout: mockOutput }); + + const result = await getRemoteRefs('https://github.com/user/repo.git', { execFileAsync: mockFileExecAsync }); + + expect(result).toEqual(['main', 'develop', 'v1.0.0']); + expect(mockFileExecAsync).toHaveBeenCalledWith('git', [ + 'ls-remote', + '--heads', + '--tags', + 'https://github.com/user/repo.git', + ]); + }); + + test('should throw error when URL does not start with git@ or https://', async () => { + const mockFileExecAsync = vi.fn(); + + await expect(getRemoteRefs('invalid-url', { execFileAsync: mockFileExecAsync })).rejects.toThrow( + "Invalid URL protocol for 'invalid-url'. URL must start with 'git@' or 'https://'", + ); + + expect(mockFileExecAsync).not.toHaveBeenCalled(); + }); + + test('should throw error when URL contains dangerous parameters', async () => { + const mockFileExecAsync = vi.fn(); + + await expect( + getRemoteRefs('https://github.com/user/repo.git --upload-pack=evil-command', { + execFileAsync: mockFileExecAsync, + }), + ).rejects.toThrow('Invalid repository URL. URL contains potentially dangerous parameters'); + + expect(mockFileExecAsync).not.toHaveBeenCalled(); + }); + + test('should throw error when git command fails', async () => { + const mockFileExecAsync = vi.fn().mockRejectedValue(new Error('git command failed')); + + await expect( + getRemoteRefs('https://github.com/user/repo.git', { execFileAsync: mockFileExecAsync }), + ).rejects.toThrow('Failed to get remote refs: git command failed'); + + expect(mockFileExecAsync).toHaveBeenCalledWith('git', [ + 'ls-remote', + '--heads', + '--tags', + 'https://github.com/user/repo.git', + ]); + }); + }); }); diff --git a/tests/core/git/gitRemoteParse.test.ts b/tests/core/git/gitRemoteParse.test.ts index d4d61f24a..6c710f06f 100644 --- a/tests/core/git/gitRemoteParse.test.ts +++ b/tests/core/git/gitRemoteParse.test.ts @@ -59,7 +59,9 @@ describe('remoteAction functions', () => { remoteBranch: 'branchname', }); expect( - parseRemoteValue('https://some.gitlab.domain/some/path/username/repo/-/tree/branchname/withslash'), + parseRemoteValue('https://some.gitlab.domain/some/path/username/repo/-/tree/branchname/withslash', [ + 'branchname/withslash', + ]), ).toEqual({ repoUrl: 'https://some.gitlab.domain/some/path/username/repo.git', remoteBranch: 'branchname/withslash',