Skip to content

Commit cf6f584

Browse files
committed
feat: add git hook command
Summary: This command is compatible with the V1 git hook so everything we have still works. This implements a custom command runner and writer that will run shell commands in parallel and display the output in a nice way. This is a full replacement for the previous implementation and packages. Test Plan: This has been tested manually and the tests have been run. Right now this is still a bit work in progress but it's a good start. Ref: #77
1 parent c05840a commit cf6f584

File tree

8 files changed

+351
-1
lines changed

8 files changed

+351
-1
lines changed

src/commands/git-hook.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import type {Arguments, Argv} from 'yargs';
2+
3+
import {configGet} from '../lib/config';
4+
import {handlerWrapper} from '../lib/handler-wrapper';
5+
import {Runner, Writer} from '../lib/runner';
6+
7+
type Options = Record<string, never>;
8+
9+
export function builder(argv: Argv<Options>) {
10+
return argv
11+
.positional('hook', {type: 'string', required: true})
12+
.string('test');
13+
}
14+
15+
export async function handler(args: Arguments<Options>): Promise<number> {
16+
const [, hook] = args._;
17+
const hooks = configGet(`hooks.${hook}`, [] as string[]);
18+
19+
// Exit early if no hooks are defined
20+
if (!hooks.length) return 0;
21+
22+
const runner = new Runner(hooks);
23+
const writer = new Writer(process.stdout);
24+
25+
runner.start();
26+
27+
writer.print(runner);
28+
const ticker = setInterval(() => writer.update(runner), 80);
29+
await runner.wait();
30+
31+
clearInterval(ticker);
32+
writer.update(runner);
33+
34+
let exitCode = 0;
35+
for (const command of runner) {
36+
if (command.state === 'success') {
37+
continue;
38+
}
39+
40+
exitCode = 1;
41+
42+
writer.writeLine('-'.repeat(process.stderr.getWindowSize()[0]));
43+
writer.writeLine(`Command failed: ${command.title}`);
44+
writer.writeLine('');
45+
writer.writeLine(command.stdout);
46+
writer.writeLine(command.stderr);
47+
}
48+
49+
return exitCode;
50+
}
51+
52+
export default {builder, handler: handlerWrapper(handler)};

src/index.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import yargs from 'yargs';
22
import {hideBin} from 'yargs/helpers';
33

44
import commitgen from './commands/commitgen';
5+
import gitHook from './commands/git-hook';
56
import gitHookInstall from './commands/git-hook:install';
67

78
export async function run(args: string[]) {
@@ -12,13 +13,19 @@ export async function run(args: string[]) {
1213
commitgen.builder,
1314
commitgen.handler,
1415
)
16+
.command(
17+
'git-hook',
18+
'Run the hooks for a given action',
19+
gitHook.builder,
20+
gitHook.handler,
21+
)
1522
.command(
1623
'git-hook:install',
1724
'Install git hooks',
1825
gitHookInstall.builder,
1926
gitHookInstall.handler,
2027
)
28+
.showHelpOnFail(false)
2129
.strictOptions()
22-
.strict()
2330
.parse();
2431
}

