Skip to content

Commit d7abd41

Browse files
authored
Merge pull request #1628 from serhiizghama/feat/auto-detect-github-shorthand
feat(cli): auto-detect GitHub shorthand (owner/repo) in positional arguments
2 parents 915f2af + f40d0e9 commit d7abd41

8 files changed

Lines changed: 254 additions & 10 deletions

File tree

src/cli/cliRun.ts

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import * as fs from 'node:fs/promises';
2+
import path from 'node:path';
13
import process from 'node:process';
24
import { Option, program } from 'commander';
35
import pc from 'picocolors';
46
import { getVersion } from '../core/file/packageJsonParse.js';
5-
import { isExplicitRemoteUrl } from '../core/git/gitRemoteUrl.js';
7+
import { isExplicitRemoteUrl, isValidShorthand } from '../core/git/gitRemoteUrl.js';
68
import { handleError, RepomixError } from '../shared/errorHandle.js';
79
import { logger, repomixLogLevels } from '../shared/logger.js';
810
import { parseHumanSizeToBytes } from '../shared/sizeParse.js';
@@ -296,6 +298,35 @@ export const runCli = async (directories: string[], cwd: string, options: CliOpt
296298
return await runRemoteAction(directories[0], options);
297299
}
298300

301+
// Auto-detect GitHub shorthand (owner/repo) in positional arguments.
302+
// Shorthand is ambiguous with relative local paths, so it is only treated as remote when:
303+
// 1. the argument does not exist as a local path, and
304+
// 2. the repository is confirmed reachable on GitHub (HEAD-only `git ls-remote` probe).
305+
// A mistyped local path (e.g. `src/uitls`) fails the probe and falls through to the
306+
// regular local-path handling instead of triggering an unintended clone attempt.
307+
// Skipped in stdin mode, where positional directory arguments are rejected.
308+
if (directories.length === 1 && !options.stdin && isValidShorthand(directories[0])) {
309+
const localPathExists = await fs.access(path.resolve(cwd, directories[0])).then(
310+
() => true,
311+
// EACCES/EPERM mean the path exists but is inaccessible — keep local-path precedence
312+
// and only fall through to the remote probe when the path is truly missing.
313+
(error: NodeJS.ErrnoException) => !['ENOENT', 'ENOTDIR'].includes(error.code ?? ''),
314+
);
315+
if (!localPathExists) {
316+
const { checkRemoteRepoExists } = await import('../core/git/gitRemoteHandle.js');
317+
if (await checkRemoteRepoExists(`https://github.com/${directories[0]}.git`)) {
318+
logger.log(
319+
pc.dim(
320+
`Detected GitHub repository shorthand: ${directories[0]} (prefix with ./ to treat it as a local path)\n`,
321+
),
322+
);
323+
const { runRemoteAction } = await import('./actions/remoteAction.js');
324+
return await runRemoteAction(directories[0], options);
325+
}
326+
logger.trace(`Argument matches owner/repo shorthand but is not a reachable GitHub repository: ${directories[0]}`);
327+
}
328+
}
329+
299330
const { runDefaultAction } = await import('./actions/defaultAction.js');
300331
return await runDefaultAction(directories, cwd, options);
301332
};

src/core/git/gitCommand.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ const GIT_REMOTE_TIMEOUT = 30000;
1111
const gitRemoteEnv = { ...process.env, GIT_TERMINAL_PROMPT: '0' };
1212
const gitRemoteOpts = { timeout: GIT_REMOTE_TIMEOUT, env: gitRemoteEnv };
1313

14+
// Opts for the automatic existence probe. It can be triggered by a mistyped local path,
15+
// so it must fail fast on slow/offline networks and must never block on credential
16+
// prompts (GCM_INTERACTIVE suppresses Git Credential Manager GUI popups on Windows).
17+
const GIT_PROBE_TIMEOUT = 5000;
18+
const gitProbeOpts = { timeout: GIT_PROBE_TIMEOUT, env: { ...gitRemoteEnv, GCM_INTERACTIVE: 'never' } };
19+
1420
export const execGitLogFilenames = async (
1521
directory: string,
1622
maxCommits = 100,
@@ -105,6 +111,27 @@ export const execLsRemote = async (
105111
}
106112
};
107113

