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
9 changes: 9 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
#!/usr/bin/env sh
# Generated by Code Foundry — see src/commands/sync.mjs.
set -eu

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
fi

git diff --cached --check
10 changes: 10 additions & 0 deletions src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,16 @@ async function main() {
} catch (error) {
fail(error instanceof Error ? error.message : String(error))
}
} else if (command === 'pre-commit') {
// The generated `.githooks/pre-commit` calls this. Without a CLI entry
// point the language-aware gate in runtime-core is unreachable from a hook.
try {
process.chdir(target)
const { preCommit } = await import('./runtime-core.mjs')
preCommit()
} catch (error) {
fail(error instanceof Error ? error.message : String(error))
}
} else fail(`unknown command: ${command}`)
}

Expand Down
14 changes: 14 additions & 0 deletions src/commands/doctor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ export function doctor(root, options = {}) {
}
const hooks = git(target, ['config', '--get', 'core.hooksPath'])
if (hooks !== '.githooks') warn('Git hooks are not enabled; run `npx code-foundry init`')
// A correctly enabled hook that enforces nothing is the failure this check
// could not see: the generated template used to be the whitespace guard
// alone while the real gate sat unused in the runtime. Reading only the
// hooksPath reports success for a hook that does nothing.
if (hooks === '.githooks') {
const hookFile = join(target, '.githooks/pre-commit')
if (!existsSync(hookFile)) warn('No .githooks/pre-commit found; run `npx code-foundry sync`')
else if (
!/code-foundry[@\w./-]*\s+(?:--yes\s+)?pre-commit/.test(readFileSync(hookFile, 'utf8'))
)
warn(
'.githooks/pre-commit does not delegate to the Code Foundry gate; run `npx code-foundry sync`'
)
}
if (
['rust', 'python', 'solidity'].some((language) =>
profile.languages.split(',').includes(language)
Expand Down
17 changes: 17 additions & 0 deletions src/commands/sync.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,9 @@ 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 @@ -586,6 +589,20 @@ 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
58 changes: 45 additions & 13 deletions src/runtime-core.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ import {
mkdirSync,
readdirSync,
readFileSync,
realpathSync,
writeFileSync,
} from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
import { detectPackageManager, resolveProfile } from './lib/profile.mjs'
import { configured, readConfig } from './lib/config.mjs'
Expand Down Expand Up @@ -1165,14 +1167,26 @@ function printProfile(command) {
writeOutput('npm_publish', config.npm_publish ?? 'false')
}

function preCommit() {
/**
* The local commit gate.
*
* Exported so the generated `.githooks/pre-commit` can reach it: the hook is
* what turns this into an actual gate, and it used to run the whitespace
* check alone. The generated hook is the consumer-visible contract, so keep
* the two in step.
*/
export function preCommit() {
const changed = capture('git', ['diff', '--cached', '--name-only'])
if (!changed) return
const check = spawnSync('git', ['diff', '--cached', '--check'], { cwd: root, stdio: 'inherit' })
if (check.status !== 0) process.exit(check.status ?? 1)
if (/\.(js|jsx|ts|tsx|json|md|mdx|yml|yaml)$/.test(changed)) {
ci('format')
ci('lint')
// Type errors are the fastest check that still needs a full toolchain, and
// the most common reason a green-looking commit fails CI later. Run it
// here while the dependency graph is warm.
ci('type_check')
}
if (/\.rs$|(^|\/)Cargo\.toml$/.test(changed)) {
run('cargo', ['fmt', '--check'])
Expand All @@ -1184,16 +1198,34 @@ function preCommit() {
}
}

const [area, task, ecosystem] = process.argv.slice(2)
try {
if (area === 'ci') ci(task)
else if (area === 'security') security(task, ecosystem)
else if (area === 'codeql') codeql()
else if (area === 'profile') printProfile(task)
else if (area === 'validation') validation(task)
else if (area === 'pre-commit') preCommit()
else throw new Error(`Unknown runtime command: ${area || '(missing)'}`)
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
// Only dispatch when this module is the process entry point. The CLI imports
// `preCommit` from here, and an unguarded dispatch would read argv and run the
// gate a second time on import.
const invokedDirectly =
process.argv[1] !== undefined &&
realpathSafe(process.argv[1]) === realpathSafe(fileURLToPath(import.meta.url))

/** @param {string} p @returns {string} */
function realpathSafe(p) {
try {
return realpathSync(p)
} catch {
return p
}
}

if (invokedDirectly) {
const [area, task, ecosystem] = process.argv.slice(2)
try {
if (area === 'ci') ci(task)
else if (area === 'security') security(task, ecosystem)
else if (area === 'codeql') codeql()
else if (area === 'profile') printProfile(task)
else if (area === 'validation') validation(task)
else if (area === 'pre-commit') preCommit()
else throw new Error(`Unknown runtime command: ${area || '(missing)'}`)
} catch (error) {
console.error(error instanceof Error ? error.message : String(error))
process.exitCode = 1
}
}
3 changes: 3 additions & 0 deletions src/templates/gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ pnpm-debug.log*
.fleet/
.opencode/
.kluster/
.zcode/
.kiro/
.goose/
.direnv/
.envrc
.vercel/
Expand Down
61 changes: 61 additions & 0 deletions test/sync-pre-commit-hook.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { strict as assert } from 'node:assert'
import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } 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'

function consumer() {
const root = mkdtempSync(join(tmpdir(), 'code-foundry-hook-pin-'))
mkdirSync(join(root, '.github/workflows'), { recursive: true })
writeFileSync(join(root, 'package.json'), '{"name":"fixture","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
}

describe('Generated pre-commit hook', () => {
it('pins the gate to the version that generated it', () => {
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')) ?? ''}`
)
} finally {
rmSync(root, { recursive: true, force: true })
}
})

it('never resolves the gate through a mutable dist-tag', () => {
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__/)
} finally {
rmSync(root, { recursive: true, force: true })
}
})

it('keeps the whitespace guard as an offline fallback', () => {
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/)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
})
Loading