Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
f6d9208
feat(core): Add getRemoteRefs function to retrieve remote repository …
yamadashy May 18, 2025
8f9b307
refactor(core/git): Improve parseRemoteValue function formatting and …
yamadashy May 18, 2025
7a09a8d
fix(core): Add URL validation to getRemoteRefs function to prevent co…
devin-ai-integration[bot] May 22, 2025
2860e53
fix(core): Update URL validation in getRemoteRefs to check for git@ o…
devin-ai-integration[bot] May 22, 2025
2f4f842
fix(core): Throw errors for invalid URLs in getRemoteRefs function
devin-ai-integration[bot] May 23, 2025
9c4e773
style: Fix linting issues in gitCommand.test.ts
devin-ai-integration[bot] May 23, 2025
881e55e
fix(core): Enhance URL validation in getRemoteRefs to pass CodeQL check
devin-ai-integration[bot] May 23, 2025
70bbaa1
refactor(core): Create common URL validation function for Git commands
devin-ai-integration[bot] May 23, 2025
5421a1f
refactor(types): Add type definition for git-url-parse refs parameter
devin-ai-integration[bot] May 24, 2025
bd05347
docs(types): Add documentation explaining the purpose of git-url-pars…
devin-ai-integration[bot] May 24, 2025
f1d8f2f
style: Fix Biome linting issues in git-url-parse.d.ts
devin-ai-integration[bot] May 24, 2025
9c79a11
refactor: Remove Japanese documentation and move validateGitUrl to en…
devin-ai-integration[bot] May 24, 2025
3dda598
refactor: Improve error message for invalid URL protocol
devin-ai-integration[bot] May 24, 2025
58495bc
style: Fix formatting in gitCommand.test.ts
devin-ai-integration[bot] May 24, 2025
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
16 changes: 14 additions & 2 deletions src/cli/actions/remoteAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import os from 'node:os';
import path from 'node:path';
import pc from 'picocolors';
import { execGitShallowClone, isGitInstalled } from '../../core/git/gitCommand.js';
import { execGitShallowClone, getRemoteRefs, isGitInstalled } from '../../core/git/gitCommand.js';
import { parseRemoteValue } from '../../core/git/gitRemoteParse.js';
import { RepomixError } from '../../shared/errorHandle.js';
import { logger } from '../../shared/logger.js';
Expand All @@ -16,14 +16,26 @@
deps = {
isGitInstalled,
execGitShallowClone,
getRemoteRefs,
runDefaultAction,
},
): Promise<DefaultActionRunnerResult> => {
if (!(await deps.isGitInstalled())) {
throw new RepomixError('Git is not installed or not in the system PATH.');
}

const parsedFields = parseRemoteValue(repoUrl);
// Get remote refs
let refs: string[] = [];
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 as Error).message);
}

Check warning on line 34 in src/cli/actions/remoteAction.ts

View check run for this annotation

Codecov / codecov/patch

src/cli/actions/remoteAction.ts#L33-L34

Added lines #L33 - L34 were not covered by tests
Comment thread
yamadashy marked this conversation as resolved.

// Parse the remote URL with the refs information
const parsedFields = parseRemoteValue(repoUrl, refs);
Comment thread
yamadashy marked this conversation as resolved.

const spinner = new Spinner('Cloning repository...', cliOptions);
const tempDirPath = await createTempDirectory();
let result: DefaultActionRunnerResult;
Expand Down
68 changes: 58 additions & 10 deletions src/core/git/gitCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,24 +120,49 @@
}
};

