refactor(dev-infra): narrow worktree hook safety checks
This commit is contained in:
@@ -2,19 +2,17 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { isAbsolute, join, resolve } from 'node:path'
|
||||
|
||||
const MINIMUM_GIT = [2, 26, 0]
|
||||
const HOOKS_DIRECTORY = 'dsh-hooks'
|
||||
const OWNERSHIP_MARKER = '.dsh-lefthook-owned'
|
||||
const LEGACY_OWNERSHIP_MARKER_CONTENT = 'deepseek-harness worktree-local lefthook hooks\n'
|
||||
const OWNERSHIP_MARKER_VERSION = 1
|
||||
const OWNERSHIP_MARKER_OWNER = 'deepseek-harness worktree-local lefthook hooks'
|
||||
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$'
|
||||
const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.'
|
||||
|
||||
function errorCode(error) {
|
||||
@@ -59,20 +57,15 @@ function stripGitLineTerminator(output) {
|
||||
: withoutLineFeed
|
||||
}
|
||||
|
||||
function fileConfigValues(root, configPath, key) {
|
||||
function directFileConfigValues(root, configPath, key) {
|
||||
return nulValues(git(
|
||||
['config', '--file', configPath, '--null', '--get-all', key],
|
||||
['config', '--file', configPath, '--no-includes', '--null', '--get-all', key],
|
||||
root,
|
||||
{ allowStatuses: [1] },
|
||||
))
|
||||
}
|
||||
|
||||
function fileConfigEntries(root, configPath, key) {
|
||||
const fields = nulValues(git(
|
||||
['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-all', key],
|
||||
root,
|
||||
{ allowStatuses: [1] },
|
||||
))
|
||||
function parseFileConfigEntries(fields, key) {
|
||||
if (fields.length % 2 !== 0) {
|
||||
throw new Error(`git config returned invalid file entries for ${key}`)
|
||||
}
|
||||
@@ -83,15 +76,24 @@ function fileConfigEntries(root, configPath, key) {
|
||||
return entries
|
||||
}
|
||||
|
||||
function includedFileConfigEntries(root, configPath, key) {
|
||||
const fields = nulValues(git(
|
||||
['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-all', key],
|
||||
root,
|
||||
{ allowStatuses: [1] },
|
||||
))
|
||||
return parseFileConfigEntries(fields, key)
|
||||
}
|
||||
|
||||
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) {
|
||||
function directFileConfigMatchingEntries(root, configPath, pattern) {
|
||||
const fields = nulValues(git(
|
||||
['config', '--file', configPath, '--includes', '--null', '--show-origin', '--get-regexp', pattern],
|
||||
['config', '--file', configPath, '--no-includes', '--null', '--show-origin', '--get-regexp', pattern],
|
||||
root,
|
||||
{ allowStatuses: [1] },
|
||||
))
|
||||
@@ -105,26 +107,6 @@ function fileConfigMatchingEntries(root, configPath, 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],
|
||||
@@ -153,7 +135,7 @@ function assertSingle(values, key) {
|
||||
|
||||
function worktreeConfigExtensionEnabled(root, commonConfigPath) {
|
||||
const extensionText = assertSingle(
|
||||
fileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'),
|
||||
directFileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'),
|
||||
'extensions.worktreeConfig',
|
||||
)
|
||||
return extensionText === undefined
|
||||
@@ -162,7 +144,7 @@ function worktreeConfigExtensionEnabled(root, commonConfigPath) {
|
||||
}
|
||||
|
||||
function hasDirectConfigEntries(root, configPath) {
|
||||
return git(['config', '--file', configPath, '--null', '--list'], root).stdout !== ''
|
||||
return git(['config', '--file', configPath, '--no-includes', '--null', '--list'], root).stdout !== ''
|
||||
}
|
||||
|
||||
function registeredWorktreeConfigPaths(commonDirectory) {
|
||||
@@ -235,74 +217,8 @@ function assertSupportedGit(root) {
|
||||
}
|
||||
}
|
||||
|
||||
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, rejectRepositoryExtensions) {
|
||||
if (rejectRepositoryExtensions) {
|
||||
const extensionEntry = fileConfigMatchingEntries(root, configPath, REPOSITORY_EXTENSION_PATTERN)[0]
|
||||
if (extensionEntry !== undefined) {
|
||||
return `${extensionEntry.name} (${configSource(extensionEntry)})`
|
||||
}
|
||||
}
|
||||
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 planWorktreeConfigMigration(root, commonConfigPath) {
|
||||
const versions = fileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion')
|
||||
const versions = directFileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion')
|
||||
const versionText = assertSingle(versions, 'core.repositoryFormatVersion')
|
||||
const version = Number(versionText)
|
||||
if (!Number.isInteger(version) || version < 0) {
|
||||
@@ -310,7 +226,7 @@ function planWorktreeConfigMigration(root, commonConfigPath) {
|
||||
}
|
||||
|
||||
if (version === 0) {
|
||||
const extensionEntry = fileConfigMatchingEntries(
|
||||
const extensionEntry = directFileConfigMatchingEntries(
|
||||
root,
|
||||
commonConfigPath,
|
||||
REPOSITORY_EXTENSION_PATTERN,
|
||||
@@ -325,42 +241,26 @@ function planWorktreeConfigMigration(root, commonConfigPath) {
|
||||
}
|
||||
|
||||
const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath)
|
||||
|
||||
if (!extensionEnabled) {
|
||||
for (const entry of fileConfigMatchingEntries(root, commonConfigPath, CONDITIONAL_INCLUDE_PATTERN)) {
|
||||
const risk = conditionalIncludeRisk(
|
||||
root,
|
||||
entry,
|
||||
configPath => migrationConfigSubject(root, configPath, version === 0),
|
||||
)
|
||||
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) {
|
||||
const worktreeText = assertSingle(
|
||||
directFileConfigValues(root, commonConfigPath, 'core.worktree'),
|
||||
'core.worktree',
|
||||
)
|
||||
if (worktreeText !== undefined) {
|
||||
throw new Error(
|
||||
`cannot enable extensions.worktreeConfig while core.worktree is in the common config (${configSource(worktreeEntry)}); `
|
||||
`cannot enable extensions.worktreeConfig while core.worktree is in the common config `
|
||||
+ `(file:${commonConfigPath}: ${JSON.stringify(worktreeText)}); `
|
||||
+ '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) {
|
||||
const directBareText = assertSingle(directFileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare')
|
||||
const directBare = directBareText === undefined ? undefined : parseGitBoolean(directBareText, 'core.bare')
|
||||
if (directBare === true) {
|
||||
throw new Error(
|
||||
`cannot enable extensions.worktreeConfig for a common config with core.bare=true (${configSource(trueBareEntry)})`,
|
||||
`cannot enable extensions.worktreeConfig for a common config with core.bare=true `
|
||||
+ `(file:${commonConfigPath}: ${JSON.stringify(directBareText)})`,
|
||||
)
|
||||
}
|
||||
const directBareText = assertSingle(fileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare')
|
||||
const directBare = directBareText === undefined ? undefined : parseGitBoolean(directBareText, 'core.bare')
|
||||
|
||||
return { directBare, extensionEnabled, version }
|
||||
}
|
||||
@@ -487,8 +387,7 @@ function ownershipMarkerContent(hooksPath) {
|
||||
})}\n`
|
||||
}
|
||||
|
||||
function parseOwnershipMarker(content, hooksPath) {
|
||||
if (content === LEGACY_OWNERSHIP_MARKER_CONTENT) return { hooksPath, legacy: true }
|
||||
function parseOwnershipMarker(content) {
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(content)
|
||||
@@ -505,7 +404,7 @@ function parseOwnershipMarker(content, hooksPath) {
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return { hooksPath: parsed.hooksPath, legacy: false }
|
||||
return { hooksPath: parsed.hooksPath }
|
||||
}
|
||||
|
||||
function inspectOwnedHooksDirectory(hooksPath) {
|
||||
@@ -520,7 +419,7 @@ function inspectOwnedHooksDirectory(hooksPath) {
|
||||
}
|
||||
const markerStat = lstatSync(markerPath)
|
||||
const marker = markerStat.isFile() && !markerStat.isSymbolicLink() && markerStat.nlink === 1
|
||||
? parseOwnershipMarker(readFileSync(markerPath, 'utf8'), hooksPath)
|
||||
? parseOwnershipMarker(readFileSync(markerPath, 'utf8'))
|
||||
: undefined
|
||||
if (marker === undefined) {
|
||||
throw new Error(`refusing to overwrite hooks directory with an invalid ownership marker: ${hooksPath}`)
|
||||
@@ -544,7 +443,7 @@ function ensureOwnedHooksDirectory(hooksPath) {
|
||||
mkdirSync(hooksPath, { mode: 0o700 })
|
||||
const markerPath = join(hooksPath, OWNERSHIP_MARKER)
|
||||
writeFileSync(markerPath, ownershipMarkerContent(hooksPath), { flag: 'wx', mode: 0o600 })
|
||||
return { markerPath, hooksPath, legacy: false }
|
||||
return { markerPath, hooksPath }
|
||||
}
|
||||
|
||||
function updateOwnershipMarker(markerPath, hooksPath) {
|
||||
@@ -597,52 +496,6 @@ function originIsFile(origin, root, configPath) {
|
||||
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 (${configSource(entry)}). `
|
||||
@@ -696,7 +549,7 @@ async function main() {
|
||||
commonConfigPath,
|
||||
worktreeConfigPath,
|
||||
)
|
||||
const worktreeEntries = fileConfigEntries(root, worktreeConfigPath, 'core.hooksPath')
|
||||
const worktreeEntries = includedFileConfigEntries(root, worktreeConfigPath, 'core.hooksPath')
|
||||
const includedWorktreeEntry = worktreeEntries.find(
|
||||
entry => !originIsFile(entry.origin, root, worktreeConfigPath),
|
||||
)
|
||||
@@ -734,8 +587,6 @@ async function main() {
|
||||
}
|
||||
}
|
||||
}
|
||||
assertConditionalHooksPaths(root, worktreeConfigPath)
|
||||
|
||||
const migration = planWorktreeConfigMigration(root, commonConfigPath)
|
||||
ownedHooksDirectory = ensureOwnedHooksDirectory(hooksPath)
|
||||
if (
|
||||
|
||||
@@ -384,6 +384,19 @@ describe('worktree-local Lefthook installer', () => {
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses direct core.worktree before enabling worktree config', async () => {
|
||||
const fixture = createFixture()
|
||||
const commonConfig = join(commonDirectory(fixture), 'config')
|
||||
git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.worktree', fixture.main])
|
||||
|
||||
const result = await runInstaller(fixture, fixture.linked)
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('core.worktree is in the common config')
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')('refuses a symlinked common repository config before writing through it', async () => {
|
||||
const fixture = createFixture()
|
||||
const commonConfig = join(commonDirectory(fixture), 'config')
|
||||
@@ -538,9 +551,9 @@ describe('worktree-local Lefthook installer', () => {
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
|
||||
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', 'extensions.dshunknown']) {
|
||||
for (const includeKey of ['include.path', 'includeIf.onbranch:conditional.path']) {
|
||||
for (const key of ['core.worktree', 'core.bare', 'extensions.dshunknown']) {
|
||||
it(`ignores ${key} loaded through ${includeKey}`, async () => {
|
||||
const fixture = createFixture()
|
||||
const commonConfig = join(commonDirectory(fixture), 'config')
|
||||
const includedConfig = join(fixture.container, `${includeKey.split('.')[0]}-${key.replace('.', '-')}.gitconfig`)
|
||||
@@ -550,25 +563,27 @@ describe('worktree-local Lefthook installer', () => {
|
||||
|
||||
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)
|
||||
}
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(
|
||||
hooksPath(fixture, fixture.linked),
|
||||
)
|
||||
expect(existsSync(join(hooksPath(fixture, fixture.linked), 'pre-commit'))).toBe(true)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('allows a conditional common-config include unrelated to migration or hooks', async () => {
|
||||
it('ignores an inactive global includeIf that provides a hook path for another repository', 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'])
|
||||
const globalConfig = fixture.env.GIT_CONFIG_GLOBAL
|
||||
if (globalConfig === undefined) throw new Error('fixture global config path is missing')
|
||||
const includedConfig = join(fixture.container, 'other-repository.gitconfig')
|
||||
const includedHooks = join(fixture.container, 'other-repository-hooks')
|
||||
git(fixture, fixture.main, ['config', '--file', includedConfig, 'core.hooksPath', includedHooks])
|
||||
git(fixture, fixture.main, [
|
||||
'config',
|
||||
'--file',
|
||||
commonConfig,
|
||||
'includeIf.onbranch:conditional.path',
|
||||
globalConfig,
|
||||
`includeIf.gitdir:${join(fixture.container, 'other')}/.path`,
|
||||
includedConfig,
|
||||
])
|
||||
|
||||
@@ -598,24 +613,6 @@ describe('worktree-local Lefthook installer', () => {
|
||||
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()
|
||||
|
||||
@@ -654,86 +651,6 @@ describe('worktree-local Lefthook installer', () => {
|
||||
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)
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user