/** * @fileoverview The pre-push hook that scripts/postinstall.js installs (scripts/git-hooks.mjs). * * Two properties matter more than the hook's contents, because the older pre-commit * installer gets both wrong and this one must not copy it: * 1. It is MARKER-OWNED: a hook the developer wrote by hand is never overwritten. * 2. The hooks directory is resolved through git, since in a worktree `.git` is a FILE * and `/.git/hooks` does not exist, and it is ONLY ever the repo's own * `/hooks`: a `core.hooksPath` elsewhere (typically a global one) is * never written to. * * ⚠️ Every filesystem/git test here runs against THROWAWAY repositories under a temp dir. * Never point the installer at this checkout: its hooks directory is shared with every * worktree of it, including whatever the developer is running right now. */ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { execFileSync, spawnSync } from 'node:child_process'; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync, writeFileSync, } from 'node:fs'; import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { PRE_PUSH_CHECKS, PRE_PUSH_MARKER, PRE_PUSH_WATCHED_PATHS, installPrePushHook, planHookInstall, renderPrePushHook, resolveGitHooksDir, } from '../scripts/git-hooks.mjs'; const repoRoot = resolve(import.meta.dirname, '..'); const read = (rel: string) => readFileSync(resolve(repoRoot, rel), 'utf8'); /** git with no user/system config leaking in (a global core.hooksPath would redirect everything). */ const GIT_ENV = { ...process.env, GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_AUTHOR_NAME: 'test', GIT_AUTHOR_EMAIL: 'test@example.invalid', GIT_COMMITTER_NAME: 'test', GIT_COMMITTER_EMAIL: 'test@example.invalid', CODEMAN_SKIP_PREPUSH: '', }; function git(cwd: string, args: string[], env: NodeJS.ProcessEnv = GIT_ENV): string { return execFileSync('git', args, { cwd, env, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim(); } let scratch: string; beforeAll(() => { scratch = realpathSync(mkdtempSync(join(tmpdir(), 'codeman-git-hooks-'))); }); afterAll(() => { rmSync(scratch, { recursive: true, force: true }); }); let counter = 0; function newRepo(): string { const dir = join(scratch, `repo-${++counter}`); mkdirSync(dir, { recursive: true }); git(dir, ['init', '-q', '-b', 'main']); git(dir, ['commit', '-q', '--allow-empty', '-m', 'init']); return dir; } describe('pre-push hook body', () => { const hook = renderPrePushHook(); it('carries the ownership marker', () => { expect(hook).toContain(PRE_PUSH_MARKER); }); it('runs every configured check through npm, and nothing slow', () => { for (const args of PRE_PUSH_CHECKS) { expect(hook).toContain(`run_check ${args.join(' ')}`); } expect(hook).toContain('npm run --silent "$@"'); // The whole point of the tier: the minutes-long suites stay out of a per-push hook. expect(hook).not.toMatch(/\btest:(ci|browser|mobile|perf|all)\b/); }); it('is POSIX sh', () => { expect(hook.startsWith('#!/bin/sh\n')).toBe(true); const r = spawnSync('sh', ['-n'], { input: hook }); expect(r.status).toBe(0); }); }); describe('pre-push checks match the static CI job', () => { const scripts = JSON.parse(read('package.json')).scripts as Record; const ci = read('.github/workflows/ci.yml'); it.each(PRE_PUSH_CHECKS.map((args) => [args.join(' ')] as const))('%s is a real script that CI runs', (joined) => { const [name] = joined.split(' '); expect(scripts[name], `package.json has no "${name}" script`).toBeTypeOf('string'); expect(ci).toContain(`npm run ${joined}`); }); }); describe('planHookInstall', () => { const hook = renderPrePushHook(); it('writes when no hook exists', () => { expect(planHookInstall({ existing: null, next: hook })).toBe('write'); }); it('refuses to clobber a hook it does not own', () => { expect(planHookInstall({ existing: '#!/bin/sh\nmake lint\n', next: hook })).toBe('skip-foreign'); }); it('refreshes its own hook when the body changed', () => { expect(planHookInstall({ existing: `#!/bin/sh\n${PRE_PUSH_MARKER}\necho old\n`, next: hook })).toBe('write'); }); it('is idempotent when already current', () => { expect(planHookInstall({ existing: hook, next: hook })).toBe('up-to-date'); }); it('treats an empty file as absent rather than foreign', () => { expect(planHookInstall({ existing: ' \n', next: hook })).toBe('write'); }); }); describe('resolveGitHooksDir (temp repos)', () => { it('resolves /.git/hooks in a plain checkout', () => { const repo = newRepo(); expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks')); }); it('resolves the SHARED hooks dir from a worktree, where .git is a file', () => { const repo = newRepo(); const wt = join(scratch, `wt-${counter}`); git(repo, ['worktree', 'add', '-q', wt, '-b', 'wt-branch']); expect(statSync(join(wt, '.git')).isFile()).toBe(true); expect(resolveGitHooksDir(wt)).toBe(join(repo, '.git', 'hooks')); }); it('returns null outside any git checkout', () => { const dir = join(scratch, `plain-${++counter}`); mkdirSync(dir); expect(resolveGitHooksDir(dir)).toBeNull(); }); it('returns null when a repo-local core.hooksPath points outside the repo', () => { const repo = newRepo(); const outside = join(scratch, `shared-hooks-${counter}`); mkdirSync(outside); git(repo, ['config', 'core.hooksPath', outside]); expect(resolveGitHooksDir(repo)).toBeNull(); }); it('returns null when core.hooksPath points at a directory that does not exist yet', () => { const repo = newRepo(); git(repo, ['config', 'core.hooksPath', join(scratch, `missing-${counter}`, 'hooks')]); expect(resolveGitHooksDir(repo)).toBeNull(); }); it("still resolves when core.hooksPath points at the repo's OWN .git/hooks", () => { const repo = newRepo(); git(repo, ['config', 'core.hooksPath', join(repo, '.git', 'hooks')]); expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks')); }); it('resolves before .git/hooks exists (compares the would-be path)', () => { const repo = newRepo(); rmSync(join(repo, '.git', 'hooks'), { recursive: true, force: true }); expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks')); }); it('returns null under a GLOBAL core.hooksPath, from a checkout and from a worktree', () => { const repo = newRepo(); const wt = join(scratch, `wt-global-${counter}`); git(repo, ['worktree', 'add', '-q', wt, '-b', 'wt-global']); const globalHooks = join(scratch, `global-hooks-${counter}`); mkdirSync(globalHooks); const globalConfig = join(scratch, `gitconfig-${counter}`); writeFileSync(globalConfig, `[core]\n\thooksPath = ${globalHooks}\n`); // resolveGitHooksDir runs git with the ambient environment, so scope the fake global // config to this test through process.env (never the developer's real ~/.gitconfig). const saved = { GIT_CONFIG_GLOBAL: process.env.GIT_CONFIG_GLOBAL, GIT_CONFIG_NOSYSTEM: process.env.GIT_CONFIG_NOSYSTEM, }; process.env.GIT_CONFIG_GLOBAL = globalConfig; process.env.GIT_CONFIG_NOSYSTEM = '1'; try { expect(git(repo, ['rev-parse', '--git-path', 'hooks'], { ...GIT_ENV, GIT_CONFIG_GLOBAL: globalConfig })).toBe( globalHooks ); expect(resolveGitHooksDir(repo)).toBeNull(); expect(resolveGitHooksDir(wt)).toBeNull(); } finally { for (const [k, v] of Object.entries(saved)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } } // Control: the same repo resolves again once the global setting is gone. expect(resolveGitHooksDir(repo)).toBe(join(repo, '.git', 'hooks')); }); it("returns null for a copy nested inside someone else's repo (e.g. under node_modules)", () => { const repo = newRepo(); const nested = join(repo, 'node_modules', 'aicodeman'); mkdirSync(nested, { recursive: true }); expect(resolveGitHooksDir(nested)).toBeNull(); }); }); describe('installPrePushHook (temp repos)', () => { it('writes an executable hook into a fresh repo', () => { const hooks = join(newRepo(), '.git', 'hooks'); expect(installPrePushHook(hooks)).toBe('write'); const path = join(hooks, 'pre-push'); expect(readFileSync(path, 'utf8')).toBe(renderPrePushHook()); expect(statSync(path).mode & 0o111).not.toBe(0); expect(installPrePushHook(hooks)).toBe('up-to-date'); }); it('leaves a foreign pre-push hook byte-identical', () => { const hooks = join(newRepo(), '.git', 'hooks'); const path = join(hooks, 'pre-push'); const mine = '#!/bin/sh\n# my own hook\nexit 0\n'; writeFileSync(path, mine, { mode: 0o755 }); expect(installPrePushHook(hooks)).toBe('skip-foreign'); expect(readFileSync(path, 'utf8')).toBe(mine); }); it('refreshes a stale managed hook and keeps it executable', () => { const hooks = join(newRepo(), '.git', 'hooks'); const path = join(hooks, 'pre-push'); writeFileSync(path, `#!/bin/sh\n${PRE_PUSH_MARKER}\necho old\n`, { mode: 0o644 }); expect(installPrePushHook(hooks)).toBe('write'); expect(readFileSync(path, 'utf8')).toBe(renderPrePushHook()); expect(statSync(path).mode & 0o111).not.toBe(0); }); }); /** * Drive the rendered hook through a real `git push` to a local bare remote. The repo gets a * stub package.json whose check scripts only record that they ran, so this exercises the * hook's control flow (ref parsing, skips, blocking) without running the real checks. */ describe('the installed hook on a real push (temp repos)', () => { function setup(opts: { failing?: string; nodeModules?: boolean } = {}) { const repo = newRepo(); const remote = join(scratch, `remote-${counter}.git`); git(scratch, ['init', '-q', '--bare', remote]); git(repo, ['remote', 'add', 'origin', remote]); const log = join(repo, 'ran.log'); const scripts: Record = {}; for (const [name] of PRE_PUSH_CHECKS) { scripts[name] = name === opts.failing ? `echo ${name} >> ran.log && echo boom-${name} && exit 1` : `echo ${name} >> ran.log`; } writeFileSync(join(repo, 'package.json'), JSON.stringify({ name: 'hook-fixture', private: true, scripts })); writeFileSync(join(repo, '.gitignore'), 'node_modules/\nran.log\n'); git(repo, ['add', 'package.json', '.gitignore']); git(repo, ['commit', '-q', '-m', 'fixture']); if (opts.nodeModules !== false) mkdirSync(join(repo, 'node_modules')); installPrePushHook(join(repo, '.git', 'hooks')); chmodSync(join(repo, '.git', 'hooks', 'pre-push'), 0o755); const ran = () => { try { return readFileSync(log, 'utf8').trim().split('\n').filter(Boolean); } catch { return []; } }; const push = (args: string[], env: NodeJS.ProcessEnv = {}) => spawnSync('git', ['push', ...args], { cwd: repo, env: { ...GIT_ENV, ...env }, encoding: 'utf8' }); return { repo, remote, ran, push }; } /** What the stubs record: npm appends the args after `--` to the script, so they prove forwarding. */ const expectedRuns = PRE_PUSH_CHECKS.map((args) => args.filter((a) => a !== '--').join(' ')); it('runs every check before a push, in order', () => { const { ran, push } = setup(); const r = push(['-q', 'origin', 'main']); expect(r.status, r.stderr + r.stdout).toBe(0); expect(ran()).toEqual(expectedRuns); }); it('blocks the push when a check fails, but still runs the rest', () => { const { ran, push, remote } = setup({ failing: 'lint' }); const r = push(['origin', 'main']); expect(r.status).not.toBe(0); expect(r.stdout + r.stderr).toContain('pre-push: FAILED npm run lint'); expect(r.stdout + r.stderr).toContain('boom-lint'); expect(ran()).toEqual(expectedRuns); expect(spawnSync('git', ['rev-parse', '--verify', '-q', 'refs/heads/main'], { cwd: remote }).status).not.toBe(0); }); it('CODEMAN_SKIP_PREPUSH=1 skips every check', () => { const { ran, push } = setup({ failing: 'lint' }); const r = push(['-q', 'origin', 'main'], { CODEMAN_SKIP_PREPUSH: '1' }); expect(r.status, r.stderr).toBe(0); expect(ran()).toEqual([]); }); it('a delete-only push skips the checks', () => { const { ran, push, repo } = setup({ failing: 'lint' }); expect(push(['-q', 'origin', 'main'], { CODEMAN_SKIP_PREPUSH: '1' }).status).toBe(0); git(repo, ['branch', 'doomed']); expect(push(['-q', 'origin', 'doomed'], { CODEMAN_SKIP_PREPUSH: '1' }).status).toBe(0); const r = push(['-q', 'origin', '--delete', 'doomed']); expect(r.status, r.stderr).toBe(0); expect(ran()).toEqual([]); }); it('skips when the pushed ref is not the checked-out HEAD', () => { const { ran, push, repo } = setup({ failing: 'lint' }); git(repo, ['branch', 'other']); git(repo, ['commit', '-q', '--allow-empty', '-m', 'only on main']); git(repo, ['checkout', '-q', 'other']); // HEAD is `other`; pushing `main` would check a working tree that is not main's. const r = push(['origin', 'main']); expect(r.status, r.stderr).toBe(0); expect(r.stdout + r.stderr).toContain( 'pre-push: skipping static checks: refs/heads/main is not the checked-out HEAD' ); expect(ran()).toEqual([]); }); it('skips when any one of several pushed refs is not HEAD', () => { const { ran, push, repo } = setup({ failing: 'lint' }); git(repo, ['branch', 'behind']); git(repo, ['commit', '-q', '--allow-empty', '-m', 'ahead']); const r = push(['origin', 'main', 'behind']); expect(r.status, r.stderr).toBe(0); expect(r.stdout + r.stderr).toContain('is not the checked-out HEAD'); expect(ran()).toEqual([]); }); it('still checks an annotated tag that points at HEAD (the tag is peeled)', () => { const { ran, push, repo } = setup(); git(repo, ['tag', '-a', 'v1', '-m', 'v1']); const r = push(['-q', 'origin', 'v1']); expect(r.status, r.stderr + r.stdout).toBe(0); expect(ran()).toEqual(expectedRuns); }); it.each(['src/wip.ts', 'config/wip.json', 'scripts/wip.mjs', 'test/wip.test.ts', 'install.sh'])( 'skips when %s is untracked (another session may own it)', (rel) => { const { ran, push, repo } = setup({ failing: 'lint' }); mkdirSync(join(repo, rel, '..'), { recursive: true }); writeFileSync(join(repo, rel), 'wip\n'); const r = push(['origin', 'main']); expect(r.status, r.stderr).toBe(0); expect(r.stdout + r.stderr).toContain('pre-push: skipping static checks: uncommitted changes under'); expect(ran()).toEqual([]); } ); it('skips when a tracked package.json has an unstaged edit', () => { const { ran, push, repo } = setup({ failing: 'lint' }); const pkg = join(repo, 'package.json'); writeFileSync(pkg, readFileSync(pkg, 'utf8') + '\n'); const r = push(['origin', 'main']); expect(r.status, r.stderr).toBe(0); expect(r.stdout + r.stderr).toContain('uncommitted changes under'); expect(ran()).toEqual([]); }); it('still checks when the only uncommitted changes are outside the watched paths', () => { const { ran, push, repo } = setup({ failing: 'lint' }); mkdirSync(join(repo, 'docs')); writeFileSync(join(repo, 'docs', 'notes.md'), 'draft\n'); writeFileSync(join(repo, 'README.md'), 'draft\n'); const r = push(['origin', 'main']); expect(r.status).not.toBe(0); expect(r.stdout + r.stderr).toContain('pre-push: FAILED npm run lint'); expect(ran()).toEqual(expectedRuns); }); it('watches exactly the paths the checks read', () => { expect(PRE_PUSH_WATCHED_PATHS).toEqual([ 'src', 'config', 'scripts', 'test', 'package.json', 'package-lock.json', 'install.sh', ]); }); it('skips (never blocks) when node_modules is absent', () => { const { ran, push } = setup({ failing: 'lint', nodeModules: false }); const r = push(['origin', 'main']); expect(r.status, r.stderr).toBe(0); expect(r.stdout + r.stderr).toContain('node_modules missing'); expect(ran()).toEqual([]); }); }); describe('postinstall wiring', () => { const postinstall = read('scripts/postinstall.js'); it('installs the pre-push hook through the shared module', () => { expect(postinstall).toContain("import('./git-hooks.mjs')"); expect(postinstall).toContain('installPrePushHook(gitHooksDir)'); }); it('resolves the hooks dir through git, so worktrees work', () => { expect(postinstall).toContain('resolveGitHooksDir('); expect(postinstall).not.toContain("join(import.meta.dirname, '..', '.git', 'hooks')"); }); });