diff --git a/.claude/commands/pr-review-request.md b/.claude/commands/pr-review-request.md new file mode 100644 index 000000000..a9b6a9a0c --- /dev/null +++ b/.claude/commands/pr-review-request.md @@ -0,0 +1 @@ +Please request a review of this pull request from Gemini and Coderabbit. diff --git a/CLAUDE.md b/CLAUDE.md index 85e13fcb7..e8692c686 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -223,6 +223,21 @@ This repository uses several automated review tools: - **Copilot Pull Request Reviewer**: GitHub's automated review suggestions - **Codecov**: Test coverage analysis and reporting +### Requesting Additional Reviews +You can request additional AI reviews manually: + +**CodeRabbit Review Request:** +``` +@coderabbitai review +``` + +**Gemini Review Request:** +``` +/gemini review +``` + +**Important**: Post each review request in separate comments for proper processing. + ### Responding to Review Feedback **1. Address Technical Issues First:** diff --git a/package-lock.json b/package-lock.json index 4f59deb6e..e6dd1b8ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "clipboardy": "^4.0.0", "commander": "^14.0.0", "fast-xml-parser": "^5.2.0", + "fflate": "^0.8.2", "git-url-parse": "^16.1.0", "globby": "^14.1.0", "handlebars": "^4.7.8", @@ -2963,6 +2964,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", diff --git a/package.json b/package.json index e97d190bc..ba9177ce9 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,7 @@ "clipboardy": "^4.0.0", "commander": "^14.0.0", "fast-xml-parser": "^5.2.0", + "fflate": "^0.8.2", "git-url-parse": "^16.1.0", "globby": "^14.1.0", "handlebars": "^4.7.8", @@ -98,12 +99,12 @@ "zod": "^3.24.3" }, "devDependencies": { - "git-up": "^8.1.1", - "@secretlint/types": "^9.3.4", "@biomejs/biome": "^1.9.4", + "@secretlint/types": "^9.3.4", "@types/node": "^22.14.1", "@types/strip-comments": "^2.0.4", "@vitest/coverage-v8": "^3.1.1", + "git-up": "^8.1.1", "rimraf": "^6.0.1", "secretlint": "^9.3.1", "tsx": "^4.19.4", diff --git a/src/cli/actions/remoteAction.ts b/src/cli/actions/remoteAction.ts index 733b42a5d..4462e2ebf 100644 --- a/src/cli/actions/remoteAction.ts +++ b/src/cli/actions/remoteAction.ts @@ -3,8 +3,9 @@ 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 { parseRemoteValue } from '../../core/git/gitRemoteParse.js'; +import { isGitHubRepository, parseGitHubRepoInfo, parseRemoteValue } from '../../core/git/gitRemoteParse.js'; import { isGitInstalled } from '../../core/git/gitRepositoryHandle.js'; import { RepomixError } from '../../shared/errorHandle.js'; import { logger } from '../../shared/logger.js'; @@ -20,12 +21,103 @@ export const runRemoteAction = async ( execGitShallowClone, getRemoteRefs, runDefaultAction, + downloadGitHubArchive, + isGitHubRepository, + parseGitHubRepoInfo, + isArchiveDownloadSupported, }, ): Promise => { + let tempDirPath = await createTempDirectory(); + let result: DefaultActionRunnerResult; + let downloadMethod: 'archive' | 'git' = 'git'; + + try { + // Check if this is a GitHub repository and archive download is supported + const githubRepoInfo = deps.parseGitHubRepoInfo(repoUrl); + const shouldTryArchive = githubRepoInfo && deps.isArchiveDownloadSupported(githubRepoInfo); + + if (shouldTryArchive) { + // Try GitHub archive download first + const spinner = new Spinner('Downloading repository archive...', cliOptions); + + try { + spinner.start(); + + // Override ref with CLI option if provided + const repoInfoWithBranch = { + ...githubRepoInfo, + ref: cliOptions.remoteBranch ?? githubRepoInfo.ref, + }; + + await deps.downloadGitHubArchive( + repoInfoWithBranch, + tempDirPath, + { + timeout: 60000, // 1 minute timeout for large repos + retries: 2, + }, + (progress) => { + if (progress.percentage !== null) { + spinner.update(`Downloading repository archive... (${progress.percentage}%)`); + } else { + // Show downloaded bytes when percentage is not available + 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 as Error).message); + + // Clear the temp directory for git clone attempt + await cleanupTempDirectory(tempDirPath); + tempDirPath = await createTempDirectory(); + + // Fall back to git clone + await performGitClone(repoUrl, tempDirPath, cliOptions, deps); + downloadMethod = 'git'; + } + } else { + // Use git clone directly + await performGitClone(repoUrl, tempDirPath, cliOptions, deps); + downloadMethod = 'git'; + } + + // Run the default action on the downloaded/cloned repository + result = await deps.runDefaultAction([tempDirPath], tempDirPath, cliOptions); + await copyOutputToCurrentDirectory(tempDirPath, process.cwd(), result.config.output.filePath); + + logger.trace(`Repository obtained via ${downloadMethod} method`); + } finally { + // Cleanup the temporary directory + await cleanupTempDirectory(tempDirPath); + } + + return result; +}; + +/** + * Performs git clone operation with spinner and error handling + */ +const performGitClone = async ( + repoUrl: string, + tempDirPath: string, + cliOptions: CliOptions, + deps: { + isGitInstalled: typeof isGitInstalled; + getRemoteRefs: typeof getRemoteRefs; + execGitShallowClone: typeof execGitShallowClone; + }, +): Promise => { + // Check if git is installed only when we actually need to use git if (!(await deps.isGitInstalled())) { throw new RepomixError('Git is not installed or not in the system PATH.'); } - // Get remote refs let refs: string[] = []; try { @@ -39,8 +131,6 @@ export const runRemoteAction = async ( const parsedFields = parseRemoteValue(repoUrl, refs); const spinner = new Spinner('Cloning repository...', cliOptions); - const tempDirPath = await createTempDirectory(); - let result: DefaultActionRunnerResult; try { spinner.start(); @@ -52,19 +142,10 @@ export const runRemoteAction = async ( spinner.succeed('Repository cloned successfully!'); logger.log(''); - - // Run the default action on the cloned repository - result = await deps.runDefaultAction([tempDirPath], tempDirPath, cliOptions); - await copyOutputToCurrentDirectory(tempDirPath, process.cwd(), result.config.output.filePath); } catch (error) { spinner.fail('Error during repository cloning. cleanup...'); throw error; - } finally { - // Cleanup the temporary directory - await cleanupTempDirectory(tempDirPath); } - - return result; }; export const createTempDirectory = async (): Promise => { diff --git a/src/core/git/gitHubArchive.ts b/src/core/git/gitHubArchive.ts new file mode 100644 index 000000000..7b99c4ee6 --- /dev/null +++ b/src/core/git/gitHubArchive.ts @@ -0,0 +1,343 @@ +import { createWriteStream } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { Readable, Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { unzip } from 'fflate'; +import { RepomixError } from '../../shared/errorHandle.js'; +import { logger } from '../../shared/logger.js'; +import { + buildGitHubArchiveUrl, + buildGitHubMasterArchiveUrl, + buildGitHubTagArchiveUrl, + checkGitHubResponse, + getArchiveFilename, +} from './gitHubArchiveApi.js'; +import type { GitHubRepoInfo } from './gitRemoteParse.js'; + +export interface ArchiveDownloadOptions { + timeout?: number; // Download timeout in milliseconds (default: 30000) + retries?: number; // Number of retry attempts (default: 3) +} + +export interface ArchiveDownloadProgress { + downloaded: number; + total: number | null; + percentage: number | null; +} + +export type ProgressCallback = (progress: ArchiveDownloadProgress) => void; + +/** + * Downloads and extracts a GitHub repository archive + */ +export const downloadGitHubArchive = async ( + repoInfo: GitHubRepoInfo, + targetDirectory: string, + options: ArchiveDownloadOptions = {}, + onProgress?: ProgressCallback, + deps = { + fetch: globalThis.fetch, + fs, + pipeline, + Transform, + createWriteStream, + }, +): Promise => { + const { timeout = 30000, retries = 3 } = options; + + // Ensure target directory exists + await deps.fs.mkdir(targetDirectory, { recursive: true }); + + let lastError: Error | null = null; + + // Try downloading with multiple URL formats: main branch, master branch (fallback), then tag format + const archiveUrls = [ + buildGitHubArchiveUrl(repoInfo), + buildGitHubMasterArchiveUrl(repoInfo), + buildGitHubTagArchiveUrl(repoInfo), + ].filter(Boolean) as string[]; + + for (const archiveUrl of archiveUrls) { + for (let attempt = 1; attempt <= retries; attempt++) { + try { + logger.trace(`Downloading GitHub archive from: ${archiveUrl} (attempt ${attempt}/${retries})`); + + await downloadAndExtractArchive(archiveUrl, targetDirectory, repoInfo, timeout, onProgress, deps); + + logger.trace('Successfully downloaded and extracted GitHub archive'); + return; // Success - exit early + } catch (error) { + lastError = error as Error; + logger.trace(`Archive download attempt ${attempt} failed:`, lastError.message); + + // If it's a 404-like error and we have more URLs to try, don't retry this URL + const isNotFoundError = + lastError instanceof RepomixError && + (lastError.message.includes('not found') || lastError.message.includes('404')); + if (isNotFoundError && archiveUrls.length > 1) { + break; + } + + // If it's the last attempt, don't wait + if (attempt < retries) { + const delay = Math.min(1000 * 2 ** (attempt - 1), 5000); // Exponential backoff, max 5s + logger.trace(`Retrying in ${delay}ms...`); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } + } + } + + // If we get here, all attempts failed + throw new RepomixError( + `Failed to download GitHub archive after ${retries} attempts. ${lastError?.message || 'Unknown error'}`, + ); +}; + +/** + * Downloads and extracts archive from a single URL + */ +const downloadAndExtractArchive = async ( + archiveUrl: string, + targetDirectory: string, + repoInfo: GitHubRepoInfo, + timeout: number, + onProgress?: ProgressCallback, + deps = { + fetch: globalThis.fetch, + fs, + pipeline, + Transform, + createWriteStream, + }, +): Promise => { + // Download the archive + const tempArchivePath = path.join(targetDirectory, getArchiveFilename(repoInfo)); + + await downloadFile(archiveUrl, tempArchivePath, timeout, onProgress, deps); + + try { + // Extract the archive + await extractZipArchive(tempArchivePath, targetDirectory, repoInfo, { fs: deps.fs }); + } finally { + // Clean up the downloaded archive file + try { + await deps.fs.unlink(tempArchivePath); + } catch (error) { + logger.trace('Failed to cleanup archive file:', (error as Error).message); + } + } +}; + +/** + * Downloads a file from URL with progress tracking + */ +const downloadFile = async ( + url: string, + filePath: string, + timeout: number, + onProgress?: ProgressCallback, + deps = { + fetch: globalThis.fetch, + fs, + pipeline, + Transform, + createWriteStream, + }, +): Promise => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await deps.fetch(url, { + signal: controller.signal, + }); + + checkGitHubResponse(response); + + if (!response.body) { + throw new RepomixError('No response body received'); + } + + const totalSize = response.headers.get('content-length'); + const total = totalSize ? Number.parseInt(totalSize, 10) : null; + let downloaded = 0; + let lastProgressUpdate = 0; + + // Use Readable.fromWeb for better stream handling + const nodeStream = Readable.fromWeb(response.body); + + // Transform stream for progress tracking + const progressStream = new deps.Transform({ + transform(chunk, _encoding, callback) { + downloaded += chunk.length; + + // Update progress at most every 100ms to avoid too frequent updates + const now = Date.now(); + if (onProgress && now - lastProgressUpdate > 100) { + lastProgressUpdate = now; + onProgress({ + downloaded, + total, + percentage: total ? Math.round((downloaded / total) * 100) : null, + }); + } + + callback(null, chunk); + }, + flush(callback) { + // Send final progress update + if (onProgress) { + onProgress({ + downloaded, + total, + percentage: total ? 100 : null, + }); + } + callback(); + }, + }); + + // Write to file + const writeStream = deps.createWriteStream(filePath); + await deps.pipeline(nodeStream, progressStream, writeStream); + } finally { + clearTimeout(timeoutId); + } +}; + +/** + * Extracts a ZIP archive using fflate library + */ +const extractZipArchive = async ( + archivePath: string, + targetDirectory: string, + repoInfo: GitHubRepoInfo, + deps = { + fs, + }, +): Promise => { + try { + // Always use in-memory extraction for simplicity and reliability + await extractZipArchiveInMemory(archivePath, targetDirectory, repoInfo, deps); + } catch (error) { + throw new RepomixError(`Failed to extract archive: ${(error as Error).message}`); + } +}; + +/** + * Extracts ZIP archive by loading it entirely into memory (faster for small files) + */ +const extractZipArchiveInMemory = async ( + archivePath: string, + targetDirectory: string, + repoInfo: GitHubRepoInfo, + deps = { + fs, + }, +): Promise => { + // Read the ZIP file as a buffer + const zipBuffer = await deps.fs.readFile(archivePath); + const zipUint8Array = new Uint8Array(zipBuffer); + + // Extract ZIP using fflate + await new Promise((resolve, reject) => { + unzip(zipUint8Array, (err, extracted) => { + if (err) { + reject(new RepomixError(`Failed to extract ZIP archive: ${err.message}`)); + return; + } + + // Process extracted files concurrently in the callback + processExtractedFiles(extracted, targetDirectory, repoInfo, deps).then(resolve).catch(reject); + }); + }); +}; + +/** + * Process extracted files sequentially to avoid EMFILE errors + */ +const processExtractedFiles = async ( + extracted: Record, + targetDirectory: string, + repoInfo: GitHubRepoInfo, + deps = { + fs, + }, +): Promise => { + const repoPrefix = `${repoInfo.repo}-`; + const createdDirs = new Set(); + + // Process files sequentially to avoid EMFILE errors completely + for (const [filePath, fileData] of Object.entries(extracted)) { + // GitHub archives have a top-level directory like "repo-branch/" + // We need to remove this prefix from the file paths + let relativePath = filePath; + + // Find and remove the repo prefix + const pathParts = filePath.split('/'); + if (pathParts.length > 0 && pathParts[0].startsWith(repoPrefix)) { + // Remove the first directory (repo-branch/) + relativePath = pathParts.slice(1).join('/'); + } + + // Skip empty paths (root directory) + if (!relativePath) { + continue; + } + + // Sanitize relativePath to prevent path traversal attacks + const sanitized = path.normalize(relativePath).replace(/^(\.\.([\/\\]|$))+/, ''); + + // Reject absolute paths outright + if (path.isAbsolute(sanitized)) { + logger.trace(`Absolute path detected in archive, skipping: ${relativePath}`); + continue; + } + + const targetPath = path.resolve(targetDirectory, sanitized); + if (!targetPath.startsWith(path.resolve(targetDirectory))) { + logger.trace(`Unsafe path detected in archive, skipping: ${relativePath}`); + continue; + } + + // Check if this entry is a directory (ends with /) or empty file data indicates directory + const isDirectory = filePath.endsWith('/') || (fileData.length === 0 && relativePath.endsWith('/')); + + if (isDirectory) { + // Create directory immediately + if (!createdDirs.has(targetPath)) { + logger.trace(`Creating directory: ${targetPath}`); + await deps.fs.mkdir(targetPath, { recursive: true }); + createdDirs.add(targetPath); + } + } else { + // Create parent directory if needed and write file + const parentDir = path.dirname(targetPath); + if (!createdDirs.has(parentDir)) { + logger.trace(`Creating parent directory for file: ${parentDir}`); + await deps.fs.mkdir(parentDir, { recursive: true }); + createdDirs.add(parentDir); + } + + // Write file sequentially + logger.trace(`Writing file: ${targetPath}`); + try { + await deps.fs.writeFile(targetPath, fileData); + } catch (fileError) { + logger.trace(`Failed to write file ${targetPath}: ${(fileError as Error).message}`); + throw fileError; + } + } + } +}; + +/** + * Checks if archive download is supported for the given repository info + */ +export const isArchiveDownloadSupported = (_repoInfo: GitHubRepoInfo): boolean => { + // Archive download is supported for all GitHub repositories + // In the future, we might add conditions here (e.g., size limits, private repos) + return true; +}; diff --git a/src/core/git/gitHubArchiveApi.ts b/src/core/git/gitHubArchiveApi.ts new file mode 100644 index 000000000..ca01f3dbf --- /dev/null +++ b/src/core/git/gitHubArchiveApi.ts @@ -0,0 +1,99 @@ +import { RepomixError } from '../../shared/errorHandle.js'; +import type { GitHubRepoInfo } from './gitRemoteParse.js'; + +/** + * Constructs GitHub archive download URL + * Format: https://github.com/owner/repo/archive/refs/heads/branch.zip + * For tags: https://github.com/owner/repo/archive/refs/tags/tag.zip + * For commits: https://github.com/owner/repo/archive/commit.zip + */ +export const buildGitHubArchiveUrl = (repoInfo: GitHubRepoInfo): string => { + const { owner, repo, ref } = repoInfo; + const baseUrl = `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/archive`; + + if (!ref) { + // Default to main branch - fallback to master will be handled by the caller + return `${baseUrl}/refs/heads/main.zip`; + } + + // Check if ref looks like a commit SHA (40 hex chars or shorter) + const isCommitSha = /^[0-9a-f]{4,40}$/i.test(ref); + if (isCommitSha) { + return `${baseUrl}/${encodeURIComponent(ref)}.zip`; + } + + // For branches and tags, we need to determine the type + // Default to branch format, will fallback to tag if needed + return `${baseUrl}/refs/heads/${encodeURIComponent(ref)}.zip`; +}; + +/** + * Builds alternative archive URL for master branch as fallback + */ +export const buildGitHubMasterArchiveUrl = (repoInfo: GitHubRepoInfo): string | null => { + const { owner, repo, ref } = repoInfo; + if (ref) { + return null; // Only applicable when no ref is specified + } + + return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/archive/refs/heads/master.zip`; +}; + +/** + * Builds alternative archive URL for tags + */ +export const buildGitHubTagArchiveUrl = (repoInfo: GitHubRepoInfo): string | null => { + const { owner, repo, ref } = repoInfo; + if (!ref || /^[0-9a-f]{4,40}$/i.test(ref)) { + return null; // Not applicable for commits or no ref + } + + return `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/archive/refs/tags/${encodeURIComponent(ref)}.zip`; +}; + +/** + * Gets the expected archive filename from GitHub + * Format: repo-branch.zip or repo-sha.zip + */ +export const getArchiveFilename = (repoInfo: GitHubRepoInfo): string => { + const { repo, ref } = repoInfo; + const refPart = ref || 'main'; + + // GitHub uses the last part of the ref for the filename + const refName = refPart.includes('/') ? refPart.split('/').pop() : refPart; + + return `${repo}-${refName}.zip`; +}; + +/** + * Checks if a response indicates a GitHub API rate limit or error + */ +export const checkGitHubResponse = (response: Response): void => { + if (response.status === 404) { + throw new RepomixError( + 'Repository not found or is private. Please check the repository URL and your access permissions.', + ); + } + + if (response.status === 403) { + const rateLimitRemaining = response.headers.get('X-RateLimit-Remaining'); + if (rateLimitRemaining === '0') { + const resetTime = response.headers.get('X-RateLimit-Reset'); + const resetDate = resetTime ? new Date(Number.parseInt(resetTime) * 1000) : null; + throw new RepomixError( + `GitHub API rate limit exceeded. ${resetDate ? `Rate limit resets at ${resetDate.toISOString()}` : 'Please try again later.'}`, + ); + } + throw new RepomixError( + 'Access denied. The repository might be private or you might not have permission to access it.', + ); + } + + if (response.status === 500 || response.status === 502 || response.status === 503 || response.status === 504) { + throw new RepomixError('GitHub server error. Please try again later.'); + } + + if (!response.ok) { + throw new RepomixError(`GitHub API error: ${response.status} ${response.statusText}`); + } +}; diff --git a/src/core/git/gitRemoteParse.ts b/src/core/git/gitRemoteParse.ts index 61aa5c93b..326c43e27 100644 --- a/src/core/git/gitRemoteParse.ts +++ b/src/core/git/gitRemoteParse.ts @@ -6,6 +6,12 @@ interface IGitUrl extends GitUrl { commit: string | undefined; } +export interface GitHubRepoInfo { + owner: string; + repo: string; + ref?: string; // branch, tag, or commit SHA +} + // 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}$`); @@ -71,3 +77,82 @@ export const isValidRemoteValue = (remoteValue: string, refs: string[] = []): bo return false; } }; + +/** + * Parses remote value and extracts GitHub repository information if it's a GitHub repo + * Returns null if the remote value is not a GitHub repository + */ +export const parseGitHubRepoInfo = (remoteValue: string): GitHubRepoInfo | null => { + try { + // Handle shorthand format: owner/repo + if (isValidShorthand(remoteValue)) { + const [owner, repo] = remoteValue.split('/'); + return { owner, repo }; + } + + // For GitHub URLs with branch/tag/commit info, extract directly from URL + try { + const url = new URL(remoteValue); + const allowedHosts = ['github.com', 'www.github.com']; + + if (allowedHosts.includes(url.hostname)) { + const pathParts = url.pathname.split('/').filter(Boolean); + + if (pathParts.length >= 2) { + const owner = pathParts[0]; + const repo = pathParts[1].replace(/\.git$/, ''); + + const result: GitHubRepoInfo = { owner, repo }; + + // Extract ref from URL patterns like /tree/branch or /commit/sha + if (pathParts.length >= 4 && (pathParts[2] === 'tree' || pathParts[2] === 'commit')) { + result.ref = pathParts.slice(3).join('/'); + } + + return result; + } + } + } catch (urlError) { + // Fall back to git-url-parse if URL parsing fails + logger.trace('URL parsing failed, falling back to git-url-parse:', (urlError as Error).message); + } + + // Parse using git-url-parse for other cases + const parsed = gitUrlParse(remoteValue) as IGitUrl; + + // Only proceed if it's a GitHub repository + if (parsed.source !== 'github.com') { + return null; + } + + // Extract owner and repo from full_name (e.g., "owner/repo") + const [owner, repo] = parsed.full_name.split('/'); + if (!owner || !repo) { + return null; + } + + const result: GitHubRepoInfo = { + owner, + repo: repo.replace(/\.git$/, ''), // Remove .git suffix + }; + + // Add ref if available + if (parsed.ref) { + result.ref = parsed.ref; + } else if (parsed.commit) { + result.ref = parsed.commit; + } + + return result; + } catch (error) { + logger.trace('Failed to parse GitHub repo info:', (error as Error).message); + return null; + } +}; + +/** + * Checks if a remote value represents a GitHub repository + */ +export const isGitHubRepository = (remoteValue: string): boolean => { + return parseGitHubRepoInfo(remoteValue) !== null; +}; diff --git a/tests/cli/actions/remoteAction.test.ts b/tests/cli/actions/remoteAction.test.ts index 1748cd920..2cc9329d2 100644 --- a/tests/cli/actions/remoteAction.test.ts +++ b/tests/cli/actions/remoteAction.test.ts @@ -22,16 +22,101 @@ describe('remoteAction functions', () => { }); describe('runRemoteAction', () => { - test('should clone the repository', async () => { + test('should clone the repository when not a GitHub repo', async () => { + const execGitShallowCloneMock = vi.fn(async (_url: string, directory: string) => { + await fs.writeFile(path.join(directory, 'README.md'), 'Hello, world!'); + }); + vi.mocked(fs.copyFile).mockResolvedValue(undefined); await runRemoteAction( - 'yamadashy/repomix', + 'https://gitlab.com/owner/repo.git', {}, { isGitInstalled: async () => Promise.resolve(true), - execGitShallowClone: async (url: string, directory: string) => { - await fs.writeFile(path.join(directory, 'README.md'), 'Hello, world!'); + execGitShallowClone: execGitShallowCloneMock, + getRemoteRefs: async () => Promise.resolve(['main']), + runDefaultAction: async () => { + return { + packResult: { + totalFiles: 1, + totalCharacters: 1, + totalTokens: 1, + fileCharCounts: {}, + fileTokenCounts: {}, + suspiciousFilesResults: [], + suspiciousGitDiffResults: [], + processedFiles: [], + safeFilePaths: [], + gitDiffTokenCount: 0, + }, + config: createMockConfig(), + } satisfies DefaultActionRunnerResult; + }, + downloadGitHubArchive: vi.fn().mockRejectedValue(new Error('Archive download not implemented in test')), + isGitHubRepository: vi.fn().mockReturnValue(false), + parseGitHubRepoInfo: vi.fn().mockReturnValue(null), + isArchiveDownloadSupported: vi.fn().mockReturnValue(false), + }, + ); + + expect(execGitShallowCloneMock).toHaveBeenCalledTimes(1); + }); + + test('should download GitHub archive successfully without git installed', async () => { + const downloadGitHubArchiveMock = vi.fn().mockResolvedValue(undefined); + const execGitShallowCloneMock = vi.fn(); + const isGitInstalledMock = vi.fn().mockResolvedValue(false); // Git is NOT installed + + vi.mocked(fs.copyFile).mockResolvedValue(undefined); + await runRemoteAction( + 'yamadashy/repomix', + {}, + { + isGitInstalled: isGitInstalledMock, + execGitShallowClone: execGitShallowCloneMock, + getRemoteRefs: async () => Promise.resolve(['main']), + runDefaultAction: async () => { + return { + packResult: { + totalFiles: 1, + totalCharacters: 1, + totalTokens: 1, + fileCharCounts: {}, + fileTokenCounts: {}, + suspiciousFilesResults: [], + suspiciousGitDiffResults: [], + processedFiles: [], + safeFilePaths: [], + gitDiffTokenCount: 0, + }, + config: createMockConfig(), + } satisfies DefaultActionRunnerResult; }, + downloadGitHubArchive: downloadGitHubArchiveMock, + isGitHubRepository: vi.fn().mockReturnValue(true), + parseGitHubRepoInfo: vi.fn().mockReturnValue({ owner: 'yamadashy', repo: 'repomix' }), + isArchiveDownloadSupported: vi.fn().mockReturnValue(true), + }, + ); + + expect(downloadGitHubArchiveMock).toHaveBeenCalledTimes(1); + expect(execGitShallowCloneMock).not.toHaveBeenCalled(); + expect(isGitInstalledMock).not.toHaveBeenCalled(); // Git check should not be called when archive succeeds + }); + + test('should fallback to git clone when archive download fails', async () => { + const downloadGitHubArchiveMock = vi.fn().mockRejectedValue(new Error('Archive download failed')); + const execGitShallowCloneMock = vi.fn(async (_url: string, directory: string) => { + await fs.writeFile(path.join(directory, 'README.md'), 'Hello, world!'); + }); + + vi.mocked(fs.copyFile).mockResolvedValue(undefined); + await runRemoteAction( + 'yamadashy/repomix', + {}, + { + isGitInstalled: async () => Promise.resolve(true), + execGitShallowClone: execGitShallowCloneMock, getRemoteRefs: async () => Promise.resolve(['main']), runDefaultAction: async () => { return { @@ -50,8 +135,60 @@ describe('remoteAction functions', () => { config: createMockConfig(), } satisfies DefaultActionRunnerResult; }, + downloadGitHubArchive: downloadGitHubArchiveMock, + isGitHubRepository: vi.fn().mockReturnValue(true), + parseGitHubRepoInfo: vi.fn().mockReturnValue({ owner: 'yamadashy', repo: 'repomix' }), + isArchiveDownloadSupported: vi.fn().mockReturnValue(true), }, ); + + expect(downloadGitHubArchiveMock).toHaveBeenCalledTimes(1); + expect(execGitShallowCloneMock).toHaveBeenCalledTimes(1); + }); + + test('should fail when archive download fails and git is not installed', async () => { + const downloadGitHubArchiveMock = vi.fn().mockRejectedValue(new Error('Archive download failed')); + const execGitShallowCloneMock = vi.fn(); + const isGitInstalledMock = vi.fn().mockResolvedValue(false); // Git is NOT installed + + vi.mocked(fs.copyFile).mockResolvedValue(undefined); + + await expect( + runRemoteAction( + 'yamadashy/repomix', + {}, + { + isGitInstalled: isGitInstalledMock, + execGitShallowClone: execGitShallowCloneMock, + getRemoteRefs: async () => Promise.resolve(['main']), + runDefaultAction: async () => { + return { + packResult: { + totalFiles: 1, + totalCharacters: 1, + totalTokens: 1, + fileCharCounts: {}, + fileTokenCounts: {}, + suspiciousFilesResults: [], + suspiciousGitDiffResults: [], + processedFiles: [], + safeFilePaths: [], + gitDiffTokenCount: 0, + }, + config: createMockConfig(), + } satisfies DefaultActionRunnerResult; + }, + downloadGitHubArchive: downloadGitHubArchiveMock, + isGitHubRepository: vi.fn().mockReturnValue(true), + parseGitHubRepoInfo: vi.fn().mockReturnValue({ owner: 'yamadashy', repo: 'repomix' }), + isArchiveDownloadSupported: vi.fn().mockReturnValue(true), + }, + ), + ).rejects.toThrow('Git is not installed or not in the system PATH.'); + + expect(downloadGitHubArchiveMock).toHaveBeenCalledTimes(1); + expect(isGitInstalledMock).toHaveBeenCalledTimes(1); // Git check should be called when fallback to git clone + expect(execGitShallowCloneMock).not.toHaveBeenCalled(); }); }); diff --git a/tests/core/git/gitHubArchive.test.ts b/tests/core/git/gitHubArchive.test.ts new file mode 100644 index 000000000..12f9d26b0 --- /dev/null +++ b/tests/core/git/gitHubArchive.test.ts @@ -0,0 +1,505 @@ +import * as path from 'node:path'; +import { Transform } from 'node:stream'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { + type ArchiveDownloadOptions, + type ProgressCallback, + downloadGitHubArchive, + isArchiveDownloadSupported, +} from '../../../src/core/git/gitHubArchive.js'; +import type { GitHubRepoInfo } from '../../../src/core/git/gitRemoteParse.js'; +import { RepomixError } from '../../../src/shared/errorHandle.js'; + +// Mock modules +vi.mock('../../../src/shared/logger'); +vi.mock('fflate', () => ({ + unzip: vi.fn(), +})); + +// Mock file system operations +const mockFs = { + mkdir: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), + unlink: vi.fn(), +}; + +// Simple ZIP test data +const mockZipData = new Uint8Array([0x50, 0x4b, 0x03, 0x04]); // Simple ZIP header + +describe('gitHubArchive', () => { + let mockFetch: ReturnType; + let mockPipeline: ReturnType; + let mockTransform: ReturnType; + let mockCreateWriteStream: ReturnType; + let mockUnzip: ReturnType; + + beforeEach(async () => { + vi.clearAllMocks(); + + mockFetch = vi.fn(); + mockPipeline = vi.fn(); + mockTransform = vi.fn(); + mockCreateWriteStream = vi.fn(); + + // Get the mocked unzip function + const { unzip } = await import('fflate'); + mockUnzip = vi.mocked(unzip); + + // Reset fs mocks + for (const mock of Object.values(mockFs)) { + mock.mockReset(); + } + + // Setup default successful behaviors + mockFs.mkdir.mockResolvedValue(undefined); + mockFs.unlink.mockResolvedValue(undefined); + mockFs.readFile.mockResolvedValue(Buffer.from(mockZipData)); + mockFs.writeFile.mockResolvedValue(undefined); + mockPipeline.mockResolvedValue(undefined); + mockCreateWriteStream.mockReturnValue({ + write: vi.fn(), + end: vi.fn(), + }); + mockTransform.mockImplementation(() => new Transform()); + }); + + describe('downloadGitHubArchive', () => { + const mockRepoInfo: GitHubRepoInfo = { + owner: 'yamadashy', + repo: 'repomix', + ref: 'main', + }; + + const mockTargetDirectory = '/test/target'; + const mockOptions: ArchiveDownloadOptions = { + timeout: 30000, + retries: 3, + }; + + test('should successfully download and extract archive', async () => { + // Mock successful response with stream + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(mockZipData); + controller.close(); + }, + }); + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map([['content-length', mockZipData.length.toString()]]), + body: mockStream, + }); + + // Mock unzip to extract files + mockUnzip.mockImplementation((_data, callback) => { + const extracted = { + 'repomix-main/': new Uint8Array(0), // Directory + 'repomix-main/test.txt': new Uint8Array([0x68, 0x65, 0x6c, 0x6c, 0x6f]), // "hello" + }; + callback(null, extracted); + }); + + await downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, mockOptions, undefined, { + fetch: mockFetch, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + fs: mockFs as any, + pipeline: mockPipeline, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + Transform: mockTransform as any, + createWriteStream: mockCreateWriteStream, + }); + + // Verify directory creation + expect(mockFs.mkdir).toHaveBeenCalledWith(mockTargetDirectory, { recursive: true }); + + // Verify fetch was called + expect(mockFetch).toHaveBeenCalledWith( + 'https://github.com/yamadashy/repomix/archive/refs/heads/main.zip', + expect.objectContaining({ + signal: expect.any(AbortSignal), + }), + ); + + // Verify file operations + expect(mockFs.writeFile).toHaveBeenCalledWith( + path.resolve(mockTargetDirectory, 'test.txt'), + expect.any(Uint8Array), + ); + + // Verify cleanup + expect(mockFs.unlink).toHaveBeenCalledWith(path.join(mockTargetDirectory, 'repomix-main.zip')); + }); + + test('should handle progress callback', async () => { + const mockProgressCallback: ProgressCallback = vi.fn(); + + const mockStream = new ReadableStream({ + start(controller) { + controller.enqueue(mockZipData); + controller.close(); + }, + }); + + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map([['content-length', mockZipData.length.toString()]]), + body: mockStream, + }); + + mockUnzip.mockImplementation((_data, callback) => { + callback(null, {}); + }); + + await downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, mockOptions, mockProgressCallback, { + fetch: mockFetch, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + fs: mockFs as any, + pipeline: mockPipeline, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + Transform: mockTransform as any, + createWriteStream: mockCreateWriteStream, + }); + + // Progress callback is called via Transform stream, which is handled internally + // Just verify the download completed successfully + expect(mockFetch).toHaveBeenCalled(); + expect(mockUnzip).toHaveBeenCalled(); + }); + + test('should retry on failure', async () => { + // First two attempts fail, third succeeds + mockFetch + .mockRejectedValueOnce(new Error('Network error')) + .mockRejectedValueOnce(new Error('Network error')) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map([['content-length', mockZipData.length.toString()]]), + body: new ReadableStream({ + start(controller) { + controller.enqueue(mockZipData); + controller.close(); + }, + }), + }); + + mockUnzip.mockImplementation((_data, callback) => { + callback(null, {}); + }); + + // Use fewer retries to speed up test + await downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, { retries: 2 }, undefined, { + fetch: mockFetch, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + fs: mockFs as any, + pipeline: mockPipeline, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + Transform: mockTransform as any, + createWriteStream: mockCreateWriteStream, + }); + + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + test('should try fallback URLs on 404', async () => { + // Mock 404 for main branch, success for master branch + mockFetch + .mockResolvedValueOnce({ + ok: false, + status: 404, + headers: new Map(), + body: null, + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + headers: new Map([['content-length', mockZipData.length.toString()]]), + body: new ReadableStream({ + start(controller) { + controller.enqueue(mockZipData); + controller.close(); + }, + }), + }); + + mockUnzip.mockImplementation((_data, callback) => { + callback(null, {}); + }); + + const repoInfoNoRef = { owner: 'yamadashy', repo: 'repomix' }; + + await downloadGitHubArchive(repoInfoNoRef, mockTargetDirectory, mockOptions, undefined, { + fetch: mockFetch, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + fs: mockFs as any, + pipeline: mockPipeline, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + Transform: mockTransform as any, + createWriteStream: mockCreateWriteStream, + }); + + // Should try main branch first, then master branch + expect(mockFetch).toHaveBeenCalledWith( + 'https://github.com/yamadashy/repomix/archive/refs/heads/main.zip', + expect.any(Object), + ); + expect(mockFetch).toHaveBeenCalledWith( + 'https://github.com/yamadashy/repomix/archive/refs/heads/master.zip', + expect.any(Object), + ); + }); + + test('should throw error after all retries fail', async () => { + mockFetch.mockRejectedValue(new Error('Network error')); + + await expect( + downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, { retries: 2 }, undefined, { + fetch: mockFetch, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + fs: mockFs as any, + pipeline: mockPipeline, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + Transform: mockTransform as any, + createWriteStream: mockCreateWriteStream, + }), + ).rejects.toThrow(RepomixError); + + // Multiple URLs are tried even with ref: main, fallback, tag + // 2 retries × 2 URLs (main + tag for "main" ref) = 4 total attempts + expect(mockFetch).toHaveBeenCalledTimes(4); + }); + + test('should handle ZIP extraction error', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map([['content-length', mockZipData.length.toString()]]), + body: new ReadableStream({ + start(controller) { + controller.enqueue(mockZipData); + controller.close(); + }, + }), + }); + + // Mock unzip to fail + mockUnzip.mockImplementation((_data, callback) => { + callback(new Error('Invalid ZIP file')); + }); + + await expect( + downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, { retries: 1 }, undefined, { + fetch: mockFetch, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + fs: mockFs as any, + pipeline: mockPipeline, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + Transform: mockTransform as any, + createWriteStream: mockCreateWriteStream, + }), + ).rejects.toThrow(RepomixError); + }); + + test('should handle path traversal attack', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map([['content-length', mockZipData.length.toString()]]), + body: new ReadableStream({ + start(controller) { + controller.enqueue(mockZipData); + controller.close(); + }, + }), + }); + + // Mock unzip with dangerous paths + mockUnzip.mockImplementation((_data, callback) => { + const extracted = { + 'repomix-main/../../../etc/passwd': new Uint8Array([0x65, 0x76, 0x69, 0x6c]), // "evil" + 'repomix-main/safe.txt': new Uint8Array([0x73, 0x61, 0x66, 0x65]), // "safe" + }; + callback(null, extracted); + }); + + await downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, mockOptions, undefined, { + fetch: mockFetch, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + fs: mockFs as any, + pipeline: mockPipeline, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + Transform: mockTransform as any, + createWriteStream: mockCreateWriteStream, + }); + + // Should write both files - the path normalization doesn't completely prevent this case + expect(mockFs.writeFile).toHaveBeenCalledWith( + path.resolve(mockTargetDirectory, 'safe.txt'), + expect.any(Uint8Array), + ); + + // Verify that both files are written (one was sanitized to remove path traversal) + expect(mockFs.writeFile).toHaveBeenCalledTimes(2); + }); + + test('should handle absolute paths in ZIP', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map([['content-length', mockZipData.length.toString()]]), + body: new ReadableStream({ + start(controller) { + controller.enqueue(mockZipData); + controller.close(); + }, + }), + }); + + // Mock unzip with absolute path + mockUnzip.mockImplementation((_data, callback) => { + const extracted = { + '/etc/passwd': new Uint8Array([0x65, 0x76, 0x69, 0x6c]), // "evil" + 'repomix-main/safe.txt': new Uint8Array([0x73, 0x61, 0x66, 0x65]), // "safe" + }; + callback(null, extracted); + }); + + await downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, mockOptions, undefined, { + fetch: mockFetch, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + fs: mockFs as any, + pipeline: mockPipeline, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + Transform: mockTransform as any, + createWriteStream: mockCreateWriteStream, + }); + + // Should only write safe file, not the absolute path + expect(mockFs.writeFile).toHaveBeenCalledWith( + path.resolve(mockTargetDirectory, 'safe.txt'), + expect.any(Uint8Array), + ); + + // Should not write the absolute path file + expect(mockFs.writeFile).not.toHaveBeenCalledWith('/etc/passwd', expect.any(Uint8Array)); + }); + + test('should cleanup archive file even on extraction failure', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map([['content-length', mockZipData.length.toString()]]), + body: new ReadableStream({ + start(controller) { + controller.enqueue(mockZipData); + controller.close(); + }, + }), + }); + + // Mock unzip to fail + mockUnzip.mockImplementation((_data, callback) => { + callback(new Error('Extraction failed')); + }); + + await expect( + downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, { retries: 1 }, undefined, { + fetch: mockFetch, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + fs: mockFs as any, + pipeline: mockPipeline, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + Transform: mockTransform as any, + createWriteStream: mockCreateWriteStream, + }), + ).rejects.toThrow(); + + // Should still attempt cleanup + expect(mockFs.unlink).toHaveBeenCalledWith(path.join(mockTargetDirectory, 'repomix-main.zip')); + }); + + test('should handle missing response body', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + headers: new Map(), + body: null, + }); + + await expect( + downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, { retries: 1 }, undefined, { + fetch: mockFetch, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + fs: mockFs as any, + pipeline: mockPipeline, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + Transform: mockTransform as any, + createWriteStream: mockCreateWriteStream, + }), + ).rejects.toThrow(RepomixError); + }); + + test('should handle timeout', async () => { + // Mock a fetch that takes too long + mockFetch.mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => { + resolve({ + ok: true, + status: 200, + headers: new Map(), + body: new ReadableStream({ + start(controller) { + controller.enqueue(mockZipData); + controller.close(); + }, + }), + }); + }, 100); // Resolve after 100ms, but timeout is 50ms + }), + ); + + const shortTimeout = 50; // 50ms timeout for faster test + + await expect( + downloadGitHubArchive(mockRepoInfo, mockTargetDirectory, { timeout: shortTimeout, retries: 1 }, undefined, { + fetch: mockFetch, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + fs: mockFs as any, + pipeline: mockPipeline, + // biome-ignore lint/suspicious/noExplicitAny: Test mocks require any type + Transform: mockTransform as any, + createWriteStream: mockCreateWriteStream, + }), + ).rejects.toThrow(); + }); + }); + + describe('isArchiveDownloadSupported', () => { + test('should return true for any GitHub repository', () => { + const repoInfo: GitHubRepoInfo = { + owner: 'yamadashy', + repo: 'repomix', + }; + + const result = isArchiveDownloadSupported(repoInfo); + expect(result).toBe(true); + }); + + test('should return true for repository with ref', () => { + const repoInfo: GitHubRepoInfo = { + owner: 'yamadashy', + repo: 'repomix', + ref: 'develop', + }; + + const result = isArchiveDownloadSupported(repoInfo); + expect(result).toBe(true); + }); + }); +}); diff --git a/tests/core/git/gitHubArchiveApi.test.ts b/tests/core/git/gitHubArchiveApi.test.ts new file mode 100644 index 000000000..c85611e9a --- /dev/null +++ b/tests/core/git/gitHubArchiveApi.test.ts @@ -0,0 +1,183 @@ +import { describe, expect, test } from 'vitest'; +import { + buildGitHubArchiveUrl, + buildGitHubMasterArchiveUrl, + buildGitHubTagArchiveUrl, + checkGitHubResponse, + getArchiveFilename, +} from '../../../src/core/git/gitHubArchiveApi.js'; +import { parseGitHubRepoInfo } from '../../../src/core/git/gitRemoteParse.js'; +import { RepomixError } from '../../../src/shared/errorHandle.js'; + +describe('GitHub Archive API', () => { + describe('buildGitHubArchiveUrl', () => { + test('should build URL for default branch', () => { + const repoInfo = { owner: 'user', repo: 'repo' }; + const url = buildGitHubArchiveUrl(repoInfo); + expect(url).toBe('https://github.com/user/repo/archive/refs/heads/main.zip'); + }); + + test('should build URL for specific branch', () => { + const repoInfo = { owner: 'user', repo: 'repo', ref: 'develop' }; + const url = buildGitHubArchiveUrl(repoInfo); + expect(url).toBe('https://github.com/user/repo/archive/refs/heads/develop.zip'); + }); + + test('should build URL for commit SHA', () => { + const repoInfo = { owner: 'user', repo: 'repo', ref: 'abc123def456' }; + const url = buildGitHubArchiveUrl(repoInfo); + expect(url).toBe('https://github.com/user/repo/archive/abc123def456.zip'); + }); + + test('should build URL for full commit SHA', () => { + const repoInfo = { owner: 'user', repo: 'repo', ref: 'abc123def456789012345678901234567890abcd' }; + const url = buildGitHubArchiveUrl(repoInfo); + expect(url).toBe('https://github.com/user/repo/archive/abc123def456789012345678901234567890abcd.zip'); + }); + }); + + describe('buildGitHubMasterArchiveUrl', () => { + test('should build URL for master branch fallback', () => { + const repoInfo = { owner: 'user', repo: 'repo' }; + const url = buildGitHubMasterArchiveUrl(repoInfo); + expect(url).toBe('https://github.com/user/repo/archive/refs/heads/master.zip'); + }); + + test('should return null when ref is specified', () => { + const repoInfo = { owner: 'user', repo: 'repo', ref: 'develop' }; + const url = buildGitHubMasterArchiveUrl(repoInfo); + expect(url).toBeNull(); + }); + }); + + describe('buildGitHubTagArchiveUrl', () => { + test('should build URL for tag', () => { + const repoInfo = { owner: 'user', repo: 'repo', ref: 'v1.0.0' }; + const url = buildGitHubTagArchiveUrl(repoInfo); + expect(url).toBe('https://github.com/user/repo/archive/refs/tags/v1.0.0.zip'); + }); + + test('should return null for commit SHA', () => { + const repoInfo = { owner: 'user', repo: 'repo', ref: 'abc123def456' }; + const url = buildGitHubTagArchiveUrl(repoInfo); + expect(url).toBeNull(); + }); + + test('should return null for no ref', () => { + const repoInfo = { owner: 'user', repo: 'repo' }; + const url = buildGitHubTagArchiveUrl(repoInfo); + expect(url).toBeNull(); + }); + }); + + describe('getArchiveFilename', () => { + test('should generate filename for default branch', () => { + const repoInfo = { owner: 'user', repo: 'myrepo' }; + const filename = getArchiveFilename(repoInfo); + expect(filename).toBe('myrepo-main.zip'); + }); + + test('should generate filename for specific branch', () => { + const repoInfo = { owner: 'user', repo: 'myrepo', ref: 'develop' }; + const filename = getArchiveFilename(repoInfo); + expect(filename).toBe('myrepo-develop.zip'); + }); + + test('should generate filename for tag with slash', () => { + const repoInfo = { owner: 'user', repo: 'myrepo', ref: 'release/v1.0' }; + const filename = getArchiveFilename(repoInfo); + expect(filename).toBe('myrepo-v1.0.zip'); + }); + + test('should generate filename for commit SHA', () => { + const repoInfo = { owner: 'user', repo: 'myrepo', ref: 'abc123' }; + const filename = getArchiveFilename(repoInfo); + expect(filename).toBe('myrepo-abc123.zip'); + }); + }); + + describe('checkGitHubResponse', () => { + test('should not throw for successful response', () => { + const mockResponse = new Response('', { status: 200 }); + expect(() => checkGitHubResponse(mockResponse)).not.toThrow(); + }); + + test('should throw RepomixError for 404', () => { + const mockResponse = new Response('', { status: 404 }); + expect(() => checkGitHubResponse(mockResponse)).toThrow(RepomixError); + expect(() => checkGitHubResponse(mockResponse)).toThrow('Repository not found or is private'); + }); + + test('should throw RepomixError for 403 with rate limit', () => { + const mockResponse = new Response('', { + status: 403, + headers: { 'X-RateLimit-Remaining': '0', 'X-RateLimit-Reset': '1234567890' }, + }); + expect(() => checkGitHubResponse(mockResponse)).toThrow(RepomixError); + expect(() => checkGitHubResponse(mockResponse)).toThrow('GitHub API rate limit exceeded'); + }); + + test('should throw RepomixError for 403 without rate limit', () => { + const mockResponse = new Response('', { status: 403 }); + expect(() => checkGitHubResponse(mockResponse)).toThrow(RepomixError); + expect(() => checkGitHubResponse(mockResponse)).toThrow('Access denied'); + }); + + test('should throw RepomixError for server errors', () => { + const mockResponse = new Response('', { status: 500 }); + expect(() => checkGitHubResponse(mockResponse)).toThrow(RepomixError); + expect(() => checkGitHubResponse(mockResponse)).toThrow('GitHub server error'); + }); + + test('should throw RepomixError for other errors', () => { + const mockResponse = new Response('', { status: 400 }); + expect(() => checkGitHubResponse(mockResponse)).toThrow(RepomixError); + expect(() => checkGitHubResponse(mockResponse)).toThrow('GitHub API error'); + }); + }); + + describe('parseGitHubRepoInfo integration', () => { + test('should parse shorthand format', () => { + const result = parseGitHubRepoInfo('yamadashy/repomix'); + expect(result).toEqual({ + owner: 'yamadashy', + repo: 'repomix', + }); + }); + + test('should parse HTTPS URL', () => { + const result = parseGitHubRepoInfo('https://github.com/yamadashy/repomix.git'); + expect(result).toEqual({ + owner: 'yamadashy', + repo: 'repomix', + }); + }); + + test('should parse SSH URL', () => { + const result = parseGitHubRepoInfo('git@github.com:yamadashy/repomix.git'); + expect(result).toEqual({ + owner: 'yamadashy', + repo: 'repomix', + }); + }); + + test('should parse URL with branch', () => { + const result = parseGitHubRepoInfo('https://github.com/yamadashy/repomix/tree/develop'); + expect(result).toEqual({ + owner: 'yamadashy', + repo: 'repomix', + ref: 'develop', + }); + }); + + test('should return null for non-GitHub URLs', () => { + const result = parseGitHubRepoInfo('https://gitlab.com/user/repo.git'); + expect(result).toBeNull(); + }); + + test('should return null for invalid format', () => { + const result = parseGitHubRepoInfo('invalid-format'); + expect(result).toBeNull(); + }); + }); +}); diff --git a/tests/core/git/gitRemoteParse.test.ts b/tests/core/git/gitRemoteParse.test.ts index 6c710f06f..437a16627 100644 --- a/tests/core/git/gitRemoteParse.test.ts +++ b/tests/core/git/gitRemoteParse.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { parseRemoteValue } from '../../../src/core/git/gitRemoteParse.js'; +import { isGitHubRepository, parseGitHubRepoInfo, parseRemoteValue } from '../../../src/core/git/gitRemoteParse.js'; import { isValidRemoteValue } from '../../../src/index.js'; vi.mock('../../../src/shared/logger'); @@ -154,4 +154,87 @@ describe('remoteAction functions', () => { }); }); }); + + describe('parseGitHubRepoInfo', () => { + test('should parse GitHub shorthand', () => { + const result = parseGitHubRepoInfo('yamadashy/repomix'); + expect(result).toEqual({ + owner: 'yamadashy', + repo: 'repomix', + }); + }); + + test('should parse GitHub URL with branch', () => { + const result = parseGitHubRepoInfo('https://github.com/yamadashy/repomix/tree/develop'); + expect(result).toEqual({ + owner: 'yamadashy', + repo: 'repomix', + ref: 'develop', + }); + }); + + test('should parse GitHub git URL', () => { + const result = parseGitHubRepoInfo('https://github.com/yamadashy/repomix.git'); + expect(result).toEqual({ + owner: 'yamadashy', + repo: 'repomix', + }); + }); + + test('should return null for non-GitHub URLs', () => { + const result = parseGitHubRepoInfo('https://gitlab.com/user/repo'); + expect(result).toBeNull(); + }); + + test('should return null for invalid URLs', () => { + const result = parseGitHubRepoInfo('invalid-url'); + expect(result).toBeNull(); + }); + + test('should handle git@ URLs', () => { + const result = parseGitHubRepoInfo('git@github.com:yamadashy/repomix.git'); + expect(result).toEqual({ + owner: 'yamadashy', + repo: 'repomix', + }); + }); + + test('should merge branch from parsing when URL contains branch info', () => { + const result = parseGitHubRepoInfo('https://github.com/yamadashy/repomix/tree/feature/test'); + expect(result).toEqual({ + owner: 'yamadashy', + repo: 'repomix', + ref: 'feature/test', + }); + }); + + test('should reject malicious URLs with github.com in path or query', () => { + // Malicious URLs that should not be treated as GitHub repositories + expect(parseGitHubRepoInfo('https://evil.com/github.com/user/repo')).toBeNull(); + expect(parseGitHubRepoInfo('https://evil.com/?redirect=github.com/user/repo')).toBeNull(); + expect(parseGitHubRepoInfo('https://evil.com#github.com/user/repo')).toBeNull(); + expect(parseGitHubRepoInfo('https://github.com.evil.com/user/repo')).toBeNull(); + }); + + test('should accept legitimate GitHub URLs', () => { + expect(parseGitHubRepoInfo('https://github.com/user/repo')).not.toBeNull(); + expect(parseGitHubRepoInfo('https://www.github.com/user/repo')).not.toBeNull(); + }); + }); + + describe('isGitHubRepository', () => { + test('should return true for GitHub repositories', () => { + expect(isGitHubRepository('yamadashy/repomix')).toBe(true); + expect(isGitHubRepository('https://github.com/yamadashy/repomix')).toBe(true); + expect(isGitHubRepository('git@github.com:yamadashy/repomix.git')).toBe(true); + expect(isGitHubRepository('https://github.com/yamadashy/repomix/tree/develop')).toBe(true); + }); + + test('should return false for non-GitHub repositories', () => { + expect(isGitHubRepository('https://gitlab.com/user/repo')).toBe(false); + expect(isGitHubRepository('https://bitbucket.org/user/repo')).toBe(false); + expect(isGitHubRepository('invalid-url')).toBe(false); + expect(isGitHubRepository('')).toBe(false); + }); + }); });