Files
deepseek-harness/scripts/install-lefthook.spec.ts
T
Tianyi Cui a7a5be1703 docs(notes): archive low-future-value Agent Notes
Run the dsh-archive-agent-notes audit over every active Agent Note on
current master, judging each record by whether its rationale still guides
work rather than by size or age.

- Archive 453 implemented bilingual triplets (417,882 English words):
  completed UI chrome, narrow adapters, closed bug fixes, implementation
  walkthroughs whose package READMEs, docs pages, generators, or successor
  notes now carry the useful behavior, and 51 records fully superseded by
  a later active note. Keep 201 implemented notes whose ownership rules,
  negative guarantees, durable or wire semantics, security rules,
  reintroduction conditions, or still-tempting rejected alternatives
  remain useful.
- Reject 7 proposals whose premise is gone or whose work shipped in
  amended form under other records; delete 2 rejected notes that no
  longer prevent a plausible mistake.
- Retarget every remaining inbound link to the archived path, and repair
  active prose that named an archived record as the owner of a live fact:
  parenthetical citations drop, ownership sentences redirect to the
  README, docs page, or active note that states the fact, and history
  citations say so. Chinese files link the English archived path because
  the pairing gate treats the frozen tree as outside the bilingual corpus.
- Seal 1,359 new frozen artifacts; existing seals are unchanged and
  outbound links from archived notes are neither inspected nor repaired.
- Regenerate docs/config-catalog.md after the hook-bridge comment edits
  shifted two source line numbers.
2026-09-05 14:37:32 +08:00

