From d97ae53342b3ed2e92e760dca777099b97fa3b44 Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 10 Aug 2026 16:26:16 +0800 Subject: [PATCH 1/2] fix(setup): preserve hook file paths in monorepos --- packages/rstack/src/setup/hooks.ts | 15 ++++++++++++++ packages/rstack/tests/setup/helpers.ts | 3 +++ packages/rstack/tests/setup/runtime.test.ts | 22 ++++++++++++++++++++- scripts/dictionary.txt | 2 ++ 4 files changed, 41 insertions(+), 1 deletion(-) diff --git a/packages/rstack/src/setup/hooks.ts b/packages/rstack/src/setup/hooks.ts index ccc2253..2873234 100644 --- a/packages/rstack/src/setup/hooks.ts +++ b/packages/rstack/src/setup/hooks.ts @@ -54,6 +54,21 @@ if ! command -v node >/dev/null 2>&1 && [ -x "$rs_node_fallback" ]; then PATH="\${PATH:+$PATH:}\${rs_node_fallback%/*}" fi +# Keep message file paths valid after changing to the owning project. +case "$rs_name" in + *-msg) + [ -n "\${1-}" ] || exit 1 + case "$1" in + /*|[A-Za-z]:/*) ;; + *) + rs_file=$1 + shift + set -- "$rs_root/$rs_file" "$@" + ;; + esac + ;; +esac + cd "$rs_root/$rs_project_path" || exit 1 export PATH="node_modules/.bin\${PATH:+:$PATH}" diff --git a/packages/rstack/tests/setup/helpers.ts b/packages/rstack/tests/setup/helpers.ts index 533cb08..206e54c 100644 --- a/packages/rstack/tests/setup/helpers.ts +++ b/packages/rstack/tests/setup/helpers.ts @@ -70,6 +70,9 @@ export const writeInit = (cwd: string, content: string): void => { export const runHook = (cwd: string, value?: string): SpawnSyncReturns => git(cwd, ['hook', 'run', 'pre-commit'], hookEnv(cwd, value)); +export const runGitHook = (cwd: string, name: string, args: string[]): SpawnSyncReturns => + git(cwd, ['hook', 'run', name, '--', ...args], hookEnv(cwd)); + export const withRepository = (callback: (cwd: string) => void): void => withDirectory((cwd) => { const globalConfig = process.env.GIT_CONFIG_GLOBAL; diff --git a/packages/rstack/tests/setup/runtime.test.ts b/packages/rstack/tests/setup/runtime.test.ts index dcbac5b..d06260c 100644 --- a/packages/rstack/tests/setup/runtime.test.ts +++ b/packages/rstack/tests/setup/runtime.test.ts @@ -2,7 +2,7 @@ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'n import path from 'node:path'; import { expect, test } from 'rstack/test'; import { installHooks } from '../../src/setup/install.ts'; -import { runHook, withRepository, writeHook, writeInit } from './helpers.ts'; +import { runGitHook, runHook, withRepository, writeHook, writeInit } from './helpers.ts'; test('loads user init and project binaries', () => { withRepository((cwd) => { @@ -35,6 +35,26 @@ rstack-hook-command }); }); +test('preserves file arguments for hooks owned by a nested project', () => { + withRepository((cwd) => { + const projectDirectory = path.join(cwd, 'frontend'); + const messagePath = '.git/COMMIT_EDITMSG'; + mkdirSync(projectDirectory); + writeFileSync(path.join(cwd, messagePath), 'commit message\n'); + + expect(installHooks({ cwd: projectDirectory }).status).toBe('installed'); + + for (const name of ['applypatch-msg', 'commit-msg', 'prepare-commit-msg']) { + writeFileSync(path.join(cwd, '.rstack', 'hooks', name), 'cat "$1"\n'); + + expect(runGitHook(cwd, name, [messagePath])).toMatchObject({ + status: 0, + stderr: 'commit message\n', + }); + } + }); +}); + test('skips user hooks when disabled by the environment or init', () => { withRepository((cwd) => { writeHook(cwd, 'echo ran >> hook-ran\n'); diff --git a/scripts/dictionary.txt b/scripts/dictionary.txt index 61e10e6..c7f2c72 100644 --- a/scripts/dictionary.txt +++ b/scripts/dictionary.txt @@ -3,7 +3,9 @@ applypatch cdpath clippy dirents +editmsg errexit +esac extglob fnames huskyrc From 35229142d1786030dd3ebc0a9c9411c17fce6352 Mon Sep 17 00:00:00 2001 From: neverland Date: Mon, 10 Aug 2026 17:53:12 +0800 Subject: [PATCH 2/2] fix(setup): preserve cwd-sensitive hook arguments --- packages/rstack/src/setup/hooks.ts | 79 +++++++++++++-------- packages/rstack/tests/setup/hooks.test.ts | 3 +- packages/rstack/tests/setup/runtime.test.ts | 15 +++- 3 files changed, 66 insertions(+), 31 deletions(-) diff --git a/packages/rstack/src/setup/hooks.ts b/packages/rstack/src/setup/hooks.ts index 2873234..676da73 100644 --- a/packages/rstack/src/setup/hooks.ts +++ b/packages/rstack/src/setup/hooks.ts @@ -30,7 +30,7 @@ const quoteShellPath = (value: string): string => { return `'${shellPath.replaceAll("'", `'"'"'`)}'`; }; -const createDispatcher = (nodeExecutable: string): string => `#!/usr/bin/env sh +const createRunner = (nodeExecutable: string): string => `#!/usr/bin/env sh # Generated by Rstack. Do not edit. rs_name=\${0##*/} @@ -54,46 +54,69 @@ if ! command -v node >/dev/null 2>&1 && [ -x "$rs_node_fallback" ]; then PATH="\${PATH:+$PATH:}\${rs_node_fallback%/*}" fi -# Keep message file paths valid after changing to the owning project. -case "$rs_name" in - *-msg) - [ -n "\${1-}" ] || exit 1 - case "$1" in - /*|[A-Za-z]:/*) ;; - *) - rs_file=$1 - shift - set -- "$rs_root/$rs_file" "$@" - ;; - esac - ;; -esac +rs_run() { + cd "$rs_root/$rs_project_path" || return 1 + export PATH="node_modules/.bin\${PATH:+:$PATH}" -cd "$rs_root/$rs_project_path" || exit 1 -export PATH="node_modules/.bin\${PATH:+:$PATH}" + rs_code=0 + sh -e "$rs_hook" "$@" || rs_code=$? -rs_code=0 -sh -e "$rs_hook" "$@" || rs_code=$? - -[ "$rs_code" = "0" ] || echo "Rstack - $rs_name hook failed (code $rs_code)" -[ "$rs_code" = "127" ] && echo "Rstack - command not found in PATH=$PATH" -exit "$rs_code" + [ "$rs_code" = "0" ] || echo "Rstack - $rs_name hook failed (code $rs_code)" + [ "$rs_code" = "127" ] && echo "Rstack - command not found in PATH=$PATH" + return "$rs_code" +} `; -// 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 +const createShim = (prepareArguments = ''): string => `#!/usr/bin/env sh rs_dir=$(CDPATH= cd "$(dirname "$0")" && pwd) || exit 1 . "$rs_dir/runner" + +${prepareArguments} +rs_run "$@" `; export const createHookFiles = ( nodeExecutable: string = process.execPath, ): Record => { - const files: Record = { runner: createDispatcher(nodeExecutable) }; + const messageShim = createShim(`# Keep the message file valid after changing directories. +[ -n "\${1-}" ] || exit 1 +case "$1" in + /*|[A-Za-z]:/*) ;; + *) + rs_file=$1 + shift + set -- "$rs_root/$rs_file" "$@" + ;; +esac +`); + + const prePushShim = createShim(`# Keep a local remote path valid after changing directories. +rs_remote_name=\${1-} +rs_remote_location=\${2-} +[ -n "$rs_remote_name" ] && [ -n "$rs_remote_location" ] || exit 1 +case "$rs_remote_location" in + /*|[A-Za-z]:/*) ;; + *:*) + # Git treats a colon before any slash as a URL or SCP-style remote. + case "\${rs_remote_location%%:*}" in + */*) rs_remote_location="$rs_root/$rs_remote_location" ;; + esac + ;; + *) rs_remote_location="$rs_root/$rs_remote_location" ;; +esac +shift 2 +set -- "$rs_remote_name" "$rs_remote_location" "$@" +`); + + const defaultShim = createShim(); + const files: Record = { runner: createRunner(nodeExecutable) }; for (const name of hookNames) { - files[name] = shim; + files[name] = name.endsWith('-msg') + ? messageShim + : name === 'pre-push' + ? prePushShim + : defaultShim; } return files; diff --git a/packages/rstack/tests/setup/hooks.test.ts b/packages/rstack/tests/setup/hooks.test.ts index 14cb8eb..5f2f8f0 100644 --- a/packages/rstack/tests/setup/hooks.test.ts +++ b/packages/rstack/tests/setup/hooks.test.ts @@ -5,7 +5,7 @@ import { expect, test } from 'rstack/test'; import { createHookFiles } from '../../src/setup/hooks.ts'; import { withDirectory } from './helpers.ts'; -test('generates the dispatcher and all client-side Git hook shims', () => { +test('generates the runner and all client-side Git hook shims', () => { const { runner, ...shims } = createHookFiles(); expect(Object.keys(shims)).toEqual([ @@ -25,7 +25,6 @@ test('generates the dispatcher and all client-side Git hook shims', () => { 'pre-auto-gc', ]); expect(runner).toBeTruthy(); - expect(new Set(Object.values(shims)).size).toBe(1); }); test.runIf(process.platform === 'win32')('converts Windows Node paths', () => { diff --git a/packages/rstack/tests/setup/runtime.test.ts b/packages/rstack/tests/setup/runtime.test.ts index d06260c..455f1a8 100644 --- a/packages/rstack/tests/setup/runtime.test.ts +++ b/packages/rstack/tests/setup/runtime.test.ts @@ -35,7 +35,7 @@ rstack-hook-command }); }); -test('preserves file arguments for hooks owned by a nested project', () => { +test('preserves cwd-sensitive hook arguments for a nested project', () => { withRepository((cwd) => { const projectDirectory = path.join(cwd, 'frontend'); const messagePath = '.git/COMMIT_EDITMSG'; @@ -52,6 +52,16 @@ test('preserves file arguments for hooks owned by a nested project', () => { stderr: 'commit message\n', }); } + + mkdirSync(path.join(cwd, 'remote.git')); + writeFileSync( + path.join(cwd, '.rstack', 'hooks', 'pre-push'), + '[ -d "$2" ] || [ "$2" = "git@example.com:repo.git" ]\n', + ); + + for (const remote of ['remote.git', 'git@example.com:repo.git']) { + expect(runGitHook(cwd, 'pre-push', ['origin', remote]).status).toBe(0); + } }); }); @@ -67,5 +77,8 @@ test('skips user hooks when disabled by the environment or init', () => { expect(runHook(cwd).status).toBe(0); expect(existsSync(path.join(cwd, 'hook-ran'))).toBe(false); + + writeFileSync(path.join(cwd, '.rstack', 'hooks', 'commit-msg'), 'exit 1\n'); + expect(runGitHook(cwd, 'commit-msg', []).status).toBe(0); }); });