gitRemoteParse.tsā¢5.98 kB
import gitUrlParse, { type GitUrl } from 'git-url-parse';
import { RepomixError } from '../../shared/errorHandle.js';
import { logger } from '../../shared/logger.js';
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}$`);
export const isValidShorthand = (remoteValue: string): boolean => {
return validShorthandRegex.test(remoteValue);
};
/**
* Check if a URL is an Azure DevOps repository URL by validating the hostname.
* This uses proper URL parsing to avoid security issues with substring matching.
*/
const isAzureDevOpsUrl = (remoteValue: string): boolean => {
// Handle SSH URLs (e.g., git@ssh.dev.azure.com:v3/org/project/repo)
if (remoteValue.startsWith('git@ssh.dev.azure.com:')) {
return true;
}
// Handle HTTP(S) URLs
try {
const url = new URL(remoteValue);
const hostname = url.hostname.toLowerCase();
// Check for exact Azure DevOps hostnames
if (hostname === 'dev.azure.com' || hostname === 'ssh.dev.azure.com') {
return true;
}
// Check for legacy Visual Studio Team Services (*.visualstudio.com)
if (hostname.endsWith('.visualstudio.com')) {
return true;
}
return false;
} catch {
// Not a valid URL, let git-url-parse handle it
return false;
}
};
export const parseRemoteValue = (
remoteValue: string,
refs: 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 for Azure DevOps URLs before parsing, as git-url-parse may not handle them correctly
// - SSH: git@ssh.dev.azure.com:v3/org/project/repo
// - HTTPS: https://dev.azure.com/organization/project/_git/repo
// - Legacy: https://org.visualstudio.com/project/_git/repo
if (isAzureDevOpsUrl(remoteValue)) {
return {
repoUrl: remoteValue,
remoteBranch: undefined,
};
}
try {
const parsedFields = gitUrlParse(remoteValue, refs) 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.ref,
};
}
if (parsedFields.commit) {
return {
repoUrl: repoUrl,
remoteBranch: parsedFields.commit,
};
}
return {
repoUrl: repoUrl,
remoteBranch: undefined,
};
} catch {
throw new RepomixError('Invalid remote repository URL or repository shorthand (owner/repo)');
}
};
export const isValidRemoteValue = (remoteValue: string, refs: string[] = []): boolean => {
try {
parseRemoteValue(remoteValue, refs);
return true;
} catch {
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;
};