@homeflare/config 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,18 @@
1
1
  /**
2
2
  * Adoption: the one wrapper every repo commits, and the check that it has not drifted.
3
3
  *
4
- * ★ WHY A WRAPPER AT ALL. husky can only run a file that is tracked in the repo, so
4
+ * ★ WHY A WRAPPER AT ALL. git can only run a file that is present in the worktree, so
5
5
  * something must be committed per repo. This keeps that something to a delegation
6
6
  * whose text is owned HERE — the behaviour lives in one package, and a repo that
7
7
  * edits its copy is reported as drift rather than quietly diverging.
8
8
  * ★ ONE FILE, TWO NAMES. The wrapper reads the hook name from `$0`, so `pre-commit`
9
9
  * and `pre-push` are byte-identical and there is a single text to keep in step.
10
+ * ★ THE DIRECTORY KEEPS HUSKY'S NAME, NOT HUSKY. `.husky/` is where the estate's hook files
11
+ * already live (homeflare-kit, homeflare-alerts, the house monorepo); since 2026-09-23 git
12
+ * runs them directly through `core.hooksPath` — see activate.ts for why husky's own
13
+ * `.husky/_` left every fresh worktree without hooks.
10
14
  */
11
- import { chmod, mkdir } from 'node:fs/promises';
15
+ import { chmod, mkdir, stat } from 'node:fs/promises';
12
16
 
13
17
  /** The hook files this package installs, in the order a contributor meets them. */
14
18
  export const HOOK_NAMES = ['pre-commit', 'pre-push'] as const;
@@ -16,27 +20,34 @@ export const HOOK_NAMES = ['pre-commit', 'pre-push'] as const;
16
20
  /** Where the runner lives once `bun install` has run. Relative: git runs hooks at the root. */
17
21
  const RUNNER = 'node_modules/@homeflare/config/bin/hooks.ts';
18
22
 
23
+ /** What a consumer's `prepare` script runs, so every `bun install` activates the hooks. */
24
+ export const PREPARE: string = `bun ${RUNNER} activate`;
25
+
19
26
  /**
20
- * ⚠️ IT EXITS 0 WHEN THE RUNNER IS ABSENT. A checkout with no `node_modules` would
27
+ * ⚠️ IT EXITS 0 WHEN THE RUNNER IS ABSENT. A worktree with no `node_modules` would
21
28
  * otherwise fail every commit with a module-resolution error, and the first thing
22
29
  * anyone would do is delete the hook. Failing open is the right trade for a
23
30
  * convenience; the required checks on `main` are what must fail closed.
31
+ * ★ `"$@"` AND STDIN PASS THROUGH. `pre-push` reads the remote name from its first argument
32
+ * and the pushed refs from stdin (push-range.ts); `exec` keeps both.
33
+ * ⚠️ NO SHEBANG, AND THAT IS MEASURED, NOT FORGOTTEN: git 2.55 runs an executable hook that
34
+ * has none through `sh` (2026-09-23), and the husky-era files in the estate have none.
24
35
  */
25
36
  export const HUSKY_HOOK: string = `# HomeFlare shared git hook. The behaviour lives in @homeflare/config, not in this file,
26
37
  # and the same bytes are installed as .husky/pre-commit and .husky/pre-push — the hook
27
- # name comes from $0.
38
+ # name comes from $0, and git's arguments and stdin pass straight through.
28
39
  #
29
- # ⚠️ A hook is a local convenience, not a gate: it is skippable with --no-verify and does
30
- # not exist in a fresh clone until \`bun install\` runs the \`prepare\` script. The
31
- # required checks on main stay the gate.
40
+ # ⚠️ A hook is a local convenience, not a gate: it is skippable with --no-verify, and a
41
+ # worktree runs it only once \`bun install\` has run there. The required checks on main
42
+ # stay the gate.
32
43
  #
33
44
  # Regenerate this file with: bun ${RUNNER} install
34
45
  hook="${RUNNER}"
35
46
  if [ ! -f "$hook" ]; then
36
- echo "husky: $hook is missing — run 'bun install' to enable the HomeFlare hooks; skipping"
47
+ echo "homeflare hooks: $hook is missing — run 'bun install' in this worktree; skipping" >&2
37
48
  exit 0
38
49
  fi
39
- exec bun "$hook" "$(basename "$0")"
50
+ exec bun "$hook" "$(basename "$0")" "$@"
40
51
  `;
41
52
 
42
53
  /** Write the wrapper into `.husky/`. Returns the paths written, relative to the project. */