114+
/**
115+
* Lightweight remote existence probe: only asks for HEAD instead of all refs,
116+
* so the response stays tiny even for repositories with thousands of branches/tags.
117+
*/
118+
export const execLsRemoteHead = async (
119+
url: string,
120+
deps = {
121+
execFileAsync,
122+
},
123+
): Promise<string> => {
124+
validateGitUrl(url);
125+
126+
try {
127+
const result = await deps.execFileAsync('git', ['ls-remote', '--', url, 'HEAD'], gitProbeOpts);
128+
return result.stdout || '';
129+
} catch (error) {
130+
logger.trace('Failed to execute git ls-remote HEAD:', (error as Error).message);
131+
throw error;
132+
}
133+
};
134+
108135
export const execGitShallowClone = async (
109136
url: string,
110137
directory: string,

src/core/git/gitRemoteHandle.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,29 @@
11
import { RepomixError } from '../../shared/errorHandle.js';
22
import { logger } from '../../shared/logger.js';
3-
import { execLsRemote, validateGitUrl } from './gitCommand.js';
3+
import { execLsRemote, execLsRemoteHead, validateGitUrl } from './gitCommand.js';
4+
5+
/**
6+
* Checks if a remote repository exists and is reachable without cloning it.
7+
* Uses a HEAD-only `git ls-remote` so the probe is cheap even for large repositories.
8+
* Returns false for unreachable, non-existent, or auth-gated repositories
9+
* (interactive credential prompts are disabled for remote git commands).
10+
*/
11+
export const checkRemoteRepoExists = async (
12+
url: string,
13+
deps = {
14+
execLsRemoteHead,
15+
},
16+
): Promise<boolean> => {
17+
validateGitUrl(url);
18+
19+
try {
20+
await deps.execLsRemoteHead(url);
21+
return true;
22+
} catch (error) {
23+
logger.trace(`Remote repository not reachable: ${url}:`, (error as Error).message);
24+
return false;
25+
}
26+
};
427

528
export const getRemoteRefs = async (
629
url: string,

src/core/git/gitRemoteParse.ts

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import gitUrlParse, { type GitUrl } from 'git-url-parse';
22
import { RepomixError } from '../../shared/errorHandle.js';
33
import { logger } from '../../shared/logger.js';
4+
import { isValidShorthand } from './gitRemoteUrl.js';
45

56
interface IGitUrl extends GitUrl {
67
commit: string | undefined;
@@ -12,12 +13,8 @@ export interface GitHubRepoInfo {
1213
ref?: string; // branch, tag, or commit SHA
1314
}
1415

15-
// Check the short form of the GitHub URL. e.g. yamadashy/repomix
16-
const VALID_NAME_PATTERN = '[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?';
17-
const validShorthandRegex = new RegExp(`^${VALID_NAME_PATTERN}/${VALID_NAME_PATTERN}$`);
18-
export const isValidShorthand = (remoteValue: string): boolean => {
19-
return validShorthandRegex.test(remoteValue);
20-
};
16+
// Re-export from lightweight module to preserve public API
17+
export { isValidShorthand } from './gitRemoteUrl.js';
2118

2219
/**
2320
* Check if a URL is an Azure DevOps repository URL by validating the hostname.

src/core/git/gitRemoteUrl.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,17 @@ export const remoteUrlPrefixes = ['https://', 'git@', 'ssh://', 'git://'] as con
1414
export const isExplicitRemoteUrl = (value: string): boolean => {
1515
return remoteUrlPrefixes.some((prefix) => value.startsWith(prefix));
1616
};
17+
18+
// Check the short form of the GitHub URL. e.g. yamadashy/repomix
19+
const VALID_NAME_PATTERN = '[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?';
20+
const validShorthandRegex = new RegExp(`^${VALID_NAME_PATTERN}/${VALID_NAME_PATTERN}$`);
21+
22+
/**
23+
* Checks if a string matches the GitHub shorthand format (owner/repo).
24+
* Note: a relative local path like `src/utils` also matches this pattern,
25+
* so callers must disambiguate against the local filesystem before treating
26+
* a shorthand match as a remote repository.
27+
*/
28+
export const isValidShorthand = (remoteValue: string): boolean => {
29+
return validShorthandRegex.test(remoteValue);
30+
};

tests/cli/cliRun.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as fs from 'node:fs/promises';
12
import { program } from 'commander';
23
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
34
import * as defaultAction from '../../src/cli/actions/defaultAction.js';
@@ -6,6 +7,7 @@ import * as remoteAction from '../../src/cli/actions/remoteAction.js';
67
import * as versionAction from '../../src/cli/actions/versionAction.js';
78
import { run, runCli } from '../../src/cli/cliRun.js';
89
import type { CliOptions } from '../../src/cli/types.js';
10+
import * as gitRemoteHandle from '../../src/core/git/gitRemoteHandle.js';
911
import type { PackResult } from '../../src/core/packager.js';
1012
import { logger, type RepomixLogLevel, repomixLogLevels } from '../../src/shared/logger.js';
1113
import { createMockConfig } from '../testing/testUtils.js';
@@ -40,11 +42,25 @@ vi.mock('../../src/shared/logger', () => ({
4042
vi.mock('../../src/cli/actions/defaultAction');
4143
vi.mock('../../src/cli/actions/initAction');
4244
vi.mock('../../src/cli/actions/remoteAction');
45+
vi.mock('../../src/core/git/gitRemoteHandle');
4346
vi.mock('../../src/cli/actions/versionAction');
4447

48+
// Partial mock: `access` is spyable for shorthand-detection tests, everything else stays real.
49+
vi.mock('node:fs/promises', async (importOriginal) => {
50+
const actual = await importOriginal<typeof import('node:fs/promises')>();
51+
return {
52+
...actual,
53+
access: vi.fn(actual.access),
54+
};
55+
});
56+
57+
const actualFs = await vi.importActual<typeof import('node:fs/promises')>('node:fs/promises');
58+
4559
describe('cliRun', () => {
4660
beforeEach(() => {
4761
vi.resetAllMocks();
62+
// resetAllMocks clears the default implementation — restore real fs.access behavior.
63+
vi.mocked(fs.access).mockImplementation(actualFs.access);
4864

4965
vi.mocked(defaultAction.runDefaultAction).mockResolvedValue({
5066
config: createMockConfig({
@@ -241,13 +257,66 @@ describe('cliRun', () => {
241257
expect(defaultAction.runDefaultAction).not.toHaveBeenCalled();
242258
});
243259

244-
test('should not auto-detect shorthand format as remote URL', async () => {
260+
test('should auto-detect shorthand when no local path exists and the repository is reachable', async () => {
261+
vi.mocked(gitRemoteHandle.checkRemoteRepoExists).mockResolvedValue(true);
262+
263+
await runCli(['user/repo'], process.cwd(), {});
264+
265+
expect(gitRemoteHandle.checkRemoteRepoExists).toHaveBeenCalledWith('https://github.com/user/repo.git');
266+
expect(remoteAction.runRemoteAction).toHaveBeenCalledWith('user/repo', expect.any(Object));
267+
expect(defaultAction.runDefaultAction).not.toHaveBeenCalled();
268+
});
269+
270+
test('should fall back to local handling when shorthand is not a reachable repository', async () => {
271+
vi.mocked(gitRemoteHandle.checkRemoteRepoExists).mockResolvedValue(false);
272+
273+
await runCli(['user/repo'], process.cwd(), {});
274+
275+
expect(gitRemoteHandle.checkRemoteRepoExists).toHaveBeenCalledWith('https://github.com/user/repo.git');
276+
expect(defaultAction.runDefaultAction).toHaveBeenCalledWith(['user/repo'], process.cwd(), expect.any(Object));
277+
expect(remoteAction.runRemoteAction).not.toHaveBeenCalled();
278+
});
279+
280+
test('should prefer existing local path over shorthand auto-detection', async () => {
281+
// Simulate an existing local path that also matches the owner/repo pattern.
282+
// Mocking fs.access keeps the test independent of the repository's own directory layout.
283+
vi.mocked(fs.access).mockResolvedValue(undefined);
284+
245285
await runCli(['user/repo'], process.cwd(), {});
246286

287+
expect(gitRemoteHandle.checkRemoteRepoExists).not.toHaveBeenCalled();
247288
expect(defaultAction.runDefaultAction).toHaveBeenCalledWith(['user/repo'], process.cwd(), expect.any(Object));
248289
expect(remoteAction.runRemoteAction).not.toHaveBeenCalled();
249290
});
250291

292+
test('should treat permission-denied local path as existing and skip the remote probe', async () => {
293+
const accessError = Object.assign(new Error('permission denied'), { code: 'EACCES' });
294+
vi.mocked(fs.access).mockRejectedValue(accessError);
295+
296+
await runCli(['user/repo'], process.cwd(), {});
297+
298+
expect(gitRemoteHandle.checkRemoteRepoExists).not.toHaveBeenCalled();
299+
expect(remoteAction.runRemoteAction).not.toHaveBeenCalled();
300+
expect(defaultAction.runDefaultAction).toHaveBeenCalledWith(['user/repo'], process.cwd(), expect.any(Object));
301+
});
302+
303+
test('should not treat Windows-style absolute path as shorthand', async () => {
304+
// `C:` contains a colon, which the owner/repo pattern rejects — no probe even when missing locally.
305+
await runCli(['C:/project'], process.cwd(), {});
306+
307+
expect(gitRemoteHandle.checkRemoteRepoExists).not.toHaveBeenCalled();
308+
expect(remoteAction.runRemoteAction).not.toHaveBeenCalled();
309+
expect(defaultAction.runDefaultAction).toHaveBeenCalledWith(['C:/project'], process.cwd(), expect.any(Object));
310+
});
311+
312+
test('should not probe shorthand in stdin mode', async () => {
313+
await runCli(['user/repo'], process.cwd(), { stdin: true });
314+
315+
expect(gitRemoteHandle.checkRemoteRepoExists).not.toHaveBeenCalled();
316+
expect(remoteAction.runRemoteAction).not.toHaveBeenCalled();
317+
expect(defaultAction.runDefaultAction).toHaveBeenCalledWith(['user/repo'], process.cwd(), expect.any(Object));
318+
});
319+
251320
test('should prioritize explicit --remote flag over auto-detected URL', async () => {
252321
await runCli(['https://github.com/other/repo'], process.cwd(), {
253322
remote: 'yamadashy/repomix',

tests/core/git/gitCommand.test.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
execGitShallowClone,
88
execGitVersion,
99
execLsRemote,
10+
execLsRemoteHead,
1011
} from '../../../src/core/git/gitCommand.js';
1112
import { logger } from '../../../src/shared/logger.js';
1213

@@ -17,6 +18,12 @@ const expectGitRemoteOpts = expect.objectContaining({
1718
env: expect.objectContaining({ GIT_TERMINAL_PROMPT: '0' }),
1819
});
1920

21+
// The automatic existence probe uses a short timeout and suppresses credential prompts.
22+
const expectGitProbeOpts = expect.objectContaining({
23+
timeout: 5000,
24+
env: expect.objectContaining({ GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'never' }),
25+
});
26+
2027
describe('gitCommand', () => {
2128
beforeEach(() => {
2229
vi.resetAllMocks();
@@ -434,4 +441,42 @@ c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8\trefs/tags/v1.0.0
434441
expect(logger.trace).toHaveBeenCalledWith('Failed to execute git ls-remote:', 'git command failed');
435442
});
436443
});
444+
445+
describe('execLsRemoteHead', () => {
446+
test('should query only HEAD instead of all refs', async () => {
447+
const mockOutput = 'a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6\tHEAD';
448+
const mockFileExecAsync = vi.fn().mockResolvedValue({ stdout: mockOutput });
449+
450+
const result = await execLsRemoteHead('https://github.com/user/repo.git', {
451+
execFileAsync: mockFileExecAsync,
452+
});
453+
454+
expect(result).toBe(mockOutput);
455+
expect(mockFileExecAsync).toHaveBeenCalledWith(
456+
'git',
457+
['ls-remote', '--', 'https://github.com/user/repo.git', 'HEAD'],
458+
expectGitProbeOpts,
459+
);
460+
});
461+
462+
test('should throw error when git ls-remote HEAD fails', async () => {
463+
const mockFileExecAsync = vi.fn().mockRejectedValue(new Error('repository not found'));
464+
465+
await expect(
466+
execLsRemoteHead('https://github.com/user/nonexistent.git', { execFileAsync: mockFileExecAsync }),
467+
).rejects.toThrow('repository not found');
468+
expect(logger.trace).toHaveBeenCalledWith('Failed to execute git ls-remote HEAD:', 'repository not found');
469+
});
470+
471+
test('should validate URL before executing', async () => {
472+
const mockFileExecAsync = vi.fn();
473+
474+
await expect(
475+
execLsRemoteHead('https://github.com/user/repo.git --upload-pack=evil-command', {
476+
execFileAsync: mockFileExecAsync,
477+
}),
478+
).rejects.toThrow('Invalid repository URL. URL contains potentially dangerous parameters');
479+
expect(mockFileExecAsync).not.toHaveBeenCalled();
480+
});
481+
});
437482
});

tests/core/git/gitRemoteHandle.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { beforeEach, describe, expect, test, vi } from 'vitest';
2-
import { getRemoteRefs } from '../../../src/core/git/gitRemoteHandle.js';
2+
import { checkRemoteRepoExists, getRemoteRefs } from '../../../src/core/git/gitRemoteHandle.js';
33
import { logger } from '../../../src/shared/logger.js';
44

55
vi.mock('../../../src/shared/logger');
@@ -9,6 +9,44 @@ describe('gitRemoteHandle', () => {
99
vi.resetAllMocks();
1010
});
1111

12+
describe('checkRemoteRepoExists', () => {
13+
test('should return true when the repository is reachable', async () => {
14+
const mockExecLsRemoteHead = vi.fn().mockResolvedValue('a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6\tHEAD');
15+
16+
const result = await checkRemoteRepoExists('https://github.com/user/repo.git', {
17+
execLsRemoteHead: mockExecLsRemoteHead,
18+
});
19+
20+
expect(result).toBe(true);
21+
expect(mockExecLsRemoteHead).toHaveBeenCalledWith('https://github.com/user/repo.git');
22+
});
23+
24+
test('should return false when the repository is not reachable', async () => {
25+
const mockExecLsRemoteHead = vi.fn().mockRejectedValue(new Error('Repository not found'));
26+
27+
const result = await checkRemoteRepoExists('https://github.com/user/nonexistent.git', {
28+
execLsRemoteHead: mockExecLsRemoteHead,
29+
});
30+
31+
expect(result).toBe(false);
32+
expect(logger.trace).toHaveBeenCalledWith(
33+
'Remote repository not reachable: https://github.com/user/nonexistent.git:',
34+
'Repository not found',
35+
);
36+
});
37+
38+
test('should reject dangerous URLs before probing', async () => {
39+
const mockExecLsRemoteHead = vi.fn();
40+
41+
await expect(
42+
checkRemoteRepoExists('https://github.com/user/repo.git --upload-pack=evil-command', {
43+
execLsRemoteHead: mockExecLsRemoteHead,
44+
}),
45+
).rejects.toThrow('Invalid repository URL. URL contains potentially dangerous parameters');
46+
expect(mockExecLsRemoteHead).not.toHaveBeenCalled();
47+
});
48+
});
49+
1250
describe('getRemoteRefs', () => {
1351
test('should return refs when URL is valid', async () => {
1452
const mockOutput = `

0 commit comments

Comments
 (0)