diff --git a/src/commands/git-hook.ts b/src/commands/git-hook.ts new file mode 100644 index 0000000..299ecf2 --- /dev/null +++ b/src/commands/git-hook.ts @@ -0,0 +1,52 @@ +import type {Arguments, Argv} from 'yargs'; + +import {configGet} from '../lib/config'; +import {handlerWrapper} from '../lib/handler-wrapper'; +import {Runner, Writer} from '../lib/runner'; + +type Options = Record; + +export function builder(argv: Argv) { + return argv + .positional('hook', {type: 'string', required: true}) + .string('test'); +} + +export async function handler(args: Arguments): Promise { + const [, hook] = args._; + const hooks = configGet(`hooks.${hook}`, [] as string[]); + + // Exit early if no hooks are defined + if (!hooks.length) return 0; + + const runner = new Runner(hooks); + const writer = new Writer(process.stdout); + + runner.start(); + + writer.print(runner); + const ticker = setInterval(() => writer.update(runner), 80); + await runner.wait(); + + clearInterval(ticker); + writer.update(runner); + + let exitCode = 0; + for (const command of runner) { + if (command.state === 'success') { + continue; + } + + exitCode = 1; + + writer.writeLine('-'.repeat(process.stderr.getWindowSize()[0])); + writer.writeLine(`Command failed: ${command.title}`); + writer.writeLine(''); + writer.writeLine(command.stdout); + writer.writeLine(command.stderr); + } + + return exitCode; +} + +export default {builder, handler: handlerWrapper(handler)}; diff --git a/src/index.ts b/src/index.ts index 5f994a9..ea14aac 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import yargs from 'yargs'; import {hideBin} from 'yargs/helpers'; import commitgen from './commands/commitgen'; +import gitHook from './commands/git-hook'; import gitHookInstall from './commands/git-hook:install'; export async function run(args: string[]) { @@ -12,13 +13,19 @@ export async function run(args: string[]) { commitgen.builder, commitgen.handler, ) + .command( + 'git-hook', + 'Run the hooks for a given action', + gitHook.builder, + gitHook.handler, + ) .command( 'git-hook:install', 'Install git hooks', gitHookInstall.builder, gitHookInstall.handler, ) + .showHelpOnFail(false) .strictOptions() - .strict() .parse(); } diff --git a/src/lib/runner/command.ts b/src/lib/runner/command.ts new file mode 100644 index 0000000..51899e4 --- /dev/null +++ b/src/lib/runner/command.ts @@ -0,0 +1,56 @@ +import type {ChildProcess} from 'child_process'; + +/** + * The shape of an internal command. + */ +export interface Command { + /** + * The title of the command. This will be displayed in the terminal. + */ + title: string; + /** + * The script that will run the command. + */ + script: string; + /** + * The currently running process. + */ + process?: ChildProcess; + /** + * The output of the command. + */ + stdout?: string; + /** + * The error output of the command. + */ + stderr?: string; + /** + * The current state of the command. + */ + state: 'pending' | 'running' | 'error' | 'success'; +} + +/** + * Create a new command structure from a script string + */ +export function createCommand(script: string | Command): Command { + if (typeof script !== 'string') { + return script; + } + + return { + script, + title: getTitle(script), + state: 'pending', + }; +} + +/** + * Get the title of the script. This will be the first line of the script if + * its multiline. + */ +export function getTitle(script: string) { + return !script.includes('\n') + ? script + : script.split('\n')[0].replace(/^#\s+(.*)/, '$1'); +} diff --git a/src/lib/runner/index.ts b/src/lib/runner/index.ts new file mode 100644 index 0000000..6bf681d --- /dev/null +++ b/src/lib/runner/index.ts @@ -0,0 +1,5 @@ +/** + * Exports for convenience + */ +export * from './runner'; +export * from './writer'; diff --git a/src/lib/runner/runner.ts b/src/lib/runner/runner.ts new file mode 100644 index 0000000..668e9e2 --- /dev/null +++ b/src/lib/runner/runner.ts @@ -0,0 +1,108 @@ +import {spawn} from 'child_process'; +import EventEmitter from 'events'; + +import {type Command, createCommand} from './command'; + +export class Runner { + /** + * A list of all the commands that need to be run by this runner + */ + private commands: Command[] = []; + + /** + * The event emitter that will handle the complete event that will get called + * when a command has finished. + */ + private eventEmitter = new EventEmitter(); + + /** + * Create the runner initializing all of the commands that need to be run + */ + constructor(commands: (Command | string)[]) { + this.commands = commands.map(createCommand); + } + + /** + * Returns the number of commands the runner is executing + */ + get length() { + return this.commands.length; + } + + /** + * Iterator implementation so we can loop over all of the commands in the + * runner + */ + *[Symbol.iterator]() { + for (const command of this.commands) { + yield command; + } + } + + /** + * Start all of the commands + */ + public start() { + this.commands.forEach(this.runCommand.bind(this)); + } + + /** + * Wait for all the commands to have exited. This will block until all of the + * scripts have finished running + */ + public wait() { + return new Promise(resolve => { + if (this.isFinished()) { + resolve(); + } + + this.eventEmitter.on('complete', () => { + if (this.isFinished()) { + resolve(); + } + }); + }); + } + + /** + * Helper function to test if all teh commands have finished running. A + * command has finished when its status is either success or error. + */ + public isFinished() { + return !this.commands.some(command => + ['pending', 'running'].includes(command.state), + ); + } + + /** + * Run a command and emits the 'complete' event when the process has exited. + * The command status will be updated before the event is fired. The status + * will be determined by the exit code of the command. + */ + private async runCommand(command: Command) { + if (command.state !== 'pending') { + return; + } + + command.process = spawn('bash', ['-c', command.script], { + stdio: ['inherit', 'pipe', 'pipe'], + }); + + command.stdout = ''; + command.process.stdout?.on('data', data => { + command.stdout += data.toString(); + }); + + command.stderr = ''; + command.process.stderr?.on('data', data => { + command.stderr += data.toString(); + }); + + command.state = 'running'; + + command.process.on('exit', code => { + command.state = code === 0 ? 'success' : 'error'; + this.eventEmitter.emit('complete'); + }); + } +} diff --git a/src/lib/runner/writer.ts b/src/lib/runner/writer.ts new file mode 100644 index 0000000..da3c39e --- /dev/null +++ b/src/lib/runner/writer.ts @@ -0,0 +1,40 @@ +import type {Command} from './command'; +import type {Runner} from './runner'; + +const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; + +export class Writer { + private frame = 0; + + constructor(private stream: NodeJS.WriteStream) {} + + public writeLine(line: string | undefined) { + if (!line) return; + this.stream.write(`${line}\n`); + } + + public print(runner: Runner) { + for (const command of runner) { + this.writeLine(`${this.prefix(command)} ${command.title}`); + } + + this.frame = ++this.frame % FRAMES.length; + } + + public update(runner: Runner) { + this.stream.moveCursor(0, runner.length * -1); + this.print(runner); + } + + private prefix(command: Command) { + if (command.state === 'pending') { + return '⧗'; + } + + if (command.state === 'running') { + return FRAMES[this.frame]; + } + + return command.state === 'success' ? '✔' : '✘'; + } +} diff --git a/tests/lib/runner/command.spec.ts b/tests/lib/runner/command.spec.ts new file mode 100644 index 0000000..011ce9d --- /dev/null +++ b/tests/lib/runner/command.spec.ts @@ -0,0 +1,57 @@ +import {describe, expect, it} from 'vitest'; + +import {createCommand} from '../../../src/lib/runner/command'; + +describe('lib/runner/command', () => { + describe('with a single line script', () => { + const command = createCommand('echo "Hello, World!"'); + + it('sets the command to a pending state by default', () => { + expect(command.state).toBe('pending'); + }); + + it('sets the script content to the command script', () => { + expect(command.script).toBe('echo "Hello, World!"'); + }); + + it('sets the command title to be the script script body when its a single line script', () => { + expect(command.title).toBe('echo "Hello, World!"'); + }); + }); + + describe('with a multi line script', () => { + const command = createCommand( + '# The command title\necho "Goodbye, World!"', + ); + + it('sets the command body to be the whole script', () => { + expect(command.script).toBe( + '# The command title\necho "Goodbye, World!"', + ); + }); + + it('sets the title to be the first line of the comment', () => { + expect(command.title).toBe('The command title'); + }); + }); + + describe('with an already created command passed in', () => { + const command = createCommand({ + title: 'My Command', + script: 'echo "Hello, World!"', + state: 'pending', + }); + + it('keeps the title of the command the same', () => { + expect(command.title).toBe('My Command'); + }); + + it('keeps the command the same', () => { + expect(command.script).toBe('echo "Hello, World!"'); + }); + + it('keeps the state the same', () => { + expect(command.state).toBe('pending'); + }); + }); +}); diff --git a/tests/lib/runner/writer.spec.ts b/tests/lib/runner/writer.spec.ts new file mode 100644 index 0000000..6e10c2b --- /dev/null +++ b/tests/lib/runner/writer.spec.ts @@ -0,0 +1,25 @@ +import {describe, expect, it, vi} from 'vitest'; + +import {Runner, Writer} from '../../../src/lib/runner'; + +describe('lib/runner/Writer', () => { + describe('with a runner that all commands are pending', () => { + const stream = {write: vi.fn()} as any as NodeJS.WriteStream; + const writer = new Writer(stream); + + const runner = new Runner([ + {title: 'Command 1', script: 'echo "Hello, World!"', state: 'pending'}, + {title: 'Command 2', script: 'echo "Goodbye, World!"', state: 'pending'}, + ]); + + writer.print(runner); + + it('prints the frist command to the console', () => { + expect(stream.write).toHaveBeenCalledWith('⧗ Command 1\n'); + }); + + it('prints the second command to the console', () => { + expect(stream.write).toHaveBeenCalledWith('⧗ Command 2\n'); + }); + }); +});