mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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/**`.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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']);
|
||||||
|
|||||||
Reference in New Issue
Block a user