install-lefthook.spec.ts 40 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881
  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. import { removeFixtureSafely, unlinkFixtureLinks } from './test-fixture-cleanup.ts'
  20. const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url))
  21. const pairingMergeDriver = 'scripts/merge-translation-pairing-driver.sh %O %A %B %P'
  22. const scriptsDirectory = fileURLToPath(new URL('.', import.meta.url))
  23. const tsxPackageDirectory = dirname(fileURLToPath(import.meta.resolve('tsx/package.json')))
  24. const fixtures: string[] = []
  25. interface Fixture {
  26. container: string
  27. env: NodeJS.ProcessEnv
  28. linked: string
  29. main: string
  30. }
  31. interface CommandResult {
  32. status: number | null
  33. stderr: string
  34. stdout: string
  35. }
  36. afterEach(() => {
  37. for (const fixture of fixtures.splice(0)) removeFixtureSafely(fixture)
  38. })
  39. function commandResult(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): CommandResult {
  40. const result = spawnSync(command, args, { cwd, encoding: 'utf8', env })
  41. return { status: result.status, stderr: result.stderr, stdout: result.stdout }
  42. }
  43. function gitResult(fixture: Fixture, cwd: string, args: string[]): CommandResult {
  44. return commandResult('git', args, cwd, fixture.env)
  45. }
  46. function git(fixture: Fixture, cwd: string, args: string[]): string {
  47. const result = gitResult(fixture, cwd, args)
  48. if (result.status !== 0) {
  49. throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`)
  50. }
  51. return result.stdout.trim()
  52. }
  53. function write(path: string, content: string, mode?: number): void {
  54. mkdirSync(dirname(path), { recursive: true })
  55. writeFileSync(path, content, mode === undefined ? undefined : { mode })
  56. }
  57. function fakeLefthookSource(): string {
  58. return `#!/usr/bin/env node
  59. import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
  60. import { execFileSync } from 'node:child_process'
  61. import { join } from 'node:path'
  62. if (process.argv.slice(2).join(' ') !== 'install --force') process.exit(64)
  63. const rootOutput = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' })
  64. const root = rootOutput.endsWith('\\n') ? rootOutput.slice(0, -1) : rootOutput
  65. const forbiddenConfigKey = process.env.DSH_TEST_FORBIDDEN_GIT_CONFIG_KEY
  66. if (forbiddenConfigKey !== undefined) {
  67. try {
  68. execFileSync('git', ['config', '--get', forbiddenConfigKey], { encoding: 'utf8' })
  69. process.exit(92)
  70. } catch (error) {
  71. if (error === null || typeof error !== 'object' || !('status' in error) || error.status !== 1) throw error
  72. }
  73. }
  74. const hooksPath = execFileSync('git', ['config', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim()
  75. mkdirSync(hooksPath, { recursive: true })
  76. const running = join(hooksPath, '.fake-lefthook-running')
  77. try {
  78. writeFileSync(running, String(process.pid), { flag: 'wx' })
  79. } catch {
  80. process.exit(91)
  81. }
  82. const delay = Number(process.env.DSH_TEST_LEFTHOOK_DELAY_MS ?? 0)
  83. if (delay > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay)
  84. const shouldFail = process.env.DSH_TEST_LEFTHOOK_FAIL === '1'
  85. if (!shouldFail) {
  86. const binary = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'lefthook.cmd' : 'lefthook')
  87. const config = readFileSync(join(root, 'lefthook.yml'), 'utf8').trim()
  88. const hook = \`#!/bin/sh\\n# root=\${root}\\n# binary=\${binary}\\n# config=\${config}\\nexit 0\\n\`
  89. for (const name of ['pre-commit', 'pre-merge-commit', 'pre-push']) writeFileSync(join(hooksPath, name), hook, { mode: 0o755 })
  90. }
  91. if (existsSync(running)) unlinkSync(running)
  92. if (process.env.DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG === '1') {
  93. const configPath = execFileSync('git', ['rev-parse', '--git-path', 'config.worktree'], { encoding: 'utf8' }).trim()
  94. writeFileSync(configPath, '[invalid\\n')
  95. }
  96. if (shouldFail) process.exit(77)
  97. `
  98. }
  99. function installFakeLefthook(root: string): void {
  100. const binDirectory = join(root, 'node_modules/.bin')
  101. mkdirSync(binDirectory, { recursive: true })
  102. writeFileSync(join(binDirectory, 'fake-lefthook.mjs'), fakeLefthookSource())
  103. if (process.platform === 'win32') {
  104. writeFileSync(
  105. join(binDirectory, 'lefthook.cmd'),
  106. `@echo off\r\n"${process.execPath}" "%~dp0\\fake-lefthook.mjs" %*\r\n`,
  107. )
  108. return
  109. }
  110. const shim = join(binDirectory, 'lefthook')
  111. writeFileSync(shim, `#!/bin/sh\nexec "${process.execPath}" "$(dirname "$0")/fake-lefthook.mjs" "$@"\n`)
  112. chmodSync(shim, 0o755)
  113. }
  114. function installPairingProbeFixture(root: string): void {
  115. const linkType = process.platform === 'win32' ? 'junction' : 'dir'
  116. symlinkSync(scriptsDirectory, join(root, 'scripts'), linkType)
  117. symlinkSync(tsxPackageDirectory, join(root, 'node_modules/tsx'), linkType)
  118. }
  119. function createFixture(names: { main?: string; linked?: string } = {}): Fixture {
  120. const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-'))
  121. fixtures.push(container)
  122. const main = join(container, names.main ?? 'main')
  123. const linked = join(container, names.linked ?? 'linked')
  124. const env: NodeJS.ProcessEnv = {
  125. ...process.env,
  126. CI: 'false',
  127. GITHUB_ACTIONS: 'false',
  128. GIT_AUTHOR_EMAIL: 'hooks@example.test',
  129. GIT_AUTHOR_NAME: 'Hooks Test',
  130. GIT_COMMITTER_EMAIL: 'hooks@example.test',
  131. GIT_COMMITTER_NAME: 'Hooks Test',
  132. GIT_CONFIG_GLOBAL: join(container, 'global.gitconfig'),
  133. GIT_CONFIG_NOSYSTEM: '1',
  134. HOME: container,
  135. XDG_CONFIG_HOME: join(container, '.config'),
  136. }
  137. const fixture = { container, env, linked, main }
  138. mkdirSync(main)
  139. git(fixture, container, ['init', main])
  140. write(join(main, 'README.md'), '# fixture\n')
  141. git(fixture, main, ['add', 'README.md'])
  142. git(fixture, main, ['commit', '-m', 'fixture'])
  143. git(fixture, main, ['worktree', 'add', '-b', 'linked', linked])
  144. write(join(main, 'lefthook.yml'), 'main-worktree-config\n')
  145. write(join(linked, 'lefthook.yml'), 'linked-worktree-config\n')
  146. installFakeLefthook(main)
  147. installFakeLefthook(linked)
  148. installPairingProbeFixture(main)
  149. installPairingProbeFixture(linked)
  150. return fixture
  151. }
  152. function gitDirectory(fixture: Fixture, root: string): string {
  153. return git(fixture, root, ['rev-parse', '--absolute-git-dir'])
  154. }
  155. function commonDirectory(fixture: Fixture): string {
  156. const output = git(fixture, fixture.main, ['rev-parse', '--git-common-dir'])
  157. return isAbsolute(output) ? output : resolve(fixture.main, output)
  158. }
  159. function hooksPath(fixture: Fixture, root: string): string {
  160. return join(gitDirectory(fixture, root), 'dsh-hooks')
  161. }
  162. function installLockPath(fixture: Fixture): string {
  163. return join(commonDirectory(fixture), 'dsh-lefthook-install.lock')
  164. }
  165. async function waitForPath(path: string): Promise<void> {
  166. const deadline = Date.now() + 10_000
  167. while (!existsSync(path)) {
  168. if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`)
  169. await new Promise(resolveWait => setTimeout(resolveWait, 10))
  170. }
  171. }
  172. function runInstaller(
  173. fixture: Fixture,
  174. root: string,
  175. extraEnv: NodeJS.ProcessEnv = {},
  176. ): Promise<CommandResult> {
  177. return new Promise((resolveResult, reject) => {
  178. const child = spawn(process.execPath, [installer], {
  179. cwd: root,
  180. env: { ...fixture.env, ...extraEnv },
  181. stdio: ['ignore', 'pipe', 'pipe'],
  182. })
  183. let stdout = ''
  184. let stderr = ''
  185. child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
  186. child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
  187. child.on('error', reject)
  188. child.on('close', (status) => { resolveResult({ status, stderr, stdout }) })
  189. })
  190. }
  191. // Every case builds scratch worktrees and drives them through spawned Git and
  192. // Node subprocesses, so the suite is bound by process creation rather than by
  193. // its assertions. The value matches DSH_COVERAGE_TEST_TIMEOUT_MS, which the
  194. // Windows coverage lane passes as --testTimeout: a describe value overrides that
  195. // flag rather than yielding to it, so a smaller one here lowers what the lane
  196. // grants every case in this file, none of which carries an allowance of its own.
  197. // Rationale and the paired hook budget are in
  198. // .agents/notes/implemented/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md.
  199. describe('worktree-local Lefthook installer', { timeout: 90_000 }, () => {
  200. for (const [label, extraEnv] of [
  201. ['CI', { CI: 'true' }],
  202. ['GitHub Actions', { GITHUB_ACTIONS: 'true' }],
  203. ] satisfies [string, NodeJS.ProcessEnv][]) {
  204. it(`skips hook installation when ${label} marks an automated job`, async () => {
  205. const fixture = createFixture()
  206. const common = commonDirectory(fixture)
  207. const missingInclude = join(fixture.container, 'missing-ci-credentials.gitconfig')
  208. git(fixture, fixture.main, [
  209. 'config',
  210. '--local',
  211. 'includeIf.gitdir:/github/workspace/.git.path',
  212. missingInclude,
  213. ])
  214. const result = await runInstaller(fixture, fixture.main, extraEnv)
  215. expect(result.status, result.stderr).toBe(0)
  216. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  217. expect(git(fixture, fixture.main, ['config', '--get', 'core.repositoryFormatVersion'])).toBe('0')
  218. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  219. expect(existsSync(join(common, 'config.worktree'))).toBe(false)
  220. expect(gitResult(fixture, fixture.main, [
  221. 'config', '--get', 'merge.dsh-translation-pairing.driver',
  222. ]).status).toBe(1)
  223. })
  224. }
  225. it('isolates main and linked worktrees without changing legacy common hooks', async () => {
  226. const fixture = createFixture()
  227. const common = commonDirectory(fixture)
  228. const legacyHook = join(common, 'hooks/pre-commit')
  229. write(legacyHook, '#!/bin/sh\n# legacy hook\n', 0o755)
  230. const mainInstall = await runInstaller(fixture, fixture.main)
  231. const linkedInstall = await runInstaller(fixture, fixture.linked)
  232. expect(mainInstall.status, mainInstall.stderr).toBe(0)
  233. expect(linkedInstall.status, linkedInstall.stderr).toBe(0)
  234. const mainHooks = hooksPath(fixture, fixture.main)
  235. const linkedHooks = hooksPath(fixture, fixture.linked)
  236. expect(mainHooks).not.toBe(linkedHooks)
  237. expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
  238. expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks)
  239. expect(git(fixture, fixture.main, [
  240. 'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
  241. ])).toBe(pairingMergeDriver)
  242. expect(git(fixture, fixture.linked, [
  243. 'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
  244. ])).toBe(pairingMergeDriver)
  245. const mainHook = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
  246. const linkedHook = readFileSync(join(linkedHooks, 'pre-commit'), 'utf8')
  247. const canonicalMain = git(fixture, fixture.main, ['rev-parse', '--show-toplevel'])
  248. const canonicalLinked = git(fixture, fixture.linked, ['rev-parse', '--show-toplevel'])
  249. expect(mainHook).toContain(`# root=${canonicalMain}`)
  250. expect(mainHook).toContain('# config=main-worktree-config')
  251. expect(mainHook).not.toContain(canonicalLinked)
  252. expect(linkedHook).toContain(`# root=${canonicalLinked}`)
  253. expect(linkedHook).toContain('# config=linked-worktree-config')
  254. expect(linkedHook).not.toContain(canonicalMain)
  255. expect(existsSync(join(mainHooks, 'pre-merge-commit'))).toBe(true)
  256. expect(existsSync(join(linkedHooks, 'pre-merge-commit'))).toBe(true)
  257. expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
  258. const commonConfig = join(common, 'config')
  259. expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.repositoryFormatVersion'])).toBe('1')
  260. expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'extensions.worktreeConfig'])).toBe('true')
  261. expect(gitResult(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.bare']).status).toBe(1)
  262. const mainHookBeforeRemoval = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
  263. // Windows Git follows the fixture's MOUNT_POINT junctions into their real
  264. // targets while removing a worktree; unlink them first so the removal
  265. // cannot delete the repository's scripts/ or tsx package.
  266. unlinkFixtureLinks(fixture.linked)
  267. git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked])
  268. expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval)
  269. expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
  270. })
  271. it('replaces the owned hook path Git copies into a newly added worktree', async () => {
  272. const fixture = createFixture()
  273. const mainInstall = await runInstaller(fixture, fixture.main)
  274. expect(mainInstall.status, mainInstall.stderr).toBe(0)
  275. const mainHooks = hooksPath(fixture, fixture.main)
  276. const mainHookBefore = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
  277. const lateLinked = join(fixture.container, 'late-linked')
  278. git(fixture, fixture.main, ['worktree', 'add', '-b', 'late-linked', lateLinked])
  279. write(join(lateLinked, 'lefthook.yml'), 'late-linked-worktree-config\n')
  280. installFakeLefthook(lateLinked)
  281. installPairingProbeFixture(lateLinked)
  282. expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
  283. const linkedInstall = await runInstaller(fixture, lateLinked)
  284. expect(linkedInstall.status, linkedInstall.stderr).toBe(0)
  285. const linkedHooks = hooksPath(fixture, lateLinked)
  286. expect(linkedHooks).not.toBe(mainHooks)
  287. expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks)
  288. expect(readFileSync(join(linkedHooks, 'pre-commit'), 'utf8')).toContain(
  289. '# config=late-linked-worktree-config',
  290. )
  291. expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBefore)
  292. })
  293. it('serializes concurrent installs and keeps repeated output stable', async () => {
  294. const fixture = createFixture()
  295. const delayed = { DSH_TEST_LEFTHOOK_DELAY_MS: '150' }
  296. const first = await Promise.all([
  297. runInstaller(fixture, fixture.main, delayed),
  298. runInstaller(fixture, fixture.linked, delayed),
  299. ])
  300. for (const result of first) expect(result.status, result.stderr).toBe(0)
  301. const mainHookPath = join(hooksPath(fixture, fixture.main), 'pre-push')
  302. const initialHook = readFileSync(mainHookPath, 'utf8')
  303. const repeated = await Promise.all([
  304. runInstaller(fixture, fixture.main, delayed),
  305. runInstaller(fixture, fixture.main, delayed),
  306. ])
  307. for (const result of repeated) expect(result.status, result.stderr).toBe(0)
  308. expect(readFileSync(mainHookPath, 'utf8')).toBe(initialHook)
  309. expect(existsSync(join(commonDirectory(fixture), 'dsh-lefthook-install.lock'))).toBe(false)
  310. expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false)
  311. })
  312. it('waits for a concurrent installer to finish publishing its lock record', async () => {
  313. const fixture = createFixture()
  314. const lockPath = installLockPath(fixture)
  315. const publishing = runInstaller(fixture, fixture.main, {
  316. DSH_TEST_LEFTHOOK_LOCK_WRITE_DELAY_MS: '200',
  317. })
  318. await waitForPath(lockPath)
  319. expect(readFileSync(lockPath, 'utf8')).toBe('')
  320. const waiting = runInstaller(fixture, fixture.linked)
  321. const results = await Promise.all([publishing, waiting])
  322. for (const result of results) expect(result.status, result.stderr).toBe(0)
  323. expect(existsSync(lockPath)).toBe(false)
  324. })
  325. it('repairs its owned absolute hook path after the checkout moves', async () => {
  326. const fixture = createFixture()
  327. const oldRoot = fixture.main
  328. const first = await runInstaller(fixture, oldRoot)
  329. expect(first.status, first.stderr).toBe(0)
  330. const oldHooks = hooksPath(fixture, oldRoot)
  331. const movedRoot = join(fixture.container, 'moved-main')
  332. renameSync(oldRoot, movedRoot)
  333. const moved = await runInstaller(fixture, movedRoot)
  334. expect(moved.status, moved.stderr).toBe(0)
  335. const movedHooks = hooksPath(fixture, movedRoot)
  336. expect(movedHooks).not.toBe(oldHooks)
  337. expect(git(fixture, movedRoot, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(movedHooks)
  338. const canonicalMoved = git(fixture, movedRoot, ['rev-parse', '--show-toplevel'])
  339. expect(readFileSync(join(movedHooks, 'pre-commit'), 'utf8')).toContain(`# root=${canonicalMoved}`)
  340. expect(readFileSync(join(movedHooks, '.dsh-lefthook-owned'), 'utf8')).toContain(
  341. JSON.stringify(movedHooks),
  342. )
  343. })
  344. it.skipIf(process.platform === 'win32')('refuses a multiply linked ownership marker before relocation rewrites it', async () => {
  345. const fixture = createFixture()
  346. const oldRoot = fixture.main
  347. const first = await runInstaller(fixture, oldRoot)
  348. expect(first.status, first.stderr).toBe(0)
  349. const oldHooks = hooksPath(fixture, oldRoot)
  350. const markerName = '.dsh-lefthook-owned'
  351. const externalMarker = join(fixture.container, 'external-marker')
  352. linkSync(join(oldHooks, markerName), externalMarker)
  353. const externalContent = readFileSync(externalMarker, 'utf8')
  354. const movedRoot = join(fixture.container, 'moved-main')
  355. renameSync(oldRoot, movedRoot)
  356. const result = await runInstaller(fixture, movedRoot)
  357. expect(result.status).toBe(1)
  358. expect(result.stderr).toContain('invalid ownership marker')
  359. expect(readFileSync(externalMarker, 'utf8')).toBe(externalContent)
  360. })
  361. it.skipIf(process.platform === 'win32')('refuses aliased generated hooks before Lefthook can overwrite their targets', async () => {
  362. for (const kind of ['symlink', 'hardlink'] as const) {
  363. const fixture = createFixture()
  364. const first = await runInstaller(fixture, fixture.main)
  365. expect(first.status, first.stderr).toBe(0)
  366. const hook = join(hooksPath(fixture, fixture.main), 'pre-commit')
  367. const externalHook = join(fixture.container, `${kind}-external-hook`)
  368. rmSync(hook)
  369. write(externalHook, `external ${kind} target\n`)
  370. if (kind === 'symlink') symlinkSync(externalHook, hook)
  371. else linkSync(externalHook, hook)
  372. const externalContent = readFileSync(externalHook, 'utf8')
  373. const result = await runInstaller(fixture, fixture.main)
  374. expect(result.status).toBe(1)
  375. expect(result.stderr).toContain('non-regular or multiply linked hook entry')
  376. expect(readFileSync(externalHook, 'utf8')).toBe(externalContent)
  377. }
  378. })
  379. it('restores the marker-backed stale hook path when relocation reinstall fails', async () => {
  380. const fixture = createFixture()
  381. const oldRoot = fixture.main
  382. const first = await runInstaller(fixture, oldRoot)
  383. expect(first.status, first.stderr).toBe(0)
  384. const oldHooks = hooksPath(fixture, oldRoot)
  385. const markerName = '.dsh-lefthook-owned'
  386. const previousMarker = readFileSync(join(oldHooks, markerName), 'utf8')
  387. const movedRoot = join(fixture.container, 'moved-main')
  388. renameSync(oldRoot, movedRoot)
  389. const failed = await runInstaller(fixture, movedRoot, { DSH_TEST_LEFTHOOK_FAIL: '1' })
  390. expect(failed.status).toBe(1)
  391. expect(failed.stderr).toContain('exit status 77')
  392. const movedHooks = hooksPath(fixture, movedRoot)
  393. expect(git(fixture, movedRoot, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(oldHooks)
  394. expect(readFileSync(join(movedHooks, markerName), 'utf8')).toBe(previousMarker)
  395. })
  396. it('refuses dormant repository extensions before upgrading the repository format', async () => {
  397. const fixture = createFixture()
  398. const commonConfig = join(commonDirectory(fixture), 'config')
  399. git(fixture, fixture.main, ['config', 'extensions.dshUnknown', 'true'])
  400. expect(gitResult(fixture, fixture.main, ['status', '--porcelain']).status).toBe(0)
  401. const result = await runInstaller(fixture, fixture.main)
  402. expect(result.status).toBe(1)
  403. expect(result.stderr).toContain('dormant repository extension extensions.dshunknown')
  404. expect(git(fixture, fixture.main, [
  405. 'config', '--file', commonConfig, '--get', 'core.repositoryFormatVersion',
  406. ])).toBe('0')
  407. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  408. expect(gitResult(fixture, fixture.main, ['status', '--porcelain']).status).toBe(0)
  409. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  410. })
  411. it('refuses direct core.worktree before enabling worktree config', async () => {
  412. const fixture = createFixture()
  413. const commonConfig = join(commonDirectory(fixture), 'config')
  414. git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.worktree', fixture.main])
  415. const result = await runInstaller(fixture, fixture.linked)
  416. expect(result.status).toBe(1)
  417. expect(result.stderr).toContain('core.worktree is in the common config')
  418. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  419. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  420. })
  421. it.skipIf(process.platform === 'win32')('refuses a symlinked common repository config before writing through it', async () => {
  422. const fixture = createFixture()
  423. const commonConfig = join(commonDirectory(fixture), 'config')
  424. const externalConfig = join(fixture.container, 'external-common.gitconfig')
  425. renameSync(commonConfig, externalConfig)
  426. symlinkSync(externalConfig, commonConfig)
  427. const externalContent = readFileSync(externalConfig, 'utf8')
  428. const result = await runInstaller(fixture, fixture.main)
  429. expect(result.status).toBe(1)
  430. expect(result.stderr).toContain('common repository config')
  431. expect(result.stderr).toContain('not a regular file')
  432. expect(lstatSync(commonConfig).isSymbolicLink()).toBe(true)
  433. expect(readFileSync(externalConfig, 'utf8')).toBe(externalContent)
  434. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  435. })
  436. it('leaves stale installer locks for explicit recovery', async () => {
  437. const fixture = createFixture()
  438. const lockPath = installLockPath(fixture)
  439. const completed = spawnSync(process.execPath, ['-e', ''])
  440. expect(completed.status).toBe(0)
  441. const staleRecord = `${String(completed.pid)} 00000000-0000-4000-8000-000000000000\n`
  442. writeFileSync(lockPath, staleRecord)
  443. const results = await Promise.all(Array.from(
  444. { length: 4 },
  445. () => runInstaller(fixture, fixture.main),
  446. ))
  447. for (const result of results) {
  448. expect(result.status).toBe(1)
  449. expect(result.stderr).toContain('stale Lefthook installer lock')
  450. expect(result.stderr).toContain('remove it manually')
  451. }
  452. expect(readFileSync(lockPath, 'utf8')).toBe(staleRecord)
  453. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  454. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  455. })
  456. it('leaves invalid installer locks for explicit recovery', async () => {
  457. const fixture = createFixture()
  458. const lockPath = installLockPath(fixture)
  459. const invalidRecord = 'not an installer lock\n'
  460. writeFileSync(lockPath, invalidRecord)
  461. const result = await runInstaller(fixture, fixture.main)
  462. expect(result.status).toBe(1)
  463. expect(result.stderr).toContain('invalid Lefthook installer lock')
  464. expect(result.stderr).toContain('remove it manually')
  465. expect(readFileSync(lockPath, 'utf8')).toBe(invalidRecord)
  466. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  467. })
  468. it('does not release an installer lock whose ownership changed', async () => {
  469. const fixture = createFixture()
  470. const lockPath = installLockPath(fixture)
  471. const runningPath = join(hooksPath(fixture, fixture.main), '.fake-lefthook-running')
  472. const install = runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_DELAY_MS: '250' })
  473. try {
  474. await waitForPath(runningPath)
  475. } catch (error) {
  476. await install
  477. throw error
  478. }
  479. const replacementRecord = 'replacement owner\n'
  480. writeFileSync(lockPath, replacementRecord)
  481. const result = await install
  482. expect(result.status).toBe(1)
  483. expect(result.stderr).toContain('installer lock ownership changed')
  484. expect(readFileSync(lockPath, 'utf8')).toBe(replacementRecord)
  485. })
  486. it.skipIf(process.platform === 'win32')('preserves trailing spaces in worktree paths', async () => {
  487. const fixture = createFixture({ main: 'main ', linked: 'linked ' })
  488. for (const root of [fixture.main, fixture.linked]) {
  489. const result = await runInstaller(fixture, root)
  490. expect(result.status, result.stderr).toBe(0)
  491. expect(git(fixture, root, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, root))
  492. }
  493. })
  494. it('preserves user-owned hook paths unless an inherited value is explicitly overridden', async () => {
  495. const fixture = createFixture()
  496. const customHook = join(fixture.main, 'custom-hooks/pre-commit')
  497. write(customHook, '#!/bin/sh\n# custom hook\n', 0o755)
  498. git(fixture, fixture.main, ['config', 'core.hooksPath', 'custom-hooks'])
  499. const refused = await runInstaller(fixture, fixture.main)
  500. expect(refused.status).toBe(1)
  501. expect(refused.stderr).toContain('refusing to replace user-owned core.hooksPath')
  502. expect(refused.stderr).toContain('DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1')
  503. expect(git(fixture, fixture.main, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks')
  504. expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n')
  505. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  506. const optedIn = await runInstaller(fixture, fixture.main, {
  507. DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
  508. })
  509. expect(optedIn.status, optedIn.stderr).toBe(0)
  510. expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.main))
  511. expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks')
  512. expect(gitResult(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1)
  513. expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n')
  514. git(fixture, fixture.linked, ['config', '--worktree', 'core.hooksPath', 'linked-custom-hooks'])
  515. const explicitWorktreePath = await runInstaller(fixture, fixture.linked, {
  516. DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
  517. })
  518. expect(explicitWorktreePath.status).toBe(1)
  519. expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks')
  520. })
  521. it('does not trust an ownership marker outside a registered worktree hook path', async () => {
  522. const fixture = createFixture()
  523. const mainInstall = await runInstaller(fixture, fixture.main)
  524. expect(mainInstall.status, mainInstall.stderr).toBe(0)
  525. const externalHooks = join(fixture.container, 'external-owned-hooks')
  526. write(
  527. join(externalHooks, '.dsh-lefthook-owned'),
  528. `${JSON.stringify({
  529. version: 1,
  530. owner: 'deepseek-harness worktree-local lefthook hooks',
  531. hooksPath: externalHooks,
  532. })}\n`,
  533. 0o600,
  534. )
  535. git(fixture, fixture.linked, ['config', '--worktree', 'core.hooksPath', externalHooks])
  536. const result = await runInstaller(fixture, fixture.linked)
  537. expect(result.status).toBe(1)
  538. expect(result.stderr).toContain('worktree-scoped core.hooksPath')
  539. expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(externalHooks)
  540. expect(existsSync(hooksPath(fixture, fixture.linked))).toBe(false)
  541. })
  542. it('refuses to activate a sibling worktree dormant hook path', async () => {
  543. const fixture = createFixture()
  544. const linkedConfig = join(gitDirectory(fixture, fixture.linked), 'config.worktree')
  545. const linkedHooks = join(fixture.linked, 'custom-hooks')
  546. git(fixture, fixture.main, ['config', '--file', linkedConfig, 'core.hooksPath', linkedHooks])
  547. expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1)
  548. const result = await runInstaller(fixture, fixture.main)
  549. expect(result.status).toBe(1)
  550. expect(result.stderr).toContain('sibling dormant worktree config')
  551. expect(result.stderr).toContain(JSON.stringify(linkedConfig))
  552. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  553. expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1)
  554. expect(git(fixture, fixture.main, ['config', '--file', linkedConfig, '--get', 'core.hooksPath'])).toBe(linkedHooks)
  555. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  556. })
  557. it.skipIf(process.platform === 'win32')('refuses an active symlinked worktree config before writing through it', async () => {
  558. const fixture = createFixture()
  559. const commonConfig = join(commonDirectory(fixture), 'config')
  560. const worktreeConfig = join(gitDirectory(fixture, fixture.main), 'config.worktree')
  561. const externalConfig = join(fixture.container, 'external.gitconfig')
  562. const externalContent = '[user]\n\tname = External owner\n'
  563. write(externalConfig, externalContent)
  564. git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1'])
  565. git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true'])
  566. symlinkSync(externalConfig, worktreeConfig)
  567. const result = await runInstaller(fixture, fixture.main)
  568. expect(result.status).toBe(1)
  569. expect(result.stderr).toContain('active worktree config')
  570. expect(result.stderr).toContain('not a regular file')
  571. expect(lstatSync(worktreeConfig).isSymbolicLink()).toBe(true)
  572. expect(readFileSync(externalConfig, 'utf8')).toBe(externalContent)
  573. expect(gitResult(fixture, fixture.main, [
  574. 'config', '--file', externalConfig, '--get', 'core.hooksPath',
  575. ]).status).toBe(1)
  576. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  577. })
  578. for (const includeKey of ['include.path', 'includeIf.onbranch:conditional.path']) {
  579. for (const key of ['core.worktree', 'core.bare', 'extensions.dshunknown']) {
  580. it(`ignores ${key} loaded through ${includeKey}`, async () => {
  581. const fixture = createFixture()
  582. const commonConfig = join(commonDirectory(fixture), 'config')
  583. const includedConfig = join(fixture.container, `${includeKey.split('.')[0]}-${key.replace('.', '-')}.gitconfig`)
  584. const value = key === 'core.worktree' ? fixture.main : 'true'
  585. git(fixture, fixture.main, ['config', '--file', includedConfig, key, value])
  586. git(fixture, fixture.main, ['config', '--file', commonConfig, includeKey, includedConfig])
  587. const result = await runInstaller(fixture, fixture.linked)
  588. expect(result.status, result.stderr).toBe(0)
  589. expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(
  590. hooksPath(fixture, fixture.linked),
  591. )
  592. expect(existsSync(join(hooksPath(fixture, fixture.linked), 'pre-commit'))).toBe(true)
  593. })
  594. }
  595. }
  596. it('ignores an inactive global includeIf that provides a hook path for another repository', async () => {
  597. const fixture = createFixture()
  598. const globalConfig = fixture.env.GIT_CONFIG_GLOBAL
  599. if (globalConfig === undefined) throw new Error('fixture global config path is missing')
  600. const includedConfig = join(fixture.container, 'other-repository.gitconfig')
  601. const includedHooks = join(fixture.container, 'other-repository-hooks')
  602. git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks])
  603. git(fixture, fixture.main, [
  604. 'config',
  605. '--file',
  606. globalConfig,
  607. `includeIf.gitdir:${join(fixture.container, 'other')}/.path`,
  608. includedConfig,
  609. ])
  610. const result = await runInstaller(fixture, fixture.linked)
  611. expect(result.status, result.stderr).toBe(0)
  612. expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.linked))
  613. })
  614. it('never overrides a command-scoped hook path', async () => {
  615. const fixture = createFixture()
  616. const commandHooks = join(fixture.container, 'command-hooks')
  617. const sentinel = join(commandHooks, 'pre-commit')
  618. write(sentinel, '#!/bin/sh\n# command-scope sentinel\n', 0o755)
  619. const result = await runInstaller(fixture, fixture.main, {
  620. DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
  621. GIT_CONFIG_COUNT: '1',
  622. GIT_CONFIG_KEY_0: 'core.hooksPath',
  623. GIT_CONFIG_VALUE_0: commandHooks,
  624. })
  625. expect(result.status).toBe(1)
  626. expect(result.stderr).toContain('command-scoped core.hooksPath')
  627. expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# command-scope sentinel\n')
  628. expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
  629. expect(gitResult(fixture, fixture.main, [
  630. 'config', '--get', 'merge.dsh-translation-pairing.driver',
  631. ]).status).toBe(1)
  632. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  633. })
  634. it('never replaces a custom worktree pairing merge driver', async () => {
  635. const fixture = createFixture()
  636. const commonConfig = join(commonDirectory(fixture), 'config')
  637. git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1'])
  638. git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true'])
  639. git(fixture, fixture.main, [
  640. 'config', '--worktree', 'merge.dsh-translation-pairing.driver', 'custom-driver %A',
  641. ])
  642. const result = await runInstaller(fixture, fixture.main)
  643. expect(result.status).toBe(1)
  644. expect(result.stderr).toContain('refusing to replace worktree merge.dsh-translation-pairing.driver')
  645. expect(git(fixture, fixture.main, [
  646. 'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
  647. ])).toBe('custom-driver %A')
  648. expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
  649. })
  650. it('never masks an inherited custom pairing merge driver', async () => {
  651. const fixture = createFixture()
  652. git(fixture, fixture.main, [
  653. 'config', '--local', 'merge.dsh-translation-pairing.driver', 'inherited-driver %A',
  654. ])
  655. const result = await runInstaller(fixture, fixture.main)
  656. expect(result.status).toBe(1)
  657. expect(result.stderr).toContain('refusing to mask inherited merge.dsh-translation-pairing.driver')
  658. expect(git(fixture, fixture.main, [
  659. 'config', '--local', '--get', 'merge.dsh-translation-pairing.driver',
  660. ])).toBe('inherited-driver %A')
  661. expect(gitResult(fixture, fixture.main, [
  662. 'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
  663. ]).status).toBe(1)
  664. expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
  665. })
  666. it('does not pass unrelated command-scoped Git config to Lefthook', async () => {
  667. const fixture = createFixture()
  668. const result = await runInstaller(fixture, fixture.main, {
  669. DSH_TEST_FORBIDDEN_GIT_CONFIG_KEY: 'dsh.testSentinel',
  670. GIT_CONFIG_COUNT: '1',
  671. GIT_CONFIG_KEY_0: 'dsh.testSentinel',
  672. GIT_CONFIG_VALUE_0: 'must-not-reach-lefthook',
  673. })
  674. expect(result.status, result.stderr).toBe(0)
  675. expect(existsSync(join(hooksPath(fixture, fixture.main), 'pre-commit'))).toBe(true)
  676. })
  677. it('never overrides a hook path included by worktree config', async () => {
  678. const fixture = createFixture()
  679. const commonConfig = join(commonDirectory(fixture), 'config')
  680. const worktreeConfig = join(gitDirectory(fixture, fixture.main), 'config.worktree')
  681. const includedConfig = join(fixture.container, 'included-worktree.gitconfig')
  682. const includedHooks = join(fixture.container, 'included-hooks')
  683. const sentinel = join(includedHooks, 'pre-commit')
  684. write(sentinel, '#!/bin/sh\n# included-worktree sentinel\n', 0o755)
  685. git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks])
  686. git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1'])
  687. git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true'])
  688. git(fixture, fixture.main, ['config', '--file', worktreeConfig, 'include.path', includedConfig])
  689. const result = await runInstaller(fixture, fixture.main, {
  690. DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
  691. })
  692. expect(result.status).toBe(1)
  693. expect(result.stderr).toContain('worktree-scoped core.hooksPath')
  694. expect(git(fixture, fixture.main, ['config', '--get', 'core.hooksPath'])).toBe(includedHooks)
  695. expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# included-worktree sentinel\n')
  696. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  697. })
  698. it('restores the previous hook lookup when Lefthook installation fails', async () => {
  699. const fixture = createFixture()
  700. const common = commonDirectory(fixture)
  701. const legacyHook = join(common, 'hooks/pre-push')
  702. write(legacyHook, '#!/bin/sh\n# legacy pre-push\n', 0o755)
  703. const result = await runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_FAIL: '1' })
  704. expect(result.status).toBe(1)
  705. expect(result.stderr).toContain('exit status 77')
  706. expect(gitResult(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1)
  707. expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
  708. expect(gitResult(fixture, fixture.main, [
  709. 'config', '--worktree', '--get', 'merge.dsh-translation-pairing.name',
  710. ]).status).toBe(1)
  711. expect(gitResult(fixture, fixture.main, [
  712. 'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
  713. ]).status).toBe(1)
  714. expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy pre-push\n')
  715. })
  716. it('does not publish worktree integration when the pairing driver probe fails', async () => {
  717. const fixture = createFixture()
  718. rmSync(join(fixture.main, 'node_modules/tsx'), { recursive: true, force: true })
  719. const result = await runInstaller(fixture, fixture.main)
  720. expect(result.status).toBe(1)
  721. expect(result.stderr).toContain('merge-translation-pairing.ts --probe failed')
  722. expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
  723. expect(gitResult(fixture, fixture.main, [
  724. 'config', '--get', 'merge.dsh-translation-pairing.driver',
  725. ]).status).toBe(1)
  726. })
  727. it('reports installation and hook-path rollback failures together', async () => {
  728. const fixture = createFixture()
  729. const result = await runInstaller(fixture, fixture.main, {
  730. DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG: '1',
  731. DSH_TEST_LEFTHOOK_FAIL: '1',
  732. })
  733. expect(result.status).toBe(1)
  734. expect(result.stderr).toContain('Lefthook installation failed')
  735. expect(result.stderr).toContain('exit status 77')
  736. expect(result.stderr).toContain('worktree integration rollback also failed')
  737. expect(result.stderr).toContain('git config --worktree --unset-all core.hooksPath failed')
  738. expect(result.stderr).toContain('git config --worktree --unset-all merge.dsh-translation-pairing.driver failed')
  739. })
  740. it('refuses an unowned directory at the reserved worktree hook path', async () => {
  741. const fixture = createFixture()
  742. const reservedHook = join(hooksPath(fixture, fixture.main), 'pre-commit')
  743. write(reservedHook, '#!/bin/sh\n# user content\n', 0o755)
  744. const result = await runInstaller(fixture, fixture.main)
  745. expect(result.status).toBe(1)
  746. expect(result.stderr).toContain('refusing to overwrite unowned hooks directory')
  747. expect(readFileSync(reservedHook, 'utf8')).toBe('#!/bin/sh\n# user content\n')
  748. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  749. })
  750. it.skipIf(process.platform === 'win32')('rejects Git without config-scope support before mutation', async () => {
  751. const fixture = createFixture()
  752. const realGit = commandResult('which', ['git'], fixture.main, fixture.env).stdout.trim()
  753. const fakeBin = join(fixture.container, 'fake-bin')
  754. const fakeGit = join(fakeBin, 'git')
  755. write(
  756. fakeGit,
  757. `#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.25.0"; exit 0; fi\nexec "${realGit}" "$@"\n`,
  758. 0o755,
  759. )
  760. const result = await runInstaller(fixture, fixture.main, {
  761. PATH: `${fakeBin}:${fixture.env.PATH ?? ''}`,
  762. })
  763. expect(result.status).toBe(1)
  764. expect(result.stderr).toContain('Git 2.26 or newer is required')
  765. expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
  766. expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
  767. })
  768. })