Skip to content
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ Rstack CLI is inspired by:
- [Cargo](https://github.com/rust-lang/cargo)
- [Deno](https://github.com/denoland/deno)
- [Bun](https://github.com/oven-sh/bun)
- [Husky](https://github.com/typicode/husky)
- [Vite Plus](https://github.com/voidzero-dev/vite-plus)

## License
Expand Down
53 changes: 53 additions & 0 deletions packages/rstack/src/hooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
// Keep this list aligned with the client-side hooks generated by Husky v9.
// `pre-auto-gc` is included even though it is not listed in Husky's documentation.
const hookNames = [
'pre-commit',
'pre-merge-commit',
'prepare-commit-msg',
'commit-msg',
'post-commit',
'applypatch-msg',
'pre-applypatch',
'post-applypatch',
'pre-rebase',
'post-rewrite',
'post-checkout',
'post-merge',
'pre-push',
'pre-auto-gc',
] as const;

type HookFileName = (typeof hookNames)[number] | 'runner';

// Generated shims live in `<hooks-directory>/_`. When a shim sources this
// dispatcher, `$0` still points to the shim, so the user hook is one level up.
const dispatcher = `#!/usr/bin/env sh

name=$(basename "$0")
dir=$(dirname "$(dirname "$0")")
hook="$dir/$name"

[ -f "$hook" ] || exit 0

sh -e "$hook" "$@"
Comment thread
chenjiahan marked this conversation as resolved.
status=$?
exit "$status"
`;

// Every generated Git hook sources the same dispatcher to keep runtime behavior
// consistent and make future initialization changes local to one file.
const shim = `#!/usr/bin/env sh
. "$(dirname "$0")/runner"
`;

export const createHookFiles = (): Record<HookFileName, string> => {
const files: Record<string, string> = {
runner: dispatcher,
};

for (const name of hookNames) {
files[name] = shim;
}

return files as Record<HookFileName, string>;
};
74 changes: 74 additions & 0 deletions packages/rstack/tests/hooks/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { expect, test } from 'rstack/test';
import { createHookFiles } from '../../src/hooks.ts';

test('generates the dispatcher and all client-side Git hook shims', () => {
const { runner, ...shims } = createHookFiles();

expect(Object.keys(shims)).toEqual([
'pre-commit',
'pre-merge-commit',
'prepare-commit-msg',
'commit-msg',
'post-commit',
'applypatch-msg',
'pre-applypatch',
'post-applypatch',
'pre-rebase',
'post-rewrite',
'post-checkout',
'post-merge',
'pre-push',
'pre-auto-gc',
]);
expect(runner).toBeTruthy();
expect(new Set(Object.values(shims)).size).toBe(1);
});

test.runIf(process.platform !== 'win32')('runs generated hooks', () => {
const directory = mkdtempSync(path.join(tmpdir(), 'rstack hooks '));
const hooksDirectory = path.join(directory, 'hooks with spaces');
const generatedDirectory = path.join(hooksDirectory, '_');
const generatedHook = path.join(generatedDirectory, 'pre-commit');
const userHook = path.join(hooksDirectory, 'pre-commit');
const files = createHookFiles();

try {
mkdirSync(generatedDirectory, { recursive: true });
writeFileSync(path.join(generatedDirectory, 'runner'), files.runner);
writeFileSync(generatedHook, files['pre-commit']);

expect(spawnSync('sh', [generatedHook]).status).toBe(0);

writeFileSync(
userHook,
`read -r input
printf '%s\\n' "$1|$input"
exit 23
`,
);
const result = spawnSync('sh', [generatedHook, 'argument with spaces'], {
encoding: 'utf8',
input: 'standard input\n',
});

expect(result.status).toBe(23);
expect(result.stdout).toBe('argument with spaces|standard input\n');

writeFileSync(
userHook,
`false
printf 'unreachable\\n'
`,
);
const errexitResult = spawnSync('sh', [generatedHook], { encoding: 'utf8' });

expect(errexitResult.status).toBe(1);
expect(errexitResult.stdout).toBe('');
} finally {
rmSync(directory, { force: true, recursive: true });
}
});
2 changes: 2 additions & 0 deletions scripts/dictionary.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
# Custom Dictionary Words
applypatch
errexit
fnames
llms
oxfmt
Expand Down