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
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
2026-07-27-worktree-local-lefthook.md: 95860efca5309464e82d6d58c8320b3a390ae14f
2026-07-27-worktree-local-lefthook.zh.md: ea1639d2e45c61ba6b041eb5800f2b983a271e4b
2026-07-27-worktree-local-lefthook.md: 2e15f2d242cbcdd56452f3c8ac59bc1e330bc7c0
2026-07-27-worktree-local-lefthook.zh.md: 742d48511f6fd46202ac9c0ead476105d9b97370
@@ -12,13 +12,13 @@ Lefthook-generated hooks prefer an absolute binary path captured from the instal
## Decision
Hook installation is worktree-scoped. The installer requires Git 2.26 or newer for configuration-scope provenance, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. The main worktree receives `$GIT_COMMON_DIR/dsh-hooks`; each linked worktree receives the corresponding directory under `$GIT_COMMON_DIR/worktrees/<id>`. A repository-scoped lock serializes configuration migration and hook writes, including repeated concurrent installs. Each lock records a process ID and random ownership token; release verifies the same file identity and exact record. A dead or invalid lock is never broken automatically, so the diagnostic requires the contributor to confirm no installer is running and remove the lock manually.
Hook installation is worktree-scoped. The installer requires Git 2.26 or newer for configuration-scope provenance, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. Before first enabling the repository-wide extension, it inspects the dormant `config.worktree` file for the main worktree and every registered linked worktree, then refuses any settings whose activation would change the current or a sibling worktree. The main worktree receives `$GIT_COMMON_DIR/dsh-hooks`; each linked worktree receives the corresponding directory under `$GIT_COMMON_DIR/worktrees/<id>`. A repository-scoped lock serializes configuration migration and hook writes, including repeated concurrent installs. Each lock records a process ID and random ownership token; release verifies the same file identity and exact record. A dead or invalid lock is never broken automatically, so the diagnostic requires the contributor to confirm no installer is running and remove the lock manually.
The installer recognizes its hook directory with a private ownership marker and updates it idempotently. It inspects the effective scope, origin, and value of `core.hooksPath`, then refuses an unowned directory, every command-scoped path, and every non-owned worktree-scoped path, including values loaded through `config.worktree` includes. It follows conditional includes with Git's parser and refuses a command- or worktree-scoped include whose target provides, or cannot safely be shown not to provide, a hook path; an inactive condition therefore cannot later hide a user-owned path behind the installer's direct value. The same risk in an inherited system, global, or common-repository include requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which explicitly opts only the current worktree into Lefthook while other worktrees retain the inherited path. Unrelated conditional includes remain valid. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation. This opt-in does not attempt to chain arbitrary hook managers.
Enabling worktree config removes the standard redundant `core.bare=false` value from the common config because false remains Git's default; an explicit `core.worktree` or `core.bare=true`, whether direct or loaded through an active common-config include, is refused for manual migration. Before enabling the extension, the installer follows common-config conditional includes and refuses a target that provides, or cannot safely be shown not to provide, either migration-sensitive key; unrelated conditional includes remain valid. If Lefthook fails during a first install, the installer removes the new worktree override so the prior inherited or common hooks remain active. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer.
[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises main and linked worktrees, removal independence, repeated and concurrent installs, stale and replaced lock ownership, the Git version boundary, migration keys loaded through active and conditional common-config includes, scoped custom-path refusal and opt-in, active and inactive worktree includes, inherited conditional paths, command-environment isolation, legacy common-hook preservation, and failed-install rollback.
[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises main and linked worktrees, removal independence, repeated and concurrent installs, stale and replaced lock ownership, the Git version boundary, dormant sibling-config refusal, migration keys loaded through active and conditional common-config includes, scoped custom-path refusal and opt-in, active and inactive worktree includes, inherited conditional paths, command-environment isolation, legacy common-hook preservation, and failed-install rollback.
## Alternatives considered
@@ -12,13 +12,13 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝
## 决策
钩子安装以 worktree 为作用域。为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/<id>` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。
钩子安装以 worktree 为作用域。为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1,启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。首次启用这一仓库级扩展前,安装程序会检查主 worktree 与每个已注册关联 worktree 中尚未生效的 `config.worktree` 文件,并拒绝任何一经激活就会改变当前或其他 worktree 的设置。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/<id>` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。
安装程序通过私有所有权标记识别其钩子目录,并以幂等方式更新该目录。它会检查 `core.hooksPath` 的生效作用域、来源和值,并拒绝没有所有权标记的目录、所有命令作用域路径,以及所有非本安装程序所有的 worktree 作用域路径,包括通过 `config.worktree` 中的 include 加载的值。安装程序会用 Git 的解析器跟踪 `includeIf`;若命令作用域或 worktree 作用域的目标配置提供钩子路径,或者无法安全证明它不会提供钩子路径,安装程序就会拒绝继续。因此,安装时未生效的条件日后也无法在安装程序的直接配置值之前隐藏用户自有路径。系统配置、全局配置或共用仓库配置中存在相同风险时,必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook,其他 worktree 则继续使用继承路径。与钩子无关的 `includeIf` 仍然有效。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。这项显式选择不会尝试串联任意钩子管理器。
启用 worktree 配置时,安装程序会从共用配置中移除标准但冗余的 `core.bare=false`,因为 false 仍是 Git 的默认值;无论共用配置直接设置了 `core.worktree``core.bare=true`,还是通过当前生效的 include 加载了这些值,安装程序都会拒绝继续并要求手动迁移。启用扩展之前,安装程序会跟踪共用配置中的 `includeIf`;若目标配置提供任一迁移敏感键,或者无法安全证明它不会提供这些键,安装程序就会拒绝继续。与迁移无关的 `includeIf` 仍然有效。若首次安装期间 Lefthook 失败,安装程序会移除新建的 worktree 覆盖,使原有的继承钩子或共用钩子继续生效。worktree 本地安装程序绝不会移除或改写 `$GIT_COMMON_DIR/hooks` 中的旧文件。
[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、陈旧锁与锁所有权被替换、Git 版本边界、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败时的回滚。
[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、陈旧锁与锁所有权被替换、Git 版本边界、拒绝激活其他 worktree 中尚未生效的配置、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子,以及安装失败时的回滚。
## 考虑过的替代方案
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write docs/development.md
development.md: 6c927c46b0a25f84295796354e7f49eb9eb7b2e9
development.zh.md: f29d08df18ca9bead7f4c9bf3cf7f749630b1b84
development.md: 16a0d7210f660d2f9dfe423609913ec6f92a8d75
development.zh.md: 98b9f7ecd74634ef1e09e3f967913a5f6bf44295
+1 -1
View File
@@ -27,7 +27,7 @@ If hooks are missing because dependencies were restored from cache or `postinsta
node scripts/install-lefthook.mjs
```
The wrapper refuses to replace an existing user-owned `core.hooksPath`. If an inherited system, global, or common-repository path should remain active in other worktrees while this worktree opts into lefthook, inspect that path first and rerun with `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths are never overridden and must be integrated or removed explicitly. The same rules apply when a currently inactive conditional include can provide a hook path; unrelated conditional includes remain valid. Before enabling the worktree-config extension, conditional common-config targets that may contain `core.worktree` or `core.bare=true` require manual migration. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command.
The wrapper refuses to replace an existing user-owned `core.hooksPath`. If an inherited system, global, or common-repository path should remain active in other worktrees while this worktree opts into lefthook, inspect that path first and rerun with `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths are never overridden and must be integrated or removed explicitly. The same rules apply when a currently inactive conditional include can provide a hook path; unrelated conditional includes remain valid. Before enabling the worktree-config extension, conditional common-config targets that may contain `core.worktree` or `core.bare=true` require manual migration. A dormant `config.worktree` in any registered worktree also requires inspection and explicit migration or removal before the extension can be enabled without changing that worktree. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command.
Run typecheck once after a fresh clone:
+1 -1
View File
@@ -27,7 +27,7 @@ pnpm install
node scripts/install-lefthook.mjs
```
包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。worktree 配置扩展启用之前,可能包含 `core.worktree``core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。
包装脚本拒绝替换现有且由用户自行管理的 `core.hooksPath`。若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook,请先检查该路径,再设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。worktree 配置扩展启用之前,可能包含 `core.worktree``core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。任一已注册 worktree 中尚未生效的 `config.worktree` 也必须先经过检查并显式迁移或移除,才能在不改变该 worktree 的前提下启用扩展。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。
新克隆后请先运行一次类型检查:
+59 -8
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)
+18
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']) {