Skip to content

Commit 2719f29

Browse files
authored
fix(setup): preserve hook file paths in monorepos (#283)
1 parent 33cebb4 commit 2719f29

5 files changed

Lines changed: 91 additions & 16 deletions

File tree

‎packages/rstack/src/setup/hooks.ts‎

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ const quoteShellPath = (value: string): string => {
3030
return `'${shellPath.replaceAll("'", `'"'"'`)}'`;
3131
};
3232

33-
const createDispatcher = (nodeExecutable: string): string => `#!/usr/bin/env sh
33+
const createRunner = (nodeExecutable: string): string => `#!/usr/bin/env sh
3434
# Generated by Rstack. Do not edit.
3535
3636
rs_name=\${0##*/}
@@ -54,31 +54,69 @@ if ! command -v node >/dev/null 2>&1 && [ -x "$rs_node_fallback" ]; then
5454
PATH="\${PATH:+$PATH:}\${rs_node_fallback%/*}"
5555
fi
5656
57-
cd "$rs_root/$rs_project_path" || exit 1
58-
export PATH="node_modules/.bin\${PATH:+:$PATH}"
57+
rs_run() {
58+
cd "$rs_root/$rs_project_path" || return 1
59+
export PATH="node_modules/.bin\${PATH:+:$PATH}"
5960
60-
rs_code=0
61-
sh -e "$rs_hook" "$@" || rs_code=$?
61+
rs_code=0
62+
sh -e "$rs_hook" "$@" || rs_code=$?
6263
63-
[ "$rs_code" = "0" ] || echo "Rstack - $rs_name hook failed (code $rs_code)"
64-
[ "$rs_code" = "127" ] && echo "Rstack - command not found in PATH=$PATH"
65-
exit "$rs_code"
64+
[ "$rs_code" = "0" ] || echo "Rstack - $rs_name hook failed (code $rs_code)"
65+
[ "$rs_code" = "127" ] && echo "Rstack - command not found in PATH=$PATH"
66+
return "$rs_code"
67+
}
6668
`;
6769

68-
// Every generated Git hook sources the same dispatcher to keep runtime behavior
69-
// consistent and make future initialization changes local to one file.
70-
const shim = `#!/usr/bin/env sh
70+
const createShim = (prepareArguments = ''): string => `#!/usr/bin/env sh
7171
rs_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) || exit 1
7272
. "$rs_dir/runner"
73+
74+
${prepareArguments}
75+
rs_run "$@"
7376
`;
7477

7578
export const createHookFiles = (
7679
nodeExecutable: string = process.execPath,
7780
): Record<string, string> => {
78-
const files: Record<string, string> = { runner: createDispatcher(nodeExecutable) };
81+
const messageShim = createShim(`# Keep the message file valid after changing directories.
82+
[ -n "\${1-}" ] || exit 1
83+
case "$1" in
84+
/*|[A-Za-z]:/*) ;;
85+
*)
86+
rs_file=$1
87+
shift
88+
set -- "$rs_root/$rs_file" "$@"
89+
;;
90+
esac
91+
`);
92+
93+
const prePushShim = createShim(`# Keep a local remote path valid after changing directories.
94+
rs_remote_name=\${1-}
95+
rs_remote_location=\${2-}
96+
[ -n "$rs_remote_name" ] && [ -n "$rs_remote_location" ] || exit 1
97+
case "$rs_remote_location" in
98+
/*|[A-Za-z]:/*) ;;
99+
*:*)
100+
# Git treats a colon before any slash as a URL or SCP-style remote.
101+
case "\${rs_remote_location%%:*}" in
102+
*/*) rs_remote_location="$rs_root/$rs_remote_location" ;;
103+
esac
104+
;;
105+
*) rs_remote_location="$rs_root/$rs_remote_location" ;;
106+
esac
107+
shift 2
108+
set -- "$rs_remote_name" "$rs_remote_location" "$@"
109+
`);
110+
111+
const defaultShim = createShim();
112+
const files: Record<string, string> = { runner: createRunner(nodeExecutable) };
79113

80114
for (const name of hookNames) {
81-
files[name] = shim;
115+
files[name] = name.endsWith('-msg')
116+
? messageShim
117+
: name === 'pre-push'
118+
? prePushShim
119+
: defaultShim;
82120
}
83121

84122
return files;

‎packages/rstack/tests/setup/helpers.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,9 @@ export const writeInit = (cwd: string, content: string): void => {
7070
export const runHook = (cwd: string, value?: string): SpawnSyncReturns<string> =>
7171
git(cwd, ['hook', 'run', 'pre-commit'], hookEnv(cwd, value));
7272

73+
export const runGitHook = (cwd: string, name: string, args: string[]): SpawnSyncReturns<string> =>
74+
git(cwd, ['hook', 'run', name, '--', ...args], hookEnv(cwd));
75+
7376
export const withRepository = (callback: (cwd: string) => void): void =>
7477
withDirectory((cwd) => {
7578
const globalConfig = process.env.GIT_CONFIG_GLOBAL;

‎packages/rstack/tests/setup/hooks.test.ts‎

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { expect, test } from 'rstack/test';
55
import { createHookFiles } from '../../src/setup/hooks.ts';
66
import { withDirectory } from './helpers.ts';
77

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

1111
expect(Object.keys(shims)).toEqual([
@@ -25,7 +25,6 @@ test('generates the dispatcher and all client-side Git hook shims', () => {
2525
'pre-auto-gc',
2626
]);
2727
expect(runner).toBeTruthy();
28-
expect(new Set(Object.values(shims)).size).toBe(1);
2928
});
3029

3130
test.runIf(process.platform === 'win32')('converts Windows Node paths', () => {

‎packages/rstack/tests/setup/runtime.test.ts‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'n
22
import path from 'node:path';
33
import { expect, test } from 'rstack/test';
44
import { installHooks } from '../../src/setup/install.ts';
5-
import { runHook, withRepository, writeHook, writeInit } from './helpers.ts';
5+
import { runGitHook, runHook, withRepository, writeHook, writeInit } from './helpers.ts';
66

77
test('loads user init and project binaries', () => {
88
withRepository((cwd) => {
@@ -35,6 +35,36 @@ rstack-hook-command
3535
});
3636
});
3737

38+
test('preserves cwd-sensitive hook arguments for a nested project', () => {
39+
withRepository((cwd) => {
40+
const projectDirectory = path.join(cwd, 'frontend');
41+
const messagePath = '.git/COMMIT_EDITMSG';
42+
mkdirSync(projectDirectory);
43+
writeFileSync(path.join(cwd, messagePath), 'commit message\n');
44+
45+
expect(installHooks({ cwd: projectDirectory }).status).toBe('installed');
46+
47+
for (const name of ['applypatch-msg', 'commit-msg', 'prepare-commit-msg']) {
48+
writeFileSync(path.join(cwd, '.rstack', 'hooks', name), 'cat "$1"\n');
49+
50+
expect(runGitHook(cwd, name, [messagePath])).toMatchObject({
51+
status: 0,
52+
stderr: 'commit message\n',
53+
});
54+
}
55+
56+
mkdirSync(path.join(cwd, 'remote.git'));
57+
writeFileSync(
58+
path.join(cwd, '.rstack', 'hooks', 'pre-push'),
59+
'[ -d "$2" ] || [ "$2" = "git@example.com:repo.git" ]\n',
60+
);
61+
62+
for (const remote of ['remote.git', 'git@example.com:repo.git']) {
63+
expect(runGitHook(cwd, 'pre-push', ['origin', remote]).status).toBe(0);
64+
}
65+
});
66+
});
67+
3868
test('skips user hooks when disabled by the environment or init', () => {
3969
withRepository((cwd) => {
4070
writeHook(cwd, 'echo ran >> hook-ran\n');
@@ -47,5 +77,8 @@ test('skips user hooks when disabled by the environment or init', () => {
4777

4878
expect(runHook(cwd).status).toBe(0);
4979
expect(existsSync(path.join(cwd, 'hook-ran'))).toBe(false);
80+
81+
writeFileSync(path.join(cwd, '.rstack', 'hooks', 'commit-msg'), 'exit 1\n');
82+
expect(runGitHook(cwd, 'commit-msg', []).status).toBe(0);
5083
});
5184
});

‎scripts/dictionary.txt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ applypatch
33
cdpath
44
clippy
55
dirents
6+
editmsg
67
errexit
8+
esac
79
extglob
810
fnames
911
huskyrc

0 commit comments

Comments
 (0)