From e60b5a8a2c0ab72c6053914c0c8438b5bffe37da Mon Sep 17 00:00:00 2001 From: Aamer Akhter Date: Sat, 26 Sep 2026 22:44:14 -0400 Subject: [PATCH] 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 /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. --- .github/CONTRIBUTING.md | 2 +- CLAUDE.md | 5 +- docs/wiki/Contributing.md | 6 +- scripts/check-browser-test-excludes.mjs | 3 + scripts/git-hooks.mjs | 90 +++++++++++++-- scripts/postinstall.js | 2 +- test/git-hooks.test.ts | 143 +++++++++++++++++++++++- 7 files changed, 232 insertions(+), 19 deletions(-) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 1468cff6..2ed1d8f1 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -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 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 diff --git a/CLAUDE.md b/CLAUDE.md index a262f074..9ae9febc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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`) | | 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 (~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 `/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` | @@ -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` | | Multi-user accounts | `codeman users add ` / `passwd ` / `list` / `rm ` (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/**`. diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index 87ce1a0a..c08229ee 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -47,8 +47,10 @@ npm test -- test/.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 (~15s) and -blocks a push that would fail them. Skip it once with `CODEMAN_SKIP_PREPUSH=1 git push`; a +`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 diff --git a/scripts/check-browser-test-excludes.mjs b/scripts/check-browser-test-excludes.mjs index 209160ce..3d3b6233 100644 --- a/scripts/check-browser-test-excludes.mjs +++ b/scripts/check-browser-test-excludes.mjs @@ -19,6 +19,9 @@ * (`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 diff --git a/scripts/git-hooks.mjs b/scripts/git-hooks.mjs index ba2ade8e..a0b5770d 100644 --- a/scripts/git-hooks.mjs +++ b/scripts/git-hooks.mjs @@ -5,12 +5,19 @@ * 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 ~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 * 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 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 * 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 { 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. */ export const PRE_PUSH_MARKER = '# codeman-managed-hook: pre-push v1'; @@ -39,6 +46,25 @@ export const PRE_PUSH_CHECKS = [ ['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. * @@ -46,6 +72,7 @@ export const PRE_PUSH_CHECKS = [ */ 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} @@ -70,16 +97,36 @@ fi # git feeds us " " 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 -while read -r _localref localsha _remoteref _remotesha; do +not_head='' +while read -r localref localsha _remoteref _remotesha; do [ -z "$localsha" ] && continue case "$localsha" in 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 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 @@ -93,7 +140,7 @@ run_check() { fi } -echo "pre-push: running static checks (~15s)..." +echo "pre-push: running static checks (~10-40s)..." ${runs} if [ -n "$failed" ]; then @@ -125,15 +172,33 @@ 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 `/.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 - * copy of this package sitting inside SOMEONE ELSE's repository (e.g. under their + * ⚠️ Returns a directory ONLY when it is this repository's own `/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 @@ -143,9 +208,12 @@ 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']); - if (!hooks) return null; - return isAbsolute(hooks) ? hooks : join(repoRoot, 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; } diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 6878d9f6..414ac2e3 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -387,7 +387,7 @@ fi // 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, ~15s)')); + 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)')); } diff --git a/test/git-hooks.test.ts b/test/git-hooks.test.ts index 04caf9a7..8ba70e87 100644 --- a/test/git-hooks.test.ts +++ b/test/git-hooks.test.ts @@ -4,8 +4,10 @@ * 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 via `git rev-parse --git-path hooks`, since in a - * worktree `.git` is a FILE and `/.git/hooks` does not exist. + * 2. The hooks directory is resolved through git, since in a worktree `.git` is a FILE + * and `/.git/hooks` does not exist, and it is ONLY ever the repo's own + * `/hooks`: a `core.hooksPath` elsewhere (typically a global one) is + * never written to. * * ⚠️ Every filesystem/git test here runs against THROWAWAY repositories under a temp dir. * Never point the installer at this checkout: its hooks directory is shared with every @@ -29,6 +31,7 @@ import { join, resolve } from 'node:path'; import { PRE_PUSH_CHECKS, PRE_PUSH_MARKER, + PRE_PUSH_WATCHED_PATHS, installPrePushHook, planHookInstall, renderPrePushHook, @@ -149,6 +152,64 @@ describe('resolveGitHooksDir (temp repos)', () => { 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'); @@ -259,6 +320,84 @@ describe('the installed hook on a real push (temp repos)', () => { 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']);