Merge remote-tracking branch 'origin/master' into feat/todo-multi-in-progress

This commit is contained in:
Chinesezjc
2026-07-28 00:42:37 +08:00
19 changed files with 2230 additions and 33 deletions

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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-explicit-change-scope-report.md
2026-07-27-explicit-change-scope-report.md: 2cce567940a142ed1f4699f4dc67322ed69565e9
2026-07-27-explicit-change-scope-report.zh.md: cc08df3ee4f3d681bc4ae8b2b0eab3588dfe1a73

View File

@@ -0,0 +1,39 @@
# Agent Note: Report an explicit repository change scope
Status: implemented
English | [中文](2026-07-27-explicit-change-scope-report.zh.md)
## Problem
The [pre-push workflow](../../../skills/dsh-pre-push-checks/SKILL.md) needs the diff against the actual base, but constructing `origin/<current-branch>` fails for a new worktree branch that tracks `origin/master` before its first push and misstates a stacked branch whose PR targets another feature branch. The [code-review](../../../skills/dsh-code-review/SKILL.md) and [documentation-audit](../../../skills/dsh-doc-standards/SKILL.md) workflows need the same current-base judgment.
An incorrect range undermines evidence selection because it can omit affected paths. A three-dot committed diff also says nothing about Git's separate staged, unstaged, and untracked layers.
## Decision
The root `change-scope` command requires `--base <ref>`, accepts `--head <ref>` with `HEAD` as the default, and offers a versioned `--json` form. It resolves both inputs to commits with ambiguity detection and requires one merge base before writing output. The report records the repository root without normalizing legal path whitespace, current branch, configured upstream, input refs, resolved base, head, and merge-base commit IDs, plus sorted committed, staged, unstaged, and untracked path sets. Path records are split at raw NUL bytes; the repository root, branch, upstream, and every path are decoded as strict UTF-8. An invalid value aborts the report before output instead of substituting characters or collapsing distinct values.
Committed paths compare the resolved merge base with the resolved head. Dirty path sets always describe the current worktree and index, even when `--head` names another commit. Every Git probe disables configured filesystem monitors and optional lock-taking; diff configuration cannot hide submodules or invoke external diff or text-conversion drivers, and rename detection is disabled so both sides of a rename remain visible.
The command never guesses or fetches a base, queries a hosting provider, or selects tests. Each calling workflow verifies current remote or stack state, supplies the base explicitly, and uses the factual report as input to semantic review or evidence selection.
Focused temporary-repository tests cover a fresh branch tracking `origin/master` without a same-name remote, its post-push upstream, a worktree path ending in legal whitespace, a stacked non-master base, every dirty layer, a configured filesystem monitor remaining unexecuted, distinct non-UTF-8 POSIX paths and branch or upstream names failing without partial output, invalid, ambiguous, and non-commit refs, deterministic human/JSON parity, and unchanged refs, index, config, and status after reporting.
## Alternatives considered
**Keep an ad hoc diff command plus a prose fallback.** This avoids a repository script but leaves normal new-worktree and stacked-base topologies inconsistent across workflows, and it omits dirty layers.
**Infer the base from the configured upstream.** An upstream may be `origin/master` before the first push, the same feature branch after a push, or a head branch whose PR targets another feature branch. No inference is correct for every topology.
**Query GitHub for the base inside the command.** This couples a local read-only report to one forge and to network credentials, yet still cannot resolve a branch with no PR.
**Generate required tests from changed paths.** Paths cannot establish behavior reached through configuration, dynamic loading, subprocesses, workers, built artifacts, or providers. Evidence selection remains judgment under the pre-push workflow.
## Consequences
The explicit input makes an incorrect base possible but visible: both input refs and all three resolved commit IDs appear in either output form. Callers pay the small cost of verifying and fetching the live base before running the command.
The string schema deliberately cannot represent non-UTF-8 path bytes. A repository containing them must rename those paths before it can produce a report, preserving exact scope instead of returning a lossy one.
The repository owns one Git-topology helper and focused tests. In return, pre-push selection, code review, and documentation audit share a deterministic, read-only account of committed and local changes without importing forge or policy concerns.

View File

@@ -0,0 +1,39 @@
# Agent Note: 显式报告仓库变更范围
Status: implemented
[English](2026-07-27-explicit-change-scope-report.md) | 中文
## 问题
[pre-push 工作流](../../../skills/dsh-pre-push-checks/SKILL.md)需要取得相对于实际基准的 diff但按 `origin/<current-branch>` 构造引用存在两类问题:对于第一次推送前跟踪 `origin/master`、尚无同名远端分支的新 worktree 分支,该引用无法解析;对于 PRPull Request以另一功能分支为基准的堆叠分支该引用会错误描述基准。[代码评审](../../../skills/dsh-code-review/SKILL.md)与[文档审计](../../../skills/dsh-doc-standards/SKILL.md)工作流同样需要判断当前基准。
错误的范围可能遗漏受影响的路径,从而削弱证据选择。三点范围产生的已提交 diff 也完全无法说明 Git 中彼此独立的已暂存、未暂存与未跟踪层。
## 决策
根目录的 `change-scope` 命令要求提供 `--base <ref>`,接受可选的 `--head <ref>`(默认为 `HEAD`),并提供带版本号的 `--json` 输出格式。该命令会检测歧义,将两个输入解析为 commit并要求二者恰好有一个合并基点之后才会输出结果。报告记录仓库根目录不对路径中的合法空白字符作规范化处理、当前分支、配置的上游、输入引用、解析后的基准、头部与合并基点 commit ID以及排序后的已提交、已暂存、未暂存和未跟踪路径集合。路径记录先按原始 NUL 字节切分;仓库根目录、分支、上游和每条路径都以严格 UTF-8 解码。遇到无效值时,命令会在写出任何结果前失败,不会用替换字符代替无效字节或把不同值合并为一条。
已提交路径由解析后的合并基点与头部之间的比较得出。即使 `--head` 指定其他 commit各类未提交路径集合仍始终描述当前 worktree 与索引。每次 Git 探测都会禁用配置的文件系统监视器和可选加锁diff 配置不能隐藏子模块,也不能调用外部 diff 或文本转换驱动;系统禁用重命名检测,因此重命名前后的路径都会保留在结果中。
该命令从不猜测或获取基准,不查询代码托管提供方,也不选择测试。调用该命令的每个工作流都会验证当前远端或堆叠状态、显式提供基准,并将这份事实报告作为语义评审或证据选择的输入。
聚焦的临时仓库测试覆盖以下情形:新分支跟踪 `origin/master` 但没有同名远端分支;同一分支推送后的上游配置;以合法空白字符结尾的 worktree 路径;堆叠分支以非 master 分支为基准;所有未提交改动层;配置的文件系统监视器不会执行;互异的非 UTF-8 POSIX 路径、分支名或上游名会使报告失败且不产生部分输出;无效、有歧义及不指向 commit 的引用;人类可读输出与 JSON 输出保持确定性一致。测试还确认生成报告前后,引用、索引、配置与状态均不发生变化。
## 考虑过的替代方案
**继续使用临时拼装的 diff 命令,辅以文字化回退说明。** 这种方式无需添加仓库脚本,但不同工作流对常见的新 worktree 与堆叠基准拓扑仍会作出不一致处理,而且无法涵盖未提交改动层。
**根据配置的上游推断基准。** 第一次推送前,上游可能是 `origin/master`;推送后,它可能是同一功能分支;也可能是一个头部分支,而其 PR 以另一功能分支为基准。没有一种推断能够适用于所有拓扑。
**在命令内查询 GitHub 以确定基准。** 这会把本地只读报告绑定到单一代码托管平台与网络凭证,却仍然无法解析尚无 PR 的分支。
**根据变更路径生成必需的测试。** 变更路径无法揭示经由配置、动态加载、子进程、worker、构建产物或提供方触达的行为。pre-push 工作流仍须通过判断来选择证据。
## 结果
显式输入仍可能指定错误的基准,但这种错误是可见的:两种输出格式都会显示输入引用与解析出的三个 commit ID。调用方需要付出少量成本在运行该命令前验证实时基准并从远端获取它。
字符串 schema 有意不表示非 UTF-8 路径字节。含有这类路径的仓库必须先重命名这些路径才能生成报告,以此保持范围精确,而非返回有损结果。
仓库需要维护一个 Git 拓扑辅助工具及相应的聚焦测试。由此pre-push 证据选择、代码评审与文档审计可以共享一份确定且只读的已提交及本地变更说明,而不必混入代码托管平台或策略职责。

View File

@@ -0,0 +1,6 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# 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: d18f6c1bf8fe240759ad48f67ca6b231000eaf2c
2026-07-27-worktree-local-lefthook.zh.md: 42a1625a3b2ec7b00942dc46b0c9c64058ecd2fc

View File

