Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
24 changes: 23 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
1 change: 1 addition & 0 deletions dist/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions dist/commands/hook/collate.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@ export interface HookCollateOptions extends ChangedFilesOptions {
dcmAnalyze?: boolean;
graphql?: boolean;
codeowners?: boolean;
withConfig?: boolean;
}
export declare function hookCollate(options?: HookCollateOptions): Promise<void>;
28 changes: 21 additions & 7 deletions dist/commands/hook/collate.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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: () => {
Expand All @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down
58 changes: 58 additions & 0 deletions dist/commands/hook/collate.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
});
20 changes: 20 additions & 0 deletions dist/types/config.d.ts
Original file line number Diff line number Diff line change
@@ -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;
}
1 change: 1 addition & 0 deletions dist/types/config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export {};
3 changes: 3 additions & 0 deletions dist/utils/config.d.ts
Original file line number Diff line number Diff line change
@@ -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;
61 changes: 61 additions & 0 deletions dist/utils/config.js
Original file line number Diff line number Diff line change
@@ -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;
}
78 changes: 78 additions & 0 deletions docs/hook.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -434,6 +435,7 @@ hook
dcmAnalyze?: boolean;
graphql?: boolean;
codeowners?: boolean;
withConfig?: boolean;
verbose?: boolean;
}) => {
await hookCollate(options);
Expand Down
Loading
Loading