Skip to content

Commit c00bd76

Browse files
authored
fix(uncheck): write the pre-commit hook husky 9 and Vite+ actually run (#9)
1 parent b405260 commit c00bd76

3 files changed

Lines changed: 153 additions & 3 deletions

File tree

‎packages/uncheck/README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,7 @@ npx uncheck prepare --pre-commit # write .git/hooks/pre-commit so every commit
132132

133133
`staged` runs the checks on the files staged for commit. The unstaged hunks of partially staged files (`git add -p`) are set aside while the checks run, so what `oxlint` and `oxfmt` see is what gets committed, then put back. The typecheck works differently by nature: `tsc` checks whole projects, so it also reports type errors in files you have not staged. Add `--only=oxlint --only=oxfmt` for a hook that never looks beyond the commit. With `--fix` the fixes are staged too. When a fix conflicts with an unstaged hunk, the fixes are undone and the commit fails, so nothing is ever lost: stage the whole file or stash its unstaged changes and commit again. A run killed before it puts them back leaves copies of the files in the git directory, and the next run stops and says where they are.
134134

135-
`prepare --pre-commit` replaces lint-staged and simple-git-hooks: it writes the git hook itself, running `uncheck staged --fix` through your package manager, and adds itself to an existing `pre-commit` hook rather than replacing it. Running it again only ever rewrites the line it wrote itself, so lines you added by hand stay, a repeated copy of its own line is dropped, and in a monorepo each package that prepares gets its own line with its own flags. Without a flag `prepare` sets nothing up. Register it as the `prepare` script so every clone installs the hook:
135+
`prepare --pre-commit` replaces lint-staged and simple-git-hooks: it writes the git hook itself, running `uncheck staged --fix` through your package manager, and adds itself to an existing `pre-commit` hook rather than replacing it. With husky 9 or Vite+ (`vp config`) managing the hooks, it writes to `.husky/pre-commit` or `.vite-hooks/pre-commit`, the file their dispatcher runs, rather than to the generated shim in `_/`, which never reaches an added line and is rewritten on the next install. Running it again only ever rewrites the line it wrote itself, so lines you added by hand stay, a repeated copy of its own line is dropped, and in a monorepo each package that prepares gets its own line with its own flags. Without a flag `prepare` sets nothing up. Register it as the `prepare` script so every clone installs the hook:
136136

137137
```json
138138
{

‎packages/uncheck/src/commands/prepare.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,13 @@ export const prepare = Command.make(
109109
const inside = path.relative(top!, yield* fs.realPath(cwd)).replaceAll('\\', '/')
110110
const line = hookLine(inside, command)
111111

112-
const file = path.join(path.resolve(cwd, hooks!), 'pre-commit')
112+
// husky 9 and Vite+ point core.hooksPath at generated shims that source the `h` dispatcher, which
113+
// exits before any line appended to a shim and runs the hook in the folder above instead.
114+
const configured = path.resolve(cwd, hooks!)
115+
const dispatched = yield* fs
116+
.exists(path.join(configured, 'h'))
117+
.pipe(Effect.orElseSucceed(() => false))
118+
const file = path.join(dispatched ? path.dirname(configured) : configured, 'pre-commit')
113119
const relative = path.relative(cwd, file)
114120
const shown = relative.startsWith('..') ? file : relative
115121
const existing = yield* fs.readFileString(file).pipe(Effect.option)
@@ -128,7 +134,11 @@ export const prepare = Command.make(
128134
yield* fs.writeFileString(file, next)
129135
}
130136

131-
yield* fs.chmod(file, 0o755)
137+
// The dispatcher runs the hook with `sh`, and flipping the mode of a committed hook would leave
138+
// every clone with a change to commit.
139+
if (!dispatched) {
140+
yield* fs.chmod(file, 0o755)
141+
}
132142
}).pipe(
133143
Effect.as(undefined),
134144
Effect.catch((error) =>

‎packages/uncheck/tests/command.test.ts‎

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,6 +1414,146 @@ describe('uncheck prepare', { timeout: 120_000 }, () => {
14141414
expect(skipped.result).toBe('ok')
14151415
expect(skipped.stdout).toBe('○ no git repository found, nothing to prepare\n')
14161416
})
1417+
1418+
it('writes the hook the husky 9 and Vite+ dispatcher runs, not their generated shim', async () => {
1419+
const dispatcher = [
1420+
's="$(dirname "$(dirname "$0")")/$(basename "$0")"',
1421+
'[ ! -f "$s" ] && exit 0',
1422+
'export PATH="node_modules/.bin:$PATH"',
1423+
'sh -e "$s" "$@"',
1424+
'exit $?',
1425+
'',
1426+
].join('\n')
1427+
const shim = '#!/usr/bin/env sh\n. "$(dirname "$0")/h"\n'
1428+
const dir = fixture(
1429+
{
1430+
'package.json': '{}\n',
1431+
'pnpm-lock.yaml': '',
1432+
'packages/app/package.json': '{}\n',
1433+
'.husky/_/h': dispatcher,
1434+
'.husky/_/pre-commit': shim,
1435+
'.husky/pre-commit': 'pnpm test\n',
1436+
},
1437+
[],
1438+
)
1439+
gitIn(dir, 'init', '--quiet')
1440+
gitIn(dir, 'config', 'core.hooksPath', '.husky/_')
1441+
chmodSync(join(dir, '.husky/_/pre-commit'), 0o755)
1442+
const hook = join(dir, '.husky/pre-commit')
1443+
const app = '(cd "packages/app" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1'
1444+
1445+
const { result, stdout } = await run(dir, ['prepare', '--pre-commit'])
1446+
1447+
expect(result).toBe('ok')
1448+
expect(stdout).toContain('✔ pre-commit .husky/pre-commit updated\n')
1449+
1450+
const nested = await run(join(dir, 'packages/app'), [
1451+
'prepare',
1452+
'--pre-commit',
1453+
'--only=oxlint',
1454+
])
1455+
1456+
expect(nested.stdout).toContain(`✔ pre-commit ${hook} updated\n`)
1457+
expect(readFileSync(hook, 'utf8')).toBe(
1458+
`pnpm test\npnpm exec uncheck staged --fix || exit 1\n${app}\n`,
1459+
)
1460+
expect(readFileSync(join(dir, '.husky/_/pre-commit'), 'utf8')).toBe(shim)
1461+
1462+
const again = await run(dir, ['prepare', '--pre-commit'])
1463+
1464+
expect(again.stdout).toContain('✔ pre-commit .husky/pre-commit unchanged\n')
1465+
1466+
if (process.platform !== 'win32') {
1467+
expect(statSync(hook).mode & 0o111).toBe(0)
1468+
1469+
const bin = fixture(
1470+
{ pnpm: '#!/bin/sh\necho "$(pwd -P) $*" >> "$LOG"\ntest ! -e fail\n' },
1471+
[],
1472+
)
1473+
const log = join(bin, 'log')
1474+
chmodSync(join(bin, 'pnpm'), 0o755)
1475+
1476+
function commit() {
1477+
writeFileSync(log, '')
1478+
1479+
const { status } = spawnSync(
1480+
'git',
1481+
[
1482+
'-c',
1483+
'user.name=uncheck',
1484+
'-c',
1485+
'user.email=uncheck@example.com',
1486+
'commit',
1487+
'--allow-empty',
1488+
'--quiet',
1489+
'--message=test',
1490+
],
1491+
{
1492+
cwd: dir,
1493+
env: { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH}`, LOG: log },
1494+
},
1495+
)
1496+
1497+
return { status, ran: readFileSync(log, 'utf8').split('\n').filter(Boolean) }
1498+
}
1499+
1500+
const top = realpathSync(dir)
1501+
1502+
expect(commit()).toEqual({
1503+
status: 0,
1504+
ran: [
1505+
`${top} test`,
1506+
`${top} exec uncheck staged --fix`,
1507+
`${top}/packages/app exec uncheck staged --fix --only=oxlint`,
1508+
],
1509+
})
1510+
1511+
writeFileSync(join(dir, 'fail'), '')
1512+
1513+
expect(commit()).toEqual({ status: 1, ran: [`${top} test`] })
1514+
}
1515+
1516+
const vite = fixture(
1517+
{ 'package.json': '{}\n', 'pnpm-lock.yaml': '', '.vite-hooks/_/h': dispatcher },
1518+
[],
1519+
)
1520+
gitIn(vite, 'init', '--quiet')
1521+
gitIn(vite, 'config', 'core.hooksPath', '.vite-hooks/_')
1522+
1523+
const created = await run(vite, ['prepare', '--pre-commit'])
1524+
1525+
expect(created.stdout).toContain('✔ pre-commit .vite-hooks/pre-commit created\n')
1526+
expect(readFileSync(join(vite, '.vite-hooks/pre-commit'), 'utf8')).toBe(
1527+
`${header}pnpm exec uncheck staged --fix || exit 1\n`,
1528+
)
1529+
expect(existsSync(join(vite, '.vite-hooks/_/pre-commit'))).toBe(false)
1530+
})
1531+
1532+
it('writes into a core.hooksPath set by hand as it is', async () => {
1533+
const dir = fixture(
1534+
{ 'package.json': '{}\n', 'pnpm-lock.yaml': '', 'packages/app/package.json': '{}\n' },
1535+
[],
1536+
)
1537+
gitIn(dir, 'init', '--quiet')
1538+
gitIn(dir, 'config', 'core.hooksPath', '.githooks')
1539+
const hook = join(dir, '.githooks/pre-commit')
1540+
1541+
const { result, stdout } = await run(dir, ['prepare', '--pre-commit'])
1542+
1543+
expect(result).toBe('ok')
1544+
expect(stdout).toContain('✔ pre-commit .githooks/pre-commit created\n')
1545+
1546+
await run(join(dir, 'packages/app'), ['prepare', '--pre-commit'])
1547+
1548+
expect(readFileSync(hook, 'utf8')).toBe(
1549+
`${header}pnpm exec uncheck staged --fix || exit 1\n(cd "packages/app" && pnpm exec uncheck staged --fix) || exit 1\n`,
1550+
)
1551+
expect(existsSync(join(dir, '.git/hooks/pre-commit'))).toBe(false)
1552+
1553+
if (process.platform !== 'win32') {
1554+
expect(statSync(hook).mode & 0o111).toBe(0o111)
1555+
}
1556+
})
14171557
})
14181558

14191559
describe('uncheck presets', { timeout: 120_000 }, () => {

0 commit comments

Comments
 (0)