contribute-now 0.1.2 → 0.2.0-dev.d4b7ede
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 +162 -133
- package/dist/index.js +1344 -373
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { defineCommand as
|
|
4
|
+
import { defineCommand as defineCommand11, runMain } from "citty";
|
|
5
5
|
|
|
6
6
|
// src/commands/clean.ts
|
|
7
7
|
import { defineCommand } from "citty";
|
|
@@ -20,7 +20,11 @@ function readConfig(cwd = process.cwd()) {
|
|
|
20
20
|
return null;
|
|
21
21
|
try {
|
|
22
22
|
const raw = readFileSync(path, "utf-8");
|
|
23
|
-
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
if (typeof parsed !== "object" || parsed === null || typeof parsed.workflow !== "string" || typeof parsed.role !== "string" || typeof parsed.mainBranch !== "string" || typeof parsed.upstream !== "string" || typeof parsed.origin !== "string" || !Array.isArray(parsed.branchPrefixes) || typeof parsed.commitConvention !== "string") {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return parsed;
|
|
24
28
|
} catch {
|
|
25
29
|
return null;
|
|
26
30
|
}
|
|
@@ -44,78 +48,67 @@ function isGitignored(cwd = process.cwd()) {
|
|
|
44
48
|
}
|
|
45
49
|
function getDefaultConfig() {
|
|
46
50
|
return {
|
|
51
|
+
workflow: "clean-flow",
|
|
47
52
|
role: "contributor",
|
|
48
53
|
mainBranch: "main",
|
|
49
54
|
devBranch: "dev",
|
|
50
55
|
upstream: "upstream",
|
|
51
56
|
origin: "origin",
|
|
52
|
-
branchPrefixes: ["feature", "fix", "docs", "chore", "test", "refactor"]
|
|
57
|
+
branchPrefixes: ["feature", "fix", "docs", "chore", "test", "refactor"],
|
|
58
|
+
commitConvention: "clean-commit"
|
|
53
59
|
};
|
|
54
60
|
}
|
|
55
61
|
|
|
56
62
|
// src/utils/confirm.ts
|
|
63
|
+
import * as clack from "@clack/prompts";
|
|
57
64
|
import pc from "picocolors";
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const response = await new Promise((resolve) => {
|
|
63
|
-
process.stdin.setEncoding("utf-8");
|
|
64
|
-
process.stdin.once("data", (data) => {
|
|
65
|
-
process.stdin.pause();
|
|
66
|
-
resolve(data.toString().trim());
|
|
67
|
-
});
|
|
68
|
-
process.stdin.resume();
|
|
69
|
-
});
|
|
70
|
-
if (response.toLowerCase() !== "y") {
|
|
71
|
-
console.log(pc.yellow("Aborted."));
|
|
72
|
-
return false;
|
|
65
|
+
function handleCancel(value) {
|
|
66
|
+
if (clack.isCancel(value)) {
|
|
67
|
+
clack.cancel("Cancelled.");
|
|
68
|
+
process.exit(0);
|
|
73
69
|
}
|
|
74
|
-
|
|
70
|
+
}
|
|
71
|
+
async function confirmPrompt(message) {
|
|
72
|
+
const result = await clack.confirm({ message });
|
|
73
|
+
handleCancel(result);
|
|
74
|
+
return result;
|
|
75
75
|
}
|
|
76
76
|
async function selectPrompt(message, choices) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
console.log(` ${pc.dim(`${i + 1}.`)} ${choice}`);
|
|
81
|
-
});
|
|
82
|
-
process.stdout.write(pc.dim(`Enter number [1-${choices.length}]: `));
|
|
83
|
-
const response = await new Promise((resolve) => {
|
|
84
|
-
process.stdin.setEncoding("utf-8");
|
|
85
|
-
process.stdin.once("data", (data) => {
|
|
86
|
-
process.stdin.pause();
|
|
87
|
-
resolve(data.toString().trim());
|
|
88
|
-
});
|
|
89
|
-
process.stdin.resume();
|
|
77
|
+
const result = await clack.select({
|
|
78
|
+
message,
|
|
79
|
+
options: choices.map((choice) => ({ value: choice, label: choice }))
|
|
90
80
|
});
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
return choices[index];
|
|
94
|
-
}
|
|
95
|
-
return choices[0];
|
|
81
|
+
handleCancel(result);
|
|
82
|
+
return result;
|
|
96
83
|
}
|
|
97
84
|
async function inputPrompt(message, defaultValue) {
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
85
|
+
const result = await clack.text({
|
|
86
|
+
message,
|
|
87
|
+
placeholder: defaultValue,
|
|
88
|
+
defaultValue
|
|
89
|
+
});
|
|
90
|
+
handleCancel(result);
|
|
91
|
+
return result || defaultValue || "";
|
|
92
|
+
}
|
|
93
|
+
async function multiSelectPrompt(message, choices) {
|
|
94
|
+
const result = await clack.multiselect({
|
|
95
|
+
message: `${message} ${pc.dim("(space to toggle, enter to confirm)")}`,
|
|
96
|
+
options: choices.map((choice) => ({ value: choice, label: choice })),
|
|
97
|
+
required: false
|
|
108
98
|
});
|
|
109
|
-
|
|
99
|
+
handleCancel(result);
|
|
100
|
+
return result;
|
|
110
101
|
}
|
|
111
102
|
|
|
112
103
|
// src/utils/git.ts
|
|
113
104
|
import { execFile as execFileCb } from "node:child_process";
|
|
105
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
106
|
+
import { join as join2 } from "node:path";
|
|
114
107
|
function run(args) {
|
|
115
108
|
return new Promise((resolve) => {
|
|
116
109
|
execFileCb("git", args, (error, stdout, stderr) => {
|
|
117
110
|
resolve({
|
|
118
|
-
exitCode: error ? error.code === "ENOENT" ? 127 : error.
|
|
111
|
+
exitCode: error ? error.code === "ENOENT" ? 127 : error.status ?? 1 : 0,
|
|
119
112
|
stdout: stdout ?? "",
|
|
120
113
|
stderr: stderr ?? ""
|
|
121
114
|
});
|
|
@@ -167,8 +160,12 @@ async function createBranch(branch, from) {
|
|
|
167
160
|
async function resetHard(ref) {
|
|
168
161
|
return run(["reset", "--hard", ref]);
|
|
169
162
|
}
|
|
170
|
-
async function
|
|
171
|
-
|
|
163
|
+
async function updateLocalBranch(branch, target) {
|
|
164
|
+
const current = await getCurrentBranch();
|
|
165
|
+
if (current === branch) {
|
|
166
|
+
return resetHard(target);
|
|
167
|
+
}
|
|
168
|
+
return run(["branch", "-f", branch, target]);
|
|
172
169
|
}
|
|
173
170
|
async function pushSetUpstream(remote, branch) {
|
|
174
171
|
return run(["push", "-u", remote, branch]);
|
|
@@ -191,8 +188,16 @@ async function getChangedFiles() {
|
|
|
191
188
|
const { exitCode, stdout } = await run(["status", "--porcelain"]);
|
|
192
189
|
if (exitCode !== 0)
|
|
193
190
|
return [];
|
|
194
|
-
return stdout.
|
|
195
|
-
`).filter(Boolean).map((l) =>
|
|
191
|
+
return stdout.trimEnd().split(`
|
|
192
|
+
`).filter(Boolean).map((l) => {
|
|
193
|
+
const line = l.replace(/\r$/, "");
|
|
194
|
+
const match = line.match(/^..\s+(.*)/);
|
|
195
|
+
if (!match)
|
|
196
|
+
return "";
|
|
197
|
+
const file = match[1];
|
|
198
|
+
const renameIdx = file.indexOf(" -> ");
|
|
199
|
+
return renameIdx !== -1 ? file.slice(renameIdx + 4) : file;
|
|
200
|
+
}).filter(Boolean);
|
|
196
201
|
}
|
|
197
202
|
async function getDivergence(branch, base) {
|
|
198
203
|
const { exitCode, stdout } = await run([
|
|
@@ -219,6 +224,18 @@ async function getMergedBranches(base) {
|
|
|
219
224
|
async function deleteBranch(branch) {
|
|
220
225
|
return run(["branch", "-d", branch]);
|
|
221
226
|
}
|
|
227
|
+
async function forceDeleteBranch(branch) {
|
|
228
|
+
return run(["branch", "-D", branch]);
|
|
229
|
+
}
|
|
230
|
+
async function deleteRemoteBranch(remote, branch) {
|
|
231
|
+
return run(["push", remote, "--delete", branch]);
|
|
232
|
+
}
|
|
233
|
+
async function mergeSquash(branch) {
|
|
234
|
+
return run(["merge", "--squash", branch]);
|
|
235
|
+
}
|
|
236
|
+
async function pushBranch(remote, branch) {
|
|
237
|
+
return run(["push", remote, branch]);
|
|
238
|
+
}
|
|
222
239
|
async function pruneRemote(remote) {
|
|
223
240
|
return run(["remote", "prune", remote]);
|
|
224
241
|
}
|
|
@@ -236,6 +253,88 @@ async function getLog(base, head) {
|
|
|
236
253
|
return stdout.trim().split(`
|
|
237
254
|
`).filter(Boolean);
|
|
238
255
|
}
|
|
256
|
+
async function pullBranch(remote, branch) {
|
|
257
|
+
return run(["pull", remote, branch]);
|
|
258
|
+
}
|
|
259
|
+
async function stageFiles(files) {
|
|
260
|
+
return run(["add", "--", ...files]);
|
|
261
|
+
}
|
|
262
|
+
async function unstageFiles(files) {
|
|
263
|
+
return run(["reset", "HEAD", "--", ...files]);
|
|
264
|
+
}
|
|
265
|
+
async function stageAll() {
|
|
266
|
+
return run(["add", "-A"]);
|
|
267
|
+
}
|
|
268
|
+
async function getFullDiffForFiles(files) {
|
|
269
|
+
const [unstaged, staged, untracked] = await Promise.all([
|
|
270
|
+
run(["diff", "--", ...files]),
|
|
271
|
+
run(["diff", "--cached", "--", ...files]),
|
|
272
|
+
getUntrackedFiles()
|
|
273
|
+
]);
|
|
274
|
+
const parts = [staged.stdout, unstaged.stdout].filter(Boolean);
|
|
275
|
+
const untrackedSet = new Set(untracked);
|
|
276
|
+
const MAX_FILE_CONTENT = 2000;
|
|
277
|
+
for (const file of files) {
|
|
278
|
+
if (untrackedSet.has(file)) {
|
|
279
|
+
try {
|
|
280
|
+
const content = readFileSync2(join2(process.cwd(), file), "utf-8");
|
|
281
|
+
const truncated = content.length > MAX_FILE_CONTENT ? `${content.slice(0, MAX_FILE_CONTENT)}
|
|
282
|
+
... (truncated)` : content;
|
|
283
|
+
const lines = truncated.split(`
|
|
284
|
+
`).map((l) => `+${l}`);
|
|
285
|
+
parts.push(`diff --git a/${file} b/${file}
|
|
286
|
+
new file
|
|
287
|
+
--- /dev/null
|
|
288
|
+
+++ b/${file}
|
|
289
|
+
${lines.join(`
|
|
290
|
+
`)}`);
|
|
291
|
+
} catch {}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return parts.join(`
|
|
295
|
+
`);
|
|
296
|
+
}
|
|
297
|
+
async function getUntrackedFiles() {
|
|
298
|
+
const { exitCode, stdout } = await run(["ls-files", "--others", "--exclude-standard"]);
|
|
299
|
+
if (exitCode !== 0)
|
|
300
|
+
return [];
|
|
301
|
+
return stdout.trim().split(`
|
|
302
|
+
`).filter(Boolean);
|
|
303
|
+
}
|
|
304
|
+
async function getFileStatus() {
|
|
305
|
+
const { exitCode, stdout } = await run(["status", "--porcelain"]);
|
|
306
|
+
if (exitCode !== 0)
|
|
307
|
+
return { staged: [], modified: [], untracked: [] };
|
|
308
|
+
const result = { staged: [], modified: [], untracked: [] };
|
|
309
|
+
const STATUS_LABELS = {
|
|
310
|
+
A: "new file",
|
|
311
|
+
M: "modified",
|
|
312
|
+
D: "deleted",
|
|
313
|
+
R: "renamed",
|
|
314
|
+
C: "copied",
|
|
315
|
+
T: "type changed"
|
|
316
|
+
};
|
|
317
|
+
for (const raw of stdout.trimEnd().split(`
|
|
318
|
+
`).filter(Boolean)) {
|
|
319
|
+
const line = raw.replace(/\r$/, "");
|
|
320
|
+
const indexStatus = line[0];
|
|
321
|
+
const workTreeStatus = line[1];
|
|
322
|
+
const pathPart = line.slice(3);
|
|
323
|
+
const renameIdx = pathPart.indexOf(" -> ");
|
|
324
|
+
const file = renameIdx !== -1 ? pathPart.slice(renameIdx + 4) : pathPart;
|
|
325
|
+
if (indexStatus === "?" && workTreeStatus === "?") {
|
|
326
|
+
result.untracked.push(file);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
if (indexStatus && indexStatus !== " " && indexStatus !== "?") {
|
|
330
|
+
result.staged.push({ file, status: STATUS_LABELS[indexStatus] ?? indexStatus });
|
|
331
|
+
}
|
|
332
|
+
if (workTreeStatus && workTreeStatus !== " " && workTreeStatus !== "?") {
|
|
333
|
+
result.modified.push({ file, status: STATUS_LABELS[workTreeStatus] ?? workTreeStatus });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
return result;
|
|
337
|
+
}
|
|
239
338
|
|
|
240
339
|
// src/utils/logger.ts
|
|
241
340
|
import { LogEngine, LogMode } from "@wgtechlabs/log-engine";
|
|
@@ -265,6 +364,53 @@ function heading(msg) {
|
|
|
265
364
|
${pc2.bold(msg)}`);
|
|
266
365
|
}
|
|
267
366
|
|
|
367
|
+
// src/utils/workflow.ts
|
|
368
|
+
var WORKFLOW_DESCRIPTIONS = {
|
|
369
|
+
"clean-flow": "Clean Flow — main + dev, squash features into dev, merge dev into main",
|
|
370
|
+
"github-flow": "GitHub Flow — main + feature branches, squash/merge into main",
|
|
371
|
+
"git-flow": "Git Flow — main + develop + release + hotfix branches"
|
|
372
|
+
};
|
|
373
|
+
function getBaseBranch(config) {
|
|
374
|
+
switch (config.workflow) {
|
|
375
|
+
case "clean-flow":
|
|
376
|
+
case "git-flow":
|
|
377
|
+
return config.devBranch ?? "dev";
|
|
378
|
+
case "github-flow":
|
|
379
|
+
return config.mainBranch;
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function hasDevBranch(workflow) {
|
|
383
|
+
return workflow === "clean-flow" || workflow === "git-flow";
|
|
384
|
+
}
|
|
385
|
+
function getSyncSource(config) {
|
|
386
|
+
const { workflow, role, mainBranch, origin, upstream } = config;
|
|
387
|
+
const devBranch = config.devBranch ?? "dev";
|
|
388
|
+
switch (workflow) {
|
|
389
|
+
case "clean-flow":
|
|
390
|
+
if (role === "contributor") {
|
|
391
|
+
return { remote: upstream, ref: `${upstream}/${devBranch}`, strategy: "pull" };
|
|
392
|
+
}
|
|
393
|
+
return { remote: origin, ref: `${origin}/${devBranch}`, strategy: "pull" };
|
|
394
|
+
case "github-flow":
|
|
395
|
+
if (role === "contributor") {
|
|
396
|
+
return { remote: upstream, ref: `${upstream}/${mainBranch}`, strategy: "pull" };
|
|
397
|
+
}
|
|
398
|
+
return { remote: origin, ref: `${origin}/${mainBranch}`, strategy: "pull" };
|
|
399
|
+
case "git-flow":
|
|
400
|
+
if (role === "contributor") {
|
|
401
|
+
return { remote: upstream, ref: `${upstream}/${devBranch}`, strategy: "pull" };
|
|
402
|
+
}
|
|
403
|
+
return { remote: origin, ref: `${origin}/${devBranch}`, strategy: "pull" };
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
function getProtectedBranches(config) {
|
|
407
|
+
const branches = [config.mainBranch];
|
|
408
|
+
if (hasDevBranch(config.workflow) && config.devBranch) {
|
|
409
|
+
branches.push(config.devBranch);
|
|
410
|
+
}
|
|
411
|
+
return branches;
|
|
412
|
+
}
|
|
413
|
+
|
|
268
414
|
// src/commands/clean.ts
|
|
269
415
|
var clean_default = defineCommand({
|
|
270
416
|
meta: {
|
|
@@ -289,12 +435,13 @@ var clean_default = defineCommand({
|
|
|
289
435
|
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
290
436
|
process.exit(1);
|
|
291
437
|
}
|
|
292
|
-
const {
|
|
438
|
+
const { origin } = config;
|
|
439
|
+
const baseBranch = getBaseBranch(config);
|
|
293
440
|
const currentBranch = await getCurrentBranch();
|
|
294
441
|
heading("\uD83E\uDDF9 contrib clean");
|
|
295
|
-
const mergedBranches = await getMergedBranches(
|
|
296
|
-
const
|
|
297
|
-
const candidates = mergedBranches.filter((b) => !
|
|
442
|
+
const mergedBranches = await getMergedBranches(baseBranch);
|
|
443
|
+
const protectedBranches = new Set([...getProtectedBranches(config), currentBranch ?? ""]);
|
|
444
|
+
const candidates = mergedBranches.filter((b) => !protectedBranches.has(b));
|
|
298
445
|
if (candidates.length === 0) {
|
|
299
446
|
info("No merged branches to clean up.");
|
|
300
447
|
} else {
|
|
@@ -330,73 +477,145 @@ ${pc3.bold("Branches to delete:")}`);
|
|
|
330
477
|
|
|
331
478
|
// src/commands/commit.ts
|
|
332
479
|
import { defineCommand as defineCommand2 } from "citty";
|
|
333
|
-
import
|
|
480
|
+
import pc5 from "picocolors";
|
|
481
|
+
|
|
482
|
+
// src/utils/convention.ts
|
|
483
|
+
var CLEAN_COMMIT_PATTERN = /^(📦|🔧|🗑\uFE0F?|🔒|⚙\uFE0F?|☕|🧪|📖|🚀) (new|update|remove|security|setup|chore|test|docs|release)(!?)( \([a-zA-Z0-9][a-zA-Z0-9-]*\))?: .{1,72}$/u;
|
|
484
|
+
var CONVENTIONAL_COMMIT_PATTERN = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(!?)(\([a-zA-Z0-9][a-zA-Z0-9._-]*\))?: .{1,72}$/;
|
|
485
|
+
var CONVENTION_LABELS = {
|
|
486
|
+
conventional: "Conventional Commits",
|
|
487
|
+
"clean-commit": "Clean Commit (by WGTech Labs)",
|
|
488
|
+
none: "No convention"
|
|
489
|
+
};
|
|
490
|
+
var CONVENTION_DESCRIPTIONS = {
|
|
491
|
+
conventional: "Conventional Commits — feat: | fix: | docs: | chore: etc. (conventionalcommits.org)",
|
|
492
|
+
"clean-commit": "Clean Commit — \uD83D\uDCE6 new: | \uD83D\uDD27 update: | \uD83D\uDDD1️ remove: etc. (by WGTech Labs)",
|
|
493
|
+
none: "No commit convention enforcement"
|
|
494
|
+
};
|
|
495
|
+
var CONVENTION_FORMAT_HINTS = {
|
|
496
|
+
conventional: [
|
|
497
|
+
"Format: <type>[!][(<scope>)]: <description>",
|
|
498
|
+
"Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert",
|
|
499
|
+
"Examples: feat: add login page | fix(auth): resolve token expiry | docs: update README"
|
|
500
|
+
],
|
|
501
|
+
"clean-commit": [
|
|
502
|
+
"Format: <emoji> <type>[!][(<scope>)]: <description>",
|
|
503
|
+
"Types: \uD83D\uDCE6 new | \uD83D\uDD27 update | \uD83D\uDDD1️ remove | \uD83D\uDD12 security | ⚙️ setup | ☕ chore | \uD83E\uDDEA test | \uD83D\uDCD6 docs | \uD83D\uDE80 release",
|
|
504
|
+
"Examples: \uD83D\uDCE6 new: user auth | \uD83D\uDD27 update (api): improve errors | ⚙️ setup (ci): add workflow"
|
|
505
|
+
]
|
|
506
|
+
};
|
|
507
|
+
function validateCommitMessage(message, convention) {
|
|
508
|
+
if (convention === "none")
|
|
509
|
+
return true;
|
|
510
|
+
if (convention === "clean-commit")
|
|
511
|
+
return CLEAN_COMMIT_PATTERN.test(message);
|
|
512
|
+
if (convention === "conventional")
|
|
513
|
+
return CONVENTIONAL_COMMIT_PATTERN.test(message);
|
|
514
|
+
return true;
|
|
515
|
+
}
|
|
516
|
+
function getValidationError(convention) {
|
|
517
|
+
if (convention === "none")
|
|
518
|
+
return [];
|
|
519
|
+
return [
|
|
520
|
+
`Commit message does not follow ${CONVENTION_LABELS[convention]} format.`,
|
|
521
|
+
...CONVENTION_FORMAT_HINTS[convention]
|
|
522
|
+
];
|
|
523
|
+
}
|
|
334
524
|
|
|
335
525
|
// src/utils/copilot.ts
|
|
336
526
|
import { CopilotClient } from "@github/copilot-sdk";
|
|
337
|
-
var
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
\uD83D\uDDD1️ remove
|
|
344
|
-
|
|
345
|
-
⚙️ setup
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
527
|
+
var CONVENTIONAL_COMMIT_SYSTEM_PROMPT = `Git commit message generator. Format: <type>[!][(<scope>)]: <description>
|
|
528
|
+
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
|
529
|
+
Rules: breaking (!) only for feat/fix/refactor/perf; imperative mood; max 72 chars; lowercase start; scope optional camelCase/kebab-case. Return ONLY the message line.
|
|
530
|
+
Examples: feat: add user auth | fix(auth): resolve token expiry | feat!: redesign auth API`;
|
|
531
|
+
var CLEAN_COMMIT_SYSTEM_PROMPT = `Git commit message generator. EXACT format: <emoji> <type>[!][ (<scope>)]: <description>
|
|
532
|
+
Spacing: EMOJI SPACE TYPE [SPACE OPENPAREN SCOPE CLOSEPAREN] COLON SPACE DESCRIPTION
|
|
533
|
+
Types: \uD83D\uDCE6 new, \uD83D\uDD27 update, \uD83D\uDDD1️ remove, \uD83D\uDD12 security, ⚙️ setup, ☕ chore, \uD83E\uDDEA test, \uD83D\uDCD6 docs, \uD83D\uDE80 release
|
|
534
|
+
Rules: breaking (!) only for new/update/remove/security; imperative mood; max 72 chars; lowercase start; scope optional. Return ONLY the message line.
|
|
535
|
+
Correct: \uD83D\uDCE6 new: add user auth | \uD83D\uDD27 update (api): improve error handling | ⚙️ setup (ci): configure github actions
|
|
536
|
+
WRONG: ⚙️setup(ci): ... | \uD83D\uDD27 update(api): ... ← always space before scope parenthesis`;
|
|
537
|
+
function getGroupingSystemPrompt(convention) {
|
|
538
|
+
const conventionBlock = convention === "conventional" ? `Use Conventional Commit format: <type>[(<scope>)]: <description>
|
|
539
|
+
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert` : `Use Clean Commit format: <emoji> <type>[!][ (<scope>)]: <description>
|
|
540
|
+
Emoji/type table:
|
|
541
|
+
\uD83D\uDCE6 new, \uD83D\uDD27 update, \uD83D\uDDD1️ remove, \uD83D\uDD12 security, ⚙️ setup, ☕ chore, \uD83E\uDDEA test, \uD83D\uDCD6 docs, \uD83D\uDE80 release`;
|
|
542
|
+
return `You are a smart commit grouping assistant. Given a list of changed files and their diffs, group related changes into logical atomic commits.
|
|
350
543
|
|
|
351
|
-
|
|
352
|
-
- Breaking change (!) only for: new, update, remove, security
|
|
353
|
-
- Description: concise, imperative mood, max 72 chars
|
|
354
|
-
- Scope: optional, camelCase or kebab-case component name
|
|
355
|
-
- Return ONLY the commit message line, nothing else
|
|
356
|
-
|
|
357
|
-
Examples:
|
|
358
|
-
\uD83D\uDCE6 new: user authentication system
|
|
359
|
-
\uD83D\uDD27 update (api): improve error handling
|
|
360
|
-
⚙️ setup (ci): configure github actions workflow
|
|
361
|
-
\uD83D\uDCE6 new!: completely redesign authentication system`;
|
|
362
|
-
var BRANCH_NAME_SYSTEM_PROMPT = `You are a git branch name generator. Convert natural language descriptions into proper git branch names.
|
|
544
|
+
${conventionBlock}
|
|
363
545
|
|
|
364
|
-
|
|
365
|
-
|
|
546
|
+
Return a JSON array of commit groups with this EXACT structure (no markdown fences, no explanation):
|
|
547
|
+
[
|
|
548
|
+
{
|
|
549
|
+
"files": ["path/to/file1.ts", "path/to/file2.ts"],
|
|
550
|
+
"message": "<commit message following the convention above>"
|
|
551
|
+
}
|
|
552
|
+
]
|
|
366
553
|
|
|
367
554
|
Rules:
|
|
368
|
-
-
|
|
369
|
-
-
|
|
370
|
-
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
Input: "add user profile page" → feature/user-profile-page
|
|
375
|
-
Input: "update readme documentation" → docs/update-readme`;
|
|
376
|
-
var PR_DESCRIPTION_SYSTEM_PROMPT = `You are a GitHub pull request description generator. Create a clear, structured PR description.
|
|
377
|
-
|
|
378
|
-
Return a JSON object with this exact structure:
|
|
379
|
-
{
|
|
380
|
-
"title": "Brief PR title (50 chars max)",
|
|
381
|
-
"body": "## Summary\\n...\\n\\n## Changes\\n...\\n\\n## Test Plan\\n..."
|
|
555
|
+
- Group files that are logically related (e.g. a utility and its tests, a feature and its types)
|
|
556
|
+
- Each group should represent ONE logical change
|
|
557
|
+
- Every file must appear in exactly one group
|
|
558
|
+
- Commit messages must follow the convention, be concise, imperative, max 72 chars
|
|
559
|
+
- Order groups so foundational changes come first (types, utils) and consumers come after
|
|
560
|
+
- Return ONLY the JSON array, nothing else`;
|
|
382
561
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
562
|
+
var BRANCH_NAME_SYSTEM_PROMPT = `Git branch name generator. Format: <prefix>/<kebab-case-name>
|
|
563
|
+
Prefixes: feature, fix, docs, chore, test, refactor
|
|
564
|
+
Rules: lowercase kebab-case, 2-5 words max. Return ONLY the branch name.
|
|
565
|
+
Examples: fix/login-timeout | feature/user-profile-page | docs/update-readme`;
|
|
566
|
+
var PR_DESCRIPTION_SYSTEM_PROMPT_BASE = `GitHub PR description generator. Return JSON: {"title":"<72 chars>","body":"## Summary\\n...\\n\\n## Changes\\n- ...\\n\\n## Test Plan\\n..."}`;
|
|
567
|
+
function getPRDescriptionSystemPrompt(convention) {
|
|
568
|
+
if (convention === "clean-commit") {
|
|
569
|
+
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
570
|
+
CRITICAL: The PR title MUST follow the Clean Commit format exactly: <emoji> <type>: <description>
|
|
571
|
+
Emoji/type table: \uD83D\uDCE6 new, \uD83D\uDD27 update, \uD83D\uDDD1️ remove, \uD83D\uDD12 security, ⚙️ setup, ☕ chore, \uD83E\uDDEA test, \uD83D\uDCD6 docs, \uD83D\uDE80 release
|
|
572
|
+
Title examples: \uD83D\uDCE6 new: add user authentication | \uD83D\uDD27 update: improve error handling | \uD83D\uDDD1️ remove: drop legacy API
|
|
573
|
+
Rules: title follows convention, present tense, max 72 chars; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
|
|
574
|
+
}
|
|
575
|
+
if (convention === "conventional") {
|
|
576
|
+
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
577
|
+
CRITICAL: The PR title MUST follow Conventional Commits format: <type>[(<scope>)]: <description>
|
|
578
|
+
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
|
579
|
+
Title examples: feat: add user authentication | fix(auth): resolve token expiry | docs: update contributing guide
|
|
580
|
+
Rules: title follows convention, present tense, max 72 chars; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
|
|
581
|
+
}
|
|
582
|
+
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
583
|
+
Rules: title concise present tense; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
|
|
584
|
+
}
|
|
585
|
+
var CONFLICT_RESOLUTION_SYSTEM_PROMPT = `Git merge conflict advisor. Explain each side, suggest resolution strategy. Never auto-resolve — guidance only. Be concise and actionable.`;
|
|
586
|
+
function suppressSubprocessWarnings() {
|
|
587
|
+
process.env.NODE_NO_WARNINGS = "1";
|
|
588
|
+
}
|
|
589
|
+
function withTimeout(promise, ms) {
|
|
590
|
+
return new Promise((resolve, reject) => {
|
|
591
|
+
const timer = setTimeout(() => reject(new Error(`Copilot request timed out after ${ms / 1000}s`)), ms);
|
|
592
|
+
promise.then((val) => {
|
|
593
|
+
clearTimeout(timer);
|
|
594
|
+
resolve(val);
|
|
595
|
+
}, (err) => {
|
|
596
|
+
clearTimeout(timer);
|
|
597
|
+
reject(err);
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
var COPILOT_TIMEOUT_MS = 30000;
|
|
602
|
+
var COPILOT_LONG_TIMEOUT_MS = 90000;
|
|
395
603
|
async function checkCopilotAvailable() {
|
|
396
|
-
let client = null;
|
|
397
604
|
try {
|
|
398
|
-
client =
|
|
399
|
-
|
|
605
|
+
const client = await getManagedClient();
|
|
606
|
+
try {
|
|
607
|
+
await client.ping();
|
|
608
|
+
} catch (err) {
|
|
609
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
610
|
+
if (msg.includes("auth") || msg.includes("token") || msg.includes("401") || msg.includes("403")) {
|
|
611
|
+
return "Copilot authentication failed. Run `gh auth login` to refresh your token.";
|
|
612
|
+
}
|
|
613
|
+
if (msg.includes("ECONNREFUSED") || msg.includes("timeout") || msg.includes("network")) {
|
|
614
|
+
return "Could not reach GitHub Copilot service. Check your internet connection.";
|
|
615
|
+
}
|
|
616
|
+
return `Copilot health check failed: ${msg}`;
|
|
617
|
+
}
|
|
618
|
+
return null;
|
|
400
619
|
} catch (err) {
|
|
401
620
|
const msg = err instanceof Error ? err.message : String(err);
|
|
402
621
|
if (msg.includes("ENOENT") || msg.includes("not found")) {
|
|
@@ -404,61 +623,99 @@ async function checkCopilotAvailable() {
|
|
|
404
623
|
}
|
|
405
624
|
return `Failed to start Copilot service: ${msg}`;
|
|
406
625
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
626
|
+
}
|
|
627
|
+
var _managedClient = null;
|
|
628
|
+
var _clientStarted = false;
|
|
629
|
+
async function getManagedClient() {
|
|
630
|
+
if (!_managedClient || !_clientStarted) {
|
|
631
|
+
suppressSubprocessWarnings();
|
|
632
|
+
_managedClient = new CopilotClient;
|
|
633
|
+
await _managedClient.start();
|
|
634
|
+
_clientStarted = true;
|
|
635
|
+
const cleanup = () => {
|
|
636
|
+
if (_managedClient && _clientStarted) {
|
|
637
|
+
try {
|
|
638
|
+
_managedClient.stop();
|
|
639
|
+
} catch {}
|
|
640
|
+
_clientStarted = false;
|
|
641
|
+
_managedClient = null;
|
|
642
|
+
}
|
|
643
|
+
};
|
|
644
|
+
process.once("exit", cleanup);
|
|
645
|
+
process.once("SIGINT", cleanup);
|
|
646
|
+
process.once("SIGTERM", cleanup);
|
|
422
647
|
}
|
|
423
|
-
return
|
|
648
|
+
return _managedClient;
|
|
424
649
|
}
|
|
425
|
-
async function callCopilot(systemMessage, userMessage, model) {
|
|
426
|
-
const client =
|
|
427
|
-
|
|
650
|
+
async function callCopilot(systemMessage, userMessage, model, timeoutMs = COPILOT_TIMEOUT_MS) {
|
|
651
|
+
const client = await getManagedClient();
|
|
652
|
+
const sessionConfig = {
|
|
653
|
+
systemMessage: { mode: "replace", content: systemMessage }
|
|
654
|
+
};
|
|
655
|
+
if (model)
|
|
656
|
+
sessionConfig.model = model;
|
|
657
|
+
const session = await client.createSession(sessionConfig);
|
|
428
658
|
try {
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
sessionConfig.model = model;
|
|
434
|
-
const session = await client.createSession(sessionConfig);
|
|
435
|
-
try {
|
|
436
|
-
const response = await session.sendAndWait({ content: userMessage });
|
|
437
|
-
if (!response?.data?.content)
|
|
438
|
-
return null;
|
|
439
|
-
return response.data.content;
|
|
440
|
-
} finally {
|
|
441
|
-
await session.destroy();
|
|
442
|
-
}
|
|
659
|
+
const response = await withTimeout(session.sendAndWait({ prompt: userMessage }), timeoutMs);
|
|
660
|
+
if (!response?.data?.content)
|
|
661
|
+
return null;
|
|
662
|
+
return response.data.content;
|
|
443
663
|
} finally {
|
|
444
|
-
await
|
|
664
|
+
await session.destroy();
|
|
445
665
|
}
|
|
446
666
|
}
|
|
447
|
-
|
|
667
|
+
function getCommitSystemPrompt(convention) {
|
|
668
|
+
if (convention === "conventional")
|
|
669
|
+
return CONVENTIONAL_COMMIT_SYSTEM_PROMPT;
|
|
670
|
+
return CLEAN_COMMIT_SYSTEM_PROMPT;
|
|
671
|
+
}
|
|
672
|
+
function extractJson(raw) {
|
|
673
|
+
let text2 = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
|
|
674
|
+
if (text2.startsWith("[") || text2.startsWith("{"))
|
|
675
|
+
return text2;
|
|
676
|
+
const arrayStart = text2.indexOf("[");
|
|
677
|
+
const objStart = text2.indexOf("{");
|
|
678
|
+
let start;
|
|
679
|
+
let closeChar;
|
|
680
|
+
if (arrayStart === -1 && objStart === -1)
|
|
681
|
+
return text2;
|
|
682
|
+
if (arrayStart === -1) {
|
|
683
|
+
start = objStart;
|
|
684
|
+
closeChar = "}";
|
|
685
|
+
} else if (objStart === -1) {
|
|
686
|
+
start = arrayStart;
|
|
687
|
+
closeChar = "]";
|
|
688
|
+
} else if (arrayStart < objStart) {
|
|
689
|
+
start = arrayStart;
|
|
690
|
+
closeChar = "]";
|
|
691
|
+
} else {
|
|
692
|
+
start = objStart;
|
|
693
|
+
closeChar = "}";
|
|
694
|
+
}
|
|
695
|
+
const end = text2.lastIndexOf(closeChar);
|
|
696
|
+
if (end > start) {
|
|
697
|
+
text2 = text2.slice(start, end + 1);
|
|
698
|
+
}
|
|
699
|
+
return text2;
|
|
700
|
+
}
|
|
701
|
+
async function generateCommitMessage(diff, stagedFiles, model, convention = "clean-commit") {
|
|
448
702
|
try {
|
|
703
|
+
const multiFileHint = stagedFiles.length > 1 ? `
|
|
704
|
+
|
|
705
|
+
IMPORTANT: Multiple files are staged. Generate ONE commit message that captures the high-level purpose of ALL changes together. Focus on the overall intent, not individual file changes. Be specific but concise — do not list every file.` : "";
|
|
449
706
|
const userMessage = `Generate a commit message for these staged changes:
|
|
450
707
|
|
|
451
708
|
Files: ${stagedFiles.join(", ")}
|
|
452
709
|
|
|
453
710
|
Diff:
|
|
454
|
-
${diff.slice(0, 4000)}`;
|
|
455
|
-
const result = await callCopilot(
|
|
711
|
+
${diff.slice(0, 4000)}${multiFileHint}`;
|
|
712
|
+
const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
|
|
456
713
|
return result?.trim() ?? null;
|
|
457
714
|
} catch {
|
|
458
715
|
return null;
|
|
459
716
|
}
|
|
460
717
|
}
|
|
461
|
-
async function generatePRDescription(commits, diff, model) {
|
|
718
|
+
async function generatePRDescription(commits, diff, model, convention = "clean-commit") {
|
|
462
719
|
try {
|
|
463
720
|
const userMessage = `Generate a PR description for these changes:
|
|
464
721
|
|
|
@@ -468,10 +725,10 @@ ${commits.join(`
|
|
|
468
725
|
|
|
469
726
|
Diff (truncated):
|
|
470
727
|
${diff.slice(0, 4000)}`;
|
|
471
|
-
const result = await callCopilot(
|
|
728
|
+
const result = await callCopilot(getPRDescriptionSystemPrompt(convention), userMessage, model);
|
|
472
729
|
if (!result)
|
|
473
730
|
return null;
|
|
474
|
-
const cleaned = result
|
|
731
|
+
const cleaned = extractJson(result);
|
|
475
732
|
return JSON.parse(cleaned);
|
|
476
733
|
} catch {
|
|
477
734
|
return null;
|
|
@@ -496,16 +753,130 @@ ${conflictDiff.slice(0, 4000)}`;
|
|
|
496
753
|
return null;
|
|
497
754
|
}
|
|
498
755
|
}
|
|
756
|
+
async function generateCommitGroups(files, diffs, model, convention = "clean-commit") {
|
|
757
|
+
const userMessage = `Group these changed files into logical atomic commits:
|
|
499
758
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
759
|
+
Files:
|
|
760
|
+
${files.join(`
|
|
761
|
+
`)}
|
|
762
|
+
|
|
763
|
+
Diffs (truncated):
|
|
764
|
+
${diffs.slice(0, 6000)}`;
|
|
765
|
+
const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
|
|
766
|
+
if (!result) {
|
|
767
|
+
throw new Error("AI returned an empty response");
|
|
768
|
+
}
|
|
769
|
+
const cleaned = extractJson(result);
|
|
770
|
+
let parsed;
|
|
771
|
+
try {
|
|
772
|
+
parsed = JSON.parse(cleaned);
|
|
773
|
+
} catch {
|
|
774
|
+
throw new Error(`AI response is not valid JSON. Raw start: "${result.slice(0, 120)}..."`);
|
|
775
|
+
}
|
|
776
|
+
const groups = parsed;
|
|
777
|
+
if (!Array.isArray(groups) || groups.length === 0) {
|
|
778
|
+
throw new Error("AI response was not a valid JSON array of commit groups");
|
|
779
|
+
}
|
|
780
|
+
for (const group of groups) {
|
|
781
|
+
if (!Array.isArray(group.files) || typeof group.message !== "string") {
|
|
782
|
+
throw new Error("AI returned groups with invalid structure (missing files or message)");
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
return groups;
|
|
504
786
|
}
|
|
787
|
+
async function regenerateAllGroupMessages(groups, diffs, model, convention = "clean-commit") {
|
|
788
|
+
const groupSummary = groups.map((g, i) => `Group ${i + 1}: [${g.files.join(", ")}]`).join(`
|
|
789
|
+
`);
|
|
790
|
+
const userMessage = `Regenerate ONLY the commit messages for these pre-defined file groups. Do NOT change the file groupings.
|
|
791
|
+
|
|
792
|
+
Groups:
|
|
793
|
+
${groupSummary}
|
|
794
|
+
|
|
795
|
+
Diffs (truncated):
|
|
796
|
+
${diffs.slice(0, 6000)}`;
|
|
797
|
+
const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
|
|
798
|
+
if (!result)
|
|
799
|
+
return groups;
|
|
800
|
+
try {
|
|
801
|
+
const cleaned = extractJson(result);
|
|
802
|
+
const parsed = JSON.parse(cleaned);
|
|
803
|
+
if (!Array.isArray(parsed) || parsed.length !== groups.length)
|
|
804
|
+
return groups;
|
|
805
|
+
return groups.map((g, i) => ({
|
|
806
|
+
files: g.files,
|
|
807
|
+
message: typeof parsed[i]?.message === "string" ? parsed[i].message : g.message
|
|
808
|
+
}));
|
|
809
|
+
} catch {
|
|
810
|
+
return groups;
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
async function regenerateGroupMessage(files, diffs, model, convention = "clean-commit") {
|
|
814
|
+
try {
|
|
815
|
+
const userMessage = `Generate a single commit message for these files:
|
|
816
|
+
|
|
817
|
+
Files: ${files.join(", ")}
|
|
818
|
+
|
|
819
|
+
Diff:
|
|
820
|
+
${diffs.slice(0, 4000)}`;
|
|
821
|
+
const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
|
|
822
|
+
return result?.trim() ?? null;
|
|
823
|
+
} catch {
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// src/utils/spinner.ts
|
|
829
|
+
import pc4 from "picocolors";
|
|
830
|
+
var FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
831
|
+
function createSpinner(text2) {
|
|
832
|
+
let frameIdx = 0;
|
|
833
|
+
let currentText = text2;
|
|
834
|
+
let stopped = false;
|
|
835
|
+
const clearLine = () => {
|
|
836
|
+
process.stderr.write("\r\x1B[K");
|
|
837
|
+
};
|
|
838
|
+
const render = () => {
|
|
839
|
+
if (stopped)
|
|
840
|
+
return;
|
|
841
|
+
const frame = pc4.cyan(FRAMES[frameIdx % FRAMES.length]);
|
|
842
|
+
clearLine();
|
|
843
|
+
process.stderr.write(`${frame} ${currentText}`);
|
|
844
|
+
frameIdx++;
|
|
845
|
+
};
|
|
846
|
+
const timer = setInterval(render, 80);
|
|
847
|
+
render();
|
|
848
|
+
const stop = () => {
|
|
849
|
+
if (stopped)
|
|
850
|
+
return;
|
|
851
|
+
stopped = true;
|
|
852
|
+
clearInterval(timer);
|
|
853
|
+
clearLine();
|
|
854
|
+
};
|
|
855
|
+
return {
|
|
856
|
+
update(newText) {
|
|
857
|
+
currentText = newText;
|
|
858
|
+
},
|
|
859
|
+
success(msg) {
|
|
860
|
+
stop();
|
|
861
|
+
process.stderr.write(`${pc4.green("✔")} ${msg}
|
|
862
|
+
`);
|
|
863
|
+
},
|
|
864
|
+
fail(msg) {
|
|
865
|
+
stop();
|
|
866
|
+
process.stderr.write(`${pc4.red("✖")} ${msg}
|
|
867
|
+
`);
|
|
868
|
+
},
|
|
869
|
+
stop() {
|
|
870
|
+
stop();
|
|
871
|
+
}
|
|
872
|
+
};
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// src/commands/commit.ts
|
|
505
876
|
var commit_default = defineCommand2({
|
|
506
877
|
meta: {
|
|
507
878
|
name: "commit",
|
|
508
|
-
description: "Stage changes and create a
|
|
879
|
+
description: "Stage changes and create a commit message (AI-powered)"
|
|
509
880
|
},
|
|
510
881
|
args: {
|
|
511
882
|
model: {
|
|
@@ -516,6 +887,11 @@ var commit_default = defineCommand2({
|
|
|
516
887
|
type: "boolean",
|
|
517
888
|
description: "Skip AI and write commit message manually",
|
|
518
889
|
default: false
|
|
890
|
+
},
|
|
891
|
+
group: {
|
|
892
|
+
type: "boolean",
|
|
893
|
+
description: "AI groups related changes into separate atomic commits",
|
|
894
|
+
default: false
|
|
519
895
|
}
|
|
520
896
|
},
|
|
521
897
|
async run({ args }) {
|
|
@@ -529,7 +905,11 @@ var commit_default = defineCommand2({
|
|
|
529
905
|
process.exit(1);
|
|
530
906
|
}
|
|
531
907
|
heading("\uD83D\uDCBE contrib commit");
|
|
532
|
-
|
|
908
|
+
if (args.group) {
|
|
909
|
+
await runGroupCommit(args.model, config);
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
let stagedFiles = await getStagedFiles();
|
|
533
913
|
if (stagedFiles.length === 0) {
|
|
534
914
|
const changedFiles = await getChangedFiles();
|
|
535
915
|
if (changedFiles.length === 0) {
|
|
@@ -537,31 +917,62 @@ var commit_default = defineCommand2({
|
|
|
537
917
|
process.exit(1);
|
|
538
918
|
}
|
|
539
919
|
console.log(`
|
|
540
|
-
${
|
|
920
|
+
${pc5.bold("Changed files:")}`);
|
|
541
921
|
for (const f of changedFiles) {
|
|
542
|
-
console.log(` ${
|
|
922
|
+
console.log(` ${pc5.dim("•")} ${f}`);
|
|
923
|
+
}
|
|
924
|
+
const stageAction = await selectPrompt("No staged changes. How would you like to stage?", [
|
|
925
|
+
"Stage all changes",
|
|
926
|
+
"Select files to stage",
|
|
927
|
+
"Cancel"
|
|
928
|
+
]);
|
|
929
|
+
if (stageAction === "Cancel") {
|
|
930
|
+
process.exit(0);
|
|
931
|
+
}
|
|
932
|
+
if (stageAction === "Stage all changes") {
|
|
933
|
+
const result2 = await stageAll();
|
|
934
|
+
if (result2.exitCode !== 0) {
|
|
935
|
+
error(`Failed to stage files: ${result2.stderr}`);
|
|
936
|
+
process.exit(1);
|
|
937
|
+
}
|
|
938
|
+
success("Staged all changes.");
|
|
939
|
+
} else {
|
|
940
|
+
const selected = await multiSelectPrompt("Select files to stage:", changedFiles);
|
|
941
|
+
if (selected.length === 0) {
|
|
942
|
+
error("No files selected.");
|
|
943
|
+
process.exit(1);
|
|
944
|
+
}
|
|
945
|
+
const result2 = await stageFiles(selected);
|
|
946
|
+
if (result2.exitCode !== 0) {
|
|
947
|
+
error(`Failed to stage files: ${result2.stderr}`);
|
|
948
|
+
process.exit(1);
|
|
949
|
+
}
|
|
950
|
+
success(`Staged ${selected.length} file(s).`);
|
|
951
|
+
}
|
|
952
|
+
stagedFiles = await getStagedFiles();
|
|
953
|
+
if (stagedFiles.length === 0) {
|
|
954
|
+
error("No staged changes after staging attempt.");
|
|
955
|
+
process.exit(1);
|
|
543
956
|
}
|
|
544
|
-
console.log();
|
|
545
|
-
warn("No staged changes. Stage your files with `git add` and re-run.");
|
|
546
|
-
process.exit(1);
|
|
547
957
|
}
|
|
548
958
|
info(`Staged files: ${stagedFiles.join(", ")}`);
|
|
549
959
|
let commitMessage = null;
|
|
550
960
|
const useAI = !args["no-ai"];
|
|
551
961
|
if (useAI) {
|
|
552
|
-
const copilotError = await checkCopilotAvailable();
|
|
962
|
+
const [copilotError, diff] = await Promise.all([checkCopilotAvailable(), getStagedDiff()]);
|
|
553
963
|
if (copilotError) {
|
|
554
964
|
warn(`AI unavailable: ${copilotError}`);
|
|
555
965
|
warn("Falling back to manual commit message entry.");
|
|
556
966
|
} else {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
commitMessage = await generateCommitMessage(diff, stagedFiles, args.model);
|
|
967
|
+
const spinner = createSpinner("Generating commit message with AI...");
|
|
968
|
+
commitMessage = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
|
|
560
969
|
if (commitMessage) {
|
|
970
|
+
spinner.success("AI commit message generated.");
|
|
561
971
|
console.log(`
|
|
562
|
-
${
|
|
972
|
+
${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(commitMessage))}`);
|
|
563
973
|
} else {
|
|
564
|
-
|
|
974
|
+
spinner.fail("AI did not return a commit message.");
|
|
975
|
+
warn("Falling back to manual entry.");
|
|
565
976
|
}
|
|
566
977
|
}
|
|
567
978
|
}
|
|
@@ -578,35 +989,42 @@ ${pc4.bold("Changed files:")}`);
|
|
|
578
989
|
} else if (action === "Edit this message") {
|
|
579
990
|
finalMessage = await inputPrompt("Edit commit message", commitMessage);
|
|
580
991
|
} else if (action === "Regenerate") {
|
|
581
|
-
|
|
992
|
+
const spinner = createSpinner("Regenerating commit message...");
|
|
582
993
|
const diff = await getStagedDiff();
|
|
583
|
-
const regen = await generateCommitMessage(diff, stagedFiles, args.model);
|
|
994
|
+
const regen = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
|
|
584
995
|
if (regen) {
|
|
996
|
+
spinner.success("Commit message regenerated.");
|
|
585
997
|
console.log(`
|
|
586
|
-
${
|
|
998
|
+
${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(regen))}`);
|
|
587
999
|
const ok = await confirmPrompt("Use this message?");
|
|
588
1000
|
finalMessage = ok ? regen : await inputPrompt("Enter commit message manually");
|
|
589
1001
|
} else {
|
|
590
|
-
|
|
1002
|
+
spinner.fail("Regeneration failed.");
|
|
591
1003
|
finalMessage = await inputPrompt("Enter commit message");
|
|
592
1004
|
}
|
|
593
1005
|
} else {
|
|
594
1006
|
finalMessage = await inputPrompt("Enter commit message");
|
|
595
1007
|
}
|
|
596
1008
|
} else {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
1009
|
+
const convention2 = config.commitConvention;
|
|
1010
|
+
if (convention2 !== "none") {
|
|
1011
|
+
console.log();
|
|
1012
|
+
for (const hint of CONVENTION_FORMAT_HINTS[convention2]) {
|
|
1013
|
+
console.log(pc5.dim(hint));
|
|
1014
|
+
}
|
|
1015
|
+
console.log();
|
|
1016
|
+
}
|
|
601
1017
|
finalMessage = await inputPrompt("Enter commit message");
|
|
602
1018
|
}
|
|
603
1019
|
if (!finalMessage) {
|
|
604
1020
|
error("No commit message provided.");
|
|
605
1021
|
process.exit(1);
|
|
606
1022
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
1023
|
+
const convention = config.commitConvention;
|
|
1024
|
+
if (!validateCommitMessage(finalMessage, convention)) {
|
|
1025
|
+
for (const line of getValidationError(convention)) {
|
|
1026
|
+
warn(line);
|
|
1027
|
+
}
|
|
610
1028
|
const proceed = await confirmPrompt("Commit anyway?");
|
|
611
1029
|
if (!proceed)
|
|
612
1030
|
process.exit(1);
|
|
@@ -616,13 +1034,320 @@ ${pc4.bold("Changed files:")}`);
|
|
|
616
1034
|
error(`Failed to commit: ${result.stderr}`);
|
|
617
1035
|
process.exit(1);
|
|
618
1036
|
}
|
|
619
|
-
success(`✅ Committed: ${
|
|
1037
|
+
success(`✅ Committed: ${pc5.bold(finalMessage)}`);
|
|
1038
|
+
}
|
|
1039
|
+
});
|
|
1040
|
+
async function runGroupCommit(model, config) {
|
|
1041
|
+
const [copilotError, changedFiles] = await Promise.all([
|
|
1042
|
+
checkCopilotAvailable(),
|
|
1043
|
+
getChangedFiles()
|
|
1044
|
+
]);
|
|
1045
|
+
if (copilotError) {
|
|
1046
|
+
error(`AI is required for --group mode but unavailable: ${copilotError}`);
|
|
1047
|
+
process.exit(1);
|
|
1048
|
+
}
|
|
1049
|
+
if (changedFiles.length === 0) {
|
|
1050
|
+
error("No changes to group-commit.");
|
|
1051
|
+
process.exit(1);
|
|
1052
|
+
}
|
|
1053
|
+
console.log(`
|
|
1054
|
+
${pc5.bold("Changed files:")}`);
|
|
1055
|
+
for (const f of changedFiles) {
|
|
1056
|
+
console.log(` ${pc5.dim("•")} ${f}`);
|
|
1057
|
+
}
|
|
1058
|
+
const spinner = createSpinner(`Asking AI to group ${changedFiles.length} file(s) into logical commits...`);
|
|
1059
|
+
const diffs = await getFullDiffForFiles(changedFiles);
|
|
1060
|
+
if (!diffs.trim()) {
|
|
1061
|
+
spinner.stop();
|
|
1062
|
+
warn("Could not retrieve diff context for any files. AI needs diffs to produce groups.");
|
|
1063
|
+
}
|
|
1064
|
+
let groups;
|
|
1065
|
+
try {
|
|
1066
|
+
groups = await generateCommitGroups(changedFiles, diffs, model, config.commitConvention);
|
|
1067
|
+
spinner.success(`AI generated ${groups.length} commit group(s).`);
|
|
1068
|
+
} catch (err) {
|
|
1069
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1070
|
+
spinner.fail(`AI grouping failed: ${reason}`);
|
|
1071
|
+
process.exit(1);
|
|
1072
|
+
}
|
|
1073
|
+
if (groups.length === 0) {
|
|
1074
|
+
error("AI could not produce commit groups. Try committing files manually.");
|
|
1075
|
+
process.exit(1);
|
|
1076
|
+
}
|
|
1077
|
+
const changedSet = new Set(changedFiles);
|
|
1078
|
+
for (const group of groups) {
|
|
1079
|
+
const invalid = group.files.filter((f) => !changedSet.has(f));
|
|
1080
|
+
if (invalid.length > 0) {
|
|
1081
|
+
warn(`AI suggested unknown file(s): ${invalid.join(", ")} — removed from group.`);
|
|
1082
|
+
}
|
|
1083
|
+
group.files = group.files.filter((f) => changedSet.has(f));
|
|
1084
|
+
}
|
|
1085
|
+
let validGroups = groups.filter((g) => g.files.length > 0);
|
|
1086
|
+
if (validGroups.length === 0) {
|
|
1087
|
+
error("No valid groups remain after validation. Try committing files manually.");
|
|
1088
|
+
process.exit(1);
|
|
1089
|
+
}
|
|
1090
|
+
let proceedToCommit = false;
|
|
1091
|
+
let commitAll = false;
|
|
1092
|
+
while (!proceedToCommit) {
|
|
1093
|
+
console.log(`
|
|
1094
|
+
${pc5.bold(`AI suggested ${validGroups.length} commit group(s):`)}
|
|
1095
|
+
`);
|
|
1096
|
+
for (let i = 0;i < validGroups.length; i++) {
|
|
1097
|
+
const g = validGroups[i];
|
|
1098
|
+
console.log(` ${pc5.cyan(`Group ${i + 1}:`)} ${pc5.bold(g.message)}`);
|
|
1099
|
+
for (const f of g.files) {
|
|
1100
|
+
console.log(` ${pc5.dim("•")} ${f}`);
|
|
1101
|
+
}
|
|
1102
|
+
console.log();
|
|
1103
|
+
}
|
|
1104
|
+
const summaryAction = await selectPrompt("What would you like to do?", [
|
|
1105
|
+
"Commit all",
|
|
1106
|
+
"Review each group",
|
|
1107
|
+
"Regenerate all messages",
|
|
1108
|
+
"Cancel"
|
|
1109
|
+
]);
|
|
1110
|
+
if (summaryAction === "Cancel") {
|
|
1111
|
+
warn("Group commit cancelled.");
|
|
1112
|
+
process.exit(0);
|
|
1113
|
+
}
|
|
1114
|
+
if (summaryAction === "Regenerate all messages") {
|
|
1115
|
+
const regenSpinner = createSpinner("Regenerating all commit messages...");
|
|
1116
|
+
try {
|
|
1117
|
+
validGroups = await regenerateAllGroupMessages(validGroups, diffs, model, config.commitConvention);
|
|
1118
|
+
regenSpinner.success("All commit messages regenerated.");
|
|
1119
|
+
} catch {
|
|
1120
|
+
regenSpinner.fail("Failed to regenerate messages. Keeping current ones.");
|
|
1121
|
+
}
|
|
1122
|
+
continue;
|
|
1123
|
+
}
|
|
1124
|
+
proceedToCommit = true;
|
|
1125
|
+
commitAll = summaryAction === "Commit all";
|
|
1126
|
+
}
|
|
1127
|
+
let committed = 0;
|
|
1128
|
+
if (commitAll) {
|
|
1129
|
+
for (let i = 0;i < validGroups.length; i++) {
|
|
1130
|
+
const group = validGroups[i];
|
|
1131
|
+
const stageResult = await stageFiles(group.files);
|
|
1132
|
+
if (stageResult.exitCode !== 0) {
|
|
1133
|
+
error(`Failed to stage group ${i + 1}: ${stageResult.stderr}`);
|
|
1134
|
+
continue;
|
|
1135
|
+
}
|
|
1136
|
+
const commitResult = await commitWithMessage(group.message);
|
|
1137
|
+
if (commitResult.exitCode !== 0) {
|
|
1138
|
+
const detail = (commitResult.stderr || commitResult.stdout).trim();
|
|
1139
|
+
error(`Failed to commit group ${i + 1}: ${detail}`);
|
|
1140
|
+
await unstageFiles(group.files);
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
committed++;
|
|
1144
|
+
success(`✅ Committed group ${i + 1}: ${pc5.bold(group.message)}`);
|
|
1145
|
+
}
|
|
1146
|
+
} else {
|
|
1147
|
+
for (let i = 0;i < validGroups.length; i++) {
|
|
1148
|
+
const group = validGroups[i];
|
|
1149
|
+
console.log(pc5.bold(`
|
|
1150
|
+
── Group ${i + 1}/${validGroups.length} ──`));
|
|
1151
|
+
console.log(` ${pc5.cyan(group.message)}`);
|
|
1152
|
+
for (const f of group.files) {
|
|
1153
|
+
console.log(` ${pc5.dim("•")} ${f}`);
|
|
1154
|
+
}
|
|
1155
|
+
let message = group.message;
|
|
1156
|
+
let actionDone = false;
|
|
1157
|
+
while (!actionDone) {
|
|
1158
|
+
const action = await selectPrompt("Action for this group:", [
|
|
1159
|
+
"Commit as-is",
|
|
1160
|
+
"Edit message and commit",
|
|
1161
|
+
"Regenerate message",
|
|
1162
|
+
"Skip this group"
|
|
1163
|
+
]);
|
|
1164
|
+
if (action === "Skip this group") {
|
|
1165
|
+
warn(`Skipped group ${i + 1}.`);
|
|
1166
|
+
actionDone = true;
|
|
1167
|
+
continue;
|
|
1168
|
+
}
|
|
1169
|
+
if (action === "Regenerate message") {
|
|
1170
|
+
const regenSpinner = createSpinner("Regenerating commit message for this group...");
|
|
1171
|
+
const newMsg = await regenerateGroupMessage(group.files, diffs, model, config.commitConvention);
|
|
1172
|
+
if (newMsg) {
|
|
1173
|
+
message = newMsg;
|
|
1174
|
+
group.message = newMsg;
|
|
1175
|
+
regenSpinner.success(`New message: ${pc5.bold(message)}`);
|
|
1176
|
+
} else {
|
|
1177
|
+
regenSpinner.fail("AI could not generate a new message. Keeping current one.");
|
|
1178
|
+
}
|
|
1179
|
+
continue;
|
|
1180
|
+
}
|
|
1181
|
+
if (action === "Edit message and commit") {
|
|
1182
|
+
message = await inputPrompt("Edit commit message", message);
|
|
1183
|
+
if (!message) {
|
|
1184
|
+
warn(`Skipped group ${i + 1} (empty message).`);
|
|
1185
|
+
actionDone = true;
|
|
1186
|
+
continue;
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
if (!validateCommitMessage(message, config.commitConvention)) {
|
|
1190
|
+
for (const line of getValidationError(config.commitConvention)) {
|
|
1191
|
+
warn(line);
|
|
1192
|
+
}
|
|
1193
|
+
const proceed = await confirmPrompt("Commit anyway?");
|
|
1194
|
+
if (!proceed) {
|
|
1195
|
+
warn(`Skipped group ${i + 1}.`);
|
|
1196
|
+
actionDone = true;
|
|
1197
|
+
continue;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
const stageResult = await stageFiles(group.files);
|
|
1201
|
+
if (stageResult.exitCode !== 0) {
|
|
1202
|
+
error(`Failed to stage group ${i + 1}: ${stageResult.stderr}`);
|
|
1203
|
+
actionDone = true;
|
|
1204
|
+
continue;
|
|
1205
|
+
}
|
|
1206
|
+
const commitResult = await commitWithMessage(message);
|
|
1207
|
+
if (commitResult.exitCode !== 0) {
|
|
1208
|
+
const detail = (commitResult.stderr || commitResult.stdout).trim();
|
|
1209
|
+
error(`Failed to commit group ${i + 1}: ${detail}`);
|
|
1210
|
+
await unstageFiles(group.files);
|
|
1211
|
+
actionDone = true;
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
committed++;
|
|
1215
|
+
success(`✅ Committed group ${i + 1}: ${pc5.bold(message)}`);
|
|
1216
|
+
actionDone = true;
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (committed === 0) {
|
|
1221
|
+
warn("No groups were committed.");
|
|
1222
|
+
} else {
|
|
1223
|
+
success(`
|
|
1224
|
+
\uD83C\uDF89 ${committed} of ${validGroups.length} group(s) committed successfully.`);
|
|
1225
|
+
}
|
|
1226
|
+
process.exit(0);
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// src/commands/hook.ts
|
|
1230
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1231
|
+
import { join as join3 } from "node:path";
|
|
1232
|
+
import { defineCommand as defineCommand3 } from "citty";
|
|
1233
|
+
import pc6 from "picocolors";
|
|
1234
|
+
var HOOK_MARKER = "# managed by contribute-now";
|
|
1235
|
+
function getHooksDir(cwd = process.cwd()) {
|
|
1236
|
+
return join3(cwd, ".git", "hooks");
|
|
1237
|
+
}
|
|
1238
|
+
function getHookPath(cwd = process.cwd()) {
|
|
1239
|
+
return join3(getHooksDir(cwd), "commit-msg");
|
|
1240
|
+
}
|
|
1241
|
+
function generateHookScript() {
|
|
1242
|
+
return `#!/bin/sh
|
|
1243
|
+
${HOOK_MARKER}
|
|
1244
|
+
# Validates commit messages against your configured convention.
|
|
1245
|
+
# Install: contrib hook install
|
|
1246
|
+
# Uninstall: contrib hook uninstall
|
|
1247
|
+
|
|
1248
|
+
commit_msg_file="$1"
|
|
1249
|
+
commit_msg=$(head -1 "$commit_msg_file")
|
|
1250
|
+
|
|
1251
|
+
# Skip merge commits and fixup/squash commits
|
|
1252
|
+
case "$commit_msg" in
|
|
1253
|
+
Merge\\ *|fixup!*|squash!*|amend!*) exit 0 ;;
|
|
1254
|
+
esac
|
|
1255
|
+
|
|
1256
|
+
# Detect available package runner
|
|
1257
|
+
if command -v contrib >/dev/null 2>&1; then
|
|
1258
|
+
contrib validate "$commit_msg"
|
|
1259
|
+
elif command -v bunx >/dev/null 2>&1; then
|
|
1260
|
+
bunx contrib validate "$commit_msg"
|
|
1261
|
+
elif command -v pnpx >/dev/null 2>&1; then
|
|
1262
|
+
pnpx contrib validate "$commit_msg"
|
|
1263
|
+
elif command -v npx >/dev/null 2>&1; then
|
|
1264
|
+
npx contrib validate "$commit_msg"
|
|
1265
|
+
else
|
|
1266
|
+
echo "Warning: No package runner found. Skipping commit message validation."
|
|
1267
|
+
exit 0
|
|
1268
|
+
fi
|
|
1269
|
+
`;
|
|
1270
|
+
}
|
|
1271
|
+
var hook_default = defineCommand3({
|
|
1272
|
+
meta: {
|
|
1273
|
+
name: "hook",
|
|
1274
|
+
description: "Install or uninstall the commit-msg git hook"
|
|
1275
|
+
},
|
|
1276
|
+
args: {
|
|
1277
|
+
action: {
|
|
1278
|
+
type: "positional",
|
|
1279
|
+
description: "Action to perform: install or uninstall",
|
|
1280
|
+
required: true
|
|
1281
|
+
}
|
|
1282
|
+
},
|
|
1283
|
+
async run({ args }) {
|
|
1284
|
+
if (!await isGitRepo()) {
|
|
1285
|
+
error("Not inside a git repository.");
|
|
1286
|
+
process.exit(1);
|
|
1287
|
+
}
|
|
1288
|
+
const action = args.action;
|
|
1289
|
+
if (action !== "install" && action !== "uninstall") {
|
|
1290
|
+
error(`Unknown action "${action}". Use "install" or "uninstall".`);
|
|
1291
|
+
process.exit(1);
|
|
1292
|
+
}
|
|
1293
|
+
if (action === "install") {
|
|
1294
|
+
await installHook();
|
|
1295
|
+
} else {
|
|
1296
|
+
await uninstallHook();
|
|
1297
|
+
}
|
|
620
1298
|
}
|
|
621
1299
|
});
|
|
1300
|
+
async function installHook() {
|
|
1301
|
+
heading("\uD83E\uDE9D hook install");
|
|
1302
|
+
const config = readConfig();
|
|
1303
|
+
if (!config) {
|
|
1304
|
+
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
1305
|
+
process.exit(1);
|
|
1306
|
+
}
|
|
1307
|
+
if (config.commitConvention === "none") {
|
|
1308
|
+
warn('Commit convention is set to "none". No hook to install.');
|
|
1309
|
+
info("Change your convention with `contrib setup` first.");
|
|
1310
|
+
process.exit(0);
|
|
1311
|
+
}
|
|
1312
|
+
const hookPath = getHookPath();
|
|
1313
|
+
const hooksDir = getHooksDir();
|
|
1314
|
+
if (existsSync2(hookPath)) {
|
|
1315
|
+
const existing = readFileSync3(hookPath, "utf-8");
|
|
1316
|
+
if (!existing.includes(HOOK_MARKER)) {
|
|
1317
|
+
error("A commit-msg hook already exists and was not installed by contribute-now.");
|
|
1318
|
+
warn(`Path: ${hookPath}`);
|
|
1319
|
+
warn("Remove it manually or back it up before installing.");
|
|
1320
|
+
process.exit(1);
|
|
1321
|
+
}
|
|
1322
|
+
info("Updating existing contribute-now hook...");
|
|
1323
|
+
}
|
|
1324
|
+
if (!existsSync2(hooksDir)) {
|
|
1325
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
1326
|
+
}
|
|
1327
|
+
writeFileSync2(hookPath, generateHookScript(), { mode: 493 });
|
|
1328
|
+
success(`commit-msg hook installed.`);
|
|
1329
|
+
info(`Convention: ${pc6.bold(CONVENTION_LABELS[config.commitConvention])}`);
|
|
1330
|
+
info(`Path: ${pc6.dim(hookPath)}`);
|
|
1331
|
+
}
|
|
1332
|
+
async function uninstallHook() {
|
|
1333
|
+
heading("\uD83E\uDE9D hook uninstall");
|
|
1334
|
+
const hookPath = getHookPath();
|
|
1335
|
+
if (!existsSync2(hookPath)) {
|
|
1336
|
+
info("No commit-msg hook found. Nothing to uninstall.");
|
|
1337
|
+
return;
|
|
1338
|
+
}
|
|
1339
|
+
const content = readFileSync3(hookPath, "utf-8");
|
|
1340
|
+
if (!content.includes(HOOK_MARKER)) {
|
|
1341
|
+
error("The commit-msg hook was not installed by contribute-now. Leaving it untouched.");
|
|
1342
|
+
process.exit(1);
|
|
1343
|
+
}
|
|
1344
|
+
rmSync(hookPath);
|
|
1345
|
+
success("commit-msg hook removed.");
|
|
1346
|
+
}
|
|
622
1347
|
|
|
623
1348
|
// src/commands/setup.ts
|
|
624
|
-
import { defineCommand as
|
|
625
|
-
import
|
|
1349
|
+
import { defineCommand as defineCommand4 } from "citty";
|
|
1350
|
+
import pc7 from "picocolors";
|
|
626
1351
|
|
|
627
1352
|
// src/utils/gh.ts
|
|
628
1353
|
import { execFile as execFileCb2 } from "node:child_process";
|
|
@@ -630,7 +1355,7 @@ function run2(args) {
|
|
|
630
1355
|
return new Promise((resolve) => {
|
|
631
1356
|
execFileCb2("gh", args, (error2, stdout, stderr) => {
|
|
632
1357
|
resolve({
|
|
633
|
-
exitCode: error2 ? error2.code
|
|
1358
|
+
exitCode: error2 ? error2.code === "ENOENT" ? 127 : error2.status ?? 1 : 0,
|
|
634
1359
|
stdout: stdout ?? "",
|
|
635
1360
|
stderr: stderr ?? ""
|
|
636
1361
|
});
|
|
@@ -653,7 +1378,10 @@ async function checkGhAuth() {
|
|
|
653
1378
|
return false;
|
|
654
1379
|
}
|
|
655
1380
|
}
|
|
1381
|
+
var SAFE_SLUG = /^[\w.-]+$/;
|
|
656
1382
|
async function checkRepoPermissions(owner, repo) {
|
|
1383
|
+
if (!SAFE_SLUG.test(owner) || !SAFE_SLUG.test(repo))
|
|
1384
|
+
return null;
|
|
657
1385
|
const { exitCode, stdout } = await run2(["api", `repos/${owner}/${repo}`, "--jq", ".permissions"]);
|
|
658
1386
|
if (exitCode !== 0)
|
|
659
1387
|
return null;
|
|
@@ -714,6 +1442,28 @@ async function createPRFill(base, draft) {
|
|
|
714
1442
|
args.push("--draft");
|
|
715
1443
|
return run2(args);
|
|
716
1444
|
}
|
|
1445
|
+
async function getPRForBranch(headBranch) {
|
|
1446
|
+
const { exitCode, stdout } = await run2([
|
|
1447
|
+
"pr",
|
|
1448
|
+
"list",
|
|
1449
|
+
"--head",
|
|
1450
|
+
headBranch,
|
|
1451
|
+
"--state",
|
|
1452
|
+
"open",
|
|
1453
|
+
"--json",
|
|
1454
|
+
"number,url,title,state",
|
|
1455
|
+
"--limit",
|
|
1456
|
+
"1"
|
|
1457
|
+
]);
|
|
1458
|
+
if (exitCode !== 0)
|
|
1459
|
+
return null;
|
|
1460
|
+
try {
|
|
1461
|
+
const prs = JSON.parse(stdout.trim());
|
|
1462
|
+
return prs.length > 0 ? prs[0] : null;
|
|
1463
|
+
} catch {
|
|
1464
|
+
return null;
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
717
1467
|
|
|
718
1468
|
// src/utils/remote.ts
|
|
719
1469
|
function parseRepoFromUrl(url) {
|
|
@@ -735,7 +1485,7 @@ async function getRepoInfoFromRemote(remote = "origin") {
|
|
|
735
1485
|
}
|
|
736
1486
|
|
|
737
1487
|
// src/commands/setup.ts
|
|
738
|
-
var setup_default =
|
|
1488
|
+
var setup_default = defineCommand4({
|
|
739
1489
|
meta: {
|
|
740
1490
|
name: "setup",
|
|
741
1491
|
description: "Initialize contribute-now config for this repo (.contributerc.json)"
|
|
@@ -746,6 +1496,27 @@ var setup_default = defineCommand3({
|
|
|
746
1496
|
process.exit(1);
|
|
747
1497
|
}
|
|
748
1498
|
heading("\uD83D\uDD27 contribute-now setup");
|
|
1499
|
+
const workflowChoice = await selectPrompt("Which git workflow does this project use?", [
|
|
1500
|
+
"Clean Flow — main + dev, squash features into dev, merge dev into main (recommended)",
|
|
1501
|
+
"GitHub Flow — main + feature branches, squash/merge into main",
|
|
1502
|
+
"Git Flow — main + develop + release + hotfix branches"
|
|
1503
|
+
]);
|
|
1504
|
+
let workflow = "clean-flow";
|
|
1505
|
+
if (workflowChoice.startsWith("GitHub"))
|
|
1506
|
+
workflow = "github-flow";
|
|
1507
|
+
else if (workflowChoice.startsWith("Git Flow"))
|
|
1508
|
+
workflow = "git-flow";
|
|
1509
|
+
info(`Workflow: ${pc7.bold(WORKFLOW_DESCRIPTIONS[workflow])}`);
|
|
1510
|
+
const conventionChoice = await selectPrompt("Which commit convention should this project use?", [
|
|
1511
|
+
`${CONVENTION_DESCRIPTIONS["clean-commit"]} (recommended)`,
|
|
1512
|
+
CONVENTION_DESCRIPTIONS.conventional,
|
|
1513
|
+
CONVENTION_DESCRIPTIONS.none
|
|
1514
|
+
]);
|
|
1515
|
+
let commitConvention = "clean-commit";
|
|
1516
|
+
if (conventionChoice.includes("Conventional Commits"))
|
|
1517
|
+
commitConvention = "conventional";
|
|
1518
|
+
else if (conventionChoice.includes("No commit"))
|
|
1519
|
+
commitConvention = "none";
|
|
749
1520
|
const remotes = await getRemotes();
|
|
750
1521
|
if (remotes.length === 0) {
|
|
751
1522
|
error("No git remotes found. Add a remote first (e.g., git remote add origin <url>).");
|
|
@@ -788,8 +1559,8 @@ var setup_default = defineCommand3({
|
|
|
788
1559
|
detectedRole = roleChoice;
|
|
789
1560
|
detectionSource = "user selection";
|
|
790
1561
|
} else {
|
|
791
|
-
info(`Detected role: ${
|
|
792
|
-
const confirmed = await confirmPrompt(`Role detected as ${
|
|
1562
|
+
info(`Detected role: ${pc7.bold(detectedRole)} (via ${detectionSource})`);
|
|
1563
|
+
const confirmed = await confirmPrompt(`Role detected as ${pc7.bold(detectedRole)}. Is this correct?`);
|
|
793
1564
|
if (!confirmed) {
|
|
794
1565
|
const roleChoice = await selectPrompt("Select your role:", ["maintainer", "contributor"]);
|
|
795
1566
|
detectedRole = roleChoice;
|
|
@@ -797,7 +1568,11 @@ var setup_default = defineCommand3({
|
|
|
797
1568
|
}
|
|
798
1569
|
const defaultConfig = getDefaultConfig();
|
|
799
1570
|
const mainBranch = await inputPrompt("Main branch name", defaultConfig.mainBranch);
|
|
800
|
-
|
|
1571
|
+
let devBranch;
|
|
1572
|
+
if (hasDevBranch(workflow)) {
|
|
1573
|
+
const defaultDev = workflow === "git-flow" ? "develop" : "dev";
|
|
1574
|
+
devBranch = await inputPrompt("Dev/develop branch name", defaultDev);
|
|
1575
|
+
}
|
|
801
1576
|
const originRemote = await inputPrompt("Origin remote name", defaultConfig.origin);
|
|
802
1577
|
let upstreamRemote = defaultConfig.upstream;
|
|
803
1578
|
if (detectedRole === "contributor") {
|
|
@@ -814,12 +1589,14 @@ var setup_default = defineCommand3({
|
|
|
814
1589
|
}
|
|
815
1590
|
}
|
|
816
1591
|
const config = {
|
|
1592
|
+
workflow,
|
|
817
1593
|
role: detectedRole,
|
|
818
1594
|
mainBranch,
|
|
819
|
-
devBranch,
|
|
1595
|
+
...devBranch ? { devBranch } : {},
|
|
820
1596
|
upstream: upstreamRemote,
|
|
821
1597
|
origin: originRemote,
|
|
822
|
-
branchPrefixes: defaultConfig.branchPrefixes
|
|
1598
|
+
branchPrefixes: defaultConfig.branchPrefixes,
|
|
1599
|
+
commitConvention
|
|
823
1600
|
};
|
|
824
1601
|
writeConfig(config);
|
|
825
1602
|
success(`✅ Config written to .contributerc.json`);
|
|
@@ -828,15 +1605,21 @@ var setup_default = defineCommand3({
|
|
|
828
1605
|
warn(' echo ".contributerc.json" >> .gitignore');
|
|
829
1606
|
}
|
|
830
1607
|
console.log();
|
|
831
|
-
info(`
|
|
832
|
-
info(`
|
|
833
|
-
info(`
|
|
1608
|
+
info(`Workflow: ${pc7.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
|
|
1609
|
+
info(`Convention: ${pc7.bold(CONVENTION_DESCRIPTIONS[config.commitConvention])}`);
|
|
1610
|
+
info(`Role: ${pc7.bold(config.role)}`);
|
|
1611
|
+
if (config.devBranch) {
|
|
1612
|
+
info(`Main: ${pc7.bold(config.mainBranch)} | Dev: ${pc7.bold(config.devBranch)}`);
|
|
1613
|
+
} else {
|
|
1614
|
+
info(`Main: ${pc7.bold(config.mainBranch)}`);
|
|
1615
|
+
}
|
|
1616
|
+
info(`Origin: ${pc7.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc7.bold(config.upstream)}` : ""}`);
|
|
834
1617
|
}
|
|
835
1618
|
});
|
|
836
1619
|
|
|
837
1620
|
// src/commands/start.ts
|
|
838
|
-
import { defineCommand as
|
|
839
|
-
import
|
|
1621
|
+
import { defineCommand as defineCommand5 } from "citty";
|
|
1622
|
+
import pc8 from "picocolors";
|
|
840
1623
|
|
|
841
1624
|
// src/utils/branch.ts
|
|
842
1625
|
var DEFAULT_PREFIXES = ["feature", "fix", "docs", "chore", "test", "refactor"];
|
|
@@ -847,15 +1630,18 @@ function formatBranchName(prefix, name) {
|
|
|
847
1630
|
const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
848
1631
|
return `${prefix}/${sanitized}`;
|
|
849
1632
|
}
|
|
1633
|
+
function isValidBranchName(name) {
|
|
1634
|
+
return /^[a-zA-Z0-9._/-]+$/.test(name) && !name.startsWith("/") && !name.endsWith("/");
|
|
1635
|
+
}
|
|
850
1636
|
function looksLikeNaturalLanguage(input) {
|
|
851
1637
|
return input.includes(" ") && !input.includes("/");
|
|
852
1638
|
}
|
|
853
1639
|
|
|
854
1640
|
// src/commands/start.ts
|
|
855
|
-
var start_default =
|
|
1641
|
+
var start_default = defineCommand5({
|
|
856
1642
|
meta: {
|
|
857
1643
|
name: "start",
|
|
858
|
-
description: "Create a new feature branch from the latest
|
|
1644
|
+
description: "Create a new feature branch from the latest base branch"
|
|
859
1645
|
},
|
|
860
1646
|
args: {
|
|
861
1647
|
name: {
|
|
@@ -887,50 +1673,57 @@ var start_default = defineCommand4({
|
|
|
887
1673
|
error("You have uncommitted changes. Please commit or stash them before creating a branch.");
|
|
888
1674
|
process.exit(1);
|
|
889
1675
|
}
|
|
890
|
-
const {
|
|
1676
|
+
const { branchPrefixes } = config;
|
|
1677
|
+
const baseBranch = getBaseBranch(config);
|
|
1678
|
+
const syncSource = getSyncSource(config);
|
|
891
1679
|
let branchName = args.name;
|
|
892
1680
|
heading("\uD83C\uDF3F contrib start");
|
|
893
1681
|
const useAI = !args["no-ai"] && looksLikeNaturalLanguage(branchName);
|
|
894
1682
|
if (useAI) {
|
|
895
|
-
|
|
1683
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
896
1684
|
const suggested = await suggestBranchName(branchName, args.model);
|
|
897
1685
|
if (suggested) {
|
|
1686
|
+
spinner.success("Branch name suggestion ready.");
|
|
898
1687
|
console.log(`
|
|
899
|
-
${
|
|
900
|
-
const accepted = await confirmPrompt(`Use ${
|
|
1688
|
+
${pc8.dim("AI suggestion:")} ${pc8.bold(pc8.cyan(suggested))}`);
|
|
1689
|
+
const accepted = await confirmPrompt(`Use ${pc8.bold(suggested)} as your branch name?`);
|
|
901
1690
|
if (accepted) {
|
|
902
1691
|
branchName = suggested;
|
|
903
1692
|
} else {
|
|
904
1693
|
branchName = await inputPrompt("Enter branch name", branchName);
|
|
905
1694
|
}
|
|
1695
|
+
} else {
|
|
1696
|
+
spinner.fail("AI did not return a branch name suggestion.");
|
|
906
1697
|
}
|
|
907
1698
|
}
|
|
908
1699
|
if (!hasPrefix(branchName, branchPrefixes)) {
|
|
909
|
-
const prefix = await selectPrompt(`Choose a branch type for ${
|
|
1700
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc8.bold(branchName)}:`, branchPrefixes);
|
|
910
1701
|
branchName = formatBranchName(prefix, branchName);
|
|
911
1702
|
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
const
|
|
1703
|
+
if (!isValidBranchName(branchName)) {
|
|
1704
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
1705
|
+
process.exit(1);
|
|
1706
|
+
}
|
|
1707
|
+
info(`Creating branch: ${pc8.bold(branchName)}`);
|
|
1708
|
+
await fetchRemote(syncSource.remote);
|
|
1709
|
+
const updateResult = await updateLocalBranch(baseBranch, syncSource.ref);
|
|
1710
|
+
if (updateResult.exitCode !== 0) {}
|
|
1711
|
+
const result = await createBranch(branchName, baseBranch);
|
|
919
1712
|
if (result.exitCode !== 0) {
|
|
920
1713
|
error(`Failed to create branch: ${result.stderr}`);
|
|
921
1714
|
process.exit(1);
|
|
922
1715
|
}
|
|
923
|
-
success(`✅ Created ${
|
|
1716
|
+
success(`✅ Created ${pc8.bold(branchName)} from latest ${pc8.bold(baseBranch)}`);
|
|
924
1717
|
}
|
|
925
1718
|
});
|
|
926
1719
|
|
|
927
1720
|
// src/commands/status.ts
|
|
928
|
-
import { defineCommand as
|
|
929
|
-
import
|
|
930
|
-
var status_default =
|
|
1721
|
+
import { defineCommand as defineCommand6 } from "citty";
|
|
1722
|
+
import pc9 from "picocolors";
|
|
1723
|
+
var status_default = defineCommand6({
|
|
931
1724
|
meta: {
|
|
932
1725
|
name: "status",
|
|
933
|
-
description: "Show sync status of
|
|
1726
|
+
description: "Show sync status of branches"
|
|
934
1727
|
},
|
|
935
1728
|
async run() {
|
|
936
1729
|
if (!await isGitRepo()) {
|
|
@@ -943,60 +1736,160 @@ var status_default = defineCommand5({
|
|
|
943
1736
|
process.exit(1);
|
|
944
1737
|
}
|
|
945
1738
|
heading("\uD83D\uDCCA contribute-now status");
|
|
1739
|
+
console.log(` ${pc9.dim("Workflow:")} ${pc9.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
|
|
1740
|
+
console.log(` ${pc9.dim("Role:")} ${pc9.bold(config.role)}`);
|
|
1741
|
+
console.log();
|
|
946
1742
|
await fetchAll();
|
|
947
1743
|
const currentBranch = await getCurrentBranch();
|
|
948
|
-
const { mainBranch,
|
|
1744
|
+
const { mainBranch, origin, upstream, workflow } = config;
|
|
1745
|
+
const baseBranch = getBaseBranch(config);
|
|
949
1746
|
const isContributor = config.role === "contributor";
|
|
950
|
-
const dirty = await
|
|
1747
|
+
const [dirty, fileStatus] = await Promise.all([
|
|
1748
|
+
hasUncommittedChanges(),
|
|
1749
|
+
getFileStatus()
|
|
1750
|
+
]);
|
|
951
1751
|
if (dirty) {
|
|
952
|
-
console.log(` ${
|
|
1752
|
+
console.log(` ${pc9.yellow("⚠")} ${pc9.yellow("Uncommitted changes in working tree")}`);
|
|
953
1753
|
console.log();
|
|
954
1754
|
}
|
|
955
1755
|
const mainRemote = `${origin}/${mainBranch}`;
|
|
956
1756
|
const mainDiv = await getDivergence(mainBranch, mainRemote);
|
|
957
1757
|
const mainStatus = formatStatus(mainBranch, mainRemote, mainDiv.ahead, mainDiv.behind);
|
|
958
1758
|
console.log(mainStatus);
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
const branchDiv = await getDivergence(currentBranch, devBranch);
|
|
970
|
-
const branchLine = formatStatus(currentBranch, devBranch, branchDiv.ahead, branchDiv.behind);
|
|
971
|
-
console.log(branchLine + pc7.dim(` (current ${pc7.green("*")})`));
|
|
1759
|
+
if (hasDevBranch(workflow) && config.devBranch) {
|
|
1760
|
+
const devRemoteRef = isContributor ? `${upstream}/${config.devBranch}` : `${origin}/${config.devBranch}`;
|
|
1761
|
+
const devDiv = await getDivergence(config.devBranch, devRemoteRef);
|
|
1762
|
+
const devLine = formatStatus(config.devBranch, devRemoteRef, devDiv.ahead, devDiv.behind);
|
|
1763
|
+
console.log(devLine);
|
|
1764
|
+
}
|
|
1765
|
+
if (currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
|
|
1766
|
+
const branchDiv = await getDivergence(currentBranch, baseBranch);
|
|
1767
|
+
const branchLine = formatStatus(currentBranch, baseBranch, branchDiv.ahead, branchDiv.behind);
|
|
1768
|
+
console.log(branchLine + pc9.dim(` (current ${pc9.green("*")})`));
|
|
972
1769
|
} else if (currentBranch) {
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1770
|
+
console.log(pc9.dim(` (on ${pc9.bold(currentBranch)} branch)`));
|
|
1771
|
+
}
|
|
1772
|
+
const hasFiles = fileStatus.staged.length > 0 || fileStatus.modified.length > 0 || fileStatus.untracked.length > 0;
|
|
1773
|
+
if (hasFiles) {
|
|
1774
|
+
console.log();
|
|
1775
|
+
if (fileStatus.staged.length > 0) {
|
|
1776
|
+
console.log(` ${pc9.green("Staged for commit:")}`);
|
|
1777
|
+
for (const { file, status } of fileStatus.staged) {
|
|
1778
|
+
console.log(` ${pc9.green("+")} ${pc9.dim(`${status}:`)} ${file}`);
|
|
1779
|
+
}
|
|
1780
|
+
}
|
|
1781
|
+
if (fileStatus.modified.length > 0) {
|
|
1782
|
+
console.log(` ${pc9.yellow("Unstaged changes:")}`);
|
|
1783
|
+
for (const { file, status } of fileStatus.modified) {
|
|
1784
|
+
console.log(` ${pc9.yellow("~")} ${pc9.dim(`${status}:`)} ${file}`);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
if (fileStatus.untracked.length > 0) {
|
|
1788
|
+
console.log(` ${pc9.red("Untracked files:")}`);
|
|
1789
|
+
for (const file of fileStatus.untracked) {
|
|
1790
|
+
console.log(` ${pc9.red("?")} ${file}`);
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
} else if (!dirty) {
|
|
1794
|
+
console.log(` ${pc9.green("✓")} ${pc9.dim("Working tree clean")}`);
|
|
1795
|
+
}
|
|
1796
|
+
const tips = [];
|
|
1797
|
+
if (fileStatus.staged.length > 0) {
|
|
1798
|
+
tips.push(`Run ${pc9.bold("contrib commit")} to commit staged changes`);
|
|
1799
|
+
}
|
|
1800
|
+
if (fileStatus.modified.length > 0 || fileStatus.untracked.length > 0) {
|
|
1801
|
+
tips.push(`Run ${pc9.bold("contrib commit")} to stage and commit changes`);
|
|
1802
|
+
}
|
|
1803
|
+
if (fileStatus.staged.length === 0 && fileStatus.modified.length === 0 && fileStatus.untracked.length === 0 && currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
|
|
1804
|
+
const branchDiv = await getDivergence(currentBranch, `${origin}/${currentBranch}`);
|
|
1805
|
+
if (branchDiv.ahead > 0) {
|
|
1806
|
+
tips.push(`Run ${pc9.bold("contrib submit")} to push and create/update your PR`);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
if (tips.length > 0) {
|
|
1810
|
+
console.log();
|
|
1811
|
+
console.log(` ${pc9.dim("\uD83D\uDCA1 Tip:")}`);
|
|
1812
|
+
for (const tip of tips) {
|
|
1813
|
+
console.log(` ${pc9.dim(tip)}`);
|
|
977
1814
|
}
|
|
978
1815
|
}
|
|
979
1816
|
console.log();
|
|
980
1817
|
}
|
|
981
1818
|
});
|
|
982
1819
|
function formatStatus(branch, base, ahead, behind) {
|
|
983
|
-
const label =
|
|
1820
|
+
const label = pc9.bold(branch.padEnd(20));
|
|
984
1821
|
if (ahead === 0 && behind === 0) {
|
|
985
|
-
return ` ${
|
|
1822
|
+
return ` ${pc9.green("✓")} ${label} ${pc9.dim(`in sync with ${base}`)}`;
|
|
986
1823
|
}
|
|
987
1824
|
if (ahead > 0 && behind === 0) {
|
|
988
|
-
return ` ${
|
|
1825
|
+
return ` ${pc9.yellow("↑")} ${label} ${pc9.yellow(`${ahead} commit${ahead !== 1 ? "s" : ""} ahead of ${base}`)}`;
|
|
989
1826
|
}
|
|
990
1827
|
if (behind > 0 && ahead === 0) {
|
|
991
|
-
return ` ${
|
|
1828
|
+
return ` ${pc9.red("↓")} ${label} ${pc9.red(`${behind} commit${behind !== 1 ? "s" : ""} behind ${base}`)}`;
|
|
992
1829
|
}
|
|
993
|
-
return ` ${
|
|
1830
|
+
return ` ${pc9.red("⚡")} ${label} ${pc9.yellow(`${ahead} ahead`)}${pc9.dim(", ")}${pc9.red(`${behind} behind`)} ${pc9.dim(base)}`;
|
|
994
1831
|
}
|
|
995
1832
|
|
|
996
1833
|
// src/commands/submit.ts
|
|
997
|
-
import { defineCommand as
|
|
998
|
-
import
|
|
999
|
-
|
|
1834
|
+
import { defineCommand as defineCommand7 } from "citty";
|
|
1835
|
+
import pc10 from "picocolors";
|
|
1836
|
+
async function performSquashMerge(origin, baseBranch, featureBranch, options) {
|
|
1837
|
+
info(`Checking out ${pc10.bold(baseBranch)}...`);
|
|
1838
|
+
const coResult = await checkoutBranch(baseBranch);
|
|
1839
|
+
if (coResult.exitCode !== 0) {
|
|
1840
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
1841
|
+
process.exit(1);
|
|
1842
|
+
}
|
|
1843
|
+
info(`Squash merging ${pc10.bold(featureBranch)} into ${pc10.bold(baseBranch)}...`);
|
|
1844
|
+
const mergeResult = await mergeSquash(featureBranch);
|
|
1845
|
+
if (mergeResult.exitCode !== 0) {
|
|
1846
|
+
error(`Squash merge failed: ${mergeResult.stderr}`);
|
|
1847
|
+
process.exit(1);
|
|
1848
|
+
}
|
|
1849
|
+
let message = options?.defaultMsg;
|
|
1850
|
+
if (!message) {
|
|
1851
|
+
const copilotError = await checkCopilotAvailable();
|
|
1852
|
+
if (!copilotError) {
|
|
1853
|
+
const spinner = createSpinner("Generating AI commit message for squash merge...");
|
|
1854
|
+
const [stagedDiff, stagedFiles] = await Promise.all([getStagedDiff(), getStagedFiles()]);
|
|
1855
|
+
const aiMsg = await generateCommitMessage(stagedDiff, stagedFiles, options?.model, options?.convention ?? "clean-commit");
|
|
1856
|
+
if (aiMsg) {
|
|
1857
|
+
message = aiMsg;
|
|
1858
|
+
spinner.success("AI commit message generated.");
|
|
1859
|
+
} else {
|
|
1860
|
+
spinner.fail("AI did not return a commit message.");
|
|
1861
|
+
}
|
|
1862
|
+
} else {
|
|
1863
|
+
warn(`AI unavailable: ${copilotError}`);
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
1866
|
+
const fallback = message || `squash merge ${featureBranch}`;
|
|
1867
|
+
const finalMsg = await inputPrompt("Commit message", fallback);
|
|
1868
|
+
const commitResult = await commitWithMessage(finalMsg);
|
|
1869
|
+
if (commitResult.exitCode !== 0) {
|
|
1870
|
+
error(`Commit failed: ${commitResult.stderr}`);
|
|
1871
|
+
process.exit(1);
|
|
1872
|
+
}
|
|
1873
|
+
info(`Pushing ${pc10.bold(baseBranch)} to ${origin}...`);
|
|
1874
|
+
const pushResult = await pushBranch(origin, baseBranch);
|
|
1875
|
+
if (pushResult.exitCode !== 0) {
|
|
1876
|
+
error(`Failed to push ${baseBranch}: ${pushResult.stderr}`);
|
|
1877
|
+
process.exit(1);
|
|
1878
|
+
}
|
|
1879
|
+
info(`Deleting local branch ${pc10.bold(featureBranch)}...`);
|
|
1880
|
+
const delLocal = await forceDeleteBranch(featureBranch);
|
|
1881
|
+
if (delLocal.exitCode !== 0) {
|
|
1882
|
+
warn(`Could not delete local branch: ${delLocal.stderr.trim()}`);
|
|
1883
|
+
}
|
|
1884
|
+
info(`Deleting remote branch ${pc10.bold(featureBranch)}...`);
|
|
1885
|
+
const delRemote = await deleteRemoteBranch(origin, featureBranch);
|
|
1886
|
+
if (delRemote.exitCode !== 0) {
|
|
1887
|
+
warn(`Could not delete remote branch: ${delRemote.stderr.trim()}`);
|
|
1888
|
+
}
|
|
1889
|
+
success(`✅ Squash merged ${pc10.bold(featureBranch)} into ${pc10.bold(baseBranch)} and pushed.`);
|
|
1890
|
+
info(`Run ${pc10.bold("contrib start")} to begin a new feature.`);
|
|
1891
|
+
}
|
|
1892
|
+
var submit_default = defineCommand7({
|
|
1000
1893
|
meta: {
|
|
1001
1894
|
name: "submit",
|
|
1002
1895
|
description: "Push current branch and create a pull request"
|
|
@@ -1027,18 +1920,20 @@ var submit_default = defineCommand6({
|
|
|
1027
1920
|
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
1028
1921
|
process.exit(1);
|
|
1029
1922
|
}
|
|
1030
|
-
const {
|
|
1923
|
+
const { origin } = config;
|
|
1924
|
+
const baseBranch = getBaseBranch(config);
|
|
1925
|
+
const protectedBranches = getProtectedBranches(config);
|
|
1031
1926
|
const currentBranch = await getCurrentBranch();
|
|
1032
1927
|
if (!currentBranch) {
|
|
1033
1928
|
error("Could not determine current branch.");
|
|
1034
1929
|
process.exit(1);
|
|
1035
1930
|
}
|
|
1036
|
-
if (currentBranch
|
|
1037
|
-
error(`Cannot submit ${
|
|
1931
|
+
if (protectedBranches.includes(currentBranch)) {
|
|
1932
|
+
error(`Cannot submit ${protectedBranches.map((b) => pc10.bold(b)).join(" or ")} as a PR. Switch to your feature branch.`);
|
|
1038
1933
|
process.exit(1);
|
|
1039
1934
|
}
|
|
1040
1935
|
heading("\uD83D\uDE80 contrib submit");
|
|
1041
|
-
info(`Pushing ${
|
|
1936
|
+
info(`Pushing ${pc10.bold(currentBranch)} to ${origin}...`);
|
|
1042
1937
|
const pushResult = await pushSetUpstream(origin, currentBranch);
|
|
1043
1938
|
if (pushResult.exitCode !== 0) {
|
|
1044
1939
|
error(`Failed to push: ${pushResult.stderr}`);
|
|
@@ -1049,53 +1944,80 @@ var submit_default = defineCommand6({
|
|
|
1049
1944
|
if (!ghInstalled || !ghAuthed) {
|
|
1050
1945
|
const repoInfo = await getRepoInfoFromRemote(origin);
|
|
1051
1946
|
if (repoInfo) {
|
|
1052
|
-
const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${
|
|
1947
|
+
const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${baseBranch}...${currentBranch}?expand=1`;
|
|
1053
1948
|
console.log();
|
|
1054
1949
|
info("Create your PR manually:");
|
|
1055
|
-
console.log(` ${
|
|
1950
|
+
console.log(` ${pc10.cyan(prUrl)}`);
|
|
1056
1951
|
} else {
|
|
1057
1952
|
info("gh CLI not available. Create your PR manually on GitHub.");
|
|
1058
1953
|
}
|
|
1059
1954
|
return;
|
|
1060
1955
|
}
|
|
1956
|
+
const existingPR = await getPRForBranch(currentBranch);
|
|
1957
|
+
if (existingPR) {
|
|
1958
|
+
success(`Pushed changes to existing PR #${existingPR.number}: ${pc10.bold(existingPR.title)}`);
|
|
1959
|
+
console.log(` ${pc10.cyan(existingPR.url)}`);
|
|
1960
|
+
return;
|
|
1961
|
+
}
|
|
1061
1962
|
let prTitle = null;
|
|
1062
1963
|
let prBody = null;
|
|
1063
1964
|
if (!args["no-ai"]) {
|
|
1064
|
-
const copilotError = await
|
|
1965
|
+
const [copilotError, commits, diff] = await Promise.all([
|
|
1966
|
+
checkCopilotAvailable(),
|
|
1967
|
+
getLog(baseBranch, "HEAD"),
|
|
1968
|
+
getLogDiff(baseBranch, "HEAD")
|
|
1969
|
+
]);
|
|
1065
1970
|
if (!copilotError) {
|
|
1066
|
-
|
|
1067
|
-
const
|
|
1068
|
-
const diff = await getLogDiff(devBranch, "HEAD");
|
|
1069
|
-
const result = await generatePRDescription(commits, diff, args.model);
|
|
1971
|
+
const spinner = createSpinner("Generating AI PR description...");
|
|
1972
|
+
const result = await generatePRDescription(commits, diff, args.model, config.commitConvention);
|
|
1070
1973
|
if (result) {
|
|
1071
1974
|
prTitle = result.title;
|
|
1072
1975
|
prBody = result.body;
|
|
1976
|
+
spinner.success("PR description generated.");
|
|
1073
1977
|
console.log(`
|
|
1074
|
-
${
|
|
1978
|
+
${pc10.dim("AI title:")} ${pc10.bold(pc10.cyan(prTitle))}`);
|
|
1075
1979
|
console.log(`
|
|
1076
|
-
${
|
|
1077
|
-
console.log(
|
|
1980
|
+
${pc10.dim("AI body preview:")}`);
|
|
1981
|
+
console.log(pc10.dim(prBody.slice(0, 300) + (prBody.length > 300 ? "..." : "")));
|
|
1078
1982
|
} else {
|
|
1079
|
-
|
|
1983
|
+
spinner.fail("AI did not return a PR description.");
|
|
1080
1984
|
}
|
|
1081
1985
|
} else {
|
|
1082
1986
|
warn(`AI unavailable: ${copilotError}`);
|
|
1083
1987
|
}
|
|
1084
1988
|
}
|
|
1989
|
+
const CANCEL = "Cancel";
|
|
1990
|
+
const SQUASH_LOCAL = `Squash merge to ${baseBranch} locally (no PR)`;
|
|
1085
1991
|
if (prTitle && prBody) {
|
|
1086
|
-
const
|
|
1992
|
+
const choices = [
|
|
1087
1993
|
"Use AI description",
|
|
1088
1994
|
"Edit title",
|
|
1089
1995
|
"Write manually",
|
|
1090
1996
|
"Use gh --fill (auto-fill from commits)"
|
|
1091
|
-
]
|
|
1997
|
+
];
|
|
1998
|
+
if (config.role === "maintainer")
|
|
1999
|
+
choices.push(SQUASH_LOCAL);
|
|
2000
|
+
choices.push(CANCEL);
|
|
2001
|
+
const action = await selectPrompt("What would you like to do with the PR description?", choices);
|
|
2002
|
+
if (action === CANCEL) {
|
|
2003
|
+
warn("Submit cancelled.");
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
if (action === SQUASH_LOCAL) {
|
|
2007
|
+
await performSquashMerge(origin, baseBranch, currentBranch, {
|
|
2008
|
+
defaultMsg: prTitle ?? undefined,
|
|
2009
|
+
model: args.model,
|
|
2010
|
+
convention: config.commitConvention
|
|
2011
|
+
});
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
1092
2014
|
if (action === "Use AI description") {} else if (action === "Edit title") {
|
|
1093
2015
|
prTitle = await inputPrompt("PR title", prTitle);
|
|
1094
2016
|
} else if (action === "Write manually") {
|
|
1095
2017
|
prTitle = await inputPrompt("PR title");
|
|
1096
2018
|
prBody = await inputPrompt("PR body (markdown)");
|
|
1097
2019
|
} else {
|
|
1098
|
-
const fillResult = await createPRFill(
|
|
2020
|
+
const fillResult = await createPRFill(baseBranch, args.draft);
|
|
1099
2021
|
if (fillResult.exitCode !== 0) {
|
|
1100
2022
|
error(`Failed to create PR: ${fillResult.stderr}`);
|
|
1101
2023
|
process.exit(1);
|
|
@@ -1104,12 +2026,30 @@ ${pc8.dim("AI body preview:")}`);
|
|
|
1104
2026
|
return;
|
|
1105
2027
|
}
|
|
1106
2028
|
} else {
|
|
1107
|
-
const
|
|
1108
|
-
|
|
2029
|
+
const choices = [
|
|
2030
|
+
"Write title & body manually",
|
|
2031
|
+
"Use gh --fill (auto-fill from commits)"
|
|
2032
|
+
];
|
|
2033
|
+
if (config.role === "maintainer")
|
|
2034
|
+
choices.push(SQUASH_LOCAL);
|
|
2035
|
+
choices.push(CANCEL);
|
|
2036
|
+
const action = await selectPrompt("How would you like to create the PR?", choices);
|
|
2037
|
+
if (action === CANCEL) {
|
|
2038
|
+
warn("Submit cancelled.");
|
|
2039
|
+
return;
|
|
2040
|
+
}
|
|
2041
|
+
if (action === SQUASH_LOCAL) {
|
|
2042
|
+
await performSquashMerge(origin, baseBranch, currentBranch, {
|
|
2043
|
+
model: args.model,
|
|
2044
|
+
convention: config.commitConvention
|
|
2045
|
+
});
|
|
2046
|
+
return;
|
|
2047
|
+
}
|
|
2048
|
+
if (action === "Write title & body manually") {
|
|
1109
2049
|
prTitle = await inputPrompt("PR title");
|
|
1110
2050
|
prBody = await inputPrompt("PR body (markdown)");
|
|
1111
2051
|
} else {
|
|
1112
|
-
const fillResult = await createPRFill(
|
|
2052
|
+
const fillResult = await createPRFill(baseBranch, args.draft);
|
|
1113
2053
|
if (fillResult.exitCode !== 0) {
|
|
1114
2054
|
error(`Failed to create PR: ${fillResult.stderr}`);
|
|
1115
2055
|
process.exit(1);
|
|
@@ -1123,7 +2063,7 @@ ${pc8.dim("AI body preview:")}`);
|
|
|
1123
2063
|
process.exit(1);
|
|
1124
2064
|
}
|
|
1125
2065
|
const prResult = await createPR({
|
|
1126
|
-
base:
|
|
2066
|
+
base: baseBranch,
|
|
1127
2067
|
title: prTitle,
|
|
1128
2068
|
body: prBody ?? "",
|
|
1129
2069
|
draft: args.draft
|
|
@@ -1137,12 +2077,12 @@ ${pc8.dim("AI body preview:")}`);
|
|
|
1137
2077
|
});
|
|
1138
2078
|
|
|
1139
2079
|
// src/commands/sync.ts
|
|
1140
|
-
import { defineCommand as
|
|
1141
|
-
import
|
|
1142
|
-
var sync_default =
|
|
2080
|
+
import { defineCommand as defineCommand8 } from "citty";
|
|
2081
|
+
import pc11 from "picocolors";
|
|
2082
|
+
var sync_default = defineCommand8({
|
|
1143
2083
|
meta: {
|
|
1144
2084
|
name: "sync",
|
|
1145
|
-
description: "
|
|
2085
|
+
description: "Sync your local branches with the remote"
|
|
1146
2086
|
},
|
|
1147
2087
|
args: {
|
|
1148
2088
|
yes: {
|
|
@@ -1162,86 +2102,70 @@ var sync_default = defineCommand7({
|
|
|
1162
2102
|
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
1163
2103
|
process.exit(1);
|
|
1164
2104
|
}
|
|
1165
|
-
const {
|
|
2105
|
+
const { workflow, role, origin } = config;
|
|
1166
2106
|
if (await hasUncommittedChanges()) {
|
|
1167
2107
|
error("You have uncommitted changes. Please commit or stash them before syncing.");
|
|
1168
2108
|
process.exit(1);
|
|
1169
2109
|
}
|
|
1170
|
-
heading(`\uD83D\uDD04 contrib sync (${role})`);
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
const ok = await confirmPrompt(`This will reset ${pc9.bold(devBranch)} to match ${pc9.bold(`${origin}/${mainBranch}`)}.`);
|
|
1186
|
-
if (!ok)
|
|
1187
|
-
process.exit(0);
|
|
1188
|
-
}
|
|
1189
|
-
const coResult = await checkoutBranch(devBranch);
|
|
1190
|
-
if (coResult.exitCode !== 0) {
|
|
1191
|
-
error(`Failed to checkout ${devBranch}: ${coResult.stderr}`);
|
|
1192
|
-
process.exit(1);
|
|
1193
|
-
}
|
|
1194
|
-
const resetResult = await resetHard(`${origin}/${mainBranch}`);
|
|
1195
|
-
if (resetResult.exitCode !== 0) {
|
|
1196
|
-
error(`Failed to reset: ${resetResult.stderr}`);
|
|
1197
|
-
process.exit(1);
|
|
1198
|
-
}
|
|
1199
|
-
const pushResult = await pushForceWithLease(origin, devBranch);
|
|
1200
|
-
if (pushResult.exitCode !== 0) {
|
|
1201
|
-
error(`Failed to push: ${pushResult.stderr}`);
|
|
1202
|
-
process.exit(1);
|
|
1203
|
-
}
|
|
1204
|
-
success(`✅ ${devBranch} has been reset to match ${origin}/${mainBranch} and pushed.`);
|
|
2110
|
+
heading(`\uD83D\uDD04 contrib sync (${workflow}, ${role})`);
|
|
2111
|
+
const baseBranch = getBaseBranch(config);
|
|
2112
|
+
const syncSource = getSyncSource(config);
|
|
2113
|
+
info(`Fetching ${syncSource.remote}...`);
|
|
2114
|
+
const fetchResult = await fetchRemote(syncSource.remote);
|
|
2115
|
+
if (fetchResult.exitCode !== 0) {
|
|
2116
|
+
error(`Failed to fetch ${syncSource.remote}: ${fetchResult.stderr}`);
|
|
2117
|
+
process.exit(1);
|
|
2118
|
+
}
|
|
2119
|
+
if (role === "contributor" && syncSource.remote !== origin) {
|
|
2120
|
+
await fetchRemote(origin);
|
|
2121
|
+
}
|
|
2122
|
+
const div = await getDivergence(baseBranch, syncSource.ref);
|
|
2123
|
+
if (div.ahead > 0 || div.behind > 0) {
|
|
2124
|
+
info(`${pc11.bold(baseBranch)} is ${pc11.yellow(`${div.ahead} ahead`)} and ${pc11.red(`${div.behind} behind`)} ${syncSource.ref}`);
|
|
1205
2125
|
} else {
|
|
1206
|
-
info(
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
2126
|
+
info(`${pc11.bold(baseBranch)} is already in sync with ${syncSource.ref}`);
|
|
2127
|
+
}
|
|
2128
|
+
if (!args.yes) {
|
|
2129
|
+
const ok = await confirmPrompt(`This will pull ${pc11.bold(syncSource.ref)} into local ${pc11.bold(baseBranch)}.`);
|
|
2130
|
+
if (!ok)
|
|
2131
|
+
process.exit(0);
|
|
2132
|
+
}
|
|
2133
|
+
const coResult = await checkoutBranch(baseBranch);
|
|
2134
|
+
if (coResult.exitCode !== 0) {
|
|
2135
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
2136
|
+
process.exit(1);
|
|
2137
|
+
}
|
|
2138
|
+
const pullResult = await pullBranch(syncSource.remote, baseBranch);
|
|
2139
|
+
if (pullResult.exitCode !== 0) {
|
|
2140
|
+
error(`Failed to pull: ${pullResult.stderr}`);
|
|
2141
|
+
process.exit(1);
|
|
2142
|
+
}
|
|
2143
|
+
success(`✅ ${baseBranch} is now in sync with ${syncSource.ref}`);
|
|
2144
|
+
if (hasDevBranch(workflow) && role === "maintainer") {
|
|
2145
|
+
const mainDiv = await getDivergence(config.mainBranch, `${origin}/${config.mainBranch}`);
|
|
2146
|
+
if (mainDiv.behind > 0) {
|
|
2147
|
+
info(`Also syncing ${pc11.bold(config.mainBranch)}...`);
|
|
2148
|
+
const mainCoResult = await checkoutBranch(config.mainBranch);
|
|
2149
|
+
if (mainCoResult.exitCode === 0) {
|
|
2150
|
+
const mainPullResult = await pullBranch(origin, config.mainBranch);
|
|
2151
|
+
if (mainPullResult.exitCode === 0) {
|
|
2152
|
+
success(`✅ ${config.mainBranch} is now in sync with ${origin}/${config.mainBranch}`);
|
|
2153
|
+
}
|
|
2154
|
+
}
|
|
2155
|
+
await checkoutBranch(baseBranch);
|
|
1231
2156
|
}
|
|
1232
|
-
success(`✅ ${devBranch} has been reset to match ${upstream}/${devBranch} and pushed.`);
|
|
1233
2157
|
}
|
|
1234
2158
|
}
|
|
1235
2159
|
});
|
|
1236
2160
|
|
|
1237
2161
|
// src/commands/update.ts
|
|
1238
|
-
import { readFileSync as
|
|
1239
|
-
import { defineCommand as
|
|
1240
|
-
import
|
|
1241
|
-
var update_default =
|
|
2162
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
2163
|
+
import { defineCommand as defineCommand9 } from "citty";
|
|
2164
|
+
import pc12 from "picocolors";
|
|
2165
|
+
var update_default = defineCommand9({
|
|
1242
2166
|
meta: {
|
|
1243
2167
|
name: "update",
|
|
1244
|
-
description: "Rebase current branch onto latest
|
|
2168
|
+
description: "Rebase current branch onto the latest base branch"
|
|
1245
2169
|
},
|
|
1246
2170
|
args: {
|
|
1247
2171
|
model: {
|
|
@@ -1264,14 +2188,16 @@ var update_default = defineCommand8({
|
|
|
1264
2188
|
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
1265
2189
|
process.exit(1);
|
|
1266
2190
|
}
|
|
1267
|
-
const
|
|
2191
|
+
const baseBranch = getBaseBranch(config);
|
|
2192
|
+
const protectedBranches = getProtectedBranches(config);
|
|
2193
|
+
const syncSource = getSyncSource(config);
|
|
1268
2194
|
const currentBranch = await getCurrentBranch();
|
|
1269
2195
|
if (!currentBranch) {
|
|
1270
2196
|
error("Could not determine current branch.");
|
|
1271
2197
|
process.exit(1);
|
|
1272
2198
|
}
|
|
1273
|
-
if (currentBranch
|
|
1274
|
-
error(`Use \`contrib sync\` to update ${
|
|
2199
|
+
if (protectedBranches.includes(currentBranch)) {
|
|
2200
|
+
error(`Use \`contrib sync\` to update ${protectedBranches.map((b) => pc12.bold(b)).join(" or ")} branches.`);
|
|
1275
2201
|
process.exit(1);
|
|
1276
2202
|
}
|
|
1277
2203
|
if (await hasUncommittedChanges()) {
|
|
@@ -1279,12 +2205,10 @@ var update_default = defineCommand8({
|
|
|
1279
2205
|
process.exit(1);
|
|
1280
2206
|
}
|
|
1281
2207
|
heading("\uD83D\uDD03 contrib update");
|
|
1282
|
-
info(`Updating ${
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
await
|
|
1286
|
-
await resetHard(remoteDevRef);
|
|
1287
|
-
const rebaseResult = await rebase(devBranch);
|
|
2208
|
+
info(`Updating ${pc12.bold(currentBranch)} with latest ${pc12.bold(baseBranch)}...`);
|
|
2209
|
+
await fetchRemote(syncSource.remote);
|
|
2210
|
+
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
2211
|
+
const rebaseResult = await rebase(baseBranch);
|
|
1288
2212
|
if (rebaseResult.exitCode !== 0) {
|
|
1289
2213
|
warn("Rebase hit conflicts. Resolve them manually.");
|
|
1290
2214
|
console.log();
|
|
@@ -1296,7 +2220,7 @@ var update_default = defineCommand8({
|
|
|
1296
2220
|
let conflictDiff = "";
|
|
1297
2221
|
for (const file of conflictFiles.slice(0, 3)) {
|
|
1298
2222
|
try {
|
|
1299
|
-
const content =
|
|
2223
|
+
const content = readFileSync4(file, "utf-8");
|
|
1300
2224
|
if (content.includes("<<<<<<<")) {
|
|
1301
2225
|
conflictDiff += `
|
|
1302
2226
|
--- ${file} ---
|
|
@@ -1306,37 +2230,80 @@ ${content.slice(0, 2000)}
|
|
|
1306
2230
|
} catch {}
|
|
1307
2231
|
}
|
|
1308
2232
|
if (conflictDiff) {
|
|
2233
|
+
const spinner = createSpinner("Analyzing conflicts with AI...");
|
|
1309
2234
|
const suggestion = await suggestConflictResolution(conflictDiff, args.model);
|
|
1310
2235
|
if (suggestion) {
|
|
2236
|
+
spinner.success("AI conflict guidance ready.");
|
|
1311
2237
|
console.log(`
|
|
1312
|
-
${
|
|
1313
|
-
console.log(
|
|
2238
|
+
${pc12.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
|
|
2239
|
+
console.log(pc12.dim("─".repeat(60)));
|
|
1314
2240
|
console.log(suggestion);
|
|
1315
|
-
console.log(
|
|
2241
|
+
console.log(pc12.dim("─".repeat(60)));
|
|
1316
2242
|
console.log();
|
|
2243
|
+
} else {
|
|
2244
|
+
spinner.fail("AI could not analyze the conflicts.");
|
|
1317
2245
|
}
|
|
1318
2246
|
}
|
|
1319
2247
|
}
|
|
1320
2248
|
}
|
|
1321
|
-
console.log(
|
|
2249
|
+
console.log(pc12.bold("To resolve:"));
|
|
1322
2250
|
console.log(` 1. Fix conflicts in the affected files`);
|
|
1323
|
-
console.log(` 2. ${
|
|
1324
|
-
console.log(` 3. ${
|
|
2251
|
+
console.log(` 2. ${pc12.cyan("git add <resolved-files>")}`);
|
|
2252
|
+
console.log(` 3. ${pc12.cyan("git rebase --continue")}`);
|
|
1325
2253
|
console.log();
|
|
1326
|
-
console.log(` Or abort: ${
|
|
2254
|
+
console.log(` Or abort: ${pc12.cyan("git rebase --abort")}`);
|
|
2255
|
+
process.exit(1);
|
|
2256
|
+
}
|
|
2257
|
+
success(`✅ ${pc12.bold(currentBranch)} has been rebased onto latest ${pc12.bold(baseBranch)}`);
|
|
2258
|
+
}
|
|
2259
|
+
});
|
|
2260
|
+
|
|
2261
|
+
// src/commands/validate.ts
|
|
2262
|
+
import { defineCommand as defineCommand10 } from "citty";
|
|
2263
|
+
import pc13 from "picocolors";
|
|
2264
|
+
var validate_default = defineCommand10({
|
|
2265
|
+
meta: {
|
|
2266
|
+
name: "validate",
|
|
2267
|
+
description: "Validate a commit message against the configured convention"
|
|
2268
|
+
},
|
|
2269
|
+
args: {
|
|
2270
|
+
message: {
|
|
2271
|
+
type: "positional",
|
|
2272
|
+
description: "The commit message to validate",
|
|
2273
|
+
required: true
|
|
2274
|
+
}
|
|
2275
|
+
},
|
|
2276
|
+
async run({ args }) {
|
|
2277
|
+
const config = readConfig();
|
|
2278
|
+
if (!config) {
|
|
2279
|
+
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
1327
2280
|
process.exit(1);
|
|
1328
2281
|
}
|
|
1329
|
-
|
|
2282
|
+
const convention = config.commitConvention;
|
|
2283
|
+
if (convention === "none") {
|
|
2284
|
+
info('Commit convention is set to "none". All messages are accepted.');
|
|
2285
|
+
process.exit(0);
|
|
2286
|
+
}
|
|
2287
|
+
const message = args.message;
|
|
2288
|
+
if (validateCommitMessage(message, convention)) {
|
|
2289
|
+
success(`Valid ${CONVENTION_LABELS[convention]} message.`);
|
|
2290
|
+
process.exit(0);
|
|
2291
|
+
}
|
|
2292
|
+
const errors = getValidationError(convention);
|
|
2293
|
+
for (const line of errors) {
|
|
2294
|
+
console.error(pc13.red(` ✗ ${line}`));
|
|
2295
|
+
}
|
|
2296
|
+
process.exit(1);
|
|
1330
2297
|
}
|
|
1331
2298
|
});
|
|
1332
2299
|
|
|
1333
2300
|
// src/ui/banner.ts
|
|
1334
2301
|
import figlet from "figlet";
|
|
1335
|
-
import
|
|
2302
|
+
import pc14 from "picocolors";
|
|
1336
2303
|
// package.json
|
|
1337
2304
|
var package_default = {
|
|
1338
2305
|
name: "contribute-now",
|
|
1339
|
-
version: "0.
|
|
2306
|
+
version: "0.2.0-dev.d4b7ede",
|
|
1340
2307
|
description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
|
|
1341
2308
|
type: "module",
|
|
1342
2309
|
bin: {
|
|
@@ -1348,12 +2315,12 @@ var package_default = {
|
|
|
1348
2315
|
],
|
|
1349
2316
|
scripts: {
|
|
1350
2317
|
build: "bun build src/index.ts --outfile dist/index.js --target node --packages external",
|
|
2318
|
+
cli: "bun run src/index.ts --",
|
|
1351
2319
|
dev: "bun src/index.ts",
|
|
1352
2320
|
test: "bun test",
|
|
1353
2321
|
lint: "biome check .",
|
|
1354
2322
|
"lint:fix": "biome check --write .",
|
|
1355
2323
|
format: "biome format --write .",
|
|
1356
|
-
prepare: "husky || true",
|
|
1357
2324
|
"www:dev": "bun run --cwd www dev",
|
|
1358
2325
|
"www:build": "bun run --cwd www build",
|
|
1359
2326
|
"www:preview": "bun run --cwd www preview"
|
|
@@ -1380,6 +2347,7 @@ var package_default = {
|
|
|
1380
2347
|
url: "git+https://github.com/warengonzaga/contribute-now.git"
|
|
1381
2348
|
},
|
|
1382
2349
|
dependencies: {
|
|
2350
|
+
"@clack/prompts": "^1.0.1",
|
|
1383
2351
|
"@github/copilot-sdk": "^0.1.25",
|
|
1384
2352
|
"@wgtechlabs/log-engine": "^2.3.1",
|
|
1385
2353
|
citty: "^0.1.6",
|
|
@@ -1390,7 +2358,6 @@ var package_default = {
|
|
|
1390
2358
|
"@biomejs/biome": "^2.4.4",
|
|
1391
2359
|
"@types/bun": "latest",
|
|
1392
2360
|
"@types/figlet": "^1.7.0",
|
|
1393
|
-
husky: "^9.1.7",
|
|
1394
2361
|
typescript: "^5.7.0"
|
|
1395
2362
|
}
|
|
1396
2363
|
};
|
|
@@ -1398,9 +2365,10 @@ var package_default = {
|
|
|
1398
2365
|
// src/ui/banner.ts
|
|
1399
2366
|
var LOGO;
|
|
1400
2367
|
try {
|
|
1401
|
-
LOGO = figlet.textSync(
|
|
2368
|
+
LOGO = figlet.textSync(`Contribute
|
|
2369
|
+
Now`, { font: "ANSI Shadow" });
|
|
1402
2370
|
} catch {
|
|
1403
|
-
LOGO = "
|
|
2371
|
+
LOGO = "Contribute Now";
|
|
1404
2372
|
}
|
|
1405
2373
|
function getVersion() {
|
|
1406
2374
|
return package_default.version ?? "unknown";
|
|
@@ -1408,16 +2376,15 @@ function getVersion() {
|
|
|
1408
2376
|
function getAuthor() {
|
|
1409
2377
|
return typeof package_default.author === "string" ? package_default.author : "unknown";
|
|
1410
2378
|
}
|
|
1411
|
-
function showBanner(
|
|
1412
|
-
console.log(
|
|
2379
|
+
function showBanner(showLinks = false) {
|
|
2380
|
+
console.log(pc14.cyan(`
|
|
1413
2381
|
${LOGO}`));
|
|
1414
|
-
console.log(` ${
|
|
1415
|
-
if (
|
|
1416
|
-
console.log(` ${pc11.dim(package_default.description)}`);
|
|
2382
|
+
console.log(` ${pc14.dim(`v${getVersion()}`)} ${pc14.dim("—")} ${pc14.dim(`Built by ${getAuthor()}`)}`);
|
|
2383
|
+
if (showLinks) {
|
|
1417
2384
|
console.log();
|
|
1418
|
-
console.log(` ${
|
|
1419
|
-
console.log(` ${
|
|
1420
|
-
console.log(` ${
|
|
2385
|
+
console.log(` ${pc14.yellow("Star")} ${pc14.cyan("https://github.com/warengonzaga/contribute-now")}`);
|
|
2386
|
+
console.log(` ${pc14.green("Contribute")} ${pc14.cyan("https://github.com/warengonzaga/contribute-now/blob/main/CONTRIBUTING.md")}`);
|
|
2387
|
+
console.log(` ${pc14.magenta("Sponsor")} ${pc14.cyan("https://warengonzaga.com/sponsor")}`);
|
|
1421
2388
|
}
|
|
1422
2389
|
console.log();
|
|
1423
2390
|
}
|
|
@@ -1425,11 +2392,11 @@ ${LOGO}`));
|
|
|
1425
2392
|
// src/index.ts
|
|
1426
2393
|
var isHelp = process.argv.includes("--help") || process.argv.includes("-h");
|
|
1427
2394
|
showBanner(isHelp);
|
|
1428
|
-
var main =
|
|
2395
|
+
var main = defineCommand11({
|
|
1429
2396
|
meta: {
|
|
1430
2397
|
name: "contrib",
|
|
1431
2398
|
version: getVersion(),
|
|
1432
|
-
description: "Git workflow CLI
|
|
2399
|
+
description: "Git workflow CLI that guides contributors through clean branching, commits, and PRs."
|
|
1433
2400
|
},
|
|
1434
2401
|
args: {
|
|
1435
2402
|
version: {
|
|
@@ -1446,7 +2413,9 @@ var main = defineCommand9({
|
|
|
1446
2413
|
update: update_default,
|
|
1447
2414
|
submit: submit_default,
|
|
1448
2415
|
clean: clean_default,
|
|
1449
|
-
status: status_default
|
|
2416
|
+
status: status_default,
|
|
2417
|
+
hook: hook_default,
|
|
2418
|
+
validate: validate_default
|
|
1450
2419
|
},
|
|
1451
2420
|
run({ args }) {
|
|
1452
2421
|
if (args.version) {
|
|
@@ -1454,4 +2423,6 @@ var main = defineCommand9({
|
|
|
1454
2423
|
}
|
|
1455
2424
|
}
|
|
1456
2425
|
});
|
|
1457
|
-
runMain(main)
|
|
2426
|
+
runMain(main).then(() => {
|
|
2427
|
+
process.exit(0);
|
|
2428
|
+
});
|