export const execGitShallowClone = async (
export const getRemoteRefs = async (
url: string,
directory: string,
remoteBranch?: string,
deps = {
execFileAsync,
},
) => {
if (url.includes('--upload-pack') || url.includes('--config') || url.includes('--exec')) {
throw new RepomixError(`Invalid repository URL. URL contains potentially dangerous parameters: ${url}`);
}
): Promise<string[]> => {
validateGitUrl(url);

// Check if the URL is valid
try {
new URL(url);
const result = await deps.execFileAsync('git', ['ls-remote', '--heads', '--tags', url]);
Comment thread Dismissed

// Extract ref names from the output
// Format is: hash\tref_name
const refs = result.stdout
.split('\n')
.filter(Boolean)
.map((line) => {
// Skip the hash part and extract only the ref name
const parts = line.split('\t');
if (parts.length < 2) return '';

// Remove 'refs/heads/' or 'refs/tags/' prefix
return parts[1].replace(/^refs\/(heads|tags)\//, '');
})
.filter(Boolean);

logger.trace(`Found ${refs.length} refs in repository: ${url}`);
return refs;
} catch (error) {
throw new RepomixError(`Invalid repository URL. Please provide a valid URL. url: ${url}`);
logger.trace('Failed to get remote refs:', (error as Error).message);
throw new RepomixError(`Failed to get remote refs: ${(error as Error).message}`);
}
};

export const execGitShallowClone = async (
url: string,
directory: string,
remoteBranch?: string,
deps = {
execFileAsync,
},
) => {
validateGitUrl(url);

if (remoteBranch) {
await deps.execFileAsync('git', ['-C', directory, 'init']);
Expand Down Expand Up @@ -177,3 +202,26 @@
// Clean up .git directory
await fs.rm(path.join(directory, '.git'), { recursive: true, force: true });
};

/**
* Validates a Git URL for security and format
* @throws {RepomixError} If the URL is invalid or contains potentially dangerous parameters
*/
const validateGitUrl = (url: string): void => {
if (url.includes('--upload-pack') || url.includes('--config') || url.includes('--exec')) {
throw new RepomixError(`Invalid repository URL. URL contains potentially dangerous parameters: ${url}`);
}

// Check if the URL starts with git@ or https://
if (!(url.startsWith('git@') || url.startsWith('https://'))) {
throw new RepomixError(`Invalid URL protocol for '${url}'. URL must start with 'git@' or 'https://'`);
}

try {
if (url.startsWith('https://')) {
new URL(url);
}
} catch (error) {
throw new RepomixError(`Invalid repository URL. Please provide a valid URL: ${url}`);
}

Check warning on line 226 in src/core/git/gitCommand.ts

View check run for this annotation

Codecov / codecov/patch

src/core/git/gitCommand.ts#L225-L226

Added lines #L225 - L226 were not covered by tests
Comment thread
yamadashy marked this conversation as resolved.
};
13 changes: 8 additions & 5 deletions src/core/git/gitRemoteParse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ export const isValidShorthand = (remoteValue: string): boolean => {
return validShorthandRegex.test(remoteValue);
};

export const parseRemoteValue = (remoteValue: string): { repoUrl: string; remoteBranch: string | undefined } => {
export const parseRemoteValue = (
remoteValue: string,
refs: string[] = [],
): { repoUrl: string; remoteBranch: string | undefined } => {
if (isValidShorthand(remoteValue)) {
logger.trace(`Formatting GitHub shorthand: ${remoteValue}`);
return {
Expand All @@ -23,7 +26,7 @@ export const parseRemoteValue = (remoteValue: string): { repoUrl: string; remote
}

try {
const parsedFields = gitUrlParse(remoteValue) as IGitUrl;
const parsedFields = gitUrlParse(remoteValue, refs) as IGitUrl;

// This will make parsedFields.toString() automatically append '.git' to the returned url
parsedFields.git_suffix = true;
Expand All @@ -40,7 +43,7 @@ export const parseRemoteValue = (remoteValue: string): { repoUrl: string; remote
if (parsedFields.ref) {
return {
repoUrl: repoUrl,
remoteBranch: parsedFields.filepath ? `${parsedFields.ref}/${parsedFields.filepath}` : parsedFields.ref,
remoteBranch: parsedFields.ref,
Comment thread
yamadashy marked this conversation as resolved.
};
}

Expand All @@ -60,9 +63,9 @@ export const parseRemoteValue = (remoteValue: string): { repoUrl: string; remote
}
};

export const isValidRemoteValue = (remoteValue: string): boolean => {
export const isValidRemoteValue = (remoteValue: string, refs: string[] = []): boolean => {
try {
parseRemoteValue(remoteValue);
parseRemoteValue(remoteValue, refs);
return true;
} catch (error) {
return false;
Expand Down
55 changes: 55 additions & 0 deletions src/types/git-url-parse.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Type definition extension for git-url-parse library
*
* This file exists because the git-url-parse library's built-in type definitions
* are incomplete. The library supports a second 'refs' parameter for the gitUrlParse
* function, which is documented in the library's README but not included in its
* type definitions.
*
* Without this type definition extension, we would need to use @ts-ignore when
* calling gitUrlParse with the refs parameter, which reduces type safety and
* makes the code harder to maintain.
*
* This file uses TypeScript's module augmentation feature to extend the existing
* type definitions without modifying the original library code.
*/

declare module 'git-url-parse' {
import gitUp = require('git-up');

namespace gitUrlParse {
interface GitUrl extends gitUp.ParsedUrl {
/** The Git provider (e.g. `"github.com"`). */
source: string;
/** The repository owner. */
owner: string;
/** The repository name. */
name: string;
/** The repository ref (e.g., "master" or "dev"). */
ref: string;
/** A filepath relative to the repository root. */
filepath: string;
/** The type of filepath in the url ("blob" or "tree"). */
filepathtype: string;
/** The owner and name values in the `owner/name` format. */
full_name: string;
/** The organization the owner belongs to. This is CloudForge specific. */
organization: string;
/** Whether to add the `.git` suffix or not. */
git_suffix?: boolean | undefined;
toString(type?: string): string;
}

function stringify(url: GitUrl, type?: string): string;
}

/**
* Parses a Git url.
* @param url The Git url to parse.
* @param refs An array of strings representing the refs. This is helpful for URLs with branches containing slashes.
* @returns The GitUrl object containing parsed information.
*/
function gitUrlParse(url: string, refs?: string[]): gitUrlParse.GitUrl;

export = gitUrlParse;
}
1 change: 1 addition & 0 deletions tests/cli/actions/remoteAction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ describe('remoteAction functions', () => {
execGitShallowClone: async (url: string, directory: string) => {
await fs.writeFile(path.join(directory, 'README.md'), 'Hello, world!');
},
getRemoteRefs: async () => Promise.resolve(['main']),
runDefaultAction: async () => {
return {
packResult: {
Expand Down
59 changes: 59 additions & 0 deletions tests/core/git/gitCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from 'vitest';
import {
execGitShallowClone,
getFileChangeCount,
getRemoteRefs,
getWorkTreeDiff,
isGitInstalled,
isGitRepository,
Expand Down Expand Up @@ -333,4 +334,62 @@ file2.ts

expect(mockFileExecAsync).not.toHaveBeenCalled();
});

describe('getRemoteRefs', () => {
test('should return refs when URL is valid', async () => {
const mockOutput = `
a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6\trefs/heads/main
b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7\trefs/heads/develop
c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8\trefs/tags/v1.0.0
`.trim();
const mockFileExecAsync = vi.fn().mockResolvedValue({ stdout: mockOutput });

const result = await getRemoteRefs('https://github.com/user/repo.git', { execFileAsync: mockFileExecAsync });

expect(result).toEqual(['main', 'develop', 'v1.0.0']);
expect(mockFileExecAsync).toHaveBeenCalledWith('git', [
'ls-remote',
'--heads',
'--tags',
'https://github.com/user/repo.git',
]);
});

test('should throw error when URL does not start with git@ or https://', async () => {
const mockFileExecAsync = vi.fn();

await expect(getRemoteRefs('invalid-url', { execFileAsync: mockFileExecAsync })).rejects.toThrow(
"Invalid URL protocol for 'invalid-url'. URL must start with 'git@' or 'https://'",
);

expect(mockFileExecAsync).not.toHaveBeenCalled();
});

test('should throw error when URL contains dangerous parameters', async () => {
const mockFileExecAsync = vi.fn();

await expect(
getRemoteRefs('https://github.com/user/repo.git --upload-pack=evil-command', {
execFileAsync: mockFileExecAsync,
}),
).rejects.toThrow('Invalid repository URL. URL contains potentially dangerous parameters');

expect(mockFileExecAsync).not.toHaveBeenCalled();
});

test('should throw error when git command fails', async () => {
const mockFileExecAsync = vi.fn().mockRejectedValue(new Error('git command failed'));

await expect(
getRemoteRefs('https://github.com/user/repo.git', { execFileAsync: mockFileExecAsync }),
).rejects.toThrow('Failed to get remote refs: git command failed');

expect(mockFileExecAsync).toHaveBeenCalledWith('git', [
'ls-remote',
'--heads',
'--tags',
'https://github.com/user/repo.git',
]);
});
});
});
4 changes: 3 additions & 1 deletion tests/core/git/gitRemoteParse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,9 @@ describe('remoteAction functions', () => {
remoteBranch: 'branchname',
});
expect(
parseRemoteValue('https://some.gitlab.domain/some/path/username/repo/-/tree/branchname/withslash'),
parseRemoteValue('https://some.gitlab.domain/some/path/username/repo/-/tree/branchname/withslash', [
'branchname/withslash',
]),
).toEqual({
repoUrl: 'https://some.gitlab.domain/some/path/username/repo.git',
remoteBranch: 'branchname/withslash',
Expand Down
Loading