@homeflare/config 0.10.0 → 0.11.1

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.
Files changed (40) hide show
  1. package/README.md +11 -41
  2. package/bin/hooks.ts +8 -4
  3. package/dist/hooks/activate.d.ts +7 -0
  4. package/dist/hooks/activate.d.ts.map +1 -0
  5. package/dist/hooks/gates.d.ts +7 -14
  6. package/dist/hooks/gates.d.ts.map +1 -1
  7. package/dist/hooks/install.d.ts +7 -1
  8. package/dist/hooks/install.d.ts.map +1 -1
  9. package/dist/hooks/oxfmt-config.d.ts +25 -0
  10. package/dist/hooks/oxfmt-config.d.ts.map +1 -0
  11. package/dist/hooks/push-plan.d.ts +59 -0
  12. package/dist/hooks/push-plan.d.ts.map +1 -0
  13. package/dist/hooks/push-range.d.ts +51 -0
  14. package/dist/hooks/push-range.d.ts.map +1 -0
  15. package/dist/hooks/report.d.ts +39 -2
  16. package/dist/hooks/report.d.ts.map +1 -1
  17. package/dist/hooks/secrets.d.ts +2 -0
  18. package/dist/hooks/secrets.d.ts.map +1 -0
  19. package/dist/hooks.d.ts +30 -6
  20. package/dist/hooks.d.ts.map +1 -1
  21. package/dist/hooks.js +408 -41
  22. package/dist/hooks.js.map +12 -7
  23. package/dist/repo-shape/yaml.d.ts +20 -3
  24. package/dist/repo-shape/yaml.d.ts.map +1 -1
  25. package/dist/repo-shape.js +8 -2
  26. package/dist/repo-shape.js.map +3 -3
  27. package/dist/versions.js +8 -2
  28. package/dist/versions.js.map +3 -3
  29. package/docs/hooks.md +118 -0
  30. package/package.json +1 -1
  31. package/src/hooks/activate.ts +71 -0
  32. package/src/hooks/gates.ts +156 -39
  33. package/src/hooks/install.ts +39 -20
  34. package/src/hooks/oxfmt-config.ts +67 -0
  35. package/src/hooks/push-plan.ts +167 -0
  36. package/src/hooks/push-range.ts +177 -0
  37. package/src/hooks/report.ts +46 -6
  38. package/src/hooks/secrets.ts +42 -0
  39. package/src/hooks.ts +30 -14
  40. package/src/repo-shape/yaml.ts +26 -4
