diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml index 5d29f39a16..a87c0b5e8b 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.i18n.yaml @@ -9,8 +9,8 @@ en: b3ec2245644b46e6 zh: 83de1cb620f9ddcf /agent-note-make-lefthook-installation-worktree-local/decision: - en: 577d90ff7e03ebaf - zh: dc680155c0c3a2a1 + en: b53a2425f88a6031 + zh: 41f459b00803b3f9 /agent-note-make-lefthook-installation-worktree-local/alternatives-considered: en: d7100b0d2c95a3f9 zh: 070c38c4d0d1331a diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md index 42adc1ed1d..66ae7a136e 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md @@ -14,7 +14,7 @@ Lefthook-generated hooks prefer an absolute binary path captured from the instal 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 so `git config --show-scope` can report which scope supplied a value, 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. +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. On Windows, acquiring or inspecting a lock retries EPERM within the existing 30-second acquisition deadline because deleting a lock can temporarily deny access while readers close their handles; other errors and expired deadlines still fail. 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. Git seeds a new linked worktree's `config.worktree` from the main worktree; when that seed contains the marker-backed reserved hook path of a registered worktree, the installer replaces only the new worktree's config with its own path. 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. diff --git a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md index b4b9059678..641bc6d2c8 100644 --- a/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md +++ b/.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.zh.md @@ -14,7 +14,7 @@ Lefthook 生成的钩子会优先使用安装时从对应 worktree 记录的绝 钩子安装以 worktree 为作用域。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,安装程序会在探测 Git 或做出任何变更之前返回,因为自动化任务不会使用贡献者钩子。否则,安装程序要求 Git 2.26 或更高版本,使 `git config --show-scope` 可以报告由哪个作用域提供配置值;它会将格式版本为 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、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须人工介入恢复,而不能自动强制解除。 +升级格式 0 之前,安装程序会拒绝共用配置中直接设置的 `extensions.*`;它还会拒绝直接设置的 `core.worktree` 或 `core.bare=true`,以及启用扩展后将被激活的非空休眠 worktree 配置。迁移会移除直接设置的 `core.bare=false`,因为 false 是 Git 的默认值。共用仓库配置和每个已有的 `config.worktree` 都必须是常规文件。这些检查会禁用 include 展开,因为 Git 的仓库格式解析器也会忽略 include 目标。仓库级锁会串行化迁移和钩子写入;释放时,锁的进程 ID、随机令牌、文件身份和完整内容必须仍然匹配。所属进程已结束或内容无效的锁必须人工介入恢复,而不能自动强制解除。在 Windows 上,获取或检查锁时遇到 EPERM 会在原有的 30 秒获取期限内重试,因为删除锁时,读取者关闭句柄前可能暂时无法访问该文件;其他错误及超时仍会导致失败。 每个钩子目录都有一个 JSON 所有权标记,其中包含上次写入 worktree 配置的绝对路径。检出目录移动后,该标记只允许替换确切的陈旧自有值。Git 会以主 worktree 的配置为新链接 worktree 初始化 `config.worktree`;当该初始配置包含某个已注册 worktree 中由所有权标记佐证的保留钩子路径时,安装程序只会在新 worktree 的配置中将其替换为新 worktree 自有的路径。Lefthook 运行前,所有权标记和每个已有的生成钩子都必须是不带别名的常规文件。安装程序会解析 `core.hooksPath` 的生效作用域、来源和值,包括通过当前生效的 `config.worktree` include 加载的值;它会拒绝命令作用域路径、非自有的 worktree 作用域路径以及非自有的保留目录。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`,从而只让当前 worktree 显式启用 Lefthook。未生效的 `includeIf` 目标不会被递归检查,因为它们不影响当前配置。完成验证后,Lefthook 子进程的环境会移除命令作用域的 Git 配置。 diff --git a/scripts/install-lefthook.mjs b/scripts/install-lefthook.mjs index 0ecbf072c2..653fa2c8dd 100644 --- a/scripts/install-lefthook.mjs +++ b/scripts/install-lefthook.mjs @@ -374,6 +374,18 @@ async function waitForLockTestBarrier(path) { } } +/** Windows can deny access to a deleted lock until its last reader closes it. */ +async function accessInstallLock(operation, deadline) { + while (true) { + try { + return operation() + } catch (error) { + if (process.platform !== 'win32' || errorCode(error) !== 'EPERM' || Date.now() >= deadline) throw error + await new Promise(resolveWait => setTimeout(resolveWait, INSTALL_LOCK_POLL_MS)) + } + } +} + async function acquireInstallLock(commonDirectory) { const lockPath = join(commonDirectory, INSTALL_LOCK) const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS @@ -382,7 +394,7 @@ async function acquireInstallLock(commonDirectory) { let observeBarrier = process.env.DSH_TEST_LEFTHOOK_LOCK_OBSERVE_BARRIER while (true) { try { - const lockHandle = openSync(lockPath, 'wx', 0o600) + const lockHandle = await accessInstallLock(() => openSync(lockPath, 'wx', 0o600), deadline) let ownedStat try { ownedStat = fstatSync(lockHandle) @@ -405,14 +417,14 @@ async function acquireInstallLock(commonDirectory) { return () => releaseInstallLock(lockPath, ownedRecord, ownedStat) } catch (error) { if (errorCode(error) !== 'EEXIST') throw error - const existingStat = installLockStat(lockPath) + const existingStat = await accessInstallLock(() => installLockStat(lockPath), deadline) if (existingStat === undefined) continue if (!existingStat.isFile() || existingStat.isSymbolicLink()) { throw manualLockRecoveryError(lockPath, 'invalid') } - const existingRecord = readInstallLock(lockPath) + const existingRecord = await accessInstallLock(() => readInstallLock(lockPath), deadline) if (existingRecord === undefined) continue - const verifiedStat = installLockStat(lockPath) + const verifiedStat = await accessInstallLock(() => installLockStat(lockPath), deadline) if (verifiedStat === undefined) continue if (!verifiedStat.isFile() || verifiedStat.isSymbolicLink()) { throw manualLockRecoveryError(lockPath, 'invalid') diff --git a/scripts/install-lefthook.spec.ts b/scripts/install-lefthook.spec.ts index f31274a1d2..c4e9af44da 100644 --- a/scripts/install-lefthook.spec.ts +++ b/scripts/install-lefthook.spec.ts @@ -183,9 +183,10 @@ function runInstaller( fixture: Fixture, root: string, extraEnv: NodeJS.ProcessEnv = {}, + nodeArgs: string[] = [], ): Promise { return new Promise((resolveResult, reject) => { - const child = spawn(process.execPath, [installer], { + const child = spawn(process.execPath, [...nodeArgs, installer], { cwd: root, env: { ...fixture.env, ...extraEnv }, stdio: ['ignore', 'pipe', 'pipe'], @@ -320,6 +321,56 @@ describe('worktree-local Lefthook installer', { timeout: 90_000 }, () => { expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false) }) + it.each([ + { operation: 'openSync', code: 'EPERM', expires: false }, + { operation: 'readFileSync', code: 'EPERM', expires: false }, + { operation: 'lstatSync', code: 'EPERM', expires: false }, + { operation: 'openSync', code: 'EPERM', expires: true }, + { operation: 'openSync', code: 'EACCES', expires: false }, + ])('handles $operation $code with expired deadline=$expires', async ({ operation, code, expires }) => { + const fixture = createFixture() + const lockPath = installLockPath(fixture) + const probe = join(fixture.container, 'lock-access-probe') + const preload = join(fixture.container, 'lock-access.cjs') + if (operation !== 'openSync') { + writeFileSync(lockPath, `${process.pid} 00000000-0000-4000-8000-000000000001\n`) + } + // The subprocess owns the injected filesystem error and clock; the test process stays unchanged. + writeFileSync(preload, ` +const fs = require('node:fs') +const { syncBuiltinESMExports } = require('node:module') +const lockPath = ${JSON.stringify(lockPath)} +const operation = ${JSON.stringify(operation)} +const original = fs[operation] +const now = Date.now +let injected = false +fs[operation] = function(path, ...args) { + if (path === lockPath && !injected) { + injected = true + fs.writeFileSync(${JSON.stringify(probe)}, 'injected') + if (operation !== 'openSync') fs.unlinkSync(lockPath) + if (${expires}) Date.now = () => now() + 31000 + throw Object.assign(new Error('injected lock access failure'), { code: ${JSON.stringify(code)} }) + } + return original.call(this, path, ...args) +} +syncBuiltinESMExports() +`) + + const result = await runInstaller(fixture, fixture.main, {}, ['--require', preload]) + + expect(readFileSync(probe, 'utf8')).toBe('injected') + const recovers = process.platform === 'win32' && code === 'EPERM' && !expires + expect(result.status, result.stderr).toBe(recovers ? 0 : 1) + if (recovers) { + expect(existsSync(join(hooksPath(fixture, fixture.main), 'pre-push'))).toBe(true) + expect(existsSync(lockPath)).toBe(false) + } else { + expect(result.stderr).toContain('injected lock access failure') + expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false) + } + }) + it('waits for a concurrent installer to finish publishing its lock record', async () => { const fixture = createFixture() const lockPath = installLockPath(fixture)