install-lefthook.mjs 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740
  1. #!/usr/bin/env node
  2. import { randomUUID } from 'node:crypto'
  3. import {
  4. closeSync,
  5. existsSync,
  6. fstatSync,
  7. lstatSync,
  8. mkdirSync,
  9. openSync,
  10. readdirSync,
  11. readFileSync,
  12. unlinkSync,
  13. writeFileSync,
  14. } from 'node:fs'
  15. import { spawnSync } from 'node:child_process'
  16. import { dirname, isAbsolute, join, resolve } from 'node:path'
  17. import lefthookPackage from 'lefthook/package.json' with { type: 'json' }
  18. const MINIMUM_GIT = [2, 26, 0]
  19. const HOOKS_DIRECTORY = 'dsh-hooks'
  20. const OWNERSHIP_MARKER = '.dsh-lefthook-owned'
  21. const OWNERSHIP_MARKER_VERSION = 1
  22. const OWNERSHIP_MARKER_OWNER = 'deepseek-harness worktree-local lefthook hooks'
  23. const INSTALL_LOCK = 'dsh-lefthook-install.lock'
  24. const INSTALL_LOCK_TIMEOUT_MS = 30_000
  25. const INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS = 1_000
  26. const INSTALL_LOCK_POLL_MS = 50
  27. const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE'
  28. const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.'
  29. function errorCode(error) {
  30. return typeof error === 'object' && error !== null && 'code' in error
  31. ? error.code
  32. : undefined
  33. }
  34. function commandFailure(command, args, result) {
  35. const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : ''
  36. const detail = result.error?.message ?? (stderr || `exit status ${String(result.status)}`)
  37. return new Error(`${command} ${args.join(' ')} failed: ${detail}`)
  38. }
  39. function capture(command, args, options = {}) {
  40. const result = spawnSync(command, args, {
  41. cwd: options.cwd,
  42. encoding: 'utf8',
  43. env: process.env,
  44. })
  45. if (result.status !== 0 && !options.allowStatuses?.includes(result.status)) {
  46. throw commandFailure(command, args, result)
  47. }
  48. return result
  49. }
  50. function git(args, root, options = {}) {
  51. return capture('git', args, { ...options, cwd: root })
  52. }
  53. function nulValues(result) {
  54. if (result.status !== 0) return []
  55. if (result.stdout === '') return ['']
  56. const output = result.stdout.endsWith('\0') ? result.stdout.slice(0, -1) : result.stdout
  57. return output.split('\0')
  58. }
  59. function stripGitLineTerminator(output) {
  60. const withoutLineFeed = output.endsWith('\n') ? output.slice(0, -1) : output
  61. return process.platform === 'win32' && withoutLineFeed.endsWith('\r')
  62. ? withoutLineFeed.slice(0, -1)
  63. : withoutLineFeed
  64. }
  65. function directFileConfigValues(root, configPath, key) {
  66. return nulValues(git(
  67. ['config', '--file', configPath, '--no-includes', '--null', '--get-all', key],
  68. root,
  69. { allowStatuses: [1] },
  70. ))
  71. }
  72. function parseFileConfigEntries(fields, key) {
  73. if (fields.length % 2 !== 0) {
  74. throw new Error(`git config returned invalid file entries for ${key}`)
  75. }
  76. const entries = []
  77. for (let index = 0; index < fields.length; index += 2) {
  78. entries.push({ origin: fields[index], value: fields[index + 1] })
  79. }
  80. return entries
  81. }
  82. function includedFileConfigEntries(root, configPath, key) {
  83. const fields = nulValues(git(
  84. ['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-all', key],
  85. root,
  86. { allowStatuses: [1] },
  87. ))
  88. return parseFileConfigEntries(fields, key)
  89. }
  90. function splitConfigNameValue(field, pattern) {
  91. const separator = field.indexOf('\n')
  92. if (separator < 0) throw new Error(`git config returned an invalid name and value for ${pattern}`)
  93. return { name: field.slice(0, separator), value: field.slice(separator + 1) }
  94. }
  95. function directFileConfigMatchingEntries(root, configPath, pattern) {
  96. const fields = nulValues(git(
  97. ['config', '--file', configPath, '--no-includes', '--null', '--show-origin', '--get-regexp', pattern],
  98. root,
  99. { allowStatuses: [1] },
  100. ))
  101. if (fields.length % 2 !== 0) {
  102. throw new Error(`git config returned invalid matching file entries for ${pattern}`)
  103. }
  104. const entries = []
  105. for (let index = 0; index < fields.length; index += 2) {
  106. entries.push({ origin: fields[index], ...splitConfigNameValue(fields[index + 1], pattern) })
  107. }
  108. return entries
  109. }
  110. function effectiveConfigEntry(root, key) {
  111. const fields = nulValues(git(
  112. ['config', '--null', '--show-scope', '--show-origin', '--get', key],
  113. root,
  114. { allowStatuses: [1] },
  115. ))
  116. if (fields.length === 0) return undefined
  117. if (fields.length !== 3) {
  118. throw new Error(`git config returned an invalid scoped value for ${key}`)
  119. }
  120. const [scope, origin, value] = fields
  121. return { origin, scope, value }
  122. }
  123. function parseGitBoolean(value, key) {
  124. const normalized = value.toLowerCase()
  125. if (normalized === '' || normalized === 'true' || normalized === 'yes' || normalized === 'on' || normalized === '1') return true
  126. if (normalized === 'false' || normalized === 'no' || normalized === 'off' || normalized === '0') return false
  127. throw new Error(`invalid Boolean value for ${key}: ${JSON.stringify(value)}`)
  128. }
  129. function assertSingle(values, key) {
  130. if (values.length > 1) throw new Error(`multiple ${key} values are not supported`)
  131. return values[0]
  132. }
  133. function worktreeConfigExtensionEnabled(root, commonConfigPath) {
  134. const extensionText = assertSingle(
  135. directFileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'),
  136. 'extensions.worktreeConfig',
  137. )
  138. return extensionText === undefined
  139. ? false
  140. : parseGitBoolean(extensionText, 'extensions.worktreeConfig')
  141. }
  142. function hasDirectConfigEntries(root, configPath) {
  143. return git(['config', '--file', configPath, '--no-includes', '--null', '--list'], root).stdout !== ''
  144. }
  145. function registeredWorktreeConfigPaths(commonDirectory) {
  146. const paths = [join(commonDirectory, 'config.worktree')]
  147. const linkedDirectory = join(commonDirectory, 'worktrees')
  148. try {
  149. const entries = readdirSync(linkedDirectory, { withFileTypes: true })
  150. .sort((left, right) => left.name.localeCompare(right.name))
  151. for (const entry of entries) {
  152. paths.push(join(linkedDirectory, entry.name, 'config.worktree'))
  153. }
  154. } catch (error) {
  155. if (errorCode(error) !== 'ENOENT') throw error
  156. }
  157. return paths
  158. }
  159. function lstatIfPresent(path) {
  160. try {
  161. return lstatSync(path)
  162. } catch (error) {
  163. if (errorCode(error) === 'ENOENT') return undefined
  164. throw error
  165. }
  166. }
  167. function assertCommonConfigFile(commonConfigPath) {
  168. const configStat = lstatIfPresent(commonConfigPath)
  169. if (configStat === undefined || !configStat.isFile() || configStat.isSymbolicLink()) {
  170. throw new Error(
  171. `refusing common repository config ${JSON.stringify(commonConfigPath)} because it is not a regular file`,
  172. )
  173. }
  174. }
  175. function assertWorktreeConfigFiles(root, commonDirectory, commonConfigPath, currentConfigPath) {
  176. const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath)
  177. for (const configPath of registeredWorktreeConfigPaths(commonDirectory)) {
  178. const configStat = lstatIfPresent(configPath)
  179. if (configStat === undefined) continue
  180. if (!configStat.isFile() || configStat.isSymbolicLink()) {
  181. const state = extensionEnabled ? 'active' : 'dormant'
  182. throw new Error(
  183. `refusing ${state} worktree config ${JSON.stringify(configPath)} because it is not a regular file; `
  184. + 'replace it with a regular worktree config or remove it before retrying',
  185. )
  186. }
  187. if (extensionEnabled) continue
  188. if (!hasDirectConfigEntries(root, configPath)) continue
  189. const isCurrent = normalizedPath(configPath) === normalizedPath(currentConfigPath)
  190. const owner = isCurrent ? 'current' : 'sibling'
  191. throw new Error(
  192. `cannot enable extensions.worktreeConfig while ${owner} dormant worktree config `
  193. + `${JSON.stringify(configPath)} contains user-owned settings that enabling the extension would activate; `
  194. + 'inspect and migrate those settings, then enable the extension explicitly or remove them before retrying',
  195. )
  196. }
  197. }
  198. function assertSupportedGit(root) {
  199. const version = git(['--version'], root).stdout.trim()
  200. const match = /git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(version)
  201. if (match === null) throw new Error(`cannot determine Git version from ${JSON.stringify(version)}`)
  202. const actual = [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)]
  203. for (let index = 0; index < MINIMUM_GIT.length; index += 1) {
  204. if (actual[index] > MINIMUM_GIT[index]) return
  205. if (actual[index] < MINIMUM_GIT[index]) {
  206. throw new Error(`Git 2.26 or newer is required for worktree-local hooks; found ${version}`)
  207. }
  208. }
  209. }
  210. function planWorktreeConfigMigration(root, commonConfigPath) {
  211. const versions = directFileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion')
  212. const versionText = assertSingle(versions, 'core.repositoryFormatVersion')
  213. const version = Number(versionText)
  214. if (!Number.isInteger(version) || version < 0) {
  215. throw new Error(`unsupported core.repositoryFormatVersion: ${JSON.stringify(versionText)}`)
  216. }
  217. if (version === 0) {
  218. const extensionEntry = directFileConfigMatchingEntries(
  219. root,
  220. commonConfigPath,
  221. REPOSITORY_EXTENSION_PATTERN,
  222. )[0]
  223. if (extensionEntry !== undefined) {
  224. throw new Error(
  225. `cannot upgrade core.repositoryFormatVersion from 0 while dormant repository extension `
  226. + `${extensionEntry.name} is configured (${configSource(extensionEntry)}); `
  227. + 'audit and migrate it, then set repository format 1 explicitly before retrying',
  228. )
  229. }
  230. }
  231. const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath)
  232. const worktreeText = assertSingle(
  233. directFileConfigValues(root, commonConfigPath, 'core.worktree'),
  234. 'core.worktree',
  235. )
  236. if (worktreeText !== undefined) {
  237. throw new Error(
  238. `cannot enable extensions.worktreeConfig while core.worktree is in the common config `
  239. + `(file:${commonConfigPath}: ${JSON.stringify(worktreeText)}); `
  240. + 'move it to the main worktree config first',
  241. )
  242. }
  243. const directBareText = assertSingle(directFileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare')
  244. const directBare = directBareText === undefined ? undefined : parseGitBoolean(directBareText, 'core.bare')
  245. if (directBare === true) {
  246. throw new Error(
  247. `cannot enable extensions.worktreeConfig for a common config with core.bare=true `
  248. + `(file:${commonConfigPath}: ${JSON.stringify(directBareText)})`,
  249. )
  250. }
  251. return { directBare, extensionEnabled, version }
  252. }
  253. function applyWorktreeConfigMigration(root, commonConfigPath, migration) {
  254. const { directBare, extensionEnabled, version } = migration
  255. if (version === 0) {
  256. git(['config', '--file', commonConfigPath, 'core.repositoryFormatVersion', '1'], root)
  257. }
  258. if (!extensionEnabled) {
  259. git(['config', '--file', commonConfigPath, 'extensions.worktreeConfig', 'true'], root)
  260. }
  261. if (directBare === false) {
  262. git(['config', '--file', commonConfigPath, '--unset-all', 'core.bare'], root)
  263. }
  264. }
  265. function readInstallLock(lockPath) {
  266. try {
  267. return readFileSync(lockPath, 'utf8')
  268. } catch (error) {
  269. if (errorCode(error) === 'ENOENT') return undefined
  270. throw error
  271. }
  272. }
  273. function installLockStat(lockPath) {
  274. try {
  275. return lstatSync(lockPath)
  276. } catch (error) {
  277. if (errorCode(error) === 'ENOENT') return undefined
  278. throw error
  279. }
  280. }
  281. function parseInstallLock(record) {
  282. const match = /^([1-9]\d*) ([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\n$/i.exec(record)
  283. if (match === null) return undefined
  284. const owner = Number(match[1])
  285. return Number.isSafeInteger(owner) ? owner : undefined
  286. }
  287. function installLockRecordMayBeIncomplete(record) {
  288. // Exclusive creation exposes the inode before its owner record is fully written.
  289. return record === '' || (!record.endsWith('\n') && /^[1-9]\d*(?: [0-9a-f-]*)?$/i.test(record))
  290. }
  291. function lockOwnerIsAlive(owner) {
  292. try {
  293. process.kill(owner, 0)
  294. return true
  295. } catch (error) {
  296. if (errorCode(error) === 'ESRCH') return false
  297. if (errorCode(error) === 'EPERM') return true
  298. throw error
  299. }
  300. }
  301. function manualLockRecoveryError(lockPath, condition) {
  302. return new Error(
  303. `${condition} Lefthook installer lock ${JSON.stringify(lockPath)}. `
  304. + 'Confirm no Lefthook installer is running, remove it manually, and retry.',
  305. )
  306. }
  307. function lockOwnershipChangedError(lockPath) {
  308. return new Error(`Lefthook installer lock ownership changed for ${lockPath}; refusing to remove it`)
  309. }
  310. function releaseInstallLock(lockPath, ownedRecord, ownedStat) {
  311. const currentStat = installLockStat(lockPath)
  312. if (
  313. currentStat === undefined
  314. || !currentStat.isFile()
  315. || currentStat.isSymbolicLink()
  316. || currentStat.dev !== ownedStat.dev
  317. || currentStat.ino !== ownedStat.ino
  318. || readInstallLock(lockPath) !== ownedRecord
  319. ) {
  320. throw lockOwnershipChangedError(lockPath)
  321. }
  322. try {
  323. unlinkSync(lockPath)
  324. } catch (error) {
  325. if (errorCode(error) === 'ENOENT') {
  326. throw lockOwnershipChangedError(lockPath)
  327. }
  328. throw error
  329. }
  330. }
  331. async function acquireInstallLock(commonDirectory) {
  332. const lockPath = join(commonDirectory, INSTALL_LOCK)
  333. const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS
  334. const ownedRecord = `${String(process.pid)} ${randomUUID()}\n`
  335. let initializingLock
  336. while (true) {
  337. try {
  338. const lockHandle = openSync(lockPath, 'wx', 0o600)
  339. let ownedStat
  340. try {
  341. ownedStat = fstatSync(lockHandle)
  342. const writeDelay = Number(process.env.DSH_TEST_LEFTHOOK_LOCK_WRITE_DELAY_MS ?? 0)
  343. if (writeDelay > 0) {
  344. await new Promise(resolveWait => setTimeout(resolveWait, writeDelay))
  345. }
  346. writeFileSync(lockHandle, ownedRecord)
  347. } finally {
  348. closeSync(lockHandle)
  349. }
  350. const publishedStat = installLockStat(lockPath)
  351. if (
  352. publishedStat === undefined
  353. || !publishedStat.isFile()
  354. || publishedStat.isSymbolicLink()
  355. || publishedStat.dev !== ownedStat.dev
  356. || publishedStat.ino !== ownedStat.ino
  357. ) {
  358. throw lockOwnershipChangedError(lockPath)
  359. }
  360. return () => releaseInstallLock(lockPath, ownedRecord, ownedStat)
  361. } catch (error) {
  362. if (errorCode(error) !== 'EEXIST') throw error
  363. const existingStat = installLockStat(lockPath)
  364. if (existingStat === undefined) continue
  365. if (!existingStat.isFile() || existingStat.isSymbolicLink()) {
  366. throw manualLockRecoveryError(lockPath, 'invalid')
  367. }
  368. const existingRecord = readInstallLock(lockPath)
  369. if (existingRecord === undefined) continue
  370. const verifiedStat = installLockStat(lockPath)
  371. if (verifiedStat === undefined) continue
  372. if (!verifiedStat.isFile() || verifiedStat.isSymbolicLink()) {
  373. throw manualLockRecoveryError(lockPath, 'invalid')
  374. }
  375. if (verifiedStat.dev !== existingStat.dev || verifiedStat.ino !== existingStat.ino) continue
  376. const owner = parseInstallLock(existingRecord)
  377. if (owner === undefined) {
  378. if (!installLockRecordMayBeIncomplete(existingRecord)) {
  379. throw manualLockRecoveryError(lockPath, 'invalid')
  380. }
  381. const now = Date.now()
  382. if (
  383. initializingLock === undefined
  384. || initializingLock.dev !== existingStat.dev
  385. || initializingLock.ino !== existingStat.ino
  386. ) {
  387. initializingLock = {
  388. deadline: now + INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS,
  389. dev: existingStat.dev,
  390. ino: existingStat.ino,
  391. }
  392. }
  393. if (now >= initializingLock.deadline) {
  394. throw manualLockRecoveryError(lockPath, 'invalid')
  395. }
  396. await new Promise(resolveWait => setTimeout(resolveWait, INSTALL_LOCK_POLL_MS))
  397. continue
  398. }
  399. initializingLock = undefined
  400. if (!lockOwnerIsAlive(owner)) throw manualLockRecoveryError(lockPath, 'stale')
  401. if (Date.now() >= deadline) {
  402. throw new Error(`timed out waiting for Lefthook installer lock ${lockPath}`)
  403. }
  404. await new Promise(resolveWait => setTimeout(resolveWait, INSTALL_LOCK_POLL_MS))
  405. }
  406. }
  407. }
  408. function ownershipMarkerContent(hooksPath) {
  409. return `${JSON.stringify({
  410. version: OWNERSHIP_MARKER_VERSION,
  411. owner: OWNERSHIP_MARKER_OWNER,
  412. hooksPath,
  413. })}\n`
  414. }
  415. function parseOwnershipMarker(content) {
  416. let parsed
  417. try {
  418. parsed = JSON.parse(content)
  419. } catch {
  420. return undefined
  421. }
  422. if (
  423. typeof parsed !== 'object'
  424. || parsed === null
  425. || parsed.version !== OWNERSHIP_MARKER_VERSION
  426. || parsed.owner !== OWNERSHIP_MARKER_OWNER
  427. || typeof parsed.hooksPath !== 'string'
  428. || !isAbsolute(parsed.hooksPath)
  429. ) {
  430. return undefined
  431. }
  432. return { hooksPath: parsed.hooksPath }
  433. }
  434. function inspectOwnedHooksDirectory(hooksPath) {
  435. const markerPath = join(hooksPath, OWNERSHIP_MARKER)
  436. if (!existsSync(hooksPath)) return undefined
  437. const hooksStat = lstatSync(hooksPath)
  438. if (!hooksStat.isDirectory() || hooksStat.isSymbolicLink()) {
  439. throw new Error(`refusing to use non-directory or symlinked hooks path ${hooksPath}`)
  440. }
  441. if (!existsSync(markerPath)) {
  442. throw new Error(`refusing to overwrite unowned hooks directory ${hooksPath}`)
  443. }
  444. const markerStat = lstatSync(markerPath)
  445. const marker = markerStat.isFile() && !markerStat.isSymbolicLink() && markerStat.nlink === 1
  446. ? parseOwnershipMarker(readFileSync(markerPath, 'utf8'))
  447. : undefined
  448. if (marker === undefined) {
  449. throw new Error(`refusing to overwrite hooks directory with an invalid ownership marker: ${hooksPath}`)
  450. }
  451. for (const name of readdirSync(hooksPath)) {
  452. if (name === OWNERSHIP_MARKER) continue
  453. const entryPath = join(hooksPath, name)
  454. const entryStat = lstatSync(entryPath)
  455. if (!entryStat.isFile() || entryStat.isSymbolicLink() || entryStat.nlink !== 1) {
  456. throw new Error(
  457. `refusing to overwrite non-regular or multiply linked hook entry ${JSON.stringify(entryPath)}`,
  458. )
  459. }
  460. }
  461. return { markerPath, ...marker }
  462. }
  463. function isRegisteredOwnedHooksPath(commonDirectory, hooksPath) {
  464. const normalizedHooksPath = normalizedPath(hooksPath)
  465. const isRegistered = registeredWorktreeConfigPaths(commonDirectory).some(
  466. configPath => normalizedPath(join(dirname(configPath), HOOKS_DIRECTORY)) === normalizedHooksPath,
  467. )
  468. if (!isRegistered) return false
  469. return inspectOwnedHooksDirectory(hooksPath)?.hooksPath === hooksPath
  470. }
  471. function ensureOwnedHooksDirectory(hooksPath) {
  472. const inspected = inspectOwnedHooksDirectory(hooksPath)
  473. if (inspected !== undefined) return inspected
  474. mkdirSync(hooksPath, { mode: 0o700 })
  475. const markerPath = join(hooksPath, OWNERSHIP_MARKER)
  476. writeFileSync(markerPath, ownershipMarkerContent(hooksPath), { flag: 'wx', mode: 0o600 })
  477. return { markerPath, hooksPath }
  478. }
  479. function updateOwnershipMarker(markerPath, hooksPath) {
  480. writeFileSync(markerPath, ownershipMarkerContent(hooksPath), { mode: 0o600 })
  481. }
  482. function environmentWithoutCommandGitConfig() {
  483. const env = { ...process.env }
  484. for (const key of Object.keys(env)) {
  485. const normalized = key.toUpperCase()
  486. if (
  487. normalized === 'GIT_CONFIG_PARAMETERS'
  488. || normalized === 'GIT_CONFIG_COUNT'
  489. || /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(normalized)
  490. ) {
  491. delete env[key]
  492. }
  493. }
  494. return env
  495. }
  496. function runLefthook(root, lefthook) {
  497. const args = ['install', '--force']
  498. const env = environmentWithoutCommandGitConfig()
  499. // Node refuses to spawn Windows `.cmd` shims directly; the quoted path is
  500. // re-parsed by cmd.exe, while POSIX can execute its extensionless shim.
  501. const result = process.platform === 'win32'
  502. ? spawnSync(`"${lefthook}"`, args, { cwd: root, env, stdio: 'inherit', shell: true })
  503. : spawnSync(lefthook, args, { cwd: root, env, stdio: 'inherit' })
  504. if (result.status !== 0) throw commandFailure(lefthook, args, result)
  505. }
  506. function configSource(entry) {
  507. return `${entry.origin}: ${JSON.stringify(entry.value)}`
  508. }
  509. function normalizedPath(path) {
  510. const normalized = resolve(path)
  511. return process.platform === 'win32' ? normalized.toLowerCase() : normalized
  512. }
  513. function configOriginPath(origin, root) {
  514. if (!origin.startsWith('file:')) return undefined
  515. const originPath = origin.slice('file:'.length)
  516. return isAbsolute(originPath) ? originPath : resolve(root, originPath)
  517. }
  518. function originIsFile(origin, root, configPath) {
  519. const originPath = configOriginPath(origin, root)
  520. return originPath !== undefined && normalizedPath(originPath) === normalizedPath(configPath)
  521. }
  522. function refuseInheritedHooksPath(entry) {
  523. throw new Error(
  524. `refusing to replace user-owned core.hooksPath (${configSource(entry)}). `
  525. + `Chain those hooks through lefthook.yml, or, if this inherited path may remain active only in other worktrees, `
  526. + `rerun with ${ALLOW_HOOKS_PATH_OVERRIDE}=1`,
  527. )
  528. }
  529. function refuseScopedHooksPath(entry) {
  530. if (entry.scope === 'command') {
  531. throw new Error(
  532. `refusing to replace command-scoped core.hooksPath (${configSource(entry)}); `
  533. + `${ALLOW_HOOKS_PATH_OVERRIDE} cannot override transient command configuration`,
  534. )
  535. }
  536. if (entry.scope === 'worktree') {
  537. throw new Error(
  538. `refusing to replace worktree-scoped core.hooksPath (${configSource(entry)}); `
  539. + 'a worktree-specific custom path must be integrated or removed explicitly',
  540. )
  541. }
  542. throw new Error(
  543. `refusing to replace core.hooksPath from unsupported ${entry.scope} scope (${configSource(entry)})`,
  544. )
  545. }
  546. async function main() {
  547. if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return
  548. if (typeof lefthookPackage.bin?.lefthook !== 'string') return
  549. const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' })
  550. if (probe.status !== 0) return
  551. const root = stripGitLineTerminator(probe.stdout)
  552. const isWindows = process.platform === 'win32'
  553. const lefthook = join(root, 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook')
  554. if (!existsSync(lefthook)) return
  555. assertSupportedGit(root)
  556. const gitDirectory = stripGitLineTerminator(git(['rev-parse', '--absolute-git-dir'], root).stdout)
  557. const commonOutput = stripGitLineTerminator(git(['rev-parse', '--git-common-dir'], root).stdout)
  558. const commonDirectory = isAbsolute(commonOutput) ? commonOutput : resolve(root, commonOutput)
  559. const commonConfigPath = join(commonDirectory, 'config')
  560. const worktreeConfigPath = join(gitDirectory, 'config.worktree')
  561. const hooksPath = join(gitDirectory, HOOKS_DIRECTORY)
  562. const releaseLock = await acquireInstallLock(commonDirectory)
  563. let installationError
  564. try {
  565. assertCommonConfigFile(commonConfigPath)
  566. assertWorktreeConfigFiles(
  567. root,
  568. commonDirectory,
  569. commonConfigPath,
  570. worktreeConfigPath,
  571. )
  572. const worktreeEntries = includedFileConfigEntries(root, worktreeConfigPath, 'core.hooksPath')
  573. const includedWorktreeEntry = worktreeEntries.find(
  574. entry => !originIsFile(entry.origin, root, worktreeConfigPath),
  575. )
  576. if (includedWorktreeEntry !== undefined) {
  577. refuseScopedHooksPath({ ...includedWorktreeEntry, scope: 'worktree' })
  578. }
  579. const worktreePath = assertSingle(
  580. worktreeEntries.map(entry => entry.value),
  581. 'worktree core.hooksPath',
  582. )
  583. let ownedHooksDirectory
  584. let copiedWorktreePathIsOwned = false
  585. if (worktreePath !== undefined && worktreePath !== hooksPath) {
  586. ownedHooksDirectory = inspectOwnedHooksDirectory(hooksPath)
  587. const worktreePathIsRelocated = ownedHooksDirectory?.hooksPath === worktreePath
  588. copiedWorktreePathIsOwned = !worktreePathIsRelocated
  589. && isRegisteredOwnedHooksPath(commonDirectory, worktreePath)
  590. if (!worktreePathIsRelocated && !copiedWorktreePathIsOwned) {
  591. refuseScopedHooksPath({ origin: `file:${worktreeConfigPath}`, scope: 'worktree', value: worktreePath })
  592. }
  593. }
  594. const directWorktreePathIsOwned = worktreePath !== undefined
  595. && (
  596. worktreePath === hooksPath
  597. || ownedHooksDirectory?.hooksPath === worktreePath
  598. || copiedWorktreePathIsOwned
  599. )
  600. const effectiveEntry = effectiveConfigEntry(root, 'core.hooksPath')
  601. if (effectiveEntry !== undefined) {
  602. const effectivePathIsOwned = effectiveEntry.scope === 'worktree'
  603. && effectiveEntry.value === worktreePath
  604. && directWorktreePathIsOwned
  605. && originIsFile(effectiveEntry.origin, root, worktreeConfigPath)
  606. if (!effectivePathIsOwned) {
  607. if (effectiveEntry.scope === 'command' || effectiveEntry.scope === 'worktree') {
  608. refuseScopedHooksPath(effectiveEntry)
  609. }
  610. if (!['system', 'global', 'local'].includes(effectiveEntry.scope)) {
  611. refuseScopedHooksPath(effectiveEntry)
  612. }
  613. if (process.env[ALLOW_HOOKS_PATH_OVERRIDE] !== '1') {
  614. refuseInheritedHooksPath(effectiveEntry)
  615. }
  616. }
  617. }
  618. const migration = planWorktreeConfigMigration(root, commonConfigPath)
  619. ownedHooksDirectory = ensureOwnedHooksDirectory(hooksPath)
  620. if (
  621. worktreePath !== undefined
  622. && worktreePath !== hooksPath
  623. && ownedHooksDirectory.hooksPath !== worktreePath
  624. && !copiedWorktreePathIsOwned
  625. ) {
  626. throw new Error(`hooks directory ownership changed while relocating ${JSON.stringify(worktreePath)}`)
  627. }
  628. applyWorktreeConfigMigration(root, commonConfigPath, migration)
  629. let pathChanged = false
  630. try {
  631. git(['config', '--worktree', 'core.hooksPath', hooksPath], root)
  632. pathChanged = worktreePath !== hooksPath
  633. const installedEntry = effectiveConfigEntry(root, 'core.hooksPath')
  634. if (
  635. installedEntry === undefined
  636. || installedEntry.scope !== 'worktree'
  637. || installedEntry.value !== hooksPath
  638. || !originIsFile(installedEntry.origin, root, worktreeConfigPath)
  639. ) {
  640. throw new Error('new worktree-local core.hooksPath did not become the effective direct worktree value')
  641. }
  642. runLefthook(root, lefthook)
  643. updateOwnershipMarker(ownedHooksDirectory.markerPath, hooksPath)
  644. } catch (error) {
  645. if (pathChanged) {
  646. try {
  647. if (worktreePath === undefined) {
  648. git(['config', '--worktree', '--unset-all', 'core.hooksPath'], root)
  649. } else {
  650. git(['config', '--worktree', 'core.hooksPath', worktreePath], root)
  651. }
  652. } catch (rollbackError) {
  653. throw new AggregateError(
  654. [error, rollbackError],
  655. `Lefthook installation failed: ${String(error)}; `
  656. + `worktree hook rollback also failed: ${String(rollbackError)}`,
  657. )
  658. }
  659. }
  660. throw error
  661. }
  662. } catch (error) {
  663. installationError = error
  664. throw error
  665. } finally {
  666. try {
  667. releaseLock()
  668. } catch (releaseError) {
  669. if (installationError !== undefined) {
  670. throw new AggregateError(
  671. [installationError, releaseError],
  672. `Lefthook installation failed: ${String(installationError)}; installer lock release also failed: ${String(releaseError)}`,
  673. )
  674. }
  675. throw releaseError
  676. }
  677. }
  678. }
  679. try {
  680. await main()
  681. } catch (error) {
  682. console.error(`[install-lefthook] ${error instanceof Error ? error.message : String(error)}`)
  683. process.exitCode = 1
  684. }