diff --git a/packages/rstack/src/setup/hooks.ts b/packages/rstack/src/setup/hooks.ts index 4dc68da..518d478 100644 --- a/packages/rstack/src/setup/hooks.ts +++ b/packages/rstack/src/setup/hooks.ts @@ -17,9 +17,22 @@ const hookNames = [ 'pre-auto-gc', ]; +// Git for Windows runs hooks in a POSIX shell, where drive-letter paths need +// their Git Bash form to avoid treating the drive colon as a PATH separator. +const quoteShellPath = (value: string): string => { + const shellPath = + process.platform === 'win32' + ? value + .replaceAll('\\', '/') + .replace(/^([A-Za-z]):\//u, (_, drive: string) => `/${drive.toLowerCase()}/`) + : value; + + return `'${shellPath.replaceAll("'", `'"'"'`)}'`; +}; + // Generated shims live in `/_`. 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 +const createDispatcher = (nodeExecutable: string): string => `#!/usr/bin/env sh name=$(basename "$0") dir=$(dirname "$(dirname "$0")") @@ -33,7 +46,14 @@ init="\${XDG_CONFIG_HOME:-$HOME/.config}/rstack/hooks-init.sh" [ "\${RSTACK_HOOKS-}" = "0" ] && exit 0 [ "\${RSTACK_HOOKS-}" = "2" ] && set -x -export PATH="node_modules/.bin:$PATH" +# Fall back to the Node.js executable that ran rs setup when GUI clients omit +# it from PATH. Keep an existing Node.js environment ahead of this fallback. +node_fallback=${quoteShellPath(nodeExecutable)} +if ! command -v node >/dev/null 2>&1 && [ -x "$node_fallback" ]; then + PATH="\${PATH:+$PATH:}\${node_fallback%/*}" +fi + +export PATH="node_modules/.bin\${PATH:+:$PATH}" code=0 sh -e "$hook" "$@" || code=$? @@ -49,8 +69,10 @@ const shim = `#!/usr/bin/env sh . "$(dirname "$0")/runner" `; -export const createHookFiles = (): Record => { - const files: Record = { runner: dispatcher }; +export const createHookFiles = ( + nodeExecutable: string = process.execPath, +): Record => { + const files: Record = { runner: createDispatcher(nodeExecutable) }; for (const name of hookNames) { files[name] = shim; diff --git a/packages/rstack/tests/setup/hooks.test.ts b/packages/rstack/tests/setup/hooks.test.ts index 9c81021..882e1b6 100644 --- a/packages/rstack/tests/setup/hooks.test.ts +++ b/packages/rstack/tests/setup/hooks.test.ts @@ -1,5 +1,5 @@ import { spawnSync } from 'node:child_process'; -import { mkdirSync, writeFileSync } from 'node:fs'; +import { mkdirSync, symlinkSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { expect, test } from 'rstack/test'; import { createHookFiles } from '../../src/setup/hooks.ts'; @@ -28,16 +28,33 @@ test('generates the dispatcher and all client-side Git hook shims', () => { expect(new Set(Object.values(shims)).size).toBe(1); }); +test.runIf(process.platform === 'win32')('converts Windows Node paths', () => { + const { runner } = createHookFiles(String.raw`C:\Program Files\nodejs\node.exe`); + + expect(runner).toContain("node_fallback='/c/Program Files/nodejs/node.exe'"); +}); + +test.runIf(process.platform !== 'win32')('preserves backslashes in POSIX Node paths', () => { + const nodeExecutable = String.raw`/opt/node\24/bin/node`; + const { runner } = createHookFiles(nodeExecutable); + + expect(runner).toContain(`node_fallback='${nodeExecutable}'`); +}); + test.runIf(process.platform !== 'win32')('runs generated hooks', () => { withDirectory((directory) => { - const hooksDirectory = path.join(directory, 'hooks with spaces'); + const hooksDirectory = path.join(directory, "hooks with ' quotes"); const generatedDirectory = path.join(hooksDirectory, '_'); const generatedHook = path.join(generatedDirectory, 'pre-commit'); const userHook = path.join(hooksDirectory, 'pre-commit'); - const files = createHookFiles(); + const fallbackNode = path.join(hooksDirectory, 'node'); + const configDirectory = path.join(directory, 'runtime config'); + const runtimeDirectory = path.join(configDirectory, 'rstack'); + const init = path.join(runtimeDirectory, 'hooks-init.sh'); + const files = createHookFiles(fallbackNode); const env: NodeJS.ProcessEnv = { ...process.env, - XDG_CONFIG_HOME: path.join(directory, 'config'), + XDG_CONFIG_HOME: configDirectory, }; mkdirSync(generatedDirectory, { recursive: true }); @@ -71,5 +88,20 @@ printf 'unreachable\\n' expect(errexitResult.status).toBe(1); expect(errexitResult.stdout).toBe('Rstack - pre-commit hook failed (code 1)\n'); + + mkdirSync(runtimeDirectory, { recursive: true }); + writeFileSync(init, `export PATH="${runtimeDirectory}"\n`); + writeFileSync(userHook, 'command -v node\n'); + symlinkSync('/bin/sh', path.join(runtimeDirectory, 'sh')); + symlinkSync('/bin/sh', fallbackNode); + + const fallbackResult = spawnSync('sh', [generatedHook], { encoding: 'utf8', env }); + expect(fallbackResult.stdout).toBe(`${fallbackNode}\n`); + + const activeNode = path.join(runtimeDirectory, 'node'); + symlinkSync('/bin/sh', activeNode); + + const activeResult = spawnSync('sh', [generatedHook], { encoding: 'utf8', env }); + expect(activeResult.stdout).toBe(`${activeNode}\n`); }); });