fix(dev-infra): guard dormant worktree configs

This commit is contained in:
Tianyi Cui
2026-07-27 21:31:12 +08:00
parent 6914b04107
commit 19b8794966
8 changed files with 87 additions and 18 deletions

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env node
import { randomUUID } from 'node:crypto'
import { existsSync, lstatSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
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'
@@ -148,6 +148,57 @@ function assertSingle(values, key) {
return values[0]
}
function worktreeConfigExtensionEnabled(root, commonConfigPath) {
const extensionText = assertSingle(
fileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'),
'extensions.worktreeConfig',
)
return extensionText === undefined
? false
: parseGitBoolean(extensionText, 'extensions.worktreeConfig')
}
function hasDirectConfigEntries(root, configPath) {
return git(['config', '--file', configPath, '--null', '--list'], root).stdout !== ''
}
function registeredWorktreeConfigPaths(commonDirectory) {
const paths = [join(commonDirectory, 'config.worktree')]
const linkedDirectory = join(commonDirectory, 'worktrees')
try {
const entries = readdirSync(linkedDirectory, { withFileTypes: true })
.sort((left, right) => left.name.localeCompare(right.name))
for (const entry of entries) {
paths.push(join(linkedDirectory, entry.name, 'config.worktree'))
}
} catch (error) {
if (errorCode(error) !== 'ENOENT') throw error
}
return paths
}
function assertDormantWorktreeConfigs(root, commonDirectory, commonConfigPath, currentConfigPath) {
if (worktreeConfigExtensionEnabled(root, commonConfigPath)) return
for (const configPath of registeredWorktreeConfigPaths(commonDirectory)) {
if (!existsSync(configPath)) continue
const configStat = lstatSync(configPath)
if (!configStat.isFile() || configStat.isSymbolicLink()) {
throw new Error(
`cannot enable extensions.worktreeConfig while dormant worktree config ${JSON.stringify(configPath)} `
+ 'is not a regular file; inspect it and enable the extension explicitly, or remove it, before retrying',
)
}
if (!hasDirectConfigEntries(root, configPath)) continue
const isCurrent = normalizedPath(configPath) === normalizedPath(currentConfigPath)
const owner = isCurrent ? 'current' : 'sibling'
throw new Error(
`cannot enable extensions.worktreeConfig while ${owner} dormant worktree config `
+ `${JSON.stringify(configPath)} contains user-owned settings that enabling the extension would activate; `
+ 'inspect and migrate those settings, then enable the extension explicitly or remove them before retrying',
)
}
}
function assertSupportedGit(root) {
const version = git(['--version'], root).stdout.trim()
const match = /git version (\d+)\.(\d+)(?:\.(\d+))?/.exec(version)
@@ -229,13 +280,7 @@ function ensureWorktreeConfig(root, commonConfigPath) {
throw new Error(`unsupported core.repositoryFormatVersion: ${JSON.stringify(versionText)}`)
}
const extensionText = assertSingle(
fileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'),
'extensions.worktreeConfig',
)
const extensionEnabled = extensionText === undefined
? false
: parseGitBoolean(extensionText, 'extensions.worktreeConfig')
const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath)
if (!extensionEnabled) {
for (const entry of fileConfigMatchingEntries(root, commonConfigPath, CONDITIONAL_INCLUDE_PATTERN)) {
@@ -576,6 +621,12 @@ async function main() {
}
assertConditionalHooksPaths(root, worktreeConfigPath)
assertDormantWorktreeConfigs(
root,
commonDirectory,
commonConfigPath,
worktreeConfigPath,
)
ensureOwnedHooksDirectory(hooksPath)
ensureWorktreeConfig(root, commonConfigPath)

View File

@@ -342,6 +342,24 @@ describe('worktree-local Lefthook installer', () => {
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe('linked-custom-hooks')
})
it('refuses to activate a sibling worktree dormant hook path', async () => {
const fixture = createFixture()
const linkedConfig = join(gitDirectory(fixture, fixture.linked), 'config.worktree')
const linkedHooks = join(fixture.linked, 'custom-hooks')
git(fixture, fixture.main, ['config', '--file', linkedConfig, 'core.hooksPath', linkedHooks])
expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1)
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('sibling dormant worktree config')
expect(result.stderr).toContain(linkedConfig)
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
expect(gitResult(fixture, fixture.linked, ['config', '--get', 'core.hooksPath']).status).toBe(1)
expect(git(fixture, fixture.main, ['config', '--file', linkedConfig, '--get', 'core.hooksPath'])).toBe(linkedHooks)
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']) {