src/lib/runner/command.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type {ChildProcess} from 'child_process';
2+
3+
/**
4+
* The shape of an internal command.
5+
*/
6+
export interface Command {
7+
/**
8+
* The title of the command. This will be displayed in the terminal.
9+
*/
10+
title: string;
11+
/**
12+
* The script that will run the command.
13+
*/
14+
script: string;
15+
/**
16+
* The currently running process.
17+
*/
18+
process?: ChildProcess;
19+
/**
20+
* The output of the command.
21+
*/
22+
stdout?: string;
23+
/**
24+
* The error output of the command.
25+
*/
26+
stderr?: string;
27+
/**
28+
* The current state of the command.
29+
*/
30+
state: 'pending' | 'running' | 'error' | 'success';
31+
}
32+
33+
/**
34+
* Create a new command structure from a script string
35+
*/
36+
export function createCommand(script: string | Command): Command {
37+
if (typeof script !== 'string') {
38+
return script;
39+
}
40+
41+
return {
42+
script,
43+
title: getTitle(script),
44+
state: 'pending',
45+
};
46+
}
47+
48+
/**
49+
* Get the title of the script. This will be the first line of the script if
50+
* its multiline.
51+
*/
52+
export function getTitle(script: string) {
53+
return !script.includes('\n')
54+
? script
55+
: script.split('\n')[0].replace(/^#\s+(.*)/, '$1');
56+
}

src/lib/runner/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/**
2+
* Exports for convenience
3+
*/
4+
export * from './runner';
5+
export * from './writer';

src/lib/runner/runner.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import {spawn} from 'child_process';
2+
import EventEmitter from 'events';
3+
4+
import {type Command, createCommand} from './command';
5+
6+
export class Runner {
7+
/**
8+
* A list of all the commands that need to be run by this runner
9+
*/
10+
private commands: Command[] = [];
11+
12+
/**
13+
* The event emitter that will handle the complete event that will get called
14+
* when a command has finished.
15+
*/
16+
private eventEmitter = new EventEmitter();
17+
18+
/**
19+
* Create the runner initializing all of the commands that need to be run
20+
*/
21+
constructor(commands: (Command | string)[]) {
22+
this.commands = commands.map(createCommand);
23+
}
24+
25+
/**
26+
* Returns the number of commands the runner is executing
27+
*/
28+
get length() {
29+
return this.commands.length;
30+
}
31+
32+
/**
33+
* Iterator implementation so we can loop over all of the commands in the
34+
* runner
35+
*/
36+
*[Symbol.iterator]() {
37+
for (const command of this.commands) {
38+
yield command;
39+
}
40+
}
41+
42+
/**
43+
* Start all of the commands
44+
*/
45+
public start() {
46+
this.commands.forEach(this.runCommand.bind(this));
47+
}
48+
49+
/**
50+
* Wait for all the commands to have exited. This will block until all of the
51+
* scripts have finished running
52+
*/
53+
public wait() {
54+
return new Promise<void>(resolve => {
55+
if (this.isFinished()) {
56+
resolve();
57+
}
58+
59+
this.eventEmitter.on('complete', () => {
60+
if (this.isFinished()) {
61+
resolve();
62+
}
63+
});
64+
});
65+
}
66+
67+
/**
68+
* Helper function to test if all teh commands have finished running. A
69+
* command has finished when its status is either success or error.
70+
*/
71+
public isFinished() {
72+
return !this.commands.some(command =>
73+
['pending', 'running'].includes(command.state),
74+
);
75+
}
76+
77+
/**
78+
* Run a command and emits the 'complete' event when the process has exited.
79+
* The command status will be updated before the event is fired. The status
80+
* will be determined by the exit code of the command.
81+
*/
82+
private async runCommand(command: Command) {
83+
if (command.state !== 'pending') {
84+
return;
85+
}
86+
87+
command.process = spawn('bash', ['-c', command.script], {
88+
stdio: ['inherit', 'pipe', 'pipe'],
89+
});
90+
91+
command.stdout = '';
92+
command.process.stdout?.on('data', data => {
93+
command.stdout += data.toString();
94+
});
95+
96+
command.stderr = '';
97+
command.process.stderr?.on('data', data => {
98+
command.stderr += data.toString();
99+
});
100+
101+
command.state = 'running';
102+
103+
command.process.on('exit', code => {
104+
command.state = code === 0 ? 'success' : 'error';
105+
this.eventEmitter.emit('complete');
106+
});
107+
}
108+
}

src/lib/runner/writer.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import type {Command} from './command';
2+
import type {Runner} from './runner';
3+
4+
const FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
5+
6+
export class Writer {
7+
private frame = 0;
8+
9+
constructor(private stream: NodeJS.WriteStream) {}
10+
11+
public writeLine(line: string | undefined) {
12+
if (!line) return;
13+
this.stream.write(`${line}\n`);
14+
}
15+
16+
public print(runner: Runner) {
17+
for (const command of runner) {
18+
this.writeLine(`${this.prefix(command)} ${command.title}`);
19+
}
20+
21+
this.frame = ++this.frame % FRAMES.length;
22+
}
23+
24+
public update(runner: Runner) {
25+
this.stream.moveCursor(0, runner.length * -1);
26+
this.print(runner);
27+
}
28+
29+
private prefix(command: Command) {
30+
if (command.state === 'pending') {
31+
return '⧗';
32+
}
33+
34+
if (command.state === 'running') {
35+
return FRAMES[this.frame];
36+
}
37+
38+
return command.state === 'success' ? '✔' : '✘';
39+
}
40+
}

tests/lib/runner/command.spec.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import {describe, expect, it} from 'vitest';
2+
3+
import {createCommand} from '../../../src/lib/runner/command';
4+
5+
describe('lib/runner/command', () => {
6+
describe('with a single line script', () => {
7+
const command = createCommand('echo "Hello, World!"');
8+
9+
it('sets the command to a pending state by default', () => {
10+
expect(command.state).toBe('pending');
11+
});
12+
13+
it('sets the script content to the command script', () => {
14+
expect(command.script).toBe('echo "Hello, World!"');
15+
});
16+
17+
it('sets the command title to be the script script body when its a single line script', () => {
18+
expect(command.title).toBe('echo "Hello, World!"');
19+
});
20+
});
21+
22+
describe('with a multi line script', () => {
23+
const command = createCommand(
24+
'# The command title\necho "Goodbye, World!"',
25+
);
26+
27+
it('sets the command body to be the whole script', () => {
28+
expect(command.script).toBe(
29+
'# The command title\necho "Goodbye, World!"',
30+
);
31+
});
32+
33+
it('sets the title to be the first line of the comment', () => {
34+
expect(command.title).toBe('The command title');
35+
});
36+
});
37+
38+
describe('with an already created command passed in', () => {
39+
const command = createCommand({
40+
title: 'My Command',
41+
script: 'echo "Hello, World!"',
42+
state: 'pending',
43+
});
44+
45+
it('keeps the title of the command the same', () => {
46+
expect(command.title).toBe('My Command');
47+
});
48+
49+
it('keeps the command the same', () => {
50+
expect(command.script).toBe('echo "Hello, World!"');
51+
});
52+
53+
it('keeps the state the same', () => {
54+
expect(command.state).toBe('pending');
55+
});
56+
});
57+
});

tests/lib/runner/writer.spec.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import {describe, expect, it, vi} from 'vitest';
2+
3+
import {Runner, Writer} from '../../../src/lib/runner';
4+
5+
describe('lib/runner/Writer', () => {
6+
describe('with a runner that all commands are pending', () => {
7+
const stream = {write: vi.fn()} as any as NodeJS.WriteStream;
8+
const writer = new Writer(stream);
9+
10+
const runner = new Runner([
11+
{title: 'Command 1', script: 'echo "Hello, World!"', state: 'pending'},
12+
{title: 'Command 2', script: 'echo "Goodbye, World!"', state: 'pending'},
13+
]);
14+
15+
writer.print(runner);
16+
17+
it('prints the frist command to the console', () => {
18+
expect(stream.write).toHaveBeenCalledWith('⧗ Command 1\n');
19+
});
20+
21+
it('prints the second command to the console', () => {
22+
expect(stream.write).toHaveBeenCalledWith('⧗ Command 2\n');
23+
});
24+
});
25+
});

0 commit comments

Comments
 (0)