@@ -0,0 +1,41 @@
# Agent Note: Make Lefthook installation worktree-local
Status: implemented
English | [中文](2026-07-27-worktree-local-lefthook.zh.md)
## Problem
Every `pnpm install` runs the root [`postinstall`](../../../../package.json), whose [`install-lefthook.mjs`](../../../../scripts/install-lefthook.mjs) invokes `lefthook install --force`. Linked Git worktrees otherwise share the common repository's default hooks directory, so an install in any worktree can rewrite hooks used by every other worktree.
Lefthook-generated hooks prefer an absolute binary path captured from the installing worktree before trying their current-worktree fallback. Shared hooks can therefore run another worktree's pinned binary until that worktree disappears, while concurrent installs write the same files.
## 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 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.
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.
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
**Keep the shared generated hooks and rely on their current-worktree fallback.** The captured absolute path wins while its worktree exists, so the fallback does not provide version or lifecycle isolation.
**Point every worktree at one checked-in `.githooks` directory.** A relative tracked directory removes generated absolute paths, but changing the shared `core.hooksPath` can disable hooks in older worktrees whose branches do not contain that directory and still couples every worktree to one shared configuration value.
**Build a general hook-manager chaining layer.** Ordering, argument forwarding, failure semantics, and upgrades become repository-owned behavior unrelated to Lefthook isolation. The installer instead refuses worktree-specific custom paths and makes the narrower inherited-path override explicit.
**Whitelist provider-specific CI credential-include paths.** Contributor hooks are unused in CI, so path exemptions would couple installer safety to provider checkout internals and weaken strict validation for contributor installs. The CI no-op avoids repository mutation without any exemptions.
**Stop installing hooks automatically.** Manual setup avoids shared writes but makes the repository's cheap commit and push checks optional by accident, especially in short-lived agent worktrees.
## Consequences
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. 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

@@ -0,0 +1,41 @@
# Agent Note: 让 Lefthook 安装限定于各 worktree
Status: implemented
[English](2026-07-27-worktree-local-lefthook.md) | 中文
## 问题
每次运行 `pnpm install` 都会执行根目录的 [`postinstall`](../../../../package.json),其中的 [`install-lefthook.mjs`](../../../../scripts/install-lefthook.mjs) 会调用 `lefthook install --force`。若无额外配置,关联的 Git worktree 共用同一仓库的默认钩子目录,因此在任一 worktree 中安装都可能改写其他所有 worktree 使用的钩子。
Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝对二进制文件路径,之后才尝试当前 worktree 的回退路径。因此,共享钩子会一直运行另一个 worktree 固定版本的二进制文件,直到该 worktree 消失;并发安装还会写入同一组文件。
## 决策
钩子安装以 worktree 为作用域。当 `CI=true``GITHUB_ACTIONS=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,为了获取配置作用域的来源信息,安装程序要求 Git 2.26 或更高版本;它会将格式版本为 0 的仓库升级到格式版本 1启用 `extensions.worktreeConfig`,并将当前 worktree 的 `core.hooksPath` 设为指向 `$GIT_DIR/dsh-hooks` 的绝对路径。
升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree``core.bare=true`,以及启用扩展后将被激活的非空且尚未生效的 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须手动恢复不会被自动破坏。
每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后该标记只允许替换确切的陈旧自有值。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查因为它们不影响当前配置。完成验证后Lefthook 子进程的环境会移除命令作用域的 Git 配置。
若 Lefthook 在更改 `core.hooksPath` 后失败,安装程序会恢复先前的 worktree 值;若回滚失败,会与安装失败一并报告。`$GIT_COMMON_DIR/hooks` 中的现有文件绝不会被移除或改写。聚焦的安装程序测试固定了隔离、迁移拒绝、所有权和检出目录移动、并发安装、自定义路径及回滚行为。
## 考虑过的替代方案
**保留共享的生成钩子,并依赖其当前 worktree 回退路径。** 只要对应 worktree 仍存在,记录的绝对路径就会优先生效,因此回退路径无法提供版本或生命周期隔离。
**让每个 worktree 都指向同一个纳入版本控制的 `.githooks` 目录。** 使用受版本控制的相对目录可以消除生成的绝对路径,但更改共享的 `core.hooksPath` 可能会禁用旧 worktree 中的钩子,因为其分支并不包含该目录;同时,每个 worktree 仍然耦合于同一个共享配置值。
**构建通用的钩子管理器串联层。** 执行顺序、参数转发、失败语义和升级都会成为仓库自行负责的行为,却与 Lefthook 隔离无关。因此,安装程序会拒绝 worktree 专属的自定义路径,只将范围更窄的继承路径覆盖设为显式操作。
**将特定 CI 提供商的凭据 include 路径加入白名单。** CI 不使用贡献者钩子因此路径豁免会使安装程序的安全性耦合于提供商的检出目录内部结构并削弱贡献者安装时的严格验证。CI 无操作方案无需任何豁免即可避免修改仓库。
**停止自动安装钩子。** 手动设置可以避免共享写入,却会使仓库中低成本的提交与推送检查意外变成可选项,短期存在、由 agent智能体使用的 worktree 尤其容易受到影响。
## 后果
安装或移除任一 worktree 不再改变其他 worktree 的生效钩子、二进制文件路径或生成的钩子字节。并发安装会串行执行,重复安装保持幂等;[快速本地 Git 钩子](2026-07-22-fast-local-git-hooks.md)所规定的任务与延迟边界保持不变。
首次安装后,仓库会采用 Git 格式版本 1。安装程序需要 Git 2.26 来使用 `--show-scope`worktree 配置扩展本身的出现早于该命令。自定义 worktree 钩子管理器需要明确选择集成方式;继承钩子路径可继续供其他 worktree 使用,但当前 worktree 显式启用 Lefthook 后,其中不会运行这些继承钩子,除非贡献者通过 `lefthook.yml` 将其串联起来。
旧的共用钩子会为尚未升级的 worktree 保留在磁盘上。它们可能逐渐陈旧,但自动删除这些钩子会破坏已注册但所在分支尚未采用本安装程序的 worktree。

View File

@@ -5,7 +5,7 @@ description: Use when reviewing a pull request in the deepseek-harness repo —
# Reviewing a DeepSeek-Harness PR
**This skill is guidance, not a complete checklist.** Read the diff against the PR's current base and enough surrounding code to understand the design, then verify suspected defects before reporting them. Re-establish that base after a retarget or merge. Prioritize correctness, lifecycle, security, and contract failures over style; a short review with one substantiated blocker is better than a list of nits.
**This skill is guidance, not a complete checklist.** Verify and fetch the PR's live base and exact head, then run `pnpm run change-scope --base <verified-base-ref> --head <verified-head-ref>` before reading the diff and enough surrounding code to understand the design. The report identifies paths and dirty layers but does not replace semantic review. Re-establish the base and rerun it after a retarget or merge. Prioritize correctness, lifecycle, security, and contract failures over style; a short review with one substantiated blocker is better than a list of nits.
## Sources of truth

View File

@@ -26,7 +26,7 @@ Run the placement test in the standard's taxonomy table, then check the constrai
## Auditing the corpus
The audit is a hunt for the standard's slop checklist, cheapest probes first. Establish the PR's current base first; after a retarget or base merge, repeat the audit for prose introduced by the new base rather than relying on the earlier result.
The audit is a hunt for the standard's slop checklist, cheapest probes first. Verify and fetch the PR's live base, then run `pnpm run change-scope --base <verified-base-ref>` to identify committed and dirty paths before applying semantic judgment. After a retarget or base merge, rerun the report and repeat the audit for prose introduced by the new base rather than relying on the earlier result.
1. Measure: `pnpm run verify-doc-budgets --list`, then `git ls-files '*.md' ':(exclude)vendor/**' | xargs wc -w | sort -rn | head -30` to spot unbudgeted outliers.
2. Hunt narrated history: `rg -n "no longer|used to|previously|was moved|renamed" --glob '*.md' --glob '*.ts' --glob '!vendor/**'` and keep only contrasts against a live alternative. Keep the vendor exclusion last so include globs cannot override it.

View File

@@ -16,14 +16,13 @@ git status --short --branch
git rev-parse --show-toplevel
```
2. Inspect the diff against its actual base.
2. Verify the live PR base or stack parent, fetch that ref, and inspect the complete scope against it.
```sh
git diff --stat
git diff --name-only origin/$(git branch --show-current)...HEAD
pnpm run change-scope --base <verified-base-ref>
```
If the branch has no upstream or that range is not meaningful for the stack, compare with the PR base branch. After merging a changed base, reassess which behavior the combined diff can affect and rerun only checks invalidated by the merge.
The command never guesses or fetches a base. Supply the ref verified from current remote or stack state; use `--head <ref>` when inspecting a commit other than `HEAD`, and `--json` when another tool consumes the report. Its committed paths are relative to the resolved merge base, while staged, unstaged, and untracked paths describe the current worktree. After merging a changed base, rerun the report, reassess which behavior the combined scope can affect, and rerun only checks invalidated by the merge.
## Select relevant evidence

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: fd7f39ae7b5aac2d44572979ca8c8f1d2df0de6f
development.zh.md: 7dd6209bad75d605e0056d2465a35b08aa091780
development.md: 32339fa2af8c1b6005d9e0b8165d57966a4145ca
development.zh.md: c74a81346639c6f95568cbd86b401d134d5eb7fc

View File

@@ -8,7 +8,7 @@ This onboarding guide helps project contributors get started with the local envi
- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).
- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.
- Git.
- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.
- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.
## First-time setup
@@ -19,14 +19,20 @@ Install dependencies from the repo root:
pnpm install
```
The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`; the wrapper script uses lefthook's reviewed `--force` mode so linked worktrees with an existing `core.hooksPath` do not fail normal `pnpm run …` commands.
The install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).
If hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:
```sh
pnpm exec lefthook install --force
node scripts/install-lefthook.mjs
```
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:
```sh

View File

