Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"clipboardy": "^4.0.0",
"commander": "^13.1.0",
"fast-xml-parser": "^4.5.1",
"git-url-parse": "^16.0.0",
"globby": "^14.0.2",
"handlebars": "^4.7.8",
"iconv-lite": "^0.6.3",
Expand All @@ -85,6 +86,7 @@
},
"devDependencies": {
"@biomejs/biome": "^1.9.4",
"@types/git-url-parse": "^9.0.3",
"@types/node": "^22.13.0",
"@types/strip-comments": "^2.0.4",
"@vitest/coverage-v8": "^3.0.5",
Expand Down
102 changes: 68 additions & 34 deletions src/cli/actions/remoteAction.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
import * as fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import GitUrlParse, { type GitUrl } from 'git-url-parse';
import pc from 'picocolors';
import { execGitShallowClone, isGitInstalled } from '../../core/file/gitCommand.js';
import { RepomixError } from '../../shared/errorHandle.js';
import { logger } from '../../shared/logger.js';
import type { CliOptions } from '../cliRun.js';
import Spinner from '../cliSpinner.js';
import { type DefaultActionRunnerResult, runDefaultAction } from './defaultAction.js';

// Check the short form of the GitHub URL. e.g. yamadashy/repomix
const remoteNamePattern = '[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?';
const remoteNamePatternRegex = new RegExp(`^${remoteNamePattern}/${remoteNamePattern}$`);

interface CorrectedGitUrl extends GitUrl {
commit: string | undefined;
}
export const runRemoteAction = async (
repoUrl: string,
options: CliOptions,
Expand All @@ -26,8 +25,10 @@ export const runRemoteAction = async (
throw new RepomixError('Git is not installed or not in the system PATH.');
}

if (!isValidRemoteValue(repoUrl)) {
throw new RepomixError('Invalid repository URL or user/repo format');
const parsedFields = parseRemoteValue(repoUrl);

if (options.remoteBranch === undefined) {
options.remoteBranch = parsedFields.remoteBranch;
}

const spinner = new Spinner('Cloning repository...');
Expand All @@ -39,7 +40,7 @@ export const runRemoteAction = async (
spinner.start();

// Clone the repository
await cloneRepository(formatRemoteValueToUrl(repoUrl), tempDirPath, options.remoteBranch, {
await cloneRepository(parsedFields.repoUrl, tempDirPath, options.remoteBranch, {
execGitShallowClone: deps.execGitShallowClone,
});

Expand All @@ -60,34 +61,67 @@ export const runRemoteAction = async (
return result;
};

export function isValidRemoteValue(remoteValue: string): boolean {
if (remoteNamePatternRegex.test(remoteValue)) {
return true;
// Check the short form of the GitHub URL. e.g. yamadashy/repomix
const VALID_NAME_PATTERN = '[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?';
const validShorthandRegex = new RegExp(`^${VALID_NAME_PATTERN}/${VALID_NAME_PATTERN}$`);
export const isValidShorthand = (remoteValue: string): boolean => {
return validShorthandRegex.test(remoteValue);
};

export const parseRemoteValue = (remoteValue: string): { repoUrl: string; remoteBranch: string | undefined } => {
if (isValidShorthand(remoteValue)) {
logger.trace(`Formatting GitHub shorthand: ${remoteValue}`);
return {
repoUrl: `https://github.com/${remoteValue}.git`,
remoteBranch: undefined,
};
}

// Check the direct form of the GitHub URL. e.g. https://github.com/yamadashy/repomix or https://gist.github.com/yamadashy/1234567890abcdef
try {
new URL(remoteValue);
return true;
const parsedFields = GitUrlParse(remoteValue);

// This will make parsedFields.toString() automatically append '.git' to the returned url
parsedFields.git_suffix = true;

const ownerSlashRepo =
parsedFields.full_name.split('/').length > 1 ? parsedFields.full_name.split('/').slice(-2).join('/') : '';

if (ownerSlashRepo !== '' && !isValidShorthand(ownerSlashRepo)) {
throw new RepomixError('Invalid owner/repo in repo URL');
}

const repoUrl = parsedFields.toString(parsedFields.protocol);

if (parsedFields.ref) {
return {
repoUrl: repoUrl,
remoteBranch: parsedFields.ref,
};
}

if ((parsedFields as CorrectedGitUrl).commit) {
return {
repoUrl: repoUrl,
remoteBranch: (parsedFields as CorrectedGitUrl).commit,
};
}

return {
repoUrl: repoUrl,
remoteBranch: undefined,
};
} catch (error) {
return false;
}
}

export const formatRemoteValueToUrl = (url: string): string => {
// If the URL is in the format owner/repo, convert it to a GitHub URL
if (remoteNamePatternRegex.test(url)) {
logger.trace(`Formatting GitHub shorthand: ${url}`);
return `https://github.com/${url}.git`;
throw new RepomixError('Invalid remote repository URL or repository shorthand (owner/repo)');
}
};
Comment thread
yamadashy marked this conversation as resolved.

// Add .git to HTTPS URLs if missing
if (url.startsWith('https://') && !url.endsWith('.git')) {
logger.trace(`Adding .git to HTTPS URL: ${url}`);
return `${url}.git`;
export const isValidRemoteValue = (remoteValue: string): boolean => {
try {
parseRemoteValue(remoteValue);
return true;
} catch (error) {
return false;
}

return url;
};

export const createTempDirectory = async (): Promise<string> => {
Expand All @@ -104,18 +138,18 @@ export const cloneRepository = async (
execGitShallowClone,
},
): Promise<void> => {
logger.log(`Clone repository: ${url} to temporary directory. ${pc.dim(`path: ${directory}`)}`);
logger.log(`Clone repository: ${url} to temporary directory.${pc.dim(`path: ${directory}`)} `);
logger.log('');

try {
await deps.execGitShallowClone(url, directory, remoteBranch);
} catch (error) {
throw new RepomixError(`Failed to clone repository: ${(error as Error).message}`);
throw new RepomixError(`Failed to clone repository: ${(error as Error).message} `);
}
};

export const cleanupTempDirectory = async (directory: string): Promise<void> => {
logger.trace(`Cleaning up temporary directory: ${directory}`);
logger.trace(`Cleaning up temporary directory: ${directory} `);
await fs.rm(directory, { recursive: true, force: true });
};

Expand All @@ -128,9 +162,9 @@ export const copyOutputToCurrentDirectory = async (
const targetPath = path.join(targetDir, outputFileName);

try {
logger.trace(`Copying output file from: ${sourcePath} to: ${targetPath}`);
logger.trace(`Copying output file from: ${sourcePath} to: ${targetPath} `);
await fs.copyFile(sourcePath, targetPath);
} catch (error) {
throw new RepomixError(`Failed to copy output file: ${(error as Error).message}`);
throw new RepomixError(`Failed to copy output file: ${(error as Error).message} `);
}
};
65 changes: 56 additions & 9 deletions tests/cli/actions/remoteAction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import type { DefaultActionRunnerResult } from '../../../src/cli/actions/defaultAction.js';
import {
copyOutputToCurrentDirectory,
formatRemoteValueToUrl,
isValidRemoteValue,
parseRemoteValue,
runRemoteAction,
} from '../../../src/cli/actions/remoteAction.js';
import { createMockConfig } from '../../testing/testUtils.js';
Expand Down Expand Up @@ -53,22 +53,69 @@ describe('remoteAction functions', () => {
});
});

describe('formatGitUrl', () => {
describe('parseRemoteValue', () => {
test('should convert GitHub shorthand to full URL', () => {
expect(formatRemoteValueToUrl('user/repo')).toBe('https://github.com/user/repo.git');
expect(formatRemoteValueToUrl('user-name/repo-name')).toBe('https://github.com/user-name/repo-name.git');
expect(formatRemoteValueToUrl('user_name/repo_name')).toBe('https://github.com/user_name/repo_name.git');
expect(formatRemoteValueToUrl('a.b/a-b_c')).toBe('https://github.com/a.b/a-b_c.git');
expect(parseRemoteValue('user/repo')).toEqual({
repoUrl: 'https://github.com/user/repo.git',
remoteBranch: undefined,
});
expect(parseRemoteValue('user-name/repo-name')).toEqual({
repoUrl: 'https://github.com/user-name/repo-name.git',
remoteBranch: undefined,
});
expect(parseRemoteValue('user_name/repo_name')).toEqual({
repoUrl: 'https://github.com/user_name/repo_name.git',
remoteBranch: undefined,
});
expect(parseRemoteValue('a.b/a-b_c')).toEqual({
repoUrl: 'https://github.com/a.b/a-b_c.git',
remoteBranch: undefined,
});
});

test('should handle HTTPS URLs', () => {
expect(formatRemoteValueToUrl('https://github.com/user/repo')).toBe('https://github.com/user/repo.git');
expect(formatRemoteValueToUrl('https://github.com/user/repo.git')).toBe('https://github.com/user/repo.git');
expect(parseRemoteValue('https://github.com/user/repo')).toEqual({
repoUrl: 'https://github.com/user/repo.git',
remoteBranch: undefined,
});
expect(parseRemoteValue('https://github.com/user/repo.git')).toEqual({
repoUrl: 'https://github.com/user/repo.git',
remoteBranch: undefined,
});
});

test('should not modify SSH URLs', () => {
const sshUrl = 'git@github.com:user/repo.git';
expect(formatRemoteValueToUrl(sshUrl)).toBe(sshUrl);
const parsed = parseRemoteValue(sshUrl);
expect(parsed).toEqual({
repoUrl: sshUrl,
remoteBranch: undefined,
});
});

test('should get correct branch name from url', () => {
expect(parseRemoteValue('https://github.com/username/repo/tree/branchname')).toEqual({
repoUrl: 'https://github.com/username/repo.git',
remoteBranch: 'branchname',
});
expect(parseRemoteValue('https://some.gitlab.domain/some/path/username/repo/-/tree/branchname')).toEqual({
repoUrl: 'https://some.gitlab.domain/some/path/username/repo.git',
remoteBranch: 'branchname',
});
});

test('should get correct commit hash from url', () => {
expect(
parseRemoteValue(
'https://some.gitlab.domain/some/path/username/repo/commit/c482755296cce46e58f87d50f25f545c5d15be6f',
),
).toEqual({
repoUrl: 'https://some.gitlab.domain/some/path/username/repo.git',
remoteBranch: 'c482755296cce46e58f87d50f25f545c5d15be6f',
});
});
test('should throw when the URL is invalid or harmful', () => {
expect(() => parseRemoteValue('some random string')).toThrowError();
});
});

Expand Down