refactor(dev-infra): narrow worktree hook safety checks

This commit is contained in:
Tianyi Cui
2026-07-28 00:05:31 +08:00
parent 95bba22f84
commit c982cf7805
9 changed files with 93 additions and 317 deletions

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 .agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md
2026-07-27-worktree-local-lefthook.md: 9d5e5583d6d8e3f15433f8b1a6f26f7b2c8cd854
2026-07-27-worktree-local-lefthook.zh.md: dfca287ae3bbfc1414322d89da9cd93f78230d3c
2026-07-27-worktree-local-lefthook.md: d18f6c1bf8fe240759ad48f67ca6b231000eaf2c
2026-07-27-worktree-local-lefthook.zh.md: 42a1625a3b2ec7b00942dc46b0c9c64058ecd2fc

View File

@@ -12,13 +12,13 @@ Lefthook-generated hooks prefer an absolute binary path captured from the instal
## Decision
Hook installation is worktree-scoped. With `CI=true` or `GITHUB_ACTIONS=true`, the installer returns before Git discovery or mutation because automated jobs do not consume contributor hooks. Otherwise, it 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 the format bump, it refuses every existing `extensions.*` key in the common config or a conditional target because format 1 would activate that dormant repository extension. Before first enabling the worktree-config extension, it inspects the `config.worktree` file for the main worktree and every registered linked worktree and refuses dormant settings whose activation would change the current or a sibling worktree. The common repository config and every active or dormant worktree config must be regular files. 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. With `CI=true` or `GITHUB_ACTIONS=true`, the installer returns before Git discovery or mutation because automated jobs do not consume contributor hooks. Otherwise, it 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 installer recognizes its hook directory with a private ownership marker and updates it idempotently. The marker records the absolute path last published to worktree config, so moving a checkout permits the installer to replace that exact stale owned value with the moved `$GIT_DIR/dsh-hooks` path and regenerate hooks; any other worktree-scoped value remains user-owned and is refused. Before invoking Lefthook, the marker and every existing generated hook must be an unaliased regular file, preventing a symlink or additional hard link from redirecting an overwrite outside the owned directory. 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.
Before upgrading format 0, the installer refuses direct common-config `extensions.*`; it also refuses direct `core.worktree` or `core.bare=true` and non-empty dormant worktree configs that enabling the extension would activate. The migration removes direct `core.bare=false` because false is Git's default. The common repository config and every existing `config.worktree` must be regular files. These checks disable include expansion because Git's repository-format parser also ignores included targets. A repository-scoped lock serializes migration and hook writes; its process ID, random token, file identity, and exact contents must still match at release. Dead or invalid locks require manual recovery rather than automatic breaking.
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. If that rollback also fails, one diagnostic preserves both failures for manual recovery. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer.
Each hook directory carries a JSON ownership marker containing the absolute path last published to worktree config. After a checkout moves, that marker permits replacement of only the exact stale owned value. Before Lefthook runs, the marker and every existing generated hook must be unaliased regular files. The installer resolves the effective scope, origin, and value of `core.hooksPath`, including active `config.worktree` includes; it refuses command-scoped paths, unowned worktree-scoped paths, and unowned reserved directories. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`, which opts only the current worktree into Lefthook. Inactive `includeIf` targets are not recursively inspected because they do not affect the current configuration. Command-scoped Git configuration is removed from the Lefthook subprocess environment after validation.
[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises the CI no-op, main and linked worktrees, removal independence, repeated and concurrent installs, checkout relocation, marker and hook alias refusal, stale and replaced lock ownership, the Git version boundary, dormant repository-extension and sibling-config refusal, common and worktree config file ownership, 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 successful and failed rollback after installation failure.
If Lefthook fails after changing `core.hooksPath`, the installer restores the previous worktree value; a rollback failure is reported alongside the installation failure. Existing files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten. Focused installer tests pin isolation, migration refusal, ownership and relocation, concurrent installation, custom paths, and rollback.
## Alternatives considered
@@ -36,6 +36,6 @@ Enabling worktree config removes the standard redundant `core.bare=false` value
Installing or removing one worktree no longer changes another worktree's active hooks, binary path, or generated hook bytes. Concurrent installs are serialized and repeated installation is idempotent, while the jobs and latency boundary owned by [Fast local Git hooks](2026-07-22-fast-local-git-hooks.md) stay unchanged.
The repository becomes a Git format-1 repository after the first installation and rejects clients older than Git 2.26. Custom worktree hook managers require an explicit integration choice; inherited hook paths can coexist across other worktrees, but opting the current worktree into Lefthook means those inherited hooks do not run there unless the contributor chains them through `lefthook.yml`.
The repository becomes a Git format-1 repository after the first installation. The installer requires Git 2.26 for `--show-scope`; the worktree-config extension itself predates that command. Custom worktree hook managers require an explicit integration choice; inherited hook paths can coexist across other worktrees, but opting the current worktree into Lefthook means those inherited hooks do not run there unless the contributor chains them through `lefthook.yml`.
Legacy common hooks remain on disk for unupgraded worktrees. They can become stale, but removing them automatically would break a registered worktree whose branch has not adopted this installer.

View File

@@ -12,13 +12,13 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝
## 决策
钩子安装以 worktree 为作用域。当 `CI=true``GITHUB_ACTIONS=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。提升格式版本之前,若共用配置或条件目标中存在任何 `extensions.*` 键,安装程序都会拒绝继续,因为格式 1 会激活这类尚未生效的仓库扩展。首次启用 worktree 配置扩展前,安装程序会检查主 worktree 与每个已注册关联 worktree 中的 `config.worktree` 文件,并拒绝一经激活就会改变当前或其他 worktree 的尚未生效设置。共用仓库配置以及每个生效或尚未生效的 worktree 配置都必须是常规文件。主 worktree 使用 `$GIT_COMMON_DIR/dsh-hooks`;每个关联 worktree 则使用 `$GIT_COMMON_DIR/worktrees/<id>` 下的对应目录。仓库级锁会串行化配置迁移与钩子写入,包括并发触发的重复安装。每个锁都会记录进程 ID 和随机所有权令牌;释放锁时会验证同一个文件身份与完全一致的记录。安装程序绝不会自动破坏所属进程已结束或内容无效的锁,因此诊断会要求贡献者先确认没有安装程序正在运行,再手动移除该锁。
钩子安装以 worktree 为作用域。当 `CI=true``GITHUB_ACTIONS=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。
安装程序通过私有所有权标记识别其钩子目录,并以幂等方式更新该目录。该标记会记录上次写入 worktree 配置的绝对路径,因此检出目录移动后,安装程序可以将这一确切的陈旧自有值替换为移动后的 `$GIT_DIR/dsh-hooks` 路径并重新生成钩子;其他 worktree 作用域值仍视为用户自有并会被拒绝。调用 Lefthook 前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件,以防符号链接或额外硬链接把覆盖操作重定向到自有目录外。安装程序会检查 `core.hooksPath` 的生效作用域、来源和值,并拒绝没有所有权标记的目录、所有命令作用域路径,以及所有非本安装程序所有的 worktree 作用域路径,包括通过 `config.worktree` 中的 include 加载的值。安装程序会用 Git 的解析器跟踪 `includeIf`;若命令作用域或 worktree 作用域的目标配置提供钩子路径,或者无法安全证明它不会提供钩子路径,安装程序就会拒绝继续。因此,安装时未生效的条件日后也无法在安装程序的直接配置值之前隐藏用户自有路径。系统配置、全局配置或共用仓库配置中存在相同风险时,必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook其他 worktree 则继续使用继承路径。与钩子无关的 `includeIf` 仍然有效。完成验证后Lefthook 子进程的环境会移除命令作用域的 Git 配置。这项显式选择不会尝试串联任意钩子管理器
升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree``core.bare=true`,以及启用扩展后将被激活的非空且尚未生效的 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须手动恢复不会被自动破坏
启用 worktree 配置时,安装程序会从共用配置中移除标准但冗余的 `core.bare=false`,因为 false 仍是 Git 的默认值;无论共用配置直接设置了 `core.worktree` `core.bare=true`,还是通过当前生效的 include 加载了这些值,安装程序都会拒绝继续并要求手动迁移。启用扩展之前,安装程序会跟踪共用配置中的 `includeIf`;若目标配置提供任一迁移敏感键,或者无法安全证明它不会提供这些键,安装程序就会拒绝继续。与迁移无关的 `includeIf` 仍然有效。若首次安装期间 Lefthook 失败,安装程序会移除新建的 worktree 覆盖使原有的继承钩子或共用钩子继续生效。若回滚也失败同一条诊断会保留两次失败供手动恢复。worktree 本地安装程序绝不会移除或改写 `$GIT_COMMON_DIR/hooks` 中的旧文件
每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后该标记只允许替换确切的陈旧自有值。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查因为它们不影响当前配置。完成验证后Lefthook 子进程的环境会移除命令作用域的 Git 配置
[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) 覆盖 CI 下不执行操作的行为、主 worktree 和关联 worktree、移除后的相互独立性、重复与并发安装、检出目录移动、拒绝标记和钩子别名、陈旧锁与锁所有权被替换、Git 版本边界、拒绝尚未生效的仓库扩展和其他 worktree 配置、共用及 worktree 配置文件的所有权、通过生效及条件式共用配置 include 加载的迁移键、按作用域拒绝自定义路径与显式覆盖、生效及未生效的 worktree include、继承的条件式路径、命令环境隔离、保留旧公共钩子以及安装失败后成功或失败的回滚
若 Lefthook 在更改 `core.hooksPath` 后失败,安装程序会恢复先前的 worktree 值;若回滚失败,会与安装失败一并报告。`$GIT_COMMON_DIR/hooks` 中的现有文件绝不会被移除或改写。聚焦的安装程序测试固定了隔离、迁移拒绝、所有权和检出目录移动、并发安装、自定义路径及回滚行为
## 考虑过的替代方案
@@ -36,6 +36,6 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝
安装或移除任一 worktree 不再改变其他 worktree 的生效钩子、二进制文件路径或生成的钩子字节。并发安装会串行执行,重复安装保持幂等;[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.md)所规定的任务与延迟边界保持不变。
首次安装后,仓库会采用 Git 格式版本 1,并拒绝版本低于 Git 2.26 的客户端。自定义 worktree 钩子管理器需要明确选择集成方式;继承钩子路径可继续供其他 worktree 使用,但当前 worktree 显式启用 Lefthook 后,其中不会运行这些继承钩子,除非贡献者通过 `lefthook.yml` 将其串联起来。
首次安装后,仓库会采用 Git 格式版本 1。安装程序需要 Git 2.26 来使用 `--show-scope`worktree 配置扩展本身的出现早于该命令。自定义 worktree 钩子管理器需要明确选择集成方式;继承钩子路径可继续供其他 worktree 使用,但当前 worktree 显式启用 Lefthook 后,其中不会运行这些继承钩子,除非贡献者通过 `lefthook.yml` 将其串联起来。
旧的共用钩子会为尚未升级的 worktree 保留在磁盘上。它们可能逐渐陈旧,但自动删除这些钩子会破坏已注册但所在分支尚未采用本安装程序的 worktree。

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: f1a853acfd1e89104b8013a5b5e9c4032979234f
development.zh.md: 493284e38ad68768b1159778d5dd50aecfe9ccd1
development.md: 32339fa2af8c1b6005d9e0b8165d57966a4145ca
development.zh.md: c74a81346639c6f95568cbd86b401d134d5eb7fc

