repomix
Version:
A tool to pack repository contents to single file for AI consumption
189 lines (187 loc) • 8.57 kB
JavaScript
import * as fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import pc from 'picocolors';
import { execGitShallowClone } from '../../core/git/gitCommand.js';
import { downloadGitHubArchive, isArchiveDownloadSupported } from '../../core/git/gitHubArchive.js';
import { getRemoteRefs } from '../../core/git/gitRemoteHandle.js';
import { isGitHubRepository, parseGitHubRepoInfo, parseRemoteValue } from '../../core/git/gitRemoteParse.js';
import { isGitInstalled } from '../../core/git/gitRepositoryHandle.js';
import { generateDefaultSkillNameFromUrl, generateProjectNameFromUrl } from '../../core/skill/skillUtils.js';
import { RepomixError } from '../../shared/errorHandle.js';
import { logger } from '../../shared/logger.js';
import { Spinner } from '../cliSpinner.js';
import { promptSkillLocation, resolveAndPrepareSkillDir } from '../prompts/skillPrompts.js';
import { runDefaultAction } from './defaultAction.js';
export const runRemoteAction = async (repoUrl, cliOptions, deps = {
isGitInstalled,
execGitShallowClone,
getRemoteRefs,
runDefaultAction,
downloadGitHubArchive,
isGitHubRepository,
parseGitHubRepoInfo,
isArchiveDownloadSupported,
}) => {
if (cliOptions.config && !path.isAbsolute(cliOptions.config)) {
throw new RepomixError(`In remote mode, --config must be an absolute path to avoid loading config from the cloned repository.\n` +
` Provided: ${cliOptions.config}\n` +
` Example: repomix --remote <url> --config /home/user/repomix.config.json`);
}
let tempDirPath = await createTempDirectory();
let result;
let downloadMethod = 'git';
try {
const githubRepoInfo = deps.parseGitHubRepoInfo(repoUrl);
const shouldTryArchive = githubRepoInfo && deps.isArchiveDownloadSupported(githubRepoInfo);
if (shouldTryArchive) {
const spinner = new Spinner('Downloading repository archive...', cliOptions);
try {
spinner.start();
const repoInfoWithBranch = {
...githubRepoInfo,
ref: cliOptions.remoteBranch ?? githubRepoInfo.ref,
};
await deps.downloadGitHubArchive(repoInfoWithBranch, tempDirPath, {
timeout: 60000,
retries: 2,
}, (progress) => {
if (progress.percentage !== null) {
spinner.update(`Downloading repository archive... (${progress.percentage}%)`);
}
else {
const downloadedMB = (progress.downloaded / 1024 / 1024).toFixed(1);
spinner.update(`Downloading repository archive... (${downloadedMB} MB)`);
}
});
downloadMethod = 'archive';
spinner.succeed('Repository archive downloaded successfully!');
logger.log('');
}
catch (archiveError) {
spinner.fail('Archive download failed, trying git clone...');
logger.trace('Archive download error:', archiveError.message);
await cleanupTempDirectory(tempDirPath);
tempDirPath = await createTempDirectory();
await performGitClone(repoUrl, tempDirPath, cliOptions, deps);
downloadMethod = 'git';
}
}
else {
await performGitClone(repoUrl, tempDirPath, cliOptions, deps);
downloadMethod = 'git';
}
let skillName;
let skillDir;
let skillProjectName;
if (cliOptions.skillGenerate !== undefined) {
skillName =
typeof cliOptions.skillGenerate === 'string'
? cliOptions.skillGenerate
: generateDefaultSkillNameFromUrl(repoUrl);
skillProjectName = generateProjectNameFromUrl(repoUrl);
if (cliOptions.skillOutput) {
if (!cliOptions.skillOutput.trim()) {
throw new RepomixError('--skill-output path cannot be empty');
}
skillDir = await resolveAndPrepareSkillDir(cliOptions.skillOutput, process.cwd(), cliOptions.force ?? false);
}
else {
const promptResult = await promptSkillLocation(skillName, process.cwd());
skillDir = promptResult.skillDir;
}
}
const skillSourceUrl = cliOptions.skillGenerate !== undefined ? repoUrl : undefined;
const trustRemoteConfig = cliOptions.remoteTrustConfig || process.env.REPOMIX_REMOTE_TRUST_CONFIG === 'true';
const optionsWithSkill = {
...cliOptions,
skillName,
skillDir,
skillProjectName,
skillSourceUrl,
skipLocalConfig: !trustRemoteConfig,
};
result = await deps.runDefaultAction([tempDirPath], tempDirPath, optionsWithSkill);
if (!cliOptions.stdout && result.config.skillGenerate === undefined) {
await copyOutputToCurrentDirectory(tempDirPath, process.cwd(), result.config.output.filePath);
}
logger.trace(`Repository obtained via ${downloadMethod} method`);
}
finally {
await cleanupTempDirectory(tempDirPath);
}
return result;
};
const performGitClone = async (repoUrl, tempDirPath, cliOptions, deps) => {
if (!(await deps.isGitInstalled())) {
throw new RepomixError('Git is not installed or not in the system PATH.');
}
let refs = [];
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.message);
}
const parsedFields = parseRemoteValue(repoUrl, refs);
const spinner = new Spinner('Cloning repository...', cliOptions);
try {
spinner.start();
await cloneRepository(parsedFields.repoUrl, tempDirPath, cliOptions.remoteBranch || parsedFields.remoteBranch, {
execGitShallowClone: deps.execGitShallowClone,
});
spinner.succeed('Repository cloned successfully!');
logger.log('');
}
catch (error) {
spinner.fail('Error during repository cloning. cleanup...');
throw error;
}
};
export const createTempDirectory = async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'repomix-'));
logger.trace(`Created temporary directory. (path: ${pc.dim(tempDir)})`);
return tempDir;
};
export const cloneRepository = async (url, directory, remoteBranch, deps = {
execGitShallowClone,
}) => {
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.message}`);
}
};
export const cleanupTempDirectory = async (directory) => {
logger.trace(`Cleaning up temporary directory: ${directory}`);
await fs.rm(directory, { recursive: true, force: true });
};
export const copyOutputToCurrentDirectory = async (sourceDir, targetDir, outputFileName) => {
const sourcePath = path.resolve(sourceDir, outputFileName);
const targetPath = path.resolve(targetDir, outputFileName);
if (sourcePath === targetPath) {
logger.trace(`Source and target are the same (${sourcePath}), skipping copy`);
return;
}
try {
logger.trace(`Copying output file from: ${sourcePath} to: ${targetPath}`);
await fs.mkdir(path.dirname(targetPath), { recursive: true });
await fs.copyFile(sourcePath, targetPath);
}
catch (error) {
const nodeError = error;
if (nodeError.code === 'EPERM' || nodeError.code === 'EACCES') {
throw new RepomixError(`Failed to copy output file to ${targetPath}: Permission denied.
The current directory may be protected or require elevated permissions.
Please try one of the following:
• Run from a different directory (e.g., your home directory or Documents folder)
• Use the --output flag to specify a writable location: --output ~/repomix-output.xml
• Use --stdout to print output directly to the console`);
}
throw new RepomixError(`Failed to copy output file: ${error.message}`);
}
};