mirror of
https://github.com/yamadashy/repomix.git
synced 2026-05-30 11:18:53 +02:00
f2b41b875a
The --config flag represents an explicit user choice and should not be blocked in remote mode. Only auto-detected config files in the cloned repo are skipped. Also adds a logger.note() message when a config file is found in the remote repository but skipped, guiding users to --remote-trust-config. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
428 lines
20 KiB
TypeScript
428 lines
20 KiB
TypeScript
import type { Stats } from 'node:fs';
|
|
import * as fs from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
import { beforeEach, describe, expect, test, vi } from 'vitest';
|
|
import { loadFileConfig, mergeConfigs } from '../../src/config/configLoad.js';
|
|
import { defaultConfig, type RepomixConfigCli, type RepomixConfigFile } from '../../src/config/configSchema.js';
|
|
import { getGlobalDirectory } from '../../src/config/globalDirectory.js';
|
|
import { RepomixConfigValidationError } from '../../src/shared/errorHandle.js';
|
|
import { logger } from '../../src/shared/logger.js';
|
|
|
|
vi.mock('node:fs/promises');
|
|
vi.mock('../../src/shared/logger', () => ({
|
|
logger: {
|
|
trace: vi.fn(),
|
|
note: vi.fn(),
|
|
log: vi.fn(),
|
|
warn: vi.fn(),
|
|
},
|
|
}));
|
|
vi.mock('../../src/config/globalDirectory', () => ({
|
|
getGlobalDirectory: vi.fn(),
|
|
}));
|
|
|
|
describe('configLoad', () => {
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
process.env = {};
|
|
});
|
|
|
|
describe('loadFileConfig', () => {
|
|
test('should load and parse a valid local config file', async () => {
|
|
const mockConfig = {
|
|
output: { filePath: 'test-output.txt' },
|
|
ignore: { useDefaultPatterns: true },
|
|
};
|
|
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockConfig));
|
|
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as Stats);
|
|
|
|
const result = await loadFileConfig(process.cwd(), 'test-config.json');
|
|
expect(result).toEqual(mockConfig);
|
|
});
|
|
|
|
test('should throw RepomixConfigValidationError for invalid config', async () => {
|
|
const invalidConfig = {
|
|
output: { filePath: 123, style: 'invalid' }, // Invalid filePath type and invalid style
|
|
ignore: { useDefaultPatterns: 'not a boolean' }, // Invalid type
|
|
};
|
|
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(invalidConfig));
|
|
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as Stats);
|
|
|
|
await expect(loadFileConfig(process.cwd(), 'test-config.json')).rejects.toThrow(RepomixConfigValidationError);
|
|
});
|
|
|
|
test('should load global config when local config is not found', async () => {
|
|
const mockGlobalConfig = {
|
|
output: { filePath: 'global-output.txt' },
|
|
ignore: { useDefaultPatterns: false },
|
|
};
|
|
vi.mocked(getGlobalDirectory).mockReturnValue('/global/repomix');
|
|
vi.mocked(fs.stat)
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.ts
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.mts
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.cts
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.js
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.mjs
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.cjs
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.json5
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.jsonc
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.json
|
|
.mockRejectedValueOnce(new Error('File not found')) // Global repomix.config.ts
|
|
.mockRejectedValueOnce(new Error('File not found')) // Global repomix.config.mts
|
|
.mockRejectedValueOnce(new Error('File not found')) // Global repomix.config.cts
|
|
.mockRejectedValueOnce(new Error('File not found')) // Global repomix.config.js
|
|
.mockRejectedValueOnce(new Error('File not found')) // Global repomix.config.mjs
|
|
.mockRejectedValueOnce(new Error('File not found')) // Global repomix.config.cjs
|
|
.mockResolvedValueOnce({ isFile: () => true } as Stats); // Global repomix.config.json5
|
|
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockGlobalConfig));
|
|
|
|
const result = await loadFileConfig(process.cwd(), null);
|
|
expect(result).toEqual(mockGlobalConfig);
|
|
expect(fs.readFile).toHaveBeenCalledWith(path.join('/global/repomix', 'repomix.config.json5'), 'utf-8');
|
|
});
|
|
|
|
test('should return an empty object if no config file is found', async () => {
|
|
const loggerSpy = vi.spyOn(logger, 'log').mockImplementation(vi.fn());
|
|
vi.mocked(getGlobalDirectory).mockReturnValue('/global/repomix');
|
|
vi.mocked(fs.stat).mockRejectedValue(new Error('File not found'));
|
|
|
|
const result = await loadFileConfig(process.cwd(), null);
|
|
expect(result).toEqual({});
|
|
|
|
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('No custom config found'));
|
|
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('repomix.config.json5'));
|
|
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('repomix.config.jsonc'));
|
|
expect(loggerSpy).toHaveBeenCalledWith(expect.stringContaining('repomix.config.json'));
|
|
});
|
|
|
|
test('should throw an error for invalid JSON', async () => {
|
|
vi.mocked(fs.readFile).mockResolvedValue('invalid json');
|
|
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as Stats);
|
|
|
|
await expect(loadFileConfig(process.cwd(), 'test-config.json')).rejects.toThrow('Invalid syntax');
|
|
});
|
|
|
|
test('should parse config file with comments', async () => {
|
|
const configWithComments = `{
|
|
// Output configuration
|
|
"output": {
|
|
"filePath": "test-output.txt"
|
|
},
|
|
/* Ignore configuration */
|
|
"ignore": {
|
|
"useGitignore": true // Use .gitignore file
|
|
}
|
|
}`;
|
|
|
|
vi.mocked(fs.readFile).mockResolvedValue(configWithComments);
|
|
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as Stats);
|
|
|
|
const result = await loadFileConfig(process.cwd(), 'test-config.json');
|
|
expect(result).toEqual({
|
|
output: { filePath: 'test-output.txt' },
|
|
ignore: { useGitignore: true },
|
|
});
|
|
});
|
|
|
|
test('should parse config file with JSON5 features', async () => {
|
|
const configWithJSON5Features = `{
|
|
// Output configuration
|
|
output: {
|
|
filePath: 'test-output.txt',
|
|
style: 'plain',
|
|
},
|
|
/* Ignore configuration */
|
|
ignore: {
|
|
useGitignore: true, // Use .gitignore file
|
|
customPatterns: [
|
|
'*.log',
|
|
'*.tmp',
|
|
'*.temp', // Trailing comma
|
|
],
|
|
},
|
|
}`;
|
|
|
|
vi.mocked(fs.readFile).mockResolvedValue(configWithJSON5Features);
|
|
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as Stats);
|
|
|
|
const result = await loadFileConfig(process.cwd(), 'test-config.json');
|
|
expect(result).toEqual({
|
|
output: { filePath: 'test-output.txt', style: 'plain' },
|
|
ignore: {
|
|
useGitignore: true,
|
|
customPatterns: ['*.log', '*.tmp', '*.temp'],
|
|
},
|
|
});
|
|
});
|
|
|
|
test('should load .jsonc config file with priority order', async () => {
|
|
const mockConfig = {
|
|
output: { filePath: 'jsonc-output.txt' },
|
|
ignore: { useDefaultPatterns: true },
|
|
};
|
|
vi.mocked(fs.stat)
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.ts
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.mts
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.cts
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.js
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.mjs
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.cjs
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.json5
|
|
.mockResolvedValueOnce({ isFile: () => true } as Stats); // repomix.config.jsonc
|
|
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockConfig));
|
|
|
|
const result = await loadFileConfig(process.cwd(), null);
|
|
expect(result).toEqual(mockConfig);
|
|
expect(fs.readFile).toHaveBeenCalledWith(path.resolve(process.cwd(), 'repomix.config.jsonc'), 'utf-8');
|
|
});
|
|
|
|
test('should prioritize .json5 over .jsonc and .json', async () => {
|
|
const mockConfig = {
|
|
output: { filePath: 'json5-output.txt' },
|
|
ignore: { useDefaultPatterns: true },
|
|
};
|
|
vi.mocked(fs.stat)
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.ts
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.mts
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.cts
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.js
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.mjs
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.cjs
|
|
.mockResolvedValueOnce({ isFile: () => true } as Stats); // repomix.config.json5 exists
|
|
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockConfig));
|
|
|
|
const result = await loadFileConfig(process.cwd(), null);
|
|
expect(result).toEqual(mockConfig);
|
|
expect(fs.readFile).toHaveBeenCalledWith(path.resolve(process.cwd(), 'repomix.config.json5'), 'utf-8');
|
|
// Should not check for .jsonc or .json since .json5 was found
|
|
expect(fs.stat).toHaveBeenCalledTimes(7);
|
|
});
|
|
|
|
test('should throw RepomixError when specific config file does not exist', async () => {
|
|
const nonExistentConfigPath = 'non-existent-config.json';
|
|
vi.mocked(fs.stat).mockRejectedValue(new Error('File not found'));
|
|
|
|
await expect(loadFileConfig(process.cwd(), nonExistentConfigPath)).rejects.toThrow(
|
|
`Config file not found at ${nonExistentConfigPath}`,
|
|
);
|
|
});
|
|
|
|
test('should throw RepomixError for unsupported config file format', async () => {
|
|
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as Stats);
|
|
|
|
await expect(loadFileConfig(process.cwd(), 'test-config.yaml')).rejects.toThrow('Unsupported config file format');
|
|
});
|
|
|
|
test('should throw RepomixError for config file with unsupported extension', async () => {
|
|
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as Stats);
|
|
|
|
await expect(loadFileConfig(process.cwd(), 'test-config.toml')).rejects.toThrow('Unsupported config file format');
|
|
});
|
|
|
|
test('should handle general errors when loading config', async () => {
|
|
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as Stats);
|
|
vi.mocked(fs.readFile).mockRejectedValue(new Error('Permission denied'));
|
|
|
|
await expect(loadFileConfig(process.cwd(), 'test-config.json')).rejects.toThrow('Error loading config');
|
|
});
|
|
|
|
test('should handle non-Error objects when loading config', async () => {
|
|
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as Stats);
|
|
vi.mocked(fs.readFile).mockRejectedValue('String error');
|
|
|
|
await expect(loadFileConfig(process.cwd(), 'test-config.json')).rejects.toThrow('Error loading config');
|
|
});
|
|
|
|
test('should skip local config auto-detection when skipLocalConfig is true', async () => {
|
|
vi.mocked(getGlobalDirectory).mockReturnValue('/global/repomix');
|
|
// All local and global config files not found
|
|
vi.mocked(fs.stat).mockRejectedValue(new Error('File not found'));
|
|
|
|
const result = await loadFileConfig('/project/repo', null, { skipLocalConfig: true });
|
|
expect(result).toEqual({});
|
|
});
|
|
|
|
test('should still load global config when skipLocalConfig is true', async () => {
|
|
const mockGlobalConfig = { output: { style: 'markdown' } };
|
|
vi.mocked(getGlobalDirectory).mockReturnValue('/global/repomix');
|
|
vi.mocked(fs.stat)
|
|
// Local config search (for skip-log detection) — all not found
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.ts
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.mts
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.cts
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.js
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.mjs
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.cjs
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.json5
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.jsonc
|
|
.mockRejectedValueOnce(new Error('File not found')) // Local repomix.config.json
|
|
// Global config search
|
|
.mockRejectedValueOnce(new Error('File not found')) // Global repomix.config.ts
|
|
.mockRejectedValueOnce(new Error('File not found')) // Global repomix.config.mts
|
|
.mockRejectedValueOnce(new Error('File not found')) // Global repomix.config.cts
|
|
.mockRejectedValueOnce(new Error('File not found')) // Global repomix.config.js
|
|
.mockRejectedValueOnce(new Error('File not found')) // Global repomix.config.mjs
|
|
.mockRejectedValueOnce(new Error('File not found')) // Global repomix.config.cjs
|
|
.mockRejectedValueOnce(new Error('File not found')) // Global repomix.config.json5
|
|
.mockRejectedValueOnce(new Error('File not found')) // Global repomix.config.jsonc
|
|
.mockResolvedValueOnce({ isFile: () => true } as Stats); // Global repomix.config.json
|
|
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockGlobalConfig));
|
|
|
|
const result = await loadFileConfig('/project/repo', null, { skipLocalConfig: true });
|
|
expect(result).toEqual(mockGlobalConfig);
|
|
});
|
|
|
|
test('should log a message when skipping config in remote mode', async () => {
|
|
vi.mocked(getGlobalDirectory).mockReturnValue('/global/repomix');
|
|
// Local config exists but should be skipped
|
|
vi.mocked(fs.stat)
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.ts
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.mts
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.cts
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.js
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.mjs
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.cjs
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.json5
|
|
.mockRejectedValueOnce(new Error('File not found')) // repomix.config.jsonc
|
|
.mockResolvedValueOnce({ isFile: () => true } as Stats) // repomix.config.json — found
|
|
.mockRejectedValue(new Error('File not found')); // global configs
|
|
|
|
await loadFileConfig('/tmp/repomix-clone', null, { skipLocalConfig: true });
|
|
|
|
expect(logger.note).toHaveBeenCalledWith(expect.stringContaining('Skipping config file'));
|
|
expect(logger.note).toHaveBeenCalledWith(expect.stringContaining('--remote-trust-config'));
|
|
});
|
|
|
|
test('should still respect --config flag even when skipLocalConfig is true', async () => {
|
|
const mockConfig = { output: { filePath: 'custom-output.xml' } };
|
|
vi.mocked(fs.stat).mockResolvedValue({ isFile: () => true } as Stats);
|
|
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockConfig));
|
|
|
|
const result = await loadFileConfig('/tmp/repomix-clone', '/home/user/my-config.json', {
|
|
skipLocalConfig: true,
|
|
});
|
|
expect(result).toEqual(mockConfig);
|
|
});
|
|
});
|
|
|
|
describe('mergeConfigs', () => {
|
|
test('should correctly merge configs', () => {
|
|
const fileConfig: RepomixConfigFile = {
|
|
output: { filePath: 'file-output.txt' },
|
|
ignore: { useDefaultPatterns: true, customPatterns: ['file-ignore'] },
|
|
};
|
|
const cliConfig: RepomixConfigCli = {
|
|
output: { filePath: 'cli-output.txt' },
|
|
ignore: { customPatterns: ['cli-ignore'] },
|
|
};
|
|
|
|
const result = mergeConfigs(process.cwd(), fileConfig, cliConfig);
|
|
|
|
expect(result.output.filePath).toBe('cli-output.txt');
|
|
expect(result.ignore.useDefaultPatterns).toBe(true);
|
|
expect(result.ignore.customPatterns).toContain('file-ignore');
|
|
expect(result.ignore.customPatterns).toContain('cli-ignore');
|
|
});
|
|
|
|
test('should throw RepomixConfigValidationError for invalid merged config', () => {
|
|
const fileConfig: RepomixConfigFile = {
|
|
output: { filePath: 'file-output.txt', style: 'plain' },
|
|
};
|
|
const cliConfig: RepomixConfigCli = {
|
|
// @ts-expect-error
|
|
output: { style: 'invalid' }, // Invalid style
|
|
};
|
|
|
|
expect(() => mergeConfigs(process.cwd(), fileConfig, cliConfig)).toThrow(RepomixConfigValidationError);
|
|
});
|
|
|
|
test('should merge nested git config correctly', () => {
|
|
const fileConfig: RepomixConfigFile = {
|
|
output: { git: { sortByChanges: false } },
|
|
};
|
|
const cliConfig: RepomixConfigCli = {
|
|
output: { git: { includeDiffs: true } },
|
|
};
|
|
const merged = mergeConfigs(process.cwd(), fileConfig, cliConfig);
|
|
|
|
// Both configs should be applied
|
|
expect(merged.output.git.sortByChanges).toBe(false);
|
|
expect(merged.output.git.includeDiffs).toBe(true);
|
|
// Defaults should still be present
|
|
expect(merged.output.git.sortByChangesMaxCommits).toBe(100);
|
|
});
|
|
|
|
test('should not mutate defaultConfig', () => {
|
|
const originalFilePath = defaultConfig.output.filePath;
|
|
const fileConfig: RepomixConfigFile = {
|
|
output: { style: 'markdown' },
|
|
};
|
|
|
|
mergeConfigs(process.cwd(), fileConfig, {});
|
|
|
|
// defaultConfig should remain unchanged
|
|
expect(defaultConfig.output.filePath).toBe(originalFilePath);
|
|
});
|
|
|
|
test('should merge tokenCount config correctly', () => {
|
|
const fileConfig: RepomixConfigFile = {
|
|
tokenCount: { encoding: 'cl100k_base' },
|
|
};
|
|
const merged = mergeConfigs(process.cwd(), fileConfig, {});
|
|
|
|
expect(merged.tokenCount.encoding).toBe('cl100k_base');
|
|
});
|
|
|
|
test('should map default filename to style when only style is provided via CLI', () => {
|
|
const merged = mergeConfigs(process.cwd(), {}, { output: { style: 'markdown' } });
|
|
expect(merged.output.filePath).toBe('repomix-output.md');
|
|
expect(merged.output.style).toBe('markdown');
|
|
});
|
|
|
|
test('should keep explicit CLI output filePath even when style is provided', () => {
|
|
const merged = mergeConfigs(process.cwd(), {}, { output: { style: 'markdown', filePath: 'custom-output.any' } });
|
|
expect(merged.output.filePath).toBe('custom-output.any');
|
|
expect(merged.output.style).toBe('markdown');
|
|
});
|
|
|
|
test('should keep explicit file config filePath even when style is provided via CLI', () => {
|
|
const merged = mergeConfigs(
|
|
process.cwd(),
|
|
{ output: { filePath: 'from-file.txt' } },
|
|
{ output: { style: 'markdown' } },
|
|
);
|
|
expect(merged.output.filePath).toBe('from-file.txt');
|
|
expect(merged.output.style).toBe('markdown');
|
|
});
|
|
|
|
test('should map default filename when style provided in file config and no filePath anywhere', () => {
|
|
const merged = mergeConfigs(process.cwd(), { output: { style: 'plain' } }, {});
|
|
expect(merged.output.filePath).toBe('repomix-output.txt');
|
|
expect(merged.output.style).toBe('plain');
|
|
});
|
|
|
|
test('should merge skillGenerate boolean from CLI config', () => {
|
|
const merged = mergeConfigs(process.cwd(), {}, { skillGenerate: true });
|
|
expect(merged.skillGenerate).toBe(true);
|
|
});
|
|
|
|
test('should merge skillGenerate string from CLI config', () => {
|
|
const merged = mergeConfigs(process.cwd(), {}, { skillGenerate: 'my-custom-skill' });
|
|
expect(merged.skillGenerate).toBe('my-custom-skill');
|
|
});
|
|
|
|
test('should not include skillGenerate in merged config when undefined', () => {
|
|
const merged = mergeConfigs(process.cwd(), {}, {});
|
|
expect(merged.skillGenerate).toBeUndefined();
|
|
});
|
|
|
|
test('should not allow skillGenerate from file config (CLI-only option)', () => {
|
|
// File config should not have skillGenerate - it's CLI-only
|
|
// This test verifies that even if somehow passed, file config doesn't affect it
|
|
const merged = mergeConfigs(process.cwd(), {}, { skillGenerate: 'from-cli' });
|
|
expect(merged.skillGenerate).toBe('from-cli');
|
|
});
|
|
});
|
|
});
|