install-lefthook.mjs 25 KB

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