Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add git hook command #92

Merged
merged 1 commit into from
May 29, 2024
Merged
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
52 changes: 52 additions & 0 deletions src/commands/git-hook.ts
Original file line number Diff line number Diff line change
@@ -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<string, never>;

export function builder(argv: Argv<Options>) {
return argv
.positional('hook', {type: 'string', required: true})
.string('test');
}

export async function handler(args: Arguments<Options>): Promise<number> {
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)};
9 changes: 8 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]) {
Expand All @@ -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();
}
56 changes: 56 additions & 0 deletions src/lib/runner/command.ts
Original file line number Diff line number Diff line change
@@ -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');
}
5 changes: 5 additions & 0 deletions src/lib/runner/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/**
* Exports for convenience
*/
export * from './runner';
export * from './writer';
108 changes: 108 additions & 0 deletions src/lib/runner/runner.ts
Original file line number Diff line number Diff line change
@@ -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<void>(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');
});
}
}
40 changes: 40 additions & 0 deletions src/lib/runner/writer.ts
Original file line number Diff line number Diff line change
@@ -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' ? '✔' : '✘';
}
}
57 changes: 57 additions & 0 deletions tests/lib/runner/command.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
25 changes: 25 additions & 0 deletions tests/lib/runner/writer.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
});
Loading