diff --git a/README.md b/README.md index 8ded8687f..d9e7eecfc 100644 --- a/README.md +++ b/README.md @@ -163,6 +163,13 @@ repomix --remote https://github.com/yamadashy/repomix --remote-branch main # Or use a specific commit hash: repomix --remote https://github.com/yamadashy/repomix --remote-branch 935b695 + +# Another convenient way is specifying the branch's URL +repomix --remote https://github.com/yamadashy/repomix/tree/main + +# Commit's URL is also supported +repomix --remote https://github.com/yamadashy/repomix/commit/836abcd7335137228ad77feb28655d85712680f1 + ``` To initialize a new configuration file (`repomix.config.json`): @@ -470,13 +477,21 @@ repomix --remote yamadashy/repomix You can specify the branch name, tag, or commit hash: ```bash +# Using --remote-branch option repomix --remote https://github.com/yamadashy/repomix --remote-branch main + +# Using branch's URL +repomix --remote https://github.com/yamadashy/repomix/tree/main ``` Or use a specific commit hash: ```bash +# Using --remote-branch option repomix --remote https://github.com/yamadashy/repomix --remote-branch 935b695 + +# Using commit's URL +repomix --remote https://github.com/yamadashy/repomix/commit/836abcd7335137228ad77feb28655d85712680f1 ``` ## ⚙️ Configuration diff --git a/package-lock.json b/package-lock.json index 3707fc52e..2aeb39826 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,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", @@ -35,6 +36,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", @@ -1743,6 +1745,13 @@ "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", "dev": true }, + "node_modules/@types/git-url-parse": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@types/git-url-parse/-/git-url-parse-9.0.3.tgz", + "integrity": "sha512-Wrb8zeghhpKbYuqAOg203g+9YSNlrZWNZYvwxJuDF4dTmerijqpnGbI79yCuPtHSXHPEwv1pAFUB4zsSqn82Og==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "22.13.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.13.0.tgz", @@ -1759,6 +1768,12 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, + "node_modules/@types/parse-path": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz", + "integrity": "sha512-LriObC2+KYZD3FzCrgWGv/qufdUy4eXrxcLgQMfYXgPbLIecKIsVBaQgUPmxSSLcjmYbDTQbMgr6qr6l/eb7Bg==", + "license": "MIT" + }, "node_modules/@types/strip-comments": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/@types/strip-comments/-/strip-comments-2.0.4.tgz", @@ -2522,6 +2537,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/git-up": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/git-up/-/git-up-8.0.0.tgz", + "integrity": "sha512-uBI8Zdt1OZlrYfGcSVroLJKgyNNXlgusYFzHk614lTasz35yg2PVpL1RMy0LOO2dcvF9msYW3pRfUSmafZNrjg==", + "license": "MIT", + "dependencies": { + "is-ssh": "^1.4.0", + "parse-url": "^9.2.0" + } + }, + "node_modules/git-url-parse": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/git-url-parse/-/git-url-parse-16.0.0.tgz", + "integrity": "sha512-Y8iAF0AmCaqXc6a5GYgPQW9ESbncNLOL+CeQAJRhmWUOmnPkKpBYeWYp4mFd3LA5j53CdGDdslzX12yEBVHQQg==", + "license": "MIT", + "dependencies": { + "git-up": "^8.0.0" + } + }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -2738,6 +2772,15 @@ "node": ">=0.12.0" } }, + "node_modules/is-ssh": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/is-ssh/-/is-ssh-1.4.0.tgz", + "integrity": "sha512-x7+VxdxOdlV3CYpjvRLBv5Lo9OJerlYanjwFrPR9fuGPjCiNiCzFgAWpiLAohSbsnH4ZAys3SBh+hq5rJosxUQ==", + "license": "MIT", + "dependencies": { + "protocols": "^2.0.1" + } + }, "node_modules/is-stream": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz", @@ -3265,6 +3308,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse-path": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse-path/-/parse-path-7.0.0.tgz", + "integrity": "sha512-Euf9GG8WT9CdqwuWJGdf3RkUcTBArppHABkO7Lm8IzRQp0e2r/kkFnmhu4TSK30Wcu5rVAZLmfPKSBBi9tWFog==", + "license": "MIT", + "dependencies": { + "protocols": "^2.0.0" + } + }, + "node_modules/parse-url": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/parse-url/-/parse-url-9.2.0.tgz", + "integrity": "sha512-bCgsFI+GeGWPAvAiUv63ZorMeif3/U0zaXABGJbOWt5OH2KCaPHF6S+0ok4aqM9RuIPGyZdx9tR9l13PsW4AYQ==", + "license": "MIT", + "dependencies": { + "@types/parse-path": "^7.0.0", + "parse-path": "^7.0.0" + }, + "engines": { + "node": ">=14.13.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -3380,6 +3445,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/protocols": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/protocols/-/protocols-2.0.1.tgz", + "integrity": "sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==", + "license": "MIT" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", diff --git a/package.json b/package.json index 5cc8962bb..47fe553a8 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/cli/actions/remoteAction.ts b/src/cli/actions/remoteAction.ts index 27cb6ab6a..ee6d66033 100644 --- a/src/cli/actions/remoteAction.ts +++ b/src/cli/actions/remoteAction.ts @@ -1,6 +1,7 @@ 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'; @@ -8,11 +9,9 @@ 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 IGitUrl extends GitUrl { + commit: string | undefined; +} export const runRemoteAction = async ( repoUrl: string, options: CliOptions, @@ -26,12 +25,8 @@ 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); const spinner = new Spinner('Cloning repository...'); - const tempDirPath = await createTempDirectory(); let result: DefaultActionRunnerResult; @@ -39,7 +34,7 @@ export const runRemoteAction = async ( spinner.start(); // Clone the repository - await cloneRepository(formatRemoteValueToUrl(repoUrl), tempDirPath, options.remoteBranch, { + await cloneRepository(parsedFields.repoUrl, tempDirPath, options.remoteBranch || parsedFields.remoteBranch, { execGitShallowClone: deps.execGitShallowClone, }); @@ -60,34 +55,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) as IGitUrl; + + // 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.filepath ? `${parsedFields.ref}/${parsedFields.filepath}` : parsedFields.ref, + }; + } + + if (parsedFields.commit) { + return { + repoUrl: repoUrl, + remoteBranch: parsedFields.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)'); } +}; - // 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 => { diff --git a/tests/cli/actions/remoteAction.test.ts b/tests/cli/actions/remoteAction.test.ts index 3669a984e..10001d4ea 100644 --- a/tests/cli/actions/remoteAction.test.ts +++ b/tests/cli/actions/remoteAction.test.ts @@ -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'; @@ -53,22 +53,75 @@ 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', + }); + expect( + parseRemoteValue('https://some.gitlab.domain/some/path/username/repo/-/tree/branchname/withslash'), + ).toEqual({ + repoUrl: 'https://some.gitlab.domain/some/path/username/repo.git', + remoteBranch: 'branchname/withslash', + }); + }); + + 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(); }); });