install-lefthook.mjs 30 KB

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