@@ -46,8 +57,8 @@ export async function installHooks(projectDir: string): Promise<readonly string[
46
57
  for (const name of HOOK_NAMES) {
47
58
  const path = `${projectDir}/.husky/${name}`;
48
59
  await Bun.write(path, HUSKY_HOOK);
49
- // ⚠️ husky's own runner does `sh -e "$s"`, which does not need the execute bit, but
50
- // `core.hooksPath=.husky` without husky does. Set it so both mechanisms work.
60
+ // ⛔ THE EXECUTE BIT IS REQUIRED. git runs a `core.hooksPath` file directly and IGNORES
61
+ // a non-executable hook, with nothing but an advice line to say so.
51
62
  await chmod(path, 0o755);
52
63
  written.push(`.husky/${name}`);
53
64
  }
@@ -66,20 +77,25 @@ export async function problemsInHooks(projectDir: string): Promise<readonly stri
66
77
  const manifest = Bun.file(`${projectDir}/package.json`);
67
78
 
68
79
  if (!(await manifest.exists())) return ['package.json: missing'];
69
- const pkg = (await manifest.json()) as {
70
- scripts?: Record<string, string>;
71
- devDependencies?: Record<string, string>;
72
- };
80
+ const pkg = (await manifest.json()) as { scripts?: Record<string, string> };
73
81
 
74
- if (!(pkg.scripts?.['prepare'] ?? '').includes('husky')) {
75
- problems.push('package.json: no "prepare": "husky" script — a fresh clone installs no hooks');
82
+ const prepare = pkg.scripts?.['prepare'] ?? '';
83
+ if (!/bin\/hooks\.ts activate/.test(prepare)) {
84
+ problems.push(
85
+ `package.json: "prepare" does not run \`${PREPARE}\` — no clone or worktree gets hooks`,
86
+ );
76
87
  }
77
- if (pkg.devDependencies?.['husky'] === undefined) {
78
- problems.push('package.json: husky is not a devDependency');
88
+ // ⚠️ husky POINTS core.hooksPath AT AN UNTRACKED `.husky/_` that exists only where it ran —
89
+ // the gap activate.ts closes. Left in `prepare`, it undoes the activation.
90
+ if (/\bhusky\b/.test(prepare)) {
91
+ problems.push(
92
+ 'package.json: "prepare" still runs husky, which re-points core.hooksPath at .husky/_',
93
+ );
79
94
  }
80
95
 
81
96
  for (const name of HOOK_NAMES) {
82
- const file = Bun.file(`${projectDir}/.husky/${name}`);
97
+ const path = `${projectDir}/.husky/${name}`;
98
+ const file = Bun.file(path);
83
99
  if (!(await file.exists())) {
84
100
  problems.push(`.husky/${name}: missing — run \`bun ${RUNNER} install\``);
85
101
  continue;
@@ -89,6 +105,9 @@ export async function problemsInHooks(projectDir: string): Promise<readonly stri
89
105
  `.husky/${name}: differs from the @homeflare/config wrapper — run \`bun ${RUNNER} install\`, or change it in the package`,
90
106
  );
91
107
  }
108
+ if (((await stat(path)).mode & 0o111) === 0) {
109
+ problems.push(`.husky/${name}: not executable, so git ignores it — chmod +x it and commit`);
110
+ }
92
111
  }
93
112
 
94
113
  return problems;
@@ -0,0 +1,167 @@
1
+ /**
2
+ * What `pre-push` runs: the repository's own `check`, with the expensive lanes narrowed to
3
+ * the push or left to CI.
4
+ *
5
+ * ★ IT READS `check` RATHER THAN NAMING TOOLS. `check` is the command CI runs, so the lanes
6
+ * here are that command's lanes — a repository that adds a step to `check` gets it in the
7
+ * hook with no change to this package. Hard-coding `tsc` and `bun test` would drift from
8
+ * whichever repository added a step first, and certify a push CI rejects.
9
+ * ★ THREE THINGS CHANGE, AND ONLY THREE:
10
+ * 1. every `bun test …` becomes `bun test … --changed=<base>` — Bun's own import-graph
11
+ * answer to "which test files can these changed files reach" (bun 1.4, measured
12
+ * 2026-09-23: 11 changed files ran 2 of 246 test files in homeflare-kit);
13
+ * 2. `build` and `smoke` scripts are skipped — minutes in a big workspace, and CI runs
14
+ * both on every pull request;
15
+ * 3. everything else (lint, types, a `--check` script) runs exactly as `check` spells it.
16
+ * They are seconds, whole-program by nature, and deterministic.
17
+ * ⛔ PURE. No git, no filesystem, no process: the whole contract is a function of the
18
+ * scripts table and the base, so the estate's real `check` shapes are pinned by a table
19
+ * test (tests/hooks-push-plan.test.ts) instead of by a live push.
20
+ */
21
+
22
+ /** One step of the pre-push run. */
23
+ export type Lane =
24
+ /** Run `command` through `sh -c` at the repository root. */
25
+ | { readonly kind: 'run'; readonly label: string; readonly command: string }
26
+ /** A test runner; `scoped` says whether it was narrowed to the push. */
27
+ | {
28
+ readonly kind: 'test';
29
+ readonly label: string;
30
+ readonly command: string;
31
+ readonly scoped: boolean;
32
+ }
33
+ /** Not run here, and why. */
34
+ | { readonly kind: 'skip'; readonly label: string; readonly why: string };
35
+
36
+ /**
37
+ * ⚠️ BY NAME, AND ONLY THESE. A build in homeflare-kit is every package; a smoke test packs
38
+ * and installs tarballs. Both are CI jobs on every pull request, and neither says anything
39
+ * a type check and the reachable tests did not already say about a typical push.
40
+ */
41
+ const LEFT_TO_CI = /^(build|smoke)(:|$)/;
42
+
43
+ /** Recursion guard: a script that names itself, directly or through another, stops here. */
44
+ const MAX_DEPTH = 8;
45
+
46
+ /**
47
+ * Split a script on top-level `&&`, or return undefined when it is anything else.
48
+ *
49
+ * ⛔ UNDEFINED MEANS "RUN IT WHOLE". `||`, `;`, a pipe, a redirect, a background `&` or a
50
+ * substitution each change what the pieces mean together, and a hook that re-plumbed them
51
+ * would run something the repository never wrote. Quoted text is left alone, so
52
+ * `--path-ignore-patterns="homeflare-*\/**"` survives intact.
53
+ */
54
+ export function andChain(script: string): readonly string[] | undefined {
55
+ const parts: string[] = [];
56
+ let quote: string | undefined;
57
+ let current = '';
58
+ for (let i = 0; i < script.length; i++) {
59
+ const char = script[i] ?? '';
60
+ if (quote !== undefined) {
61
+ if (char === quote) quote = undefined;
62
+ current += char;
63
+ continue;
64
+ }
65
+ if (char === '"' || char === "'") {
66
+ quote = char;
67
+ current += char;
68
+ continue;
69
+ }
70
+ if (char === '&' && script[i + 1] === '&') {
71
+ parts.push(current.trim());
72
+ current = '';
73
+ i += 1;
74
+ continue;
75
+ }
76
+ if ('|;&<>`'.includes(char) || (char === '$' && script[i + 1] === '(')) return undefined;
77
+ current += char;
78
+ }
79
+ if (quote !== undefined) return undefined;
80
+ parts.push(current.trim());
81
+ return parts.some((part) => part === '') ? undefined : parts;
82
+ }
83
+
84
+ /** The script a segment names: `bun run x`, `npm run x`, or `npm test`. Else undefined. */
85
+ function scriptRef(segment: string, scripts: Readonly<Record<string, string>>): string | undefined {
86
+ const words = segment.split(/\s+/);
87
+ if (words.length === 2 && words[0] === 'npm' && words[1] === 'test') return 'test';
88
+ const [runner, verb, name] = words;
89
+ if (words.length !== 3 || verb !== 'run' || name === undefined) return undefined;
90
+ if (runner !== 'bun' && runner !== 'npm') return undefined;
91
+ return Object.hasOwn(scripts, name) ? name : undefined;
92
+ }
93
+
94
+ /** `bun test …` — the runner, not a script called `test`. */
95
+ const isBunTest = (segment: string): boolean => /^bun\s+test(\s|$)/.test(segment);
96
+
97
+ function testLane(segment: string, base: string | undefined): Lane {
98
+ return base === undefined
99
+ ? { kind: 'test', label: segment, command: segment, scoped: false }
100
+ : { kind: 'test', label: segment, command: `${segment} --changed=${base}`, scoped: true };
101
+ }
102
+
103
+ function expand(
104
+ script: string,
105
+ scripts: Readonly<Record<string, string>>,
106
+ base: string | undefined,
107
+ depth: number,
108
+ ): readonly Lane[] | undefined {
109
+ const segments = andChain(script);
110
+ if (segments === undefined || depth > MAX_DEPTH) return undefined;
111
+ const lanes: Lane[] = [];
112
+ for (const segment of segments) {
113
+ if (isBunTest(segment)) {
114
+ lanes.push(testLane(segment, base));
115
+ continue;
116
+ }
117
+ const name = scriptRef(segment, scripts);
118
+ if (name === undefined) {
119
+ lanes.push({ kind: 'run', label: segment, command: segment });
120
+ continue;
121
+ }
122
+ if (LEFT_TO_CI.test(name)) {
123
+ lanes.push({ kind: 'skip', label: segment, why: 'CI runs it on every pull request' });
124
+ continue;
125
+ }
126
+ const inner = expand(scripts[name] ?? '', scripts, base, depth + 1);
127
+ if (inner !== undefined && inner.some((lane) => lane.kind !== 'run')) {
128
+ // Something inside needs narrowing or skipping: open the script up.
129
+ lanes.push(...inner);
130
+ } else if (/^test(:|$)/.test(name)) {
131
+ // ⚠️ A TEST SCRIPT WITH NO `bun test` IN IT RUNS IN FULL, AND SAYS SO — vitest with
132
+ // coverage thresholds (a narrowed run would fail them), `node --test`, anything piped.
133
+ // Narrowing it would mean rewriting a command this package does not understand.
134
+ lanes.push({ kind: 'test', label: segment, command: segment, scoped: false });
135
+ } else {
136
+ // ★ A SCRIPT WITH NOTHING TO NARROW RUNS UNDER ITS OWN NAME. `bun run lint` keeps
137
+ // Bun's PATH handling and reads the way the repository wrote it.
138
+ lanes.push({ kind: 'run', label: segment, command: segment });
139
+ }
140
+ }
141
+ return lanes;
142
+ }
143
+
144
+ /**
145
+ * The lanes `pre-push` runs for this `scripts` table.
146
+ *
147
+ * `base` is the commit the push is measured from; `undefined` runs every test lane in full
148
+ * (an unknown base, or a push that changes what every test runs on).
149
+ * ⚠️ AN EMPTY LIST MEANS NO `check` SCRIPT — the caller reports that; it is not a pass.
150
+ */
151
+ export function planLanes(
152
+ scripts: Readonly<Record<string, string>>,
153
+ base: string | undefined,
154
+ ): readonly Lane[] {
155
+ const check = scripts['check'];
156
+ if (check === undefined) return [];
157
+ // ⚠️ A `check` THAT IS NOT A PLAIN `&&` CHAIN IS RUN WHOLE, tests and all, and reported
158
+ // as unscoped. No estate repository has one (surveyed 2026-09-23); the fallback exists so
159
+ // an unusual one is checked rather than skipped.
160
+ const whole: Lane = {
161
+ kind: 'test',
162
+ label: 'bun run check',
163
+ command: 'bun run check',
164
+ scoped: false,
165
+ };
166
+ return expand(check, scripts, base, 0) ?? [whole];
167
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * What a push changes: the base it is measured from, and the files between that base and
3
+ * the commit being pushed.
4
+ *
5
+ * ★ THE BASE COMES FROM GIT, NOT FROM A GUESS. A pre-push hook reads
6
+ * `<local ref> <local sha> <remote ref> <remote sha>` on stdin. The remote sha is exactly
7
+ * "what the remote has now", so a second push to a branch checks only what is new.
8
+ * 🔴 A BRANCH-NAME BASE IS VACUOUS ON THE BASE BRANCH. Measured in the house monorepo
9
+ * 2026-09-08: `turbo --affected` compares against `main`, so on `main` itself it selected
10
+ * zero packages and a real two-commit push ran zero tests. Taking the base from stdin
11
+ * cannot do that — the remote sha is never the commit being pushed.
12
+ * ⛔ AN UNKNOWN BASE WIDENS, IT NEVER NARROWS TO NOTHING. No merge base (a shallow clone, a
13
+ * rewritten history), no remote-tracking branch: every lane runs, tests in full. A gate
14
+ * whose base is unusable must do MORE work, never quietly run nothing.
15
+ */
16
+ import { probe } from './report.ts';
17
+
18
+ /** One line of git's pre-push stdin. */
19
+ export type PushRef = {
20
+ readonly localRef: string;
21
+ readonly localSha: string;
22
+ readonly remoteRef: string;
23
+ readonly remoteSha: string;
24
+ };
25
+
26
+ export type PushScope =
27
+ /** The push changes no file (a deletion, or a branch at its base): nothing to check. */
28
+ | { readonly kind: 'empty'; readonly why: string }
29
+ /** Measured from `base`: `changed` is every path that differs between it and the tip. */
30
+ | {
31
+ readonly kind: 'scoped';
32
+ readonly base: string;
33
+ readonly changed: readonly string[];
34
+ readonly why: string;
35
+ }
36
+ /** No usable base: run every lane, tests in full. */
37
+ | { readonly kind: 'unscoped'; readonly why: string }
38
+ /** The pushed commit is not what is checked out, so the working tree cannot vouch for it. */
39
+ | { readonly kind: 'elsewhere'; readonly why: string };
40
+
41
+ /** git's "no such ref" sentinel — 40 zeros (64 under SHA-256). */
42
+ const ZERO = /^0+$/;
43
+
44
+ export function parsePushRefs(stdin: string): readonly PushRef[] {
45
+ const refs: PushRef[] = [];
46
+ for (const line of stdin.split('\n')) {
47
+ const [localRef, localSha, remoteRef, remoteSha] = line.trim().split(/\s+/);
48
+ if (localRef && localSha && remoteRef && remoteSha) {
49
+ refs.push({ localRef, localSha, remoteRef, remoteSha });
50
+ }
51
+ }
52
+ return refs;
53
+ }
54
+
55
+ const short = (sha: string): string => sha.slice(0, 9);
56
+
57
+ async function ok(root: string, args: readonly string[]): Promise<boolean> {
58
+ return (await probe(['git', '-C', root, ...args])).code === 0;
59
+ }
60
+
61
+ async function out(root: string, args: readonly string[]): Promise<string | undefined> {
62
+ const result = await probe(['git', '-C', root, ...args]);
63
+ return result.code === 0 ? result.stdout.trim() : undefined;
64
+ }
65
+
66
+ /**
67
+ * The remote's default branch as a local ref: `<remote>/HEAD` when the clone recorded it,
68
+ * else `<remote>/main`, else `<remote>/master`.
69
+ * ⚠️ `refs/remotes/<remote>/HEAD` IS OFTEN ABSENT — `git clone` sets it, `git init` plus
70
+ * `git remote add` does not — so the fallbacks are the common path, not the rare one.
71
+ */
72
+ async function defaultBranch(root: string, remote: string): Promise<string | undefined> {
73
+ const head = await out(root, ['symbolic-ref', '--quiet', `refs/remotes/${remote}/HEAD`]);
74
+ if (head !== undefined && head !== '') return head;
75
+ for (const name of ['main', 'master']) {
76
+ const ref = `refs/remotes/${remote}/${name}`;
77
+ if (await ok(root, ['rev-parse', '--verify', '--quiet', ref])) return ref;
78
+ }
79
+ return undefined;
80
+ }
81
+
82
+ /**
83
+ * Where the pushed commit is measured from.
84
+ *
85
+ * ★ A FAST-FORWARD IS MEASURED FROM THE REMOTE SHA — only what this push adds.
86
+ * ⚠️ ANYTHING ELSE IS MEASURED FROM THE MERGE BASE WITH THE DEFAULT BRANCH: a new branch
87
+ * (zero remote sha), a remote sha this clone has not fetched, or a force-push after a
88
+ * rebase — where diffing against the old tip would drag in everything `main` gained since.
89
+ */
90
+ async function baseFor(
91
+ root: string,
92
+ remote: string,
93
+ ref: PushRef,
94
+ ): Promise<{ base: string; why: string } | { why: string }> {
95
+ const { localSha, remoteSha } = ref;
96
+ if (
97
+ !ZERO.test(remoteSha) &&
98
+ (await ok(root, ['cat-file', '-e', `${remoteSha}^{commit}`])) &&
99
+ (await ok(root, ['merge-base', '--is-ancestor', remoteSha, localSha]))
100
+ ) {
101
+ return { base: remoteSha, why: `since ${short(remoteSha)}, what ${remote} has now` };
102
+ }
103
+ const branch = await defaultBranch(root, remote);
104
+ if (branch === undefined) return { why: `no ${remote}/main or ${remote}/master to measure from` };
105
+ const mergeBase = await out(root, ['merge-base', localSha, branch]);
106
+ if (mergeBase === undefined || mergeBase === '') {
107
+ return { why: `no merge base with ${branch} (a shallow clone, or unrelated history)` };
108
+ }
109
+ const name = branch.replace(/^refs\/remotes\//, '');
110
+ return { base: mergeBase, why: `since ${short(mergeBase)}, where this branch left ${name}` };
111
+ }
112
+
113
+ /**
114
+ * Resolve the push git described on stdin. With no stdin (a manual run) the push is
115
+ * `HEAD` to a new branch — measured from the merge base with the default branch.
116
+ * 🔴 THE LANES RUN ON THE WORKING TREE, SO ONLY A PUSH OF `HEAD` CAN BE CHECKED HERE. Found
117
+ * in review 2026-09-23 and reproduced: `git push origin broken` from a clean `main`
118
+ * measured the right files, then ran `bun test --changed` against `main`'s tree, found
119
+ * nothing, and printed "passed". A ref that is not checked out now comes back
120
+ * `elsewhere`, which the caller reports as NOT CHECKED — never as a pass. (The old
121
+ * whole-`check` hook had the same blind spot; it just ran unrelated tests while in it.)
122
+ * ⚠️ WITH SEVERAL REFS, THE ONE AT `HEAD` IS MEASURED and the rest are named as unchecked.
123
+ */
124
+ export async function pushScope(
125
+ root: string,
126
+ remote: string,
127
+ refs: readonly PushRef[],
128
+ ): Promise<PushScope> {
129
+ const pushed = refs.filter((ref) => !ZERO.test(ref.localSha));
130
+ if (refs.length > 0 && pushed.length === 0) {
131
+ return { kind: 'empty', why: 'this push only deletes refs' };
132
+ }
133
+ const head = await out(root, ['rev-parse', 'HEAD']);
134
+ const atHead = pushed.find((ref) => ref.localSha === head);
135
+ const others = pushed.filter((ref) => ref !== atHead).map((ref) => ref.localRef);
136
+ if (pushed.length > 0 && atHead === undefined) {
137
+ return {
138
+ kind: 'elsewhere',
139
+ why: `pushing ${others.join(', ')}, but the checkout is at ${short(head ?? '?')}`,
140
+ };
141
+ }
142
+ const ref = atHead ?? {
143
+ localRef: 'HEAD',
144
+ localSha: head ?? 'HEAD',
145
+ remoteRef: '',
146
+ remoteSha: '0'.repeat(40),
147
+ };
148
+ const found = await baseFor(root, remote, ref);
149
+ const also = others.length > 0 ? `; ${others.join(', ')} not checked here` : '';
150
+ if (!('base' in found)) return { kind: 'unscoped', why: `${found.why}${also}` };
151
+
152
+ const diff = await probe([
153
+ 'git',
154
+ '-C',
155
+ root,
156
+ 'diff',
157
+ '--name-only',
158
+ '-z',
159
+ found.base,
160
+ ref.localSha,
161
+ ]);
162
+ if (diff.code !== 0) return { kind: 'unscoped', why: `git diff ${short(found.base)} failed` };
163
+ const changed = diff.stdout.split('\0').filter((path) => path !== '');
164
+ if (changed.length === 0) return { kind: 'empty', why: `no file differs ${found.why}${also}` };
165
+ return { kind: 'scoped', base: found.base, changed, why: `${found.why}${also}` };
166
+ }
167
+
168
+ /**
169
+ * A path that changes what EVERY test runs on: a manifest, a lockfile, Bun's config, a
170
+ * tsconfig. Bun's `--changed` follows imports and none of these is imported — measured
171
+ * 2026-09-23, editing package.json selected 0 of 246 test files — so a push touching one
172
+ * runs the tests in full rather than none of them.
173
+ */
174
+ export function changesEverything(path: string): boolean {
175
+ const name = path.split('/').pop() ?? '';
176
+ return /^(package\.json|bun\.lockb?|bunfig\.toml|tsconfig.*\.json)$/.test(name);
177
+ }
@@ -28,7 +28,7 @@ const BYPASS: Record<Hook, string> = {
28
28
  * ⛔ So the hook strips them rather than trusting fourteen test suites to remember. The
29
29
  * gate must see the repository through `cwd`, the way CI does.
30
30
  */
31
- function withoutGitEnv(): Record<string, string | undefined> {
31
+ export function withoutGitEnv(): Record<string, string | undefined> {
32
32
  return Object.fromEntries(
33
33
  Object.entries(process.env).filter(([name]) => !name.startsWith('GIT_')),
34
34
  );
@@ -49,10 +49,34 @@ export async function run(cmd: readonly string[], isolated = false): Promise<num
49
49
 
50
50
  /** Capture a command's stdout. Used for git plumbing only. */
51
51
  export async function capture(cmd: readonly string[]): Promise<string> {
52
+ return (await probe(cmd)).stdout;
53
+ }
54
+
55
+ /** Capture a command's stdout AND its exit code — git plumbing that answers by status. */
56
+ export async function probe(cmd: readonly string[]): Promise<{ code: number; stdout: string }> {
52
57
  const proc = Bun.spawn([...cmd], { stdout: 'pipe', stderr: 'ignore' });
53
- const text = await new Response(proc.stdout).text();
54
- await proc.exited;
55
- return text;
58
+ const stdout = await new Response(proc.stdout).text();
59
+ return { code: await proc.exited, stdout };
60
+ }
61
+
62
+ /**
63
+ * Run one pre-push lane through `sh -c` at `root`, as `bun run` would run a script line.
64
+ *
65
+ * ⚠️ `node_modules/.bin` FIRST ON PATH, because a lane opened up from a script is no longer
66
+ * run by `bun run`, which is what used to put it there — `vitest` or `oxfmt` in an expanded
67
+ * script would otherwise be "command not found" on a machine without a global copy.
68
+ * 🔴 AND WITHOUT THE HOOK'S `GIT_*` — see `withoutGitEnv`.
69
+ */
70
+ export async function runLane(command: string, root: string): Promise<number> {
71
+ const env = withoutGitEnv();
72
+ env['PATH'] = `${root}/node_modules/.bin:${env['PATH'] ?? ''}`;
73
+ const proc = Bun.spawn(['sh', '-c', command], {
74
+ cwd: root,
75
+ env,
76
+ stdout: 'inherit',
77
+ stderr: 'inherit',
78
+ });
79
+ return await proc.exited;
56
80
  }
57
81
 
58
82
  /**
@@ -60,8 +84,9 @@ export async function capture(cmd: readonly string[]): Promise<string> {
60
84
  *
61
85
  * ⚠️ NOT `bunx` BY DEFAULT. On a cache miss `bunx` downloads from the registry, and a
62
86
  * git hook that reaches the network mid-commit is a hang waiting for a flaky link.
63
- * husky puts `node_modules/.bin` on PATH, but this runs outside husky in tests, so
64
- * the local binary is named outright when it exists and `bunx` is only the fallback.
87
+ * Git runs the hook with the caller's PATH, which has no `node_modules/.bin` (husky
88
+ * used to add it; the hooks no longer run through husky), so the local binary is named
89
+ * outright when it exists and `bunx` is only the fallback.
65
90
  */
66
91
  export function tool(root: string, name: string): readonly string[] {
67
92
  const local = `${root}/node_modules/.bin/${name}`;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Refuse to commit anything that looks like a credential.
3
+ *
4
+ * ⛔ FIRST GATE, ALWAYS. A secret that reaches a public repository is compromised the moment
5
+ * it is pushed — rotating it is the only remedy, and rewriting history does not help
6
+ * because the object is already fetched and mirrored. Every other check can be fixed
7
+ * after the fact; this one cannot. Moved here from homeflare-kit's own
8
+ * scripts/hooks/secrets.ts (2026-09-23) so every repository commits under it, not one.
9
+ * ★ gitleaks IS THE TOOL, not a hand-rolled regex list. It ships hundreds of maintained rules
10
+ * and an entropy engine, and it reads the repository's own `.gitleaks.toml` when there is
11
+ * one; a homegrown pattern set covers the token shapes its author thought of.
12
+ * ⚠️ IT IS A GO BINARY, NOT AN npm PACKAGE — nothing in package.json can install it, so a
13
+ * missing binary is explained rather than surfacing as "command not found".
14
+ * ⚠️ `git --staged`, NOT `protect`. Measured 2026-09-15 on gitleaks 8.30.1: `protect` still
15
+ * runs, but the documented surface is `gitleaks git` / `dir` / `stdin`.
16
+ */
17
+ import { fail, ok, run } from './report.ts';
18
+
19
+ const INSTALL = 'brew install gitleaks — Linux: https://github.com/gitleaks/gitleaks/releases';
20
+
21
+ export async function scanStagedSecrets(): Promise<void> {
22
+ // ⛔ NOT A SILENT SKIP. A secret scan that quietly does nothing is worse than none: it
23
+ // reads, in a log and in a reviewer's head, as coverage that does not exist.
24
+ if (Bun.which('gitleaks') === null) {
25
+ fail(
26
+ 'pre-commit',
27
+ 'gitleaks is not installed, so the staged changes were NOT scanned',
28
+ INSTALL,
29
+ );
30
+ }
31
+ // `--redact`, so a real finding never prints the secret into a scrollback, a CI log, or an
32
+ // agent's context.
33
+ const code = await run(['gitleaks', 'git', '--staged', '--redact', '--no-banner', '.']);
34
+ if (code !== 0) {
35
+ fail(
36
+ 'pre-commit',
37
+ 'gitleaks found a secret in the staged changes',
38
+ 'remove it, then ROTATE it — assume anything committed is already compromised',
39
+ );
40
+ }
41
+ ok('pre-commit: gitleaks found no secret in the staged changes');
42
+ }
package/src/hooks.ts CHANGED
@@ -6,43 +6,59 @@
6
6
  * notices, and two repos disagree about what a commit must satisfy. Here the repo
7
7
  * commits a delegating wrapper and the behaviour ships with the package, so changing
8
8
  * the rule is one release and a version bump rather than fourteen edits.
9
- *
10
- * ⚠️ HUSKY, NOT lefthook OR A BARE `core.hooksPath`. It is already the estate's
11
- * mechanism in the repos that have working hooks, it installs from `prepare` on a
12
- * plain `bun install`, and it keeps the hook files tracked and reviewable. A second
13
- * mechanism alongside it would mean two ways to answer "are hooks on in this repo".
9
+ * ★ TRACKED `.husky/`, RUN BY GIT THROUGH `core.hooksPath` — NOT husky's `.husky/_`. Since
10
+ * 2026-09-23 the mechanism is the one that reaches every worktree; activate.ts has the
11
+ * measurement. The files keep the directory name the estate already uses.
12
+ * ★ PRE-PUSH IS SCOPED TO THE PUSH (push-range.ts, push-plan.ts): the repository's own
13
+ * `check`, with `bun test` narrowed to the tests the pushed files can reach and the build
14
+ * and smoke test left to CI. Many agents push to many repositories at once; a pre-push
15
+ * that re-ran every suite was the one people learned to skip.
14
16
  *
15
17
  * Usage from a hook file — see `HUSKY_HOOK`:
16
18
  *
17
19
  * bun node_modules/@homeflare/config/bin/hooks.ts pre-commit
18
20
  */
21
+ import { activateHooks } from './hooks/activate.ts';
19
22
  import { preCommit, prePush } from './hooks/gates.ts';
20
- import { HOOK_NAMES, HUSKY_HOOK, installHooks, problemsInHooks } from './hooks/install.ts';
23
+ import { HOOK_NAMES, HUSKY_HOOK, PREPARE, installHooks, problemsInHooks } from './hooks/install.ts';
24
+ import { type Lane, planLanes } from './hooks/push-plan.ts';
21
25
  import { type Hook, note, ok } from './hooks/report.ts';
22
26
 
23
- export { HOOK_NAMES, HUSKY_HOOK, installHooks, problemsInHooks };
24
- export type { Hook };
27
+ export { HOOK_NAMES, HUSKY_HOOK, PREPARE, activateHooks, installHooks, planLanes, problemsInHooks };
28
+ export type { Hook, Lane };
25
29
 
26
30
  /** The commands `bin/hooks.ts` accepts. */
27
- export type Command = Hook | 'install';
31
+ export type Command = Hook | 'install' | 'activate';
28
32
 
29
33
  export function isCommand(value: string): value is Command {
30
- return value === 'pre-commit' || value === 'pre-push' || value === 'install';
34
+ return ['pre-commit', 'pre-push', 'install', 'activate'].includes(value);
31
35
  }
32
36
 
33
37
  /**
34
- * Run one hook, or install the wrappers.
38
+ * Run one hook, write the wrappers, or activate them.
35
39
  *
40
+ * `args` are git's own hook arguments (for `pre-push`: the remote name and URL) and
41
+ * `stdin` is what git wrote to the hook (for `pre-push`: the refs being pushed).
36
42
  * ⛔ Never exits non-zero for a reason the caller cannot act on: an unknown command is a
37
43
  * programming error in the wrapper and is reported as such, not as a failed commit.
38
44
  */
39
- export async function runCommand(command: Command, root: string): Promise<void> {
45
+ export async function runCommand(
46
+ command: Command,
47
+ root: string,
48
+ args: readonly string[] = [],
49
+ stdin = '',
50
+ ): Promise<void> {
40
51
  if (command === 'install') {
41
52
  const written = await installHooks(root);
42
53
  ok(`wrote ${written.join(', ')} — commit them`);
43
- note('they do nothing until `bun install` runs `prepare` (husky)');
54
+ note(`then set "prepare": "${PREPARE}" so every install activates them`);
55
+ return;
56
+ }
57
+ if (command === 'activate') {
58
+ const result = await activateHooks(root);
59
+ (result.active ? ok : note)(`homeflare hooks: ${result.message}`);
44
60
  return;
45
61
  }
46
62
  if (command === 'pre-commit') return await preCommit(root);
47
- return await prePush(root);
63
+ return await prePush(root, args, stdin);
48
64
  }