fix(dev-infra): isolate Lefthook per worktree
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
# 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. The installer requires Git 2.20 or newer, upgrades a format-0 repository to format 1, enables `extensions.worktreeConfig`, and assigns the current worktree an absolute `core.hooksPath` at `$GIT_DIR/dsh-hooks`. The main worktree receives `$GIT_COMMON_DIR/dsh-hooks`; each linked worktree receives the corresponding directory under `$GIT_COMMON_DIR/worktrees/<id>`. A repository-scoped lock serializes configuration migration and hook writes, including repeated concurrent installs.
|
||||
|
||||
The installer recognizes its hook directory with a private ownership marker and updates it idempotently. It refuses an unowned directory or a worktree-specific custom `core.hooksPath`. An inherited global or common-repository hook path is preserved by default; `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1` explicitly lets only the current worktree override it, so worktrees without that override continue using the inherited path. This opt-in does not attempt to chain arbitrary hook managers.
|
||||
|
||||
Enabling worktree config removes the standard redundant `core.bare=false` value from the common config because false remains Git's default; an explicit `core.worktree` or `core.bare=true` is refused for manual migration. If Lefthook fails during a first install, the installer removes the new worktree override so the prior inherited or common hooks remain active. Legacy files in `$GIT_COMMON_DIR/hooks` are never removed or rewritten by the worktree-local installer.
|
||||
|
||||
[`install-lefthook.spec.ts`](../../../../scripts/install-lefthook.spec.ts) exercises main and linked worktrees, removal independence, repeated and concurrent installs, the Git version boundary, custom-path refusal and opt-in, legacy common-hook preservation, and failed-install 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.
|
||||
|
||||
**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 and rejects clients older than Git 2.20. 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.
|
||||
+5
-3
@@ -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.20 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,16 @@ 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`. The wrapper 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 to replace an existing user-owned `core.hooksPath`. If an inherited global or repository path should remain active in other worktrees while this worktree opts into lefthook, inspect that path first and rerun with `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; a worktree-specific custom path is never overwritten and must be integrated or removed explicitly.
|
||||
|
||||
Run typecheck once after a fresh clone:
|
||||
|
||||
```sh
|
||||
|
||||
+1
-1
@@ -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:
|
||||
|
||||
+277
-16
@@ -1,21 +1,282 @@
|
||||
#!/usr/bin/env node
|
||||
import { existsSync } from 'node:fs'
|
||||
import { existsSync, lstatSync, mkdirSync, 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, 20, 0]
|
||||
const HOOKS_DIRECTORY = 'dsh-hooks'
|
||||
const OWNERSHIP_MARKER = '.dsh-lefthook-owned'
|
||||
const OWNERSHIP_MARKER_CONTENT = 'deepseek-harness worktree-local lefthook hooks\n'
|
||||
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 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 fileConfigValues(root, configPath, key) {
|
||||
return nulValues(git(
|
||||
['config', '--file', configPath, '--null', '--get-all', key],
|
||||
root,
|
||||
{ allowStatuses: [1] },
|
||||
))
|
||||
}
|
||||
|
||||
function effectiveConfigValue(root, key) {
|
||||
const values = nulValues(git(
|
||||
['config', '--null', '--get', key],
|
||||
root,
|
||||
{ allowStatuses: [1] },
|
||||
))
|
||||
if (values.length > 1) throw new Error(`git config returned multiple effective values for ${key}`)
|
||||
return values[0]
|
||||
}
|
||||
|
||||
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 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.20 or newer is required for worktree-local hooks; found ${version}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function ensureWorktreeConfig(root, commonConfigPath) {
|
||||
const versions = fileConfigValues(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)}`)
|
||||
}
|
||||
|
||||
const worktrees = fileConfigValues(root, commonConfigPath, 'core.worktree')
|
||||
if (worktrees.length > 0) {
|
||||
throw new Error('cannot enable extensions.worktreeConfig while core.worktree is in the common config; move it to the main worktree config first')
|
||||
}
|
||||
|
||||
const bareText = assertSingle(fileConfigValues(root, commonConfigPath, 'core.bare'), 'core.bare')
|
||||
const bare = bareText === undefined ? undefined : parseGitBoolean(bareText, 'core.bare')
|
||||
if (bare === true) {
|
||||
throw new Error('cannot enable extensions.worktreeConfig for a common config with core.bare=true')
|
||||
}
|
||||
|
||||
const extensionText = assertSingle(
|
||||
fileConfigValues(root, commonConfigPath, 'extensions.worktreeConfig'),
|
||||
'extensions.worktreeConfig',
|
||||
)
|
||||
const extensionEnabled = extensionText === undefined
|
||||
? false
|
||||
: parseGitBoolean(extensionText, 'extensions.worktreeConfig')
|
||||
|
||||
if (version === 0) {
|
||||
git(['config', '--file', commonConfigPath, 'core.repositoryFormatVersion', '1'], root)
|
||||
}
|
||||
if (!extensionEnabled) {
|
||||
git(['config', '--file', commonConfigPath, 'extensions.worktreeConfig', 'true'], root)
|
||||
}
|
||||
if (bare === false) {
|
||||
git(['config', '--file', commonConfigPath, '--unset-all', 'core.bare'], root)
|
||||
}
|
||||
}
|
||||
|
||||
function lockOwnerIsAlive(lockPath) {
|
||||
let owner
|
||||
try {
|
||||
owner = Number(readFileSync(lockPath, 'utf8').trim())
|
||||
} catch (error) {
|
||||
if (errorCode(error) === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
if (!Number.isSafeInteger(owner) || owner <= 0) return true
|
||||
try {
|
||||
process.kill(owner, 0)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (errorCode(error) === 'ESRCH') return false
|
||||
if (errorCode(error) === 'EPERM') return true
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function removeStaleLock(lockPath) {
|
||||
try {
|
||||
unlinkSync(lockPath)
|
||||
} catch (error) {
|
||||
if (errorCode(error) !== 'ENOENT') throw error
|
||||
// Another waiting installer removed the same stale lock first.
|
||||
}
|
||||
}
|
||||
|
||||
async function acquireInstallLock(commonDirectory) {
|
||||
const lockPath = join(commonDirectory, INSTALL_LOCK)
|
||||
const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS
|
||||
while (true) {
|
||||
try {
|
||||
writeFileSync(lockPath, `${String(process.pid)}\n`, { flag: 'wx', mode: 0o600 })
|
||||
return () => removeStaleLock(lockPath)
|
||||
} catch (error) {
|
||||
if (errorCode(error) !== 'EEXIST') throw error
|
||||
if (!lockOwnerIsAlive(lockPath)) {
|
||||
removeStaleLock(lockPath)
|
||||
continue
|
||||
}
|
||||
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 ensureOwnedHooksDirectory(hooksPath) {
|
||||
const markerPath = join(hooksPath, OWNERSHIP_MARKER)
|
||||
if (!existsSync(hooksPath)) {
|
||||
mkdirSync(hooksPath, { mode: 0o700 })
|
||||
writeFileSync(markerPath, OWNERSHIP_MARKER_CONTENT, { flag: 'wx', mode: 0o600 })
|
||||
return
|
||||
}
|
||||
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)
|
||||
if (!markerStat.isFile() || markerStat.isSymbolicLink() || readFileSync(markerPath, 'utf8') !== OWNERSHIP_MARKER_CONTENT) {
|
||||
throw new Error(`refusing to overwrite hooks directory with an invalid ownership marker: ${hooksPath}`)
|
||||
}
|
||||
}
|
||||
|
||||
function runLefthook(root, lefthook) {
|
||||
const args = ['install', '--force']
|
||||
// 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, stdio: 'inherit', shell: true })
|
||||
: spawnSync(lefthook, args, { cwd: root, stdio: 'inherit' })
|
||||
if (result.status !== 0) throw commandFailure(lefthook, args, result)
|
||||
}
|
||||
|
||||
function refuseCustomHooksPath(root, hooksPath) {
|
||||
const origin = git(
|
||||
['config', '--show-origin', '--get', 'core.hooksPath'],
|
||||
root,
|
||||
{ allowStatuses: [1] },
|
||||
).stdout.trim()
|
||||
const source = origin === '' ? hooksPath : origin
|
||||
throw new Error(
|
||||
`refusing to replace user-owned core.hooksPath (${source}). `
|
||||
+ `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`,
|
||||
)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const probe = spawnSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' })
|
||||
if (probe.status !== 0) return
|
||||
const root = probe.stdout.trim()
|
||||
const isWindows = process.platform === 'win32'
|
||||
const lefthook = join(root, 'node_modules', '.bin', isWindows ? 'lefthook.cmd' : 'lefthook')
|
||||
if (!existsSync(lefthook)) return
|
||||
|
||||
assertSupportedGit(root)
|
||||
const gitDirectory = git(['rev-parse', '--absolute-git-dir'], root).stdout.trim()
|
||||
const commonOutput = git(['rev-parse', '--git-common-dir'], root).stdout.trim()
|
||||
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)
|
||||
|
||||
try {
|
||||
const worktreePath = assertSingle(
|
||||
fileConfigValues(root, worktreeConfigPath, 'core.hooksPath'),
|
||||
'worktree core.hooksPath',
|
||||
)
|
||||
if (worktreePath !== undefined && worktreePath !== hooksPath) refuseCustomHooksPath(root, worktreePath)
|
||||
|
||||
const effectivePath = effectiveConfigValue(root, 'core.hooksPath')
|
||||
const effectivePathIsOwned = effectivePath === hooksPath && worktreePath === hooksPath
|
||||
if (
|
||||
effectivePath !== undefined
|
||||
&& !effectivePathIsOwned
|
||||
&& process.env[ALLOW_HOOKS_PATH_OVERRIDE] !== '1'
|
||||
) {
|
||||
refuseCustomHooksPath(root, effectivePath)
|
||||
}
|
||||
|
||||
ensureOwnedHooksDirectory(hooksPath)
|
||||
ensureWorktreeConfig(root, commonConfigPath)
|
||||
|
||||
let pathChanged = false
|
||||
try {
|
||||
git(['config', '--worktree', 'core.hooksPath', hooksPath], root)
|
||||
pathChanged = worktreePath === undefined
|
||||
runLefthook(root, lefthook)
|
||||
} catch (error) {
|
||||
if (pathChanged) {
|
||||
git(['config', '--worktree', '--unset-all', 'core.hooksPath'], root)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
} finally {
|
||||
releaseLock()
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await main()
|
||||
} catch (error) {
|
||||
console.error(`[install-lefthook] ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
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 root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()
|
||||
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 (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(): Fixture {
|
||||
const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-'))
|
||||
fixtures.push(container)
|
||||
const main = join(container, 'main')
|
||||
const linked = join(container, 'linked')
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
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 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', () => {
|
||||
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('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('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('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 worktree-config 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.19.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.20 or newer is required')
|
||||
expect(gitResult(fixture, fixture.main, ['config', '--get', 'extensions.worktreeConfig']).status).toBe(1)
|
||||
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user