install-lefthook.mjs 26 KB

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