install-lefthook.mjs 24 KB

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