@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
package/dist/hooks.js CHANGED
@@ -16,10 +16,29 @@ async function run(cmd, isolated = false) {
16
16
  return await proc.exited;
17
17
  }
18
18
  async function capture(cmd) {
19
+ return (await probe(cmd)).stdout;
20
+ }
21
+ async function runCaptured(cmd) {
22
+ const proc = Bun.spawn([...cmd], { stdout: "pipe", stderr: "inherit" });
23
+ const stdout = await new Response(proc.stdout).text();
24
+ process.stdout.write(stdout);
25
+ return { code: await proc.exited, stdout };
26
+ }
27
+ async function probe(cmd) {
19
28
  const proc = Bun.spawn([...cmd], { stdout: "pipe", stderr: "ignore" });
20
- const text = await new Response(proc.stdout).text();
21
- await proc.exited;
22
- return text;
29
+ const stdout = await new Response(proc.stdout).text();
30
+ return { code: await proc.exited, stdout };
31
+ }
32
+ async function runLane(command, root) {
33
+ const env = withoutGitEnv();
34
+ env["PATH"] = `${root}/node_modules/.bin:${env["PATH"] ?? ""}`;
35
+ const proc = Bun.spawn(["sh", "-c", command], {
36
+ cwd: root,
37
+ env,
38
+ stdout: "inherit",
39
+ stderr: "inherit"
40
+ });
41
+ return await proc.exited;
23
42
  }
