Files
Codeman/test/git-hooks.test.ts
T
Codeman maintainer ae28534406 feat(install): install without administrator rights (--no-admin)
Codeman itself always lived under $HOME; administrator rights were only
spent on what it needs first (system packages for Node.js, tmux and git,
Homebrew on a Mac). An account without those rights could not install it:
the installer died in ensure_sudo, or in Homebrew's own installer.

install.sh now has a no-admin route, taken by itself when the account has
no administrator rights (no sudo binary; outside sudo/wheel/admin and
`sudo -n true` fails; a refused sudo password; a Mac account outside the
admin group with no writable Homebrew) and forced with --no-admin or
CODEMAN_NO_ADMIN=1:

- Node.js: the official tarball from nodejs.org, checked against the
  SHASUMS256.txt published beside it, into ~/.codeman/runtime/node.
- tmux: the tmux project's own build (tmux/tmux-builds 3.7c), checked
  against a SHA-256 pinned in the installer, into ~/.codeman/runtime/bin.
- Both are linked from ~/.local/bin (never over a file that is not ours),
  and the runtime is on PATH and on the LaunchAgent's PATH.
- git is never installed. Without a working git the source comes as an
  archive, the install is marked with .install-source, and it updates by
  re-running the installer: the new tree is built beside the old one and
  swapped in only after the build succeeded. A later re-run on a machine
  that has gained git turns it back into a clone.
- Nothing on this route runs sudo, Homebrew or `xcode-select --install`,
  and the route is never decided by `sudo -v`.

What it cannot supply is checked before the first download: a C/C++
toolchain on Linux (node-pty 1.1.0 compiles there), musl, and macOS below
15 (the tmux build's deployment target).

Along the way:

- check_git no longer trusts `command -v git`. On a Mac without the Xcode
  Command Line Tools /usr/bin/git is a stub that opens Apple's install
  dialog; it is judged by `xcode-select -p` and never executed. Any other
  git is asked for its version.
- scripts/git-hooks.mjs answers "not a checkout" without running git, for
  the same reason: postinstall calls it on every npm install.
- The in-app updater knows the install kind `archive` and points such an
  install at the installer instead of `npm i -g aicodeman@latest`.
- download() and download_to_stdout() resolve DOWNLOADER themselves. Only
  main() used to set it, so `install.sh cloudflared` died on `set -u` with
  the message swallowed by the step log.
- The AI CLI menu leaves out a Homebrew-only install command where there
  is no Homebrew.

Verified with the real installer: a bare Ubuntu 24.04 container as an
unprivileged user (install, a PTY round trip through the API, update by
re-run, cloudflared, uninstall), the same without a compiler (stops before
any download), with a broken then working git, with sudo present but not
usable, and on macOS 26 (arm64) in an isolated HOME with sudo, brew, git,
the compilers, launchctl and tailscale replaced by tripwires. Not verified
on a real standard macOS account: that account was an administrator.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-05 18:50:58 +01:00

490 lines
19 KiB
TypeScript

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