lintlane 3.0.1 → 4.0.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.
- package/README.md +204 -632
- package/bin/check-commit.mjs +54 -137
- package/bin/{ci.mjs → checks.mjs} +104 -35
- package/bin/cli.mjs +226 -69
- package/bin/commit-cli.mjs +36 -122
- package/bin/doctor.mjs +328 -0
- package/bin/hook.mjs +156 -0
- package/bin/proc.mjs +20 -1
- package/bin/project.mjs +100 -4
- package/bin/ui-server.mjs +77 -10
- package/package.json +18 -5
- package/presets/commitizen.cjs +4 -0
- package/presets/commitlint.mjs +47 -0
- package/templates/commit-msg +2 -2
- package/templates/pre-commit +2 -5
- package/templates/pre-push +2 -2
- package/templates/prepare-commit-msg +2 -2
- package/ui/index.html +210 -44
- package/bin/commit-config.mjs +0 -24
- package/bin/commit-lint.mjs +0 -143
- package/bin/commit-msg.mjs +0 -18
- package/bin/commit-prompt.mjs +0 -110
- package/bin/pre-commit.mjs +0 -55
- package/bin/pre-push-check.mjs +0 -48
- package/bin/prepare-commit-msg.mjs +0 -46
- package/bin/staged.mjs +0 -333
package/bin/cli.mjs
CHANGED
|
@@ -4,13 +4,17 @@ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { parseArgs } from "node:util";
|
|
7
|
+
import { resolveChecks } from "./checks.mjs";
|
|
7
8
|
import { binPath, resolveBiome, runInherit, runProcess } from "./proc.mjs";
|
|
8
9
|
import {
|
|
10
|
+
COMMITIZEN_PATH,
|
|
11
|
+
classifyHook,
|
|
9
12
|
detectPackageManager,
|
|
10
13
|
detectPreset,
|
|
11
|
-
|
|
14
|
+
hasCommitlintConfig,
|
|
15
|
+
hasLintStagedConfig,
|
|
12
16
|
installCommand,
|
|
13
|
-
|
|
17
|
+
LINT_STAGED_CONFIG,
|
|
14
18
|
PRESET_PATHS,
|
|
15
19
|
renderWorkflow,
|
|
16
20
|
runnerCommand,
|
|
@@ -36,11 +40,14 @@ Commands:
|
|
|
36
40
|
check [paths] Run biome check (defaults to .)
|
|
37
41
|
check:fix [paths] Run biome check --write (defaults to .)
|
|
38
42
|
ci [paths] Run biome ci --reporter=github (defaults to .)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
--
|
|
42
|
-
|
|
43
|
-
|
|
43
|
+
run [checks] Run the project's checks (Biome, typecheck, build, tests) in
|
|
44
|
+
order, the same list CI and the dashboard use. Flags:
|
|
45
|
+
--keep-going (don't stop at the first failure), --list
|
|
46
|
+
doctor [--json] Diagnose the setup: tools, hooks, configs, CI. Read-only
|
|
47
|
+
commit [options] Guided Conventional Commit (alias: c), run by Commitizen;
|
|
48
|
+
git flags go after --
|
|
49
|
+
check-commit Validate commit messages with commitlint (--message,
|
|
50
|
+
--commit-msg-file, --rev-range, -d)
|
|
44
51
|
ui [options] Open the local dashboard: run checks, review errors and
|
|
45
52
|
proposed fixes as a diff, apply fixes per file
|
|
46
53
|
|
|
@@ -53,6 +60,7 @@ init options:
|
|
|
53
60
|
--no-hooks Skip husky + pre-commit hook setup
|
|
54
61
|
--no-commit-lint Skip the commit-message hooks (commit-msg, prepare-commit-msg, pre-push)
|
|
55
62
|
--no-ci Skip the GitHub Actions workflow
|
|
63
|
+
--dry-run Show everything init would do; change nothing
|
|
56
64
|
--skip-install Print install commands instead of running them
|
|
57
65
|
-h, --help Show this help
|
|
58
66
|
|
|
@@ -102,6 +110,10 @@ const { values, positionals } = parseArgs({
|
|
|
102
110
|
"no-ci": { type: "boolean", default: false },
|
|
103
111
|
"no-commit-lint": { type: "boolean", default: false },
|
|
104
112
|
"skip-install": { type: "boolean", default: false },
|
|
113
|
+
"dry-run": { type: "boolean", default: false },
|
|
114
|
+
json: { type: "boolean", default: false },
|
|
115
|
+
"keep-going": { type: "boolean", default: false },
|
|
116
|
+
list: { type: "boolean", default: false },
|
|
105
117
|
port: { type: "string" },
|
|
106
118
|
"no-open": { type: "boolean", default: false },
|
|
107
119
|
help: { type: "boolean", short: "h", default: false },
|
|
@@ -140,6 +152,71 @@ if (command === "check-commit") {
|
|
|
140
152
|
process.exit(await main(commandArgs, cwd));
|
|
141
153
|
}
|
|
142
154
|
|
|
155
|
+
if (command === "doctor") {
|
|
156
|
+
const { formatReport, runDoctor } = await import("./doctor.mjs");
|
|
157
|
+
const report = await runDoctor({ cwd, kitDir: KIT_DIR });
|
|
158
|
+
console.log(values.json ? JSON.stringify(report, null, 2) : formatReport(report));
|
|
159
|
+
process.exit(report.ok ? 0 : 1);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (command === "hook") {
|
|
163
|
+
const { runHook } = await import("./hook.mjs");
|
|
164
|
+
const at = rawArgs.indexOf("hook");
|
|
165
|
+
const [name, ...hookArgs] = rawArgs.slice(at + 1);
|
|
166
|
+
process.exit(await runHook(name, hookArgs, cwd));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (command === "run") {
|
|
170
|
+
const biome = resolveBiome(cwd);
|
|
171
|
+
if (!biome) fail(`Biome isn't installed in this project. Run: ${RUNNER} ${KIT_NAME} init`);
|
|
172
|
+
const pkg = existsSync(join(cwd, "package.json"))
|
|
173
|
+
? await readJson(join(cwd, "package.json"))
|
|
174
|
+
: {};
|
|
175
|
+
const { steps, problems } = resolveChecks({
|
|
176
|
+
cwd,
|
|
177
|
+
biome,
|
|
178
|
+
pmName: detectPackageManager(cwd, pkg).name,
|
|
179
|
+
pkg,
|
|
180
|
+
});
|
|
181
|
+
for (const problem of problems) warn(problem);
|
|
182
|
+
if (values.list) {
|
|
183
|
+
for (const step of steps) {
|
|
184
|
+
console.log(`${step.id.padEnd(10)} ${step.defaultOn ? "on " : "off"} ${step.display}`);
|
|
185
|
+
}
|
|
186
|
+
process.exit(0);
|
|
187
|
+
}
|
|
188
|
+
const wanted = positionals.slice(1);
|
|
189
|
+
const unknown = wanted.filter((id) => !steps.some((step) => step.id === id));
|
|
190
|
+
if (unknown.length)
|
|
191
|
+
fail(`Unknown check: ${unknown.join(", ")}. Available: ${steps.map((st) => st.id).join(", ")}`);
|
|
192
|
+
const chosen = wanted.length
|
|
193
|
+
? steps.filter((step) => wanted.includes(step.id))
|
|
194
|
+
: steps.filter((step) => step.defaultOn);
|
|
195
|
+
if (chosen.length === 0) fail("No checks to run.");
|
|
196
|
+
const { runCiSteps } = await import("./checks.mjs");
|
|
197
|
+
const results = [];
|
|
198
|
+
const byId = new Map(steps.map((step) => [step.id, step]));
|
|
199
|
+
await runCiSteps({
|
|
200
|
+
cwd,
|
|
201
|
+
steps: chosen,
|
|
202
|
+
stopOnFail: !values["keep-going"],
|
|
203
|
+
send: (event) => {
|
|
204
|
+
if (event.type === "start")
|
|
205
|
+
console.log(`▶ ${byId.get(event.id).label} (${byId.get(event.id).display})`);
|
|
206
|
+
else if (event.type === "log") process.stdout.write(event.text);
|
|
207
|
+
else if (event.type === "end") {
|
|
208
|
+
results.push(event);
|
|
209
|
+
console.log(
|
|
210
|
+
`${event.code === 0 ? "✔" : "✘"} ${byId.get(event.id).label} (${(event.ms / 1000).toFixed(1)}s)`,
|
|
211
|
+
);
|
|
212
|
+
} else if (event.type === "skip") console.log(`- skipped ${byId.get(event.id).label}`);
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
process.exit(
|
|
216
|
+
results.every((result) => result.code === 0) && results.length === chosen.length ? 0 : 1,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
143
220
|
if (command === "ui") {
|
|
144
221
|
const biome = resolveBiome(cwd);
|
|
145
222
|
if (!biome) {
|
|
@@ -166,7 +243,7 @@ if (command === "ui") {
|
|
|
166
243
|
|
|
167
244
|
if (command !== "init") {
|
|
168
245
|
fail(
|
|
169
|
-
`Unknown command "${command}". Usage: ${RUNNER} ${KIT_NAME} <init|check|check:fix|ci|commit|check-commit|ui> [options]`,
|
|
246
|
+
`Unknown command "${command}". Usage: ${RUNNER} ${KIT_NAME} <init|check|check:fix|ci|run|doctor|commit|check-commit|ui> [options]`,
|
|
170
247
|
);
|
|
171
248
|
}
|
|
172
249
|
|
|
@@ -196,16 +273,38 @@ if (preset === undefined) {
|
|
|
196
273
|
}
|
|
197
274
|
}
|
|
198
275
|
|
|
276
|
+
const dryRun = values["dry-run"];
|
|
277
|
+
if (dryRun) console.log("Dry run: nothing will be changed.\n");
|
|
278
|
+
|
|
279
|
+
// Every change goes through act(): in a dry run it only says what it would do.
|
|
280
|
+
async function act(message, fn) {
|
|
281
|
+
if (dryRun) {
|
|
282
|
+
console.log(`+ would ${message}`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
await fn();
|
|
286
|
+
ok(message[0].toUpperCase() + message.slice(1));
|
|
287
|
+
}
|
|
288
|
+
|
|
199
289
|
// LINTLANE_INSTALL_SPEC lets you install lintlane from somewhere other than the
|
|
200
290
|
// registry (a tarball from `npm pack`, a local folder) when testing a build.
|
|
201
291
|
const kitSpec = process.env.LINTLANE_INSTALL_SPEC || `${KIT_NAME}@${KIT_VERSION}`;
|
|
202
|
-
const
|
|
203
|
-
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
if (
|
|
207
|
-
|
|
292
|
+
const declared = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
293
|
+
// Only install what's missing, so an existing Biome or husky version is never bumped.
|
|
294
|
+
const devPackages = [];
|
|
295
|
+
if (!("@biomejs/biome" in declared)) devPackages.push("@biomejs/biome");
|
|
296
|
+
if (process.env.LINTLANE_INSTALL_SPEC || declared[KIT_NAME] !== KIT_VERSION)
|
|
297
|
+
devPackages.push(kitSpec);
|
|
298
|
+
if (!values["no-hooks"] && !("husky" in declared)) devPackages.push("husky");
|
|
299
|
+
|
|
300
|
+
if (devPackages.length === 0) {
|
|
301
|
+
ok("Dependencies already installed");
|
|
302
|
+
} else if (values["skip-install"] || dryRun) {
|
|
303
|
+
const argv = installCommand(pmName, devPackages).join(" ");
|
|
304
|
+
if (dryRun) console.log(`+ would run: ${argv}`);
|
|
305
|
+
else warn(`Skipped install. Run: ${argv}`);
|
|
208
306
|
} else {
|
|
307
|
+
const installArgv = installCommand(pmName, devPackages);
|
|
209
308
|
const install = await runProcess(installArgv[0], installArgv.slice(1), { cwd });
|
|
210
309
|
if (install.code !== 0) {
|
|
211
310
|
const reason =
|
|
@@ -228,7 +327,7 @@ const biomeJsoncPath = join(cwd, "biome.jsonc");
|
|
|
228
327
|
const extendsSpecifier = `${KIT_NAME}/${PRESET_PATHS[preset]}`;
|
|
229
328
|
|
|
230
329
|
if (existsSync(biomeJsonPath) || existsSync(biomeJsoncPath)) {
|
|
231
|
-
warn(`biome.json already exists —
|
|
330
|
+
warn(`biome.json already exists — make sure its "extends" includes "${extendsSpecifier}"`);
|
|
232
331
|
} else {
|
|
233
332
|
const config = {
|
|
234
333
|
$schema: "./node_modules/@biomejs/biome/configuration_schema.json",
|
|
@@ -237,45 +336,86 @@ if (existsSync(biomeJsonPath) || existsSync(biomeJsoncPath)) {
|
|
|
237
336
|
if (preset === "next") {
|
|
238
337
|
config.files = { includes: ["**", "!**/.next", "!**/out", "!**/next-env.d.ts"] };
|
|
239
338
|
}
|
|
240
|
-
await
|
|
241
|
-
|
|
339
|
+
await act(`write biome.json (extends ${extendsSpecifier})`, () =>
|
|
340
|
+
writeFile(biomeJsonPath, `${JSON.stringify(config, null, 2)}\n`),
|
|
341
|
+
);
|
|
242
342
|
}
|
|
243
343
|
|
|
344
|
+
// package.json: scripts, lint-staged, Commitizen. Only ever adds what's missing.
|
|
244
345
|
pkg.scripts ??= {};
|
|
245
|
-
|
|
346
|
+
const pkgEdits = [];
|
|
347
|
+
const addScript = (name, value) => {
|
|
348
|
+
if (!pkg.scripts[name]) {
|
|
349
|
+
pkgEdits.push(() => {
|
|
350
|
+
pkg.scripts[name] = value;
|
|
351
|
+
});
|
|
352
|
+
return `add script "${name}"`;
|
|
353
|
+
}
|
|
354
|
+
warn(`Script "${name}" already exists — left untouched`);
|
|
355
|
+
return null;
|
|
356
|
+
};
|
|
357
|
+
const pending = [
|
|
358
|
+
addScript("check", "biome check ."),
|
|
359
|
+
addScript("check:fix", "biome check --write ."),
|
|
360
|
+
];
|
|
246
361
|
|
|
247
|
-
if (!
|
|
248
|
-
pkg.scripts.
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
|
|
362
|
+
if (!values["no-hooks"]) {
|
|
363
|
+
if (!pkg.scripts.prepare) pending.push(addScript("prepare", "husky"));
|
|
364
|
+
else if (!pkg.scripts.prepare.includes("husky")) {
|
|
365
|
+
pkgEdits.push(() => {
|
|
366
|
+
pkg.scripts.prepare = `${pkg.scripts.prepare} && husky`;
|
|
367
|
+
});
|
|
368
|
+
pending.push('append husky to the existing "prepare" script');
|
|
369
|
+
} else {
|
|
370
|
+
warn('Script "prepare" already runs husky — left untouched');
|
|
371
|
+
}
|
|
254
372
|
|
|
255
|
-
if (
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
373
|
+
if (hasLintStagedConfig(cwd, pkg)) {
|
|
374
|
+
warn("lint-staged is already configured — left untouched");
|
|
375
|
+
} else {
|
|
376
|
+
pkgEdits.push(() => {
|
|
377
|
+
pkg["lint-staged"] = LINT_STAGED_CONFIG;
|
|
378
|
+
});
|
|
379
|
+
pending.push('add "lint-staged" config (Biome on staged files)');
|
|
380
|
+
}
|
|
261
381
|
}
|
|
262
382
|
|
|
263
|
-
if (!values["no-
|
|
264
|
-
if (
|
|
265
|
-
|
|
266
|
-
pkgChanged = true;
|
|
267
|
-
ok('Added script "prepare"');
|
|
268
|
-
} else if (!pkg.scripts.prepare.includes("husky")) {
|
|
269
|
-
pkg.scripts.prepare = `${pkg.scripts.prepare} && husky`;
|
|
270
|
-
pkgChanged = true;
|
|
271
|
-
ok('Appended husky to existing "prepare" script');
|
|
383
|
+
if (!values["no-commit-lint"]) {
|
|
384
|
+
if (pkg.config?.commitizen?.path) {
|
|
385
|
+
warn("Commitizen is already configured — left untouched");
|
|
272
386
|
} else {
|
|
273
|
-
|
|
387
|
+
pkgEdits.push(() => {
|
|
388
|
+
pkg.config = {
|
|
389
|
+
...pkg.config,
|
|
390
|
+
commitizen: { ...pkg.config?.commitizen, path: COMMITIZEN_PATH },
|
|
391
|
+
};
|
|
392
|
+
});
|
|
393
|
+
pending.push('add "config.commitizen" (guided commits)');
|
|
274
394
|
}
|
|
275
395
|
}
|
|
276
396
|
|
|
277
|
-
|
|
397
|
+
for (const message of pending.filter(Boolean)) {
|
|
398
|
+
if (dryRun) console.log(`+ would ${message} in package.json`);
|
|
399
|
+
}
|
|
400
|
+
if (pkgEdits.length > 0 && !dryRun) {
|
|
401
|
+
for (const edit of pkgEdits) edit();
|
|
278
402
|
await writeFile(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`);
|
|
403
|
+
for (const message of pending.filter(Boolean))
|
|
404
|
+
ok(`${message[0].toUpperCase()}${message.slice(1)}`);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// commitlint config
|
|
408
|
+
if (!values["no-commit-lint"]) {
|
|
409
|
+
if (hasCommitlintConfig(cwd, pkg)) {
|
|
410
|
+
warn("commitlint is already configured — left untouched");
|
|
411
|
+
} else {
|
|
412
|
+
await act("write commitlint.config.mjs", () =>
|
|
413
|
+
writeFile(
|
|
414
|
+
join(cwd, "commitlint.config.mjs"),
|
|
415
|
+
'export { default } from "lintlane/commitlint";\n',
|
|
416
|
+
),
|
|
417
|
+
);
|
|
418
|
+
}
|
|
279
419
|
}
|
|
280
420
|
|
|
281
421
|
if (!values["no-hooks"]) {
|
|
@@ -291,30 +431,33 @@ if (!values["no-hooks"]) {
|
|
|
291
431
|
for (const hook of hooks) {
|
|
292
432
|
const hookPath = join(huskyDir, hook);
|
|
293
433
|
const template = await readFile(join(KIT_DIR, "templates", hook), "utf8");
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
wroteHook = true;
|
|
303
|
-
} else if (hook === "pre-commit" && hasUnsafeRestaging(existing)) {
|
|
304
|
-
warn(
|
|
305
|
-
".husky/pre-commit runs `git update-index --again`, which can stage changes you didn't stage. " +
|
|
306
|
-
"Replace its contents with node_modules/lintlane/templates/pre-commit (or delete it and re-run init).",
|
|
307
|
-
);
|
|
308
|
-
} else {
|
|
309
|
-
warn(`.husky/${hook} already exists — left untouched`);
|
|
310
|
-
}
|
|
434
|
+
const writeHook = async () => {
|
|
435
|
+
await mkdir(huskyDir, { recursive: true });
|
|
436
|
+
await writeFile(hookPath, template);
|
|
437
|
+
await chmod(hookPath, 0o755);
|
|
438
|
+
wroteHook = true;
|
|
439
|
+
};
|
|
440
|
+
if (!existsSync(hookPath)) {
|
|
441
|
+
await act(`write .husky/${hook}`, writeHook);
|
|
311
442
|
continue;
|
|
312
443
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
444
|
+
const state = classifyHook(hook, await readFile(hookPath, "utf8"), template);
|
|
445
|
+
if (state === "current") {
|
|
446
|
+
ok(`.husky/${hook} is up to date`);
|
|
447
|
+
} else if (state === "legacy") {
|
|
448
|
+
// Untouched hook from an older lintlane; the files it called are gone.
|
|
449
|
+
await act(`upgrade .husky/${hook} (the old one is from an earlier lintlane)`, writeHook);
|
|
450
|
+
} else if (state === "stale" || state === "unsafe") {
|
|
451
|
+
warn(
|
|
452
|
+
`.husky/${hook} was edited and still calls files lintlane no longer ships` +
|
|
453
|
+
(state === "unsafe"
|
|
454
|
+
? " (and `git update-index --again` can stage changes you didn't stage)"
|
|
455
|
+
: "") +
|
|
456
|
+
`. Replace its contents with node_modules/lintlane/templates/${hook}.`,
|
|
457
|
+
);
|
|
458
|
+
} else {
|
|
459
|
+
warn(`.husky/${hook} already exists — left untouched`);
|
|
460
|
+
}
|
|
318
461
|
}
|
|
319
462
|
|
|
320
463
|
if (wroteHook) {
|
|
@@ -330,6 +473,8 @@ if (!values["no-hooks"]) {
|
|
|
330
473
|
warn(`husky binary not found at ${husky} — run it manually once installed`);
|
|
331
474
|
}
|
|
332
475
|
}
|
|
476
|
+
} else if (dryRun) {
|
|
477
|
+
console.log("+ would run husky to point git at .husky");
|
|
333
478
|
}
|
|
334
479
|
}
|
|
335
480
|
}
|
|
@@ -338,13 +483,25 @@ if (!values["no-ci"]) {
|
|
|
338
483
|
const workflowsDir = join(cwd, ".github", "workflows");
|
|
339
484
|
const ciPath = join(workflowsDir, "biome.yml");
|
|
340
485
|
if (existsSync(ciPath)) {
|
|
341
|
-
warn(
|
|
486
|
+
warn(
|
|
487
|
+
".github/workflows/biome.yml already exists — left untouched (run `lintlane doctor` to compare it with your checks)",
|
|
488
|
+
);
|
|
342
489
|
} else {
|
|
343
490
|
const withCommitCheck = !values["no-commit-lint"] && !values["no-hooks"];
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
491
|
+
const biomeArgv = resolveBiome(cwd) ?? ["biome"];
|
|
492
|
+
const { steps: checks } = resolveChecks({ cwd, biome: biomeArgv, pmName, pkg });
|
|
493
|
+
await act("write .github/workflows/biome.yml", async () => {
|
|
494
|
+
await mkdir(workflowsDir, { recursive: true });
|
|
495
|
+
await writeFile(
|
|
496
|
+
ciPath,
|
|
497
|
+
renderWorkflow({ pmName, kitName: KIT_NAME, withCommitCheck, checks }),
|
|
498
|
+
);
|
|
499
|
+
});
|
|
347
500
|
}
|
|
348
501
|
}
|
|
349
502
|
|
|
350
|
-
console.log(
|
|
503
|
+
console.log(
|
|
504
|
+
dryRun
|
|
505
|
+
? "\nDry run finished. Run again without --dry-run to apply."
|
|
506
|
+
: `\nRun: ${pmName} run check:fix (and \`${RUNNER} ${KIT_NAME} doctor\` to verify the setup)`,
|
|
507
|
+
);
|
package/bin/commit-cli.mjs
CHANGED
|
@@ -1,148 +1,62 @@
|
|
|
1
|
-
// `lintlane commit` — guided Conventional Commit
|
|
1
|
+
// `lintlane commit` — guided Conventional Commit, run by Commitizen.
|
|
2
2
|
//
|
|
3
|
-
// lintlane commit [options] [-- <git commit args>]
|
|
3
|
+
// lintlane commit [commitizen options] [-- <git commit args>]
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
// (e.g. `-- -a -S` to stage all and sign, `-- -n` to skip hooks)
|
|
11
|
-
import { spawnSync } from "node:child_process";
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
-
import { dirname, resolve } from "node:path";
|
|
14
|
-
import { parseArgs } from "node:util";
|
|
15
|
-
import { loadCommitConfig } from "./commit-config.mjs";
|
|
16
|
-
import { promptCommit } from "./commit-prompt.mjs";
|
|
5
|
+
// Commitizen's own flags work as-is: --dry-run, --retry, -a, -S, ... and
|
|
6
|
+
// everything after -- goes to `git commit` (e.g. `-- -n` to skip hooks).
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { resolveTool, runInherit } from "./proc.mjs";
|
|
17
10
|
|
|
18
11
|
export const COMMIT_HELP = `Usage: lintlane commit [options] [-- <git commit args>]
|
|
19
12
|
|
|
20
|
-
Asks a few questions and creates a Conventional Commit. Alias: lintlane c
|
|
13
|
+
Asks a few questions and creates a Conventional Commit (Commitizen). Alias: lintlane c
|
|
21
14
|
|
|
22
|
-
Options:
|
|
23
|
-
--dry-run
|
|
24
|
-
--
|
|
25
|
-
--
|
|
26
|
-
|
|
27
|
-
-l, --message-length-limit <n> Maximum header length (0 = no limit)
|
|
15
|
+
Options (passed to Commitizen):
|
|
16
|
+
--dry-run Show what would happen without committing
|
|
17
|
+
--retry Reuse the last commit message after a failed commit
|
|
18
|
+
-a, --all Stage all tracked changes first
|
|
19
|
+
-S, --signoff Sign off the commit
|
|
28
20
|
|
|
29
21
|
Anything after -- is passed to git commit:
|
|
30
|
-
lintlane commit -- -
|
|
31
|
-
lintlane commit -- -n
|
|
32
|
-
|
|
22
|
+
lintlane commit -- -S sign the commit
|
|
23
|
+
lintlane commit -- -n skip git hooks
|
|
24
|
+
|
|
25
|
+
Needs "config.commitizen.path" in package.json; \`lintlane init\` adds it.
|
|
33
26
|
`;
|
|
34
27
|
|
|
35
28
|
const err = (text) => process.stderr.write(`${text}\n`);
|
|
36
29
|
|
|
37
|
-
function
|
|
38
|
-
|
|
30
|
+
function hasAdapter(cwd) {
|
|
31
|
+
try {
|
|
32
|
+
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
|
|
33
|
+
return Boolean(pkg?.config?.commitizen?.path) || existsSync(join(cwd, ".czrc"));
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
39
37
|
}
|
|
40
38
|
|
|
41
39
|
export async function runCommit({ argv, gitArgs = [], cwd = process.cwd() }) {
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
({ values } = parseArgs({
|
|
45
|
-
args: argv,
|
|
46
|
-
options: {
|
|
47
|
-
"dry-run": { type: "boolean" },
|
|
48
|
-
"write-message-to-file": { type: "string" },
|
|
49
|
-
retry: { type: "boolean" },
|
|
50
|
-
"no-retry": { type: "boolean" },
|
|
51
|
-
"message-length-limit": { type: "string", short: "l" },
|
|
52
|
-
help: { type: "boolean", short: "h" },
|
|
53
|
-
},
|
|
54
|
-
}));
|
|
55
|
-
} catch (error) {
|
|
56
|
-
err(`✘ ${error.message}\n\n${COMMIT_HELP}`);
|
|
57
|
-
return 2;
|
|
58
|
-
}
|
|
59
|
-
if (values.help) {
|
|
40
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
60
41
|
process.stdout.write(COMMIT_HELP);
|
|
61
42
|
return 0;
|
|
62
43
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const inRepo = git(["rev-parse", "--git-dir"], cwd);
|
|
69
|
-
if (inRepo.status !== 0) {
|
|
70
|
-
err("✘ Not inside a git repository.");
|
|
44
|
+
const cz = resolveTool("commitizen", "git-cz");
|
|
45
|
+
if (!cz) {
|
|
46
|
+
err("✘ Commitizen isn't installed. Reinstall lintlane with your package manager.");
|
|
71
47
|
return 1;
|
|
72
48
|
}
|
|
73
|
-
|
|
74
|
-
const config = loadCommitConfig(cwd);
|
|
75
|
-
const options = {
|
|
76
|
-
...config,
|
|
77
|
-
messageLengthLimit: values["message-length-limit"] ?? config.messageLengthLimit,
|
|
78
|
-
};
|
|
79
|
-
const dryRun = Boolean(values["dry-run"]);
|
|
80
|
-
|
|
81
|
-
// `-- -a` makes git stage tracked changes itself, so "nothing staged" is fine then.
|
|
82
|
-
const stagesItself = gitArgs.some((a) => a === "-a" || a === "--all");
|
|
83
|
-
if (!dryRun && !stagesItself && git(["diff", "--cached", "--quiet"], cwd).status === 0) {
|
|
49
|
+
if (!hasAdapter(cwd)) {
|
|
84
50
|
err(
|
|
85
|
-
|
|
51
|
+
'✘ Commitizen has no adapter configured. Run `lintlane init`, or add to package.json:\n "config": { "commitizen": { "path": "lintlane/presets/commitizen.cjs" } }',
|
|
86
52
|
);
|
|
87
53
|
return 1;
|
|
88
54
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
let message;
|
|
97
|
-
const autoRetry = Boolean(config.retryAfterFailure) && !values["no-retry"] && saved !== null;
|
|
98
|
-
if (values.retry || autoRetry) {
|
|
99
|
-
if (saved === null) {
|
|
100
|
-
err("✘ There's no previous commit message to retry.");
|
|
101
|
-
return 1;
|
|
102
|
-
}
|
|
103
|
-
message = saved;
|
|
104
|
-
err("↻ Reusing your last commit message:\n");
|
|
105
|
-
err(message.replace(/^/gm, " "));
|
|
106
|
-
err("");
|
|
107
|
-
} else {
|
|
108
|
-
try {
|
|
109
|
-
message = await promptCommit({ options });
|
|
110
|
-
} catch {
|
|
111
|
-
err("\n✘ Commit cancelled.");
|
|
112
|
-
return 1;
|
|
113
|
-
}
|
|
114
|
-
if (message === null) {
|
|
115
|
-
err("\n✘ Commit cancelled.");
|
|
116
|
-
return 1;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (values["write-message-to-file"]) {
|
|
121
|
-
const target = resolve(cwd, values["write-message-to-file"]);
|
|
122
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
123
|
-
writeFileSync(target, `${message}\n`);
|
|
124
|
-
}
|
|
125
|
-
if (dryRun) {
|
|
126
|
-
process.stdout.write(`${message}\n`);
|
|
127
|
-
return 0;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Keep the message until the commit actually succeeds, so a failing
|
|
131
|
-
// pre-commit hook doesn't throw away your answers.
|
|
132
|
-
mkdirSync(dirname(savedPath), { recursive: true });
|
|
133
|
-
writeFileSync(savedPath, message);
|
|
134
|
-
|
|
135
|
-
const result = spawnSync("git", ["commit", "-F", "-", ...gitArgs], {
|
|
136
|
-
cwd,
|
|
137
|
-
input: message,
|
|
138
|
-
stdio: ["pipe", "inherit", "inherit"],
|
|
139
|
-
});
|
|
140
|
-
if (result.status === 0) {
|
|
141
|
-
rmSync(savedPath, { force: true });
|
|
142
|
-
return 0;
|
|
143
|
-
}
|
|
144
|
-
err(
|
|
145
|
-
"\n✘ git commit failed. Your message was saved — fix the problem, then run: lintlane commit --retry",
|
|
55
|
+
return runInherit(
|
|
56
|
+
cz[0],
|
|
57
|
+
[...cz.slice(1), ...argv, ...(gitArgs.length ? ["--", ...gitArgs] : [])],
|
|
58
|
+
{
|
|
59
|
+
cwd,
|
|
60
|
+
},
|
|
146
61
|
);
|
|
147
|
-
return result.status ?? 1;
|
|
148
62
|
}
|