mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #500 from aakhter/pr/prepush-browser-excludes
build: add a browser-test exclusion check and a pre-push static-check hook
This commit is contained in:
@@ -32,8 +32,11 @@ npm run typecheck # tsc --noEmit, strict mode
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm run check:frontend-syntax # syntax-checks the plain-JS frontend modules
|
||||
npm run check:browser-excludes # every browser-driven test is kept out of `npm test`
|
||||
```
|
||||
|
||||
`npm install` also installs a `pre-push` git hook that runs these static checks (about 10-40s, machine-dependent) and blocks the push if one fails. It skips itself when you push something other than the checked-out HEAD, or when the tree has uncommitted changes the checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; it never replaces a `pre-push` hook of your own.
|
||||
|
||||
### Tests
|
||||
|
||||
```bash
|
||||
|
||||
@@ -34,6 +34,13 @@ jobs:
|
||||
- name: Frontend JS syntax check
|
||||
run: npm run check:frontend-syntax
|
||||
|
||||
# Asks `vitest list` what CI would actually collect, rather than matching
|
||||
# filenames: a browser-driven test missing from BROWSER_TEST_GLOBS
|
||||
# (config/test-suites.ts) passes locally and dies in the test job with
|
||||
# "browserType.launch: Executable doesn't exist".
|
||||
- name: Browser-test exclusion check
|
||||
run: npm run check:browser-excludes
|
||||
|
||||
- name: Format check
|
||||
run: npm run format:check
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- To land a commit on master **without** switching branches (which would yank the tree out from under the other session): `git push origin HEAD:master` then `git branch -f master HEAD`. Never `git checkout master` to "fix" it.
|
||||
- **Never `git add -A`/`git add .`** — stage explicit paths. A sweep will pick up another session's WIP.
|
||||
- Another session's broken WIP can block `npm run build`, since `tsc` is the first step and the build gates on it. That is not your bug to fix. ⚠️ `tsc` still EMITS on type errors, so a failed `npm run build` leaves a rebuilt `dist/index.js` compiled from their tree; check what it pulled in before restarting the service. To deploy frontend-only changes past a blocked `tsc`, run the asset stage of `scripts/build.mjs` (everything after the `tsc`/`chmod` lines is independent of it).
|
||||
- **A pre-push failure in a file you did not touch is another session's WIP.** Push with `CODEMAN_SKIP_PREPUSH=1 git push` and leave it alone. (The hook already skips itself when the tree has uncommitted changes in a path it checks, so this mostly happens once the other session has committed.)
|
||||
|
||||
## CRITICAL: Always Test Before Deploying
|
||||
|
||||
@@ -112,6 +113,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| Gesture playground | `npm run dev` **in** `packages/gesture-control/` (standalone vite demo, fake tabs) |
|
||||
| Check public-asset formatting | `npm run check:public-assets` (prettier-checks `src/web/public/**` text assets; `scripts/check-public-assets.mjs`) |
|
||||
| Frontend JS syntax check | `npm run check:frontend-syntax` (`scripts/check-frontend-syntax.mjs`; runs in CI) |
|
||||
| Browser-test exclusion check | `npm run check:browser-excludes` (`scripts/check-browser-test-excludes.mjs`; runs in CI, <1s). Fails if a test importing playwright/puppeteer is still collected by `config/vitest.ci.config.ts`; add it to `BROWSER_TEST_GLOBS` in `config/test-suites.ts` |
|
||||
| Pre-push hook | Installed by `npm install` (`scripts/git-hooks.mjs`, via postinstall): runs the static CI checks (~10-40s) before `git push`. Skip once: `CODEMAN_SKIP_PREPUSH=1 git push`. Skips itself with a notice when HEAD is not the pushed commit or the tree has uncommitted changes the checks would read. Marker-owned, so a hand-written `pre-push` is never overwritten; installs ONLY into the repo's own `<git-common-dir>/hooks` (worktree-safe; a `core.hooksPath` elsewhere, e.g. a global one, is left alone) |
|
||||
| Excluded-suite runners | `npm run test:browser` · `npm run test:mobile` · `npm run test:perf` · `npm run test:all` (everything, environmental failures included) — see Testing |
|
||||
| Production start | `npm run start` |
|
||||
| Production logs | `journalctl --user -u codeman-web -f` |
|
||||
@@ -120,7 +123,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
| Dependency doctor | `codeman doctor` (alias `check-deps`; `--json`, `--category core\|office\|other`). Probes Node/Claude CLI/tmux/LibreOffice/MS Office against `config/dependency-registry.ts`; engine is pure given an injectable `ProbeHost` |
|
||||
| Multi-user accounts | `codeman users add <name>` / `passwd <name>` / `list` / `rm <name>` (writes `~/.codeman/users.json`, mode 0600; see Multi-user mode) |
|
||||
|
||||
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 9 Playwright tests; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`).
|
||||
**CI**: `.github/workflows/ci.yml` (push to master/main + PRs, Node 22) runs two jobs: **(1)** `check:lockfile`, `typecheck`, `lint`, `check:frontend-syntax`, `check:browser-excludes`, `format:check`, then a **server boot smoke test** (`tsx src/index.ts web --port 3151` must answer `/api/status` within 30s); **(2)** the **unit/integration test suite** via `npm run test:ci` (`config/vitest.ci.config.ts` — excludes the browser-driven `test/mobile/**` suite, `perf-*` benchmarks, and 14 Playwright tests; globs live in `config/test-suites.ts`), followed by the **`packages/xterm-zerolag-input` package tests** (a bare `npx vitest run` in that directory; its vitest is hoisted by the root `npm ci`, so no separate install, and `npm test` at the root does NOT run them). `npm test` runs this same config, so local green == CI green. Tests are tmux-safe in CI: `TmuxManager` no-ops all shell commands under `VITEST` (see Testing). A third workflow, `wiki-sync.yml`, fires only on master pushes touching `docs/wiki/**` and mirrors that directory to the GitHub wiki (browser edits to the wiki are overwritten by the next sync, so fix pages via `docs/wiki/`).
|
||||
|
||||
**Code style**: Prettier (`singleQuote: true`, `printWidth: 120`, `trailingComma: "es5"`) — config lives in the **`"prettier"` key of `package.json`**, not a `.prettierrc` (keeps the repo root short; editors read it natively). `.prettierignore` stays at the root because Prettier resolves it relative to cwd. ESLint flat config (`config/eslint.config.js`) allows `no-console`, warns on `@typescript-eslint/no-explicit-any`. Ignores: `app.js`, `scripts/**/*.mjs`, `src/web/public/vendor/**`, `scripts/remotion/**`.
|
||||
|
||||
|
||||
@@ -42,10 +42,17 @@ npm run typecheck
|
||||
npm run lint
|
||||
npm run format:check
|
||||
npm run check:frontend-syntax
|
||||
npm run check:browser-excludes
|
||||
npm test -- test/<file>.test.ts # one file, the normal way
|
||||
npm run test:ci # the full CI sweep
|
||||
```
|
||||
|
||||
`npm install` installs a `pre-push` git hook that runs the static checks above (about 10-40s,
|
||||
machine-dependent) and blocks a push that would fail them. It skips itself when you push
|
||||
something other than the checked-out HEAD, or when the tree has uncommitted changes the
|
||||
checks would read. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a
|
||||
`pre-push` hook of your own is never overwritten.
|
||||
|
||||
**Never run bare `npm test`.** The default configuration includes browser-driven Playwright
|
||||
suites that need a live server, Chromium, and environment-specific baselines; they hang or
|
||||
fail on a normal machine. `test:ci` is the honest "run everything".
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
"pretest:mobile": "node scripts/prepare-test-vendor.mjs",
|
||||
"test:mobile": "vitest run --config test/mobile/vitest.config.ts",
|
||||
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
|
||||
"check:browser-excludes": "node scripts/check-browser-test-excludes.mjs",
|
||||
"fix:node-pty": "node scripts/fix-node-pty.mjs",
|
||||
"typecheck": "tsc --noEmit && tsc -p config/tsconfig.scripts.json",
|
||||
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Browser-test exclusion check.
|
||||
*
|
||||
* `npm run test:ci` must never try to drive a real browser: CI runners (and any
|
||||
* clean checkout) have no chromium, so such a file dies with
|
||||
* `browserType.launch: Executable doesn't exist` and takes the whole suite with
|
||||
* it. `config/vitest.ci.config.ts` therefore excludes every browser-driven test
|
||||
* via `BROWSER_TEST_GLOBS` in `config/test-suites.ts`. That list is maintained
|
||||
* BY HAND, and a new browser test simply does not appear in it unless someone
|
||||
* remembers. The omission is invisible on a developer machine that has run
|
||||
* `npx playwright install`, where the test passes, and only shows up on a clean
|
||||
* runner.
|
||||
*
|
||||
* Two deliberate design choices:
|
||||
*
|
||||
* 1. **Detection is by CONTENT, not filename.** Matching `*.browser.test.ts`
|
||||
* would miss the browser tests that predate that convention
|
||||
* (`inline-rename`, `opencode-resize`, `webgl-fallback`,
|
||||
* `terminal-copy-shortcut`, `codex-predictive-echo`). What actually makes a
|
||||
* file dangerous is importing a browser driver, so that is what is tested.
|
||||
* ⚠️ Only a DIRECT import is seen: a test that reaches playwright through a
|
||||
* helper module (e.g. `test/mobile/helpers/browser.ts`) is not detected, so
|
||||
* such a test still has to be added to `BROWSER_TEST_GLOBS` by hand.
|
||||
*
|
||||
* 2. **The exclusion side is answered by vitest itself**, via
|
||||
* `vitest list --filesOnly`, rather than by re-implementing glob matching
|
||||
* against the config's `exclude` array. Patterns there include `test/mobile/**`
|
||||
* and `perf-*`; a hand-rolled matcher that disagreed with vitest by even one
|
||||
* edge case would report a gap that does not exist, or miss one that does.
|
||||
* Asking the real resolver cannot drift from the real behaviour.
|
||||
*
|
||||
* The pure pieces are exported for test/check-browser-test-excludes.test.ts; the
|
||||
* check itself only runs when this file is executed directly.
|
||||
*/
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join, dirname, relative, sep, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
|
||||
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const CI_CONFIG = join('config', 'vitest.ci.config.ts');
|
||||
const SUITES_FILE = join('config', 'test-suites.ts');
|
||||
|
||||
/** Importing any one of these means the test needs a real browser binary. */
|
||||
const BROWSER_DRIVER =
|
||||
/\bfrom\s+['"](?:playwright|playwright-core|@playwright\/test|puppeteer|puppeteer-core)['"]|\b(?:require|import)\(\s*['"](?:playwright|playwright-core|@playwright\/test|puppeteer|puppeteer-core)['"]\s*\)/;
|
||||
|
||||
/** @param {string} source */
|
||||
export function importsBrowserDriver(source) {
|
||||
return BROWSER_DRIVER.test(source);
|
||||
}
|
||||
|
||||
/** @param {string} dir @returns {string[]} */
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const path = join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...walk(path));
|
||||
else if (entry.isFile() && entry.name.endsWith('.test.ts')) out.push(path);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every `*.test.ts` under `<root>/test` that imports a browser driver, as sorted
|
||||
* repo-relative POSIX paths (the form `vitest list` prints).
|
||||
*
|
||||
* @param {string} root
|
||||
* @returns {string[]}
|
||||
*/
|
||||
export function findBrowserTests(root) {
|
||||
return walk(join(root, 'test'))
|
||||
.filter((file) => importsBrowserDriver(readFileSync(file, 'utf8')))
|
||||
.map((file) => relative(root, file).split(sep).join('/'))
|
||||
.sort();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse `vitest list --filesOnly` output into a set of repo-relative paths. Stray
|
||||
* blank or decorative lines are ignored rather than assuming the format is pristine.
|
||||
*
|
||||
* @param {string} output
|
||||
* @returns {Set<string>}
|
||||
*/
|
||||
export function parseVitestFileList(output) {
|
||||
return new Set(
|
||||
output
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.endsWith('.test.ts'))
|
||||
.map((line) => line.replace(/^\.\//, ''))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string[]} browserTests
|
||||
* @param {Set<string>} ciFiles
|
||||
* @returns {string[]} browser-driven files that the CI config would still collect
|
||||
*/
|
||||
export function findLeaks(browserTests, ciFiles) {
|
||||
return browserTests.filter((file) => ciFiles.has(file));
|
||||
}
|
||||
|
||||
function main() {
|
||||
const browserTests = findBrowserTests(ROOT);
|
||||
|
||||
let collected;
|
||||
try {
|
||||
collected = execFileSync('npx', ['vitest', 'list', '--config', CI_CONFIG, '--filesOnly'], {
|
||||
cwd: ROOT,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('✗ could not enumerate the CI test set via `vitest list`.');
|
||||
console.error(err.stderr ? err.stderr.toString() : String(err));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ciFiles = parseVitestFileList(collected);
|
||||
if (ciFiles.size === 0) {
|
||||
// An empty list would make every browser test look excluded: fail rather than pass vacuously.
|
||||
console.error('✗ `vitest list` reported no test files; refusing to pass on an empty CI set.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const leaked = findLeaks(browserTests, ciFiles);
|
||||
|
||||
if (leaked.length > 0) {
|
||||
console.error(`✗ ${leaked.length} browser-driven test file(s) are NOT excluded from ${CI_CONFIG}:\n`);
|
||||
for (const file of leaked) console.error(` ${file}`);
|
||||
console.error(`
|
||||
These import a browser driver, so on a runner with no chromium they fail with
|
||||
"browserType.launch: Executable doesn't exist" and take the suite down. Add each
|
||||
to BROWSER_TEST_GLOBS in ${SUITES_FILE} (${CI_CONFIG} derives its excludes from
|
||||
it, and \`npm run test:browser\` its includes).
|
||||
|
||||
They may well pass on this machine; that is the trap. To reproduce a clean
|
||||
runner locally:
|
||||
PLAYWRIGHT_BROWSERS_PATH=\$(mktemp -d) PUPPETEER_CACHE_DIR=\$(mktemp -d) npm run test:ci`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✓ all ${browserTests.length} browser-driven test files are excluded from the CI suite (${ciFiles.size} files collected)`
|
||||
);
|
||||
}
|
||||
|
||||
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
main();
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* @fileoverview Git hook bodies + install policy, shared by scripts/postinstall.js and
|
||||
* pinned by test/git-hooks.test.ts.
|
||||
*
|
||||
* Why a pre-push hook: the static CI job (lockfile, typecheck, lint, format, frontend
|
||||
* syntax, ...) fails often on things a contributor could have caught locally in seconds,
|
||||
* and finding out after a push costs a full CI round-trip plus a fix-up commit. Running
|
||||
* the same checks before the push surfaces those failures in ~10-40s instead (12s on a fast
|
||||
* workstation, ~35s measured elsewhere; typecheck, format:check and lint dominate).
|
||||
*
|
||||
* Why pre-PUSH and not pre-commit: a commit is cheap and local, a push is what CI and
|
||||
* reviewers pick up. And why the STATIC tier only: the unit/integration suite takes
|
||||
* minutes, which nobody tolerates per push, so a hook that ran it would be bypassed
|
||||
* within a day. The checks below mirror the static CI job.
|
||||
*
|
||||
* ⚠️ The checks read the WORKING TREE, not the commits being pushed. So the hook skips
|
||||
* (with a one-line notice) whenever the two can differ: when HEAD is not the commit being
|
||||
* pushed, and when `git status` shows uncommitted or untracked changes in a path a check
|
||||
* reads ({@link PRE_PUSH_WATCHED_PATHS}). In a checkout shared by several agent sessions
|
||||
* the second case is usually another session's WIP, which must not block this push.
|
||||
*
|
||||
* ⚠️ This installer is deliberately MARKER-OWNED, unlike the older pre-commit installer in
|
||||
* postinstall.js which overwrites whatever it finds. A developer's own pre-push hook must
|
||||
* survive `npm install`.
|
||||
*/
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
||||
import { basename, dirname, join, resolve } from 'node:path';
|
||||
|
||||
/** Ownership marker. Bump the version suffix when the body changes meaningfully. */
|
||||
export const PRE_PUSH_MARKER = '# codeman-managed-hook: pre-push v1';
|
||||
|
||||
/**
|
||||
* Checks that make up the fast tier, cheapest first so failures surface sooner. Each entry
|
||||
* is the argument list for `npm run`, and each is a step of the static job in
|
||||
* .github/workflows/ci.yml (test/git-hooks.test.ts pins that every script exists).
|
||||
*/
|
||||
export const PRE_PUSH_CHECKS = [
|
||||
['check:lockfile'],
|
||||
['generate:cli-catalog', '--', '--check'],
|
||||
['check:browser-excludes'],
|
||||
['check:frontend-syntax'],
|
||||
['format:check'],
|
||||
['lint'],
|
||||
['typecheck'],
|
||||
];
|
||||
|
||||
/**
|
||||
* Paths whose uncommitted state would leak into a check, so a dirty one makes the hook skip.
|
||||
* Derived from what each check reads: src/ (format:check, lint, typecheck,
|
||||
* check:frontend-syntax), config/ (eslint + vitest configs, test-suites.ts, the CLI
|
||||
* catalogue), scripts/ (every check is a script there, and typecheck's second pass compiles
|
||||
* one), test/ (check:browser-excludes scans it and runs `vitest list` over it),
|
||||
* package.json + package-lock.json (check:lockfile) and install.sh (generate:cli-catalog
|
||||
* --check diffs its generated block).
|
||||
*/
|
||||
export const PRE_PUSH_WATCHED_PATHS = [
|
||||
'src',
|
||||
'config',
|
||||
'scripts',
|
||||
'test',
|
||||
'package.json',
|
||||
'package-lock.json',
|
||||
'install.sh',
|
||||
];
|
||||
|
||||
/**
|
||||
* Render the pre-push hook script.
|
||||
*
|
||||
* POSIX sh, not bash: this ships to whatever shell the contributor's git uses.
|
||||
*/
|
||||
export function renderPrePushHook() {
|
||||
const runs = PRE_PUSH_CHECKS.map((args) => `run_check ${args.join(' ')}`).join('\n');
|
||||
const watched = PRE_PUSH_WATCHED_PATHS.join(' ');
|
||||
|
||||
return `#!/bin/sh
|
||||
${PRE_PUSH_MARKER}
|
||||
# Installed by scripts/postinstall.js. Edit scripts/git-hooks.mjs, not this file:
|
||||
# it is regenerated on npm install. Delete the marker line above to take ownership
|
||||
# and the installer will leave your version alone.
|
||||
#
|
||||
# Skip once: CODEMAN_SKIP_PREPUSH=1 git push
|
||||
# Skip always: remove this file.
|
||||
|
||||
[ "$CODEMAN_SKIP_PREPUSH" = "1" ] && exit 0
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
|
||||
cd "$repo_root" || exit 0
|
||||
|
||||
# Nothing to check without dependencies (fresh clone, or a worktree that never ran
|
||||
# npm install). Warn rather than blocking the push on a setup detail.
|
||||
if [ ! -d node_modules ]; then
|
||||
echo "pre-push: node_modules missing, skipping checks (run 'npm install' to enable them)."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# git feeds us "<localref> <localsha> <remoteref> <remotesha>" per ref. A deletion has an
|
||||
# all-zero local sha and no tree worth checking; if every ref is a deletion, skip.
|
||||
# The checks below read the working tree, so they only say something about a pushed commit
|
||||
# that IS the checked-out HEAD (tags are peeled to their commit first).
|
||||
head=$(git rev-parse -q --verify HEAD 2>/dev/null)
|
||||
has_content=0
|
||||
not_head=''
|
||||
while read -r localref localsha _remoteref _remotesha; do
|
||||
[ -z "$localsha" ] && continue
|
||||
case "$localsha" in
|
||||
0000000000000000000000000000000000000000) ;;
|
||||
*)
|
||||
has_content=1
|
||||
commit=$(git rev-parse -q --verify "$localsha^{commit}" 2>/dev/null)
|
||||
[ -n "$head" ] && [ "$commit" = "$head" ] || not_head="$localref"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
[ "$has_content" = "0" ] && exit 0
|
||||
|
||||
if [ -n "$not_head" ]; then
|
||||
echo "pre-push: skipping static checks: $not_head is not the checked-out HEAD, and the checks read the working tree."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Uncommitted or untracked changes in a path a check reads would be judged instead of the
|
||||
# pushed commit. In a checkout shared by several sessions that is usually someone else's WIP.
|
||||
if [ -n "$(git --no-optional-locks status --porcelain -- ${watched} 2>/dev/null)" ]; then
|
||||
echo "pre-push: skipping static checks: uncommitted changes under ${watched} would be checked instead of the pushed commit."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
log=$(mktemp "\${TMPDIR:-/tmp}/codeman-prepush.XXXXXX") || exit 0
|
||||
trap 'rm -f "$log"' EXIT
|
||||
|
||||
failed=''
|
||||
run_check() {
|
||||
if ! npm run --silent "$@" >"$log" 2>&1; then
|
||||
echo ""
|
||||
echo "pre-push: FAILED npm run $*"
|
||||
tail -n 25 "$log"
|
||||
failed="$failed $1"
|
||||
fi
|
||||
}
|
||||
|
||||
echo "pre-push: running static checks (~10-40s)..."
|
||||
${runs}
|
||||
|
||||
if [ -n "$failed" ]; then
|
||||
echo ""
|
||||
echo "pre-push: blocked by:$failed"
|
||||
echo "Fix, or push anyway with: CODEMAN_SKIP_PREPUSH=1 git push"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "pre-push: static checks passed."
|
||||
exit 0
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide what to do with an existing hook file.
|
||||
*
|
||||
* @param {{ existing: string | null | undefined, next: string }} args
|
||||
* @returns {'write' | 'up-to-date' | 'skip-foreign'}
|
||||
*/
|
||||
export function planHookInstall({ existing, next }) {
|
||||
if (existing === null || existing === undefined || existing.trim() === '') return 'write';
|
||||
if (!existing.includes(PRE_PUSH_MARKER)) return 'skip-foreign';
|
||||
return existing === next ? 'up-to-date' : 'write';
|
||||
}
|
||||
|
||||
/** @param {string} cwd @param {string[]} args */
|
||||
function git(cwd, args) {
|
||||
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* realpath() that tolerates a missing leaf: a fresh `.git` may have no `hooks/` yet, so
|
||||
* canonicalize the parent and re-append the name. Throws if the parent is missing too.
|
||||
*
|
||||
* @param {string} path
|
||||
*/
|
||||
function canonicalPath(path) {
|
||||
return existsSync(path) ? realpathSync(path) : join(realpathSync(dirname(path)), basename(path));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the hooks directory for the checkout rooted at `repoRoot`, or null when there
|
||||
* is nothing to install into.
|
||||
*
|
||||
* Asks git (`--git-path hooks`) rather than assuming `<root>/.git/hooks`: in a worktree
|
||||
* `.git` is a FILE pointing at the parent repo, so the hooks live under
|
||||
* `--git-common-dir`.
|
||||
*
|
||||
* ⚠️ Returns a directory ONLY when it is this repository's own `<git-common-dir>/hooks`.
|
||||
* `--git-path hooks` also reports `core.hooksPath`, and that setting is often GLOBAL (a
|
||||
* shared hooks directory used by every repo on the machine); installing there would
|
||||
* overwrite the user's own hooks and run Codeman's checks on unrelated repos. A
|
||||
* `core.hooksPath` that points back at the repo's own hooks dir still resolves, because
|
||||
* the comparison is on canonical paths rather than on whether the setting exists.
|
||||
*
|
||||
* Also returns null unless `repoRoot` is itself the top of a work tree. Without that guard,
|
||||
* a copy of this package sitting inside SOMEONE ELSE's repository (e.g. under their
|
||||
* node_modules) would resolve to their hooks directory and install Codeman's hook there.
|
||||
*
|
||||
* @param {string} repoRoot
|
||||
* @returns {string | null}
|
||||
*/
|
||||
export function resolveGitHooksDir(repoRoot) {
|
||||
try {
|
||||
const top = git(repoRoot, ['rev-parse', '--show-toplevel']);
|
||||
if (!top || realpathSync(top) !== realpathSync(repoRoot)) return null;
|
||||
// Both are printed relative to the cwd (repoRoot) unless already absolute.
|
||||
const hooks = git(repoRoot, ['rev-parse', '--git-path', 'hooks']);
|
||||
const common = git(repoRoot, ['rev-parse', '--git-common-dir']);
|
||||
if (!hooks || !common) return null;
|
||||
const own = join(realpathSync(resolve(repoRoot, common)), 'hooks');
|
||||
return canonicalPath(resolve(repoRoot, hooks)) === own ? own : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Install (or refresh) the managed pre-push hook in `hooksDir`, honouring
|
||||
* {@link planHookInstall}: a hook without the marker is never touched.
|
||||
*
|
||||
* @param {string} hooksDir
|
||||
* @returns {'write' | 'up-to-date' | 'skip-foreign'}
|
||||
*/
|
||||
export function installPrePushHook(hooksDir) {
|
||||
const path = join(hooksDir, 'pre-push');
|
||||
const next = renderPrePushHook();
|
||||
const existing = existsSync(path) ? readFileSync(path, 'utf8') : null;
|
||||
const action = planHookInstall({ existing, next });
|
||||
if (action === 'write') {
|
||||
mkdirSync(hooksDir, { recursive: true });
|
||||
writeFileSync(path, next, { mode: 0o755 });
|
||||
chmodSync(path, 0o755); // `mode` only applies when the file is created
|
||||
}
|
||||
return action;
|
||||
}
|
||||
+16
-4
@@ -356,14 +356,17 @@ if (!isGlobalInstall) {
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
// 5. Install git pre-commit hook (format check)
|
||||
// 5. Install git hooks (pre-commit format check, pre-push static checks)
|
||||
// ----------------------------------------------------------------------------
|
||||
|
||||
if (!isGlobalInstall) {
|
||||
try {
|
||||
const { writeFileSync, mkdirSync } = await import('fs');
|
||||
const gitHooksDir = join(import.meta.dirname, '..', '.git', 'hooks');
|
||||
if (existsSync(join(import.meta.dirname, '..', '.git'))) {
|
||||
const { resolveGitHooksDir, installPrePushHook } = await import('./git-hooks.mjs');
|
||||
// Resolved through git, not `../.git/hooks`: in a worktree `.git` is a file.
|
||||
// null when this directory is not the top of a git checkout.
|
||||
const gitHooksDir = resolveGitHooksDir(join(import.meta.dirname, '..'));
|
||||
if (gitHooksDir) {
|
||||
mkdirSync(gitHooksDir, { recursive: true });
|
||||
const hook = `#!/bin/bash
|
||||
# Auto-installed by postinstall — prevents CI format failures
|
||||
@@ -379,9 +382,18 @@ fi
|
||||
const hookPath = join(gitHooksDir, 'pre-commit');
|
||||
writeFileSync(hookPath, hook, { mode: 0o755 });
|
||||
console.log(colors.green('✓ Git pre-commit hook installed (prettier check)'));
|
||||
|
||||
// Unlike the pre-commit hook above, this one is marker-owned: a pre-push
|
||||
// hook the developer wrote themselves is left alone.
|
||||
const action = installPrePushHook(gitHooksDir);
|
||||
if (action === 'write') {
|
||||
console.log(colors.green('✓ Git pre-push hook installed') + colors.dim(' (static CI checks, ~10-40s)'));
|
||||
} else if (action === 'skip-foreign') {
|
||||
console.log(colors.dim(' Existing pre-push hook left untouched (not Codeman-managed)'));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-critical — git hook is a convenience
|
||||
// Non-critical — git hooks are a convenience
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @fileoverview scripts/check-browser-test-excludes.mjs: the detection side (which test
|
||||
* files need a real browser) and the leak computation. The exclusion side is vitest's own
|
||||
* `vitest list`, which `npm run check:browser-excludes` exercises for real in CI.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import {
|
||||
findBrowserTests,
|
||||
findLeaks,
|
||||
importsBrowserDriver,
|
||||
parseVitestFileList,
|
||||
} from '../scripts/check-browser-test-excludes.mjs';
|
||||
import { BROWSER_TEST_GLOBS } from '../config/test-suites';
|
||||
|
||||
const repoRoot = resolve(import.meta.dirname, '..');
|
||||
|
||||
// Fixture sources are assembled from the module name at runtime, so THIS file never contains
|
||||
// a literal driver import and is not itself flagged by the checker it tests.
|
||||
const fromImport = (mod: string) => `import { chromium, type Browser } from '${mod}';\n`;
|
||||
|
||||
describe('importsBrowserDriver', () => {
|
||||
it.each([
|
||||
fromImport('playwright'),
|
||||
fromImport('playwright-core').replace(/'/g, '"'),
|
||||
fromImport('@playwright/test'),
|
||||
fromImport('puppeteer'),
|
||||
`import type { Page } from '${'playwright'}';`,
|
||||
`const { chromium } = require('${'playwright'}');`,
|
||||
`const pw = await import('${'playwright'}');`,
|
||||
])('flags %s', (src) => {
|
||||
expect(importsBrowserDriver(src)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"import { describe } from 'vitest';",
|
||||
"// needs ms-playwright's cache dir\nconst dir = '.cache/ms-playwright';",
|
||||
fromImport('./playwright-helpers'),
|
||||
fromImport('playwright-extra-thing'),
|
||||
])('ignores %s', (src) => {
|
||||
expect(importsBrowserDriver(src)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findBrowserTests (fixture tree)', () => {
|
||||
let root: string;
|
||||
beforeAll(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'codeman-browser-excludes-'));
|
||||
const put = (rel: string, src: string) => {
|
||||
mkdirSync(join(root, rel, '..'), { recursive: true });
|
||||
writeFileSync(join(root, rel), src);
|
||||
};
|
||||
put('test/unit.test.ts', "import { it } from 'vitest';\n");
|
||||
put('test/legacy-name.test.ts', fromImport('playwright'));
|
||||
put('test/new.browser.test.ts', fromImport('playwright'));
|
||||
put('test/nested/deep.test.ts', fromImport('puppeteer'));
|
||||
put('test/helpers/browser.ts', fromImport('playwright')); // not a test file
|
||||
});
|
||||
afterAll(() => rmSync(root, { recursive: true, force: true }));
|
||||
|
||||
it('finds driver imports by content, recursively, as sorted repo-relative paths', () => {
|
||||
expect(findBrowserTests(root)).toEqual([
|
||||
'test/legacy-name.test.ts',
|
||||
'test/nested/deep.test.ts',
|
||||
'test/new.browser.test.ts',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseVitestFileList + findLeaks', () => {
|
||||
it('keeps only test paths and normalizes a leading ./', () => {
|
||||
const out = '\n./test/a.test.ts\ntest/b.test.ts\nsome banner line\n test/c.test.ts \n';
|
||||
expect([...parseVitestFileList(out)].sort()).toEqual(['test/a.test.ts', 'test/b.test.ts', 'test/c.test.ts']);
|
||||
});
|
||||
|
||||
it('reports exactly the browser tests the CI set still collects', () => {
|
||||
const ci = new Set(['test/unit.test.ts', 'test/legacy-name.test.ts']);
|
||||
expect(findLeaks(['test/legacy-name.test.ts', 'test/new.browser.test.ts'], ci)).toEqual([
|
||||
'test/legacy-name.test.ts',
|
||||
]);
|
||||
expect(findLeaks(['test/new.browser.test.ts'], ci)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('against this repository', () => {
|
||||
it('detects every file already listed in BROWSER_TEST_GLOBS', () => {
|
||||
// If detection stopped recognising a known browser test, the checker would go blind to
|
||||
// exactly the class of file it exists for.
|
||||
const detected = new Set(findBrowserTests(repoRoot));
|
||||
const literals = BROWSER_TEST_GLOBS.filter((g) => !/[*?[{]/.test(g));
|
||||
expect(literals.length).toBeGreaterThan(0);
|
||||
for (const file of literals) expect(detected, file).toContain(file);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,422 @@
|
||||
/**
|
||||
* @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 `<root>/.git/hooks` does not exist, and it is ONLY ever the repo's own
|
||||
* `<git-common-dir>/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<string, string>;
|
||||
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 <root>/.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<string, string> = {};
|
||||
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')");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user