View File

@@ -27,7 +27,11 @@ 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 upgrading a format-0 repository, existing `extensions.*` keys in the common config or a conditional target require manual audit and migration because format 1 activates them. 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. The common repository config and every active or dormant worktree config must be regular files. The owned hook directory may contain only unaliased regular files; replace a reported symlink, hard link, or non-file entry before retrying. After moving the checkout, rerun the wrapper so its ownership marker can replace the exact stale path it installed and regenerate hooks at the new Git directory. If the installer reports a stale or invalid lock, confirm no installer is running, remove the reported lock manually, and rerun the command. If Lefthook installation and automatic hook-path rollback both fail, the diagnostic preserves both failures; inspect the worktree config and remove the new path manually before retrying.
The wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths must be integrated or removed explicitly.
Before enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.
After moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.
Run typecheck once after a fresh clone:

View File

@@ -27,7 +27,11 @@ pnpm install
node scripts/install-lefthook.mjs
```
包装脚本拒绝替换现有且由用户自行管理`core.hooksPath`若要让继承自系统、全局或共用仓库配置的路径在其他 worktree 中继续生效,同时让当前 worktree 显式启用 lefthook请先检查该路径设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` 重新运行;命令作用域和 worktree 作用域的自定义路径绝不会被覆盖,必须显式集成或移除。当前未生效的 `includeIf` 可能提供钩子路径时,同样适用这些规则;与钩子无关的 `includeIf` 仍然有效。升级格式版本为 0 的仓库之前,若共用配置或条件目标中已有 `extensions.*` 键,就需要手动审计和迁移,因为格式 1 会激活这些键。worktree 配置扩展启用之前,可能包含 `core.worktree``core.bare=true` 的共用配置 `includeIf` 目标需要手动迁移。任一已注册 worktree 中尚未生效的 `config.worktree` 也必须先经过检查并显式迁移或移除,才能在不改变该 worktree 的前提下启用扩展。共用仓库配置以及每个生效或尚未生效的 worktree 配置都必须是常规文件。自有钩子目录只能包含不带别名的常规文件;请先替换诊断中报告的符号链接、硬链接或非文件条目,再重试。检出目录移动后,请重新运行包装脚本,使其所有权标记可以替换之前写入的确切陈旧路径,并在新的 Git 目录中重新生成钩子。若安装程序报告陈旧锁或无效锁,请先确认没有安装程序正在运行,手动移除诊断中报告的锁,再重新运行命令。若 Lefthook 安装和钩子路径自动回滚都失败,诊断会保留两次失败;请检查 worktree 配置并手动移除新路径,再重试。
包装层会拒绝用户自有`core.hooksPath`。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`;命令作用域和 worktree 作用域的自定义路径必须显式集成或移除。
启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree``core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。
检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。
新克隆后请先运行一次类型检查:

View File

@@ -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 (

View File

@@ -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