diff --git a/README.md b/README.md index 831ceda..76affe7 100644 --- a/README.md +++ b/README.md @@ -32,13 +32,35 @@ cat > .git/hooks/pre-push << 'EOF' set -o pipefail echo "📋 Running pre-push tsu checks" -tsu hook collate +tsu hook collate --with-config # Uses config file for timeout settings # tsu hook collate --verbose # If you want verbose output EOF chmod +x .git/hooks/pre-push ``` +### Configuration File + +Create a `.tsurc` file in your project root to customize timeout settings: + +```json +{ + "timeout": 5000, + "hook": { + "collate": { + "timeout": 20000, + "checks": { + "dart-format": { "timeout": 3000 }, + "dart-analysis": { "timeout": 15000 }, + "dcm-analyze": { "timeout": 10000 } + } + } + } +} +``` + +See `templates/.tsurc.example` for a complete example. + ### Available Namespaces - **check** - System dependency checks ([documentation](docs/check.md)) diff --git a/dist/cli.js b/dist/cli.js index f5707b4..6744544 100755 --- a/dist/cli.js +++ b/dist/cli.js @@ -296,6 +296,7 @@ hook .option('--dcm-analyze', 'run DCM analyze check') .option('--graphql', 'run GraphQL check') .option('--codeowners', 'run git codeowners check') + .option('--with-config', 'load configuration from config file (.tsurc, .tsurc.json, etc.)') .option('-v, --verbose', 'show human-readable status messages (output to stderr)') .action(async (options) => { await hookCollate(options); diff --git a/dist/commands/hook/collate.d.ts b/dist/commands/hook/collate.d.ts index fd6eaa2..2e82829 100644 --- a/dist/commands/hook/collate.d.ts +++ b/dist/commands/hook/collate.d.ts @@ -5,5 +5,6 @@ export interface HookCollateOptions extends ChangedFilesOptions { dcmAnalyze?: boolean; graphql?: boolean; codeowners?: boolean; + withConfig?: boolean; } export declare function hookCollate(options?: HookCollateOptions): Promise; diff --git a/dist/commands/hook/collate.js b/dist/commands/hook/collate.js index 1131bc2..db40c31 100644 --- a/dist/commands/hook/collate.js +++ b/dist/commands/hook/collate.js @@ -8,6 +8,7 @@ import { isDartPackage } from '../dart/utils/dart.js'; import { ensureCondition } from '../../utils/command-helpers.js'; import { logIfVerbose } from '../../utils/logger.js'; import { setVerbose } from '../../utils/verbose-state.js'; +import { loadConfig, getTimeoutFromConfig } from '../../utils/config.js'; const execFileAsync = promisify(execFile); function parseFailureOutput(output) { const lines = output.split('\n').map((line) => line.trim()); @@ -64,6 +65,10 @@ export async function hookCollate(options = {}) { const verbose = options.verbose || false; setVerbose(verbose); logIfVerbose(verbose, '📋 Running pre-push checks...'); + const config = options.withConfig ? loadConfig() : null; + if (config && verbose) { + logIfVerbose(verbose, '⚙️ Loaded configuration from file'); + } ensureCondition(isGitRepo(), 'Error: Not in a git repository'); ensureCondition(isDartPackage(), 'Error: Not in a Dart package'); const cwd = process.cwd(); @@ -98,7 +103,7 @@ export async function hookCollate(options = {}) { args.push('--verbose'); return args; }; - const createHookTask = (name, file, args, skipCondition = false, appendChangedFileArgs = true) => { + const createHookTask = (name, file, args, skipCondition = false, appendChangedFileArgs = true, timeoutMs) => { return { title: name, skip: () => { @@ -110,7 +115,11 @@ export async function hookCollate(options = {}) { task: async (ctx, task) => { try { const cmdArgs = appendChangedFileArgs ? [...args, ...buildArgs()] : [...args]; - const result = await execFileAsync(file, cmdArgs, { cwd }); + const execOptions = { cwd }; + if (timeoutMs) { + execOptions.timeout = timeoutMs; + } + const result = await execFileAsync(file, cmdArgs, execOptions); if (verbose) { const output = []; if (result.stdout) { @@ -168,23 +177,28 @@ export async function hookCollate(options = {}) { const tsuCmd = getTsuCommand(); const hookTasks = []; if (runDartFormat) { - hookTasks.push(createHookTask('dart format check', tsuCmd.file, [...tsuCmd.args, 'hook', 'format', 'check'], dartFiles.length === 0)); + const timeout = getTimeoutFromConfig(config, ['hook', 'collate'], 'dart-format'); + hookTasks.push(createHookTask('dart format check', tsuCmd.file, [...tsuCmd.args, 'hook', 'format', 'check'], dartFiles.length === 0, true, timeout)); } if (runDartAnalysis) { - hookTasks.push(createHookTask('dart analysis check', tsuCmd.file, [...tsuCmd.args, 'hook', 'analysis', 'check'], dartFiles.length === 0)); + const timeout = getTimeoutFromConfig(config, ['hook', 'collate'], 'dart-analysis'); + hookTasks.push(createHookTask('dart analysis check', tsuCmd.file, [...tsuCmd.args, 'hook', 'analysis', 'check'], dartFiles.length === 0, true, timeout)); } if (runDcmAnalyze) { - hookTasks.push(createHookTask('DCM analyze check', tsuCmd.file, [...tsuCmd.args, 'hook', 'dcm', 'analyze', 'check'], dartFiles.length === 0)); + const timeout = getTimeoutFromConfig(config, ['hook', 'collate'], 'dcm-analyze'); + hookTasks.push(createHookTask('DCM analyze check', tsuCmd.file, [...tsuCmd.args, 'hook', 'dcm', 'analyze', 'check'], dartFiles.length === 0, true, timeout)); } if (runGraphql) { - hookTasks.push(createHookTask('GraphQL check', tsuCmd.file, [...tsuCmd.args, 'hook', 'graphql', 'check'], graphqlFiles.length === 0)); + const timeout = getTimeoutFromConfig(config, ['hook', 'collate'], 'graphql'); + hookTasks.push(createHookTask('GraphQL check', tsuCmd.file, [...tsuCmd.args, 'hook', 'graphql', 'check'], graphqlFiles.length === 0, true, timeout)); } if (runCodeowners) { const codeownersArgs = [...tsuCmd.args, 'git', 'codeowners', 'check']; if (verbose) { codeownersArgs.push('--verbose'); } - hookTasks.push(createHookTask('git codeowners check', tsuCmd.file, codeownersArgs, false, false)); + const timeout = getTimeoutFromConfig(config, ['hook', 'collate'], 'codeowners'); + hookTasks.push(createHookTask('git codeowners check', tsuCmd.file, codeownersArgs, false, false, timeout)); } const tasks = new Listr(hookTasks, { concurrent: true, diff --git a/dist/commands/hook/collate.test.js b/dist/commands/hook/collate.test.js index 16cd876..85b8eef 100644 --- a/dist/commands/hook/collate.test.js +++ b/dist/commands/hook/collate.test.js @@ -407,4 +407,62 @@ Run \`dart fix --apply\` to fix some issues automatically. expect(codeownersArgs).not.toContain('develop'); mockExit.mockRestore(); }); + describe('with config file', () => { + it('should load and use config when --with-config is set', async () => { + const { writeFileSync, mkdirSync, rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { tmpdir } = await import('node:os'); + const testDir = join(tmpdir(), `tsu-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + const configData = { + timeout: 5000, + hook: { + collate: { + timeout: 20000, + checks: { + 'dart-format': { timeout: 3000 }, + }, + }, + }, + }; + writeFileSync(join(testDir, '.tsurc'), JSON.stringify(configData)); + const originalCwd = process.cwd(); + process.chdir(testDir); + mockGetAllChangedFiles.mockReturnValue(['lib/main.dart']); + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/tsu')); + let capturedTimeout; + mockExecFile.mockImplementation((_file, args, options, callback) => { + const argsArray = args; + if (argsArray && argsArray.includes('format')) { + capturedTimeout = options?.timeout; + } + if (callback) { + callback(null, '', ''); + } + return {}; + }); + const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => { })); + await hookCollate({ dartFormat: true, withConfig: true, verbose: false }); + expect(capturedTimeout).toBe(3000); + mockExit.mockRestore(); + process.chdir(originalCwd); + rmSync(testDir, { recursive: true, force: true }); + }); + it('should work without config when --with-config is not set', async () => { + mockGetAllChangedFiles.mockReturnValue(['lib/main.dart']); + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/tsu')); + let capturedTimeout; + mockExecFile.mockImplementation((_file, _args, options, callback) => { + capturedTimeout = options?.timeout; + if (callback) { + callback(null, '', ''); + } + return {}; + }); + const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => { })); + await hookCollate({ dartFormat: true, verbose: false }); + expect(capturedTimeout).toBeUndefined(); + mockExit.mockRestore(); + }); + }); }); diff --git a/dist/types/config.d.ts b/dist/types/config.d.ts new file mode 100644 index 0000000..347b5d1 --- /dev/null +++ b/dist/types/config.d.ts @@ -0,0 +1,20 @@ +export interface TimeoutConfig { + timeout?: number; +} +export interface HookChecksConfig { + 'dart-format'?: TimeoutConfig; + 'dart-analysis'?: TimeoutConfig; + 'dcm-analyze'?: TimeoutConfig; + graphql?: TimeoutConfig; + codeowners?: TimeoutConfig; +} +export interface HookCollateConfig extends TimeoutConfig { + checks?: HookChecksConfig; +} +export interface HookConfig { + collate?: HookCollateConfig; +} +export interface TsuConfig { + timeout?: number; + hook?: HookConfig; +} diff --git a/dist/types/config.js b/dist/types/config.js new file mode 100644 index 0000000..cb0ff5c --- /dev/null +++ b/dist/types/config.js @@ -0,0 +1 @@ +export {}; diff --git a/dist/utils/config.d.ts b/dist/utils/config.d.ts new file mode 100644 index 0000000..0e8e097 --- /dev/null +++ b/dist/utils/config.d.ts @@ -0,0 +1,3 @@ +import type { TsuConfig } from '../types/config.js'; +export declare function loadConfig(startPath?: string): TsuConfig | null; +export declare function getTimeoutFromConfig(config: TsuConfig | null, commandPath: string[], checkName?: string): number | undefined; diff --git a/dist/utils/config.js b/dist/utils/config.js new file mode 100644 index 0000000..e3df3c5 --- /dev/null +++ b/dist/utils/config.js @@ -0,0 +1,61 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; +const CONFIG_FILE_NAMES = ['.tsurc', '.tsurc.json', 'tsu.config.json', '.tsu.config.json']; +function findConfigFile(startPath) { + let currentPath = startPath; + while (currentPath !== dirname(currentPath)) { + for (const fileName of CONFIG_FILE_NAMES) { + const configPath = join(currentPath, fileName); + if (existsSync(configPath)) { + return configPath; + } + } + currentPath = dirname(currentPath); + } + for (const fileName of CONFIG_FILE_NAMES) { + const configPath = join(homedir(), fileName); + if (existsSync(configPath)) { + return configPath; + } + } + return null; +} +function loadConfigFromFile(configPath) { + try { + const content = readFileSync(configPath, 'utf-8'); + const config = JSON.parse(content); + return config; + } + catch (error) { + throw new Error(`Failed to load config from ${configPath}: ${error instanceof Error ? error.message : String(error)}`); + } +} +export function loadConfig(startPath = process.cwd()) { + const configPath = findConfigFile(startPath); + if (!configPath) { + return null; + } + return loadConfigFromFile(configPath); +} +export function getTimeoutFromConfig(config, commandPath, checkName) { + if (!config) { + return undefined; + } + if (checkName && commandPath[0] === 'hook' && commandPath[1] === 'collate') { + const checks = config.hook?.collate?.checks; + if (checks && checkName in checks) { + const checkConfig = checks[checkName]; + if (checkConfig?.timeout !== undefined) { + return checkConfig.timeout; + } + } + } + if (commandPath[0] === 'hook' && commandPath[1] === 'collate') { + const collateTimeout = config.hook?.collate?.timeout; + if (collateTimeout !== undefined) { + return collateTimeout; + } + } + return config.timeout; +} diff --git a/docs/hook.md b/docs/hook.md index 0e3e720..8f1865f 100644 --- a/docs/hook.md +++ b/docs/hook.md @@ -15,6 +15,84 @@ tsutils hook fix check # Run dart fix on Dart files about to be pushed tsutils hook dcm fix check # Run DCM fix on Dart files about to be pushed (for git hooks) tsutils hook dcm analyze check # Run DCM analyze on Dart files about to be pushed tsutils hook graphql check # Check GraphQL codegen is up to date (for git hooks) +tsutils hook collate # Run multiple hook checks concurrently +``` + +## Hook Collate Command + +The `hook collate` command runs multiple hook checks concurrently and provides a unified summary of all results. This is the recommended way to use multiple hooks in your git workflow. + +**Basic usage:** +```bash +# Run all checks (default) +tsutils hook collate + +# Run specific checks +tsutils hook collate --dart-format --dart-analysis + +# Use with config file +tsutils hook collate --with-config + +# Verbose output +tsutils hook collate --verbose +``` + +**How it works:** +1. Determines which hooks to run (default: all applicable hooks) +2. Runs selected checks concurrently for efficiency +3. Tracks failures and continues running remaining checks +4. Provides a unified summary of all results +5. Exits with code 1 if any check fails, 0 if all pass + +**Check selection flags:** +- `--dart-format` - Run only dart format check +- `--dart-analysis` - Run only dart analysis check +- `--dcm-analyze` - Run only DCM analyze check +- `--graphql` - Run only GraphQL check +- `--codeowners` - Run only git codeowners check + +If no flags are specified, all checks run by default. If any flag is specified, only those checks run. + +**Config file support:** + +Use the `--with-config` flag to load timeout settings from a config file. Config files are searched in this order: +1. `.tsurc` (current directory, then parent directories) +2. `.tsurc.json` (current directory, then parent directories) +3. `tsu.config.json` (current directory, then parent directories) +4. `.tsu.config.json` (current directory, then parent directories) +5. Home directory (`~/.tsurc`, `~/.tsurc.json`, etc.) + +**Config file format:** +```json +{ + "timeout": 5000, + "hook": { + "collate": { + "timeout": 20000, + "checks": { + "dart-format": { "timeout": 3000 }, + "dart-analysis": { "timeout": 15000 }, + "dcm-analyze": { "timeout": 10000 }, + "graphql": { "timeout": 30000 }, + "codeowners": { "timeout": 5000 } + } + } + } +} +``` + +**Timeout resolution:** +- Per-check timeout (most specific) > Command timeout > Global timeout +- Timeouts are in milliseconds +- If no timeout is specified, commands run without timeout limits + +**Example usage in git hooks:** +```bash +# In .git/hooks/pre-push +#!/bin/bash +set -o pipefail +echo "📋 Running pre-push tsu checks" +tsu hook collate --with-config || exit 1 ``` ## File Filtering Options diff --git a/src/cli.ts b/src/cli.ts index abdf977..b1741ac 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -422,6 +422,7 @@ hook .option('--dcm-analyze', 'run DCM analyze check') .option('--graphql', 'run GraphQL check') .option('--codeowners', 'run git codeowners check') + .option('--with-config', 'load configuration from config file (.tsurc, .tsurc.json, etc.)') .option('-v, --verbose', 'show human-readable status messages (output to stderr)') .action( async (options: { @@ -434,6 +435,7 @@ hook dcmAnalyze?: boolean; graphql?: boolean; codeowners?: boolean; + withConfig?: boolean; verbose?: boolean; }) => { await hookCollate(options); diff --git a/src/commands/hook/collate.test.ts b/src/commands/hook/collate.test.ts index 093e1f1..ff72dd1 100644 --- a/src/commands/hook/collate.test.ts +++ b/src/commands/hook/collate.test.ts @@ -688,4 +688,102 @@ Run \`dart fix --apply\` to fix some issues automatically. mockExit.mockRestore(); }); + + describe('with config file', () => { + it('should load and use config when --with-config is set', async () => { + // Create a temporary config file + const { writeFileSync, mkdirSync, rmSync } = await import('node:fs'); + const { join } = await import('node:path'); + const { tmpdir } = await import('node:os'); + + const testDir = join( + tmpdir(), + `tsu-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(testDir, { recursive: true }); + + // Create config file with timeout settings + const configData = { + timeout: 5000, + hook: { + collate: { + timeout: 20000, + checks: { + 'dart-format': { timeout: 3000 }, + }, + }, + }, + }; + writeFileSync(join(testDir, '.tsurc'), JSON.stringify(configData)); + + // Change cwd to test directory + const originalCwd = process.cwd(); + process.chdir(testDir); + + mockGetAllChangedFiles.mockReturnValue(['lib/main.dart']); + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/tsu')); + + // Track the timeout passed to execFile + let capturedTimeout: number | undefined; + mockExecFile.mockImplementation( + ( + _file: string, + args: readonly string[] | null | undefined, + options: any, + callback?: ((error: any, stdout: string, stderr: string) => void) | null + ) => { + const argsArray = args as string[]; + if (argsArray && argsArray.includes('format')) { + capturedTimeout = options?.timeout; + } + if (callback) { + callback(null, '', ''); + } + return {} as any; + } + ); + + const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never); + + await hookCollate({ dartFormat: true, withConfig: true, verbose: false }); + + // Should have used timeout from config + expect(capturedTimeout).toBe(3000); + + mockExit.mockRestore(); + process.chdir(originalCwd); + rmSync(testDir, { recursive: true, force: true }); + }); + + it('should work without config when --with-config is not set', async () => { + mockGetAllChangedFiles.mockReturnValue(['lib/main.dart']); + mockExecSync.mockReturnValue(Buffer.from('/usr/bin/tsu')); + + // Track the timeout passed to execFile + let capturedTimeout: number | undefined; + mockExecFile.mockImplementation( + ( + _file: string, + _args: readonly string[] | null | undefined, + options: any, + callback?: ((error: any, stdout: string, stderr: string) => void) | null + ) => { + capturedTimeout = options?.timeout; + if (callback) { + callback(null, '', ''); + } + return {} as any; + } + ); + + const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never); + + await hookCollate({ dartFormat: true, verbose: false }); + + // Should not have timeout set (undefined) + expect(capturedTimeout).toBeUndefined(); + + mockExit.mockRestore(); + }); + }); }); diff --git a/src/commands/hook/collate.ts b/src/commands/hook/collate.ts index 04b33e1..6b138c7 100644 --- a/src/commands/hook/collate.ts +++ b/src/commands/hook/collate.ts @@ -9,6 +9,7 @@ import { ensureCondition } from '../../utils/command-helpers.js'; import { logIfVerbose } from '../../utils/logger.js'; import type { ChangedFilesOptions } from '../../types/command-options.js'; import { setVerbose } from '../../utils/verbose-state.js'; +import { loadConfig, getTimeoutFromConfig } from '../../utils/config.js'; const execFileAsync = promisify(execFile); @@ -97,6 +98,8 @@ export interface HookCollateOptions extends ChangedFilesOptions { graphql?: boolean; /** Run git codeowners check */ codeowners?: boolean; + /** Load configuration from config file */ + withConfig?: boolean; } /** @@ -121,6 +124,12 @@ export async function hookCollate(options: HookCollateOptions = {}): Promise { return { title: name, @@ -185,7 +195,12 @@ export async function hookCollate(options: HookCollateOptions = {}): Promise[] = []; if (runDartFormat) { + const timeout = getTimeoutFromConfig(config, ['hook', 'collate'], 'dart-format'); hookTasks.push( createHookTask( 'dart format check', tsuCmd.file, [...tsuCmd.args, 'hook', 'format', 'check'], - dartFiles.length === 0 + dartFiles.length === 0, + true, + timeout ) ); } if (runDartAnalysis) { + const timeout = getTimeoutFromConfig(config, ['hook', 'collate'], 'dart-analysis'); hookTasks.push( createHookTask( 'dart analysis check', tsuCmd.file, [...tsuCmd.args, 'hook', 'analysis', 'check'], - dartFiles.length === 0 + dartFiles.length === 0, + true, + timeout ) ); } if (runDcmAnalyze) { + const timeout = getTimeoutFromConfig(config, ['hook', 'collate'], 'dcm-analyze'); hookTasks.push( createHookTask( 'DCM analyze check', tsuCmd.file, [...tsuCmd.args, 'hook', 'dcm', 'analyze', 'check'], - dartFiles.length === 0 + dartFiles.length === 0, + true, + timeout ) ); } if (runGraphql) { + const timeout = getTimeoutFromConfig(config, ['hook', 'collate'], 'graphql'); hookTasks.push( createHookTask( 'GraphQL check', tsuCmd.file, [...tsuCmd.args, 'hook', 'graphql', 'check'], - graphqlFiles.length === 0 + graphqlFiles.length === 0, + true, + timeout ) ); } @@ -309,13 +336,15 @@ export async function hookCollate(options: HookCollateOptions = {}): Promise { + let testDir: string; + + beforeEach(() => { + // Create a unique temp directory for each test + testDir = join(tmpdir(), `tsu-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + // Clean up temp directory + if (testDir) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe('loadConfig', () => { + it('should return null when no config file exists', () => { + const config = loadConfig(testDir); + expect(config).toBeNull(); + }); + + it('should load .tsurc file', () => { + const configData: TsuConfig = { timeout: 5000 }; + writeFileSync(join(testDir, '.tsurc'), JSON.stringify(configData)); + + const config = loadConfig(testDir); + expect(config).toEqual(configData); + }); + + it('should load .tsurc.json file', () => { + const configData: TsuConfig = { timeout: 10000 }; + writeFileSync(join(testDir, '.tsurc.json'), JSON.stringify(configData)); + + const config = loadConfig(testDir); + expect(config).toEqual(configData); + }); + + it('should load tsu.config.json file', () => { + const configData: TsuConfig = { timeout: 15000 }; + writeFileSync(join(testDir, 'tsu.config.json'), JSON.stringify(configData)); + + const config = loadConfig(testDir); + expect(config).toEqual(configData); + }); + + it('should prefer .tsurc over other config files', () => { + const configData1: TsuConfig = { timeout: 5000 }; + const configData2: TsuConfig = { timeout: 10000 }; + writeFileSync(join(testDir, '.tsurc'), JSON.stringify(configData1)); + writeFileSync(join(testDir, 'tsu.config.json'), JSON.stringify(configData2)); + + const config = loadConfig(testDir); + expect(config).toEqual(configData1); + }); + + it('should walk up directory tree to find config', () => { + const subDir = join(testDir, 'sub', 'nested', 'deep'); + mkdirSync(subDir, { recursive: true }); + + const configData: TsuConfig = { timeout: 7000 }; + writeFileSync(join(testDir, '.tsurc'), JSON.stringify(configData)); + + const config = loadConfig(subDir); + expect(config).toEqual(configData); + }); + + it('should load config with hook settings', () => { + const configData: TsuConfig = { + timeout: 5000, + hook: { + collate: { + timeout: 20000, + checks: { + 'dart-format': { timeout: 3000 }, + 'dart-analysis': { timeout: 15000 }, + }, + }, + }, + }; + writeFileSync(join(testDir, '.tsurc'), JSON.stringify(configData)); + + const config = loadConfig(testDir); + expect(config).toEqual(configData); + }); + + it('should throw error for invalid JSON', () => { + writeFileSync(join(testDir, '.tsurc'), 'invalid json {'); + + expect(() => loadConfig(testDir)).toThrow('Failed to load config'); + }); + }); + + describe('getTimeoutFromConfig', () => { + it('should return undefined when config is null', () => { + const timeout = getTimeoutFromConfig(null, ['hook', 'collate']); + expect(timeout).toBeUndefined(); + }); + + it('should return global timeout when no specific timeout is set', () => { + const config: TsuConfig = { timeout: 5000 }; + const timeout = getTimeoutFromConfig(config, ['hook', 'collate']); + expect(timeout).toBe(5000); + }); + + it('should return command-specific timeout over global', () => { + const config: TsuConfig = { + timeout: 5000, + hook: { + collate: { + timeout: 15000, + }, + }, + }; + const timeout = getTimeoutFromConfig(config, ['hook', 'collate']); + expect(timeout).toBe(15000); + }); + + it('should return check-specific timeout over command timeout', () => { + const config: TsuConfig = { + timeout: 5000, + hook: { + collate: { + timeout: 15000, + checks: { + 'dart-format': { timeout: 3000 }, + }, + }, + }, + }; + const timeout = getTimeoutFromConfig(config, ['hook', 'collate'], 'dart-format'); + expect(timeout).toBe(3000); + }); + + it('should fall back to command timeout when check-specific is not set', () => { + const config: TsuConfig = { + timeout: 5000, + hook: { + collate: { + timeout: 15000, + checks: { + 'dart-format': { timeout: 3000 }, + }, + }, + }, + }; + const timeout = getTimeoutFromConfig(config, ['hook', 'collate'], 'dart-analysis'); + expect(timeout).toBe(15000); + }); + + it('should return undefined when no timeout is configured', () => { + const config: TsuConfig = {}; + const timeout = getTimeoutFromConfig(config, ['hook', 'collate']); + expect(timeout).toBeUndefined(); + }); + + it('should handle check name that does not exist in config', () => { + const config: TsuConfig = { + timeout: 5000, + hook: { + collate: { + checks: { + 'dart-format': { timeout: 3000 }, + }, + }, + }, + }; + const timeout = getTimeoutFromConfig(config, ['hook', 'collate'], 'nonexistent-check'); + expect(timeout).toBe(5000); + }); + + it('should return global timeout for non-collate commands', () => { + const config: TsuConfig = { + timeout: 5000, + hook: { + collate: { + timeout: 15000, + }, + }, + }; + const timeout = getTimeoutFromConfig(config, ['git', 'changed']); + expect(timeout).toBe(5000); + }); + }); +}); diff --git a/src/utils/config.ts b/src/utils/config.ts new file mode 100644 index 0000000..2ee4ee7 --- /dev/null +++ b/src/utils/config.ts @@ -0,0 +1,114 @@ +/** + * Configuration file loading utilities + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { homedir } from 'node:os'; +import type { TsuConfig } from '../types/config.js'; + +/** + * Possible config file names, in order of preference + */ +const CONFIG_FILE_NAMES = ['.tsurc', '.tsurc.json', 'tsu.config.json', '.tsu.config.json']; + +/** + * Finds the closest config file by walking up the directory tree + * @param startPath - Starting directory to search from + * @returns Path to config file or null if not found + */ +function findConfigFile(startPath: string): string | null { + let currentPath = startPath; + + // Walk up directory tree until we find a config file or reach root + while (currentPath !== dirname(currentPath)) { + for (const fileName of CONFIG_FILE_NAMES) { + const configPath = join(currentPath, fileName); + if (existsSync(configPath)) { + return configPath; + } + } + currentPath = dirname(currentPath); + } + + // Check home directory as fallback + for (const fileName of CONFIG_FILE_NAMES) { + const configPath = join(homedir(), fileName); + if (existsSync(configPath)) { + return configPath; + } + } + + return null; +} + +/** + * Loads and parses a config file + * @param configPath - Path to the config file + * @returns Parsed config object + * @throws Error if file cannot be read or parsed + */ +function loadConfigFromFile(configPath: string): TsuConfig { + try { + const content = readFileSync(configPath, 'utf-8'); + const config = JSON.parse(content) as TsuConfig; + return config; + } catch (error) { + throw new Error( + `Failed to load config from ${configPath}: ${error instanceof Error ? error.message : String(error)}` + ); + } +} + +/** + * Loads the tsu configuration from the closest config file + * @param startPath - Starting directory to search from (defaults to current directory) + * @returns Parsed config object or null if no config file found + */ +export function loadConfig(startPath: string = process.cwd()): TsuConfig | null { + const configPath = findConfigFile(startPath); + if (!configPath) { + return null; + } + + return loadConfigFromFile(configPath); +} + +/** + * Gets the timeout for a specific command from the config + * @param config - The config object + * @param commandPath - Array of command path segments (e.g., ['hook', 'collate']) + * @param checkName - Optional check name for per-check timeouts (e.g., 'dart-format') + * @returns Timeout in milliseconds or undefined if not configured + */ +export function getTimeoutFromConfig( + config: TsuConfig | null, + commandPath: string[], + checkName?: string +): number | undefined { + if (!config) { + return undefined; + } + + // Check for per-check timeout first (most specific) + if (checkName && commandPath[0] === 'hook' && commandPath[1] === 'collate') { + const checks = config.hook?.collate?.checks; + if (checks && checkName in checks) { + const checkConfig = checks[checkName as keyof typeof checks]; + if (checkConfig?.timeout !== undefined) { + return checkConfig.timeout; + } + } + } + + // Check for command-specific timeout + if (commandPath[0] === 'hook' && commandPath[1] === 'collate') { + const collateTimeout = config.hook?.collate?.timeout; + if (collateTimeout !== undefined) { + return collateTimeout; + } + } + + // Fall back to global timeout + return config.timeout; +} diff --git a/templates/.tsurc.example b/templates/.tsurc.example new file mode 100644 index 0000000..0ae8a9b --- /dev/null +++ b/templates/.tsurc.example @@ -0,0 +1,25 @@ +{ + "timeout": 5000, + "hook": { + "collate": { + "timeout": 20000, + "checks": { + "dart-format": { + "timeout": 3000 + }, + "dart-analysis": { + "timeout": 15000 + }, + "dcm-analyze": { + "timeout": 10000 + }, + "graphql": { + "timeout": 30000 + }, + "codeowners": { + "timeout": 5000 + } + } + } + } +}