@@ -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,67 @@
1
+ /**
2
+ * Which `.oxfmtrc.*` file governs this repo — so the hook can name it explicitly.
3
+ *
4
+ * ⛔ BUG, MEASURED 2026-09-23 (homeflare-desktop docs agent): the shared pre-commit ran
5
+ * bare `oxfmt`, and bare `oxfmt` auto-discovers only `.oxfmtrc.json` and `.oxfmtrc.jsonc`.
6
+ * Checked against the pinned oxfmt 0.68.0 directly: a repo with only `.oxfmtrc.mjs` and
7
+ * no `-c` prints "No config found, using defaults" and formats with oxfmt's built-in
8
+ * style, silently — even though `oxfmt --help` lists `.ts/.mts/.cts/.js/.mjs/.cjs` as
9
+ * valid `--config` targets. Desktop's fix was renaming to `.oxfmtrc.json`; this fixes the
10
+ * hook so a repo does not have to.
11
+ * ★ WHY A FILE SCAN, NOT A PACKAGE.JSON READ. The repo's own `format`/`check` script names
12
+ * the config it wants (or relies on auto-discovery finding `.oxfmtrc.json`), but its exact
13
+ * invocation shape varies per repo. Finding the config file on disk and handing it to
14
+ * oxfmt directly gets the same result without parsing an arbitrary shell command.
15
+ * ⛔ EVERY `.oxfmtrc.*` LIVES AT THE REPO ROOT TODAY (checked: every repo in the estate has
16
+ * exactly one, at root — no nested per-package overrides). Explicit `-c` disables oxfmt's
17
+ * own nested-config search (measured: a nested `.oxfmtrc.json` was ignored once `-c` named
18
+ * the root one), so this only scans `root` itself — it would misfire on a repo that added a
19
+ * package-level override, which none currently do.
20
+ */
21
+ import { readdir } from 'node:fs/promises';
22
+
23
+ /** Auto-discovered by bare `oxfmt` — nothing for the hook to pass. */
24
+ const AUTO_DISCOVERED = ['.oxfmtrc.json', '.oxfmtrc.jsonc'];
25
+
26
+ /**
27
+ * Accepted by `oxfmt -c/--config` (per `oxfmt --help`, oxfmt 0.68.0) but never found on its
28
+ * own — checked empirically for each extension. Listed in the order `--help` documents them.
29
+ */
30
+ const NEEDS_EXPLICIT_CONFIG = [
31
+ '.oxfmtrc.ts',
32
+ '.oxfmtrc.mts',
33
+ '.oxfmtrc.cts',
34
+ '.oxfmtrc.js',
35
+ '.oxfmtrc.mjs',
36
+ '.oxfmtrc.cjs',
37
+ ];
38
+
39
+ export type OxfmtConfigResolution =
40
+ /** `.oxfmtrc.json` or `.oxfmtrc.jsonc` exists — bare oxfmt already finds it. */
41
+ | { readonly kind: 'auto' }
42
+ /** No `.oxfmtrc.*` at all — let oxfmt use its built-in defaults, as today. */
43
+ | { readonly kind: 'none' }
44
+ /** Exactly one config oxfmt would not find unaided — pass it with `-c`. */
45
+ | { readonly kind: 'explicit'; readonly path: string }
46
+ /**
47
+ * Two or more configs, none of them auto-discovered — bare oxfmt (and a repo's own
48
+ * bare `bun run format`) would silently fall back to defaults too, honouring neither.
49
+ * Guessing which one the repo meant would just move the silent-defaults bug here.
50
+ */
51
+ | { readonly kind: 'ambiguous'; readonly files: readonly string[] };
52
+
53
+ /** Every `.oxfmtrc.*` file actually present at `root`, in a fixed, deterministic order. */
54
+ async function present(root: string, names: readonly string[]): Promise<readonly string[]> {
55
+ const entries = new Set(await readdir(root).catch(() => []));
56
+ return names.filter((name) => entries.has(name));
57
+ }
58
+
59
+ export async function resolveOxfmtConfig(root: string): Promise<OxfmtConfigResolution> {
60
+ if ((await present(root, AUTO_DISCOVERED)).length > 0) return { kind: 'auto' };
61
+
62
+ const explicit = await present(root, NEEDS_EXPLICIT_CONFIG);
63
+ const [only, ...rest] = explicit;
64
+ if (only === undefined) return { kind: 'none' };
65
+ if (rest.length === 0) return { kind: 'explicit', path: `${root}/${only}` };
66
+ return { kind: 'ambiguous', files: explicit };
67
+ }
@@ -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,49 @@ 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
+ /**
56
+ * Run a command, relaying its stdout live-enough (after it exits) while also handing the
57
+ * caller the text — gates.ts reads oxfmt's own "on N files" summary from it. `stderr` stays
58
+ * `inherit`: diagnostics (a parse error, "no files matched") must show immediately, and nothing
59
+ * here needs to inspect them.
60
+ */
61
+ export async function runCaptured(
62
+ cmd: readonly string[],
63
+ ): Promise<{ readonly code: number; readonly stdout: string }> {
64
+ const proc = Bun.spawn([...cmd], { stdout: 'pipe', stderr: 'inherit' });
65
+ const stdout = await new Response(proc.stdout).text();
66
+ process.stdout.write(stdout);
67
+ return { code: await proc.exited, stdout };
68
+ }
69
+
70
+ /** Capture a command's stdout AND its exit code — git plumbing that answers by status. */
71
+ export async function probe(cmd: readonly string[]): Promise<{ code: number; stdout: string }> {
52
72
  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;
73
+ const stdout = await new Response(proc.stdout).text();
74
+ return { code: await proc.exited, stdout };
75
+ }
76
+
77
+ /**
78
+ * Run one pre-push lane through `sh -c` at `root`, as `bun run` would run a script line.
79
+ *
80
+ * ⚠️ `node_modules/.bin` FIRST ON PATH, because a lane opened up from a script is no longer
81
+ * run by `bun run`, which is what used to put it there — `vitest` or `oxfmt` in an expanded
82
+ * script would otherwise be "command not found" on a machine without a global copy.
83
+ * 🔴 AND WITHOUT THE HOOK'S `GIT_*` — see `withoutGitEnv`.
84
+ */
85
+ export async function runLane(command: string, root: string): Promise<number> {
86
+ const env = withoutGitEnv();
87
+ env['PATH'] = `${root}/node_modules/.bin:${env['PATH'] ?? ''}`;
88
+ const proc = Bun.spawn(['sh', '-c', command], {
89
+ cwd: root,
90
+ env,
91
+ stdout: 'inherit',
92
+ stderr: 'inherit',
93
+ });
94
+ return await proc.exited;
56
95
  }
57
96
 
58
97
  /**
@@ -60,8 +99,9 @@ export async function capture(cmd: readonly string[]): Promise<string> {
60
99
  *
61
100
  * ⚠️ NOT `bunx` BY DEFAULT. On a cache miss `bunx` downloads from the registry, and a
62
101
  * 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.
102
+ * Git runs the hook with the caller's PATH, which has no `node_modules/.bin` (husky
103
+ * used to add it; the hooks no longer run through husky), so the local binary is named
104
+ * outright when it exists and `bunx` is only the fallback.
65
105
  */
66
106
  export function tool(root: string, name: string): readonly string[] {
67
107
  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
+ }