Skip to content

Commit e35a8fa

Browse files
authored
feat(setup): add Git hook dispatcher generation (#81)
1 parent 27a4d92 commit e35a8fa

4 files changed

Lines changed: 130 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ Rstack CLI is inspired by:
8383
- [Cargo](https://github.com/rust-lang/cargo)
8484
- [Deno](https://github.com/denoland/deno)
8585
- [Bun](https://github.com/oven-sh/bun)
86+
- [Husky](https://github.com/typicode/husky)
8687
- [Vite Plus](https://github.com/voidzero-dev/vite-plus)
8788

8889
## License

packages/rstack/src/hooks.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Keep this list aligned with the client-side hooks generated by Husky v9.
2+
// `pre-auto-gc` is included even though it is not listed in Husky's documentation.
3+
const hookNames = [
4+
'pre-commit',
5+
'pre-merge-commit',
6+
'prepare-commit-msg',
7+
'commit-msg',
8+
'post-commit',
9+
'applypatch-msg',
10+
'pre-applypatch',
11+
'post-applypatch',
12+
'pre-rebase',
13+
'post-rewrite',
14+
'post-checkout',
15+
'post-merge',
16+
'pre-push',
17+
'pre-auto-gc',
18+
] as const;
19+
20+
type HookFileName = (typeof hookNames)[number] | 'runner';
21+
22+
// Generated shims live in `<hooks-directory>/_`. When a shim sources this
23+
// dispatcher, `$0` still points to the shim, so the user hook is one level up.
24+
const dispatcher = `#!/usr/bin/env sh
25+
26+
name=$(basename "$0")
27+
dir=$(dirname "$(dirname "$0")")
28+
hook="$dir/$name"
29+
30+
[ -f "$hook" ] || exit 0
31+
32+
sh -e "$hook" "$@"
33+
status=$?
34+
exit "$status"
35+
`;
36+
37+
// Every generated Git hook sources the same dispatcher to keep runtime behavior
38+
// consistent and make future initialization changes local to one file.
39+
const shim = `#!/usr/bin/env sh
40+
. "$(dirname "$0")/runner"
41+
`;
42+
43+
export const createHookFiles = (): Record<HookFileName, string> => {
44+
const files: Record<string, string> = {
45+
runner: dispatcher,
46+
};
47+
48+
for (const name of hookNames) {
49+
files[name] = shim;
50+
}
51+
52+
return files as Record<HookFileName, string>;
53+
};
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { spawnSync } from 'node:child_process';
2+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3+
import { tmpdir } from 'node:os';
4+
import path from 'node:path';
5+
import { expect, test } from 'rstack/test';
6+
import { createHookFiles } from '../../src/hooks.ts';
7+
8+
test('generates the dispatcher and all client-side Git hook shims', () => {
9+
const { runner, ...shims } = createHookFiles();
10+
11+
expect(Object.keys(shims)).toEqual([
12+
'pre-commit',
13+
'pre-merge-commit',
14+
'prepare-commit-msg',
15+
'commit-msg',
16+
'post-commit',
17+
'applypatch-msg',
18+
'pre-applypatch',
19+
'post-applypatch',
20+
'pre-rebase',
21+
'post-rewrite',
22+
'post-checkout',
23+
'post-merge',
24+
'pre-push',
25+
'pre-auto-gc',
26+
]);
27+
expect(runner).toBeTruthy();
28+
expect(new Set(Object.values(shims)).size).toBe(1);
29+
});
30+
31+
test.runIf(process.platform !== 'win32')('runs generated hooks', () => {
32+
const directory = mkdtempSync(path.join(tmpdir(), 'rstack hooks '));
33+
const hooksDirectory = path.join(directory, 'hooks with spaces');
34+
const generatedDirectory = path.join(hooksDirectory, '_');
35+
const generatedHook = path.join(generatedDirectory, 'pre-commit');
36+
const userHook = path.join(hooksDirectory, 'pre-commit');
37+
const files = createHookFiles();
38+
39+
try {
40+
mkdirSync(generatedDirectory, { recursive: true });
41+
writeFileSync(path.join(generatedDirectory, 'runner'), files.runner);
42+
writeFileSync(generatedHook, files['pre-commit']);
43+
44+
expect(spawnSync('sh', [generatedHook]).status).toBe(0);
45+
46+
writeFileSync(
47+
userHook,
48+
`read -r input
49+
printf '%s\\n' "$1|$input"
50+
exit 23
51+
`,
52+
);
53+
const result = spawnSync('sh', [generatedHook, 'argument with spaces'], {
54+
encoding: 'utf8',
55+
input: 'standard input\n',
56+
});
57+
58+
expect(result.status).toBe(23);
59+
expect(result.stdout).toBe('argument with spaces|standard input\n');
60+
61+
writeFileSync(
62+
userHook,
63+
`false
64+
printf 'unreachable\\n'
65+
`,
66+
);
67+
const errexitResult = spawnSync('sh', [generatedHook], { encoding: 'utf8' });
68+
69+
expect(errexitResult.status).toBe(1);
70+
expect(errexitResult.stdout).toBe('');
71+
} finally {
72+
rmSync(directory, { force: true, recursive: true });
73+
}
74+
});

scripts/dictionary.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
# Custom Dictionary Words
2+
applypatch
3+
errexit
24
fnames
35
llms
46
oxfmt

0 commit comments

Comments
 (0)