install-lefthook.spec.ts 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714
  1. import { spawn, spawnSync } from 'node:child_process'
  2. import {
  3. chmodSync,
  4. existsSync,
  5. linkSync,
  6. mkdirSync,
  7. mkdtempSync,
  8. lstatSync,
  9. readFileSync,
  10. renameSync,
  11. rmSync,
  12. symlinkSync,
  13. writeFileSync,
  14. } from 'node:fs'
  15. import { tmpdir } from 'node:os'
  16. import { dirname, isAbsolute, join, resolve } from 'node:path'
  17. import { fileURLToPath } from 'node:url'
  18. import { afterEach, describe, expect, it } from 'vitest'
  19. const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url))
  20. const fixtures: string[] = []
  21. interface Fixture {
  22. container: string
  23. env: NodeJS.ProcessEnv
  24. linked: string
  25. main: string
  26. }
  27. interface CommandResult {
  28. status: number | null
  29. stderr: string
  30. stdout: string
  31. }
  32. afterEach(() => {
  33. for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true })
  34. })
  35. function commandResult(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): CommandResult {
  36. const result = spawnSync(command, args, { cwd, encoding: 'utf8', env })
  37. return { status: result.status, stderr: result.stderr, stdout: result.stdout }
  38. }
  39. function gitResult(fixture: Fixture, cwd: string, args: string[]): CommandResult {
  40. return commandResult('git', args, cwd, fixture.env)
  41. }
  42. function git(fixture: Fixture, cwd: string, args: string[]): string {
  43. const result = gitResult(fixture, cwd, args)
  44. if (result.status !== 0) {
  45. throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`)
  46. }
  47. return result.stdout.trim()
  48. }
  49. function write(path: string, content: string, mode?: number): void {
  50. mkdirSync(dirname(path), { recursive: true })
  51. writeFileSync(path, content, mode === undefined ? undefined : { mode })
  52. }
  53. function fakeLefthookSource(): string {
  54. return `#!/usr/bin/env node
  55. import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
  56. import { execFileSync } from 'node:child_process'
  57. import { join } from 'node:path'
  58. if (process.argv.slice(2).join(' ') !== 'install --force') process.exit(64)
  59. const rootOutput = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' })
  60. const root = rootOutput.endsWith('\\n') ? rootOutput.slice(0, -1) : rootOutput
  61. const forbiddenConfigKey = process.env.DSH_TEST_FORBIDDEN_GIT_CONFIG_KEY
  62. if (forbiddenConfigKey !== undefined) {
  63. try {
  64. execFileSync('git', ['config', '--get', forbiddenConfigKey], { encoding: 'utf8' })
  65. process.exit(92)
  66. } catch (error) {
  67. if (error === null || typeof error !== 'object' || !('status' in error) || error.status !== 1) throw error
  68. }
  69. }
  70. const hooksPath = execFileSync('git', ['config', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim()
  71. mkdirSync(hooksPath, { recursive: true })
  72. const running = join(hooksPath, '.fake-lefthook-running')
  73. try {
  74. writeFileSync(running, String(process.pid), { flag: 'wx' })
  75. } catch {
  76. process.exit(91)
  77. }
  78. const delay = Number(process.env.DSH_TEST_LEFTHOOK_DELAY_MS ?? 0)
  79. if (delay > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay)
  80. const shouldFail = process.env.DSH_TEST_LEFTHOOK_FAIL === '1'
  81. if (!shouldFail) {
  82. const binary = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'lefthook.cmd' : 'lefthook')
  83. const config = readFileSync(join(root, 'lefthook.yml'), 'utf8').trim()
  84. const hook = \`#!/bin/sh\\n# root=\${root}\\n# binary=\${binary}\\n# config=\${config}\\nexit 0\\n\`
  85. for (const name of ['pre-commit', 'pre-push']) writeFileSync(join(hooksPath, name), hook, { mode: 0o755 })
  86. }
  87. if (existsSync(running)) unlinkSync(running)
  88. if (process.env.DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG === '1') {
  89. const configPath = execFileSync('git', ['rev-parse', '--git-path', 'config.worktree'], { encoding: 'utf8' }).trim()
  90. writeFileSync(configPath, '[invalid\\n')
  91. }
  92. if (shouldFail) process.exit(77)
  93. `
  94. }
  95. function installFakeLefthook(root: string): void {
  96. const binDirectory = join(root, 'node_modules/.bin')
  97. mkdirSync(binDirectory, { recursive: true })
  98. writeFileSync(join(binDirectory, 'fake-lefthook.mjs'), fakeLefthookSource())
  99. if (process.platform === 'win32') {
  100. writeFileSync(
  101. join(binDirectory, 'lefthook.cmd'),
  102. `@echo off\r\n"${process.execPath}" "%~dp0\\fake-lefthook.mjs" %*\r\n`,
  103. )
  104. return
  105. }
  106. const shim = join(binDirectory, 'lefthook')
  107. writeFileSync(shim, `#!/bin/sh\nexec "${process.execPath}" "$(dirname "$0")/fake-lefthook.mjs" "$@"\n`)
  108. chmodSync(shim, 0o755)
  109. }
  110. function createFixture(names: { main?: string; linked?: string } = {}): Fixture {
  111. const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-'))
  112. fixtures.push(container)
  113. const main = join(container, names.main ?? 'main')
  114. const linked = join(container, names.linked ?? 'linked')
  115. const env: NodeJS.ProcessEnv = {
  116. ...process.env,
  117. CI: 'false',
  118. GITHUB_ACTIONS: 'false',
  119. GIT_AUTHOR_EMAIL: 'hooks@example.test',
  120. GIT_AUTHOR_NAME: 'Hooks Test',
  121. GIT_COMMITTER_EMAIL: 'hooks@example.test',
  122. GIT_COMMITTER_NAME: 'Hooks Test',
  123. GIT_CONFIG_GLOBAL: join(container, 'global.gitconfig'),
  124. GIT_CONFIG_NOSYSTEM: '1',
  125. HOME: container,
  126. XDG_CONFIG_HOME: join(container, '.config'),
  127. }
  128. const fixture = { container, env, linked, main }
  129. mkdirSync(main)
  130. git(fixture, container, ['init', main])
  131. write(join(main, 'README.md'), '# fixture\n')
  132. git(fixture, main, ['add', 'README.md'])
  133. git(fixture, main, ['commit', '-m', 'fixture'])
  134. git(fixture, main, ['worktree', 'add', '-b', 'linked', linked])
  135. write(join(main, 'lefthook.yml'), 'main-worktree-config\n')
  136. write(join(linked, 'lefthook.yml'), 'linked-worktree-config\n')
  137. installFakeLefthook(main)
  138. installFakeLefthook(linked)
  139. return fixture
  140. }
  141. function gitDirectory(fixture: Fixture, root: string): string {
  142. return git(fixture, root, ['rev-parse', '--absolute-git-dir'])
  143. }
  144. function commonDirectory(fixture: Fixture): string {
  145. const output = git(fixture, fixture.main, ['rev-parse', '--git-common-dir'])
  146. return isAbsolute(output) ? output : resolve(fixture.main, output)
  147. }
  148. function hooksPath(fixture: Fixture, root: string): string {
  149. return join(gitDirectory(fixture, root), 'dsh-hooks')
  150. }
  151. function installLockPath(fixture: Fixture): string {
  152. return join(commonDirectory(fixture), 'dsh-lefthook-install.lock')
  153. }
  154. async function waitForPath(path: string): Promise<void> {
  155. const deadline = Date.now() + 5_000
  156. while (!existsSync(path)) {
  157. if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`)
  158. await new Promise(resolveWait => setTimeout(resolveWait, 10))
  159. }
  160. }
  161. function runInstaller(
  162. fixture: Fixture,
  163. root: string,
  164. extraEnv: NodeJS.ProcessEnv = {},
  165. ): Promise<CommandResult> {
  166. return new Promise((resolveResult, reject) => {
  167. const child = spawn(process.execPath, [installer], {
  168. cwd: root,
  169. env: { ...fixture.env, ...extraEnv },
  170. stdio: ['ignore', 'pipe', 'pipe'],
  171. })
  172. let stdout = ''
  173. let stderr = ''
  174. child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
  175. child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
  176. child.on('error', reject)
  177. child.on('close', (status) => { resolveResult({ status, stderr, stdout }) })
  178. })
  179. }
  180. describe('worktree-local Lefthook installer', () => {
  181. for (const [label, extraEnv] of [
  182. ['CI', { CI: 'true' }],
  183. ['GitHub Actions', { GITHUB_ACTIONS: 'true' }],
  184. ] satisfies [string, NodeJS.ProcessEnv][]) {
  185. it(`skips hook installation when ${label} marks an automated job`, async () => {
  186. const fixture = createFixture()
  187. const common = commonDirectory(fixture)
  188. const missingInclude = join(fixture.container, 'missing-ci-credentials.gitconfig')
  189. git(fixture, fixture.main, [
  190. 'config',
  191. '--local',
  192. 'includeIf.gitdir:/github/workspace/.git.path',
  193. missingInclude,
  194. ])
  195. const result = await runInstaller(fixture, fixture.main, extraEnv)
  196. expect(result.status, result.stderr).toBe(0)
  197. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  198. expect(git(fixture, fixture.main, ['config', '--get', 'core.repositoryFormatVersion'])).toBe('0')
  199. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  200. expect(existsSync(join(common, 'config.worktree'))).toBe(false)
  201. })
  202. }
  203. it('isolates main and linked worktrees without changing legacy common hooks', async () => {
  204. const fixture = createFixture()
  205. const common = commonDirectory(fixture)
  206. const legacyHook = join(common, 'hooks/pre-commit')
  207. write(legacyHook, '#!/bin/sh\n# legacy hook\n', 0o755)
  208. const mainInstall = await runInstaller(fixture, fixture.main)
  209. const linkedInstall = await runInstaller(fixture, fixture.linked)
  210. expect(mainInstall.status, mainInstall.stderr).toBe(0)
  211. expect(linkedInstall.status, linkedInstall.stderr).toBe(0)
  212. const mainHooks = hooksPath(fixture, fixture.main)
  213. const linkedHooks = hooksPath(fixture, fixture.linked)
  214. expect(mainHooks).not.toBe(linkedHooks)
  215. expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
  216. expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks)
  217. const mainHook = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
  218. const linkedHook = readFileSync(join(linkedHooks, 'pre-commit'), 'utf8')
  219. const canonicalMain = git(fixture, fixture.main, ['rev-parse', '--show-toplevel'])
  220. const canonicalLinked = git(fixture, fixture.linked, ['rev-parse', '--show-toplevel'])
  221. expect(mainHook).toContain(`# root=${canonicalMain}`)
  222. expect(mainHook).toContain('# config=main-worktree-config')
  223. expect(mainHook).not.toContain(canonicalLinked)
  224. expect(linkedHook).toContain(`# root=${canonicalLinked}`)
  225. expect(linkedHook).toContain('# config=linked-worktree-config')
  226. expect(linkedHook).not.toContain(canonicalMain)
  227. expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
  228. const commonConfig = join(common, 'config')
  229. expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.repositoryFormatVersion'])).toBe('1')
  230. expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'extensions.worktreeConfig'])).toBe('true')
  231. expect(gitResult(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.bare']).status).toBe(1)
  232. const mainHookBeforeRemoval = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
  233. git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked])
  234. expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval)
  235. expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
  236. })
  237. it('serializes concurrent installs and keeps repeated output stable', async () => {
  238. const fixture = createFixture()
  239. const delayed = { DSH_TEST_LEFTHOOK_DELAY_MS: '150' }
  240. const first = await Promise.all([
  241. runInstaller(fixture, fixture.main, delayed),
  242. runInstaller(fixture, fixture.linked, delayed),
  243. ])
  244. for (const result of first) expect(result.status, result.stderr).toBe(0)
  245. const mainHookPath = join(hooksPath(fixture, fixture.main), 'pre-push')
  246. const initialHook = readFileSync(mainHookPath, 'utf8')
  247. const repeated = await Promise.all([
  248. runInstaller(fixture, fixture.main, delayed),
  249. runInstaller(fixture, fixture.main, delayed),
  250. ])
  251. for (const result of repeated) expect(result.status, result.stderr).toBe(0)
  252. expect(readFileSync(mainHookPath, 'utf8')).toBe(initialHook)
  253. expect(existsSync(join(commonDirectory(fixture), 'dsh-lefthook-install.lock'))).toBe(false)
  254. expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false)
  255. })
  256. it('repairs its owned absolute hook path after the checkout moves', async () => {
  257. const fixture = createFixture()
  258. const oldRoot = fixture.main
  259. const first = await runInstaller(fixture, oldRoot)
  260. expect(first.status, first.stderr).toBe(0)
  261. const oldHooks = hooksPath(fixture, oldRoot)
  262. const movedRoot = join(fixture.container, 'moved-main')
  263. renameSync(oldRoot, movedRoot)
  264. const moved = await runInstaller(fixture, movedRoot)
  265. expect(moved.status, moved.stderr).toBe(0)
  266. const movedHooks = hooksPath(fixture, movedRoot)
  267. expect(movedHooks).not.toBe(oldHooks)
  268. expect(git(fixture, movedRoot, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(movedHooks)
  269. const canonicalMoved = git(fixture, movedRoot, ['rev-parse', '--show-toplevel'])
  270. expect(readFileSync(join(movedHooks, 'pre-commit'), 'utf8')).toContain(`# root=${canonicalMoved}`)
  271. expect(readFileSync(join(movedHooks, '.dsh-lefthook-owned'), 'utf8')).toContain(
  272. JSON.stringify(movedHooks),
  273. )
  274. })
  275. it.skipIf(process.platform === 'win32')('refuses a multiply linked ownership marker before relocation rewrites it', async () => {
  276. const fixture = createFixture()
  277. const oldRoot = fixture.main
  278. const first = await runInstaller(fixture, oldRoot)
  279. expect(first.status, first.stderr).toBe(0)
  280. const oldHooks = hooksPath(fixture, oldRoot)
  281. const markerName = '.dsh-lefthook-owned'
  282. const externalMarker = join(fixture.container, 'external-marker')
  283. linkSync(join(oldHooks, markerName), externalMarker)
  284. const externalContent = readFileSync(externalMarker, 'utf8')
  285. const movedRoot = join(fixture.container, 'moved-main')
  286. renameSync(oldRoot, movedRoot)
  287. const result = await runInstaller(fixture, movedRoot)
  288. expect(result.status).toBe(1)
  289. expect(result.stderr).toContain('invalid ownership marker')
  290. expect(readFileSync(externalMarker, 'utf8')).toBe(externalContent)
  291. })
  292. it.skipIf(process.platform === 'win32')('refuses aliased generated hooks before Lefthook can overwrite their targets', async () => {
  293. for (const kind of ['symlink', 'hardlink'] as const) {
  294. const fixture = createFixture()
  295. const first = await runInstaller(fixture, fixture.main)
  296. expect(first.status, first.stderr).toBe(0)
  297. const hook = join(hooksPath(fixture, fixture.main), 'pre-commit')
  298. const externalHook = join(fixture.container, `${kind}-external-hook`)
  299. rmSync(hook)
  300. write(externalHook, `external ${kind} target\n`)
  301. if (kind === 'symlink') symlinkSync(externalHook, hook)
  302. else linkSync(externalHook, hook)
  303. const externalContent = readFileSync(externalHook, 'utf8')
  304. const result = await runInstaller(fixture, fixture.main)
  305. expect(result.status).toBe(1)
  306. expect(result.stderr).toContain('non-regular or multiply linked hook entry')
  307. expect(readFileSync(externalHook, 'utf8')).toBe(externalContent)
  308. }
  309. })
  310. it('restores the marker-backed stale hook path when relocation reinstall fails', async () => {
  311. const fixture = createFixture()
  312. const oldRoot = fixture.main
  313. const first = await runInstaller(fixture, oldRoot)
  314. expect(first.status, first.stderr).toBe(0)
  315. const oldHooks = hooksPath(fixture, oldRoot)
  316. const markerName = '.dsh-lefthook-owned'
  317. const previousMarker = readFileSync(join(oldHooks, markerName), 'utf8')
  318. const movedRoot = join(fixture.container, 'moved-main')
  319. renameSync(oldRoot, movedRoot)
  320. const failed = await runInstaller(fixture, movedRoot, { DSH_TEST_LEFTHOOK_FAIL: '1' })
  321. expect(failed.status).toBe(1)
  322. expect(failed.stderr).toContain('exit status 77')
  323. const movedHooks = hooksPath(fixture, movedRoot)
  324. expect(git(fixture, movedRoot, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(oldHooks)
  325. expect(readFileSync(join(movedHooks, markerName), 'utf8')).toBe(previousMarker)
  326. })
  327. it('refuses dormant repository extensions before upgrading the repository format', async () => {
  328. const fixture = createFixture()
  329. const commonConfig = join(commonDirectory(fixture), 'config')
  330. git(fixture, fixture.main, ['config', 'extensions.dshUnknown', 'true'])
  331. expect(gitResult(fixture, fixture.main, ['status', '--porcelain']).status).toBe(0)
  332. const result = await runInstaller(fixture, fixture.main)
  333. expect(result.status).toBe(1)
  334. expect(result.stderr).toContain('dormant repository extension extensions.dshunknown')
  335. expect(git(fixture, fixture.main, [
  336. 'config', '--file', commonConfig, '--get', 'core.repositoryFormatVersion',
  337. ])).toBe('0')
  338. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  339. expect(gitResult(fixture, fixture.main, ['status', '--porcelain']).status).toBe(0)
  340. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  341. })
  342. it('refuses direct core.worktree before enabling worktree config', async () => {
  343. const fixture = createFixture()
  344. const commonConfig = join(commonDirectory(fixture), 'config')
  345. git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.worktree', fixture.main])
  346. const result = await runInstaller(fixture, fixture.linked)
  347. expect(result.status).toBe(1)
  348. expect(result.stderr).toContain('core.worktree is in the common config')
  349. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  350. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  351. })
  352. it.skipIf(process.platform === 'win32')('refuses a symlinked common repository config before writing through it', async () => {
  353. const fixture = createFixture()
  354. const commonConfig = join(commonDirectory(fixture), 'config')
  355. const externalConfig = join(fixture.container, 'external-common.gitconfig')
  356. renameSync(commonConfig, externalConfig)
  357. symlinkSync(externalConfig, commonConfig)
  358. const externalContent = readFileSync(externalConfig, 'utf8')
  359. const result = await runInstaller(fixture, fixture.main)
  360. expect(result.status).toBe(1)
  361. expect(result.stderr).toContain('common repository config')
  362. expect(result.stderr).toContain('not a regular file')
  363. expect(lstatSync(commonConfig).isSymbolicLink()).toBe(true)
  364. expect(readFileSync(externalConfig, 'utf8')).toBe(externalContent)
  365. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  366. })
  367. it('leaves stale installer locks for explicit recovery', async () => {
  368. const fixture = createFixture()
  369. const lockPath = installLockPath(fixture)
  370. const completed = spawnSync(process.execPath, ['-e', ''])
  371. expect(completed.status).toBe(0)
  372. const staleRecord = `${String(completed.pid)} 00000000-0000-4000-8000-000000000000\n`
  373. writeFileSync(lockPath, staleRecord)
  374. const results = await Promise.all(Array.from(
  375. { length: 4 },
  376. () => runInstaller(fixture, fixture.main),
  377. ))
  378. for (const result of results) {
  379. expect(result.status).toBe(1)
  380. expect(result.stderr).toContain('stale Lefthook installer lock')
  381. expect(result.stderr).toContain('remove it manually')
  382. }
  383. expect(readFileSync(lockPath, 'utf8')).toBe(staleRecord)
  384. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  385. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  386. })
  387. it('leaves invalid installer locks for explicit recovery', async () => {
  388. const fixture = createFixture()
  389. const lockPath = installLockPath(fixture)
  390. const invalidRecord = 'not an installer lock\n'
  391. writeFileSync(lockPath, invalidRecord)
  392. const result = await runInstaller(fixture, fixture.main)
  393. expect(result.status).toBe(1)
  394. expect(result.stderr).toContain('invalid Lefthook installer lock')
  395. expect(result.stderr).toContain('remove it manually')
  396. expect(readFileSync(lockPath, 'utf8')).toBe(invalidRecord)
  397. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  398. })
  399. it('does not release an installer lock whose ownership changed', async () => {
  400. const fixture = createFixture()
  401. const lockPath = installLockPath(fixture)
  402. const runningPath = join(hooksPath(fixture, fixture.main), '.fake-lefthook-running')
  403. const install = runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_DELAY_MS: '250' })
  404. await waitForPath(runningPath)
  405. const replacementRecord = 'replacement owner\n'
  406. writeFileSync(lockPath, replacementRecord)
  407. const result = await install
  408. expect(result.status).toBe(1)
  409. expect(result.stderr).toContain('installer lock ownership changed')
  410. expect(readFileSync(lockPath, 'utf8')).toBe(replacementRecord)
  411. })
  412. it.skipIf(process.platform === 'win32')('preserves trailing spaces in worktree paths', async () => {
  413. const fixture = createFixture({ main: 'main ', linked: 'linked ' })
  414. for (const root of [fixture.main, fixture.linked]) {
  415. const result = await runInstaller(fixture, root)
  416. expect(result.status, result.stderr).toBe(0)
  417. expect(git(fixture, root, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, root))
  418. }
  419. })
  420. it('preserves user-owned hook paths unless an inherited value is explicitly overridden', async () => {
  421. const fixture = createFixture()
  422. const customHook = join(fixture.main, 'custom-hooks/pre-commit')
  423. write(customHook, '#!/bin/sh\n# custom hook\n', 0o755)
  424. git(fixture, fixture.main, ['config', 'core.hooksPath', 'custom-hooks'])
  425. const refused = await runInstaller(fixture, fixture.main)
  426. expect(refused.status).toBe(1)
  427. expect(refused.stderr).toContain('refusing to replace user-owned core.hooksPath')
  428. expect(refused.stderr).toContain('DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1')
  429. expect(git(fixture, fixture.main, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks')
  430. expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n')
  431. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  432. const optedIn = await runInstaller(fixture, fixture.main, {
  433. DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
  434. })
  435. expect(optedIn.status, optedIn.stderr).toBe(0)
  436. expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.main))
  437. expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks')
  438. expect(gitResult(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1)
  439. expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n')
  440. git(fixture, fixture.linked, ['config', '--worktree', 'core.hooksPath', 'linked-custom-hooks'])
  441. const explicitWorktreePath = await runInstaller(fixture, fixture.linked, {
  442. DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
  443. })
  444. expect(explicitWorktreePath.status).toBe(1)
  445. expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks')
  446. })
  447. it('refuses to activate a sibling worktree dormant hook path', async () => {
  448. const fixture = createFixture()
  449. const linkedConfig = join(gitDirectory(fixture, fixture.linked), 'config.worktree')
  450. const linkedHooks = join(fixture.linked, 'custom-hooks')
  451. git(fixture, fixture.main, ['config', '--file', linkedConfig, 'core.hooksPath', linkedHooks])
  452. expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1)
  453. const result = await runInstaller(fixture, fixture.main)
  454. expect(result.status).toBe(1)
  455. expect(result.stderr).toContain('sibling dormant worktree config')
  456. expect(result.stderr).toContain(linkedConfig)
  457. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  458. expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1)
  459. expect(git(fixture, fixture.main, ['config', '--file', linkedConfig, '--get', 'core.hooksPath'])).toBe(linkedHooks)
  460. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  461. })
  462. it.skipIf(process.platform === 'win32')('refuses an active symlinked worktree config before writing through it', async () => {
  463. const fixture = createFixture()
  464. const commonConfig = join(commonDirectory(fixture), 'config')
  465. const worktreeConfig = join(gitDirectory(fixture, fixture.main), 'config.worktree')
  466. const externalConfig = join(fixture.container, 'external.gitconfig')
  467. const externalContent = '[user]\n\tname = External owner\n'
  468. write(externalConfig, externalContent)
  469. git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1'])
  470. git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true'])
  471. symlinkSync(externalConfig, worktreeConfig)
  472. const result = await runInstaller(fixture, fixture.main)
  473. expect(result.status).toBe(1)
  474. expect(result.stderr).toContain('active worktree config')
  475. expect(result.stderr).toContain('not a regular file')
  476. expect(lstatSync(worktreeConfig).isSymbolicLink()).toBe(true)
  477. expect(readFileSync(externalConfig, 'utf8')).toBe(externalContent)
  478. expect(gitResult(fixture, fixture.main, [
  479. 'config', '--file', externalConfig, '--get', 'core.hooksPath',
  480. ]).status).toBe(1)
  481. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  482. })
  483. for (const includeKey of ['include.path', 'includeIf.onbranch:conditional.path']) {
  484. for (const key of ['core.worktree', 'core.bare', 'extensions.dshunknown']) {
  485. it(`ignores ${key} loaded through ${includeKey}`, async () => {
  486. const fixture = createFixture()
  487. const commonConfig = join(commonDirectory(fixture), 'config')
  488. const includedConfig = join(fixture.container, `${includeKey.split('.')[0]}-${key.replace('.', '-')}.gitconfig`)
  489. const value = key === 'core.worktree' ? fixture.main : 'true'
  490. git(fixture, fixture.main, ['config', '--file', includedConfig, key, value])
  491. git(fixture, fixture.main, ['config', '--file', commonConfig, includeKey, includedConfig])
  492. const result = await runInstaller(fixture, fixture.linked)
  493. expect(result.status, result.stderr).toBe(0)
  494. expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(
  495. hooksPath(fixture, fixture.linked),
  496. )
  497. expect(existsSync(join(hooksPath(fixture, fixture.linked), 'pre-commit'))).toBe(true)
  498. })
  499. }
  500. }
  501. it('ignores an inactive global includeIf that provides a hook path for another repository', async () => {
  502. const fixture = createFixture()
  503. const globalConfig = fixture.env.GIT_CONFIG_GLOBAL
  504. if (globalConfig === undefined) throw new Error('fixture global config path is missing')
  505. const includedConfig = join(fixture.container, 'other-repository.gitconfig')
  506. const includedHooks = join(fixture.container, 'other-repository-hooks')
  507. git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks])
  508. git(fixture, fixture.main, [
  509. 'config',
  510. '--file',
  511. globalConfig,
  512. `includeIf.gitdir:${join(fixture.container, 'other')}/.path`,
  513. includedConfig,
  514. ])
  515. const result = await runInstaller(fixture, fixture.linked)
  516. expect(result.status, result.stderr).toBe(0)
  517. expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.linked))
  518. })
  519. it('never overrides a command-scoped hook path', async () => {
  520. const fixture = createFixture()
  521. const commandHooks = join(fixture.container, 'command-hooks')
  522. const sentinel = join(commandHooks, 'pre-commit')
  523. write(sentinel, '#!/bin/sh\n# command-scope sentinel\n', 0o755)
  524. const result = await runInstaller(fixture, fixture.main, {
  525. DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
  526. GIT_CONFIG_COUNT: '1',
  527. GIT_CONFIG_KEY_0: 'core.hooksPath',
  528. GIT_CONFIG_VALUE_0: commandHooks,
  529. })
  530. expect(result.status).toBe(1)
  531. expect(result.stderr).toContain('command-scoped core.hooksPath')
  532. expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# command-scope sentinel\n')
  533. expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
  534. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  535. })
  536. it('does not pass unrelated command-scoped Git config to Lefthook', async () => {
  537. const fixture = createFixture()
  538. const result = await runInstaller(fixture, fixture.main, {
  539. DSH_TEST_FORBIDDEN_GIT_CONFIG_KEY: 'dsh.testSentinel',
  540. GIT_CONFIG_COUNT: '1',
  541. GIT_CONFIG_KEY_0: 'dsh.testSentinel',
  542. GIT_CONFIG_VALUE_0: 'must-not-reach-lefthook',
  543. })
  544. expect(result.status, result.stderr).toBe(0)
  545. expect(existsSync(join(hooksPath(fixture, fixture.main), 'pre-commit'))).toBe(true)
  546. })
  547. it('never overrides a hook path included by worktree config', async () => {
  548. const fixture = createFixture()
  549. const commonConfig = join(commonDirectory(fixture), 'config')
  550. const worktreeConfig = join(gitDirectory(fixture, fixture.main), 'config.worktree')
  551. const includedConfig = join(fixture.container, 'included-worktree.gitconfig')
  552. const includedHooks = join(fixture.container, 'included-hooks')
  553. const sentinel = join(includedHooks, 'pre-commit')
  554. write(sentinel, '#!/bin/sh\n# included-worktree sentinel\n', 0o755)
  555. git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks])
  556. git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1'])
  557. git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true'])
  558. git(fixture, fixture.main, ['config', '--file', worktreeConfig, 'include.path', includedConfig])
  559. const result = await runInstaller(fixture, fixture.main, {
  560. DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
  561. })
  562. expect(result.status).toBe(1)
  563. expect(result.stderr).toContain('worktree-scoped core.hooksPath')
  564. expect(git(fixture, fixture.main, ['config', '--get', 'core.hooksPath'])).toBe(includedHooks)
  565. expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# included-worktree sentinel\n')
  566. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  567. })
  568. it('restores the previous hook lookup when Lefthook installation fails', async () => {
  569. const fixture = createFixture()
  570. const common = commonDirectory(fixture)
  571. const legacyHook = join(common, 'hooks/pre-push')
  572. write(legacyHook, '#!/bin/sh\n# legacy pre-push\n', 0o755)
  573. const result = await runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_FAIL: '1' })
  574. expect(result.status).toBe(1)
  575. expect(result.stderr).toContain('exit status 77')
  576. expect(gitResult(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1)
  577. expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
  578. expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy pre-push\n')
  579. })
  580. it('reports installation and hook-path rollback failures together', async () => {
  581. const fixture = createFixture()
  582. const result = await runInstaller(fixture, fixture.main, {
  583. DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG: '1',
  584. DSH_TEST_LEFTHOOK_FAIL: '1',
  585. })
  586. expect(result.status).toBe(1)
  587. expect(result.stderr).toContain('Lefthook installation failed')
  588. expect(result.stderr).toContain('exit status 77')
  589. expect(result.stderr).toContain('worktree hook rollback also failed')
  590. expect(result.stderr).toContain('git config --worktree --unset-all core.hooksPath failed')
  591. })
  592. it('refuses an unowned directory at the reserved worktree hook path', async () => {
  593. const fixture = createFixture()
  594. const reservedHook = join(hooksPath(fixture, fixture.main), 'pre-commit')
  595. write(reservedHook, '#!/bin/sh\n# user content\n', 0o755)
  596. const result = await runInstaller(fixture, fixture.main)
  597. expect(result.status).toBe(1)
  598. expect(result.stderr).toContain('refusing to overwrite unowned hooks directory')
  599. expect(readFileSync(reservedHook, 'utf8')).toBe('#!/bin/sh\n# user content\n')
  600. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  601. })
  602. it.skipIf(process.platform === 'win32')('rejects Git without config-scope support before mutation', async () => {
  603. const fixture = createFixture()
  604. const realGit = commandResult('which', ['git'], fixture.main, fixture.env).stdout.trim()
  605. const fakeBin = join(fixture.container, 'fake-bin')
  606. const fakeGit = join(fakeBin, 'git')
  607. write(
  608. fakeGit,
  609. `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.25.0"; exit 0; fi\nexec "${realGit}" "$@"\n`,
  610. 0o755,
  611. )
  612. const result = await runInstaller(fixture, fixture.main, {
  613. PATH: `${fakeBin}:${fixture.env.PATH ?? ''}`,
  614. })
  615. expect(result.status).toBe(1)
  616. expect(result.stderr).toContain('Git 2.26 or newer is required')
  617. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  618. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  619. })
  620. })