-
Notifications
You must be signed in to change notification settings - Fork 0
feat(setup): install Git hook shims #82
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+288
−12
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| import { spawnSync } from 'node:child_process'; | ||
| import { chmodSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'; | ||
| import path from 'node:path'; | ||
| import { createHookFiles } from './hooks.js'; | ||
|
|
||
| const hooksPath = '.rstack/hooks/_'; | ||
| const gitignore = '*\n'; | ||
|
|
||
| type InstallResult = | ||
| | { status: 'installed'; hooksPath: string } | ||
| | { status: 'unchanged'; hooksPath: string } | ||
| | { status: 'skipped'; reason: 'not-git-repository' } | ||
| | { status: 'failed'; reason: string; message: string }; | ||
|
|
||
| const fail = (reason: string, message: string): InstallResult => ({ | ||
| status: 'failed', | ||
| reason, | ||
| message, | ||
| }); | ||
|
|
||
| const runGit = (cwd: string, args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8' }); | ||
|
|
||
| const gitFailure = (error: NodeJS.ErrnoException | undefined, stderr: string): InstallResult => { | ||
| if (error?.code === 'ENOENT') { | ||
| return fail('git-not-found', 'Git command not found.'); | ||
| } | ||
|
|
||
| return fail('git-command-failed', `Failed to run Git: ${error?.message || stderr.trim()}`); | ||
| }; | ||
|
|
||
| const isCurrentFile = (filePath: string, content: string, executable = false): boolean => { | ||
| try { | ||
| // Windows does not expose POSIX executable bits, but Git for Windows still runs hook shims. | ||
| return ( | ||
| readFileSync(filePath, 'utf8') === content && | ||
| (!executable || process.platform === 'win32' || (statSync(filePath).mode & 0o777) === 0o755) | ||
| ); | ||
| } catch { | ||
| return false; | ||
| } | ||
| }; | ||
|
|
||
| export const installHooks = (cwd: string = process.cwd()): InstallResult => { | ||
| // Check Git before touching the filesystem so non-repositories have no side effects. | ||
| const repository = runGit(cwd, ['rev-parse', '--is-inside-work-tree']); | ||
| if (repository.error || repository.status === null) { | ||
| return gitFailure(repository.error, repository.stderr); | ||
| } | ||
| if (repository.status !== 0 || repository.stdout.trim() !== 'true') { | ||
| return { status: 'skipped', reason: 'not-git-repository' }; | ||
| } | ||
|
|
||
| const config = runGit(cwd, ['config', '--local', '--get', 'core.hooksPath']); | ||
| if (config.error || config.status === null) { | ||
| return gitFailure(config.error, config.stderr); | ||
| } | ||
| if (config.status !== 0 && config.status !== 1) { | ||
| return fail('git-config-failed', `Failed to read core.hooksPath: ${config.stderr.trim()}`); | ||
| } | ||
|
|
||
| const directory = path.join(cwd, hooksPath); | ||
|
chenjiahan marked this conversation as resolved.
|
||
| const files = Object.entries(createHookFiles()); | ||
| // Skip all writes only when the config, generated content, and executable modes match. | ||
| const unchanged = | ||
| config.stdout.trim() === hooksPath && | ||
| isCurrentFile(path.join(directory, '.gitignore'), gitignore) && | ||
| files.every(([name, content]) => isCurrentFile(path.join(directory, name), content, true)); | ||
|
|
||
| if (unchanged) { | ||
| return { status: 'unchanged', hooksPath }; | ||
| } | ||
|
|
||
| try { | ||
| mkdirSync(directory, { recursive: true }); | ||
| writeFileSync(path.join(directory, '.gitignore'), gitignore); | ||
|
|
||
| for (const [name, content] of files) { | ||
| const filePath = path.join(directory, name); | ||
| writeFileSync(filePath, content); | ||
| // chmod also repairs existing files because writeFile does not update their mode. | ||
| chmodSync(filePath, 0o755); | ||
| } | ||
| } catch (error) { | ||
| const message = error instanceof Error ? error.message : String(error); | ||
| return fail('write-failed', `Failed to write Git hook files: ${message}`); | ||
| } | ||
|
|
||
| // Point Git at the generated directory only after every runtime file is ready. | ||
| const configured = runGit(cwd, ['config', '--local', 'core.hooksPath', hooksPath]); | ||
| if (configured.error || configured.status === null) { | ||
| return gitFailure(configured.error, configured.stderr); | ||
| } | ||
| if (configured.status !== 0) { | ||
| return fail( | ||
| 'git-config-failed', | ||
| `Failed to configure core.hooksPath: ${configured.stderr.trim()}`, | ||
| ); | ||
| } | ||
|
|
||
| return { status: 'installed', hooksPath }; | ||
| }; | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| import { spawnSync } from 'node:child_process'; | ||
| import { | ||
| chmodSync, | ||
| existsSync, | ||
| mkdirSync, | ||
| mkdtempSync, | ||
| readFileSync, | ||
| rmSync, | ||
| statSync, | ||
| writeFileSync, | ||
| } from 'node:fs'; | ||
| import { tmpdir } from 'node:os'; | ||
| import path from 'node:path'; | ||
| import { expect, test } from 'rstack/test'; | ||
| import { createHookFiles } from '../../src/setup/hooks.ts'; | ||
| import { installHooks } from '../../src/setup/install.ts'; | ||
|
|
||
| const hooksPath = '.rstack/hooks/_'; | ||
|
|
||
| const git = (cwd: string, args: string[]) => spawnSync('git', args, { cwd, encoding: 'utf8' }); | ||
|
|
||
| const runGit = (cwd: string, args: string[]): string => { | ||
| const result = git(cwd, args); | ||
| if (result.status !== 0) { | ||
| throw new Error(result.stderr || `Git exited with status ${result.status}`); | ||
| } | ||
| return result.stdout.trim(); | ||
| }; | ||
|
|
||
| const withDirectory = (callback: (cwd: string) => void): void => { | ||
| const cwd = mkdtempSync(path.join(tmpdir(), 'rstack hooks ')); | ||
| try { | ||
| callback(cwd); | ||
| } finally { | ||
| rmSync(cwd, { force: true, recursive: true }); | ||
| } | ||
| }; | ||
|
|
||
| const restoreEnv = (name: string, value: string | undefined): void => { | ||
| if (value === undefined) { | ||
| delete process.env[name]; | ||
| } else { | ||
| process.env[name] = value; | ||
| } | ||
| }; | ||
|
|
||
| const withRepository = (callback: (cwd: string) => void): void => | ||
| withDirectory((cwd) => { | ||
| const globalConfig = process.env.GIT_CONFIG_GLOBAL; | ||
| const noSystemConfig = process.env.GIT_CONFIG_NOSYSTEM; | ||
| process.env.GIT_CONFIG_GLOBAL = path.join(cwd, 'global.gitconfig'); | ||
| process.env.GIT_CONFIG_NOSYSTEM = '1'; | ||
|
|
||
| try { | ||
| runGit(cwd, ['init', '--quiet']); | ||
| runGit(cwd, ['config', '--local', 'user.name', 'Rstack Test']); | ||
| runGit(cwd, ['config', '--local', 'user.email', 'test@rstack.dev']); | ||
| callback(cwd); | ||
| } finally { | ||
| restoreEnv('GIT_CONFIG_GLOBAL', globalConfig); | ||
| restoreEnv('GIT_CONFIG_NOSYSTEM', noSystemConfig); | ||
| } | ||
| }); | ||
|
|
||
| test('installs generated hooks and configures the repository', () => { | ||
| withRepository((cwd) => { | ||
| expect(installHooks(cwd)).toEqual({ status: 'installed', hooksPath }); | ||
| expect(runGit(cwd, ['config', '--local', '--get', 'core.hooksPath'])).toBe(hooksPath); | ||
|
|
||
| const directory = path.join(cwd, hooksPath); | ||
| expect(readFileSync(path.join(directory, '.gitignore'), 'utf8')).toBe('*\n'); | ||
| expect(runGit(cwd, ['status', '--short', '--untracked-files=all'])).toBe(''); | ||
|
|
||
| for (const [name, content] of Object.entries(createHookFiles())) { | ||
| const filePath = path.join(directory, name); | ||
| expect(readFileSync(filePath, 'utf8')).toBe(content); | ||
| if (process.platform !== 'win32') { | ||
| expect(statSync(filePath).mode & 0o777).toBe(0o755); | ||
| } | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| test('is idempotent and preserves user hooks', () => { | ||
| withRepository((cwd) => { | ||
| const userDirectory = path.join(cwd, '.rstack', 'hooks'); | ||
| const userHook = path.join(userDirectory, 'pre-commit'); | ||
| mkdirSync(userDirectory, { recursive: true }); | ||
| writeFileSync(userHook, 'echo user hook\n'); | ||
|
|
||
| expect(installHooks(cwd).status).toBe('installed'); | ||
| expect(installHooks(cwd)).toEqual({ status: 'unchanged', hooksPath }); | ||
|
|
||
| expect(readFileSync(userHook, 'utf8')).toBe('echo user hook\n'); | ||
| expect(existsSync(path.join(userDirectory, 'commit-msg'))).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| test.runIf(process.platform !== 'win32')('restores executable mode on existing shims', () => { | ||
| withRepository((cwd) => { | ||
| expect(installHooks(cwd).status).toBe('installed'); | ||
| const shim = path.join(cwd, hooksPath, 'pre-commit'); | ||
| chmodSync(shim, 0o644); | ||
|
|
||
| expect(installHooks(cwd).status).toBe('installed'); | ||
| expect(statSync(shim).mode & 0o777).toBe(0o755); | ||
| }); | ||
| }); | ||
|
|
||
| test('skips non-Git directories without creating files', () => { | ||
| withDirectory((cwd) => { | ||
| expect(installHooks(cwd)).toEqual({ | ||
| status: 'skipped', | ||
| reason: 'not-git-repository', | ||
| }); | ||
| expect(existsSync(path.join(cwd, '.rstack'))).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| test('reports when Git is unavailable', () => { | ||
| withDirectory((cwd) => { | ||
| const originalPath = process.env.PATH; | ||
| process.env.PATH = ''; | ||
| try { | ||
| expect(installHooks(cwd)).toEqual({ | ||
| status: 'failed', | ||
| reason: 'git-not-found', | ||
| message: 'Git command not found.', | ||
| }); | ||
| } finally { | ||
| restoreEnv('PATH', originalPath); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| test('does not configure Git when writing generated files fails', () => { | ||
| withRepository((cwd) => { | ||
| writeFileSync(path.join(cwd, '.rstack'), 'not a directory'); | ||
|
|
||
| expect(installHooks(cwd)).toMatchObject({ | ||
| status: 'failed', | ||
| reason: 'write-failed', | ||
| }); | ||
| expect(git(cwd, ['config', '--local', '--get', 'core.hooksPath']).status).toBe(1); | ||
| }); | ||
| }); | ||
|
|
||
| test('reports Git configuration failures without changing hooksPath', () => { | ||
| withRepository((cwd) => { | ||
| writeFileSync(path.join(cwd, '.git', 'config.lock'), 'locked'); | ||
|
|
||
| expect(installHooks(cwd)).toMatchObject({ | ||
| status: 'failed', | ||
| reason: 'git-config-failed', | ||
| }); | ||
| expect(git(cwd, ['config', '--local', '--get', 'core.hooksPath']).status).toBe(1); | ||
| expect(existsSync(path.join(cwd, hooksPath, 'runner'))).toBe(true); | ||
| }); | ||
| }); | ||
|
|
||
| test('blocks commits when a user hook fails', () => { | ||
| withRepository((cwd) => { | ||
| const userDirectory = path.join(cwd, '.rstack', 'hooks'); | ||
| mkdirSync(userDirectory, { recursive: true }); | ||
| writeFileSync( | ||
| path.join(userDirectory, 'pre-commit'), | ||
| `printf 'ran\\n' > hook-ran | ||
| exit 1 | ||
| `, | ||
| ); | ||
|
|
||
| expect(installHooks(cwd).status).toBe('installed'); | ||
| writeFileSync(path.join(cwd, 'file.txt'), 'content\n'); | ||
| runGit(cwd, ['add', 'file.txt']); | ||
|
|
||
| expect(git(cwd, ['commit', '--quiet', '-m', 'test']).status).not.toBe(0); | ||
| expect(readFileSync(path.join(cwd, 'hook-ran'), 'utf8')).toBe('ran\n'); | ||
| expect(git(cwd, ['rev-parse', '--verify', 'HEAD']).status).not.toBe(0); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ applypatch | |
| errexit | ||
| fnames | ||
| llms | ||
| nosystem | ||
| oxfmt | ||
| rsbuild | ||
| rslib | ||
|
|
||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.