@@ -8,7 +8,7 @@
- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。
- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`
- Git。
- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展
- 可选:一个 DeepSeek API key用于 TUI、headless 和 ACPAgent Client Protocol自动化 agent智能体演示以及真实 API 的 e2e 测试。
## 首次搭建
@@ -19,14 +19,20 @@
pnpm install
```
安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。包装脚本使用 lefthook 经过评审的 `--force` 模式,确保已存在 `core.hooksPath` 的关联 worktree 不会导致正常的 `pnpm run …` 命令失败
安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。`CI=true``GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)
如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:
```sh
pnpm exec lefthook install --force
node scripts/install-lefthook.mjs
```
包装层会拒绝用户自有的 `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) 统一定义。
新克隆后请先运行一次类型检查:
```sh

View File

@@ -1,6 +1,6 @@
# Git hooks (lefthook). Keep these local checkpoints fast; CI owns the full
# repository-wide gate matrix.
# Install: `pnpm exec lefthook install` (runs automatically via postinstall).
# Install: `node scripts/install-lefthook.mjs` (runs automatically via postinstall).
pre-commit:
jobs:

View File

@@ -17,6 +17,7 @@
"build": "tsc -b && tsdown",
"build:web": "pnpm --filter @deepseek-ai/dsh-frontend run build",
"clean": "tsx scripts/clean.ts",
"change-scope": "tsx scripts/change-scope.ts",
"typecheck": "tsc -b",
"lint": "eslint .",
"lint:fix": "eslint . --fix",

View File

@@ -0,0 +1,343 @@
import { execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { writeChangeScope } from './change-scope.ts'
interface Report {
formatVersion: number
repository: { root: string; branch: string | null; upstream: string | null }
input: { base: string; head: string }
resolved: { baseSha: string; headSha: string; mergeBaseSha: string }
paths: { committed: string[]; staged: string[]; unstaged: string[]; untracked: string[] }
}
interface Fixture {
container: string
root: string
origin: string
}
const fixtureRoots: string[] = []
afterEach(() => {
for (const root of fixtureRoots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function git(cwd: string, args: string[], input?: string | Buffer): string {
return execFileSync('git', ['-C', cwd, ...args], {
encoding: 'utf8',
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
input,
stdio: ['pipe', 'pipe', 'pipe'],
}).trim()
}
function gitBytes(cwd: string, args: string[], input?: Buffer): Buffer {
return execFileSync('git', ['-C', cwd, ...args], {
env: { ...process.env, LANG: 'C', LC_ALL: 'C' },
input,
stdio: ['pipe', 'pipe', 'pipe'],
})
}
function write(path: string, content: string, mode?: number): void {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, content, mode === undefined ? undefined : { mode })
}
function fixture(worktreeName = 'worktree'): Fixture {
const container = mkdtempSync(join(tmpdir(), 'dsh-change-scope-'))
fixtureRoots.push(container)
const origin = join(container, 'origin.git')
const root = join(container, worktreeName)
const hooks = join(container, 'hooks')
mkdirSync(hooks)
git(container, ['init', '--bare', '--initial-branch=master', origin])
git(container, ['init', '--initial-branch=master', root])
git(root, ['config', 'user.email', 'change-scope@example.com'])
git(root, ['config', 'user.name', 'Change Scope Tests'])
git(root, ['config', 'commit.gpgsign', 'false'])
git(root, ['config', 'core.hooksPath', hooks])
write(join(root, 'README.md'), '# Fixture\n')
git(root, ['add', 'README.md'])
git(root, ['commit', '-m', 'initial'])
git(root, ['remote', 'add', 'origin', origin])
git(root, ['push', '--set-upstream', 'origin', 'master'])
return { container, root, origin }
}
function commit(root: string, path: string, content: string): string {
write(join(root, path), content)
git(root, ['add', '--', path])
git(root, ['commit', '-m', `add ${path}`])
return git(root, ['rev-parse', 'HEAD'])
}
function invoke(root: string, args: string[]): string {
const output: string[] = []
writeChangeScope(args, root, chunk => output.push(chunk))
expect(output).toHaveLength(1)
return output[0] as string
}
function jsonReport(root: string, base: string, head?: string): Report {
const args = ['--base', base, '--json']
if (head !== undefined) args.push('--head', head)
return JSON.parse(invoke(root, args)) as Report
}
function formatHumanFromJson(report: Report): string {
const value = (input: string | null): string => JSON.stringify(input) ?? 'null'
const paths = (label: string, entries: string[]): string[] => [
`${label} (${entries.length}):`,
...(entries.length === 0 ? [' (none)'] : entries.map(entry => ` - ${value(entry)}`)),
]
return [
`Format version: ${report.formatVersion}`,
`Repository root: ${value(report.repository.root)}`,
`Branch: ${value(report.repository.branch)}`,
`Upstream: ${value(report.repository.upstream)}`,
`Base ref: ${value(report.input.base)}`,
`Head ref: ${value(report.input.head)}`,
`Base commit: ${report.resolved.baseSha}`,
`Head commit: ${report.resolved.headSha}`,
`Merge base: ${report.resolved.mergeBaseSha}`,
...paths('Committed paths', report.paths.committed),
...paths('Staged paths', report.paths.staged),
...paths('Unstaged paths', report.paths.unstaged),
...paths('Untracked paths', report.paths.untracked),
'',
].join('\n')
}
function repositoryState(root: string): Record<string, string> {
const status = git(root, ['status', '--porcelain=v2', '--branch', '-z'])
return {
status,
head: git(root, ['rev-parse', 'HEAD']),
refs: git(root, ['for-each-ref', '--format=%(refname) %(objectname)']),
index: readFileSync(join(root, '.git/index')).toString('base64'),
config: readFileSync(join(root, '.git/config')).toString('base64'),
}
}
describe('change-scope', () => {
it('uses an explicit base on a fresh branch without a same-name remote and after its first push', () => {
const { root } = fixture()
git(root, ['switch', '-c', 'feature'])
git(root, ['branch', '--set-upstream-to=origin/master'])
const headSha = commit(root, 'feature.txt', 'feature\n')
const fresh = jsonReport(root, 'origin/master')
expect(fresh.repository).toEqual({ root: realpathSync(root), branch: 'feature', upstream: 'origin/master' })
expect(fresh.resolved).toEqual({
baseSha: git(root, ['rev-parse', 'origin/master']),
headSha,
mergeBaseSha: git(root, ['rev-parse', 'origin/master']),
})
expect(fresh.paths).toEqual({ committed: ['feature.txt'], staged: [], unstaged: [], untracked: [] })
expect(git(root, ['for-each-ref', '--format=%(refname)', 'refs/remotes/origin/feature'])).toBe('')
git(root, ['push', '--set-upstream', 'origin', 'feature'])
const pushed = jsonReport(root, 'origin/master')
expect(pushed.repository.upstream).toBe('origin/feature')
expect(pushed.paths.committed).toEqual(['feature.txt'])
})
it.skipIf(process.platform === 'win32')('preserves trailing spaces in the worktree path', () => {
const { root } = fixture('worktree ')
const report = jsonReport(root, 'HEAD')
expect(report.repository.root).toBe(realpathSync(root))
expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
})
it('preserves legal Unicode edge whitespace in branch and upstream names', () => {
const { root } = fixture()
const branch = '\u00a0topic\u3000'
const upstreamBranch = '\u3000upstream\u00a0'
git(root, ['switch', '-c', branch])
git(root, ['push', 'origin', `HEAD:refs/heads/${upstreamBranch}`])
git(root, ['branch', '--set-upstream-to', `origin/${upstreamBranch}`])
const report = jsonReport(root, 'origin/master')
expect(report.repository.branch).toBe(branch)
expect(report.repository.upstream).toBe(`origin/${upstreamBranch}`)
})
it('reports an exact head above a non-master stacked base while dirty paths remain worktree-local', () => {
const { root } = fixture()
git(root, ['switch', '-c', 'foundation'])
const baseSha = commit(root, 'foundation.txt', 'foundation\n')
git(root, ['switch', '-c', 'topic'])
const headSha = commit(root, 'topic.txt', 'topic\n')
commit(root, 'later.txt', 'later\n')
write(join(root, 'current-worktree.txt'), 'current worktree\n')
const report = jsonReport(root, 'foundation', headSha)
expect(report.input).toEqual({ base: 'foundation', head: headSha })
expect(report.resolved).toEqual({ baseSha, headSha, mergeBaseSha: baseSha })
expect(report.paths.committed).toEqual(['topic.txt'])
expect(report.paths.untracked).toEqual(['current-worktree.txt'])
})
it('keeps committed, staged, unstaged, and untracked paths independent and does not mutate state', () => {
const { root } = fixture()
commit(root, 'unstaged.txt', 'before\n')
const baseSha = git(root, ['rev-parse', 'HEAD'])
commit(root, 'committed.txt', 'committed\n')
write(join(root, 'staged.txt'), 'staged\n')
write(join(root, 'mixed.txt'), 'staged part\n')
git(root, ['add', 'staged.txt', 'mixed.txt'])
write(join(root, 'mixed.txt'), 'staged part\nunstaged part\n')
write(join(root, 'unstaged.txt'), 'unstaged\n')
write(join(root, 'untracked.txt'), 'untracked\n')
const before = repositoryState(root)
const report = jsonReport(root, baseSha)
expect(report.paths).toEqual({
committed: ['committed.txt'],
staged: ['mixed.txt', 'staged.txt'],
unstaged: ['mixed.txt', 'unstaged.txt'],
untracked: ['untracked.txt'],
})
expect(repositoryState(root)).toEqual(before)
})
it.skipIf(process.platform === 'win32')('does not execute a configured filesystem monitor', () => {
const { container, root } = fixture()
const monitor = join(container, 'fsmonitor.sh')
const sideEffect = `${monitor}.ran`
write(monitor, '#!/bin/sh\ntouch "$0.ran"\n', 0o755)
git(root, ['config', 'core.fsmonitor', monitor])
const report = jsonReport(root, 'HEAD')
expect(report.paths).toEqual({ committed: [], staged: [], unstaged: [], untracked: [] })
expect(existsSync(sideEffect)).toBe(false)
})
it.skipIf(process.platform === 'win32')('rejects non-UTF-8 branch and upstream names without partial output', () => {
const invalidBranch = fixture()
const branchHead = git(invalidBranch.root, ['rev-parse', 'HEAD'])
const invalidBranchName = Buffer.from([0x80])
writeFileSync(join(invalidBranch.root, '.git/packed-refs'), Buffer.concat([
Buffer.from(`${branchHead} refs/heads/`),
invalidBranchName,
Buffer.from('\n'),
]))
writeFileSync(
join(invalidBranch.root, '.git/HEAD'),
Buffer.concat([Buffer.from('ref: refs/heads/'), invalidBranchName, Buffer.from('\n')]),
)
const branchOutput: string[] = []
expect(() => {
writeChangeScope(['--base', branchHead, '--json'], invalidBranch.root, chunk => branchOutput.push(chunk))
}).toThrow('cannot inspect the current branch: Git stdout is not valid UTF-8')
expect(branchOutput).toEqual([])
const invalidUpstream = fixture()
const upstreamHead = git(invalidUpstream.root, ['rev-parse', 'HEAD'])
const invalidUpstreamName = Buffer.from([0x81])
writeFileSync(join(invalidUpstream.root, '.git/packed-refs'), Buffer.concat([
Buffer.from(`${upstreamHead} refs/remotes/origin/`),
invalidUpstreamName,
Buffer.from('\n'),
]))
const configPath = join(invalidUpstream.root, '.git/config')
const config = readFileSync(configPath)
const merge = Buffer.from('\tmerge = refs/heads/master\n')
const mergeIndex = config.indexOf(merge)
expect(mergeIndex).toBeGreaterThanOrEqual(0)
writeFileSync(configPath, Buffer.concat([
config.subarray(0, mergeIndex),
Buffer.from('\tmerge = refs/heads/'),
invalidUpstreamName,
Buffer.from('\n'),
config.subarray(mergeIndex + merge.length),
]))
const upstreamOutput: string[] = []
expect(() => {
writeChangeScope(['--base', upstreamHead, '--json'], invalidUpstream.root, chunk => upstreamOutput.push(chunk))
}).toThrow('cannot inspect the configured upstream: Git stdout is not valid UTF-8')
expect(upstreamOutput).toEqual([])
})
it.skipIf(process.platform === 'win32')('rejects distinct non-UTF-8 Git paths without partial output', () => {
const { root } = fixture()
const blobSha = git(root, ['hash-object', '-w', '--stdin'], 'content')
const entry = Buffer.from(`100644 ${blobSha}\t`, 'ascii')
const firstPath = Buffer.from([0x80])
const secondPath = Buffer.from([0x81])
gitBytes(root, ['update-index', '-z', '--index-info'], Buffer.concat([
entry,
firstPath,
Buffer.from([0]),
entry,
secondPath,
Buffer.from([0]),
]))
expect(gitBytes(root, ['diff', '--cached', '--name-only', '-z', '--'])).toEqual(Buffer.concat([
firstPath,
Buffer.from([0]),
secondPath,
Buffer.from([0]),
]))
const output: string[] = []
expect(() => {
writeChangeScope(['--base', 'HEAD', '--json'], root, chunk => output.push(chunk))
}).toThrow('cannot inspect staged paths: Git path 1 is not valid UTF-8')
expect(output).toEqual([])
})
it('rejects missing, ambiguous, and non-commit refs before writing output', () => {
const { root } = fixture()
git(root, ['branch', 'collision'])
git(root, ['tag', 'collision'])
write(join(root, 'blob.txt'), 'blob\n')
const blobSha = git(root, ['hash-object', '-w', 'blob.txt'])
git(root, ['tag', 'blob-ref', blobSha])
for (const { args, message } of [
{ args: ['--base', 'missing'], message: /base ref .* does not resolve to a commit/u },
{ args: ['--base', 'collision'], message: /base ref .* is ambiguous/u },
{ args: ['--base', 'blob-ref'], message: /base ref .* does not resolve to a commit/u },
{ args: ['--base', 'HEAD', '--head', 'missing'], message: /head ref .* does not resolve to a commit/u },
]) {
const output: string[] = []
expect(() => {
writeChangeScope(args, root, (chunk) => {
output.push(chunk)
})
}).toThrow(message)
expect(output).toEqual([])
}
})
it('renders deterministic human and JSON forms with the same facts', () => {
const { root } = fixture()
git(root, ['switch', '-c', 'format'])
commit(root, 'zeta.txt', 'zeta\n')
commit(root, 'alpha.txt', 'alpha\n')
const json = invoke(root, ['--base', 'origin/master', '--json'])
const repeatedJson = invoke(root, ['--base', 'origin/master', '--json'])
const human = invoke(root, ['--base', 'origin/master'])
const repeatedHuman = invoke(root, ['--base', 'origin/master'])
const report = JSON.parse(json) as Report
expect(json).toBe(repeatedJson)
expect(report.formatVersion).toBe(1)
expect(report.paths.committed).toEqual(['alpha.txt', 'zeta.txt'])
expect(human).toBe(repeatedHuman)
expect(human).toBe(formatHumanFromJson(report))
})
})

320
scripts/change-scope.ts Normal file
View File

@@ -0,0 +1,320 @@
/** Report the explicit committed and worktree scope of a repository change. */
import { spawnSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { resolve } from 'node:path'
import { parseArgs, TextDecoder } from 'node:util'
const FORMAT_VERSION = 1
const MAX_GIT_OUTPUT = 64 * 1024 * 1024
const UTF8_DECODER = new TextDecoder('utf-8', { fatal: true })
interface ChangeScopeReport {
formatVersion: typeof FORMAT_VERSION
repository: {
root: string
branch: string | null
upstream: string | null
}
input: {
base: string
head: string
}
resolved: {
baseSha: string
headSha: string
mergeBaseSha: string
}
paths: {
committed: string[]
staged: string[]
unstaged: string[]
untracked: string[]
}
}
interface GitCommandResult {
status: number | null
stdout: string
stderr: string
error: Error | undefined
}
interface GitBytesCommandResult {
status: number | null
stdout: Buffer
stderr: Buffer
error: Error | undefined
}
interface ChangeScopeOptions {
base: string
head: string
json: boolean
}
function executeGit(cwd: string, args: string[], context: string): GitCommandResult {
const result = executeGitBytes(cwd, args)
return {
status: result.status,
stdout: decodeGitText(result.stdout, context, 'stdout'),
stderr: decodeGitText(result.stderr, context, 'stderr'),
error: result.error,
}
}
function executeGitBytes(cwd: string, args: string[]): GitBytesCommandResult {
const result = spawnSync('git', ['-C', cwd, '-c', 'core.fsmonitor=false', ...args], {
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', LANG: 'C', LC_ALL: 'C' },
maxBuffer: MAX_GIT_OUTPUT,
})
return {
status: result.status,
stdout: result.stdout,
stderr: result.stderr,
error: result.error,
}
}
function decodeGitText(output: Buffer, context: string, stream: 'stdout' | 'stderr'): string {
try {
return UTF8_DECODER.decode(output)
} catch {
throw new Error(`${context}: Git ${stream} is not valid UTF-8`)
}
}
function failureDetail(result: GitCommandResult): string {
return result.error?.message ?? (result.stderr.trim() || `Git exited with status ${String(result.status)}`)
}
function requireGit(cwd: string, args: string[], context: string): string {
const result = executeGit(cwd, args, context)
if (result.status !== 0) throw new Error(`${context}: ${failureDetail(result)}`)
return result.stdout
}
function requireGitBytes(cwd: string, args: string[], context: string): Buffer {
const result = executeGitBytes(cwd, args)
if (result.status !== 0) {
const detail = result.error?.message
?? (result.stderr.toString('utf8').trim() || `Git exited with status ${String(result.status)}`)
throw new Error(`${context}: ${detail}`)
}
return result.stdout
}
function parseOptions(args: string[]): ChangeScopeOptions {
const { values } = parseArgs({
args,
allowPositionals: false,
options: {
base: { type: 'string' },
head: { type: 'string', default: 'HEAD' },
json: { type: 'boolean', default: false },
},
strict: true,
})
if (values.base === undefined) throw new Error('missing required --base <ref>')
return { base: values.base, head: values.head, json: values.json }
}
function resolveCommit(root: string, label: 'base' | 'head', ref: string): string {
const context = `cannot resolve ${label} ref ${JSON.stringify(ref)}`
const result = executeGit(root, [
'-c',
'core.warnAmbiguousRefs=true',
'rev-parse',
'--verify',
'--end-of-options',
`${ref}^{commit}`,
], context)
if (/\bambiguous\b/iu.test(result.stderr)) {
throw new Error(`${label} ref ${JSON.stringify(ref)} is ambiguous; use a fully qualified ref or commit ID`)
}
if (result.status !== 0) {
throw new Error(`${label} ref ${JSON.stringify(ref)} does not resolve to a commit: ${failureDetail(result)}`)
}
const commits = result.stdout.trim().split(/\r?\n/u).filter(Boolean)
if (commits.length !== 1) {
throw new Error(`${label} ref ${JSON.stringify(ref)} did not resolve to exactly one commit`)
}
return commits[0] as string
}
function resolveMergeBase(root: string, baseSha: string, headSha: string): string {
const result = executeGit(
root,
['merge-base', '--all', baseSha, headSha],
'cannot resolve the merge base',
)
if (result.status !== 0) {
throw new Error(`base and head do not have a merge base: ${failureDetail(result)}`)
}
const mergeBases = result.stdout.trim().split(/\r?\n/u).filter(Boolean)
if (mergeBases.length !== 1) {
throw new Error(`base and head do not have a unique merge base; found ${mergeBases.length}`)
}
return mergeBases[0] as string
}
function currentBranch(root: string): string | null {
const result = executeGit(
root,
['symbolic-ref', '--quiet', '--short', 'HEAD'],
'cannot inspect the current branch',
)
if (result.status === 1) return null
if (result.status !== 0) throw new Error(`cannot inspect the current branch: ${failureDetail(result)}`)
return stripGitLineTerminator(result.stdout)
}
function configuredUpstream(root: string, branch: string | null): string | null {
if (branch === null) return null
const output = stripGitLineTerminator(requireGit(
root,
['for-each-ref', '--count=1', '--format=%(upstream:short)', `refs/heads/${branch}`],
'cannot inspect the configured upstream',
))
return output === '' ? null : output
}
function comparePaths(left: string, right: string): number {
if (left < right) return -1
if (left > right) return 1
return 0
}
function parsePathSet(output: Buffer, context: string): string[] {
const paths: string[] = []
let start = 0
let record = 0
for (let end = 0; end < output.length; end += 1) {
if (output[end] !== 0) continue
if (end > start) {
record += 1
try {
paths.push(UTF8_DECODER.decode(output.subarray(start, end)))
} catch {
throw new Error(`${context}: Git path ${record} is not valid UTF-8`)
}
}
start = end + 1
}
return [...new Set(paths)].sort(comparePaths)
}
function diffPaths(root: string, args: string[], context: string): string[] {
return parsePathSet(requireGitBytes(root, [
'diff',
'--no-ext-diff',
'--no-textconv',
'--no-renames',
'--ignore-submodules=none',
'--name-only',
'-z',
...args,
'--',
], context), context)
}
function stripGitLineTerminator(output: string): string {
const withoutLineFeed = output.endsWith('\n') ? output.slice(0, -1) : output
return process.platform === 'win32' && withoutLineFeed.endsWith('\r')
? withoutLineFeed.slice(0, -1)
: withoutLineFeed
}
function collectReport(options: ChangeScopeOptions, cwd: string): ChangeScopeReport {
const root = stripGitLineTerminator(
requireGit(cwd, ['rev-parse', '--show-toplevel'], 'cannot locate a Git worktree'),
)
const baseSha = resolveCommit(root, 'base', options.base)
const headSha = resolveCommit(root, 'head', options.head)
const mergeBaseSha = resolveMergeBase(root, baseSha, headSha)
const branch = currentBranch(root)
return {
formatVersion: FORMAT_VERSION,
repository: {
root,
branch,
upstream: configuredUpstream(root, branch),
},
input: {
base: options.base,
head: options.head,
},
resolved: {
baseSha,
headSha,
mergeBaseSha,
},
paths: {
committed: diffPaths(root, [mergeBaseSha, headSha], 'cannot inspect committed paths'),
staged: diffPaths(root, ['--cached'], 'cannot inspect staged paths'),
unstaged: diffPaths(root, [], 'cannot inspect unstaged paths'),
untracked: parsePathSet(requireGitBytes(
root,
['ls-files', '--others', '--exclude-standard', '-z', '--'],
'cannot inspect untracked paths',
), 'cannot inspect untracked paths'),
},
}
}
function formatValue(value: string | null): string {
return JSON.stringify(value)
}
function formatPaths(label: string, paths: string[]): string[] {
return [
`${label} (${paths.length}):`,
...(paths.length === 0 ? [' (none)'] : paths.map(path => ` - ${formatValue(path)}`)),
]
}
function formatHuman(report: ChangeScopeReport): string {
return [
`Format version: ${report.formatVersion}`,
`Repository root: ${formatValue(report.repository.root)}`,
`Branch: ${formatValue(report.repository.branch)}`,
`Upstream: ${formatValue(report.repository.upstream)}`,
`Base ref: ${formatValue(report.input.base)}`,
`Head ref: ${formatValue(report.input.head)}`,
`Base commit: ${report.resolved.baseSha}`,
`Head commit: ${report.resolved.headSha}`,
`Merge base: ${report.resolved.mergeBaseSha}`,
...formatPaths('Committed paths', report.paths.committed),
...formatPaths('Staged paths', report.paths.staged),
...formatPaths('Unstaged paths', report.paths.unstaged),
...formatPaths('Untracked paths', report.paths.untracked),
].join('\n')
}
/**
* Validate arguments, collect one complete report, then invoke the writer once.
* @param args - Command-line arguments after the script path.
* @param cwd - Directory whose containing Git worktree is inspected.
* @param write - Destination called once only after every Git query succeeds.
* @returns Nothing.
*/
export function writeChangeScope(
args: string[],
cwd: string,
write: (output: string) => void,
): void {
const options = parseOptions(args)
const report = collectReport(options, cwd)
write(`${options.json ? JSON.stringify(report, null, 2) : formatHuman(report)}\n`)
}
const entryPath = process.argv[1]
if (entryPath !== undefined && resolve(entryPath) === fileURLToPath(import.meta.url)) {
try {
writeChangeScope(process.argv.slice(2), process.cwd(), output => process.stdout.write(output))
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
process.stderr.write(`change-scope: ${message}\n`)
process.exitCode = 1
}
}

View File

@@ -1,21 +1,657 @@
#!/usr/bin/env node
import { existsSync } from 'node:fs'
import { randomUUID } from 'node:crypto'
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { spawnSync } from 'node:child_process'
import { join } from 'node:path'
import { isAbsolute, join, resolve } from 'node:path'
const git = spawnSync('git', ['rev-parse', '--git-dir'], { stdio: 'ignore' })
if (git.status !== 0) process.exit(0)
const MINIMUM_GIT = [2, 26, 0]
const HOOKS_DIRECTORY = 'dsh-hooks'
const OWNERSHIP_MARKER = '.dsh-lefthook-owned'
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 REPOSITORY_EXTENSION_PATTERN = '^extensions\\.'
const isWindows = process.platform === 'win32'
const lefthook = join(process.cwd(), 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook')
if (!existsSync(lefthook)) process.exit(0)
function errorCode(error) {
return typeof error === 'object' && error !== null && 'code' in error
? error.code
: undefined
}
// On Windows the bin shim is a `.cmd` file, and recent Node (CVE-2024-27980)
// refuses to launch `.cmd`/`.bat` via spawn without `shell: true` — it returns
// `EINVAL` with a null status, which would otherwise fail postinstall. Quote
// the path because a shell re-parses the command line and the path may contain
// spaces. POSIX needs no shell: the extensionless shim is directly executable.
const result = isWindows
? spawnSync(`"${lefthook}"`, ['install', '--force'], { stdio: 'inherit', shell: true })
: spawnSync(lefthook, ['install', '--force'], { stdio: 'inherit' })
process.exit(result.status ?? 1)
function commandFailure(command, args, result) {
const stderr = typeof result.stderr === 'string' ? result.stderr.trim() : ''
const detail = result.error?.message ?? (stderr || `exit status ${String(result.status)}`)
return new Error(`${command} ${args.join(' ')} failed: ${detail}`)
}
function capture(command, args, options = {}) {
const result = spawnSync(command, args, {
cwd: options.cwd,
encoding: 'utf8',
env: process.env,
})
if (result.status !== 0 && !options.allowStatuses?.includes(result.status)) {
throw commandFailure(command, args, result)
}
return result
}
function git(args, root, options = {}) {
return capture('git', args, { ...options, cwd: root })
}
function nulValues(result) {
if (result.status !== 0) return []
if (result.stdout === '') return ['']
const output = result.stdout.endsWith('\0') ? result.stdout.slice(0, -1) : result.stdout
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 directFileConfigValues(root, configPath, key) {
return nulValues(git(
['config', '--file', configPath, '--no-includes', '--null', '--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}`)
}
const entries = []
for (let index = 0; index < fields.length; index += 2) {
entries.push({ origin: fields[index], value: fields[index + 1] })
}
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 directFileConfigMatchingEntries(root, configPath, pattern) {
const fields = nulValues(git(
['config', '--file', configPath, '--no-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 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) {
const normalized = value.toLowerCase()
if (normalized === '' || normalized === 'true' || normalized === 'yes' || normalized === 'on' || normalized === '1') return true
if (normalized === 'false' || normalized === 'no' || normalized === 'off' || normalized === '0') return false
throw new Error(`invalid Boolean value for ${key}: ${JSON.stringify(value)}`)
}
function assertSingle(values, key) {
if (values.length > 1) throw new Error(`multiple ${key} values are not supported`)
return values[0]
}
function worktreeConfigExtensionEnabled(root, commonConfigPath) {
const extensionText = assertSingle(
directFileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'),
'extensions.worktreeConfig',
)
return extensionText === undefined
? false
: parseGitBoolean(extensionText, 'extensions.worktreeConfig')
}
function hasDirectConfigEntries(root, configPath) {
return git(['config', '--file', configPath, '--no-includes', '--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 lstatIfPresent(path) {
try {
return lstatSync(path)
} catch (error) {
if (errorCode(error) === 'ENOENT') return undefined
throw error
}
}
function assertCommonConfigFile(commonConfigPath) {
const configStat = lstatIfPresent(commonConfigPath)
if (configStat === undefined || !configStat.isFile() || configStat.isSymbolicLink()) {
throw new Error(
`refusing common repository config ${JSON.stringify(commonConfigPath)} because it is not a regular file`,
)
}
}
function assertWorktreeConfigFiles(root, commonDirectory, commonConfigPath, currentConfigPath) {
const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath)
for (const configPath of registeredWorktreeConfigPaths(commonDirectory)) {
const configStat = lstatIfPresent(configPath)
if (configStat === undefined) continue
if (!configStat.isFile() || configStat.isSymbolicLink()) {
const state = extensionEnabled ? 'active' : 'dormant'
throw new Error(
`refusing ${state} worktree config ${JSON.stringify(configPath)} because it is not a regular file; `
+ 'replace it with a regular worktree config or remove it before retrying',
)
}
if (extensionEnabled) continue
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)
if (match === null) throw new Error(`cannot determine Git version from ${JSON.stringify(version)}`)
const actual = [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)]
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.26 or newer is required for worktree-local hooks; found ${version}`)
}
}
}
function planWorktreeConfigMigration(root, commonConfigPath) {
const versions = directFileConfigValues(root, commonConfigPath, 'core.repositoryFormatVersion')
const versionText = assertSingle(versions, 'core.repositoryFormatVersion')
const version = Number(versionText)
if (!Number.isInteger(version) || version < 0) {
throw new Error(`unsupported core.repositoryFormatVersion: ${JSON.stringify(versionText)}`)
}
if (version === 0) {
const extensionEntry = directFileConfigMatchingEntries(
root,
commonConfigPath,
REPOSITORY_EXTENSION_PATTERN,
)[0]
if (extensionEntry !== undefined) {
throw new Error(
`cannot upgrade core.repositoryFormatVersion from 0 while dormant repository extension `
+ `${extensionEntry.name} is configured (${configSource(extensionEntry)}); `
+ 'audit and migrate it, then set repository format 1 explicitly before retrying',
)
}
}
const extensionEnabled = worktreeConfigExtensionEnabled(root, commonConfigPath)
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 `
+ `(file:${commonConfigPath}: ${JSON.stringify(worktreeText)}); `
+ 'move it to the main worktree config first',
)
}
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 `
+ `(file:${commonConfigPath}: ${JSON.stringify(directBareText)})`,
)
}
return { directBare, extensionEnabled, version }
}
function applyWorktreeConfigMigration(root, commonConfigPath, migration) {
const { directBare, extensionEnabled, version } = migration
if (version === 0) {
git(['config', '--file', commonConfigPath, 'core.repositoryFormatVersion', '1'], root)
}
if (!extensionEnabled) {
git(['config', '--file', commonConfigPath, 'extensions.worktreeConfig', 'true'], root)
}
if (directBare === false) {
git(['config', '--file', commonConfigPath, '--unset-all', 'core.bare'], root)
}
}
function readInstallLock(lockPath) {
try {
return readFileSync(lockPath, 'utf8')
} catch (error) {
if (errorCode(error) === 'ENOENT') return undefined
throw error
}
}
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
} catch (error) {
if (errorCode(error) === 'ESRCH') return false
if (errorCode(error) === 'EPERM') return true
throw error
}
}
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 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, 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
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}`)
}
await new Promise(resolveWait => setTimeout(resolveWait, INSTALL_LOCK_POLL_MS))
}
}
}
function ownershipMarkerContent(hooksPath) {
return `${JSON.stringify({
version: OWNERSHIP_MARKER_VERSION,
owner: OWNERSHIP_MARKER_OWNER,
hooksPath,
})}\n`
}
function parseOwnershipMarker(content) {
let parsed
try {
parsed = JSON.parse(content)
} catch {
return undefined
}
if (
typeof parsed !== 'object'
|| parsed === null
|| parsed.version !== OWNERSHIP_MARKER_VERSION
|| parsed.owner !== OWNERSHIP_MARKER_OWNER
|| typeof parsed.hooksPath !== 'string'
|| !isAbsolute(parsed.hooksPath)
) {
return undefined
}
return { hooksPath: parsed.hooksPath }
}
function inspectOwnedHooksDirectory(hooksPath) {
const markerPath = join(hooksPath, OWNERSHIP_MARKER)
if (!existsSync(hooksPath)) return undefined
const hooksStat = lstatSync(hooksPath)
if (!hooksStat.isDirectory() || hooksStat.isSymbolicLink()) {
throw new Error(`refusing to use non-directory or symlinked hooks path ${hooksPath}`)
}
if (!existsSync(markerPath)) {
throw new Error(`refusing to overwrite unowned hooks directory ${hooksPath}`)
}
const markerStat = lstatSync(markerPath)
const marker = markerStat.isFile() && !markerStat.isSymbolicLink() && markerStat.nlink === 1
? parseOwnershipMarker(readFileSync(markerPath, 'utf8'))
: undefined
if (marker === undefined) {
throw new Error(`refusing to overwrite hooks directory with an invalid ownership marker: ${hooksPath}`)
}
for (const name of readdirSync(hooksPath)) {
if (name === OWNERSHIP_MARKER) continue
const entryPath = join(hooksPath, name)
const entryStat = lstatSync(entryPath)
if (!entryStat.isFile() || entryStat.isSymbolicLink() || entryStat.nlink !== 1) {
throw new Error(
`refusing to overwrite non-regular or multiply linked hook entry ${JSON.stringify(entryPath)}`,
)
}
}
return { markerPath, ...marker }
}
function ensureOwnedHooksDirectory(hooksPath) {
const inspected = inspectOwnedHooksDirectory(hooksPath)
if (inspected !== undefined) return inspected
mkdirSync(hooksPath, { mode: 0o700 })
const markerPath = join(hooksPath, OWNERSHIP_MARKER)
writeFileSync(markerPath, ownershipMarkerContent(hooksPath), { flag: 'wx', mode: 0o600 })
return { markerPath, hooksPath }
}
function updateOwnershipMarker(markerPath, hooksPath) {
writeFileSync(markerPath, ownershipMarkerContent(hooksPath), { mode: 0o600 })
}
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, env, stdio: 'inherit', shell: true })
: spawnSync(lefthook, args, { cwd: root, env, stdio: 'inherit' })
if (result.status !== 0) throw commandFailure(lefthook, args, result)
}
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 refuseInheritedHooksPath(entry) {
throw new Error(
`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() {
if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return
const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' })
if (probe.status !== 0) return
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 = 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 {
assertCommonConfigFile(commonConfigPath)
assertWorktreeConfigFiles(
root,
commonDirectory,
commonConfigPath,
worktreeConfigPath,
)
const worktreeEntries = includedFileConfigEntries(root, worktreeConfigPath, 'core.hooksPath')
const includedWorktreeEntry = worktreeEntries.find(
entry => !originIsFile(entry.origin, root, worktreeConfigPath),
)
if (includedWorktreeEntry !== undefined) {
refuseScopedHooksPath({ ...includedWorktreeEntry, scope: 'worktree' })
}
const worktreePath = assertSingle(
worktreeEntries.map(entry => entry.value),
'worktree core.hooksPath',
)
let ownedHooksDirectory
if (worktreePath !== undefined && worktreePath !== hooksPath) {
ownedHooksDirectory = inspectOwnedHooksDirectory(hooksPath)
if (ownedHooksDirectory === undefined || ownedHooksDirectory.hooksPath !== worktreePath) {
refuseScopedHooksPath({ origin: `file:${worktreeConfigPath}`, scope: 'worktree', value: worktreePath })
}
}
const directWorktreePathIsOwned = worktreePath !== undefined
&& (worktreePath === hooksPath || ownedHooksDirectory?.hooksPath === worktreePath)
const effectiveEntry = effectiveConfigEntry(root, 'core.hooksPath')
if (effectiveEntry !== undefined) {
const effectivePathIsOwned = effectiveEntry.scope === 'worktree'
&& effectiveEntry.value === worktreePath
&& directWorktreePathIsOwned
&& 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)
}
}
}
const migration = planWorktreeConfigMigration(root, commonConfigPath)
ownedHooksDirectory = ensureOwnedHooksDirectory(hooksPath)
if (
worktreePath !== undefined
&& worktreePath !== hooksPath
&& ownedHooksDirectory.hooksPath !== worktreePath
) {
throw new Error(`hooks directory ownership changed while relocating ${JSON.stringify(worktreePath)}`)
}
applyWorktreeConfigMigration(root, commonConfigPath, migration)
let pathChanged = false
try {
git(['config', '--worktree', 'core.hooksPath', hooksPath], root)
pathChanged = worktreePath !== hooksPath
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)
updateOwnershipMarker(ownedHooksDirectory.markerPath, hooksPath)
} catch (error) {
if (pathChanged) {
try {
if (worktreePath === undefined) {
git(['config', '--worktree', '--unset-all', 'core.hooksPath'], root)
} else {
git(['config', '--worktree', 'core.hooksPath', worktreePath], root)
}
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
`Lefthook installation failed: ${String(error)}; `
+ `worktree hook rollback also failed: ${String(rollbackError)}`,
)
}
}
throw error
}
} catch (error) {
installationError = error
throw error
} finally {
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
}
}
}
try {
await main()
} catch (error) {
console.error(`[install-lefthook] ${error instanceof Error ? error.message : String(error)}`)
process.exitCode = 1
}

View File

@@ -0,0 +1,714 @@
import { spawn, spawnSync } from 'node:child_process'
import {
chmodSync,
existsSync,
linkSync,
mkdirSync,
mkdtempSync,
lstatSync,
readFileSync,
renameSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url))
const fixtures: string[] = []
interface Fixture {
container: string
env: NodeJS.ProcessEnv
linked: string
main: string
}
interface CommandResult {
status: number | null
stderr: string
stdout: string
}
afterEach(() => {
for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true })
})
function commandResult(command: string, args: string[], cwd: string, env: NodeJS.ProcessEnv): CommandResult {
const result = spawnSync(command, args, { cwd, encoding: 'utf8', env })
return { status: result.status, stderr: result.stderr, stdout: result.stdout }
}
function gitResult(fixture: Fixture, cwd: string, args: string[]): CommandResult {
return commandResult('git', args, cwd, fixture.env)
}
function git(fixture: Fixture, cwd: string, args: string[]): string {
const result = gitResult(fixture, cwd, args)
if (result.status !== 0) {
throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`)
}
return result.stdout.trim()
}
function write(path: string, content: string, mode?: number): void {
mkdirSync(dirname(path), { recursive: true })
writeFileSync(path, content, mode === undefined ? undefined : { mode })
}
function fakeLefthookSource(): string {
return `#!/usr/bin/env node
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { join } from 'node:path'
if (process.argv.slice(2).join(' ') !== 'install --force') process.exit(64)
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')
try {
writeFileSync(running, String(process.pid), { flag: 'wx' })
} catch {
process.exit(91)
}
const delay = Number(process.env.DSH_TEST_LEFTHOOK_DELAY_MS ?? 0)
if (delay > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delay)
const shouldFail = process.env.DSH_TEST_LEFTHOOK_FAIL === '1'
if (!shouldFail) {
const binary = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'lefthook.cmd' : 'lefthook')
const config = readFileSync(join(root, 'lefthook.yml'), 'utf8').trim()
const hook = \`#!/bin/sh\\n# root=\${root}\\n# binary=\${binary}\\n# config=\${config}\\nexit 0\\n\`
for (const name of ['pre-commit', 'pre-push']) writeFileSync(join(hooksPath, name), hook, { mode: 0o755 })
}
if (existsSync(running)) unlinkSync(running)
if (process.env.DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG === '1') {
const configPath = execFileSync('git', ['rev-parse', '--git-path', 'config.worktree'], { encoding: 'utf8' }).trim()
writeFileSync(configPath, '[invalid\\n')
}
if (shouldFail) process.exit(77)
`
}
function installFakeLefthook(root: string): void {
const binDirectory = join(root, 'node_modules/.bin')
mkdirSync(binDirectory, { recursive: true })
writeFileSync(join(binDirectory, 'fake-lefthook.mjs'), fakeLefthookSource())
if (process.platform === 'win32') {
writeFileSync(
join(binDirectory, 'lefthook.cmd'),
`@echo off\r\n"${process.execPath}" "%~dp0\\fake-lefthook.mjs" %*\r\n`,
)
return
}
const shim = join(binDirectory, 'lefthook')
writeFileSync(shim, `#!/bin/sh\nexec "${process.execPath}" "$(dirname "$0")/fake-lefthook.mjs" "$@"\n`)
chmodSync(shim, 0o755)
}
function createFixture(names: { main?: string; linked?: string } = {}): Fixture {
const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-'))
fixtures.push(container)
const main = join(container, names.main ?? 'main')
const linked = join(container, names.linked ?? 'linked')
const env: NodeJS.ProcessEnv = {
...process.env,
CI: 'false',
GITHUB_ACTIONS: 'false',
GIT_AUTHOR_EMAIL: 'hooks@example.test',
GIT_AUTHOR_NAME: 'Hooks Test',
GIT_COMMITTER_EMAIL: 'hooks@example.test',
GIT_COMMITTER_NAME: 'Hooks Test',
GIT_CONFIG_GLOBAL: join(container, 'global.gitconfig'),
GIT_CONFIG_NOSYSTEM: '1',
HOME: container,
XDG_CONFIG_HOME: join(container, '.config'),
}
const fixture = { container, env, linked, main }
mkdirSync(main)
git(fixture, container, ['init', main])
write(join(main, 'README.md'), '# fixture\n')
git(fixture, main, ['add', 'README.md'])
git(fixture, main, ['commit', '-m', 'fixture'])
git(fixture, main, ['worktree', 'add', '-b', 'linked', linked])
write(join(main, 'lefthook.yml'), 'main-worktree-config\n')
write(join(linked, 'lefthook.yml'), 'linked-worktree-config\n')
installFakeLefthook(main)
installFakeLefthook(linked)
return fixture
}
function gitDirectory(fixture: Fixture, root: string): string {
return git(fixture, root, ['rev-parse', '--absolute-git-dir'])
}
function commonDirectory(fixture: Fixture): string {
const output = git(fixture, fixture.main, ['rev-parse', '--git-common-dir'])
return isAbsolute(output) ? output : resolve(fixture.main, output)
}
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,
extraEnv: NodeJS.ProcessEnv = {},
): Promise<CommandResult> {
return new Promise((resolveResult, reject) => {
const child = spawn(process.execPath, [installer], {
cwd: root,
env: { ...fixture.env, ...extraEnv },
stdio: ['ignore', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString() })
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString() })
child.on('error', reject)
child.on('close', (status) => { resolveResult({ status, stderr, stdout }) })
})
}
describe('worktree-local Lefthook installer', () => {
for (const [label, extraEnv] of [
['CI', { CI: 'true' }],
['GitHub Actions', { GITHUB_ACTIONS: 'true' }],
] satisfies [string, NodeJS.ProcessEnv][]) {
it(`skips hook installation when ${label} marks an automated job`, async () => {
const fixture = createFixture()
const common = commonDirectory(fixture)
const missingInclude = join(fixture.container, 'missing-ci-credentials.gitconfig')
git(fixture, fixture.main, [
'config',
'--local',
'includeIf.gitdir:/github/workspace/.git.path',
missingInclude,
])
const result = await runInstaller(fixture, fixture.main, extraEnv)
expect(result.status, result.stderr).toBe(0)
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
expect(git(fixture, fixture.main, ['config', '--get', 'core.repositoryFormatVersion'])).toBe('0')
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
expect(existsSync(join(common, 'config.worktree'))).toBe(false)
})
}
it('isolates main and linked worktrees without changing legacy common hooks', async () => {
const fixture = createFixture()
const common = commonDirectory(fixture)
const legacyHook = join(common, 'hooks/pre-commit')
write(legacyHook, '#!/bin/sh\n# legacy hook\n', 0o755)
const mainInstall = await runInstaller(fixture, fixture.main)
const linkedInstall = await runInstaller(fixture, fixture.linked)
expect(mainInstall.status, mainInstall.stderr).toBe(0)
expect(linkedInstall.status, linkedInstall.stderr).toBe(0)
const mainHooks = hooksPath(fixture, fixture.main)
const linkedHooks = hooksPath(fixture, fixture.linked)
expect(mainHooks).not.toBe(linkedHooks)
expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks)
const mainHook = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
const linkedHook = readFileSync(join(linkedHooks, 'pre-commit'), 'utf8')
const canonicalMain = git(fixture, fixture.main, ['rev-parse', '--show-toplevel'])
const canonicalLinked = git(fixture, fixture.linked, ['rev-parse', '--show-toplevel'])
expect(mainHook).toContain(`# root=${canonicalMain}`)
expect(mainHook).toContain('# config=main-worktree-config')
expect(mainHook).not.toContain(canonicalLinked)
expect(linkedHook).toContain(`# root=${canonicalLinked}`)
expect(linkedHook).toContain('# config=linked-worktree-config')
expect(linkedHook).not.toContain(canonicalMain)
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
const commonConfig = join(common, 'config')
expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.repositoryFormatVersion'])).toBe('1')
expect(git(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'extensions.worktreeConfig'])).toBe('true')
expect(gitResult(fixture, fixture.main, ['config', '--file', commonConfig, '--get', 'core.bare']).status).toBe(1)
const mainHookBeforeRemoval = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
git(fixture, fixture.main, ['worktree', 'remove', '--force', fixture.linked])
expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBeforeRemoval)
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
})
it('serializes concurrent installs and keeps repeated output stable', async () => {
const fixture = createFixture()
const delayed = { DSH_TEST_LEFTHOOK_DELAY_MS: '150' }
const first = await Promise.all([
runInstaller(fixture, fixture.main, delayed),
runInstaller(fixture, fixture.linked, delayed),
])
for (const result of first) expect(result.status, result.stderr).toBe(0)
const mainHookPath = join(hooksPath(fixture, fixture.main), 'pre-push')
const initialHook = readFileSync(mainHookPath, 'utf8')
const repeated = await Promise.all([
runInstaller(fixture, fixture.main, delayed),
runInstaller(fixture, fixture.main, delayed),
])
for (const result of repeated) expect(result.status, result.stderr).toBe(0)
expect(readFileSync(mainHookPath, 'utf8')).toBe(initialHook)
expect(existsSync(join(commonDirectory(fixture), 'dsh-lefthook-install.lock'))).toBe(false)
expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false)
})
it('repairs its owned absolute hook path after the checkout moves', async () => {
const fixture = createFixture()
const oldRoot = fixture.main
const first = await runInstaller(fixture, oldRoot)
expect(first.status, first.stderr).toBe(0)
const oldHooks = hooksPath(fixture, oldRoot)
const movedRoot = join(fixture.container, 'moved-main')
renameSync(oldRoot, movedRoot)
const moved = await runInstaller(fixture, movedRoot)
expect(moved.status, moved.stderr).toBe(0)
const movedHooks = hooksPath(fixture, movedRoot)
expect(movedHooks).not.toBe(oldHooks)
expect(git(fixture, movedRoot, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(movedHooks)
const canonicalMoved = git(fixture, movedRoot, ['rev-parse', '--show-toplevel'])
expect(readFileSync(join(movedHooks, 'pre-commit'), 'utf8')).toContain(`# root=${canonicalMoved}`)
expect(readFileSync(join(movedHooks, '.dsh-lefthook-owned'), 'utf8')).toContain(
JSON.stringify(movedHooks),
)
})
it.skipIf(process.platform === 'win32')('refuses a multiply linked ownership marker before relocation rewrites it', async () => {
const fixture = createFixture()
const oldRoot = fixture.main
const first = await runInstaller(fixture, oldRoot)
expect(first.status, first.stderr).toBe(0)
const oldHooks = hooksPath(fixture, oldRoot)
const markerName = '.dsh-lefthook-owned'
const externalMarker = join(fixture.container, 'external-marker')
linkSync(join(oldHooks, markerName), externalMarker)
const externalContent = readFileSync(externalMarker, 'utf8')
const movedRoot = join(fixture.container, 'moved-main')
renameSync(oldRoot, movedRoot)
const result = await runInstaller(fixture, movedRoot)
expect(result.status).toBe(1)
expect(result.stderr).toContain('invalid ownership marker')
expect(readFileSync(externalMarker, 'utf8')).toBe(externalContent)
})
it.skipIf(process.platform === 'win32')('refuses aliased generated hooks before Lefthook can overwrite their targets', async () => {
for (const kind of ['symlink', 'hardlink'] as const) {
const fixture = createFixture()
const first = await runInstaller(fixture, fixture.main)
expect(first.status, first.stderr).toBe(0)
const hook = join(hooksPath(fixture, fixture.main), 'pre-commit')
const externalHook = join(fixture.container, `${kind}-external-hook`)
rmSync(hook)
write(externalHook, `external ${kind} target\n`)
if (kind === 'symlink') symlinkSync(externalHook, hook)
else linkSync(externalHook, hook)
const externalContent = readFileSync(externalHook, 'utf8')
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('non-regular or multiply linked hook entry')
expect(readFileSync(externalHook, 'utf8')).toBe(externalContent)
}
})
it('restores the marker-backed stale hook path when relocation reinstall fails', async () => {
const fixture = createFixture()
const oldRoot = fixture.main
const first = await runInstaller(fixture, oldRoot)
expect(first.status, first.stderr).toBe(0)
const oldHooks = hooksPath(fixture, oldRoot)
const markerName = '.dsh-lefthook-owned'
const previousMarker = readFileSync(join(oldHooks, markerName), 'utf8')
const movedRoot = join(fixture.container, 'moved-main')
renameSync(oldRoot, movedRoot)
const failed = await runInstaller(fixture, movedRoot, { DSH_TEST_LEFTHOOK_FAIL: '1' })
expect(failed.status).toBe(1)
expect(failed.stderr).toContain('exit status 77')
const movedHooks = hooksPath(fixture, movedRoot)
expect(git(fixture, movedRoot, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(oldHooks)
expect(readFileSync(join(movedHooks, markerName), 'utf8')).toBe(previousMarker)
})
it('refuses dormant repository extensions before upgrading the repository format', async () => {
const fixture = createFixture()
const commonConfig = join(commonDirectory(fixture), 'config')
git(fixture, fixture.main, ['config', 'extensions.dshUnknown', 'true'])
expect(gitResult(fixture, fixture.main, ['status', '--porcelain']).status).toBe(0)
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('dormant repository extension extensions.dshunknown')
expect(git(fixture, fixture.main, [
'config', '--file', commonConfig, '--get', 'core.repositoryFormatVersion',
])).toBe('0')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
expect(gitResult(fixture, fixture.main, ['status', '--porcelain']).status).toBe(0)
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')
const externalConfig = join(fixture.container, 'external-common.gitconfig')
renameSync(commonConfig, externalConfig)
symlinkSync(externalConfig, commonConfig)
const externalContent = readFileSync(externalConfig, 'utf8')
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('common repository config')
expect(result.stderr).toContain('not a regular file')
expect(lstatSync(commonConfig).isSymbolicLink()).toBe(true)
expect(readFileSync(externalConfig, 'utf8')).toBe(externalContent)
expect(existsSync(hooksPath(fixture, fixture.main))).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')
write(customHook, '#!/bin/sh\n# custom hook\n', 0o755)
git(fixture, fixture.main, ['config', 'core.hooksPath', 'custom-hooks'])
const refused = await runInstaller(fixture, fixture.main)
expect(refused.status).toBe(1)
expect(refused.stderr).toContain('refusing to replace user-owned core.hooksPath')
expect(refused.stderr).toContain('DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1')
expect(git(fixture, fixture.main, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks')
expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
const optedIn = await runInstaller(fixture, fixture.main, {
DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
})
expect(optedIn.status, optedIn.stderr).toBe(0)
expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(hooksPath(fixture, fixture.main))
expect(git(fixture, fixture.linked, ['config', '--get', 'core.hooksPath'])).toBe('custom-hooks')
expect(gitResult(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1)
expect(readFileSync(customHook, 'utf8')).toBe('#!/bin/sh\n# custom hook\n')
git(fixture, fixture.linked, ['config', '--worktree', 'core.hooksPath', 'linked-custom-hooks'])
const explicitWorktreePath = await runInstaller(fixture, fixture.linked, {
DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE: '1',
})
expect(explicitWorktreePath.status).toBe(1)
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.skipIf(process.platform === 'win32')('refuses an active symlinked worktree config before writing through it', async () => {
const fixture = createFixture()
const commonConfig = join(commonDirectory(fixture), 'config')
const worktreeConfig = join(gitDirectory(fixture, fixture.main), 'config.worktree')
const externalConfig = join(fixture.container, 'external.gitconfig')
const externalContent = '[user]\n\tname = External owner\n'
write(externalConfig, externalContent)
git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1'])
git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true'])
symlinkSync(externalConfig, worktreeConfig)
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('active worktree config')
expect(result.stderr).toContain('not a regular file')
expect(lstatSync(worktreeConfig).isSymbolicLink()).toBe(true)
expect(readFileSync(externalConfig, 'utf8')).toBe(externalContent)
expect(gitResult(fixture, fixture.main, [
'config', '--file', externalConfig, '--get', 'core.hooksPath',
]).status).toBe(1)
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
})
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`)
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, 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('ignores an inactive global includeIf that provides a hook path for another repository', async () => {
const fixture = createFixture()
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',
globalConfig,
`includeIf.gitdir:${join(fixture.container, 'other')}/.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('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('restores the previous hook lookup when Lefthook installation fails', async () => {
const fixture = createFixture()
const common = commonDirectory(fixture)
const legacyHook = join(common, 'hooks/pre-push')
write(legacyHook, '#!/bin/sh\n# legacy pre-push\n', 0o755)
const result = await runInstaller(fixture, fixture.main, { DSH_TEST_LEFTHOOK_FAIL: '1' })
expect(result.status).toBe(1)
expect(result.stderr).toContain('exit status 77')
expect(gitResult(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1)
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy pre-push\n')
})
it('reports installation and hook-path rollback failures together', async () => {
const fixture = createFixture()
const result = await runInstaller(fixture, fixture.main, {
DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG: '1',
DSH_TEST_LEFTHOOK_FAIL: '1',
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('Lefthook installation failed')
expect(result.stderr).toContain('exit status 77')
expect(result.stderr).toContain('worktree hook rollback also failed')
expect(result.stderr).toContain('git config --worktree --unset-all core.hooksPath failed')
})
it('refuses an unowned directory at the reserved worktree hook path', async () => {
const fixture = createFixture()
const reservedHook = join(hooksPath(fixture, fixture.main), 'pre-commit')
write(reservedHook, '#!/bin/sh\n# user content\n', 0o755)
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('refusing to overwrite unowned hooks directory')
expect(readFileSync(reservedHook, 'utf8')).toBe('#!/bin/sh\n# user content\n')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
})
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.25.0"; exit 0; fi\nexec "${realGit}" "$@"\n`,
0o755,
)
const result = await runInstaller(fixture, fixture.main, {
PATH: `${fakeBin}:${fixture.env.PATH ?? ''}`,
})
expect(result.status).toBe(1)
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)
})
})

File diff suppressed because one or more lines are too long