build: address review on the pre-push hook and hooks-dir resolution

resolveGitHooksDir now returns a directory only when it is the repo's own
<git-common-dir>/hooks (compared on canonical paths), so a core.hooksPath
elsewhere, global or repo-local, is never written to by postinstall, while a
core.hooksPath pointing back at the repo's own .git/hooks still resolves.

The pre-push hook skips with a one-line notice when a pushed ref is not the
checked-out HEAD (tags peeled) or when git status shows uncommitted or
untracked changes under a path the checks read (src, config, scripts, test,
package.json, package-lock.json, install.sh), since the checks read the
working tree rather than the pushed commit.

Also: honest timing (~10-40s instead of ~15s), CLAUDE.md Session Safety note
on CODEMAN_SKIP_PREPUSH for another session's WIP, 14 (not 9) Playwright
tests, and a note that the browser-excludes check only sees direct imports.
This commit is contained in:
Aamer Akhter
2026-09-26 22:44:14 -04:00
parent 1d85909a06
commit e60b5a8a2c
7 changed files with 232 additions and 19 deletions
+1 -1
View File
@@ -35,7 +35,7 @@ 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 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 (~15s) and blocks the push if one fails. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; it never replaces a `pre-push` hook of your own. `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 ### Tests
+3 -2
View File
@@ -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. - 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. - **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). - 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 ## CRITICAL: Always Test Before Deploying
@@ -113,7 +114,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
| Check public-asset formatting | `npm run check:public-assets` (prettier-checks `src/web/public/**` text assets; `scripts/check-public-assets.mjs`) | | 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) | | 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` | | 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 (~15s) before `git push`. Skip once: `CODEMAN_SKIP_PREPUSH=1 git push`. Marker-owned, so a hand-written `pre-push` is never overwritten; hooks dir resolved via `git rev-parse --git-path hooks` (worktree-safe) | | 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 | | 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 start | `npm run start` |
| Production logs | `journalctl --user -u codeman-web -f` | | Production logs | `journalctl --user -u codeman-web -f` |
@@ -122,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` | | 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) | | 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`, `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 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/**`. **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/**`.
+4 -2
View File
@@ -47,8 +47,10 @@ npm test -- test/<file>.test.ts # one file, the normal way
npm run test:ci # the full CI sweep npm run test:ci # the full CI sweep
``` ```
`npm install` installs a `pre-push` git hook that runs the static checks above (~15s) and `npm install` installs a `pre-push` git hook that runs the static checks above (about 10-40s,
blocks a push that would fail them. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a 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. `pre-push` hook of your own is never overwritten.
**Never run bare `npm test`.** The default configuration includes browser-driven Playwright **Never run bare `npm test`.** The default configuration includes browser-driven Playwright
+3
View File
@@ -19,6 +19,9 @@
* (`inline-rename`, `opencode-resize`, `webgl-fallback`, * (`inline-rename`, `opencode-resize`, `webgl-fallback`,
* `terminal-copy-shortcut`, `codex-predictive-echo`). What actually makes a * `terminal-copy-shortcut`, `codex-predictive-echo`). What actually makes a
* file dangerous is importing a browser driver, so that is what is tested. * 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 * 2. **The exclusion side is answered by vitest itself**, via
* `vitest list --filesOnly`, rather than by re-implementing glob matching * `vitest list --filesOnly`, rather than by re-implementing glob matching
+79 -11
View File
@@ -5,12 +5,19 @@
* Why a pre-push hook: the static CI job (lockfile, typecheck, lint, format, frontend * 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, * 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 * 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 ~15s instead. * 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 * 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 * 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 * 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 and measured ~15s total. * 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 * ⚠️ 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 * postinstall.js which overwrites whatever it finds. A developer's own pre-push hook must
@@ -19,7 +26,7 @@
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { isAbsolute, join } from 'node:path'; import { basename, dirname, join, resolve } from 'node:path';
/** Ownership marker. Bump the version suffix when the body changes meaningfully. */ /** Ownership marker. Bump the version suffix when the body changes meaningfully. */
export const PRE_PUSH_MARKER = '# codeman-managed-hook: pre-push v1'; export const PRE_PUSH_MARKER = '# codeman-managed-hook: pre-push v1';
@@ -39,6 +46,25 @@ export const PRE_PUSH_CHECKS = [
['typecheck'], ['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. * Render the pre-push hook script.
* *
@@ -46,6 +72,7 @@ export const PRE_PUSH_CHECKS = [
*/ */
export function renderPrePushHook() { export function renderPrePushHook() {
const runs = PRE_PUSH_CHECKS.map((args) => `run_check ${args.join(' ')}`).join('\n'); const runs = PRE_PUSH_CHECKS.map((args) => `run_check ${args.join(' ')}`).join('\n');
const watched = PRE_PUSH_WATCHED_PATHS.join(' ');
return `#!/bin/sh return `#!/bin/sh
${PRE_PUSH_MARKER} ${PRE_PUSH_MARKER}
@@ -70,16 +97,36 @@ fi
# git feeds us "<localref> <localsha> <remoteref> <remotesha>" per ref. A deletion has an # 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. # 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 has_content=0
while read -r _localref localsha _remoteref _remotesha; do not_head=''
while read -r localref localsha _remoteref _remotesha; do
[ -z "$localsha" ] && continue [ -z "$localsha" ] && continue
case "$localsha" in case "$localsha" in
0000000000000000000000000000000000000000) ;; 0000000000000000000000000000000000000000) ;;
*) has_content=1 ;; *)
has_content=1
commit=$(git rev-parse -q --verify "$localsha^{commit}" 2>/dev/null)
[ -n "$head" ] && [ "$commit" = "$head" ] || not_head="$localref"
;;
esac esac
done done
[ "$has_content" = "0" ] && exit 0 [ "$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 log=$(mktemp "\${TMPDIR:-/tmp}/codeman-prepush.XXXXXX") || exit 0
trap 'rm -f "$log"' EXIT trap 'rm -f "$log"' EXIT
@@ -93,7 +140,7 @@ run_check() {
fi fi
} }
echo "pre-push: running static checks (~15s)..." echo "pre-push: running static checks (~10-40s)..."
${runs} ${runs}
if [ -n "$failed" ]; then if [ -n "$failed" ]; then
@@ -125,15 +172,33 @@ function git(cwd, args) {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); 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 * Resolve the hooks directory for the checkout rooted at `repoRoot`, or null when there
* is nothing to install into. * is nothing to install into.
* *
* Asks git (`--git-path hooks`) rather than assuming `<root>/.git/hooks`: in a worktree * Asks git (`--git-path hooks`) rather than assuming `<root>/.git/hooks`: in a worktree
* `.git` is a FILE pointing at the parent repo, and `core.hooksPath` can move it anywhere. * `.git` is a FILE pointing at the parent repo, so the hooks live under
* `--git-common-dir`.
* *
* Returns null unless `repoRoot` is itself the top of a work tree. Without that guard, a * ⚠️ Returns a directory ONLY when it is this repository's own `<git-common-dir>/hooks`.
* copy of this package sitting inside SOMEONE ELSE's repository (e.g. under their * `--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. * node_modules) would resolve to their hooks directory and install Codeman's hook there.
* *
* @param {string} repoRoot * @param {string} repoRoot
@@ -143,9 +208,12 @@ export function resolveGitHooksDir(repoRoot) {
try { try {
const top = git(repoRoot, ['rev-parse', '--show-toplevel']); const top = git(repoRoot, ['rev-parse', '--show-toplevel']);
if (!top || realpathSync(top) !== realpathSync(repoRoot)) return null; 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 hooks = git(repoRoot, ['rev-parse', '--git-path', 'hooks']);
if (!hooks) return null; const common = git(repoRoot, ['rev-parse', '--git-common-dir']);
return isAbsolute(hooks) ? hooks : join(repoRoot, hooks); if (!hooks || !common) return null;
const own = join(realpathSync(resolve(repoRoot, common)), 'hooks');
return canonicalPath(resolve(repoRoot, hooks)) === own ? own : null;
} catch { } catch {
return null; return null;
} }
+1 -1
View File
@@ -387,7 +387,7 @@ fi
// hook the developer wrote themselves is left alone. // hook the developer wrote themselves is left alone.
const action = installPrePushHook(gitHooksDir); const action = installPrePushHook(gitHooksDir);
if (action === 'write') { if (action === 'write') {
console.log(colors.green('✓ Git pre-push hook installed') + colors.dim(' (static CI checks, ~15s)')); console.log(colors.green('✓ Git pre-push hook installed') + colors.dim(' (static CI checks, ~10-40s)'));
} else if (action === 'skip-foreign') { } else if (action === 'skip-foreign') {
console.log(colors.dim(' Existing pre-push hook left untouched (not Codeman-managed)')); console.log(colors.dim(' Existing pre-push hook left untouched (not Codeman-managed)'));
} }
+141 -2
View File
@@ -4,8 +4,10 @@
* Two properties matter more than the hook's contents, because the older pre-commit * 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: * 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. * 1. It is MARKER-OWNED: a hook the developer wrote by hand is never overwritten.
* 2. The hooks directory is resolved via `git rev-parse --git-path hooks`, since in a * 2. The hooks directory is resolved through git, since in a worktree `.git` is a FILE
* worktree `.git` is a FILE and `<root>/.git/hooks` does not exist. * 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. * ⚠️ 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 * Never point the installer at this checkout: its hooks directory is shared with every
@@ -29,6 +31,7 @@ import { join, resolve } from 'node:path';
import { import {
PRE_PUSH_CHECKS, PRE_PUSH_CHECKS,
PRE_PUSH_MARKER, PRE_PUSH_MARKER,
PRE_PUSH_WATCHED_PATHS,
installPrePushHook, installPrePushHook,
planHookInstall, planHookInstall,
renderPrePushHook, renderPrePushHook,
@@ -149,6 +152,64 @@ describe('resolveGitHooksDir (temp repos)', () => {
expect(resolveGitHooksDir(dir)).toBeNull(); 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)", () => { it("returns null for a copy nested inside someone else's repo (e.g. under node_modules)", () => {
const repo = newRepo(); const repo = newRepo();
const nested = join(repo, 'node_modules', 'aicodeman'); const nested = join(repo, 'node_modules', 'aicodeman');
@@ -259,6 +320,84 @@ describe('the installed hook on a real push (temp repos)', () => {
expect(ran()).toEqual([]); 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', () => { it('skips (never blocks) when node_modules is absent', () => {
const { ran, push } = setup({ failing: 'lint', nodeModules: false }); const { ran, push } = setup({ failing: 'lint', nodeModules: false });
const r = push(['origin', 'main']); const r = push(['origin', 'main']);