24
43
  function tool(root, name) {
25
44
  const local = `${root}/node_modules/.bin/${name}`;
@@ -44,12 +63,270 @@ function fail(hook, what, fix) {
44
63
  process.exit(1);
45
64
  }
46
65
 
66
+ // src/hooks/activate.ts
67
+ function inCi(env) {
68
+ const ci = env["CI"];
69
+ return ci !== undefined && ci !== "" && ci !== "0" && ci.toLowerCase() !== "false";
70
+ }
71
+ async function activateHooks(root, env = process.env) {
72
+ if (inCi(env))
73
+ return { active: false, message: "CI is set \u2014 hooks stay off; CI runs the gate" };
74
+ const where = await probe(["git", "-C", root, "rev-parse", "--show-prefix"]);
75
+ if (where.code !== 0)
76
+ return { active: false, message: "not a git work tree \u2014 nothing to do" };
77
+ const tracked = await Promise.all(["pre-commit", "pre-push"].map((name) => Bun.file(`${root}/.husky/${name}`).exists()));
78
+ if (!tracked.some(Boolean)) {
79
+ return { active: false, message: "no .husky/pre-commit or .husky/pre-push to point git at" };
80
+ }
81
+ const want = `${where.stdout.trim()}.husky`;
82
+ const current = (await probe(["git", "-C", root, "config", "--get", "core.hooksPath"])).stdout.trim();
83
+ if (current === want)
84
+ return { active: true, message: `core.hooksPath is already ${want}` };
85
+ const set = await probe(["git", "-C", root, "config", "core.hooksPath", want]);
86
+ if (set.code !== 0)
87
+ return { active: false, message: "git config core.hooksPath failed" };
88
+ const was = current === "" ? "unset" : current;
89
+ return {
90
+ active: true,
91
+ message: `core.hooksPath ${was} \u2192 ${want}, for every worktree of this clone`
92
+ };
93
+ }
94
+
95
+ // src/hooks/gates.ts
96
+ import { existsSync } from "fs";
97
+
98
+ // src/hooks/oxfmt-config.ts
99
+ import { readdir } from "fs/promises";
100
+ var AUTO_DISCOVERED = [".oxfmtrc.json", ".oxfmtrc.jsonc"];
101
+ var NEEDS_EXPLICIT_CONFIG = [
102
+ ".oxfmtrc.ts",
103
+ ".oxfmtrc.mts",
104
+ ".oxfmtrc.cts",
105
+ ".oxfmtrc.js",
106
+ ".oxfmtrc.mjs",
107
+ ".oxfmtrc.cjs"
108
+ ];
109
+ async function present(root, names) {
110
+ const entries = new Set(await readdir(root).catch(() => []));
111
+ return names.filter((name) => entries.has(name));
112
+ }
113
+ async function resolveOxfmtConfig(root) {
114
+ if ((await present(root, AUTO_DISCOVERED)).length > 0)
115
+ return { kind: "auto" };
116
+ const explicit = await present(root, NEEDS_EXPLICIT_CONFIG);
117
+ const [only, ...rest] = explicit;
118
+ if (only === undefined)
119
+ return { kind: "none" };
120
+ if (rest.length === 0)
121
+ return { kind: "explicit", path: `${root}/${only}` };
122
+ return { kind: "ambiguous", files: explicit };
123
+ }
124
+
125
+ // src/hooks/push-plan.ts
126
+ var LEFT_TO_CI = /^(build|smoke)(:|$)/;
127
+ var MAX_DEPTH = 8;
128
+ function andChain(script) {
129
+ const parts = [];
130
+ let quote;
131
+ let current = "";
132
+ for (let i = 0;i < script.length; i++) {
133
+ const char = script[i] ?? "";
134
+ if (quote !== undefined) {
135
+ if (char === quote)
136
+ quote = undefined;
137
+ current += char;
138
+ continue;
139
+ }
140
+ if (char === '"' || char === "'") {
141
+ quote = char;
142
+ current += char;
143
+ continue;
144
+ }
145
+ if (char === "&" && script[i + 1] === "&") {
146
+ parts.push(current.trim());
147
+ current = "";
148
+ i += 1;
149
+ continue;
150
+ }
151
+ if ("|;&<>`".includes(char) || char === "$" && script[i + 1] === "(")
152
+ return;
153
+ current += char;
154
+ }
155
+ if (quote !== undefined)
156
+ return;
157
+ parts.push(current.trim());
158
+ return parts.some((part) => part === "") ? undefined : parts;
159
+ }
160
+ function scriptRef(segment, scripts) {
161
+ const words = segment.split(/\s+/);
162
+ if (words.length === 2 && words[0] === "npm" && words[1] === "test")
163
+ return "test";
164
+ const [runner, verb, name] = words;
165
+ if (words.length !== 3 || verb !== "run" || name === undefined)
166
+ return;
167
+ if (runner !== "bun" && runner !== "npm")
168
+ return;
169
+ return Object.hasOwn(scripts, name) ? name : undefined;
170
+ }
171
+ var isBunTest = (segment) => /^bun\s+test(\s|$)/.test(segment);
172
+ function testLane(segment, base) {
173
+ return base === undefined ? { kind: "test", label: segment, command: segment, scoped: false } : { kind: "test", label: segment, command: `${segment} --changed=${base}`, scoped: true };
174
+ }
175
+ function expand(script, scripts, base, depth) {
176
+ const segments = andChain(script);
177
+ if (segments === undefined || depth > MAX_DEPTH)
178
+ return;
179
+ const lanes = [];
180
+ for (const segment of segments) {
181
+ if (isBunTest(segment)) {
182
+ lanes.push(testLane(segment, base));
183
+ continue;
184
+ }
185
+ const name = scriptRef(segment, scripts);
186
+ if (name === undefined) {
187
+ lanes.push({ kind: "run", label: segment, command: segment });
188
+ continue;
189
+ }
190
+ if (LEFT_TO_CI.test(name)) {
191
+ lanes.push({ kind: "skip", label: segment, why: "CI runs it on every pull request" });
192
+ continue;
193
+ }
194
+ const inner = expand(scripts[name] ?? "", scripts, base, depth + 1);
195
+ if (inner !== undefined && inner.some((lane) => lane.kind !== "run")) {
196
+ lanes.push(...inner);
197
+ } else if (/^test(:|$)/.test(name)) {
198
+ lanes.push({ kind: "test", label: segment, command: segment, scoped: false });
199
+ } else {
200
+ lanes.push({ kind: "run", label: segment, command: segment });
201
+ }
202
+ }
203
+ return lanes;
204
+ }
205
+ function planLanes(scripts, base) {
206
+ const check = scripts["check"];
207
+ if (check === undefined)
208
+ return [];
209
+ const whole = {
210
+ kind: "test",
211
+ label: "bun run check",
212
+ command: "bun run check",
213
+ scoped: false
214
+ };
215
+ return expand(check, scripts, base, 0) ?? [whole];
216
+ }
217
+
218
+ // src/hooks/push-range.ts
219
+ var ZERO = /^0+$/;
220
+ function parsePushRefs(stdin) {
221
+ const refs = [];
222
+ for (const line2 of stdin.split(`
223
+ `)) {
224
+ const [localRef, localSha, remoteRef, remoteSha] = line2.trim().split(/\s+/);
225
+ if (localRef && localSha && remoteRef && remoteSha) {
226
+ refs.push({ localRef, localSha, remoteRef, remoteSha });
227
+ }
228
+ }
229
+ return refs;
230
+ }
231
+ var short = (sha) => sha.slice(0, 9);
232
+ async function ok2(root, args) {
233
+ return (await probe(["git", "-C", root, ...args])).code === 0;
234
+ }
235
+ async function out(root, args) {
236
+ const result = await probe(["git", "-C", root, ...args]);
237
+ return result.code === 0 ? result.stdout.trim() : undefined;
238
+ }
239
+ async function defaultBranch(root, remote) {
240
+ const head = await out(root, ["symbolic-ref", "--quiet", `refs/remotes/${remote}/HEAD`]);
241
+ if (head !== undefined && head !== "")
242
+ return head;
243
+ for (const name of ["main", "master"]) {
244
+ const ref = `refs/remotes/${remote}/${name}`;
245
+ if (await ok2(root, ["rev-parse", "--verify", "--quiet", ref]))
246
+ return ref;
247
+ }
248
+ return;
249
+ }
250
+ async function baseFor(root, remote, ref) {
251
+ const { localSha, remoteSha } = ref;
252
+ if (!ZERO.test(remoteSha) && await ok2(root, ["cat-file", "-e", `${remoteSha}^{commit}`]) && await ok2(root, ["merge-base", "--is-ancestor", remoteSha, localSha])) {
253
+ return { base: remoteSha, why: `since ${short(remoteSha)}, what ${remote} has now` };
254
+ }
255
+ const branch = await defaultBranch(root, remote);
256
+ if (branch === undefined)
257
+ return { why: `no ${remote}/main or ${remote}/master to measure from` };
258
+ const mergeBase = await out(root, ["merge-base", localSha, branch]);
259
+ if (mergeBase === undefined || mergeBase === "") {
260
+ return { why: `no merge base with ${branch} (a shallow clone, or unrelated history)` };
261
+ }
262
+ const name = branch.replace(/^refs\/remotes\//, "");
263
+ return { base: mergeBase, why: `since ${short(mergeBase)}, where this branch left ${name}` };
264
+ }
265
+ async function pushScope(root, remote, refs) {
266
+ const pushed = refs.filter((ref2) => !ZERO.test(ref2.localSha));
267
+ if (refs.length > 0 && pushed.length === 0) {
268
+ return { kind: "empty", why: "this push only deletes refs" };
269
+ }
270
+ const head = await out(root, ["rev-parse", "HEAD"]);
271
+ const atHead = pushed.find((ref2) => ref2.localSha === head);
272
+ const others = pushed.filter((ref2) => ref2 !== atHead).map((ref2) => ref2.localRef);
273
+ if (pushed.length > 0 && atHead === undefined) {
274
+ return {
275
+ kind: "elsewhere",
276
+ why: `pushing ${others.join(", ")}, but the checkout is at ${short(head ?? "?")}`
277
+ };
278
+ }
279
+ const ref = atHead ?? {
280
+ localRef: "HEAD",
281
+ localSha: head ?? "HEAD",
282
+ remoteRef: "",
283
+ remoteSha: "0".repeat(40)
284
+ };
285
+ const found = await baseFor(root, remote, ref);
286
+ const also = others.length > 0 ? `; ${others.join(", ")} not checked here` : "";
287
+ if (!("base" in found))
288
+ return { kind: "unscoped", why: `${found.why}${also}` };
289
+ const diff = await probe([
290
+ "git",
291
+ "-C",
292
+ root,
293
+ "diff",
294
+ "--name-only",
295
+ "-z",
296
+ found.base,
297
+ ref.localSha
298
+ ]);
299
+ if (diff.code !== 0)
300
+ return { kind: "unscoped", why: `git diff ${short(found.base)} failed` };
301
+ const changed = diff.stdout.split("\x00").filter((path) => path !== "");
302
+ if (changed.length === 0)
303
+ return { kind: "empty", why: `no file differs ${found.why}${also}` };
304
+ return { kind: "scoped", base: found.base, changed, why: `${found.why}${also}` };
305
+ }
306
+ function changesEverything(path) {
307
+ const name = path.split("/").pop() ?? "";
308
+ return /^(package\.json|bun\.lockb?|bunfig\.toml|tsconfig.*\.json)$/.test(name);
309
+ }
310
+
311
+ // src/hooks/secrets.ts
312
+ var INSTALL = "brew install gitleaks \u2014 Linux: https://github.com/gitleaks/gitleaks/releases";
313
+ async function scanStagedSecrets() {
314
+ if (Bun.which("gitleaks") === null) {
315
+ fail("pre-commit", "gitleaks is not installed, so the staged changes were NOT scanned", INSTALL);
316
+ }
317
+ const code = await run(["gitleaks", "git", "--staged", "--redact", "--no-banner", "."]);
318
+ if (code !== 0) {
319
+ fail("pre-commit", "gitleaks found a secret in the staged changes", "remove it, then ROTATE it \u2014 assume anything committed is already compromised");
320
+ }
321
+ ok("pre-commit: gitleaks found no secret in the staged changes");
322
+ }
323
+
47
324
  // src/hooks/staged.ts
48
325
  var FORMATTABLE = /\.(ts|tsx|js|jsx|mjs|cjs|json|jsonc|md)$/;
49
326
  var CODE = /\.(ts|tsx|js|jsx|mjs|cjs)$/;
50
327
  async function names(args) {
51
- const out = await capture(["git", ...args, "-z"]);
52
- return out.split("\x00").filter((line2) => line2.length > 0);
328
+ const out2 = await capture(["git", ...args, "-z"]);
329
+ return out2.split("\x00").filter((line2) => line2.length > 0);
53
330
  }
54
331
  async function staged() {
55
332
  const indexed = await names(["diff", "--cached", "--name-only", "--diff-filter=ACMR"]);
@@ -62,20 +339,46 @@ async function staged() {
62
339
  };
63
340
  }
64
341
  async function fingerprints(files) {
65
- const out = new Map;
342
+ const out2 = new Map;
66
343
  for (const file of files) {
67
344
  const handle = Bun.file(file);
68
345
  if (!await handle.exists())
69
346
  continue;
70
- out.set(file, String(Bun.hash(await handle.arrayBuffer())));
347
+ out2.set(file, String(Bun.hash(await handle.arrayBuffer())));
71
348
  }
72
- return out;
349
+ return out2;
73
350
  }
74
351
 
75
352
  // src/hooks/gates.ts
353
+ function matchedFileCount(stdout) {
354
+ const match = /\bon (\d+) files? using/.exec(stdout);
355
+ return match?.[1] === undefined ? null : Number(match[1]);
356
+ }
357
+ function installed(root, hook) {
358
+ if (existsSync(`${root}/node_modules`))
359
+ return true;
360
+ note(`${hook}: no node_modules in this worktree \u2014 run 'bun install'; skipping the rest`);
361
+ return false;
362
+ }
76
363
  async function preCommit(root) {
77
- const oxfmt = tool(root, "oxfmt");
364
+ await scanStagedSecrets();
365
+ if (!installed(root, "pre-commit"))
366
+ return;
78
367
  const { formattable, code, partial } = await staged();
368
+ if (formattable.length === 0 && partial.length === 0) {
369
+ ok("pre-commit: nothing staged to format");
370
+ return;
371
+ }
372
+ const config = await resolveOxfmtConfig(root);
373
+ if (config.kind === "ambiguous") {
374
+ fail("pre-commit", `${String(config.files.length)} oxfmt configs found (${config.files.join(", ")}) and oxfmt auto-discovers none of them`, "keep exactly one .oxfmtrc.* file at the repo root (.oxfmtrc.json is auto-discovered)");
375
+ }
376
+ const oxfmt = [
377
+ ...tool(root, "oxfmt"),
378
+ ...config.kind === "explicit" ? ["--config", config.path] : [],
379
+ "--no-error-on-unmatched-pattern"
380
+ ];
381
+ const oxlint = [...tool(root, "oxlint"), "--no-error-on-unmatched-pattern"];
79
382
  if (partial.length > 0) {
80
383
  note(`${partial.length} staged file(s) also have unstaged edits; checking without rewriting`);
81
384
  if (await run([...oxfmt, "--check", ...partial]) !== 0) {
@@ -87,56 +390,107 @@ async function preCommit(root) {
87
390
  return;
88
391
  }
89
392
  const before = await fingerprints(formattable);
90
- if (await run([...oxfmt, ...formattable]) !== 0) {
393
+ const { code: fmtExit, stdout: fmtOut } = await runCaptured([...oxfmt, ...formattable]);
394
+ if (fmtExit !== 0) {
91
395
  fail("pre-commit", "oxfmt could not format the staged files", "bun run format");
92
396
  }
93
- const after = await fingerprints(formattable);
94
- const rewritten = formattable.filter((file) => before.get(file) !== after.get(file));
95
- if (rewritten.length > 0) {
96
- note(`oxfmt rewrote and restaged ${rewritten.length} file(s): ${rewritten.join(", ")}`);
97
- if (await run(["git", "add", "--", ...rewritten]) !== 0) {
98
- fail("pre-commit", "could not restage the formatted files", "git add the listed files");
397
+ const matched = matchedFileCount(fmtOut);
398
+ if (matched !== 0) {
399
+ const after = await fingerprints(formattable);
400
+ const rewritten = formattable.filter((file) => before.get(file) !== after.get(file));
401
+ if (rewritten.length > 0) {
402
+ note(`oxfmt rewrote and restaged ${rewritten.length} file(s): ${rewritten.join(", ")}`);
403
+ if (await run(["git", "add", "--", ...rewritten]) !== 0) {
404
+ fail("pre-commit", "could not restage the formatted files", "git add the listed files");
405
+ }
99
406
  }
100
407
  }
101
- if (code.length > 0 && await run([...tool(root, "oxlint"), "--deny-warnings", ...code]) !== 0) {
408
+ if (code.length > 0 && await run([...oxlint, "--deny-warnings", ...code]) !== 0) {
102
409
  fail("pre-commit", `oxlint found problems in ${code.length} staged file(s)`, "bun run lint:fix, then fix by hand what remains");
103
410
  }
104
- ok(`pre-commit: ${formattable.length} staged file(s) formatted and linted`);
411
+ if (matched === 0) {
412
+ note(`${String(formattable.length)} staged file(s) matched a formattable extension, but all are excluded by ignore rules`);
413
+ ok("pre-commit: no formattable staged files");
414
+ } else {
415
+ ok(`pre-commit: ${formattable.length} staged file(s) formatted and linted`);
416
+ }
417
+ }
418
+ function describe(lane) {
419
+ if (lane.kind === "skip")
420
+ return `skip ${lane.label} \u2014 ${lane.why}`;
421
+ if (lane.kind === "test" && lane.scoped)
422
+ return `run ${lane.command} (only the tests the push can reach)`;
423
+ if (lane.kind === "test")
424
+ return `run ${lane.command} (IN FULL)`;
425
+ return `run ${lane.command}`;
105
426
  }
106
- async function prePush(root) {
427
+ async function prePush(root, args, stdin) {
107
428
  const manifest = Bun.file(`${root}/package.json`);
108
429
  const pkg = await manifest.exists() ? await manifest.json() : {};
109
- if (pkg.scripts?.["check"] === undefined) {
430
+ const scripts = pkg.scripts ?? {};
431
+ if (scripts["check"] === undefined) {
110
432
  note("pre-push: no `check` script declared in package.json; nothing to run");
111
433
  return;
112
434
  }
113
- note("pre-push: running `bun run check` \u2014 the same gate CI runs");
435
+ if (!installed(root, "pre-push"))
436
+ return;
437
+ const scope = await pushScope(root, args[0] ?? "origin", parsePushRefs(stdin));
438
+ if (scope.kind === "empty") {
439
+ ok(`pre-push: ${scope.why} \u2014 nothing to check`);
440
+ return;
441
+ }
442
+ if (scope.kind === "elsewhere") {
443
+ note(`pre-push: ${scope.why} \u2014 the working tree is not what is being pushed`);
444
+ note(" NOT CHECKED here; CI checks it. To check it locally, check it out and push from there.");
445
+ return;
446
+ }
447
+ let base;
448
+ if (scope.kind === "unscoped") {
449
+ note(`pre-push: ${scope.why} \u2014 every lane runs, tests in full`);
450
+ } else {
451
+ const global = scope.changed.filter(changesEverything);
452
+ note(`pre-push: ${String(scope.changed.length)} file(s) changed ${scope.why}`);
453
+ if (global.length > 0)
454
+ note(` ${global.join(", ")} changes what every test runs on \u2014 tests in full`);
455
+ else
456
+ base = scope.base;
457
+ }
458
+ const lanes = planLanes(scripts, base);
114
459
  const started = Bun.nanoseconds();
115
- if (await run(["bun", "run", "check"], true) !== 0) {
116
- fail("pre-push", "bun run check failed \u2014 CI would fail the same way, on a shared runner", "bun run lint:fix, then bun run check until it is green");
460
+ let ran = 0;
461
+ for (const lane of lanes) {
462
+ note(describe(lane));
463
+ if (lane.kind === "skip")
464
+ continue;
465
+ if (await runLane(lane.command, root) !== 0) {
466
+ fail("pre-push", `\`${lane.label}\` failed`, `${lane.command} \u2014 until it is green`);
467
+ }
468
+ ran += 1;
117
469
  }
118
- ok(`pre-push: bun run check passed in ${((Bun.nanoseconds() - started) / 1e9).toFixed(1)}s`);
470
+ const seconds = ((Bun.nanoseconds() - started) / 1e9).toFixed(1);
471
+ ok(`pre-push: ${String(ran)} lane(s) of \`check\` passed in ${seconds}s \u2014 CI runs the full gate`);
119
472
  }
120
473
 
121
474
  // src/hooks/install.ts
122
- import { chmod, mkdir } from "fs/promises";
475
+ import { chmod, mkdir, stat } from "fs/promises";
123
476
  var HOOK_NAMES = ["pre-commit", "pre-push"];
124
477
  var RUNNER = "node_modules/@homeflare/config/bin/hooks.ts";
478
+ var PREPARE = `bun ${RUNNER} activate`;
125
479
  var HUSKY_HOOK = `# HomeFlare shared git hook. The behaviour lives in @homeflare/config, not in this file,
126
480
  # and the same bytes are installed as .husky/pre-commit and .husky/pre-push \u2014 the hook
127
- # name comes from $0.
481
+ # name comes from $0, and git's arguments and stdin pass straight through.
128
482
  #
129
- # \u26A0\uFE0F A hook is a local convenience, not a gate: it is skippable with --no-verify and does
130
- # not exist in a fresh clone until \`bun install\` runs the \`prepare\` script. The
131
- # required checks on main stay the gate.
483
+ # \u26A0\uFE0F A hook is a local convenience, not a gate: it is skippable with --no-verify, and a
484
+ # worktree runs it only once \`bun install\` has run there. The required checks on main
485
+ # stay the gate.
132
486
  #
133
487
  # Regenerate this file with: bun ${RUNNER} install
134
488
  hook="${RUNNER}"
135
489
  if [ ! -f "$hook" ]; then
136
- echo "husky: $hook is missing \u2014 run 'bun install' to enable the HomeFlare hooks; skipping"
490
+ echo "homeflare hooks: $hook is missing \u2014 run 'bun install' in this worktree; skipping" >&2
137
491
  exit 0
138
492
  fi
139
- exec bun "$hook" "$(basename "$0")"
493
+ exec bun "$hook" "$(basename "$0")" "$@"
140
494
  `;
141
495
  async function installHooks(projectDir) {
142
496
  await mkdir(`${projectDir}/.husky`, { recursive: true });
@@ -155,14 +509,16 @@ async function problemsInHooks(projectDir) {
155
509
  if (!await manifest.exists())
156
510
  return ["package.json: missing"];
157
511
  const pkg = await manifest.json();
158
- if (!(pkg.scripts?.["prepare"] ?? "").includes("husky")) {
159
- problems.push('package.json: no "prepare": "husky" script \u2014 a fresh clone installs no hooks');
512
+ const prepare = pkg.scripts?.["prepare"] ?? "";
513
+ if (!/bin\/hooks\.ts activate/.test(prepare)) {
514
+ problems.push(`package.json: "prepare" does not run \`${PREPARE}\` \u2014 no clone or worktree gets hooks`);
160
515
  }
161
- if (pkg.devDependencies?.["husky"] === undefined) {
162
- problems.push("package.json: husky is not a devDependency");
516
+ if (/\bhusky\b/.test(prepare)) {
517
+ problems.push('package.json: "prepare" still runs husky, which re-points core.hooksPath at .husky/_');
163
518
  }
164
519
  for (const name of HOOK_NAMES) {
165
- const file = Bun.file(`${projectDir}/.husky/${name}`);
520
+ const path = `${projectDir}/.husky/${name}`;
521
+ const file = Bun.file(path);
166
522
  if (!await file.exists()) {
167
523
  problems.push(`.husky/${name}: missing \u2014 run \`bun ${RUNNER} install\``);
168
524
  continue;
@@ -170,33 +526,44 @@ async function problemsInHooks(projectDir) {
170
526
  if (await file.text() !== HUSKY_HOOK) {
171
527
  problems.push(`.husky/${name}: differs from the @homeflare/config wrapper \u2014 run \`bun ${RUNNER} install\`, or change it in the package`);
172
528
  }
529
+ if (((await stat(path)).mode & 73) === 0) {
530
+ problems.push(`.husky/${name}: not executable, so git ignores it \u2014 chmod +x it and commit`);
531
+ }
173
532
  }
174
533
  return problems;
175
534
  }
176
535
 
177
536
  // src/hooks.ts
178
537
  function isCommand(value) {
179
- return value === "pre-commit" || value === "pre-push" || value === "install";
538
+ return ["pre-commit", "pre-push", "install", "activate"].includes(value);
180
539
  }
181
- async function runCommand(command, root) {
540
+ async function runCommand(command, root, args = [], stdin = "") {
182
541
  if (command === "install") {
183
542
  const written = await installHooks(root);
184
543
  ok(`wrote ${written.join(", ")} \u2014 commit them`);
185
- note("they do nothing until `bun install` runs `prepare` (husky)");
544
+ note(`then set "prepare": "${PREPARE}" so every install activates them`);
545
+ return;
546
+ }
547
+ if (command === "activate") {
548
+ const result = await activateHooks(root);
549
+ (result.active ? ok : note)(`homeflare hooks: ${result.message}`);
186
550
  return;
187
551
  }
188
552
  if (command === "pre-commit")
189
553
  return await preCommit(root);
190
- return await prePush(root);
554
+ return await prePush(root, args, stdin);
191
555
  }
192
556
  export {
193
557
  HOOK_NAMES,
194
558
  HUSKY_HOOK,
559
+ PREPARE,
560
+ activateHooks,
195
561
  installHooks,
196
562
  isCommand,
563
+ planLanes,
197
564
  problemsInHooks,
198
565
  runCommand
199
566
  };
200
567
 
201
- //# debugId=3480C5F48A0E1AD864756E2164756E21
568
+ //# debugId=F6FB90E426FFDF3C64756E2164756E21
202
569
  //# sourceMappingURL=hooks.js.map