install-lefthook.spec.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305
  1. import { spawn, spawnSync } from 'node:child_process'
  2. import {
  3. chmodSync,
  4. existsSync,
  5. mkdirSync,
  6. mkdtempSync,
  7. readFileSync,
  8. rmSync,
  9. writeFileSync,
  10. } from 'node:fs'
  11. import { tmpdir } from 'node:os'
  12. import { dirname, isAbsolute, join, resolve } from 'node:path'
  13. import { fileURLToPath } from 'node:url'
  14. import { afterEach, describe, expect, it } from 'vitest'
  15. const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url))
  16. const fixtures: string[] = []
  17. interface Fixture {
  18. container: string
  19. env: NodeJS.ProcessEnv
  20. linked: string
  21. main: string
  22. }
  23. interface CommandResult {
  24. status: number | null
  25. stderr: string
  26. stdout: string
  27. }
  28. afterEach(() => {
  29. for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true })
  30. })
  31. function commandResult(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): CommandResult {
  32. const result = spawnSync(command, args, { cwd, encoding: 'utf8', env })
  33. return { status: result.status, stderr: result.stderr, stdout: result.stdout }
  34. }
  35. function gitResult(fixture: Fixture, cwd: string, args: string[]): CommandResult {
  36. return commandResult('git', args, cwd, fixture.env)
  37. }
  38. function git(fixture: Fixture, cwd: string, args: string[]): string {
  39. const result = gitResult(fixture, cwd, args)
  40. if (result.status !== 0) {
  41. throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`)
  42. }
  43. return result.stdout.trim()
  44. }
  45. function write(path: string, content: string, mode?: number): void {
  46. mkdirSync(dirname(path), { recursive: true })
  47. writeFileSync(path, content, mode === undefined ? undefined : { mode })
  48. }
  49. function fakeLefthookSource(): string {
  50. return `#!/usr/bin/env node
  51. import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
  52. import { execFileSync } from 'node:child_process'
  53. import { join } from 'node:path'
  54. if (process.argv.slice(2).join(' ') !== 'install --force') process.exit(64)
  55. const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()
  56. const hooksPath = execFileSync('git', ['config', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim()
  57. mkdirSync(hooksPath, { recursive: true })
  58. const running = join(hooksPath, '.fake-lefthook-running')
  59. try {
  60. writeFileSync(running, String(process.pid), { flag: 'wx' })
  61. } catch {
  62. process.exit(91)
  63. }
  64. const delay = Number(process.env.DSH_TEST_LEFTHOOK_DELAY_MS ?? 0)
  65. if (delay > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay)
  66. const shouldFail = process.env.DSH_TEST_LEFTHOOK_FAIL === '1'
  67. if (!shouldFail) {
  68. const binary = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'lefthook.cmd' : 'lefthook')
  69. const config = readFileSync(join(root, 'lefthook.yml'), 'utf8').trim()
  70. const hook = \`#!/bin/sh\\n# root=\${root}\\n# binary=\${binary}\\n# config=\${config}\\nexit 0\\n\`
  71. for (const name of ['pre-commit', 'pre-push']) writeFileSync(join(hooksPath, name), hook, { mode: 0o755 })
  72. }
  73. if (existsSync(running)) unlinkSync(running)
  74. if (shouldFail) process.exit(77)
  75. `
  76. }
  77. function installFakeLefthook(root: string): void {
  78. const binDirectory = join(root, 'node_modules/.bin')
  79. mkdirSync(binDirectory, { recursive: true })
  80. writeFileSync(join(binDirectory, 'fake-lefthook.mjs'), fakeLefthookSource())
  81. if (process.platform === 'win32') {
  82. writeFileSync(
  83. join(binDirectory, 'lefthook.cmd'),
  84. `@echo off\r\n"${process.execPath}" "%~dp0\\fake-lefthook.mjs" %*\r\n`,
  85. )
  86. return
  87. }
  88. const shim = join(binDirectory, 'lefthook')
  89. writeFileSync(shim, `#!/bin/sh\nexec "${process.execPath}" "$(dirname "$0")/fake-lefthook.mjs" "$@"\n`)
  90. chmodSync(shim, 0o755)
  91. }
  92. function createFixture(): Fixture {
  93. const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-'))
  94. fixtures.push(container)
  95. const main = join(container, 'main')
  96. const linked = join(container, 'linked')
  97. const env: NodeJS.ProcessEnv = {
  98. ...process.env,
  99. GIT_AUTHOR_EMAIL: 'hooks@example.test',
  100. GIT_AUTHOR_NAME: 'Hooks Test',
  101. GIT_COMMITTER_EMAIL: 'hooks@example.test',
  102. GIT_COMMITTER_NAME: 'Hooks Test',
  103. GIT_CONFIG_GLOBAL: join(container, 'global.gitconfig'),
  104. GIT_CONFIG_NOSYSTEM: '1',
  105. HOME: container,
  106. XDG_CONFIG_HOME: join(container, '.config'),
  107. }
  108. const fixture = { container, env, linked, main }
  109. mkdirSync(main)
  110. git(fixture, container, ['init', main])
  111. write(join(main, 'README.md'), '# fixture\n')
  112. git(fixture, main, ['add', 'README.md'])
  113. git(fixture, main, ['commit', '-m', 'fixture'])
  114. git(fixture, main, ['worktree', 'add', '-b', 'linked', linked])
  115. write(join(main, 'lefthook.yml'), 'main-worktree-config\n')
  116. write(join(linked, 'lefthook.yml'), 'linked-worktree-config\n')
  117. installFakeLefthook(main)
  118. installFakeLefthook(linked)
  119. return fixture
  120. }
  121. function gitDirectory(fixture: Fixture, root: string): string {
  122. return git(fixture, root, ['rev-parse', '--absolute-git-dir'])
  123. }
  124. function commonDirectory(fixture: Fixture): string {
  125. const output = git(fixture, fixture.main, ['rev-parse', '--git-common-dir'])
  126. return isAbsolute(output) ? output : resolve(fixture.main, output)
  127. }
  128. function hooksPath(fixture: Fixture, root: string): string {
  129. return join(gitDirectory(fixture, root), 'dsh-hooks')
  130. }
  131. function runInstaller(
  132. fixture: Fixture,
  133. root: string,
  134. extraEnv: NodeJS.ProcessEnv = {},
  135. ): Promise<CommandResult> {
  136. return new Promise((resolveResult, reject) => {
  137. const child = spawn(process.execPath, [installer], {
  138. cwd: root,
  139. env: { ...fixture.env, ...extraEnv },
  140. stdio: ['ignore', 'pipe', 'pipe'],
  141. })
  142. let stdout = ''
  143. let stderr = ''
  144. child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
  145. child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
  146. child.on('error', reject)
  147. child.on('close', (status) => { resolveResult({ status, stderr, stdout }) })
  148. })
  149. }
  150. describe('worktree-local Lefthook installer', () => {
  151. it('isolates main and linked worktrees without changing legacy common hooks', async () => {
  152. const fixture = createFixture()
  153. const common = commonDirectory(fixture)
  154. const legacyHook = join(common, 'hooks/pre-commit')
  155. write(legacyHook, '#!/bin/sh\n# legacy hook\n', 0o755)
  156. const mainInstall = await runInstaller(fixture, fixture.main)
  157. const linkedInstall = await runInstaller(fixture, fixture.linked)
  158. expect(mainInstall.status, mainInstall.stderr).toBe(0)
  159. expect(linkedInstall.status, linkedInstall.stderr).toBe(0)
  160. const mainHooks = hooksPath(fixture, fixture.main)
  161. const linkedHooks = hooksPath(fixture, fixture.linked)
  162. expect(mainHooks).not.toBe(linkedHooks)
  163. expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
  164. expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks)
  165. const mainHook = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
  166. const linkedHook = readFileSync(join(linkedHooks, 'pre-commit'), 'utf8')
  167. const canonicalMain = git(fixture, fixture.main, ['rev-parse', '--show-toplevel'])
  168. const canonicalLinked = git(fixture, fixture.linked, ['rev-parse', '--show-toplevel'])
  169. expect(mainHook).toContain(`# root=${canonicalMain}`)
  170. expect(mainHook).toContain('# config=main-worktree-config')
  171. expect(mainHook).not.toContain(canonicalLinked)
  172. expect(linkedHook).toContain(`# root=${canonicalLinked}`)
  173. expect(linkedHook).toContain('# config=linked-worktree-config')
  174. expect(linkedHook).not.toContain(canonicalMain)
  175. expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
  176. const commonConfig = join(common, 'config')
  177. expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.repositoryFormatVersion'])).toBe('1')
  178. expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'extensions.worktreeConfig'])).toBe('true')
  179. expect(gitResult(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.bare']).status).toBe(1)
  180. const mainHookBeforeRemoval = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
  181. git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked])
  182. expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval)
  183. expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
  184. })
  185. it('serializes concurrent installs and keeps repeated output stable', async () => {
  186. const fixture = createFixture()
  187. const delayed = { DSH_TEST_LEFTHOOK_DELAY_MS: '150' }
  188. const first = await Promise.all([
  189. runInstaller(fixture, fixture.main, delayed),
  190. runInstaller(fixture, fixture.linked, delayed),
  191. ])
  192. for (const result of first) expect(result.status, result.stderr).toBe(0)
  193. const mainHookPath = join(hooksPath(fixture, fixture.main), 'pre-push')
  194. const initialHook = readFileSync(mainHookPath, 'utf8')
  195. const repeated = await Promise.all([
  196. runInstaller(fixture, fixture.main, delayed),
  197. runInstaller(fixture, fixture.main, delayed),
  198. ])
  199. for (const result of repeated) expect(result.status, result.stderr).toBe(0)
  200. expect(readFileSync(mainHookPath, 'utf8')).toBe(initialHook)
  201. expect(existsSync(join(commonDirectory(fixture), 'dsh-lefthook-install.lock'))).toBe(false)
  202. expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false)
  203. })
  204. it('preserves user-owned hook paths unless an inherited value is explicitly overridden', async () => {
  205. const fixture = createFixture()
  206. const customHook = join(fixture.main, 'custom-hooks/pre-commit')
  207. write(customHook, '#!/bin/sh\n# custom hook\n', 0o755)
  208. git(fixture, fixture.main, ['config', 'core.hooksPath', 'custom-hooks'])
  209. const refused = await runInstaller(fixture, fixture.main)
  210. expect(refused.status).toBe(1)
  211. expect(refused.stderr).toContain('refusing to replace user-owned core.hooksPath')
  212. expect(refused.stderr).toContain('DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1')
  213. expect(git(fixture, fixture.main, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks')
  214. expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n')
  215. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  216. const optedIn = await runInstaller(fixture, fixture.main, {
  217. DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
  218. })
  219. expect(optedIn.status, optedIn.stderr).toBe(0)
  220. expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.main))
  221. expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks')
  222. expect(gitResult(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1)
  223. expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n')
  224. git(fixture, fixture.linked, ['config', '--worktree', 'core.hooksPath', 'linked-custom-hooks'])
  225. const explicitWorktreePath = await runInstaller(fixture, fixture.linked, {
  226. DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
  227. })
  228. expect(explicitWorktreePath.status).toBe(1)
  229. expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks')
  230. })
  231. it('restores the previous hook lookup when Lefthook installation fails', async () => {
  232. const fixture = createFixture()
  233. const common = commonDirectory(fixture)
  234. const legacyHook = join(common, 'hooks/pre-push')
  235. write(legacyHook, '#!/bin/sh\n# legacy pre-push\n', 0o755)
  236. const result = await runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_FAIL: '1' })
  237. expect(result.status).toBe(1)
  238. expect(result.stderr).toContain('exit status 77')
  239. expect(gitResult(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1)
  240. expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
  241. expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy pre-push\n')
  242. })
  243. it('refuses an unowned directory at the reserved worktree hook path', async () => {
  244. const fixture = createFixture()
  245. const reservedHook = join(hooksPath(fixture, fixture.main), 'pre-commit')
  246. write(reservedHook, '#!/bin/sh\n# user content\n', 0o755)
  247. const result = await runInstaller(fixture, fixture.main)
  248. expect(result.status).toBe(1)
  249. expect(result.stderr).toContain('refusing to overwrite unowned hooks directory')
  250. expect(readFileSync(reservedHook, 'utf8')).toBe('#!/bin/sh\n# user content\n')
  251. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  252. })
  253. it.skipIf(process.platform === 'win32')('rejects Git without worktree-config support before mutation', async () => {
  254. const fixture = createFixture()
  255. const realGit = commandResult('which', ['git'], fixture.main, fixture.env).stdout.trim()
  256. const fakeBin = join(fixture.container, 'fake-bin')
  257. const fakeGit = join(fakeBin, 'git')
  258. write(
  259. fakeGit,
  260. `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.19.0"; exit 0; fi\nexec "${realGit}" "$@"\n`,
  261. 0o755,
  262. )
  263. const result = await runInstaller(fixture, fixture.main, {
  264. PATH: `${fakeBin}:${fixture.env.PATH ?? ''}`,
  265. })
  266. expect(result.status).toBe(1)
  267. expect(result.stderr).toContain('Git 2.20 or newer is required')
  268. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  269. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  270. })
  271. })