Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,15 @@ if [ -z "$(git diff --cached --name-only)" ]; then
exit 0
fi

if command -v npx >/dev/null 2>&1 && npx --yes code-foundry@__VERSION__ pre-commit; then
exit 0
# Prefer a runtime the repository already depends on. A repository opts into
# the gate by depending on code-foundry; one that does not keeps the
# whitespace guard and stays offline. Nothing is fetched at commit time.
if [ -x node_modules/.bin/code-foundry ]; then
exec node_modules/.bin/code-foundry pre-commit
fi

if [ -f src/runtime.mjs ]; then
exec node src/runtime.mjs pre-commit
fi

git diff --cached --check
17 changes: 0 additions & 17 deletions src/commands/sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -260,9 +260,6 @@ function synchronize(options) {
if (file === 'LICENSE' && license !== 'preserve' && license !== 'none') continue
if (file === '.github/CODEOWNERS' && existsSync(destination)) continue
let content = readFileSync(sourceFile)
if (file === '.githooks/pre-commit') {
content = Buffer.from(renderHook(sourceFile, source))
}
if (file === 'release-please-config.json') {
content = Buffer.from(renderReleaseConfig(target, sourceFile))
}
Expand Down Expand Up @@ -589,20 +586,6 @@ function renderReleaseConfig(target, sourceFile) {
return `${JSON.stringify(mergeReleaseConfig(target, sourceFile), null, 2)}\n`
}

/**
* The hook runs `npx code-foundry@<version>` on every commit, so the version
* is substituted at generation time rather than resolved at run time. A
* mutable dist-tag there would let any publish under that tag run code on
* every developer's machine at commit time; pinning means the gate a
* repository received is the gate that generated it.
*
* @param {string} sourceFile the template path
* @param {string} sourceRoot the package root that carries package.json
*/
function renderHook(sourceFile, sourceRoot) {
return readFileSync(sourceFile, 'utf8').replaceAll('__VERSION__', readPackageVersion(sourceRoot))
}

/** @param {string} file @param {string} languages @param {string} features @param {Record<string, string>} config */
function shouldInclude(file, languages, features, config) {
if (file === 'ruff.toml') return includesValue(languages, 'python')
Expand Down
68 changes: 45 additions & 23 deletions test/sync-pre-commit-hook.test.mjs
Original file line number Diff line number Diff line change
@@ -1,59 +1,81 @@
import { strict as assert } from 'node:assert'
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync, chmodSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, it } from 'node:test'
import { syncRepository, readPackageVersion } from '../src/commands/sync.mjs'
import { spawnSync } from 'node:child_process'
import { syncRepository } from '../src/commands/sync.mjs'

function consumer() {
const root = mkdtempSync(join(tmpdir(), 'code-foundry-hook-pin-'))
const root = mkdtempSync(join(tmpdir(), 'cf-hook-'))
mkdirSync(join(root, '.github/workflows'), { recursive: true })
writeFileSync(join(root, 'package.json'), '{"name":"fixture","version":"1.0.0"}\n')
writeFileSync(join(root, 'package.json'), '{"name":"f","version":"1.0.0"}\n')
writeFileSync(
join(root, '.github/code-foundry.yml'),
'languages: typescript\npackage_manager: bun\nfeatures: all\ngit_workflow: direct\nmerge_strategy: squash\nrelease_merge_strategy: squash\n'
)
return root
}
function stage(root) {
writeFileSync(join(root, 'a.txt'), 'x\n')
spawnSync('git', ['init', '-q'], { cwd: root })
spawnSync('git', ['add', 'a.txt'], { cwd: root })
}
function gate(root) {
const p = join(root, '.githooks/pre-commit')
chmodSync(p, 0o755)
return spawnSync(p, [], { cwd: root, encoding: 'utf8' })
}

describe('generated pre-commit hook', () => {
it('never fetches anything at commit time', () => {
const root = consumer()
try {
syncRepository({ target: root, source: process.cwd() })
const h = readFileSync(join(root, '.githooks/pre-commit'), 'utf8')
assert.doesNotMatch(h, /\bnpx\b/, 'must not shell out to npx')
assert.doesNotMatch(h, /https?:/, 'must not reference a registry')
} finally {
rmSync(root, { recursive: true, force: true })
}
})

describe('Generated pre-commit hook', () => {
it('pins the gate to the version that generated it', () => {
it('fails the commit when the depended-on gate fails', () => {
const root = consumer()
try {
syncRepository({ target: root, source: process.cwd() })
const hook = readFileSync(join(root, '.githooks/pre-commit'), 'utf8')
const version = readPackageVersion(process.cwd())
assert.ok(
hook.includes(`code-foundry@${version} pre-commit`),
`expected the gate pinned to ${version}, got: ${hook.split('\n').find((l) => l.includes('npx')) ?? ''}`
)
mkdirSync(join(root, 'node_modules/.bin'), { recursive: true })
writeFileSync(join(root, 'node_modules/.bin/code-foundry'), '#!/bin/sh\nexit 7\n')
chmodSync(join(root, 'node_modules/.bin/code-foundry'), 0o755)
stage(root)
const r = gate(root)
assert.equal(r.status, 7, `gate failure must fail the commit, got ${r.status}`)
} finally {
rmSync(root, { recursive: true, force: true })
}
})

it('never resolves the gate through a mutable dist-tag', () => {
it('falls back to the whitespace guard when the gate is not installed', () => {
const root = consumer()
try {
syncRepository({ target: root, source: process.cwd() })
const hook = readFileSync(join(root, '.githooks/pre-commit'), 'utf8')
// A dist-tag here would let any publish under that tag execute code on
// every developer's machine at commit time.
assert.doesNotMatch(hook, /code-foundry@(latest|next|beta|canary)\b/)
assert.doesNotMatch(hook, /__VERSION__/)
stage(root)
const r = gate(root)
assert.equal(r.status, 0)
} finally {
rmSync(root, { recursive: true, force: true })
}
})

it('keeps the whitespace guard as an offline fallback', () => {
it('blocks a commit with trailing whitespace only when the gate is absent', () => {
const root = consumer()
try {
syncRepository({ target: root, source: process.cwd() })
const hook = readFileSync(join(root, '.githooks/pre-commit'), 'utf8')
assert.match(hook, /git diff --cached --check/)
// The fallback must not be able to short-circuit the gate.
assert.match(hook, /exit 0[\s\S]*npx --yes code-foundry@\d+\.\d+\.\d+ pre-commit/)
writeFileSync(join(root, 'b.txt'), 'trailing \n')
spawnSync('git', ['init', '-q'], { cwd: root })
spawnSync('git', ['add', 'b.txt'], { cwd: root })
const r = gate(root)
assert.notEqual(r.status, 0, 'the fallback must still catch whitespace errors')
} finally {
rmSync(root, { recursive: true, force: true })
}
Expand Down
Loading