Skip to content

Commit e6add72

Browse files
committed
fix(setup): provide Node fallback for Git hooks
1 parent ba09df7 commit e6add72

2 files changed

Lines changed: 62 additions & 8 deletions

File tree

packages/rstack/src/setup/hooks.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,22 @@ const hookNames = [
1717
'pre-auto-gc',
1818
];
1919

20+
// Git for Windows runs hooks in a POSIX shell, where drive-letter paths need
21+
// their Git Bash form to avoid treating the drive colon as a PATH separator.
22+
const quoteShellPath = (value: string): string => {
23+
const shellPath =
24+
process.platform === 'win32'
25+
? value
26+
.replaceAll('\\', '/')
27+
.replace(/^([A-Za-z]):\//u, (_, drive: string) => `/${drive.toLowerCase()}/`)
28+
: value;
29+
30+
return `'${shellPath.replaceAll("'", `'"'"'`)}'`;
31+
};
32+
2033
// Generated shims live in `<hooks-directory>/_`. When a shim sources this
2134
// dispatcher, `$0` still points to the shim, so the user hook is one level up.
22-
const dispatcher = `#!/usr/bin/env sh
35+
const createDispatcher = (nodeExecutable: string): string => `#!/usr/bin/env sh
2336
2437
name=$(basename "$0")
2538
dir=$(dirname "$(dirname "$0")")
@@ -33,7 +46,14 @@ init="\${XDG_CONFIG_HOME:-$HOME/.config}/rstack/hooks-init.sh"
3346
[ "\${RSTACK_HOOKS-}" = "0" ] && exit 0
3447
[ "\${RSTACK_HOOKS-}" = "2" ] && set -x
3548
36-
export PATH="node_modules/.bin:$PATH"
49+
# Fall back to the Node.js executable that ran rs setup when GUI clients omit
50+
# it from PATH. Keep an existing Node.js environment ahead of this fallback.
51+
node_fallback=${quoteShellPath(nodeExecutable)}
52+
if ! command -v node >/dev/null 2>&1 && [ -x "$node_fallback" ]; then
53+
PATH="\${PATH:+$PATH:}\${node_fallback%/*}"
54+
fi
55+
56+
export PATH="node_modules/.bin\${PATH:+:$PATH}"
3757
3858
code=0
3959
sh -e "$hook" "$@" || code=$?
@@ -49,8 +69,10 @@ const shim = `#!/usr/bin/env sh
4969
. "$(dirname "$0")/runner"
5070
`;
5171

52-
export const createHookFiles = (): Record<string, string> => {
53-
const files: Record<string, string> = { runner: dispatcher };
72+
export const createHookFiles = (
73+
nodeExecutable: string = process.execPath,
74+
): Record<string, string> => {
75+
const files: Record<string, string> = { runner: createDispatcher(nodeExecutable) };
5476

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

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

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { spawnSync } from 'node:child_process';
2-
import { mkdirSync, writeFileSync } from 'node:fs';
2+
import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs';
33
import path from 'node:path';
44
import { expect, test } from 'rstack/test';
55
import { createHookFiles } from '../../src/setup/hooks.ts';
@@ -28,16 +28,33 @@ test('generates the dispatcher and all client-side Git hook shims', () => {
2828
expect(new Set(Object.values(shims)).size).toBe(1);
2929
});
3030

31+
test.runIf(process.platform === 'win32')('converts Windows Node paths', () => {
32+
const { runner } = createHookFiles(String.raw`C:\Program Files\nodejs\node.exe`);
33+
34+
expect(runner).toContain("node_fallback='/c/Program Files/nodejs/node.exe'");
35+
});
36+
37+
test.runIf(process.platform !== 'win32')('preserves backslashes in POSIX Node paths', () => {
38+
const nodeExecutable = String.raw`/opt/node\24/bin/node`;
39+
const { runner } = createHookFiles(nodeExecutable);
40+
41+
expect(runner).toContain(`node_fallback='${nodeExecutable}'`);
42+
});
43+
3144
test.runIf(process.platform !== 'win32')('runs generated hooks', () => {
3245
withDirectory((directory) => {
33-
const hooksDirectory = path.join(directory, 'hooks with spaces');
46+
const hooksDirectory = path.join(directory, "hooks with ' quotes");
3447
const generatedDirectory = path.join(hooksDirectory, '_');
3548
const generatedHook = path.join(generatedDirectory, 'pre-commit');
3649
const userHook = path.join(hooksDirectory, 'pre-commit');
37-
const files = createHookFiles();
50+
const fallbackNode = path.join(hooksDirectory, 'node');
51+
const configDirectory = path.join(directory, 'runtime config');
52+
const runtimeDirectory = path.join(configDirectory, 'rstack');
53+
const init = path.join(runtimeDirectory, 'hooks-init.sh');
54+
const files = createHookFiles(fallbackNode);
3855
const env: NodeJS.ProcessEnv = {
3956
...process.env,
40-
XDG_CONFIG_HOME: path.join(directory, 'config'),
57+
XDG_CONFIG_HOME: configDirectory,
4158
};
4259

4360
mkdirSync(generatedDirectory, { recursive: true });
@@ -71,5 +88,20 @@ printf 'unreachable\\n'
7188

7289
expect(errexitResult.status).toBe(1);
7390
expect(errexitResult.stdout).toBe('Rstack - pre-commit hook failed (code 1)\n');
91+
92+
mkdirSync(runtimeDirectory, { recursive: true });
93+
writeFileSync(init, `export PATH="${runtimeDirectory}"\n`);
94+
writeFileSync(userHook, 'command -v node\n');
95+
symlinkSync('/bin/sh', path.join(runtimeDirectory, 'sh'));
96+
symlinkSync('/bin/sh', fallbackNode);
97+
98+
const fallbackResult = spawnSync('sh', [generatedHook], { encoding: 'utf8', env });
99+
expect(fallbackResult.stdout).toBe(`${fallbackNode}\n`);
100+
101+
const activeNode = path.join(runtimeDirectory, 'node');
102+
symlinkSync('/bin/sh', activeNode);
103+
104+
const activeResult = spawnSync('sh', [generatedHook], { encoding: 'utf8', env });
105+
expect(activeResult.stdout).toBe(`${activeNode}\n`);
74106
});
75107
});

0 commit comments

Comments
 (0)