@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.
- package/README.md +11 -41
- package/bin/hooks.ts +8 -4
- package/dist/hooks/activate.d.ts +7 -0
- package/dist/hooks/activate.d.ts.map +1 -0
- package/dist/hooks/gates.d.ts +7 -14
- package/dist/hooks/gates.d.ts.map +1 -1
- package/dist/hooks/install.d.ts +7 -1
- package/dist/hooks/install.d.ts.map +1 -1
- package/dist/hooks/oxfmt-config.d.ts +25 -0
- package/dist/hooks/oxfmt-config.d.ts.map +1 -0
- package/dist/hooks/push-plan.d.ts +59 -0
- package/dist/hooks/push-plan.d.ts.map +1 -0
- package/dist/hooks/push-range.d.ts +51 -0
- package/dist/hooks/push-range.d.ts.map +1 -0
- package/dist/hooks/report.d.ts +39 -2
- package/dist/hooks/report.d.ts.map +1 -1
- package/dist/hooks/secrets.d.ts +2 -0
- package/dist/hooks/secrets.d.ts.map +1 -0
- package/dist/hooks.d.ts +30 -6
- package/dist/hooks.d.ts.map +1 -1
- package/dist/hooks.js +408 -41
- package/dist/hooks.js.map +12 -7
- package/dist/repo-shape/yaml.d.ts +20 -3
- package/dist/repo-shape/yaml.d.ts.map +1 -1
- package/dist/repo-shape.js +8 -2
- package/dist/repo-shape.js.map +3 -3
- package/dist/versions.js +8 -2
- package/dist/versions.js.map +3 -3
- package/docs/hooks.md +118 -0
- package/package.json +1 -1
- package/src/hooks/activate.ts +71 -0
- package/src/hooks/gates.ts +156 -39
- package/src/hooks/install.ts +39 -20
- package/src/hooks/oxfmt-config.ts +67 -0
- package/src/hooks/push-plan.ts +167 -0
- package/src/hooks/push-range.ts +177 -0
- package/src/hooks/report.ts +46 -6
- package/src/hooks/secrets.ts +42 -0
- package/src/hooks.ts +30 -14
- 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
|
|
21
|
-
await proc.exited;
|
|
22
|
-
|
|
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
|
|
52
|
-
return
|
|
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
|
|
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
|
-
|
|
347
|
+
out2.set(file, String(Bun.hash(await handle.arrayBuffer())));
|
|
71
348
|
}
|
|
72
|
-
return
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
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([...
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
|
|
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
|
|
130
|
-
#
|
|
131
|
-
#
|
|
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 "
|
|
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
|
-
|
|
159
|
-
|
|
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 (
|
|
162
|
-
problems.push(
|
|
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
|
|
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
|
|
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("
|
|
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=
|
|
568
|
+
//# debugId=F6FB90E426FFDF3C64756E2164756E21
|
|
202
569
|
//# sourceMappingURL=hooks.js.map
|