882 lines
40 KiB
TypeScript

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'
import { removeFixtureSafely, unlinkFixtureLinks } from './test-fixture-cleanup.ts'
const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url))
const pairingMergeDriver = 'scripts/merge-translation-pairing-driver.sh %O %A %B %P'
const scriptsDirectory = fileURLToPath(new URL('.', import.meta.url))
const tsxPackageDirectory = dirname(fileURLToPath(import.meta.resolve('tsx/package.json')))
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)) removeFixtureSafely(fixture)
})
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-merge-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 installPairingProbeFixture(root: string): void {
const linkType = process.platform === 'win32' ? 'junction' : 'dir'
symlinkSync(scriptsDirectory, join(root, 'scripts'), linkType)
symlinkSync(tsxPackageDirectory, join(root, 'node_modules/tsx'), linkType)
}
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)
installPairingProbeFixture(main)
installPairingProbeFixture(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() + 10_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 }) })
})
}
// Every case builds scratch worktrees and drives them through spawned Git and
// Node subprocesses, so the suite is bound by process creation rather than by
// its assertions. The value matches DSH_COVERAGE_TEST_TIMEOUT_MS, which the
// Windows coverage lane passes as --testTimeout: a describe value overrides that
// flag rather than yielding to it, so a smaller one here lowers what the lane
// grants every case in this file, none of which carries an allowance of its own.
// Rationale and the paired hook budget are in
// .agents/notes/archived/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md.
describe('worktree-local Lefthook installer', { timeout: 90_000 }, () => {
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)
expect(gitResult(fixture, fixture.main, [
'config', '--get', 'merge.dsh-translation-pairing.driver',
]).status).toBe(1)
})
}
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)
expect(git(fixture, fixture.main, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
])).toBe(pairingMergeDriver)
expect(git(fixture, fixture.linked, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
])).toBe(pairingMergeDriver)
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(existsSync(join(mainHooks, 'pre-merge-commit'))).toBe(true)
expect(existsSync(join(linkedHooks, 'pre-merge-commit'))).toBe(true)
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')
// Windows Git follows the fixture's MOUNT_POINT junctions into their real
// targets while removing a worktree; unlink them first so the removal
// cannot delete the repository's scripts/ or tsx package.
unlinkFixtureLinks(fixture.linked)
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('replaces the owned hook path Git copies into a newly added worktree', async () => {
const fixture = createFixture()
const mainInstall = await runInstaller(fixture, fixture.main)
expect(mainInstall.status, mainInstall.stderr).toBe(0)
const mainHooks = hooksPath(fixture, fixture.main)
const mainHookBefore = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
const lateLinked = join(fixture.container, 'late-linked')
git(fixture, fixture.main, ['worktree', 'add', '-b', 'late-linked', lateLinked])
write(join(lateLinked, 'lefthook.yml'), 'late-linked-worktree-config\n')
installFakeLefthook(lateLinked)
installPairingProbeFixture(lateLinked)
expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
const linkedInstall = await runInstaller(fixture, lateLinked)
expect(linkedInstall.status, linkedInstall.stderr).toBe(0)
const linkedHooks = hooksPath(fixture, lateLinked)
expect(linkedHooks).not.toBe(mainHooks)
expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks)
expect(readFileSync(join(linkedHooks, 'pre-commit'), 'utf8')).toContain(
'# config=late-linked-worktree-config',
)
expect(readFileSync(join(mainHooks, 'pre-commit'), 'utf8')).toBe(mainHookBefore)
})
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('waits for a concurrent installer to finish publishing its lock record', async () => {
const fixture = createFixture()
const lockPath = installLockPath(fixture)
const publishing = runInstaller(fixture, fixture.main, {
DSH_TEST_LEFTHOOK_LOCK_WRITE_DELAY_MS: '200',
})
await waitForPath(lockPath)
expect(readFileSync(lockPath, 'utf8')).toBe('')
const waiting = runInstaller(fixture, fixture.linked)
const results = await Promise.all([publishing, waiting])
for (const result of results) expect(result.status, result.stderr).toBe(0)
expect(existsSync(lockPath)).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' })
try {
await waitForPath(runningPath)
} catch (error) {
await install
throw error
}
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('does not trust an ownership marker outside a registered worktree hook path', async () => {
const fixture = createFixture()
const mainInstall = await runInstaller(fixture, fixture.main)
expect(mainInstall.status, mainInstall.stderr).toBe(0)
const externalHooks = join(fixture.container, 'external-owned-hooks')
write(
join(externalHooks, '.dsh-lefthook-owned'),
`${JSON.stringify({
version: 1,
owner: 'deepseek-harness worktree-local lefthook hooks',
hooksPath: externalHooks,
})}\n`,
0o600,
)
git(fixture, fixture.linked, ['config', '--worktree', 'core.hooksPath', externalHooks])
const result = await runInstaller(fixture, fixture.linked)
expect(result.status).toBe(1)
expect(result.stderr).toContain('worktree-scoped core.hooksPath')
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(externalHooks)
expect(existsSync(hooksPath(fixture, fixture.linked))).toBe(false)
})
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(JSON.stringify(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(gitResult(fixture, fixture.main, [
'config', '--get', 'merge.dsh-translation-pairing.driver',
]).status).toBe(1)
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
})
it('never replaces a custom worktree pairing merge driver', async () => {
const fixture = createFixture()
const commonConfig = join(commonDirectory(fixture), 'config')
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', '--worktree', 'merge.dsh-translation-pairing.driver', 'custom-driver %A',
])
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('refusing to replace worktree merge.dsh-translation-pairing.driver')
expect(git(fixture, fixture.main, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
])).toBe('custom-driver %A')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
})
it('never masks an inherited custom pairing merge driver', async () => {
const fixture = createFixture()
git(fixture, fixture.main, [
'config', '--local', 'merge.dsh-translation-pairing.driver', 'inherited-driver %A',
])
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('refusing to mask inherited merge.dsh-translation-pairing.driver')
expect(git(fixture, fixture.main, [
'config', '--local', '--get', 'merge.dsh-translation-pairing.driver',
])).toBe('inherited-driver %A')
expect(gitResult(fixture, fixture.main, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
]).status).toBe(1)
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
})
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(gitResult(fixture, fixture.main, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.name',
]).status).toBe(1)
expect(gitResult(fixture, fixture.main, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
]).status).toBe(1)
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy pre-push\n')
})
it('does not publish worktree integration when the pairing driver probe fails', async () => {
const fixture = createFixture()
rmSync(join(fixture.main, 'node_modules/tsx'), { recursive: true, force: true })
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('merge-translation-pairing.ts --probe failed')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
expect(gitResult(fixture, fixture.main, [
'config', '--get', 'merge.dsh-translation-pairing.driver',
]).status).toBe(1)
})
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 integration rollback also failed')
expect(result.stderr).toContain('git config --worktree --unset-all core.hooksPath failed')
expect(result.stderr).toContain('git config --worktree --unset-all merge.dsh-translation-pairing.driver 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)
})
})