fix(dev-infra): harden worktree hook migration
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
#!/usr/bin/env node
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
|
||||
const MINIMUM_GIT = [2, 20, 0]
|
||||
const MINIMUM_GIT = [2, 26, 0]
|
||||
const HOOKS_DIRECTORY = 'dsh-hooks'
|
||||
const OWNERSHIP_MARKER = '.dsh-lefthook-owned'
|
||||
const OWNERSHIP_MARKER_CONTENT = 'deepseek-harness worktree-local lefthook hooks\n'
|
||||
@@ -11,6 +12,7 @@ const INSTALL_LOCK = 'dsh-lefthook-install.lock'
|
||||
const INSTALL_LOCK_TIMEOUT_MS = 30_000
|
||||
const INSTALL_LOCK_POLL_MS = 50
|
||||
const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE'
|
||||
const CONDITIONAL_INCLUDE_PATTERN = '^includeif\\..*\\.path$'
|
||||
|
||||
function errorCode(error) {
|
||||
return typeof error === 'object' && error !== null && 'code' in error
|
||||
@@ -47,6 +49,13 @@ function nulValues(result) {
|
||||
return output.split('\0')
|
||||
}
|
||||
|
||||
function stripGitLineTerminator(output) {
|
||||
const withoutLineFeed = output.endsWith('\n') ? output.slice(0, -1) : output
|
||||
return process.platform === 'win32' && withoutLineFeed.endsWith('\r')
|
||||
? withoutLineFeed.slice(0, -1)
|
||||
: withoutLineFeed
|
||||
}
|
||||
|
||||
function fileConfigValues(root, configPath, key) {
|
||||
return nulValues(git(
|
||||
['config', '--file', configPath, '--null', '--get-all', key],
|
||||
@@ -55,14 +64,76 @@ function fileConfigValues(root, configPath, key) {
|
||||
))
|
||||
}
|
||||
|
||||
function effectiveConfigValue(root, key) {
|
||||
const values = nulValues(git(
|
||||
['config', '--null', '--get', key],
|
||||
function fileConfigEntries(root, configPath, key) {
|
||||
const fields = nulValues(git(
|
||||
['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-all', key],
|
||||
root,
|
||||
{ allowStatuses: [1] },
|
||||
))
|
||||
if (values.length > 1) throw new Error(`git config returned multiple effective values for ${key}`)
|
||||
return values[0]
|
||||
if (fields.length % 2 !== 0) {
|
||||
throw new Error(`git config returned invalid file entries for ${key}`)
|
||||
}
|
||||
const entries = []
|
||||
for (let index = 0; index < fields.length; index += 2) {
|
||||
entries.push({ origin: fields[index], value: fields[index + 1] })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function splitConfigNameValue(field, pattern) {
|
||||
const separator = field.indexOf('\n')
|
||||
if (separator < 0) throw new Error(`git config returned an invalid name and value for ${pattern}`)
|
||||
return { name: field.slice(0, separator), value: field.slice(separator + 1) }
|
||||
}
|
||||
|
||||
function fileConfigMatchingEntries(root, configPath, pattern) {
|
||||
const fields = nulValues(git(
|
||||
['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-regexp', pattern],
|
||||
root,
|
||||
{ allowStatuses: [1] },
|
||||
))
|
||||
if (fields.length % 2 !== 0) {
|
||||
throw new Error(`git config returned invalid matching file entries for ${pattern}`)
|
||||
}
|
||||
const entries = []
|
||||
for (let index = 0; index < fields.length; index += 2) {
|
||||
entries.push({ origin: fields[index], ...splitConfigNameValue(fields[index + 1], pattern) })
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function scopedConfigMatchingEntries(root, pattern) {
|
||||
const fields = nulValues(git(
|
||||
['config', '--includes', '--null', '--show-scope', '--show-origin', '--get-regexp', pattern],
|
||||
root,
|
||||
{ allowStatuses: [1] },
|
||||
))
|
||||
if (fields.length % 3 !== 0) {
|
||||
throw new Error(`git config returned invalid scoped entries for ${pattern}`)
|
||||
}
|
||||
const entries = []
|
||||
for (let index = 0; index < fields.length; index += 3) {
|
||||
entries.push({
|
||||
scope: fields[index],
|
||||
origin: fields[index + 1],
|
||||
...splitConfigNameValue(fields[index + 2], pattern),
|
||||
})
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
function effectiveConfigEntry(root, key) {
|
||||
const fields = nulValues(git(
|
||||
['config', '--null', '--show-scope', '--show-origin', '--get', key],
|
||||
root,
|
||||
{ allowStatuses: [1] },
|
||||
))
|
||||
if (fields.length === 0) return undefined
|
||||
if (fields.length !== 3) {
|
||||
throw new Error(`git config returned an invalid scoped value for ${key}`)
|
||||
}
|
||||
const [scope, origin, value] = fields
|
||||
return { origin, scope, value }
|
||||
}
|
||||
|
||||
function parseGitBoolean(value, key) {
|
||||
@@ -85,11 +156,71 @@ function assertSupportedGit(root) {
|
||||
for (let index = 0; index < MINIMUM_GIT.length; index += 1) {
|
||||
if (actual[index] > MINIMUM_GIT[index]) return
|
||||
if (actual[index] < MINIMUM_GIT[index]) {
|
||||
throw new Error(`Git 2.20 or newer is required for worktree-local hooks; found ${version}`)
|
||||
throw new Error(`Git 2.26 or newer is required for worktree-local hooks; found ${version}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function conditionalIncludeTarget(entry, root) {
|
||||
if (isAbsolute(entry.value)) return entry.value
|
||||
const sourcePath = configOriginPath(entry.origin, root)
|
||||
if (sourcePath === undefined) return undefined
|
||||
if (entry.value.startsWith('~/')) {
|
||||
const home = process.env.HOME
|
||||
return home === undefined ? undefined : resolve(home, entry.value.slice(2))
|
||||
}
|
||||
if (entry.value.startsWith('~') || entry.value.startsWith('%(')) return undefined
|
||||
return resolve(dirname(sourcePath), entry.value)
|
||||
}
|
||||
|
||||
function inspectConditionalConfig(root, configPath, inspect, seen = new Set()) {
|
||||
const identity = normalizedPath(configPath)
|
||||
if (seen.has(identity)) return undefined
|
||||
seen.add(identity)
|
||||
if (!existsSync(configPath)) {
|
||||
return { configPath, detail: 'the included config does not exist and cannot be inspected' }
|
||||
}
|
||||
try {
|
||||
const subject = inspect(configPath)
|
||||
if (subject !== undefined) return { configPath, subject }
|
||||
for (const entry of fileConfigMatchingEntries(root, configPath, CONDITIONAL_INCLUDE_PATTERN)) {
|
||||
const target = conditionalIncludeTarget(entry, root)
|
||||
if (target === undefined) {
|
||||
return { configPath, detail: `the nested include path ${JSON.stringify(entry.value)} cannot be resolved safely` }
|
||||
}
|
||||
const nested = inspectConditionalConfig(root, target, inspect, seen)
|
||||
if (nested !== undefined) return nested
|
||||
}
|
||||
return undefined
|
||||
} catch (error) {
|
||||
return {
|
||||
configPath,
|
||||
detail: `the included config could not be inspected: ${error instanceof Error ? error.message : String(error)}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function conditionalIncludeRisk(root, entry, inspect) {
|
||||
const target = conditionalIncludeTarget(entry, root)
|
||||
if (target === undefined) {
|
||||
return { detail: `the include path ${JSON.stringify(entry.value)} cannot be resolved safely` }
|
||||
}
|
||||
return inspectConditionalConfig(root, target, inspect)
|
||||
}
|
||||
|
||||
function migrationConfigSubject(root, configPath) {
|
||||
const worktreeEntry = fileConfigEntries(root, configPath, 'core.worktree')[0]
|
||||
if (worktreeEntry !== undefined) return `core.worktree (${configSource(worktreeEntry)})`
|
||||
const trueBareEntry = fileConfigEntries(root, configPath, 'core.bare')
|
||||
.find(entry => parseGitBoolean(entry.value, 'core.bare'))
|
||||
return trueBareEntry === undefined ? undefined : `core.bare=true (${configSource(trueBareEntry)})`
|
||||
}
|
||||
|
||||
function hooksPathConfigSubject(root, configPath) {
|
||||
const entry = fileConfigEntries(root, configPath, 'core.hooksPath')[0]
|
||||
return entry === undefined ? undefined : `core.hooksPath (${configSource(entry)})`
|
||||
}
|
||||
|
||||
function ensureWorktreeConfig(root, commonConfigPath) {
|
||||
const versions = fileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion')
|
||||
const versionText = assertSingle(versions, 'core.repositoryFormatVersion')
|
||||
@@ -98,17 +229,6 @@ function ensureWorktreeConfig(root, commonConfigPath) {
|
||||
throw new Error(`unsupported core.repositoryFormatVersion: ${JSON.stringify(versionText)}`)
|
||||
}
|
||||
|
||||
const worktrees = fileConfigValues(root, commonConfigPath, 'core.worktree')
|
||||
if (worktrees.length > 0) {
|
||||
throw new Error('cannot enable extensions.worktreeConfig while core.worktree is in the common config; move it to the main worktree config first')
|
||||
}
|
||||
|
||||
const bareText = assertSingle(fileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare')
|
||||
const bare = bareText === undefined ? undefined : parseGitBoolean(bareText, 'core.bare')
|
||||
if (bare === true) {
|
||||
throw new Error('cannot enable extensions.worktreeConfig for a common config with core.bare=true')
|
||||
}
|
||||
|
||||
const extensionText = assertSingle(
|
||||
fileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'),
|
||||
'extensions.worktreeConfig',
|
||||
@@ -117,26 +237,79 @@ function ensureWorktreeConfig(root, commonConfigPath) {
|
||||
? false
|
||||
: parseGitBoolean(extensionText, 'extensions.worktreeConfig')
|
||||
|
||||
if (!extensionEnabled) {
|
||||
for (const entry of fileConfigMatchingEntries(root, commonConfigPath, CONDITIONAL_INCLUDE_PATTERN)) {
|
||||
const risk = conditionalIncludeRisk(
|
||||
root,
|
||||
entry,
|
||||
configPath => migrationConfigSubject(root, configPath),
|
||||
)
|
||||
if (risk !== undefined) {
|
||||
const reason = risk.subject ?? risk.detail
|
||||
throw new Error(
|
||||
`cannot enable extensions.worktreeConfig while common conditional include `
|
||||
+ `${entry.origin}: ${entry.name}=${JSON.stringify(entry.value)} may provide migration-sensitive config (${reason}); `
|
||||
+ 'audit and migrate it, then enable the extension explicitly',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const worktreeEntry = fileConfigEntries(root, commonConfigPath, 'core.worktree')[0]
|
||||
if (worktreeEntry !== undefined) {
|
||||
throw new Error(
|
||||
`cannot enable extensions.worktreeConfig while core.worktree is in the common config (${configSource(worktreeEntry)}); `
|
||||
+ 'move it to the main worktree config first',
|
||||
)
|
||||
}
|
||||
|
||||
const bareEntries = fileConfigEntries(root, commonConfigPath, 'core.bare')
|
||||
const trueBareEntry = bareEntries.find(entry => parseGitBoolean(entry.value, 'core.bare'))
|
||||
if (trueBareEntry !== undefined) {
|
||||
throw new Error(
|
||||
`cannot enable extensions.worktreeConfig for a common config with core.bare=true (${configSource(trueBareEntry)})`,
|
||||
)
|
||||
}
|
||||
const directBareText = assertSingle(fileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare')
|
||||
const directBare = directBareText === undefined ? undefined : parseGitBoolean(directBareText, 'core.bare')
|
||||
|
||||
if (version === 0) {
|
||||
git(['config', '--file', commonConfigPath, 'core.repositoryFormatVersion', '1'], root)
|
||||
}
|
||||
if (!extensionEnabled) {
|
||||
git(['config', '--file', commonConfigPath, 'extensions.worktreeConfig', 'true'], root)
|
||||
}
|
||||
if (bare === false) {
|
||||
if (directBare === false) {
|
||||
git(['config', '--file', commonConfigPath, '--unset-all', 'core.bare'], root)
|
||||
}
|
||||
}
|
||||
|
||||
function lockOwnerIsAlive(lockPath) {
|
||||
let owner
|
||||
function readInstallLock(lockPath) {
|
||||
try {
|
||||
owner = Number(readFileSync(lockPath, 'utf8').trim())
|
||||
return readFileSync(lockPath, 'utf8')
|
||||
} catch (error) {
|
||||
if (errorCode(error) === 'ENOENT') return false
|
||||
if (errorCode(error) === 'ENOENT') return undefined
|
||||
throw error
|
||||
}
|
||||
if (!Number.isSafeInteger(owner) || owner <= 0) return true
|
||||
}
|
||||
|
||||
function installLockStat(lockPath) {
|
||||
try {
|
||||
return lstatSync(lockPath)
|
||||
} catch (error) {
|
||||
if (errorCode(error) === 'ENOENT') return undefined
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function parseInstallLock(record) {
|
||||
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)
|
||||
if (match === null) return undefined
|
||||
const owner = Number(match[1])
|
||||
return Number.isSafeInteger(owner) ? owner : undefined
|
||||
}
|
||||
|
||||
function lockOwnerIsAlive(owner) {
|
||||
try {
|
||||
process.kill(owner, 0)
|
||||
return true
|
||||
@@ -147,28 +320,63 @@ function lockOwnerIsAlive(lockPath) {
|
||||
}
|
||||
}
|
||||
|
||||
function removeStaleLock(lockPath) {
|
||||
function manualLockRecoveryError(lockPath, condition) {
|
||||
return new Error(
|
||||
`${condition} Lefthook installer lock ${JSON.stringify(lockPath)}. `
|
||||
+ 'Confirm no Lefthook installer is running, remove it manually, and retry.',
|
||||
)
|
||||
}
|
||||
|
||||
function lockOwnershipChangedError(lockPath) {
|
||||
return new Error(`Lefthook installer lock ownership changed for ${lockPath}; refusing to remove it`)
|
||||
}
|
||||
|
||||
function releaseInstallLock(lockPath, ownedRecord, ownedStat) {
|
||||
const currentStat = installLockStat(lockPath)
|
||||
if (
|
||||
currentStat === undefined
|
||||
|| !currentStat.isFile()
|
||||
|| currentStat.isSymbolicLink()
|
||||
|| currentStat.dev !== ownedStat.dev
|
||||
|| currentStat.ino !== ownedStat.ino
|
||||
|| readInstallLock(lockPath) !== ownedRecord
|
||||
) {
|
||||
throw lockOwnershipChangedError(lockPath)
|
||||
}
|
||||
try {
|
||||
unlinkSync(lockPath)
|
||||
} catch (error) {
|
||||
if (errorCode(error) !== 'ENOENT') throw error
|
||||
// Another waiting installer removed the same stale lock first.
|
||||
if (errorCode(error) === 'ENOENT') {
|
||||
throw lockOwnershipChangedError(lockPath)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireInstallLock(commonDirectory) {
|
||||
const lockPath = join(commonDirectory, INSTALL_LOCK)
|
||||
const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS
|
||||
const ownedRecord = `${String(process.pid)} ${randomUUID()}\n`
|
||||
while (true) {
|
||||
try {
|
||||
writeFileSync(lockPath, `${String(process.pid)}\n`, { flag: 'wx', mode: 0o600 })
|
||||
return () => removeStaleLock(lockPath)
|
||||
writeFileSync(lockPath, ownedRecord, { flag: 'wx', mode: 0o600 })
|
||||
const ownedStat = installLockStat(lockPath)
|
||||
if (ownedStat === undefined || !ownedStat.isFile() || ownedStat.isSymbolicLink()) {
|
||||
throw lockOwnershipChangedError(lockPath)
|
||||
}
|
||||
return () => releaseInstallLock(lockPath, ownedRecord, ownedStat)
|
||||
} catch (error) {
|
||||
if (errorCode(error) !== 'EEXIST') throw error
|
||||
if (!lockOwnerIsAlive(lockPath)) {
|
||||
removeStaleLock(lockPath)
|
||||
continue
|
||||
const existingStat = installLockStat(lockPath)
|
||||
if (existingStat === undefined) continue
|
||||
if (!existingStat.isFile() || existingStat.isSymbolicLink()) {
|
||||
throw manualLockRecoveryError(lockPath, 'invalid')
|
||||
}
|
||||
const existingRecord = readInstallLock(lockPath)
|
||||
if (existingRecord === undefined) continue
|
||||
const owner = parseInstallLock(existingRecord)
|
||||
if (owner === undefined) throw manualLockRecoveryError(lockPath, 'invalid')
|
||||
if (!lockOwnerIsAlive(owner)) throw manualLockRecoveryError(lockPath, 'stale')
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`timed out waiting for Lefthook installer lock ${lockPath}`)
|
||||
}
|
||||
@@ -197,63 +405,176 @@ function ensureOwnedHooksDirectory(hooksPath) {
|
||||
}
|
||||
}
|
||||
|
||||
function environmentWithoutCommandGitConfig() {
|
||||
const env = { ...process.env }
|
||||
for (const key of Object.keys(env)) {
|
||||
const normalized = key.toUpperCase()
|
||||
if (
|
||||
normalized === 'GIT_CONFIG_PARAMETERS'
|
||||
|| normalized === 'GIT_CONFIG_COUNT'
|
||||
|| /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/.test(normalized)
|
||||
) {
|
||||
delete env[key]
|
||||
}
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
function runLefthook(root, lefthook) {
|
||||
const args = ['install', '--force']
|
||||
const env = environmentWithoutCommandGitConfig()
|
||||
// Node refuses to spawn Windows `.cmd` shims directly; the quoted path is
|
||||
// re-parsed by cmd.exe, while POSIX can execute its extensionless shim.
|
||||
const result = process.platform === 'win32'
|
||||
? spawnSync(`"${lefthook}"`, args, { cwd: root, stdio: 'inherit', shell: true })
|
||||
: spawnSync(lefthook, args, { cwd: root, stdio: 'inherit' })
|
||||
? spawnSync(`"${lefthook}"`, args, { cwd: root, env, stdio: 'inherit', shell: true })
|
||||
: spawnSync(lefthook, args, { cwd: root, env, stdio: 'inherit' })
|
||||
if (result.status !== 0) throw commandFailure(lefthook, args, result)
|
||||
}
|
||||
|
||||
function refuseCustomHooksPath(root, hooksPath) {
|
||||
const origin = git(
|
||||
['config', '--show-origin', '--get', 'core.hooksPath'],
|
||||
root,
|
||||
{ allowStatuses: [1] },
|
||||
).stdout.trim()
|
||||
const source = origin === '' ? hooksPath : origin
|
||||
function configSource(entry) {
|
||||
return `${entry.origin}: ${JSON.stringify(entry.value)}`
|
||||
}
|
||||
|
||||
function normalizedPath(path) {
|
||||
const normalized = resolve(path)
|
||||
return process.platform === 'win32' ? normalized.toLowerCase() : normalized
|
||||
}
|
||||
|
||||
function configOriginPath(origin, root) {
|
||||
if (!origin.startsWith('file:')) return undefined
|
||||
const originPath = origin.slice('file:'.length)
|
||||
return isAbsolute(originPath) ? originPath : resolve(root, originPath)
|
||||
}
|
||||
|
||||
function originIsFile(origin, root, configPath) {
|
||||
const originPath = configOriginPath(origin, root)
|
||||
return originPath !== undefined && normalizedPath(originPath) === normalizedPath(configPath)
|
||||
}
|
||||
|
||||
function conditionalIncludeSource(entry) {
|
||||
return `${entry.origin}: ${entry.name}=${JSON.stringify(entry.value)}`
|
||||
}
|
||||
|
||||
function conditionalIncludes(root, worktreeConfigPath) {
|
||||
const entries = scopedConfigMatchingEntries(root, CONDITIONAL_INCLUDE_PATTERN)
|
||||
entries.push(...fileConfigMatchingEntries(root, worktreeConfigPath, CONDITIONAL_INCLUDE_PATTERN)
|
||||
.map(entry => ({ ...entry, scope: 'worktree' })))
|
||||
const unique = new Map()
|
||||
for (const entry of entries) {
|
||||
unique.set(`${entry.scope}\0${entry.origin}\0${entry.name}\0${entry.value}`, entry)
|
||||
}
|
||||
return [...unique.values()]
|
||||
}
|
||||
|
||||
function assertConditionalHooksPaths(root, worktreeConfigPath) {
|
||||
for (const entry of conditionalIncludes(root, worktreeConfigPath)) {
|
||||
const risk = conditionalIncludeRisk(
|
||||
root,
|
||||
entry,
|
||||
configPath => hooksPathConfigSubject(root, configPath),
|
||||
)
|
||||
if (risk === undefined) continue
|
||||
const reason = risk.subject ?? risk.detail
|
||||
if (entry.scope === 'command' || entry.scope === 'worktree') {
|
||||
throw new Error(
|
||||
`refusing ${entry.scope}-scoped conditional include ${conditionalIncludeSource(entry)}; `
|
||||
+ `it may provide a user-owned core.hooksPath (${reason}) and cannot be overridden`,
|
||||
)
|
||||
}
|
||||
if (!['system', 'global', 'local'].includes(entry.scope)) {
|
||||
throw new Error(
|
||||
`refusing conditional include from unsupported ${entry.scope} scope ${conditionalIncludeSource(entry)}; `
|
||||
+ `it may provide core.hooksPath (${reason})`,
|
||||
)
|
||||
}
|
||||
if (process.env[ALLOW_HOOKS_PATH_OVERRIDE] !== '1') {
|
||||
throw new Error(
|
||||
`refusing to replace core.hooksPath that may be provided by inherited conditional include `
|
||||
+ `${conditionalIncludeSource(entry)} (${reason}). Inspect that include and rerun with `
|
||||
+ `${ALLOW_HOOKS_PATH_OVERRIDE}=1 only if it may remain active in other worktrees`,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function refuseInheritedHooksPath(entry) {
|
||||
throw new Error(
|
||||
`refusing to replace user-owned core.hooksPath (${source}). `
|
||||
`refusing to replace user-owned core.hooksPath (${configSource(entry)}). `
|
||||
+ `Chain those hooks through lefthook.yml, or, if this inherited path may remain active only in other worktrees, `
|
||||
+ `rerun with ${ALLOW_HOOKS_PATH_OVERRIDE}=1`,
|
||||
)
|
||||
}
|
||||
|
||||
function refuseScopedHooksPath(entry) {
|
||||
if (entry.scope === 'command') {
|
||||
throw new Error(
|
||||
`refusing to replace command-scoped core.hooksPath (${configSource(entry)}); `
|
||||
+ `${ALLOW_HOOKS_PATH_OVERRIDE} cannot override transient command configuration`,
|
||||
)
|
||||
}
|
||||
if (entry.scope === 'worktree') {
|
||||
throw new Error(
|
||||
`refusing to replace worktree-scoped core.hooksPath (${configSource(entry)}); `
|
||||
+ 'a worktree-specific custom path must be integrated or removed explicitly',
|
||||
)
|
||||
}
|
||||
throw new Error(
|
||||
`refusing to replace core.hooksPath from unsupported ${entry.scope} scope (${configSource(entry)})`,
|
||||
)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' })
|
||||
if (probe.status !== 0) return
|
||||
const root = probe.stdout.trim()
|
||||
const root = stripGitLineTerminator(probe.stdout)
|
||||
const isWindows = process.platform === 'win32'
|
||||
const lefthook = join(root, 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook')
|
||||
if (!existsSync(lefthook)) return
|
||||
|
||||
assertSupportedGit(root)
|
||||
const gitDirectory = git(['rev-parse', '--absolute-git-dir'], root).stdout.trim()
|
||||
const commonOutput = git(['rev-parse', '--git-common-dir'], root).stdout.trim()
|
||||
const gitDirectory = stripGitLineTerminator(git(['rev-parse', '--absolute-git-dir'], root).stdout)
|
||||
const commonOutput = stripGitLineTerminator(git(['rev-parse', '--git-common-dir'], root).stdout)
|
||||
const commonDirectory = isAbsolute(commonOutput) ? commonOutput : resolve(root, commonOutput)
|
||||
const commonConfigPath = join(commonDirectory, 'config')
|
||||
const worktreeConfigPath = join(gitDirectory, 'config.worktree')
|
||||
const hooksPath = join(gitDirectory, HOOKS_DIRECTORY)
|
||||
const releaseLock = await acquireInstallLock(commonDirectory)
|
||||
let installationError
|
||||
|
||||
try {
|
||||
const worktreeEntries = fileConfigEntries(root, worktreeConfigPath, 'core.hooksPath')
|
||||
const includedWorktreeEntry = worktreeEntries.find(
|
||||
entry => !originIsFile(entry.origin, root, worktreeConfigPath),
|
||||
)
|
||||
if (includedWorktreeEntry !== undefined) {
|
||||
refuseScopedHooksPath({ ...includedWorktreeEntry, scope: 'worktree' })
|
||||
}
|
||||
const worktreePath = assertSingle(
|
||||
fileConfigValues(root, worktreeConfigPath, 'core.hooksPath'),
|
||||
worktreeEntries.map(entry => entry.value),
|
||||
'worktree core.hooksPath',
|
||||
)
|
||||
if (worktreePath !== undefined && worktreePath !== hooksPath) refuseCustomHooksPath(root, worktreePath)
|
||||
|
||||
const effectivePath = effectiveConfigValue(root, 'core.hooksPath')
|
||||
const effectivePathIsOwned = effectivePath === hooksPath && worktreePath === hooksPath
|
||||
if (
|
||||
effectivePath !== undefined
|
||||
&& !effectivePathIsOwned
|
||||
&& process.env[ALLOW_HOOKS_PATH_OVERRIDE] !== '1'
|
||||
) {
|
||||
refuseCustomHooksPath(root, effectivePath)
|
||||
if (worktreePath !== undefined && worktreePath !== hooksPath) {
|
||||
refuseScopedHooksPath({ origin: `file:${worktreeConfigPath}`, scope: 'worktree', value: worktreePath })
|
||||
}
|
||||
const effectiveEntry = effectiveConfigEntry(root, 'core.hooksPath')
|
||||
if (effectiveEntry !== undefined) {
|
||||
const effectivePathIsOwned = effectiveEntry.scope === 'worktree'
|
||||
&& effectiveEntry.value === hooksPath
|
||||
&& worktreePath === hooksPath
|
||||
&& originIsFile(effectiveEntry.origin, root, worktreeConfigPath)
|
||||
if (!effectivePathIsOwned) {
|
||||
if (effectiveEntry.scope === 'command' || effectiveEntry.scope === 'worktree') {
|
||||
refuseScopedHooksPath(effectiveEntry)
|
||||
}
|
||||
if (!['system', 'global', 'local'].includes(effectiveEntry.scope)) {
|
||||
refuseScopedHooksPath(effectiveEntry)
|
||||
}
|
||||
if (process.env[ALLOW_HOOKS_PATH_OVERRIDE] !== '1') {
|
||||
refuseInheritedHooksPath(effectiveEntry)
|
||||
}
|
||||
}
|
||||
}
|
||||
assertConditionalHooksPaths(root, worktreeConfigPath)
|
||||
|
||||
ensureOwnedHooksDirectory(hooksPath)
|
||||
ensureWorktreeConfig(root, commonConfigPath)
|
||||
@@ -262,6 +583,15 @@ async function main() {
|
||||
try {
|
||||
git(['config', '--worktree', 'core.hooksPath', hooksPath], root)
|
||||
pathChanged = worktreePath === undefined
|
||||
const installedEntry = effectiveConfigEntry(root, 'core.hooksPath')
|
||||
if (
|
||||
installedEntry === undefined
|
||||
|| installedEntry.scope !== 'worktree'
|
||||
|| installedEntry.value !== hooksPath
|
||||
|| !originIsFile(installedEntry.origin, root, worktreeConfigPath)
|
||||
) {
|
||||
throw new Error('new worktree-local core.hooksPath did not become the effective direct worktree value')
|
||||
}
|
||||
runLefthook(root, lefthook)
|
||||
} catch (error) {
|
||||
if (pathChanged) {
|
||||
@@ -269,8 +599,21 @@ async function main() {
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} catch (error) {
|
||||
installationError = error
|
||||
throw error
|
||||
} finally {
|
||||
releaseLock()
|
||||
try {
|
||||
releaseLock()
|
||||
} catch (releaseError) {
|
||||
if (installationError !== undefined) {
|
||||
throw new AggregateError(
|
||||
[installationError, releaseError],
|
||||
`Lefthook installation failed: ${String(installationError)}; installer lock release also failed: ${String(releaseError)}`,
|
||||
)
|
||||
}
|
||||
throw releaseError
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,7 +62,17 @@ import { execFileSync } from 'node:child_process'
|
||||
import { join } from 'node:path'
|
||||
|
||||
if (process.argv.slice(2).join(' ') !== 'install --force') process.exit(64)
|
||||
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()
|
||||
const rootOutput = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' })
|
||||
const root = rootOutput.endsWith('\\n') ? rootOutput.slice(0, -1) : rootOutput
|
||||
const forbiddenConfigKey = process.env.DSH_TEST_FORBIDDEN_GIT_CONFIG_KEY
|
||||
if (forbiddenConfigKey !== undefined) {
|
||||
try {
|
||||
execFileSync('git', ['config', '--get', forbiddenConfigKey], { encoding: 'utf8' })
|
||||
process.exit(92)
|
||||
} catch (error) {
|
||||
if (error === null || typeof error !== 'object' || !('status' in error) || error.status !== 1) throw error
|
||||
}
|
||||
}
|
||||
const hooksPath = execFileSync('git', ['config', '--get', 'core.hooksPath'], { encoding: 'utf8' }).trim()
|
||||
mkdirSync(hooksPath, { recursive: true })
|
||||
const running = join(hooksPath, '.fake-lefthook-running')
|
||||
@@ -101,11 +111,11 @@ function installFakeLefthook(root: string): void {
|
||||
chmodSync(shim, 0o755)
|
||||
}
|
||||
|
||||
function createFixture(): Fixture {
|
||||
function createFixture(names: { main?: string; linked?: string } = {}): Fixture {
|
||||
const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-'))
|
||||
fixtures.push(container)
|
||||
const main = join(container, 'main')
|
||||
const linked = join(container, 'linked')
|
||||
const main = join(container, names.main ?? 'main')
|
||||
const linked = join(container, names.linked ?? 'linked')
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
GIT_AUTHOR_EMAIL: 'hooks@example.test',
|
||||
@@ -144,6 +154,18 @@ function hooksPath(fixture: Fixture, root: string): string {
|
||||
return join(gitDirectory(fixture, root), 'dsh-hooks')
|
||||
}
|
||||
|
||||
function installLockPath(fixture: Fixture): string {
|
||||
return join(commonDirectory(fixture), 'dsh-lefthook-install.lock')
|
||||
}
|
||||
|
||||
async function waitForPath(path: string): Promise<void> {
|
||||
const deadline = Date.now() + 5_000
|
||||
while (!existsSync(path)) {
|
||||
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`)
|
||||
await new Promise(resolveWait => setTimeout(resolveWait, 10))
|
||||
}
|
||||
}
|
||||
|
||||
function runInstaller(
|
||||
fixture: Fixture,
|
||||
root: string,
|
||||
@@ -226,6 +248,69 @@ describe('worktree-local Lefthook installer', () => {
|
||||
expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves stale installer locks for explicit recovery', async () => {
|
||||
const fixture = createFixture()
|
||||
const lockPath = installLockPath(fixture)
|
||||
const completed = spawnSync(process.execPath, ['-e', ''])
|
||||
expect(completed.status).toBe(0)
|
||||
const staleRecord = `${String(completed.pid)} 00000000-0000-4000-8000-000000000000\n`
|
||||
writeFileSync(lockPath, staleRecord)
|
||||
|
||||
const results = await Promise.all(Array.from(
|
||||
{ length: 4 },
|
||||
() => runInstaller(fixture, fixture.main),
|
||||
))
|
||||
|
||||
for (const result of results) {
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('stale Lefthook installer lock')
|
||||
expect(result.stderr).toContain('remove it manually')
|
||||
}
|
||||
expect(readFileSync(lockPath, 'utf8')).toBe(staleRecord)
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
|
||||
})
|
||||
|
||||
it('leaves invalid installer locks for explicit recovery', async () => {
|
||||
const fixture = createFixture()
|
||||
const lockPath = installLockPath(fixture)
|
||||
const invalidRecord = 'not an installer lock\n'
|
||||
writeFileSync(lockPath, invalidRecord)
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('invalid Lefthook installer lock')
|
||||
expect(result.stderr).toContain('remove it manually')
|
||||
expect(readFileSync(lockPath, 'utf8')).toBe(invalidRecord)
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not release an installer lock whose ownership changed', async () => {
|
||||
const fixture = createFixture()
|
||||
const lockPath = installLockPath(fixture)
|
||||
const runningPath = join(hooksPath(fixture, fixture.main), '.fake-lefthook-running')
|
||||
const install = runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_DELAY_MS: '250' })
|
||||
await waitForPath(runningPath)
|
||||
const replacementRecord = 'replacement owner\n'
|
||||
writeFileSync(lockPath, replacementRecord)
|
||||
|
||||
const result = await install
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('installer lock ownership changed')
|
||||
expect(readFileSync(lockPath, 'utf8')).toBe(replacementRecord)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('preserves trailing spaces in worktree paths', async () => {
|
||||
const fixture = createFixture({ main: 'main ', linked: 'linked ' })
|
||||
|
||||
for (const root of [fixture.main, fixture.linked]) {
|
||||
const result = await runInstaller(fixture, root)
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(git(fixture, root, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, root))
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves user-owned hook paths unless an inherited value is explicitly overridden', async () => {
|
||||
const fixture = createFixture()
|
||||
const customHook = join(fixture.main, 'custom-hooks/pre-commit')
|
||||
@@ -257,6 +342,202 @@ describe('worktree-local Lefthook installer', () => {
|
||||
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks')
|
||||
})
|
||||
|
||||
it('refuses migration keys loaded through active or conditional common-config includes', async () => {
|
||||
for (const includeKey of ['include.path', 'includeIf.onbranch:conditional.path']) {
|
||||
for (const key of ['core.worktree', 'core.bare']) {
|
||||
const fixture = createFixture()
|
||||
const commonConfig = join(commonDirectory(fixture), 'config')
|
||||
const includedConfig = join(fixture.container, `${includeKey.split('.')[0]}-${key.replace('.', '-')}.gitconfig`)
|
||||
const value = key === 'core.worktree' ? fixture.main : 'true'
|
||||
git(fixture, fixture.main, ['config', '--file', includedConfig, key, value])
|
||||
git(fixture, fixture.main, ['config', '--file', commonConfig, includeKey, includedConfig])
|
||||
|
||||
const result = await runInstaller(fixture, fixture.linked)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain(key)
|
||||
expect(result.stderr).toContain(includedConfig)
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
|
||||
expect(existsSync(join(hooksPath(fixture, fixture.linked), 'pre-commit'))).toBe(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('allows a conditional common-config include unrelated to migration or hooks', async () => {
|
||||
const fixture = createFixture()
|
||||
const commonConfig = join(commonDirectory(fixture), 'config')
|
||||
const includedConfig = join(fixture.container, 'conditional-identity.gitconfig')
|
||||
git(fixture, fixture.main, ['config', '--file', includedConfig, 'user.email', 'conditional@example.test'])
|
||||
git(fixture, fixture.main, [
|
||||
'config',
|
||||
'--file',
|
||||
commonConfig,
|
||||
'includeIf.onbranch:conditional.path',
|
||||
includedConfig,
|
||||
])
|
||||
|
||||
const result = await runInstaller(fixture, fixture.linked)
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.linked))
|
||||
})
|
||||
|
||||
it('never overrides a command-scoped hook path', async () => {
|
||||
const fixture = createFixture()
|
||||
const commandHooks = join(fixture.container, 'command-hooks')
|
||||
const sentinel = join(commandHooks, 'pre-commit')
|
||||
write(sentinel, '#!/bin/sh\n# command-scope sentinel\n', 0o755)
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main, {
|
||||
DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: 'core.hooksPath',
|
||||
GIT_CONFIG_VALUE_0: commandHooks,
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('command-scoped core.hooksPath')
|
||||
expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# command-scope sentinel\n')
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
|
||||
it('never overrides a hook path behind a command-scoped conditional include', async () => {
|
||||
const fixture = createFixture()
|
||||
const includedConfig = join(fixture.container, 'command-conditional.gitconfig')
|
||||
const includedHooks = join(fixture.container, 'command-conditional-hooks')
|
||||
git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks])
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main, {
|
||||
DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: 'includeIf.onbranch:conditional.path',
|
||||
GIT_CONFIG_VALUE_0: includedConfig,
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('command-scoped conditional include')
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
|
||||
it('does not pass unrelated command-scoped Git config to Lefthook', async () => {
|
||||
const fixture = createFixture()
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main, {
|
||||
DSH_TEST_FORBIDDEN_GIT_CONFIG_KEY: 'dsh.testSentinel',
|
||||
GIT_CONFIG_COUNT: '1',
|
||||
GIT_CONFIG_KEY_0: 'dsh.testSentinel',
|
||||
GIT_CONFIG_VALUE_0: 'must-not-reach-lefthook',
|
||||
})
|
||||
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(existsSync(join(hooksPath(fixture, fixture.main), 'pre-commit'))).toBe(true)
|
||||
})
|
||||
|
||||
it('never overrides a hook path included by worktree config', async () => {
|
||||
const fixture = createFixture()
|
||||
const commonConfig = join(commonDirectory(fixture), 'config')
|
||||
const worktreeConfig = join(gitDirectory(fixture, fixture.main), 'config.worktree')
|
||||
const includedConfig = join(fixture.container, 'included-worktree.gitconfig')
|
||||
const includedHooks = join(fixture.container, 'included-hooks')
|
||||
const sentinel = join(includedHooks, 'pre-commit')
|
||||
write(sentinel, '#!/bin/sh\n# included-worktree sentinel\n', 0o755)
|
||||
git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks])
|
||||
git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1'])
|
||||
git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true'])
|
||||
git(fixture, fixture.main, ['config', '--file', worktreeConfig, 'include.path', includedConfig])
|
||||
|
||||
const result = await runInstaller(fixture, fixture.main, {
|
||||
DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('worktree-scoped core.hooksPath')
|
||||
expect(git(fixture, fixture.main, ['config', '--get', 'core.hooksPath'])).toBe(includedHooks)
|
||||
expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# included-worktree sentinel\n')
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses an inactive conditional worktree include that can later provide a hook path', async () => {
|
||||
const fixture = createFixture()
|
||||
const commonConfig = join(commonDirectory(fixture), 'config')
|
||||
const worktreeConfig = join(gitDirectory(fixture, fixture.linked), 'config.worktree')
|
||||
const includedConfig = join(fixture.container, 'conditional-worktree.gitconfig')
|
||||
const includedHooks = join(fixture.container, 'conditional-hooks')
|
||||
const sentinel = join(includedHooks, 'pre-commit')
|
||||
write(sentinel, '#!/bin/sh\n# conditional-worktree sentinel\n', 0o755)
|
||||
git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks])
|
||||
git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1'])
|
||||
git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true'])
|
||||
git(fixture, fixture.main, [
|
||||
'config',
|
||||
'--file',
|
||||
worktreeConfig,
|
||||
'includeIf.onbranch:conditional.path',
|
||||
includedConfig,
|
||||
])
|
||||
|
||||
const result = await runInstaller(fixture, fixture.linked)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('worktree-scoped conditional include')
|
||||
expect(result.stderr).toContain('includeif.onbranch:conditional.path')
|
||||
expect(gitResult(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1)
|
||||
expect(existsSync(hooksPath(fixture, fixture.linked))).toBe(false)
|
||||
|
||||
git(fixture, fixture.linked, ['switch', '-c', 'conditional'])
|
||||
expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(includedHooks)
|
||||
expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# conditional-worktree sentinel\n')
|
||||
})
|
||||
|
||||
it('requires opt-in for inherited conditional includes that can later provide a hook path', async () => {
|
||||
for (const scope of ['local', 'global']) {
|
||||
const fixture = createFixture()
|
||||
const commonConfig = join(commonDirectory(fixture), 'config')
|
||||
const conditionalOwner = scope === 'local'
|
||||
? commonConfig
|
||||
: fixture.env.GIT_CONFIG_GLOBAL
|
||||
if (conditionalOwner === undefined) throw new Error('fixture global config path is missing')
|
||||
const includedConfig = join(fixture.container, `${scope}-conditional.gitconfig`)
|
||||
const includedHooks = join(fixture.container, `${scope}-conditional-hooks`)
|
||||
git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks])
|
||||
git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1'])
|
||||
git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true'])
|
||||
git(fixture, fixture.main, [
|
||||
'config',
|
||||
'--file',
|
||||
conditionalOwner,
|
||||
'includeIf.onbranch:conditional.path',
|
||||
includedConfig,
|
||||
])
|
||||
|
||||
const refused = await runInstaller(fixture, fixture.linked)
|
||||
|
||||
expect(refused.status).toBe(1)
|
||||
expect(refused.stderr).toContain('inherited conditional include')
|
||||
expect(refused.stderr).toContain('DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1')
|
||||
expect(gitResult(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1)
|
||||
|
||||
const optedIn = await runInstaller(fixture, fixture.linked, {
|
||||
DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
|
||||
})
|
||||
expect(optedIn.status, optedIn.stderr).toBe(0)
|
||||
|
||||
git(fixture, fixture.linked, ['switch', '-c', 'conditional'])
|
||||
expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.linked))
|
||||
|
||||
const repeatedRefusal = await runInstaller(fixture, fixture.linked)
|
||||
expect(repeatedRefusal.status).toBe(1)
|
||||
expect(repeatedRefusal.stderr).toContain('inherited conditional include')
|
||||
expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.linked))
|
||||
|
||||
const repeatedOptIn = await runInstaller(fixture, fixture.linked, {
|
||||
DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
|
||||
})
|
||||
expect(repeatedOptIn.status, repeatedOptIn.stderr).toBe(0)
|
||||
}
|
||||
})
|
||||
|
||||
it('restores the previous hook lookup when Lefthook installation fails', async () => {
|
||||
const fixture = createFixture()
|
||||
const common = commonDirectory(fixture)
|
||||
@@ -283,14 +564,14 @@ describe('worktree-local Lefthook installer', () => {
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('rejects Git without worktree-config support before mutation', async () => {
|
||||
it.skipIf(process.platform === 'win32')('rejects Git without config-scope support before mutation', async () => {
|
||||
const fixture = createFixture()
|
||||
const realGit = commandResult('which', ['git'], fixture.main, fixture.env).stdout.trim()
|
||||
const fakeBin = join(fixture.container, 'fake-bin')
|
||||
const fakeGit = join(fakeBin, 'git')
|
||||
write(
|
||||
fakeGit,
|
||||
`#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.19.0"; exit 0; fi\nexec "${realGit}" "$@"\n`,
|
||||
`#!/bin/sh\nif [ "$1" = "--version" ]; then echo "git version 2.25.0"; exit 0; fi\nexec "${realGit}" "$@"\n`,
|
||||
0o755,
|
||||
)
|
||||
|
||||
@@ -298,7 +579,7 @@ describe('worktree-local Lefthook installer', () => {
|
||||
PATH: `${fakeBin}:${fixture.env.PATH ?? ''}`,
|
||||
})
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('Git 2.20 or newer is required')
|
||||
expect(result.stderr).toContain('Git 2.26 or newer is required')
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user