contribute-now 0.2.0-dev.e0cfab8 → 0.2.0-dev.f9ae5fc
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/dist/index.js +2740 -712
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { createRequire } from "node:module";
|
|
3
|
+
var __require = /* @__PURE__ */ createRequire(import.meta.url);
|
|
2
4
|
|
|
3
5
|
// src/index.ts
|
|
4
|
-
import { defineCommand as
|
|
6
|
+
import { defineCommand as defineCommand14, runMain } from "citty";
|
|
5
7
|
|
|
6
|
-
// src/commands/
|
|
8
|
+
// src/commands/branch.ts
|
|
7
9
|
import { defineCommand } from "citty";
|
|
8
|
-
import
|
|
10
|
+
import pc2 from "picocolors";
|
|
9
11
|
|
|
10
12
|
// src/utils/config.ts
|
|
11
13
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -14,13 +16,20 @@ var CONFIG_FILENAME = ".contributerc.json";
|
|
|
14
16
|
function getConfigPath(cwd = process.cwd()) {
|
|
15
17
|
return join(cwd, CONFIG_FILENAME);
|
|
16
18
|
}
|
|
19
|
+
function configExists(cwd = process.cwd()) {
|
|
20
|
+
return existsSync(getConfigPath(cwd));
|
|
21
|
+
}
|
|
17
22
|
function readConfig(cwd = process.cwd()) {
|
|
18
23
|
const path = getConfigPath(cwd);
|
|
19
24
|
if (!existsSync(path))
|
|
20
25
|
return null;
|
|
21
26
|
try {
|
|
22
27
|
const raw = readFileSync(path, "utf-8");
|
|
23
|
-
|
|
28
|
+
const parsed = JSON.parse(raw);
|
|
29
|
+
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") {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return parsed;
|
|
24
33
|
} catch {
|
|
25
34
|
return null;
|
|
26
35
|
}
|
|
@@ -55,69 +64,15 @@ function getDefaultConfig() {
|
|
|
55
64
|
};
|
|
56
65
|
}
|
|
57
66
|
|
|
58
|
-
// src/utils/confirm.ts
|
|
59
|
-
import pc from "picocolors";
|
|
60
|
-
async function confirmPrompt(message) {
|
|
61
|
-
console.log(`
|
|
62
|
-
${message}`);
|
|
63
|
-
process.stdout.write(`${pc.dim("Continue? [y/N] ")}`);
|
|
64
|
-
const response = await new Promise((resolve) => {
|
|
65
|
-
process.stdin.setEncoding("utf-8");
|
|
66
|
-
process.stdin.once("data", (data) => {
|
|
67
|
-
process.stdin.pause();
|
|
68
|
-
resolve(data.toString().trim());
|
|
69
|
-
});
|
|
70
|
-
process.stdin.resume();
|
|
71
|
-
});
|
|
72
|
-
if (response.toLowerCase() !== "y") {
|
|
73
|
-
console.log(pc.yellow("Aborted."));
|
|
74
|
-
return false;
|
|
75
|
-
}
|
|
76
|
-
return true;
|
|
77
|
-
}
|
|
78
|
-
async function selectPrompt(message, choices) {
|
|
79
|
-
console.log(`
|
|
80
|
-
${message}`);
|
|
81
|
-
choices.forEach((choice, i) => {
|
|
82
|
-
console.log(` ${pc.dim(`${i + 1}.`)} ${choice}`);
|
|
83
|
-
});
|
|
84
|
-
process.stdout.write(pc.dim(`Enter number [1-${choices.length}]: `));
|
|
85
|
-
const response = await new Promise((resolve) => {
|
|
86
|
-
process.stdin.setEncoding("utf-8");
|
|
87
|
-
process.stdin.once("data", (data) => {
|
|
88
|
-
process.stdin.pause();
|
|
89
|
-
resolve(data.toString().trim());
|
|
90
|
-
});
|
|
91
|
-
process.stdin.resume();
|
|
92
|
-
});
|
|
93
|
-
const index = Number.parseInt(response, 10) - 1;
|
|
94
|
-
if (index >= 0 && index < choices.length) {
|
|
95
|
-
return choices[index];
|
|
96
|
-
}
|
|
97
|
-
return choices[0];
|
|
98
|
-
}
|
|
99
|
-
async function inputPrompt(message, defaultValue) {
|
|
100
|
-
const hint = defaultValue ? ` ${pc.dim(`[${defaultValue}]`)}` : "";
|
|
101
|
-
process.stdout.write(`
|
|
102
|
-
${message}${hint}: `);
|
|
103
|
-
const response = await new Promise((resolve) => {
|
|
104
|
-
process.stdin.setEncoding("utf-8");
|
|
105
|
-
process.stdin.once("data", (data) => {
|
|
106
|
-
process.stdin.pause();
|
|
107
|
-
resolve(data.toString().trim());
|
|
108
|
-
});
|
|
109
|
-
process.stdin.resume();
|
|
110
|
-
});
|
|
111
|
-
return response || defaultValue || "";
|
|
112
|
-
}
|
|
113
|
-
|
|
114
67
|
// src/utils/git.ts
|
|
115
68
|
import { execFile as execFileCb } from "node:child_process";
|
|
69
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
70
|
+
import { join as join2 } from "node:path";
|
|
116
71
|
function run(args) {
|
|
117
72
|
return new Promise((resolve) => {
|
|
118
73
|
execFileCb("git", args, (error, stdout, stderr) => {
|
|
119
74
|
resolve({
|
|
120
|
-
exitCode: error ? error.code === "ENOENT" ? 127 : error.
|
|
75
|
+
exitCode: error ? error.code === "ENOENT" ? 127 : error.status ?? 1 : 0,
|
|
121
76
|
stdout: stdout ?? "",
|
|
122
77
|
stderr: stderr ?? ""
|
|
123
78
|
});
|
|
@@ -159,7 +114,7 @@ async function fetchRemote(remote) {
|
|
|
159
114
|
async function fetchAll() {
|
|
160
115
|
return run(["fetch", "--all", "--quiet"]);
|
|
161
116
|
}
|
|
162
|
-
async function
|
|
117
|
+
async function checkoutBranch2(branch) {
|
|
163
118
|
return run(["checkout", branch]);
|
|
164
119
|
}
|
|
165
120
|
async function createBranch(branch, from) {
|
|
@@ -169,12 +124,74 @@ async function createBranch(branch, from) {
|
|
|
169
124
|
async function resetHard(ref) {
|
|
170
125
|
return run(["reset", "--hard", ref]);
|
|
171
126
|
}
|
|
127
|
+
async function updateLocalBranch(branch, target) {
|
|
128
|
+
const current = await getCurrentBranch();
|
|
129
|
+
if (current === branch) {
|
|
130
|
+
return resetHard(target);
|
|
131
|
+
}
|
|
132
|
+
return run(["branch", "-f", branch, target]);
|
|
133
|
+
}
|
|
172
134
|
async function pushSetUpstream(remote, branch) {
|
|
173
135
|
return run(["push", "-u", remote, branch]);
|
|
174
136
|
}
|
|
175
137
|
async function rebase(branch) {
|
|
176
138
|
return run(["rebase", branch]);
|
|
177
139
|
}
|
|
140
|
+
async function getUpstreamRef() {
|
|
141
|
+
const { exitCode, stdout } = await run([
|
|
142
|
+
"rev-parse",
|
|
143
|
+
"--abbrev-ref",
|
|
144
|
+
"--symbolic-full-name",
|
|
145
|
+
"@{u}"
|
|
146
|
+
]);
|
|
147
|
+
if (exitCode !== 0)
|
|
148
|
+
return null;
|
|
149
|
+
return stdout.trim() || null;
|
|
150
|
+
}
|
|
151
|
+
async function unsetUpstream() {
|
|
152
|
+
return run(["branch", "--unset-upstream"]);
|
|
153
|
+
}
|
|
154
|
+
async function rebaseOnto(newBase, oldBase) {
|
|
155
|
+
return run(["rebase", "--onto", newBase, oldBase]);
|
|
156
|
+
}
|
|
157
|
+
async function getMergeBase(ref1, ref2) {
|
|
158
|
+
const { exitCode, stdout } = await run(["merge-base", ref1, ref2]);
|
|
159
|
+
if (exitCode !== 0)
|
|
160
|
+
return null;
|
|
161
|
+
return stdout.trim() || null;
|
|
162
|
+
}
|
|
163
|
+
async function getCommitHash(ref) {
|
|
164
|
+
const { exitCode, stdout } = await run(["rev-parse", ref]);
|
|
165
|
+
if (exitCode !== 0)
|
|
166
|
+
return null;
|
|
167
|
+
return stdout.trim() || null;
|
|
168
|
+
}
|
|
169
|
+
async function determineRebaseStrategy(currentBranch, syncRef) {
|
|
170
|
+
const upstreamRef = await getUpstreamRef();
|
|
171
|
+
if (!upstreamRef) {
|
|
172
|
+
return { strategy: "plain" };
|
|
173
|
+
}
|
|
174
|
+
const upstreamHash = await getCommitHash(upstreamRef);
|
|
175
|
+
if (!upstreamHash) {
|
|
176
|
+
return { strategy: "plain" };
|
|
177
|
+
}
|
|
178
|
+
const slashIdx = upstreamRef.indexOf("/");
|
|
179
|
+
const upstreamBranchName = slashIdx !== -1 ? upstreamRef.slice(slashIdx + 1) : upstreamRef;
|
|
180
|
+
if (upstreamBranchName === currentBranch) {
|
|
181
|
+
return { strategy: "plain" };
|
|
182
|
+
}
|
|
183
|
+
const [forkFromUpstream, forkFromSync] = await Promise.all([
|
|
184
|
+
getMergeBase("HEAD", upstreamRef),
|
|
185
|
+
getMergeBase("HEAD", syncRef)
|
|
186
|
+
]);
|
|
187
|
+
if (forkFromUpstream && forkFromSync && forkFromUpstream === forkFromSync) {
|
|
188
|
+
return { strategy: "plain" };
|
|
189
|
+
}
|
|
190
|
+
if (forkFromUpstream) {
|
|
191
|
+
return { strategy: "onto", ontoOldBase: forkFromUpstream };
|
|
192
|
+
}
|
|
193
|
+
return { strategy: "plain" };
|
|
194
|
+
}
|
|
178
195
|
async function getStagedDiff() {
|
|
179
196
|
const { stdout } = await run(["diff", "--cached"]);
|
|
180
197
|
return stdout;
|
|
@@ -190,8 +207,16 @@ async function getChangedFiles() {
|
|
|
190
207
|
const { exitCode, stdout } = await run(["status", "--porcelain"]);
|
|
191
208
|
if (exitCode !== 0)
|
|
192
209
|
return [];
|
|
193
|
-
return stdout.
|
|
194
|
-
`).filter(Boolean).map((l) =>
|
|
210
|
+
return stdout.trimEnd().split(`
|
|
211
|
+
`).filter(Boolean).map((l) => {
|
|
212
|
+
const line = l.replace(/\r$/, "");
|
|
213
|
+
const match = line.match(/^..\s+(.*)/);
|
|
214
|
+
if (!match)
|
|
215
|
+
return "";
|
|
216
|
+
const file = match[1];
|
|
217
|
+
const renameIdx = file.indexOf(" -> ");
|
|
218
|
+
return renameIdx !== -1 ? file.slice(renameIdx + 4) : file;
|
|
219
|
+
}).filter(Boolean);
|
|
195
220
|
}
|
|
196
221
|
async function getDivergence(branch, base) {
|
|
197
222
|
const { exitCode, stdout } = await run([
|
|
@@ -215,9 +240,38 @@ async function getMergedBranches(base) {
|
|
|
215
240
|
return stdout.trim().split(`
|
|
216
241
|
`).map((b) => b.replace(/^\*?\s+/, "").trim()).filter(Boolean);
|
|
217
242
|
}
|
|
243
|
+
async function getGoneBranches() {
|
|
244
|
+
const { exitCode, stdout } = await run(["branch", "-vv"]);
|
|
245
|
+
if (exitCode !== 0)
|
|
246
|
+
return [];
|
|
247
|
+
return stdout.trimEnd().split(`
|
|
248
|
+
`).filter((line) => line.includes(": gone]")).map((line) => line.replace(/^\*?\s+/, "").split(/\s+/)[0]).filter(Boolean);
|
|
249
|
+
}
|
|
218
250
|
async function deleteBranch(branch) {
|
|
219
251
|
return run(["branch", "-d", branch]);
|
|
220
252
|
}
|
|
253
|
+
async function forceDeleteBranch(branch) {
|
|
254
|
+
return run(["branch", "-D", branch]);
|
|
255
|
+
}
|
|
256
|
+
async function renameBranch(oldName, newName) {
|
|
257
|
+
return run(["branch", "-m", oldName, newName]);
|
|
258
|
+
}
|
|
259
|
+
async function hasLocalWork(remote, branch) {
|
|
260
|
+
const uncommitted = await hasUncommittedChanges();
|
|
261
|
+
const trackingRef = `${remote}/${branch}`;
|
|
262
|
+
const { exitCode, stdout } = await run(["rev-list", "--count", `${trackingRef}..${branch}`]);
|
|
263
|
+
const unpushedCommits = exitCode === 0 ? Number.parseInt(stdout.trim(), 10) || 0 : 0;
|
|
264
|
+
return { uncommitted, unpushedCommits };
|
|
265
|
+
}
|
|
266
|
+
async function deleteRemoteBranch(remote, branch) {
|
|
267
|
+
return run(["push", remote, "--delete", branch]);
|
|
268
|
+
}
|
|
269
|
+
async function mergeSquash(branch) {
|
|
270
|
+
return run(["merge", "--squash", branch]);
|
|
271
|
+
}
|
|
272
|
+
async function pushBranch(remote, branch) {
|
|
273
|
+
return run(["push", remote, branch]);
|
|
274
|
+
}
|
|
221
275
|
async function pruneRemote(remote) {
|
|
222
276
|
return run(["remote", "prune", remote]);
|
|
223
277
|
}
|
|
@@ -238,10 +292,161 @@ async function getLog(base, head) {
|
|
|
238
292
|
async function pullBranch(remote, branch) {
|
|
239
293
|
return run(["pull", remote, branch]);
|
|
240
294
|
}
|
|
295
|
+
async function stageFiles(files) {
|
|
296
|
+
return run(["add", "--", ...files]);
|
|
297
|
+
}
|
|
298
|
+
async function unstageFiles(files) {
|
|
299
|
+
return run(["reset", "HEAD", "--", ...files]);
|
|
300
|
+
}
|
|
301
|
+
async function stageAll() {
|
|
302
|
+
return run(["add", "-A"]);
|
|
303
|
+
}
|
|
304
|
+
async function getFullDiffForFiles(files) {
|
|
305
|
+
const [unstaged, staged, untracked] = await Promise.all([
|
|
306
|
+
run(["diff", "--", ...files]),
|
|
307
|
+
run(["diff", "--cached", "--", ...files]),
|
|
308
|
+
getUntrackedFiles()
|
|
309
|
+
]);
|
|
310
|
+
const parts = [staged.stdout, unstaged.stdout].filter(Boolean);
|
|
311
|
+
const untrackedSet = new Set(untracked);
|
|
312
|
+
const MAX_FILE_CONTENT = 2000;
|
|
313
|
+
for (const file of files) {
|
|
314
|
+
if (untrackedSet.has(file)) {
|
|
315
|
+
try {
|
|
316
|
+
const content = readFileSync2(join2(process.cwd(), file), "utf-8");
|
|
317
|
+
const truncated = content.length > MAX_FILE_CONTENT ? `${content.slice(0, MAX_FILE_CONTENT)}
|
|
318
|
+
... (truncated)` : content;
|
|
319
|
+
const lines = truncated.split(`
|
|
320
|
+
`).map((l) => `+${l}`);
|
|
321
|
+
parts.push(`diff --git a/${file} b/${file}
|
|
322
|
+
new file
|
|
323
|
+
--- /dev/null
|
|
324
|
+
+++ b/${file}
|
|
325
|
+
${lines.join(`
|
|
326
|
+
`)}`);
|
|
327
|
+
} catch {}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return parts.join(`
|
|
331
|
+
`);
|
|
332
|
+
}
|
|
333
|
+
async function getUntrackedFiles() {
|
|
334
|
+
const { exitCode, stdout } = await run(["ls-files", "--others", "--exclude-standard"]);
|
|
335
|
+
if (exitCode !== 0)
|
|
336
|
+
return [];
|
|
337
|
+
return stdout.trim().split(`
|
|
338
|
+
`).filter(Boolean);
|
|
339
|
+
}
|
|
340
|
+
async function getFileStatus() {
|
|
341
|
+
const { exitCode, stdout } = await run(["status", "--porcelain"]);
|
|
342
|
+
if (exitCode !== 0)
|
|
343
|
+
return { staged: [], modified: [], untracked: [] };
|
|
344
|
+
const result = { staged: [], modified: [], untracked: [] };
|
|
345
|
+
const STATUS_LABELS = {
|
|
346
|
+
A: "new file",
|
|
347
|
+
M: "modified",
|
|
348
|
+
D: "deleted",
|
|
349
|
+
R: "renamed",
|
|
350
|
+
C: "copied",
|
|
351
|
+
T: "type changed"
|
|
352
|
+
};
|
|
353
|
+
for (const raw of stdout.trimEnd().split(`
|
|
354
|
+
`).filter(Boolean)) {
|
|
355
|
+
const line = raw.replace(/\r$/, "");
|
|
356
|
+
const indexStatus = line[0];
|
|
357
|
+
const workTreeStatus = line[1];
|
|
358
|
+
const pathPart = line.slice(3);
|
|
359
|
+
const renameIdx = pathPart.indexOf(" -> ");
|
|
360
|
+
const file = renameIdx !== -1 ? pathPart.slice(renameIdx + 4) : pathPart;
|
|
361
|
+
if (indexStatus === "?" && workTreeStatus === "?") {
|
|
362
|
+
result.untracked.push(file);
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
if (indexStatus && indexStatus !== " " && indexStatus !== "?") {
|
|
366
|
+
result.staged.push({ file, status: STATUS_LABELS[indexStatus] ?? indexStatus });
|
|
367
|
+
}
|
|
368
|
+
if (workTreeStatus && workTreeStatus !== " " && workTreeStatus !== "?") {
|
|
369
|
+
result.modified.push({ file, status: STATUS_LABELS[workTreeStatus] ?? workTreeStatus });
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return result;
|
|
373
|
+
}
|
|
374
|
+
async function getLogGraph(options) {
|
|
375
|
+
const count = options?.count ?? 20;
|
|
376
|
+
const args = [
|
|
377
|
+
"log",
|
|
378
|
+
"--oneline",
|
|
379
|
+
"--graph",
|
|
380
|
+
"--decorate",
|
|
381
|
+
`--max-count=${count}`,
|
|
382
|
+
"--color=never"
|
|
383
|
+
];
|
|
384
|
+
if (options?.all) {
|
|
385
|
+
args.push("--all");
|
|
386
|
+
}
|
|
387
|
+
if (options?.branch) {
|
|
388
|
+
args.push(options.branch);
|
|
389
|
+
}
|
|
390
|
+
const { exitCode, stdout } = await run(args);
|
|
391
|
+
if (exitCode !== 0)
|
|
392
|
+
return [];
|
|
393
|
+
return stdout.trimEnd().split(`
|
|
394
|
+
`);
|
|
395
|
+
}
|
|
396
|
+
async function getLogEntries(options) {
|
|
397
|
+
const count = options?.count ?? 20;
|
|
398
|
+
const args = [
|
|
399
|
+
"log",
|
|
400
|
+
`--format=%h||%s||%D`,
|
|
401
|
+
`--max-count=${count}`
|
|
402
|
+
];
|
|
403
|
+
if (options?.all) {
|
|
404
|
+
args.push("--all");
|
|
405
|
+
}
|
|
406
|
+
if (options?.branch) {
|
|
407
|
+
args.push(options.branch);
|
|
408
|
+
}
|
|
409
|
+
const { exitCode, stdout } = await run(args);
|
|
410
|
+
if (exitCode !== 0)
|
|
411
|
+
return [];
|
|
412
|
+
return stdout.trimEnd().split(`
|
|
413
|
+
`).filter(Boolean).map((line) => {
|
|
414
|
+
const [hash = "", subject = "", refs = ""] = line.split("||");
|
|
415
|
+
return { hash: hash.trim(), subject: subject.trim(), refs: refs.trim() };
|
|
416
|
+
});
|
|
417
|
+
}
|
|
418
|
+
async function getLocalBranches() {
|
|
419
|
+
const { exitCode, stdout } = await run(["branch", "-vv", "--no-color"]);
|
|
420
|
+
if (exitCode !== 0)
|
|
421
|
+
return [];
|
|
422
|
+
return stdout.trimEnd().split(`
|
|
423
|
+
`).filter(Boolean).map((line) => {
|
|
424
|
+
const isCurrent = line.startsWith("*");
|
|
425
|
+
const trimmed = line.slice(2);
|
|
426
|
+
const nameMatch = trimmed.match(/^(\S+)/);
|
|
427
|
+
const name = nameMatch?.[1] ?? "";
|
|
428
|
+
const upstreamMatch = trimmed.match(/\[([^\]]+)\]/);
|
|
429
|
+
let upstream = null;
|
|
430
|
+
let gone = false;
|
|
431
|
+
if (upstreamMatch) {
|
|
432
|
+
const bracketContent = upstreamMatch[1];
|
|
433
|
+
gone = bracketContent.includes(": gone");
|
|
434
|
+
upstream = bracketContent.split(":")[0].trim();
|
|
435
|
+
}
|
|
436
|
+
return { name, isCurrent, upstream, gone };
|
|
437
|
+
}).filter((b) => b.name.length > 0);
|
|
438
|
+
}
|
|
439
|
+
async function getRemoteBranches() {
|
|
440
|
+
const { exitCode, stdout } = await run(["branch", "-r", "--no-color"]);
|
|
441
|
+
if (exitCode !== 0)
|
|
442
|
+
return [];
|
|
443
|
+
return stdout.trimEnd().split(`
|
|
444
|
+
`).map((line) => line.trim()).filter((line) => line.length > 0 && !line.includes(" -> "));
|
|
445
|
+
}
|
|
241
446
|
|
|
242
447
|
// src/utils/logger.ts
|
|
243
448
|
import { LogEngine, LogMode } from "@wgtechlabs/log-engine";
|
|
244
|
-
import
|
|
449
|
+
import pc from "picocolors";
|
|
245
450
|
LogEngine.configure({
|
|
246
451
|
mode: LogMode.INFO,
|
|
247
452
|
format: {
|
|
@@ -264,7 +469,7 @@ function info(msg) {
|
|
|
264
469
|
}
|
|
265
470
|
function heading(msg) {
|
|
266
471
|
console.log(`
|
|
267
|
-
${
|
|
472
|
+
${pc.bold(msg)}`);
|
|
268
473
|
}
|
|
269
474
|
|
|
270
475
|
// src/utils/workflow.ts
|
|
@@ -314,17 +519,23 @@ function getProtectedBranches(config) {
|
|
|
314
519
|
return branches;
|
|
315
520
|
}
|
|
316
521
|
|
|
317
|
-
// src/commands/
|
|
318
|
-
var
|
|
522
|
+
// src/commands/branch.ts
|
|
523
|
+
var branch_default = defineCommand({
|
|
319
524
|
meta: {
|
|
320
|
-
name: "
|
|
321
|
-
description: "
|
|
525
|
+
name: "branch",
|
|
526
|
+
description: "List branches with workflow-aware labels and status"
|
|
322
527
|
},
|
|
323
528
|
args: {
|
|
324
|
-
|
|
529
|
+
all: {
|
|
325
530
|
type: "boolean",
|
|
326
|
-
alias: "
|
|
327
|
-
description: "
|
|
531
|
+
alias: "a",
|
|
532
|
+
description: "Show both local and remote branches",
|
|
533
|
+
default: false
|
|
534
|
+
},
|
|
535
|
+
remote: {
|
|
536
|
+
type: "boolean",
|
|
537
|
+
alias: "r",
|
|
538
|
+
description: "Show only remote branches",
|
|
328
539
|
default: false
|
|
329
540
|
}
|
|
330
541
|
},
|
|
@@ -334,189 +545,277 @@ var clean_default = defineCommand({
|
|
|
334
545
|
process.exit(1);
|
|
335
546
|
}
|
|
336
547
|
const config = readConfig();
|
|
337
|
-
|
|
338
|
-
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
339
|
-
process.exit(1);
|
|
340
|
-
}
|
|
341
|
-
const { origin } = config;
|
|
342
|
-
const baseBranch = getBaseBranch(config);
|
|
548
|
+
const protectedBranches = config ? getProtectedBranches(config) : ["main", "master"];
|
|
343
549
|
const currentBranch = await getCurrentBranch();
|
|
344
|
-
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
if (
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
${pc3.bold("Branches to delete:")}`);
|
|
353
|
-
for (const b of candidates) {
|
|
354
|
-
console.log(` ${pc3.dim("•")} ${b}`);
|
|
355
|
-
}
|
|
356
|
-
console.log();
|
|
357
|
-
const ok = args.yes || await confirmPrompt(`Delete ${pc3.bold(String(candidates.length))} merged branch${candidates.length !== 1 ? "es" : ""}?`);
|
|
358
|
-
if (!ok) {
|
|
359
|
-
info("Skipped branch deletion.");
|
|
550
|
+
const showRemoteOnly = args.remote;
|
|
551
|
+
const showAll = args.all;
|
|
552
|
+
heading("\uD83C\uDF3F branches");
|
|
553
|
+
console.log();
|
|
554
|
+
if (!showRemoteOnly) {
|
|
555
|
+
const localBranches = await getLocalBranches();
|
|
556
|
+
if (localBranches.length === 0) {
|
|
557
|
+
console.log(pc2.dim(" No local branches found."));
|
|
360
558
|
} else {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
559
|
+
console.log(` ${pc2.bold("Local")}`);
|
|
560
|
+
console.log();
|
|
561
|
+
for (const branch of localBranches) {
|
|
562
|
+
const parts = [];
|
|
563
|
+
if (branch.isCurrent) {
|
|
564
|
+
parts.push(pc2.green("* "));
|
|
365
565
|
} else {
|
|
366
|
-
|
|
566
|
+
parts.push(" ");
|
|
567
|
+
}
|
|
568
|
+
const nameStr = colorBranchName(branch.name, protectedBranches, currentBranch);
|
|
569
|
+
parts.push(nameStr.padEnd(30));
|
|
570
|
+
if (branch.gone) {
|
|
571
|
+
parts.push(pc2.red(" ✗ remote gone"));
|
|
572
|
+
} else if (branch.upstream) {
|
|
573
|
+
parts.push(pc2.dim(` → ${branch.upstream}`));
|
|
574
|
+
} else {
|
|
575
|
+
parts.push(pc2.dim(" (no remote)"));
|
|
367
576
|
}
|
|
577
|
+
const labels = getBranchLabels(branch.name, protectedBranches, config);
|
|
578
|
+
if (labels.length > 0) {
|
|
579
|
+
parts.push(` ${labels.join(" ")}`);
|
|
580
|
+
}
|
|
581
|
+
console.log(` ${parts.join("")}`);
|
|
368
582
|
}
|
|
369
583
|
}
|
|
370
584
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
585
|
+
if (showRemoteOnly || showAll) {
|
|
586
|
+
const remoteBranches = await getRemoteBranches();
|
|
587
|
+
if (!showRemoteOnly) {
|
|
588
|
+
console.log();
|
|
589
|
+
}
|
|
590
|
+
if (remoteBranches.length === 0) {
|
|
591
|
+
console.log(pc2.dim(" No remote branches found."));
|
|
592
|
+
} else {
|
|
593
|
+
const grouped = groupByRemote(remoteBranches);
|
|
594
|
+
for (const [remote, branches] of Object.entries(grouped)) {
|
|
595
|
+
console.log(` ${pc2.bold(`Remote: ${remote}`)}`);
|
|
596
|
+
console.log();
|
|
597
|
+
for (const fullRef of branches) {
|
|
598
|
+
const branchName = fullRef.slice(remote.length + 1);
|
|
599
|
+
const nameStr = colorBranchName(branchName, protectedBranches, currentBranch);
|
|
600
|
+
const remotePrefix = pc2.dim(`${remote}/`);
|
|
601
|
+
console.log(` ${remotePrefix}${nameStr}`);
|
|
602
|
+
}
|
|
603
|
+
console.log();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
377
606
|
}
|
|
607
|
+
const tips = [];
|
|
608
|
+
if (!showAll && !showRemoteOnly) {
|
|
609
|
+
tips.push(`Use ${pc2.bold("contrib branch -a")} to include remote branches`);
|
|
610
|
+
}
|
|
611
|
+
if (!showRemoteOnly) {
|
|
612
|
+
tips.push(`Use ${pc2.bold("contrib start")} to create a new feature branch`);
|
|
613
|
+
tips.push(`Use ${pc2.bold("contrib clean")} to remove merged/stale branches`);
|
|
614
|
+
}
|
|
615
|
+
if (tips.length > 0) {
|
|
616
|
+
console.log(` ${pc2.dim("\uD83D\uDCA1 Tip:")}`);
|
|
617
|
+
for (const tip of tips) {
|
|
618
|
+
console.log(` ${pc2.dim(tip)}`);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
console.log();
|
|
378
622
|
}
|
|
379
623
|
});
|
|
624
|
+
function colorBranchName(name, protectedBranches, currentBranch) {
|
|
625
|
+
if (name === currentBranch) {
|
|
626
|
+
return pc2.bold(pc2.green(name));
|
|
627
|
+
}
|
|
628
|
+
if (protectedBranches.includes(name)) {
|
|
629
|
+
return pc2.bold(pc2.red(name));
|
|
630
|
+
}
|
|
631
|
+
return name;
|
|
632
|
+
}
|
|
633
|
+
function getBranchLabels(name, protectedBranches, config) {
|
|
634
|
+
const labels = [];
|
|
635
|
+
if (protectedBranches.includes(name)) {
|
|
636
|
+
labels.push(pc2.dim(pc2.red("[protected]")));
|
|
637
|
+
}
|
|
638
|
+
if (config) {
|
|
639
|
+
if (name === config.mainBranch) {
|
|
640
|
+
labels.push(pc2.dim(pc2.cyan("[main]")));
|
|
641
|
+
}
|
|
642
|
+
if (config.devBranch && name === config.devBranch) {
|
|
643
|
+
labels.push(pc2.dim(pc2.cyan("[dev]")));
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
return labels;
|
|
647
|
+
}
|
|
648
|
+
function groupByRemote(branches) {
|
|
649
|
+
const grouped = {};
|
|
650
|
+
for (const ref of branches) {
|
|
651
|
+
const slashIdx = ref.indexOf("/");
|
|
652
|
+
const remote = slashIdx !== -1 ? ref.slice(0, slashIdx) : "unknown";
|
|
653
|
+
if (!grouped[remote]) {
|
|
654
|
+
grouped[remote] = [];
|
|
655
|
+
}
|
|
656
|
+
grouped[remote].push(ref);
|
|
657
|
+
}
|
|
658
|
+
return grouped;
|
|
659
|
+
}
|
|
380
660
|
|
|
381
|
-
// src/commands/
|
|
661
|
+
// src/commands/clean.ts
|
|
382
662
|
import { defineCommand as defineCommand2 } from "citty";
|
|
383
|
-
import
|
|
663
|
+
import pc5 from "picocolors";
|
|
384
664
|
|
|
385
|
-
// src/utils/
|
|
386
|
-
var
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
conventional: "Conventional Commits",
|
|
390
|
-
"clean-commit": "Clean Commit (by WGTech Labs)",
|
|
391
|
-
none: "No convention"
|
|
392
|
-
};
|
|
393
|
-
var CONVENTION_DESCRIPTIONS = {
|
|
394
|
-
conventional: "Conventional Commits — feat: | fix: | docs: | chore: etc. (conventionalcommits.org)",
|
|
395
|
-
"clean-commit": "Clean Commit — \uD83D\uDCE6 new: | \uD83D\uDD27 update: | \uD83D\uDDD1️ remove: etc. (by WGTech Labs)",
|
|
396
|
-
none: "No commit convention enforcement"
|
|
397
|
-
};
|
|
398
|
-
var CONVENTION_FORMAT_HINTS = {
|
|
399
|
-
conventional: [
|
|
400
|
-
"Format: <type>[!][(<scope>)]: <description>",
|
|
401
|
-
"Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert",
|
|
402
|
-
"Examples: feat: add login page | fix(auth): resolve token expiry | docs: update README"
|
|
403
|
-
],
|
|
404
|
-
"clean-commit": [
|
|
405
|
-
"Format: <emoji> <type>[!][(<scope>)]: <description>",
|
|
406
|
-
"Types: \uD83D\uDCE6 new | \uD83D\uDD27 update | \uD83D\uDDD1️ remove | \uD83D\uDD12 security | ⚙️ setup | ☕ chore | \uD83E\uDDEA test | \uD83D\uDCD6 docs | \uD83D\uDE80 release",
|
|
407
|
-
"Examples: \uD83D\uDCE6 new: user auth | \uD83D\uDD27 update (api): improve errors | ⚙️ setup (ci): add workflow"
|
|
408
|
-
]
|
|
409
|
-
};
|
|
410
|
-
function validateCommitMessage(message, convention) {
|
|
411
|
-
if (convention === "none")
|
|
412
|
-
return true;
|
|
413
|
-
if (convention === "clean-commit")
|
|
414
|
-
return CLEAN_COMMIT_PATTERN.test(message);
|
|
415
|
-
if (convention === "conventional")
|
|
416
|
-
return CONVENTIONAL_COMMIT_PATTERN.test(message);
|
|
417
|
-
return true;
|
|
665
|
+
// src/utils/branch.ts
|
|
666
|
+
var DEFAULT_PREFIXES = ["feature", "fix", "docs", "chore", "test", "refactor"];
|
|
667
|
+
function hasPrefix(branchName, prefixes = DEFAULT_PREFIXES) {
|
|
668
|
+
return prefixes.some((p) => branchName.startsWith(`${p}/`));
|
|
418
669
|
}
|
|
419
|
-
function
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
670
|
+
function formatBranchName(prefix, name) {
|
|
671
|
+
const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
672
|
+
return `${prefix}/${sanitized}`;
|
|
673
|
+
}
|
|
674
|
+
function isValidBranchName(name) {
|
|
675
|
+
return /^[a-zA-Z0-9._/-]+$/.test(name) && !name.startsWith("/") && !name.endsWith("/");
|
|
676
|
+
}
|
|
677
|
+
function looksLikeNaturalLanguage(input) {
|
|
678
|
+
return input.includes(" ") && !input.includes("/");
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// src/utils/confirm.ts
|
|
682
|
+
import * as clack from "@clack/prompts";
|
|
683
|
+
import pc3 from "picocolors";
|
|
684
|
+
function handleCancel(value) {
|
|
685
|
+
if (clack.isCancel(value)) {
|
|
686
|
+
clack.cancel("Cancelled.");
|
|
687
|
+
process.exit(0);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
async function confirmPrompt(message) {
|
|
691
|
+
const result = await clack.confirm({ message });
|
|
692
|
+
handleCancel(result);
|
|
693
|
+
return result;
|
|
694
|
+
}
|
|
695
|
+
async function selectPrompt(message, choices) {
|
|
696
|
+
const result = await clack.select({
|
|
697
|
+
message,
|
|
698
|
+
options: choices.map((choice) => ({ value: choice, label: choice }))
|
|
699
|
+
});
|
|
700
|
+
handleCancel(result);
|
|
701
|
+
return result;
|
|
702
|
+
}
|
|
703
|
+
async function inputPrompt(message, defaultValue) {
|
|
704
|
+
const result = await clack.text({
|
|
705
|
+
message,
|
|
706
|
+
placeholder: defaultValue,
|
|
707
|
+
defaultValue
|
|
708
|
+
});
|
|
709
|
+
handleCancel(result);
|
|
710
|
+
return result || defaultValue || "";
|
|
711
|
+
}
|
|
712
|
+
async function multiSelectPrompt(message, choices) {
|
|
713
|
+
const result = await clack.multiselect({
|
|
714
|
+
message: `${message} ${pc3.dim("(space to toggle, enter to confirm)")}`,
|
|
715
|
+
options: choices.map((choice) => ({ value: choice, label: choice })),
|
|
716
|
+
required: false
|
|
717
|
+
});
|
|
718
|
+
handleCancel(result);
|
|
719
|
+
return result;
|
|
426
720
|
}
|
|
427
721
|
|
|
428
722
|
// src/utils/copilot.ts
|
|
429
723
|
import { CopilotClient } from "@github/copilot-sdk";
|
|
430
|
-
var CONVENTIONAL_COMMIT_SYSTEM_PROMPT = `
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
docs
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
ci
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
Rules:
|
|
447
|
-
- Breaking change (!) only for: feat, fix, refactor, perf
|
|
448
|
-
- Description: concise, imperative mood, max 72 chars, lowercase start
|
|
449
|
-
- Scope: optional, camelCase or kebab-case component name
|
|
450
|
-
- Return ONLY the commit message line, nothing else
|
|
451
|
-
|
|
452
|
-
Examples:
|
|
453
|
-
feat: add user authentication system
|
|
454
|
-
fix(auth): resolve token expiry issue
|
|
455
|
-
docs: update contributing guidelines
|
|
456
|
-
feat!: redesign authentication API`;
|
|
457
|
-
var CLEAN_COMMIT_SYSTEM_PROMPT = `You are a git commit message generator. Generate a Clean Commit message following this exact format:
|
|
458
|
-
<emoji> <type>[!][(<scope>)]: <description>
|
|
459
|
-
|
|
460
|
-
Emoji and type table:
|
|
461
|
-
\uD83D\uDCE6 new – new features, files, or capabilities
|
|
462
|
-
\uD83D\uDD27 update – changes, refactoring, improvements
|
|
463
|
-
\uD83D\uDDD1️ remove – removing code, files, or dependencies
|
|
464
|
-
\uD83D\uDD12 security – security fixes or patches
|
|
465
|
-
⚙️ setup – configs, CI/CD, tooling, build systems
|
|
466
|
-
☕ chore – maintenance, dependency updates
|
|
467
|
-
\uD83E\uDDEA test – adding or updating tests
|
|
468
|
-
\uD83D\uDCD6 docs – documentation changes
|
|
469
|
-
\uD83D\uDE80 release – version releases
|
|
470
|
-
|
|
471
|
-
Rules:
|
|
472
|
-
- Breaking change (!) only for: new, update, remove, security
|
|
473
|
-
- Description: concise, imperative mood, max 72 chars
|
|
474
|
-
- Scope: optional, camelCase or kebab-case component name
|
|
475
|
-
- Return ONLY the commit message line, nothing else
|
|
724
|
+
var CONVENTIONAL_COMMIT_SYSTEM_PROMPT = `Git commit message generator. Format: <type>[!][(<scope>)]: <description>
|
|
725
|
+
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
|
726
|
+
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.
|
|
727
|
+
Examples: feat: add user auth | fix(auth): resolve token expiry | feat!: redesign auth API`;
|
|
728
|
+
var CLEAN_COMMIT_SYSTEM_PROMPT = `Git commit message generator. EXACT format: <emoji> <type>[!][ (<scope>)]: <description>
|
|
729
|
+
Spacing: EMOJI SPACE TYPE [SPACE OPENPAREN SCOPE CLOSEPAREN] COLON SPACE DESCRIPTION
|
|
730
|
+
Types: \uD83D\uDCE6 new, \uD83D\uDD27 update, \uD83D\uDDD1️ remove, \uD83D\uDD12 security, ⚙️ setup, ☕ chore, \uD83E\uDDEA test, \uD83D\uDCD6 docs, \uD83D\uDE80 release
|
|
731
|
+
Rules: breaking (!) only for new/update/remove/security; imperative mood; max 72 chars; lowercase start; scope optional. Return ONLY the message line.
|
|
732
|
+
Correct: \uD83D\uDCE6 new: add user auth | \uD83D\uDD27 update (api): improve error handling | ⚙️ setup (ci): configure github actions
|
|
733
|
+
WRONG: ⚙️setup(ci): ... | \uD83D\uDD27 update(api): ... ← always space before scope parenthesis`;
|
|
734
|
+
function getGroupingSystemPrompt(convention) {
|
|
735
|
+
const conventionBlock = convention === "conventional" ? `Use Conventional Commit format: <type>[(<scope>)]: <description>
|
|
736
|
+
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert` : `Use Clean Commit format: <emoji> <type>[!][ (<scope>)]: <description>
|
|
737
|
+
Emoji/type table:
|
|
738
|
+
\uD83D\uDCE6 new, \uD83D\uDD27 update, \uD83D\uDDD1️ remove, \uD83D\uDD12 security, ⚙️ setup, ☕ chore, \uD83E\uDDEA test, \uD83D\uDCD6 docs, \uD83D\uDE80 release`;
|
|
739
|
+
return `You are a smart commit grouping assistant. Given a list of changed files and their diffs, group related changes into logical atomic commits.
|
|
476
740
|
|
|
477
|
-
|
|
478
|
-
\uD83D\uDCE6 new: user authentication system
|
|
479
|
-
\uD83D\uDD27 update (api): improve error handling
|
|
480
|
-
⚙️ setup (ci): configure github actions workflow
|
|
481
|
-
\uD83D\uDCE6 new!: completely redesign authentication system`;
|
|
482
|
-
var BRANCH_NAME_SYSTEM_PROMPT = `You are a git branch name generator. Convert natural language descriptions into proper git branch names.
|
|
741
|
+
${conventionBlock}
|
|
483
742
|
|
|
484
|
-
|
|
485
|
-
|
|
743
|
+
Return a JSON array of commit groups with this EXACT structure (no markdown fences, no explanation):
|
|
744
|
+
[
|
|
745
|
+
{
|
|
746
|
+
"files": ["path/to/file1.ts", "path/to/file2.ts"],
|
|
747
|
+
"message": "<commit message following the convention above>"
|
|
748
|
+
}
|
|
749
|
+
]
|
|
486
750
|
|
|
487
751
|
Rules:
|
|
488
|
-
-
|
|
489
|
-
-
|
|
490
|
-
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
752
|
+
- Group files that are logically related (e.g. a utility and its tests, a feature and its types)
|
|
753
|
+
- Each group should represent ONE logical change
|
|
754
|
+
- Every file must appear in exactly one group
|
|
755
|
+
- Commit messages must follow the convention, be concise, imperative, max 72 chars
|
|
756
|
+
- Order groups so foundational changes come first (types, utils) and consumers come after
|
|
757
|
+
- Return ONLY the JSON array, nothing else`;
|
|
758
|
+
}
|
|
759
|
+
var BRANCH_NAME_SYSTEM_PROMPT = `You are a git branch name generator. Your ONLY job is to output a single git branch name. NOTHING ELSE.
|
|
760
|
+
Output format: <prefix>/<kebab-case-name>
|
|
761
|
+
Valid prefixes: feature, fix, docs, chore, test, refactor
|
|
762
|
+
Rules: lowercase, kebab-case, 2-5 words after the prefix, no punctuation.
|
|
763
|
+
CRITICAL: Output ONLY the branch name on a single line. No explanation. No markdown. No questions. No other text.
|
|
764
|
+
Examples: fix/login-timeout | feature/user-profile-page | docs/update-readme | chore/update-pr-title`;
|
|
765
|
+
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..."}
|
|
766
|
+
IMPORTANT: The title must capture the overall theme or goal of the PR — NOT enumerate individual changes. Think: what problem does this PR solve or what capability does it add? Keep it focused and specific but high-level.`;
|
|
767
|
+
function getPRDescriptionSystemPrompt(convention) {
|
|
768
|
+
if (convention === "clean-commit") {
|
|
769
|
+
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
770
|
+
CRITICAL: The PR title MUST follow the Clean Commit format exactly: <emoji> <type>: <description>
|
|
771
|
+
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
|
|
772
|
+
Title examples: \uD83D\uDCE6 new: add user authentication | \uD83D\uDD27 update: improve error handling | \uD83D\uDDD1️ remove: drop legacy API
|
|
773
|
+
Rules: title follows convention, present tense, max 72 chars, describes the PR theme not individual commits; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
|
|
774
|
+
}
|
|
775
|
+
if (convention === "conventional") {
|
|
776
|
+
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
777
|
+
CRITICAL: The PR title MUST follow Conventional Commits format: <type>[(<scope>)]: <description>
|
|
778
|
+
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
|
779
|
+
Title examples: feat: add user authentication | fix(auth): resolve token expiry | docs: update contributing guide
|
|
780
|
+
Rules: title follows convention, present tense, max 72 chars, describes the PR theme not individual commits; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
|
|
781
|
+
}
|
|
782
|
+
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
783
|
+
Rules: title concise present tense, describes the PR theme not individual commits; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
|
|
784
|
+
}
|
|
785
|
+
var CONFLICT_RESOLUTION_SYSTEM_PROMPT = `Git merge conflict advisor. Explain each side, suggest resolution strategy. Never auto-resolve — guidance only. Be concise and actionable.`;
|
|
786
|
+
function suppressSubprocessWarnings() {
|
|
787
|
+
process.env.NODE_NO_WARNINGS = "1";
|
|
788
|
+
}
|
|
789
|
+
function withTimeout(promise, ms) {
|
|
790
|
+
return new Promise((resolve, reject) => {
|
|
791
|
+
const timer = setTimeout(() => reject(new Error(`Copilot request timed out after ${ms / 1000}s`)), ms);
|
|
792
|
+
promise.then((val) => {
|
|
793
|
+
clearTimeout(timer);
|
|
794
|
+
resolve(val);
|
|
795
|
+
}, (err) => {
|
|
796
|
+
clearTimeout(timer);
|
|
797
|
+
reject(err);
|
|
798
|
+
});
|
|
799
|
+
});
|
|
502
800
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
- title: concise, present tense, describes what the PR does
|
|
506
|
-
- body: markdown with Summary, Changes (bullet list), and Test Plan sections
|
|
507
|
-
- Return ONLY the JSON object, no markdown fences, no extra text`;
|
|
508
|
-
var CONFLICT_RESOLUTION_SYSTEM_PROMPT = `You are a git merge conflict resolution advisor. Analyze the conflict markers and provide guidance.
|
|
509
|
-
|
|
510
|
-
Rules:
|
|
511
|
-
- Explain what each side of the conflict contains
|
|
512
|
-
- Suggest the most likely correct resolution strategy
|
|
513
|
-
- Never auto-resolve — provide guidance only
|
|
514
|
-
- Be concise and actionable`;
|
|
801
|
+
var COPILOT_TIMEOUT_MS = 30000;
|
|
802
|
+
var COPILOT_LONG_TIMEOUT_MS = 90000;
|
|
515
803
|
async function checkCopilotAvailable() {
|
|
516
|
-
let client = null;
|
|
517
804
|
try {
|
|
518
|
-
client =
|
|
519
|
-
|
|
805
|
+
const client = await getManagedClient();
|
|
806
|
+
try {
|
|
807
|
+
await client.ping();
|
|
808
|
+
} catch (err) {
|
|
809
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
810
|
+
if (msg.includes("auth") || msg.includes("token") || msg.includes("401") || msg.includes("403")) {
|
|
811
|
+
return "Copilot authentication failed. Run `gh auth login` to refresh your token.";
|
|
812
|
+
}
|
|
813
|
+
if (msg.includes("ECONNREFUSED") || msg.includes("timeout") || msg.includes("network")) {
|
|
814
|
+
return "Could not reach GitHub Copilot service. Check your internet connection.";
|
|
815
|
+
}
|
|
816
|
+
return `Copilot health check failed: ${msg}`;
|
|
817
|
+
}
|
|
818
|
+
return null;
|
|
520
819
|
} catch (err) {
|
|
521
820
|
const msg = err instanceof Error ? err.message : String(err);
|
|
522
821
|
if (msg.includes("ENOENT") || msg.includes("not found")) {
|
|
@@ -524,44 +823,45 @@ async function checkCopilotAvailable() {
|
|
|
524
823
|
}
|
|
525
824
|
return `Failed to start Copilot service: ${msg}`;
|
|
526
825
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
826
|
+
}
|
|
827
|
+
var _managedClient = null;
|
|
828
|
+
var _clientStarted = false;
|
|
829
|
+
async function getManagedClient() {
|
|
830
|
+
if (!_managedClient || !_clientStarted) {
|
|
831
|
+
suppressSubprocessWarnings();
|
|
832
|
+
_managedClient = new CopilotClient;
|
|
833
|
+
await _managedClient.start();
|
|
834
|
+
_clientStarted = true;
|
|
835
|
+
const cleanup = () => {
|
|
836
|
+
if (_managedClient && _clientStarted) {
|
|
837
|
+
try {
|
|
838
|
+
_managedClient.stop();
|
|
839
|
+
} catch {}
|
|
840
|
+
_clientStarted = false;
|
|
841
|
+
_managedClient = null;
|
|
842
|
+
}
|
|
843
|
+
};
|
|
844
|
+
process.once("exit", cleanup);
|
|
845
|
+
process.once("SIGINT", cleanup);
|
|
846
|
+
process.once("SIGTERM", cleanup);
|
|
542
847
|
}
|
|
543
|
-
return
|
|
848
|
+
return _managedClient;
|
|
544
849
|
}
|
|
545
|
-
async function callCopilot(systemMessage, userMessage, model) {
|
|
546
|
-
const client =
|
|
547
|
-
|
|
850
|
+
async function callCopilot(systemMessage, userMessage, model, timeoutMs = COPILOT_TIMEOUT_MS) {
|
|
851
|
+
const client = await getManagedClient();
|
|
852
|
+
const sessionConfig = {
|
|
853
|
+
systemMessage: { mode: "replace", content: systemMessage }
|
|
854
|
+
};
|
|
855
|
+
if (model)
|
|
856
|
+
sessionConfig.model = model;
|
|
857
|
+
const session = await client.createSession(sessionConfig);
|
|
548
858
|
try {
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
sessionConfig.model = model;
|
|
554
|
-
const session = await client.createSession(sessionConfig);
|
|
555
|
-
try {
|
|
556
|
-
const response = await session.sendAndWait({ content: userMessage });
|
|
557
|
-
if (!response?.data?.content)
|
|
558
|
-
return null;
|
|
559
|
-
return response.data.content;
|
|
560
|
-
} finally {
|
|
561
|
-
await session.destroy();
|
|
562
|
-
}
|
|
859
|
+
const response = await withTimeout(session.sendAndWait({ prompt: userMessage }), timeoutMs);
|
|
860
|
+
if (!response?.data?.content)
|
|
861
|
+
return null;
|
|
862
|
+
return response.data.content;
|
|
563
863
|
} finally {
|
|
564
|
-
await
|
|
864
|
+
await session.destroy();
|
|
565
865
|
}
|
|
566
866
|
}
|
|
567
867
|
function getCommitSystemPrompt(convention) {
|
|
@@ -569,21 +869,53 @@ function getCommitSystemPrompt(convention) {
|
|
|
569
869
|
return CONVENTIONAL_COMMIT_SYSTEM_PROMPT;
|
|
570
870
|
return CLEAN_COMMIT_SYSTEM_PROMPT;
|
|
571
871
|
}
|
|
872
|
+
function extractJson(raw) {
|
|
873
|
+
let text2 = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
|
|
874
|
+
if (text2.startsWith("[") || text2.startsWith("{"))
|
|
875
|
+
return text2;
|
|
876
|
+
const arrayStart = text2.indexOf("[");
|
|
877
|
+
const objStart = text2.indexOf("{");
|
|
878
|
+
let start;
|
|
879
|
+
let closeChar;
|
|
880
|
+
if (arrayStart === -1 && objStart === -1)
|
|
881
|
+
return text2;
|
|
882
|
+
if (arrayStart === -1) {
|
|
883
|
+
start = objStart;
|
|
884
|
+
closeChar = "}";
|
|
885
|
+
} else if (objStart === -1) {
|
|
886
|
+
start = arrayStart;
|
|
887
|
+
closeChar = "]";
|
|
888
|
+
} else if (arrayStart < objStart) {
|
|
889
|
+
start = arrayStart;
|
|
890
|
+
closeChar = "]";
|
|
891
|
+
} else {
|
|
892
|
+
start = objStart;
|
|
893
|
+
closeChar = "}";
|
|
894
|
+
}
|
|
895
|
+
const end = text2.lastIndexOf(closeChar);
|
|
896
|
+
if (end > start) {
|
|
897
|
+
text2 = text2.slice(start, end + 1);
|
|
898
|
+
}
|
|
899
|
+
return text2;
|
|
900
|
+
}
|
|
572
901
|
async function generateCommitMessage(diff, stagedFiles, model, convention = "clean-commit") {
|
|
573
902
|
try {
|
|
903
|
+
const multiFileHint = stagedFiles.length > 1 ? `
|
|
904
|
+
|
|
905
|
+
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.` : "";
|
|
574
906
|
const userMessage = `Generate a commit message for these staged changes:
|
|
575
907
|
|
|
576
908
|
Files: ${stagedFiles.join(", ")}
|
|
577
909
|
|
|
578
910
|
Diff:
|
|
579
|
-
${diff.slice(0, 4000)}`;
|
|
911
|
+
${diff.slice(0, 4000)}${multiFileHint}`;
|
|
580
912
|
const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
|
|
581
913
|
return result?.trim() ?? null;
|
|
582
914
|
} catch {
|
|
583
915
|
return null;
|
|
584
916
|
}
|
|
585
917
|
}
|
|
586
|
-
async function generatePRDescription(commits, diff, model) {
|
|
918
|
+
async function generatePRDescription(commits, diff, model, convention = "clean-commit") {
|
|
587
919
|
try {
|
|
588
920
|
const userMessage = `Generate a PR description for these changes:
|
|
589
921
|
|
|
@@ -593,10 +925,10 @@ ${commits.join(`
|
|
|
593
925
|
|
|
594
926
|
Diff (truncated):
|
|
595
927
|
${diff.slice(0, 4000)}`;
|
|
596
|
-
const result = await callCopilot(
|
|
928
|
+
const result = await callCopilot(getPRDescriptionSystemPrompt(convention), userMessage, model);
|
|
597
929
|
if (!result)
|
|
598
930
|
return null;
|
|
599
|
-
const cleaned = result
|
|
931
|
+
const cleaned = extractJson(result);
|
|
600
932
|
return JSON.parse(cleaned);
|
|
601
933
|
} catch {
|
|
602
934
|
return null;
|
|
@@ -605,7 +937,11 @@ ${diff.slice(0, 4000)}`;
|
|
|
605
937
|
async function suggestBranchName(description, model) {
|
|
606
938
|
try {
|
|
607
939
|
const result = await callCopilot(BRANCH_NAME_SYSTEM_PROMPT, description, model);
|
|
608
|
-
|
|
940
|
+
const trimmed = result?.trim() ?? null;
|
|
941
|
+
if (trimmed && /^[a-z]+\/[a-z0-9-]+$/.test(trimmed)) {
|
|
942
|
+
return trimmed;
|
|
943
|
+
}
|
|
944
|
+
return null;
|
|
609
945
|
} catch {
|
|
610
946
|
return null;
|
|
611
947
|
}
|
|
@@ -621,21 +957,359 @@ ${conflictDiff.slice(0, 4000)}`;
|
|
|
621
957
|
return null;
|
|
622
958
|
}
|
|
623
959
|
}
|
|
960
|
+
async function generateCommitGroups(files, diffs, model, convention = "clean-commit") {
|
|
961
|
+
const userMessage = `Group these changed files into logical atomic commits:
|
|
624
962
|
|
|
625
|
-
|
|
626
|
-
|
|
963
|
+
Files:
|
|
964
|
+
${files.join(`
|
|
965
|
+
`)}
|
|
966
|
+
|
|
967
|
+
Diffs (truncated):
|
|
968
|
+
${diffs.slice(0, 6000)}`;
|
|
969
|
+
const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
|
|
970
|
+
if (!result) {
|
|
971
|
+
throw new Error("AI returned an empty response");
|
|
972
|
+
}
|
|
973
|
+
const cleaned = extractJson(result);
|
|
974
|
+
let parsed;
|
|
975
|
+
try {
|
|
976
|
+
parsed = JSON.parse(cleaned);
|
|
977
|
+
} catch {
|
|
978
|
+
throw new Error(`AI response is not valid JSON. Raw start: "${result.slice(0, 120)}..."`);
|
|
979
|
+
}
|
|
980
|
+
const groups = parsed;
|
|
981
|
+
if (!Array.isArray(groups) || groups.length === 0) {
|
|
982
|
+
throw new Error("AI response was not a valid JSON array of commit groups");
|
|
983
|
+
}
|
|
984
|
+
for (const group of groups) {
|
|
985
|
+
if (!Array.isArray(group.files) || typeof group.message !== "string") {
|
|
986
|
+
throw new Error("AI returned groups with invalid structure (missing files or message)");
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
return groups;
|
|
990
|
+
}
|
|
991
|
+
async function regenerateAllGroupMessages(groups, diffs, model, convention = "clean-commit") {
|
|
992
|
+
const groupSummary = groups.map((g, i) => `Group ${i + 1}: [${g.files.join(", ")}]`).join(`
|
|
993
|
+
`);
|
|
994
|
+
const userMessage = `Regenerate ONLY the commit messages for these pre-defined file groups. Do NOT change the file groupings.
|
|
995
|
+
|
|
996
|
+
Groups:
|
|
997
|
+
${groupSummary}
|
|
998
|
+
|
|
999
|
+
Diffs (truncated):
|
|
1000
|
+
${diffs.slice(0, 6000)}`;
|
|
1001
|
+
const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
|
|
1002
|
+
if (!result)
|
|
1003
|
+
return groups;
|
|
1004
|
+
try {
|
|
1005
|
+
const cleaned = extractJson(result);
|
|
1006
|
+
const parsed = JSON.parse(cleaned);
|
|
1007
|
+
if (!Array.isArray(parsed) || parsed.length !== groups.length)
|
|
1008
|
+
return groups;
|
|
1009
|
+
return groups.map((g, i) => ({
|
|
1010
|
+
files: g.files,
|
|
1011
|
+
message: typeof parsed[i]?.message === "string" ? parsed[i].message : g.message
|
|
1012
|
+
}));
|
|
1013
|
+
} catch {
|
|
1014
|
+
return groups;
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
async function regenerateGroupMessage(files, diffs, model, convention = "clean-commit") {
|
|
1018
|
+
try {
|
|
1019
|
+
const userMessage = `Generate a single commit message for these files:
|
|
1020
|
+
|
|
1021
|
+
Files: ${files.join(", ")}
|
|
1022
|
+
|
|
1023
|
+
Diff:
|
|
1024
|
+
${diffs.slice(0, 4000)}`;
|
|
1025
|
+
const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
|
|
1026
|
+
return result?.trim() ?? null;
|
|
1027
|
+
} catch {
|
|
1028
|
+
return null;
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
// src/utils/gh.ts
|
|
1033
|
+
import { execFile as execFileCb2 } from "node:child_process";
|
|
1034
|
+
function run2(args) {
|
|
1035
|
+
return new Promise((resolve) => {
|
|
1036
|
+
execFileCb2("gh", args, (error2, stdout, stderr) => {
|
|
1037
|
+
resolve({
|
|
1038
|
+
exitCode: error2 ? error2.code === "ENOENT" ? 127 : error2.status ?? 1 : 0,
|
|
1039
|
+
stdout: stdout ?? "",
|
|
1040
|
+
stderr: stderr ?? ""
|
|
1041
|
+
});
|
|
1042
|
+
});
|
|
1043
|
+
});
|
|
1044
|
+
}
|
|
1045
|
+
async function checkGhInstalled() {
|
|
1046
|
+
try {
|
|
1047
|
+
const { exitCode } = await run2(["--version"]);
|
|
1048
|
+
return exitCode === 0;
|
|
1049
|
+
} catch {
|
|
1050
|
+
return false;
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
async function checkGhAuth() {
|
|
1054
|
+
try {
|
|
1055
|
+
const { exitCode } = await run2(["auth", "status"]);
|
|
1056
|
+
return exitCode === 0;
|
|
1057
|
+
} catch {
|
|
1058
|
+
return false;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
var SAFE_SLUG = /^[\w.-]+$/;
|
|
1062
|
+
async function checkRepoPermissions(owner, repo) {
|
|
1063
|
+
if (!SAFE_SLUG.test(owner) || !SAFE_SLUG.test(repo))
|
|
1064
|
+
return null;
|
|
1065
|
+
const { exitCode, stdout } = await run2(["api", `repos/${owner}/${repo}`, "--jq", ".permissions"]);
|
|
1066
|
+
if (exitCode !== 0)
|
|
1067
|
+
return null;
|
|
1068
|
+
try {
|
|
1069
|
+
return JSON.parse(stdout.trim());
|
|
1070
|
+
} catch {
|
|
1071
|
+
return null;
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
async function isRepoFork() {
|
|
1075
|
+
const { exitCode, stdout } = await run2(["repo", "view", "--json", "isFork", "-q", ".isFork"]);
|
|
1076
|
+
if (exitCode !== 0)
|
|
1077
|
+
return null;
|
|
1078
|
+
const val = stdout.trim();
|
|
1079
|
+
if (val === "true")
|
|
1080
|
+
return true;
|
|
1081
|
+
if (val === "false")
|
|
1082
|
+
return false;
|
|
1083
|
+
return null;
|
|
1084
|
+
}
|
|
1085
|
+
async function getCurrentRepoInfo() {
|
|
1086
|
+
const { exitCode, stdout } = await run2([
|
|
1087
|
+
"repo",
|
|
1088
|
+
"view",
|
|
1089
|
+
"--json",
|
|
1090
|
+
"nameWithOwner",
|
|
1091
|
+
"-q",
|
|
1092
|
+
".nameWithOwner"
|
|
1093
|
+
]);
|
|
1094
|
+
if (exitCode !== 0)
|
|
1095
|
+
return null;
|
|
1096
|
+
const nameWithOwner = stdout.trim();
|
|
1097
|
+
if (!nameWithOwner)
|
|
1098
|
+
return null;
|
|
1099
|
+
const [owner, repo] = nameWithOwner.split("/");
|
|
1100
|
+
if (!owner || !repo)
|
|
1101
|
+
return null;
|
|
1102
|
+
return { owner, repo };
|
|
1103
|
+
}
|
|
1104
|
+
async function createPR(options) {
|
|
1105
|
+
const args = [
|
|
1106
|
+
"pr",
|
|
1107
|
+
"create",
|
|
1108
|
+
"--base",
|
|
1109
|
+
options.base,
|
|
1110
|
+
"--title",
|
|
1111
|
+
options.title,
|
|
1112
|
+
"--body",
|
|
1113
|
+
options.body
|
|
1114
|
+
];
|
|
1115
|
+
if (options.draft)
|
|
1116
|
+
args.push("--draft");
|
|
1117
|
+
return run2(args);
|
|
1118
|
+
}
|
|
1119
|
+
async function createPRFill(base, draft) {
|
|
1120
|
+
const args = ["pr", "create", "--base", base, "--fill"];
|
|
1121
|
+
if (draft)
|
|
1122
|
+
args.push("--draft");
|
|
1123
|
+
return run2(args);
|
|
1124
|
+
}
|
|
1125
|
+
async function getPRForBranch(headBranch) {
|
|
1126
|
+
const { exitCode, stdout } = await run2([
|
|
1127
|
+
"pr",
|
|
1128
|
+
"list",
|
|
1129
|
+
"--head",
|
|
1130
|
+
headBranch,
|
|
1131
|
+
"--state",
|
|
1132
|
+
"open",
|
|
1133
|
+
"--json",
|
|
1134
|
+
"number,url,title,state",
|
|
1135
|
+
"--limit",
|
|
1136
|
+
"1"
|
|
1137
|
+
]);
|
|
1138
|
+
if (exitCode !== 0)
|
|
1139
|
+
return null;
|
|
1140
|
+
try {
|
|
1141
|
+
const prs = JSON.parse(stdout.trim());
|
|
1142
|
+
return prs.length > 0 ? prs[0] : null;
|
|
1143
|
+
} catch {
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
async function getMergedPRForBranch(headBranch) {
|
|
1148
|
+
const { exitCode, stdout } = await run2([
|
|
1149
|
+
"pr",
|
|
1150
|
+
"list",
|
|
1151
|
+
"--head",
|
|
1152
|
+
headBranch,
|
|
1153
|
+
"--state",
|
|
1154
|
+
"merged",
|
|
1155
|
+
"--json",
|
|
1156
|
+
"number,url,title,state",
|
|
1157
|
+
"--limit",
|
|
1158
|
+
"1"
|
|
1159
|
+
]);
|
|
1160
|
+
if (exitCode !== 0)
|
|
1161
|
+
return null;
|
|
1162
|
+
try {
|
|
1163
|
+
const prs = JSON.parse(stdout.trim());
|
|
1164
|
+
return prs.length > 0 ? prs[0] : null;
|
|
1165
|
+
} catch {
|
|
1166
|
+
return null;
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
// src/utils/spinner.ts
|
|
1171
|
+
import pc4 from "picocolors";
|
|
1172
|
+
var FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
1173
|
+
function createSpinner(text2) {
|
|
1174
|
+
let frameIdx = 0;
|
|
1175
|
+
let currentText = text2;
|
|
1176
|
+
let stopped = false;
|
|
1177
|
+
const clearLine = () => {
|
|
1178
|
+
process.stderr.write("\r\x1B[K");
|
|
1179
|
+
};
|
|
1180
|
+
const render = () => {
|
|
1181
|
+
if (stopped)
|
|
1182
|
+
return;
|
|
1183
|
+
const frame = pc4.cyan(FRAMES[frameIdx % FRAMES.length]);
|
|
1184
|
+
clearLine();
|
|
1185
|
+
process.stderr.write(`${frame} ${currentText}`);
|
|
1186
|
+
frameIdx++;
|
|
1187
|
+
};
|
|
1188
|
+
const timer = setInterval(render, 80);
|
|
1189
|
+
render();
|
|
1190
|
+
const stop = () => {
|
|
1191
|
+
if (stopped)
|
|
1192
|
+
return;
|
|
1193
|
+
stopped = true;
|
|
1194
|
+
clearInterval(timer);
|
|
1195
|
+
clearLine();
|
|
1196
|
+
};
|
|
1197
|
+
return {
|
|
1198
|
+
update(newText) {
|
|
1199
|
+
currentText = newText;
|
|
1200
|
+
},
|
|
1201
|
+
success(msg) {
|
|
1202
|
+
stop();
|
|
1203
|
+
process.stderr.write(`${pc4.green("✔")} ${msg}
|
|
1204
|
+
`);
|
|
1205
|
+
},
|
|
1206
|
+
fail(msg) {
|
|
1207
|
+
stop();
|
|
1208
|
+
process.stderr.write(`${pc4.red("✖")} ${msg}
|
|
1209
|
+
`);
|
|
1210
|
+
},
|
|
1211
|
+
stop() {
|
|
1212
|
+
stop();
|
|
1213
|
+
}
|
|
1214
|
+
};
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// src/commands/clean.ts
|
|
1218
|
+
async function handleCurrentBranchDeletion(currentBranch, baseBranch, config) {
|
|
1219
|
+
if (!config)
|
|
1220
|
+
return "skipped";
|
|
1221
|
+
const { origin } = config;
|
|
1222
|
+
const localWork = await hasLocalWork(origin, currentBranch);
|
|
1223
|
+
const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
|
|
1224
|
+
if (hasWork) {
|
|
1225
|
+
if (localWork.uncommitted) {
|
|
1226
|
+
warn("You have uncommitted changes in your working tree.");
|
|
1227
|
+
}
|
|
1228
|
+
if (localWork.unpushedCommits > 0) {
|
|
1229
|
+
warn(`You have ${pc5.bold(String(localWork.unpushedCommits))} local commit${localWork.unpushedCommits !== 1 ? "s" : ""} not pushed.`);
|
|
1230
|
+
}
|
|
1231
|
+
const SAVE_NEW_BRANCH = "Save changes to a new branch";
|
|
1232
|
+
const DISCARD = "Discard all changes and clean up";
|
|
1233
|
+
const CANCEL = "Skip this branch";
|
|
1234
|
+
const action = await selectPrompt(`${pc5.bold(currentBranch)} has local changes. What would you like to do?`, [SAVE_NEW_BRANCH, DISCARD, CANCEL]);
|
|
1235
|
+
if (action === CANCEL)
|
|
1236
|
+
return "skipped";
|
|
1237
|
+
if (action === SAVE_NEW_BRANCH) {
|
|
1238
|
+
if (!config)
|
|
1239
|
+
return "skipped";
|
|
1240
|
+
info(pc5.dim("Tip: Describe what you're going to work on in plain English and we'll generate a branch name."));
|
|
1241
|
+
const description = await inputPrompt("What are you going to work on?");
|
|
1242
|
+
let newBranchName = description;
|
|
1243
|
+
if (looksLikeNaturalLanguage(description)) {
|
|
1244
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
1245
|
+
const suggested = await suggestBranchName(description);
|
|
1246
|
+
if (suggested) {
|
|
1247
|
+
spinner.success("Branch name suggestion ready.");
|
|
1248
|
+
console.log(`
|
|
1249
|
+
${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(suggested))}`);
|
|
1250
|
+
const accepted = await confirmPrompt(`Use ${pc5.bold(suggested)} as your branch name?`);
|
|
1251
|
+
newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
|
|
1252
|
+
} else {
|
|
1253
|
+
spinner.fail("AI did not return a suggestion.");
|
|
1254
|
+
newBranchName = await inputPrompt("Enter branch name", description);
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
if (!hasPrefix(newBranchName, config.branchPrefixes)) {
|
|
1258
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc5.bold(newBranchName)}:`, config.branchPrefixes);
|
|
1259
|
+
newBranchName = formatBranchName(prefix, newBranchName);
|
|
1260
|
+
}
|
|
1261
|
+
if (!isValidBranchName(newBranchName)) {
|
|
1262
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
1263
|
+
return "skipped";
|
|
1264
|
+
}
|
|
1265
|
+
const renameResult = await renameBranch(currentBranch, newBranchName);
|
|
1266
|
+
if (renameResult.exitCode !== 0) {
|
|
1267
|
+
error(`Failed to rename branch: ${renameResult.stderr}`);
|
|
1268
|
+
return "skipped";
|
|
1269
|
+
}
|
|
1270
|
+
success(`Renamed ${pc5.bold(currentBranch)} → ${pc5.bold(newBranchName)}`);
|
|
1271
|
+
const syncSource2 = getSyncSource(config);
|
|
1272
|
+
await fetchRemote(syncSource2.remote);
|
|
1273
|
+
const savedUpstreamRef = await getUpstreamRef();
|
|
1274
|
+
const rebaseResult = savedUpstreamRef && savedUpstreamRef !== syncSource2.ref ? await rebaseOnto(syncSource2.ref, savedUpstreamRef) : await rebase(syncSource2.ref);
|
|
1275
|
+
if (rebaseResult.exitCode !== 0) {
|
|
1276
|
+
warn("Rebase encountered conflicts. Resolve them after cleanup:");
|
|
1277
|
+
info(` ${pc5.bold(`git checkout ${newBranchName} && git rebase --continue`)}`);
|
|
1278
|
+
} else {
|
|
1279
|
+
success(`Rebased ${pc5.bold(newBranchName)} onto ${pc5.bold(syncSource2.ref)}.`);
|
|
1280
|
+
}
|
|
1281
|
+
const coResult2 = await checkoutBranch(baseBranch);
|
|
1282
|
+
if (coResult2.exitCode !== 0) {
|
|
1283
|
+
error(`Failed to checkout ${baseBranch}: ${coResult2.stderr}`);
|
|
1284
|
+
return "saved";
|
|
1285
|
+
}
|
|
1286
|
+
await updateLocalBranch(baseBranch, syncSource2.ref);
|
|
1287
|
+
success(`Synced ${pc5.bold(baseBranch)} with ${pc5.bold(syncSource2.ref)}.`);
|
|
1288
|
+
return "saved";
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
const syncSource = getSyncSource(config);
|
|
1292
|
+
info(`Switching to ${pc5.bold(baseBranch)} and syncing...`);
|
|
1293
|
+
await fetchRemote(syncSource.remote);
|
|
1294
|
+
const coResult = await checkoutBranch(baseBranch);
|
|
1295
|
+
if (coResult.exitCode !== 0) {
|
|
1296
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
1297
|
+
return "skipped";
|
|
1298
|
+
}
|
|
1299
|
+
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
1300
|
+
success(`Synced ${pc5.bold(baseBranch)} with ${pc5.bold(syncSource.ref)}.`);
|
|
1301
|
+
return "switched";
|
|
1302
|
+
}
|
|
1303
|
+
var clean_default = defineCommand2({
|
|
627
1304
|
meta: {
|
|
628
|
-
name: "
|
|
629
|
-
description: "
|
|
1305
|
+
name: "clean",
|
|
1306
|
+
description: "Delete merged branches and prune remote refs"
|
|
630
1307
|
},
|
|
631
1308
|
args: {
|
|
632
|
-
|
|
633
|
-
type: "string",
|
|
634
|
-
description: "AI model to use for commit message generation"
|
|
635
|
-
},
|
|
636
|
-
"no-ai": {
|
|
1309
|
+
yes: {
|
|
637
1310
|
type: "boolean",
|
|
638
|
-
|
|
1311
|
+
alias: "y",
|
|
1312
|
+
description: "Skip confirmation prompt",
|
|
639
1313
|
default: false
|
|
640
1314
|
}
|
|
641
1315
|
},
|
|
@@ -649,115 +1323,888 @@ var commit_default = defineCommand2({
|
|
|
649
1323
|
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
650
1324
|
process.exit(1);
|
|
651
1325
|
}
|
|
652
|
-
|
|
653
|
-
const
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
1326
|
+
const { origin } = config;
|
|
1327
|
+
const baseBranch = getBaseBranch(config);
|
|
1328
|
+
let currentBranch = await getCurrentBranch();
|
|
1329
|
+
heading("\uD83E\uDDF9 contrib clean");
|
|
1330
|
+
info(`Pruning ${origin} remote refs...`);
|
|
1331
|
+
const pruneResult = await pruneRemote(origin);
|
|
1332
|
+
if (pruneResult.exitCode === 0) {
|
|
1333
|
+
success(`Pruned ${origin} remote refs.`);
|
|
1334
|
+
} else {
|
|
1335
|
+
warn(`Could not prune remote: ${pruneResult.stderr.trim()}`);
|
|
1336
|
+
}
|
|
1337
|
+
const protectedBranches = new Set(getProtectedBranches(config));
|
|
1338
|
+
const mergedBranches = await getMergedBranches(baseBranch);
|
|
1339
|
+
const mergedCandidates = mergedBranches.filter((b) => !protectedBranches.has(b));
|
|
1340
|
+
const goneBranches = await getGoneBranches();
|
|
1341
|
+
const goneCandidates = goneBranches.filter((b) => !protectedBranches.has(b) && !mergedCandidates.includes(b));
|
|
1342
|
+
if (currentBranch && !protectedBranches.has(currentBranch) && !mergedCandidates.includes(currentBranch) && !goneCandidates.includes(currentBranch)) {
|
|
1343
|
+
const ghInstalled = await checkGhInstalled();
|
|
1344
|
+
const ghAuthed = ghInstalled && await checkGhAuth();
|
|
1345
|
+
if (ghInstalled && ghAuthed) {
|
|
1346
|
+
const mergedPR = await getMergedPRForBranch(currentBranch);
|
|
1347
|
+
if (mergedPR) {
|
|
1348
|
+
warn(`PR #${mergedPR.number} (${pc5.bold(mergedPR.title)}) has already been merged.`);
|
|
1349
|
+
info(`Link: ${pc5.underline(mergedPR.url)}`);
|
|
1350
|
+
goneCandidates.push(currentBranch);
|
|
1351
|
+
}
|
|
659
1352
|
}
|
|
1353
|
+
}
|
|
1354
|
+
if (mergedCandidates.length > 0) {
|
|
660
1355
|
console.log(`
|
|
661
|
-
${
|
|
662
|
-
for (const
|
|
663
|
-
|
|
1356
|
+
${pc5.bold("Merged branches to delete:")}`);
|
|
1357
|
+
for (const b of mergedCandidates) {
|
|
1358
|
+
const marker = b === currentBranch ? pc5.yellow(" (current)") : "";
|
|
1359
|
+
console.log(` ${pc5.dim("•")} ${b}${marker}`);
|
|
664
1360
|
}
|
|
665
1361
|
console.log();
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
1362
|
+
const ok = args.yes || await confirmPrompt(`Delete ${pc5.bold(String(mergedCandidates.length))} merged branch${mergedCandidates.length !== 1 ? "es" : ""}?`);
|
|
1363
|
+
if (ok) {
|
|
1364
|
+
for (const branch of mergedCandidates) {
|
|
1365
|
+
if (branch === currentBranch) {
|
|
1366
|
+
const result2 = await handleCurrentBranchDeletion(currentBranch, baseBranch, config);
|
|
1367
|
+
if (result2 === "skipped") {
|
|
1368
|
+
warn(` Skipped ${branch}.`);
|
|
1369
|
+
continue;
|
|
1370
|
+
}
|
|
1371
|
+
if (result2 === "saved") {
|
|
1372
|
+
currentBranch = baseBranch;
|
|
1373
|
+
continue;
|
|
1374
|
+
}
|
|
1375
|
+
currentBranch = baseBranch;
|
|
1376
|
+
}
|
|
1377
|
+
const result = await deleteBranch(branch);
|
|
1378
|
+
if (result.exitCode === 0) {
|
|
1379
|
+
success(` Deleted ${pc5.bold(branch)}`);
|
|
1380
|
+
} else {
|
|
1381
|
+
warn(` Failed to delete ${branch}: ${result.stderr.trim()}`);
|
|
1382
|
+
}
|
|
686
1383
|
}
|
|
1384
|
+
} else {
|
|
1385
|
+
info("Skipped merged branch deletion.");
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
if (goneCandidates.length > 0) {
|
|
1389
|
+
console.log(`
|
|
1390
|
+
${pc5.bold("Stale branches (remote deleted, likely squash-merged):")}`);
|
|
1391
|
+
for (const b of goneCandidates) {
|
|
1392
|
+
const marker = b === currentBranch ? pc5.yellow(" (current)") : "";
|
|
1393
|
+
console.log(` ${pc5.dim("•")} ${b}${marker}`);
|
|
687
1394
|
}
|
|
1395
|
+
console.log();
|
|
1396
|
+
const ok = args.yes || await confirmPrompt(`Delete ${pc5.bold(String(goneCandidates.length))} stale branch${goneCandidates.length !== 1 ? "es" : ""}?`);
|
|
1397
|
+
if (ok) {
|
|
1398
|
+
for (const branch of goneCandidates) {
|
|
1399
|
+
if (branch === currentBranch) {
|
|
1400
|
+
const result2 = await handleCurrentBranchDeletion(currentBranch, baseBranch, config);
|
|
1401
|
+
if (result2 === "skipped") {
|
|
1402
|
+
warn(` Skipped ${branch}.`);
|
|
1403
|
+
continue;
|
|
1404
|
+
}
|
|
1405
|
+
if (result2 === "saved") {
|
|
1406
|
+
currentBranch = baseBranch;
|
|
1407
|
+
continue;
|
|
1408
|
+
}
|
|
1409
|
+
currentBranch = baseBranch;
|
|
1410
|
+
}
|
|
1411
|
+
const result = await forceDeleteBranch(branch);
|
|
1412
|
+
if (result.exitCode === 0) {
|
|
1413
|
+
success(` Deleted ${pc5.bold(branch)}`);
|
|
1414
|
+
} else {
|
|
1415
|
+
warn(` Failed to delete ${branch}: ${result.stderr.trim()}`);
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
} else {
|
|
1419
|
+
info("Skipped stale branch deletion.");
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
if (mergedCandidates.length === 0 && goneCandidates.length === 0) {
|
|
1423
|
+
info("No branches to clean up. Everything is tidy! \uD83E\uDDF9");
|
|
1424
|
+
}
|
|
1425
|
+
const finalBranch = await getCurrentBranch();
|
|
1426
|
+
if (finalBranch && protectedBranches.has(finalBranch)) {
|
|
1427
|
+
console.log();
|
|
1428
|
+
info(`You're on ${pc5.bold(finalBranch)}. Run ${pc5.bold("contrib start")} to begin a new feature.`);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
});
|
|
1432
|
+
|
|
1433
|
+
// src/commands/commit.ts
|
|
1434
|
+
import { defineCommand as defineCommand3 } from "citty";
|
|
1435
|
+
import pc6 from "picocolors";
|
|
1436
|
+
|
|
1437
|
+
// src/utils/convention.ts
|
|
1438
|
+
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;
|
|
1439
|
+
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}$/;
|
|
1440
|
+
var CONVENTION_LABELS = {
|
|
1441
|
+
conventional: "Conventional Commits",
|
|
1442
|
+
"clean-commit": "Clean Commit (by WGTech Labs)",
|
|
1443
|
+
none: "No convention"
|
|
1444
|
+
};
|
|
1445
|
+
var CONVENTION_DESCRIPTIONS = {
|
|
1446
|
+
conventional: "Conventional Commits — feat: | fix: | docs: | chore: etc. (conventionalcommits.org)",
|
|
1447
|
+
"clean-commit": "Clean Commit — \uD83D\uDCE6 new: | \uD83D\uDD27 update: | \uD83D\uDDD1️ remove: etc. (by WGTech Labs)",
|
|
1448
|
+
none: "No commit convention enforcement"
|
|
1449
|
+
};
|
|
1450
|
+
var CONVENTION_FORMAT_HINTS = {
|
|
1451
|
+
conventional: [
|
|
1452
|
+
"Format: <type>[!][(<scope>)]: <description>",
|
|
1453
|
+
"Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert",
|
|
1454
|
+
"Examples: feat: add login page | fix(auth): resolve token expiry | docs: update README"
|
|
1455
|
+
],
|
|
1456
|
+
"clean-commit": [
|
|
1457
|
+
"Format: <emoji> <type>[!][(<scope>)]: <description>",
|
|
1458
|
+
"Types: \uD83D\uDCE6 new | \uD83D\uDD27 update | \uD83D\uDDD1️ remove | \uD83D\uDD12 security | ⚙️ setup | ☕ chore | \uD83E\uDDEA test | \uD83D\uDCD6 docs | \uD83D\uDE80 release",
|
|
1459
|
+
"Examples: \uD83D\uDCE6 new: user auth | \uD83D\uDD27 update (api): improve errors | ⚙️ setup (ci): add workflow"
|
|
1460
|
+
]
|
|
1461
|
+
};
|
|
1462
|
+
function validateCommitMessage(message, convention) {
|
|
1463
|
+
if (convention === "none")
|
|
1464
|
+
return true;
|
|
1465
|
+
if (convention === "clean-commit")
|
|
1466
|
+
return CLEAN_COMMIT_PATTERN.test(message);
|
|
1467
|
+
if (convention === "conventional")
|
|
1468
|
+
return CONVENTIONAL_COMMIT_PATTERN.test(message);
|
|
1469
|
+
return true;
|
|
1470
|
+
}
|
|
1471
|
+
function getValidationError(convention) {
|
|
1472
|
+
if (convention === "none")
|
|
1473
|
+
return [];
|
|
1474
|
+
return [
|
|
1475
|
+
`Commit message does not follow ${CONVENTION_LABELS[convention]} format.`,
|
|
1476
|
+
...CONVENTION_FORMAT_HINTS[convention]
|
|
1477
|
+
];
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// src/commands/commit.ts
|
|
1481
|
+
var commit_default = defineCommand3({
|
|
1482
|
+
meta: {
|
|
1483
|
+
name: "commit",
|
|
1484
|
+
description: "Stage changes and create a commit message (AI-powered)"
|
|
1485
|
+
},
|
|
1486
|
+
args: {
|
|
1487
|
+
model: {
|
|
1488
|
+
type: "string",
|
|
1489
|
+
description: "AI model to use for commit message generation"
|
|
1490
|
+
},
|
|
1491
|
+
"no-ai": {
|
|
1492
|
+
type: "boolean",
|
|
1493
|
+
description: "Skip AI and write commit message manually",
|
|
1494
|
+
default: false
|
|
1495
|
+
},
|
|
1496
|
+
group: {
|
|
1497
|
+
type: "boolean",
|
|
1498
|
+
description: "AI groups related changes into separate atomic commits",
|
|
1499
|
+
default: false
|
|
1500
|
+
}
|
|
1501
|
+
},
|
|
1502
|
+
async run({ args }) {
|
|
1503
|
+
if (!await isGitRepo()) {
|
|
1504
|
+
error("Not inside a git repository.");
|
|
1505
|
+
process.exit(1);
|
|
1506
|
+
}
|
|
1507
|
+
const config = readConfig();
|
|
1508
|
+
if (!config) {
|
|
1509
|
+
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
1510
|
+
process.exit(1);
|
|
1511
|
+
}
|
|
1512
|
+
heading("\uD83D\uDCBE contrib commit");
|
|
1513
|
+
if (args.group) {
|
|
1514
|
+
await runGroupCommit(args.model, config);
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
let stagedFiles = await getStagedFiles();
|
|
1518
|
+
if (stagedFiles.length === 0) {
|
|
1519
|
+
const changedFiles = await getChangedFiles();
|
|
1520
|
+
if (changedFiles.length === 0) {
|
|
1521
|
+
error("No changes to commit.");
|
|
1522
|
+
process.exit(1);
|
|
1523
|
+
}
|
|
1524
|
+
console.log(`
|
|
1525
|
+
${pc6.bold("Changed files:")}`);
|
|
1526
|
+
for (const f of changedFiles) {
|
|
1527
|
+
console.log(` ${pc6.dim("•")} ${f}`);
|
|
1528
|
+
}
|
|
1529
|
+
const stageAction = await selectPrompt("No staged changes. How would you like to stage?", [
|
|
1530
|
+
"Stage all changes",
|
|
1531
|
+
"Select files to stage",
|
|
1532
|
+
"Cancel"
|
|
1533
|
+
]);
|
|
1534
|
+
if (stageAction === "Cancel") {
|
|
1535
|
+
process.exit(0);
|
|
1536
|
+
}
|
|
1537
|
+
if (stageAction === "Stage all changes") {
|
|
1538
|
+
const result2 = await stageAll();
|
|
1539
|
+
if (result2.exitCode !== 0) {
|
|
1540
|
+
error(`Failed to stage files: ${result2.stderr}`);
|
|
1541
|
+
process.exit(1);
|
|
1542
|
+
}
|
|
1543
|
+
success("Staged all changes.");
|
|
1544
|
+
} else {
|
|
1545
|
+
const selected = await multiSelectPrompt("Select files to stage:", changedFiles);
|
|
1546
|
+
if (selected.length === 0) {
|
|
1547
|
+
error("No files selected.");
|
|
1548
|
+
process.exit(1);
|
|
1549
|
+
}
|
|
1550
|
+
const result2 = await stageFiles(selected);
|
|
1551
|
+
if (result2.exitCode !== 0) {
|
|
1552
|
+
error(`Failed to stage files: ${result2.stderr}`);
|
|
1553
|
+
process.exit(1);
|
|
1554
|
+
}
|
|
1555
|
+
success(`Staged ${selected.length} file(s).`);
|
|
1556
|
+
}
|
|
1557
|
+
stagedFiles = await getStagedFiles();
|
|
1558
|
+
if (stagedFiles.length === 0) {
|
|
1559
|
+
error("No staged changes after staging attempt.");
|
|
1560
|
+
process.exit(1);
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
info(`Staged files: ${stagedFiles.join(", ")}`);
|
|
1564
|
+
let commitMessage = null;
|
|
1565
|
+
const useAI = !args["no-ai"];
|
|
1566
|
+
if (useAI) {
|
|
1567
|
+
const [copilotError, diff] = await Promise.all([checkCopilotAvailable(), getStagedDiff()]);
|
|
1568
|
+
if (copilotError) {
|
|
1569
|
+
warn(`AI unavailable: ${copilotError}`);
|
|
1570
|
+
warn("Falling back to manual commit message entry.");
|
|
1571
|
+
} else {
|
|
1572
|
+
const spinner = createSpinner("Generating commit message with AI...");
|
|
1573
|
+
commitMessage = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
|
|
1574
|
+
if (commitMessage) {
|
|
1575
|
+
spinner.success("AI commit message generated.");
|
|
1576
|
+
console.log(`
|
|
1577
|
+
${pc6.dim("AI suggestion:")} ${pc6.bold(pc6.cyan(commitMessage))}`);
|
|
1578
|
+
} else {
|
|
1579
|
+
spinner.fail("AI did not return a commit message.");
|
|
1580
|
+
warn("Falling back to manual entry.");
|
|
1581
|
+
}
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
let finalMessage = null;
|
|
1585
|
+
if (commitMessage) {
|
|
1586
|
+
const action = await selectPrompt("What would you like to do?", [
|
|
1587
|
+
"Accept this message",
|
|
1588
|
+
"Edit this message",
|
|
1589
|
+
"Regenerate",
|
|
1590
|
+
"Write manually"
|
|
1591
|
+
]);
|
|
1592
|
+
if (action === "Accept this message") {
|
|
1593
|
+
finalMessage = commitMessage;
|
|
1594
|
+
} else if (action === "Edit this message") {
|
|
1595
|
+
finalMessage = await inputPrompt("Edit commit message", commitMessage);
|
|
1596
|
+
} else if (action === "Regenerate") {
|
|
1597
|
+
const spinner = createSpinner("Regenerating commit message...");
|
|
1598
|
+
const diff = await getStagedDiff();
|
|
1599
|
+
const regen = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
|
|
1600
|
+
if (regen) {
|
|
1601
|
+
spinner.success("Commit message regenerated.");
|
|
1602
|
+
console.log(`
|
|
1603
|
+
${pc6.dim("AI suggestion:")} ${pc6.bold(pc6.cyan(regen))}`);
|
|
1604
|
+
const ok = await confirmPrompt("Use this message?");
|
|
1605
|
+
finalMessage = ok ? regen : await inputPrompt("Enter commit message manually");
|
|
1606
|
+
} else {
|
|
1607
|
+
spinner.fail("Regeneration failed.");
|
|
1608
|
+
finalMessage = await inputPrompt("Enter commit message");
|
|
1609
|
+
}
|
|
1610
|
+
} else {
|
|
1611
|
+
finalMessage = await inputPrompt("Enter commit message");
|
|
1612
|
+
}
|
|
1613
|
+
} else {
|
|
1614
|
+
const convention2 = config.commitConvention;
|
|
1615
|
+
if (convention2 !== "none") {
|
|
1616
|
+
console.log();
|
|
1617
|
+
for (const hint of CONVENTION_FORMAT_HINTS[convention2]) {
|
|
1618
|
+
console.log(pc6.dim(hint));
|
|
1619
|
+
}
|
|
1620
|
+
console.log();
|
|
1621
|
+
}
|
|
1622
|
+
finalMessage = await inputPrompt("Enter commit message");
|
|
1623
|
+
}
|
|
1624
|
+
if (!finalMessage) {
|
|
1625
|
+
error("No commit message provided.");
|
|
1626
|
+
process.exit(1);
|
|
1627
|
+
}
|
|
1628
|
+
const convention = config.commitConvention;
|
|
1629
|
+
if (!validateCommitMessage(finalMessage, convention)) {
|
|
1630
|
+
for (const line of getValidationError(convention)) {
|
|
1631
|
+
warn(line);
|
|
1632
|
+
}
|
|
1633
|
+
const proceed = await confirmPrompt("Commit anyway?");
|
|
1634
|
+
if (!proceed)
|
|
1635
|
+
process.exit(1);
|
|
1636
|
+
}
|
|
1637
|
+
const result = await commitWithMessage(finalMessage);
|
|
1638
|
+
if (result.exitCode !== 0) {
|
|
1639
|
+
error(`Failed to commit: ${result.stderr}`);
|
|
1640
|
+
process.exit(1);
|
|
1641
|
+
}
|
|
1642
|
+
success(`✅ Committed: ${pc6.bold(finalMessage)}`);
|
|
1643
|
+
}
|
|
1644
|
+
});
|
|
1645
|
+
async function runGroupCommit(model, config) {
|
|
1646
|
+
const [copilotError, changedFiles] = await Promise.all([
|
|
1647
|
+
checkCopilotAvailable(),
|
|
1648
|
+
getChangedFiles()
|
|
1649
|
+
]);
|
|
1650
|
+
if (copilotError) {
|
|
1651
|
+
error(`AI is required for --group mode but unavailable: ${copilotError}`);
|
|
1652
|
+
process.exit(1);
|
|
1653
|
+
}
|
|
1654
|
+
if (changedFiles.length === 0) {
|
|
1655
|
+
error("No changes to group-commit.");
|
|
1656
|
+
process.exit(1);
|
|
1657
|
+
}
|
|
1658
|
+
console.log(`
|
|
1659
|
+
${pc6.bold("Changed files:")}`);
|
|
1660
|
+
for (const f of changedFiles) {
|
|
1661
|
+
console.log(` ${pc6.dim("•")} ${f}`);
|
|
1662
|
+
}
|
|
1663
|
+
const spinner = createSpinner(`Asking AI to group ${changedFiles.length} file(s) into logical commits...`);
|
|
1664
|
+
const diffs = await getFullDiffForFiles(changedFiles);
|
|
1665
|
+
if (!diffs.trim()) {
|
|
1666
|
+
spinner.stop();
|
|
1667
|
+
warn("Could not retrieve diff context for any files. AI needs diffs to produce groups.");
|
|
1668
|
+
}
|
|
1669
|
+
let groups;
|
|
1670
|
+
try {
|
|
1671
|
+
groups = await generateCommitGroups(changedFiles, diffs, model, config.commitConvention);
|
|
1672
|
+
spinner.success(`AI generated ${groups.length} commit group(s).`);
|
|
1673
|
+
} catch (err) {
|
|
1674
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1675
|
+
spinner.fail(`AI grouping failed: ${reason}`);
|
|
1676
|
+
process.exit(1);
|
|
1677
|
+
}
|
|
1678
|
+
if (groups.length === 0) {
|
|
1679
|
+
error("AI could not produce commit groups. Try committing files manually.");
|
|
1680
|
+
process.exit(1);
|
|
1681
|
+
}
|
|
1682
|
+
const changedSet = new Set(changedFiles);
|
|
1683
|
+
for (const group of groups) {
|
|
1684
|
+
const invalid = group.files.filter((f) => !changedSet.has(f));
|
|
1685
|
+
if (invalid.length > 0) {
|
|
1686
|
+
warn(`AI suggested unknown file(s): ${invalid.join(", ")} — removed from group.`);
|
|
1687
|
+
}
|
|
1688
|
+
group.files = group.files.filter((f) => changedSet.has(f));
|
|
1689
|
+
}
|
|
1690
|
+
let validGroups = groups.filter((g) => g.files.length > 0);
|
|
1691
|
+
if (validGroups.length === 0) {
|
|
1692
|
+
error("No valid groups remain after validation. Try committing files manually.");
|
|
1693
|
+
process.exit(1);
|
|
1694
|
+
}
|
|
1695
|
+
let proceedToCommit = false;
|
|
1696
|
+
let commitAll = false;
|
|
1697
|
+
while (!proceedToCommit) {
|
|
1698
|
+
console.log(`
|
|
1699
|
+
${pc6.bold(`AI suggested ${validGroups.length} commit group(s):`)}
|
|
1700
|
+
`);
|
|
1701
|
+
for (let i = 0;i < validGroups.length; i++) {
|
|
1702
|
+
const g = validGroups[i];
|
|
1703
|
+
console.log(` ${pc6.cyan(`Group ${i + 1}:`)} ${pc6.bold(g.message)}`);
|
|
1704
|
+
for (const f of g.files) {
|
|
1705
|
+
console.log(` ${pc6.dim("•")} ${f}`);
|
|
1706
|
+
}
|
|
1707
|
+
console.log();
|
|
1708
|
+
}
|
|
1709
|
+
const summaryAction = await selectPrompt("What would you like to do?", [
|
|
1710
|
+
"Commit all",
|
|
1711
|
+
"Review each group",
|
|
1712
|
+
"Regenerate all messages",
|
|
1713
|
+
"Cancel"
|
|
1714
|
+
]);
|
|
1715
|
+
if (summaryAction === "Cancel") {
|
|
1716
|
+
warn("Group commit cancelled.");
|
|
1717
|
+
process.exit(0);
|
|
1718
|
+
}
|
|
1719
|
+
if (summaryAction === "Regenerate all messages") {
|
|
1720
|
+
const regenSpinner = createSpinner("Regenerating all commit messages...");
|
|
1721
|
+
try {
|
|
1722
|
+
validGroups = await regenerateAllGroupMessages(validGroups, diffs, model, config.commitConvention);
|
|
1723
|
+
regenSpinner.success("All commit messages regenerated.");
|
|
1724
|
+
} catch {
|
|
1725
|
+
regenSpinner.fail("Failed to regenerate messages. Keeping current ones.");
|
|
1726
|
+
}
|
|
1727
|
+
continue;
|
|
1728
|
+
}
|
|
1729
|
+
proceedToCommit = true;
|
|
1730
|
+
commitAll = summaryAction === "Commit all";
|
|
1731
|
+
}
|
|
1732
|
+
let committed = 0;
|
|
1733
|
+
if (commitAll) {
|
|
1734
|
+
for (let i = 0;i < validGroups.length; i++) {
|
|
1735
|
+
const group = validGroups[i];
|
|
1736
|
+
const stageResult = await stageFiles(group.files);
|
|
1737
|
+
if (stageResult.exitCode !== 0) {
|
|
1738
|
+
error(`Failed to stage group ${i + 1}: ${stageResult.stderr}`);
|
|
1739
|
+
continue;
|
|
1740
|
+
}
|
|
1741
|
+
const commitResult = await commitWithMessage(group.message);
|
|
1742
|
+
if (commitResult.exitCode !== 0) {
|
|
1743
|
+
const detail = (commitResult.stderr || commitResult.stdout).trim();
|
|
1744
|
+
error(`Failed to commit group ${i + 1}: ${detail}`);
|
|
1745
|
+
await unstageFiles(group.files);
|
|
1746
|
+
continue;
|
|
1747
|
+
}
|
|
1748
|
+
committed++;
|
|
1749
|
+
success(`✅ Committed group ${i + 1}: ${pc6.bold(group.message)}`);
|
|
1750
|
+
}
|
|
1751
|
+
} else {
|
|
1752
|
+
for (let i = 0;i < validGroups.length; i++) {
|
|
1753
|
+
const group = validGroups[i];
|
|
1754
|
+
console.log(pc6.bold(`
|
|
1755
|
+
── Group ${i + 1}/${validGroups.length} ──`));
|
|
1756
|
+
console.log(` ${pc6.cyan(group.message)}`);
|
|
1757
|
+
for (const f of group.files) {
|
|
1758
|
+
console.log(` ${pc6.dim("•")} ${f}`);
|
|
1759
|
+
}
|
|
1760
|
+
let message = group.message;
|
|
1761
|
+
let actionDone = false;
|
|
1762
|
+
while (!actionDone) {
|
|
1763
|
+
const action = await selectPrompt("Action for this group:", [
|
|
1764
|
+
"Commit as-is",
|
|
1765
|
+
"Edit message and commit",
|
|
1766
|
+
"Regenerate message",
|
|
1767
|
+
"Skip this group"
|
|
1768
|
+
]);
|
|
1769
|
+
if (action === "Skip this group") {
|
|
1770
|
+
warn(`Skipped group ${i + 1}.`);
|
|
1771
|
+
actionDone = true;
|
|
1772
|
+
continue;
|
|
1773
|
+
}
|
|
1774
|
+
if (action === "Regenerate message") {
|
|
1775
|
+
const regenSpinner = createSpinner("Regenerating commit message for this group...");
|
|
1776
|
+
const newMsg = await regenerateGroupMessage(group.files, diffs, model, config.commitConvention);
|
|
1777
|
+
if (newMsg) {
|
|
1778
|
+
message = newMsg;
|
|
1779
|
+
group.message = newMsg;
|
|
1780
|
+
regenSpinner.success(`New message: ${pc6.bold(message)}`);
|
|
1781
|
+
} else {
|
|
1782
|
+
regenSpinner.fail("AI could not generate a new message. Keeping current one.");
|
|
1783
|
+
}
|
|
1784
|
+
continue;
|
|
1785
|
+
}
|
|
1786
|
+
if (action === "Edit message and commit") {
|
|
1787
|
+
message = await inputPrompt("Edit commit message", message);
|
|
1788
|
+
if (!message) {
|
|
1789
|
+
warn(`Skipped group ${i + 1} (empty message).`);
|
|
1790
|
+
actionDone = true;
|
|
1791
|
+
continue;
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
if (!validateCommitMessage(message, config.commitConvention)) {
|
|
1795
|
+
for (const line of getValidationError(config.commitConvention)) {
|
|
1796
|
+
warn(line);
|
|
1797
|
+
}
|
|
1798
|
+
const proceed = await confirmPrompt("Commit anyway?");
|
|
1799
|
+
if (!proceed) {
|
|
1800
|
+
warn(`Skipped group ${i + 1}.`);
|
|
1801
|
+
actionDone = true;
|
|
1802
|
+
continue;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
const stageResult = await stageFiles(group.files);
|
|
1806
|
+
if (stageResult.exitCode !== 0) {
|
|
1807
|
+
error(`Failed to stage group ${i + 1}: ${stageResult.stderr}`);
|
|
1808
|
+
actionDone = true;
|
|
1809
|
+
continue;
|
|
1810
|
+
}
|
|
1811
|
+
const commitResult = await commitWithMessage(message);
|
|
1812
|
+
if (commitResult.exitCode !== 0) {
|
|
1813
|
+
const detail = (commitResult.stderr || commitResult.stdout).trim();
|
|
1814
|
+
error(`Failed to commit group ${i + 1}: ${detail}`);
|
|
1815
|
+
await unstageFiles(group.files);
|
|
1816
|
+
actionDone = true;
|
|
1817
|
+
continue;
|
|
1818
|
+
}
|
|
1819
|
+
committed++;
|
|
1820
|
+
success(`✅ Committed group ${i + 1}: ${pc6.bold(message)}`);
|
|
1821
|
+
actionDone = true;
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
if (committed === 0) {
|
|
1826
|
+
warn("No groups were committed.");
|
|
1827
|
+
} else {
|
|
1828
|
+
success(`
|
|
1829
|
+
\uD83C\uDF89 ${committed} of ${validGroups.length} group(s) committed successfully.`);
|
|
1830
|
+
}
|
|
1831
|
+
process.exit(0);
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
// src/commands/doctor.ts
|
|
1835
|
+
import { execFile as execFileCb3 } from "node:child_process";
|
|
1836
|
+
import { defineCommand as defineCommand4 } from "citty";
|
|
1837
|
+
import pc7 from "picocolors";
|
|
1838
|
+
// package.json
|
|
1839
|
+
var package_default = {
|
|
1840
|
+
name: "contribute-now",
|
|
1841
|
+
version: "0.2.0-dev.f9ae5fc",
|
|
1842
|
+
description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
|
|
1843
|
+
type: "module",
|
|
1844
|
+
bin: {
|
|
1845
|
+
contrib: "dist/index.js",
|
|
1846
|
+
contribute: "dist/index.js"
|
|
1847
|
+
},
|
|
1848
|
+
files: [
|
|
1849
|
+
"dist"
|
|
1850
|
+
],
|
|
1851
|
+
scripts: {
|
|
1852
|
+
build: "bun build src/index.ts --outfile dist/index.js --target node --packages external",
|
|
1853
|
+
cli: "bun run src/index.ts --",
|
|
1854
|
+
dev: "bun src/index.ts",
|
|
1855
|
+
test: "bun test",
|
|
1856
|
+
lint: "biome check .",
|
|
1857
|
+
"lint:fix": "biome check --write .",
|
|
1858
|
+
format: "biome format --write .",
|
|
1859
|
+
"www:dev": "bun run --cwd www dev",
|
|
1860
|
+
"www:build": "bun run --cwd www build",
|
|
1861
|
+
"www:preview": "bun run --cwd www preview"
|
|
1862
|
+
},
|
|
1863
|
+
engines: {
|
|
1864
|
+
node: ">=18",
|
|
1865
|
+
bun: ">=1.0"
|
|
1866
|
+
},
|
|
1867
|
+
keywords: [
|
|
1868
|
+
"git",
|
|
1869
|
+
"workflow",
|
|
1870
|
+
"squash-merge",
|
|
1871
|
+
"sync",
|
|
1872
|
+
"cli",
|
|
1873
|
+
"contribute",
|
|
1874
|
+
"fork",
|
|
1875
|
+
"dev-branch",
|
|
1876
|
+
"clean-commit"
|
|
1877
|
+
],
|
|
1878
|
+
author: "Waren Gonzaga",
|
|
1879
|
+
license: "GPL-3.0",
|
|
1880
|
+
repository: {
|
|
1881
|
+
type: "git",
|
|
1882
|
+
url: "git+https://github.com/warengonzaga/contribute-now.git"
|
|
1883
|
+
},
|
|
1884
|
+
dependencies: {
|
|
1885
|
+
"@clack/prompts": "^1.0.1",
|
|
1886
|
+
"@github/copilot-sdk": "^0.1.25",
|
|
1887
|
+
"@wgtechlabs/log-engine": "^2.3.1",
|
|
1888
|
+
citty: "^0.1.6",
|
|
1889
|
+
figlet: "^1.10.0",
|
|
1890
|
+
picocolors: "^1.1.1"
|
|
1891
|
+
},
|
|
1892
|
+
devDependencies: {
|
|
1893
|
+
"@biomejs/biome": "^2.4.4",
|
|
1894
|
+
"@types/bun": "latest",
|
|
1895
|
+
"@types/figlet": "^1.7.0",
|
|
1896
|
+
typescript: "^5.7.0"
|
|
1897
|
+
}
|
|
1898
|
+
};
|
|
1899
|
+
|
|
1900
|
+
// src/utils/remote.ts
|
|
1901
|
+
function parseRepoFromUrl(url) {
|
|
1902
|
+
const httpsMatch = url.match(/https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
1903
|
+
if (httpsMatch) {
|
|
1904
|
+
return { owner: httpsMatch[1], repo: httpsMatch[2] };
|
|
1905
|
+
}
|
|
1906
|
+
const sshMatch = url.match(/git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
1907
|
+
if (sshMatch) {
|
|
1908
|
+
return { owner: sshMatch[1], repo: sshMatch[2] };
|
|
1909
|
+
}
|
|
1910
|
+
return null;
|
|
1911
|
+
}
|
|
1912
|
+
async function detectForkSetup() {
|
|
1913
|
+
const remotes = await getRemotes();
|
|
1914
|
+
const hasOrigin = remotes.includes("origin");
|
|
1915
|
+
const hasUpstream = remotes.includes("upstream");
|
|
1916
|
+
return {
|
|
1917
|
+
isFork: hasUpstream,
|
|
1918
|
+
originRemote: hasOrigin ? "origin" : null,
|
|
1919
|
+
upstreamRemote: hasUpstream ? "upstream" : null
|
|
1920
|
+
};
|
|
1921
|
+
}
|
|
1922
|
+
async function getRepoInfoFromRemote(remote = "origin") {
|
|
1923
|
+
const url = await getRemoteUrl(remote);
|
|
1924
|
+
if (!url)
|
|
1925
|
+
return null;
|
|
1926
|
+
return parseRepoFromUrl(url);
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// src/commands/doctor.ts
|
|
1930
|
+
var PASS = ` ${pc7.green("✔")} `;
|
|
1931
|
+
var FAIL = ` ${pc7.red("✗")} `;
|
|
1932
|
+
var WARN = ` ${pc7.yellow("⚠")} `;
|
|
1933
|
+
function printReport(report) {
|
|
1934
|
+
for (const section of report.sections) {
|
|
1935
|
+
console.log(`
|
|
1936
|
+
${pc7.bold(pc7.underline(section.title))}`);
|
|
1937
|
+
for (const check of section.checks) {
|
|
1938
|
+
const prefix = check.ok ? check.warning ? WARN : PASS : FAIL;
|
|
1939
|
+
const text2 = check.detail ? `${check.label} ${pc7.dim(`— ${check.detail}`)}` : check.label;
|
|
1940
|
+
console.log(`${prefix}${text2}`);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
console.log();
|
|
1944
|
+
}
|
|
1945
|
+
function toJson(report) {
|
|
1946
|
+
return JSON.stringify(report.sections.map((s) => ({
|
|
1947
|
+
section: s.title,
|
|
1948
|
+
checks: s.checks.map((c) => ({
|
|
1949
|
+
label: c.label,
|
|
1950
|
+
ok: c.ok,
|
|
1951
|
+
warning: c.warning ?? false,
|
|
1952
|
+
detail: c.detail ?? null
|
|
1953
|
+
}))
|
|
1954
|
+
})), null, 2);
|
|
1955
|
+
}
|
|
1956
|
+
function runCmd(cmd, args) {
|
|
1957
|
+
return new Promise((resolve) => {
|
|
1958
|
+
execFileCb3(cmd, args, (error2, stdout) => {
|
|
1959
|
+
resolve({
|
|
1960
|
+
ok: !error2,
|
|
1961
|
+
stdout: (stdout ?? "").trim()
|
|
1962
|
+
});
|
|
1963
|
+
});
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
async function toolSection() {
|
|
1967
|
+
const checks = [];
|
|
1968
|
+
checks.push({
|
|
1969
|
+
label: `contrib v${package_default.version ?? "unknown"}`,
|
|
1970
|
+
ok: true
|
|
1971
|
+
});
|
|
1972
|
+
const runtime = typeof globalThis.Bun !== "undefined" ? `Bun ${globalThis.Bun.version ?? "?"}` : `Node ${process.version}`;
|
|
1973
|
+
checks.push({ label: runtime, ok: true, detail: `${process.platform}-${process.arch}` });
|
|
1974
|
+
return { title: "Tool", checks };
|
|
1975
|
+
}
|
|
1976
|
+
async function depsSection() {
|
|
1977
|
+
const checks = [];
|
|
1978
|
+
const git = await runCmd("git", ["--version"]);
|
|
1979
|
+
checks.push({
|
|
1980
|
+
label: git.ok ? git.stdout.replace("git version ", "git ") : "git not found",
|
|
1981
|
+
ok: git.ok
|
|
1982
|
+
});
|
|
1983
|
+
const ghInstalled = await checkGhInstalled();
|
|
1984
|
+
if (ghInstalled) {
|
|
1985
|
+
const ghVer = await runCmd("gh", ["--version"]);
|
|
1986
|
+
const ver = ghVer.stdout.split(`
|
|
1987
|
+
`)[0] ?? "gh";
|
|
1988
|
+
checks.push({ label: ver, ok: true });
|
|
1989
|
+
const ghAuth = await checkGhAuth();
|
|
1990
|
+
checks.push({
|
|
1991
|
+
label: ghAuth ? "gh authenticated" : "gh not authenticated",
|
|
1992
|
+
ok: ghAuth,
|
|
1993
|
+
warning: !ghAuth,
|
|
1994
|
+
detail: ghAuth ? undefined : "run `gh auth login`"
|
|
1995
|
+
});
|
|
1996
|
+
} else {
|
|
1997
|
+
checks.push({
|
|
1998
|
+
label: "gh CLI not installed",
|
|
1999
|
+
ok: false,
|
|
2000
|
+
detail: "install from https://cli.github.com"
|
|
2001
|
+
});
|
|
2002
|
+
}
|
|
2003
|
+
try {
|
|
2004
|
+
await import("@github/copilot-sdk");
|
|
2005
|
+
checks.push({ label: "Copilot SDK importable", ok: true });
|
|
2006
|
+
} catch {
|
|
2007
|
+
checks.push({
|
|
2008
|
+
label: "Copilot SDK not loadable",
|
|
2009
|
+
ok: false,
|
|
2010
|
+
warning: true,
|
|
2011
|
+
detail: "AI features will be unavailable"
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
return { title: "Dependencies", checks };
|
|
2015
|
+
}
|
|
2016
|
+
async function configSection() {
|
|
2017
|
+
const checks = [];
|
|
2018
|
+
const exists = configExists();
|
|
2019
|
+
if (!exists) {
|
|
2020
|
+
checks.push({
|
|
2021
|
+
label: ".contributerc.json not found",
|
|
2022
|
+
ok: false,
|
|
2023
|
+
detail: "run `contrib setup` to create it"
|
|
2024
|
+
});
|
|
2025
|
+
return { title: "Config", checks };
|
|
2026
|
+
}
|
|
2027
|
+
const config = readConfig();
|
|
2028
|
+
if (!config) {
|
|
2029
|
+
checks.push({ label: ".contributerc.json found but invalid", ok: false });
|
|
2030
|
+
return { title: "Config", checks };
|
|
2031
|
+
}
|
|
2032
|
+
checks.push({ label: ".contributerc.json found and valid", ok: true });
|
|
2033
|
+
const desc = WORKFLOW_DESCRIPTIONS[config.workflow] ?? config.workflow;
|
|
2034
|
+
checks.push({
|
|
2035
|
+
label: `Workflow: ${config.workflow}`,
|
|
2036
|
+
ok: true,
|
|
2037
|
+
detail: desc
|
|
2038
|
+
});
|
|
2039
|
+
checks.push({ label: `Role: ${config.role}`, ok: true });
|
|
2040
|
+
checks.push({ label: `Commit convention: ${config.commitConvention}`, ok: true });
|
|
2041
|
+
if (hasDevBranch(config.workflow)) {
|
|
2042
|
+
checks.push({
|
|
2043
|
+
label: `Dev branch: ${config.devBranch ?? "(not set)"}`,
|
|
2044
|
+
ok: !!config.devBranch
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
const ignored = isGitignored();
|
|
2048
|
+
checks.push({
|
|
2049
|
+
label: ignored ? ".contributerc.json in .gitignore" : ".contributerc.json NOT in .gitignore",
|
|
2050
|
+
ok: true,
|
|
2051
|
+
warning: !ignored,
|
|
2052
|
+
detail: ignored ? undefined : "consider adding it to .gitignore"
|
|
2053
|
+
});
|
|
2054
|
+
return { title: "Config", checks };
|
|
2055
|
+
}
|
|
2056
|
+
async function gitSection() {
|
|
2057
|
+
const checks = [];
|
|
2058
|
+
const inRepo = await isGitRepo();
|
|
2059
|
+
checks.push({
|
|
2060
|
+
label: inRepo ? "Inside a git repository" : "Not inside a git repository",
|
|
2061
|
+
ok: inRepo
|
|
2062
|
+
});
|
|
2063
|
+
if (!inRepo)
|
|
2064
|
+
return { title: "Git Environment", checks };
|
|
2065
|
+
const branch = await getCurrentBranch();
|
|
2066
|
+
const head = await runCmd("git", ["rev-parse", "--short", "HEAD"]);
|
|
2067
|
+
checks.push({
|
|
2068
|
+
label: `Branch: ${branch ?? "(detached)"}`,
|
|
2069
|
+
ok: !!branch,
|
|
2070
|
+
detail: head.ok ? `HEAD ${head.stdout}` : undefined
|
|
2071
|
+
});
|
|
2072
|
+
const remotes = await getRemotes();
|
|
2073
|
+
if (remotes.length === 0) {
|
|
2074
|
+
checks.push({ label: "No remotes configured", ok: false, warning: true });
|
|
2075
|
+
} else {
|
|
2076
|
+
for (const remote of remotes) {
|
|
2077
|
+
const url = await getRemoteUrl(remote);
|
|
2078
|
+
const repoInfo = url ? parseRepoFromUrl(url) : null;
|
|
2079
|
+
const detail = repoInfo ? `${repoInfo.owner}/${repoInfo.repo}` : url ?? "unknown URL";
|
|
2080
|
+
checks.push({ label: `Remote: ${remote}`, ok: true, detail });
|
|
2081
|
+
}
|
|
2082
|
+
}
|
|
2083
|
+
const dirty = await hasUncommittedChanges();
|
|
2084
|
+
checks.push({
|
|
2085
|
+
label: dirty ? "Uncommitted changes detected" : "Working tree clean",
|
|
2086
|
+
ok: true,
|
|
2087
|
+
warning: dirty
|
|
2088
|
+
});
|
|
2089
|
+
return { title: "Git Environment", checks };
|
|
2090
|
+
}
|
|
2091
|
+
async function forkSection() {
|
|
2092
|
+
const checks = [];
|
|
2093
|
+
const fork = await detectForkSetup();
|
|
2094
|
+
checks.push({
|
|
2095
|
+
label: fork.isFork ? "Fork detected (upstream remote exists)" : "Not a fork (no upstream remote)",
|
|
2096
|
+
ok: true
|
|
2097
|
+
});
|
|
2098
|
+
if (fork.originRemote) {
|
|
2099
|
+
checks.push({ label: `Origin remote: ${fork.originRemote}`, ok: true });
|
|
2100
|
+
}
|
|
2101
|
+
if (fork.upstreamRemote) {
|
|
2102
|
+
checks.push({ label: `Upstream remote: ${fork.upstreamRemote}`, ok: true });
|
|
2103
|
+
}
|
|
2104
|
+
return { title: "Fork Detection", checks };
|
|
2105
|
+
}
|
|
2106
|
+
async function workflowSection() {
|
|
2107
|
+
const checks = [];
|
|
2108
|
+
const config = readConfig();
|
|
2109
|
+
if (!config) {
|
|
2110
|
+
checks.push({
|
|
2111
|
+
label: "Cannot resolve workflow (no config)",
|
|
2112
|
+
ok: false,
|
|
2113
|
+
detail: "run `contrib setup` first"
|
|
2114
|
+
});
|
|
2115
|
+
return { title: "Workflow Resolution", checks };
|
|
2116
|
+
}
|
|
2117
|
+
const baseBranch = getBaseBranch(config);
|
|
2118
|
+
checks.push({ label: `Base branch: ${baseBranch}`, ok: true });
|
|
2119
|
+
const sync = getSyncSource(config);
|
|
2120
|
+
checks.push({
|
|
2121
|
+
label: `Sync source: ${sync.ref}`,
|
|
2122
|
+
ok: true,
|
|
2123
|
+
detail: `strategy: ${sync.strategy}`
|
|
2124
|
+
});
|
|
2125
|
+
checks.push({
|
|
2126
|
+
label: `Branch prefixes: ${config.branchPrefixes.join(", ")}`,
|
|
2127
|
+
ok: config.branchPrefixes.length > 0
|
|
2128
|
+
});
|
|
2129
|
+
return { title: "Workflow Resolution", checks };
|
|
2130
|
+
}
|
|
2131
|
+
function envSection() {
|
|
2132
|
+
const checks = [];
|
|
2133
|
+
const vars = ["GITHUB_TOKEN", "GH_TOKEN", "COPILOT_AGENT_TOKEN", "NO_COLOR", "FORCE_COLOR", "CI"];
|
|
2134
|
+
for (const name of vars) {
|
|
2135
|
+
const val = process.env[name];
|
|
2136
|
+
if (val !== undefined) {
|
|
2137
|
+
const isSecret = name.toLowerCase().includes("token");
|
|
2138
|
+
const display = isSecret ? `${val.slice(0, 4)}${"*".repeat(Math.min(val.length - 4, 12))}` : val;
|
|
2139
|
+
checks.push({ label: `${name} = ${display}`, ok: true });
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
if (checks.length === 0) {
|
|
2143
|
+
checks.push({ label: "No relevant environment variables set", ok: true });
|
|
2144
|
+
}
|
|
2145
|
+
return { title: "Environment", checks };
|
|
2146
|
+
}
|
|
2147
|
+
var doctor_default = defineCommand4({
|
|
2148
|
+
meta: {
|
|
2149
|
+
name: "doctor",
|
|
2150
|
+
description: "Diagnose the contribute-now CLI environment and configuration"
|
|
2151
|
+
},
|
|
2152
|
+
args: {
|
|
2153
|
+
json: {
|
|
2154
|
+
type: "boolean",
|
|
2155
|
+
description: "Output report as JSON",
|
|
2156
|
+
default: false
|
|
688
2157
|
}
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
}
|
|
715
|
-
|
|
716
|
-
}
|
|
2158
|
+
},
|
|
2159
|
+
async run({ args }) {
|
|
2160
|
+
const isJson = args.json;
|
|
2161
|
+
const [tool, deps, config, git, fork, workflow] = await Promise.all([
|
|
2162
|
+
toolSection(),
|
|
2163
|
+
depsSection(),
|
|
2164
|
+
configSection(),
|
|
2165
|
+
gitSection(),
|
|
2166
|
+
forkSection(),
|
|
2167
|
+
workflowSection()
|
|
2168
|
+
]);
|
|
2169
|
+
const env = envSection();
|
|
2170
|
+
const report = {
|
|
2171
|
+
sections: [tool, deps, config, git, fork, workflow, env]
|
|
2172
|
+
};
|
|
2173
|
+
if (isJson) {
|
|
2174
|
+
console.log(toJson(report));
|
|
2175
|
+
return;
|
|
2176
|
+
}
|
|
2177
|
+
heading("\uD83E\uDE7A contribute-now doctor");
|
|
2178
|
+
printReport(report);
|
|
2179
|
+
const total = report.sections.flatMap((s) => s.checks);
|
|
2180
|
+
const failures = total.filter((c) => !c.ok);
|
|
2181
|
+
const warnings = total.filter((c) => c.ok && c.warning);
|
|
2182
|
+
if (failures.length === 0 && warnings.length === 0) {
|
|
2183
|
+
console.log(` ${pc7.green("All checks passed!")} No issues detected.
|
|
2184
|
+
`);
|
|
717
2185
|
} else {
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
console.log();
|
|
721
|
-
for (const hint of CONVENTION_FORMAT_HINTS[convention2]) {
|
|
722
|
-
console.log(pc4.dim(hint));
|
|
723
|
-
}
|
|
724
|
-
console.log();
|
|
2186
|
+
if (failures.length > 0) {
|
|
2187
|
+
console.log(` ${pc7.red(`${failures.length} issue${failures.length !== 1 ? "s" : ""} found.`)}`);
|
|
725
2188
|
}
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
if (!finalMessage) {
|
|
729
|
-
error("No commit message provided.");
|
|
730
|
-
process.exit(1);
|
|
731
|
-
}
|
|
732
|
-
const convention = config.commitConvention;
|
|
733
|
-
if (!validateCommitMessage(finalMessage, convention)) {
|
|
734
|
-
for (const line of getValidationError(convention)) {
|
|
735
|
-
warn(line);
|
|
2189
|
+
if (warnings.length > 0) {
|
|
2190
|
+
console.log(` ${pc7.yellow(`${warnings.length} warning${warnings.length !== 1 ? "s" : ""}.`)}`);
|
|
736
2191
|
}
|
|
737
|
-
|
|
738
|
-
if (!proceed)
|
|
739
|
-
process.exit(1);
|
|
740
|
-
}
|
|
741
|
-
const result = await commitWithMessage(finalMessage);
|
|
742
|
-
if (result.exitCode !== 0) {
|
|
743
|
-
error(`Failed to commit: ${result.stderr}`);
|
|
744
|
-
process.exit(1);
|
|
2192
|
+
console.log();
|
|
745
2193
|
}
|
|
746
|
-
success(`✅ Committed: ${pc4.bold(finalMessage)}`);
|
|
747
2194
|
}
|
|
748
2195
|
});
|
|
749
2196
|
|
|
750
2197
|
// src/commands/hook.ts
|
|
751
|
-
import { existsSync as existsSync2, mkdirSync, readFileSync as
|
|
752
|
-
import { join as
|
|
753
|
-
import { defineCommand as
|
|
754
|
-
import
|
|
2198
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
2199
|
+
import { join as join3 } from "node:path";
|
|
2200
|
+
import { defineCommand as defineCommand5 } from "citty";
|
|
2201
|
+
import pc8 from "picocolors";
|
|
755
2202
|
var HOOK_MARKER = "# managed by contribute-now";
|
|
756
2203
|
function getHooksDir(cwd = process.cwd()) {
|
|
757
|
-
return
|
|
2204
|
+
return join3(cwd, ".git", "hooks");
|
|
758
2205
|
}
|
|
759
2206
|
function getHookPath(cwd = process.cwd()) {
|
|
760
|
-
return
|
|
2207
|
+
return join3(getHooksDir(cwd), "commit-msg");
|
|
761
2208
|
}
|
|
762
2209
|
function generateHookScript() {
|
|
763
2210
|
return `#!/bin/sh
|
|
@@ -774,11 +2221,22 @@ case "$commit_msg" in
|
|
|
774
2221
|
Merge\\ *|fixup!*|squash!*|amend!*) exit 0 ;;
|
|
775
2222
|
esac
|
|
776
2223
|
|
|
777
|
-
#
|
|
778
|
-
|
|
2224
|
+
# Detect available package runner
|
|
2225
|
+
if command -v contrib >/dev/null 2>&1; then
|
|
2226
|
+
contrib validate "$commit_msg"
|
|
2227
|
+
elif command -v bunx >/dev/null 2>&1; then
|
|
2228
|
+
bunx contrib validate "$commit_msg"
|
|
2229
|
+
elif command -v pnpx >/dev/null 2>&1; then
|
|
2230
|
+
pnpx contrib validate "$commit_msg"
|
|
2231
|
+
elif command -v npx >/dev/null 2>&1; then
|
|
2232
|
+
npx contrib validate "$commit_msg"
|
|
2233
|
+
else
|
|
2234
|
+
echo "Warning: No package runner found. Skipping commit message validation."
|
|
2235
|
+
exit 0
|
|
2236
|
+
fi
|
|
779
2237
|
`;
|
|
780
2238
|
}
|
|
781
|
-
var hook_default =
|
|
2239
|
+
var hook_default = defineCommand5({
|
|
782
2240
|
meta: {
|
|
783
2241
|
name: "hook",
|
|
784
2242
|
description: "Install or uninstall the commit-msg git hook"
|
|
@@ -822,7 +2280,7 @@ async function installHook() {
|
|
|
822
2280
|
const hookPath = getHookPath();
|
|
823
2281
|
const hooksDir = getHooksDir();
|
|
824
2282
|
if (existsSync2(hookPath)) {
|
|
825
|
-
const existing =
|
|
2283
|
+
const existing = readFileSync3(hookPath, "utf-8");
|
|
826
2284
|
if (!existing.includes(HOOK_MARKER)) {
|
|
827
2285
|
error("A commit-msg hook already exists and was not installed by contribute-now.");
|
|
828
2286
|
warn(`Path: ${hookPath}`);
|
|
@@ -836,8 +2294,8 @@ async function installHook() {
|
|
|
836
2294
|
}
|
|
837
2295
|
writeFileSync2(hookPath, generateHookScript(), { mode: 493 });
|
|
838
2296
|
success(`commit-msg hook installed.`);
|
|
839
|
-
info(`Convention: ${
|
|
840
|
-
info(`Path: ${
|
|
2297
|
+
info(`Convention: ${pc8.bold(CONVENTION_LABELS[config.commitConvention])}`);
|
|
2298
|
+
info(`Path: ${pc8.dim(hookPath)}`);
|
|
841
2299
|
}
|
|
842
2300
|
async function uninstallHook() {
|
|
843
2301
|
heading("\uD83E\uDE9D hook uninstall");
|
|
@@ -846,7 +2304,7 @@ async function uninstallHook() {
|
|
|
846
2304
|
info("No commit-msg hook found. Nothing to uninstall.");
|
|
847
2305
|
return;
|
|
848
2306
|
}
|
|
849
|
-
const content =
|
|
2307
|
+
const content = readFileSync3(hookPath, "utf-8");
|
|
850
2308
|
if (!content.includes(HOOK_MARKER)) {
|
|
851
2309
|
error("The commit-msg hook was not installed by contribute-now. Leaving it untouched.");
|
|
852
2310
|
process.exit(1);
|
|
@@ -855,122 +2313,165 @@ async function uninstallHook() {
|
|
|
855
2313
|
success("commit-msg hook removed.");
|
|
856
2314
|
}
|
|
857
2315
|
|
|
858
|
-
// src/commands/
|
|
859
|
-
import { defineCommand as
|
|
860
|
-
import
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
2316
|
+
// src/commands/log.ts
|
|
2317
|
+
import { defineCommand as defineCommand6 } from "citty";
|
|
2318
|
+
import pc9 from "picocolors";
|
|
2319
|
+
var log_default = defineCommand6({
|
|
2320
|
+
meta: {
|
|
2321
|
+
name: "log",
|
|
2322
|
+
description: "Show a colorized, workflow-aware commit log with graph"
|
|
2323
|
+
},
|
|
2324
|
+
args: {
|
|
2325
|
+
count: {
|
|
2326
|
+
type: "string",
|
|
2327
|
+
alias: "n",
|
|
2328
|
+
description: "Number of commits to show (default: 20)"
|
|
2329
|
+
},
|
|
2330
|
+
all: {
|
|
2331
|
+
type: "boolean",
|
|
2332
|
+
alias: "a",
|
|
2333
|
+
description: "Show all branches, not just current",
|
|
2334
|
+
default: false
|
|
2335
|
+
},
|
|
2336
|
+
graph: {
|
|
2337
|
+
type: "boolean",
|
|
2338
|
+
alias: "g",
|
|
2339
|
+
description: "Show graph view with branch lines",
|
|
2340
|
+
default: true
|
|
2341
|
+
},
|
|
2342
|
+
branch: {
|
|
2343
|
+
type: "string",
|
|
2344
|
+
alias: "b",
|
|
2345
|
+
description: "Show log for a specific branch"
|
|
2346
|
+
}
|
|
2347
|
+
},
|
|
2348
|
+
async run({ args }) {
|
|
2349
|
+
if (!await isGitRepo()) {
|
|
2350
|
+
error("Not inside a git repository.");
|
|
2351
|
+
process.exit(1);
|
|
2352
|
+
}
|
|
2353
|
+
const config = readConfig();
|
|
2354
|
+
const count = args.count ? Number.parseInt(args.count, 10) : 20;
|
|
2355
|
+
const showAll = args.all;
|
|
2356
|
+
const showGraph = args.graph;
|
|
2357
|
+
const targetBranch = args.branch;
|
|
2358
|
+
const protectedBranches = config ? getProtectedBranches(config) : ["main", "master"];
|
|
2359
|
+
const currentBranch = await getCurrentBranch();
|
|
2360
|
+
heading("\uD83D\uDCDC commit log");
|
|
2361
|
+
if (showGraph) {
|
|
2362
|
+
const lines = await getLogGraph({ count, all: showAll, branch: targetBranch });
|
|
2363
|
+
if (lines.length === 0) {
|
|
2364
|
+
console.log(pc9.dim(" No commits found."));
|
|
2365
|
+
console.log();
|
|
2366
|
+
return;
|
|
2367
|
+
}
|
|
2368
|
+
console.log();
|
|
2369
|
+
for (const line of lines) {
|
|
2370
|
+
console.log(` ${colorizeGraphLine(line, protectedBranches, currentBranch)}`);
|
|
2371
|
+
}
|
|
2372
|
+
} else {
|
|
2373
|
+
const entries = await getLogEntries({ count, all: showAll, branch: targetBranch });
|
|
2374
|
+
if (entries.length === 0) {
|
|
2375
|
+
console.log(pc9.dim(" No commits found."));
|
|
2376
|
+
console.log();
|
|
2377
|
+
return;
|
|
2378
|
+
}
|
|
2379
|
+
console.log();
|
|
2380
|
+
for (const entry of entries) {
|
|
2381
|
+
const hashStr = pc9.yellow(entry.hash);
|
|
2382
|
+
const refsStr = entry.refs ? ` ${colorizeRefs(entry.refs, protectedBranches, currentBranch)}` : "";
|
|
2383
|
+
const subjectStr = colorizeSubject(entry.subject);
|
|
2384
|
+
console.log(` ${hashStr}${refsStr} ${subjectStr}`);
|
|
2385
|
+
}
|
|
2386
|
+
}
|
|
2387
|
+
console.log();
|
|
2388
|
+
console.log(pc9.dim(` Showing ${count} most recent commits${showAll ? " (all branches)" : targetBranch ? ` (${targetBranch})` : ""}`));
|
|
2389
|
+
console.log(pc9.dim(` Use ${pc9.bold("contrib log -n 50")} for more, or ${pc9.bold("contrib log --all")} for all branches`));
|
|
2390
|
+
console.log();
|
|
881
2391
|
}
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
return
|
|
887
|
-
} catch {
|
|
888
|
-
return false;
|
|
2392
|
+
});
|
|
2393
|
+
function colorizeGraphLine(line, protectedBranches, currentBranch) {
|
|
2394
|
+
const match = line.match(/^([|/\\*\s_.-]*)([a-f0-9]{7,12})(\s+\(([^)]+)\))?\s*(.*)/);
|
|
2395
|
+
if (!match) {
|
|
2396
|
+
return pc9.cyan(line);
|
|
889
2397
|
}
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
return null;
|
|
895
|
-
try {
|
|
896
|
-
return JSON.parse(stdout.trim());
|
|
897
|
-
} catch {
|
|
898
|
-
return null;
|
|
2398
|
+
const [, graphPart = "", hash, , refs, subject = ""] = match;
|
|
2399
|
+
const parts = [];
|
|
2400
|
+
if (graphPart) {
|
|
2401
|
+
parts.push(colorizeGraphChars(graphPart));
|
|
899
2402
|
}
|
|
2403
|
+
parts.push(pc9.yellow(hash));
|
|
2404
|
+
if (refs) {
|
|
2405
|
+
parts.push(` (${colorizeRefs(refs, protectedBranches, currentBranch)})`);
|
|
2406
|
+
}
|
|
2407
|
+
parts.push(` ${colorizeSubject(subject)}`);
|
|
2408
|
+
return parts.join("");
|
|
2409
|
+
}
|
|
2410
|
+
function colorizeGraphChars(graphPart) {
|
|
2411
|
+
return graphPart.split("").map((ch) => {
|
|
2412
|
+
switch (ch) {
|
|
2413
|
+
case "*":
|
|
2414
|
+
return pc9.green(ch);
|
|
2415
|
+
case "|":
|
|
2416
|
+
return pc9.cyan(ch);
|
|
2417
|
+
case "/":
|
|
2418
|
+
case "\\":
|
|
2419
|
+
return pc9.cyan(ch);
|
|
2420
|
+
case "-":
|
|
2421
|
+
case "_":
|
|
2422
|
+
return pc9.cyan(ch);
|
|
2423
|
+
default:
|
|
2424
|
+
return ch;
|
|
2425
|
+
}
|
|
2426
|
+
}).join("");
|
|
2427
|
+
}
|
|
2428
|
+
function colorizeRefs(refs, protectedBranches, currentBranch) {
|
|
2429
|
+
return refs.split(",").map((ref) => {
|
|
2430
|
+
const trimmed = ref.trim();
|
|
2431
|
+
if (trimmed.startsWith("HEAD ->") || trimmed === "HEAD") {
|
|
2432
|
+
const branchName = trimmed.replace("HEAD -> ", "");
|
|
2433
|
+
if (trimmed === "HEAD") {
|
|
2434
|
+
return pc9.bold(pc9.cyan("HEAD"));
|
|
2435
|
+
}
|
|
2436
|
+
return `${pc9.bold(pc9.cyan("HEAD"))} ${pc9.dim("->")} ${colorizeRefName(branchName, protectedBranches, currentBranch)}`;
|
|
2437
|
+
}
|
|
2438
|
+
if (trimmed.startsWith("tag:")) {
|
|
2439
|
+
return pc9.bold(pc9.magenta(trimmed));
|
|
2440
|
+
}
|
|
2441
|
+
return colorizeRefName(trimmed, protectedBranches, currentBranch);
|
|
2442
|
+
}).join(pc9.dim(", "));
|
|
900
2443
|
}
|
|
901
|
-
|
|
902
|
-
const
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
if (val === "true")
|
|
907
|
-
return true;
|
|
908
|
-
if (val === "false")
|
|
909
|
-
return false;
|
|
910
|
-
return null;
|
|
911
|
-
}
|
|
912
|
-
async function getCurrentRepoInfo() {
|
|
913
|
-
const { exitCode, stdout } = await run2([
|
|
914
|
-
"repo",
|
|
915
|
-
"view",
|
|
916
|
-
"--json",
|
|
917
|
-
"nameWithOwner",
|
|
918
|
-
"-q",
|
|
919
|
-
".nameWithOwner"
|
|
920
|
-
]);
|
|
921
|
-
if (exitCode !== 0)
|
|
922
|
-
return null;
|
|
923
|
-
const nameWithOwner = stdout.trim();
|
|
924
|
-
if (!nameWithOwner)
|
|
925
|
-
return null;
|
|
926
|
-
const [owner, repo] = nameWithOwner.split("/");
|
|
927
|
-
if (!owner || !repo)
|
|
928
|
-
return null;
|
|
929
|
-
return { owner, repo };
|
|
930
|
-
}
|
|
931
|
-
async function createPR(options) {
|
|
932
|
-
const args = [
|
|
933
|
-
"pr",
|
|
934
|
-
"create",
|
|
935
|
-
"--base",
|
|
936
|
-
options.base,
|
|
937
|
-
"--title",
|
|
938
|
-
options.title,
|
|
939
|
-
"--body",
|
|
940
|
-
options.body
|
|
941
|
-
];
|
|
942
|
-
if (options.draft)
|
|
943
|
-
args.push("--draft");
|
|
944
|
-
return run2(args);
|
|
945
|
-
}
|
|
946
|
-
async function createPRFill(base, draft) {
|
|
947
|
-
const args = ["pr", "create", "--base", base, "--fill"];
|
|
948
|
-
if (draft)
|
|
949
|
-
args.push("--draft");
|
|
950
|
-
return run2(args);
|
|
951
|
-
}
|
|
952
|
-
|
|
953
|
-
// src/utils/remote.ts
|
|
954
|
-
function parseRepoFromUrl(url) {
|
|
955
|
-
const httpsMatch = url.match(/https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
956
|
-
if (httpsMatch) {
|
|
957
|
-
return { owner: httpsMatch[1], repo: httpsMatch[2] };
|
|
2444
|
+
function colorizeRefName(name, protectedBranches, currentBranch) {
|
|
2445
|
+
const isRemote = name.includes("/");
|
|
2446
|
+
const localName = isRemote ? name.split("/").slice(1).join("/") : name;
|
|
2447
|
+
if (protectedBranches.includes(localName)) {
|
|
2448
|
+
return isRemote ? pc9.bold(pc9.red(name)) : pc9.bold(pc9.red(name));
|
|
958
2449
|
}
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
return { owner: sshMatch[1], repo: sshMatch[2] };
|
|
2450
|
+
if (localName === currentBranch) {
|
|
2451
|
+
return pc9.bold(pc9.green(name));
|
|
962
2452
|
}
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
2453
|
+
if (isRemote) {
|
|
2454
|
+
return pc9.blue(name);
|
|
2455
|
+
}
|
|
2456
|
+
return pc9.green(name);
|
|
2457
|
+
}
|
|
2458
|
+
function colorizeSubject(subject) {
|
|
2459
|
+
const emojiMatch = subject.match(/^([\p{Emoji_Presentation}\p{Emoji}\uFE0F]+\s*)/u);
|
|
2460
|
+
if (emojiMatch) {
|
|
2461
|
+
const emoji = emojiMatch[1];
|
|
2462
|
+
const rest = subject.slice(emoji.length);
|
|
2463
|
+
return `${emoji}${pc9.white(rest)}`;
|
|
2464
|
+
}
|
|
2465
|
+
if (subject.startsWith("Merge ")) {
|
|
2466
|
+
return pc9.dim(subject);
|
|
2467
|
+
}
|
|
2468
|
+
return pc9.white(subject);
|
|
970
2469
|
}
|
|
971
2470
|
|
|
972
2471
|
// src/commands/setup.ts
|
|
973
|
-
|
|
2472
|
+
import { defineCommand as defineCommand7 } from "citty";
|
|
2473
|
+
import pc10 from "picocolors";
|
|
2474
|
+
var setup_default = defineCommand7({
|
|
974
2475
|
meta: {
|
|
975
2476
|
name: "setup",
|
|
976
2477
|
description: "Initialize contribute-now config for this repo (.contributerc.json)"
|
|
@@ -991,7 +2492,7 @@ var setup_default = defineCommand4({
|
|
|
991
2492
|
workflow = "github-flow";
|
|
992
2493
|
else if (workflowChoice.startsWith("Git Flow"))
|
|
993
2494
|
workflow = "git-flow";
|
|
994
|
-
info(`Workflow: ${
|
|
2495
|
+
info(`Workflow: ${pc10.bold(WORKFLOW_DESCRIPTIONS[workflow])}`);
|
|
995
2496
|
const conventionChoice = await selectPrompt("Which commit convention should this project use?", [
|
|
996
2497
|
`${CONVENTION_DESCRIPTIONS["clean-commit"]} (recommended)`,
|
|
997
2498
|
CONVENTION_DESCRIPTIONS.conventional,
|
|
@@ -1044,8 +2545,8 @@ var setup_default = defineCommand4({
|
|
|
1044
2545
|
detectedRole = roleChoice;
|
|
1045
2546
|
detectionSource = "user selection";
|
|
1046
2547
|
} else {
|
|
1047
|
-
info(`Detected role: ${
|
|
1048
|
-
const confirmed = await confirmPrompt(`Role detected as ${
|
|
2548
|
+
info(`Detected role: ${pc10.bold(detectedRole)} (via ${detectionSource})`);
|
|
2549
|
+
const confirmed = await confirmPrompt(`Role detected as ${pc10.bold(detectedRole)}. Is this correct?`);
|
|
1049
2550
|
if (!confirmed) {
|
|
1050
2551
|
const roleChoice = await selectPrompt("Select your role:", ["maintainer", "contributor"]);
|
|
1051
2552
|
detectedRole = roleChoice;
|
|
@@ -1090,37 +2591,22 @@ var setup_default = defineCommand4({
|
|
|
1090
2591
|
warn(' echo ".contributerc.json" >> .gitignore');
|
|
1091
2592
|
}
|
|
1092
2593
|
console.log();
|
|
1093
|
-
info(`Workflow: ${
|
|
1094
|
-
info(`Convention: ${
|
|
1095
|
-
info(`Role: ${
|
|
2594
|
+
info(`Workflow: ${pc10.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
|
|
2595
|
+
info(`Convention: ${pc10.bold(CONVENTION_DESCRIPTIONS[config.commitConvention])}`);
|
|
2596
|
+
info(`Role: ${pc10.bold(config.role)}`);
|
|
1096
2597
|
if (config.devBranch) {
|
|
1097
|
-
info(`Main: ${
|
|
2598
|
+
info(`Main: ${pc10.bold(config.mainBranch)} | Dev: ${pc10.bold(config.devBranch)}`);
|
|
1098
2599
|
} else {
|
|
1099
|
-
info(`Main: ${
|
|
2600
|
+
info(`Main: ${pc10.bold(config.mainBranch)}`);
|
|
1100
2601
|
}
|
|
1101
|
-
info(`Origin: ${
|
|
2602
|
+
info(`Origin: ${pc10.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc10.bold(config.upstream)}` : ""}`);
|
|
1102
2603
|
}
|
|
1103
2604
|
});
|
|
1104
2605
|
|
|
1105
2606
|
// src/commands/start.ts
|
|
1106
|
-
import { defineCommand as
|
|
1107
|
-
import
|
|
1108
|
-
|
|
1109
|
-
// src/utils/branch.ts
|
|
1110
|
-
var DEFAULT_PREFIXES = ["feature", "fix", "docs", "chore", "test", "refactor"];
|
|
1111
|
-
function hasPrefix(branchName, prefixes = DEFAULT_PREFIXES) {
|
|
1112
|
-
return prefixes.some((p) => branchName.startsWith(`${p}/`));
|
|
1113
|
-
}
|
|
1114
|
-
function formatBranchName(prefix, name) {
|
|
1115
|
-
const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1116
|
-
return `${prefix}/${sanitized}`;
|
|
1117
|
-
}
|
|
1118
|
-
function looksLikeNaturalLanguage(input) {
|
|
1119
|
-
return input.includes(" ") && !input.includes("/");
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
// src/commands/start.ts
|
|
1123
|
-
var start_default = defineCommand5({
|
|
2607
|
+
import { defineCommand as defineCommand8 } from "citty";
|
|
2608
|
+
import pc11 from "picocolors";
|
|
2609
|
+
var start_default = defineCommand8({
|
|
1124
2610
|
meta: {
|
|
1125
2611
|
name: "start",
|
|
1126
2612
|
description: "Create a new feature branch from the latest base branch"
|
|
@@ -1128,8 +2614,8 @@ var start_default = defineCommand5({
|
|
|
1128
2614
|
args: {
|
|
1129
2615
|
name: {
|
|
1130
2616
|
type: "positional",
|
|
1131
|
-
description: "Branch name or description",
|
|
1132
|
-
required:
|
|
2617
|
+
description: "Branch name or description (prompted if omitted)",
|
|
2618
|
+
required: false
|
|
1133
2619
|
},
|
|
1134
2620
|
model: {
|
|
1135
2621
|
type: "string",
|
|
@@ -1160,42 +2646,57 @@ var start_default = defineCommand5({
|
|
|
1160
2646
|
const syncSource = getSyncSource(config);
|
|
1161
2647
|
let branchName = args.name;
|
|
1162
2648
|
heading("\uD83C\uDF3F contrib start");
|
|
2649
|
+
if (!branchName) {
|
|
2650
|
+
branchName = await inputPrompt("What are you going to work on?");
|
|
2651
|
+
if (!branchName || branchName.trim().length === 0) {
|
|
2652
|
+
error("A branch name or description is required.");
|
|
2653
|
+
process.exit(1);
|
|
2654
|
+
}
|
|
2655
|
+
branchName = branchName.trim();
|
|
2656
|
+
}
|
|
1163
2657
|
const useAI = !args["no-ai"] && looksLikeNaturalLanguage(branchName);
|
|
1164
2658
|
if (useAI) {
|
|
1165
|
-
|
|
2659
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
1166
2660
|
const suggested = await suggestBranchName(branchName, args.model);
|
|
1167
2661
|
if (suggested) {
|
|
2662
|
+
spinner.success("Branch name suggestion ready.");
|
|
1168
2663
|
console.log(`
|
|
1169
|
-
${
|
|
1170
|
-
const accepted = await confirmPrompt(`Use ${
|
|
2664
|
+
${pc11.dim("AI suggestion:")} ${pc11.bold(pc11.cyan(suggested))}`);
|
|
2665
|
+
const accepted = await confirmPrompt(`Use ${pc11.bold(suggested)} as your branch name?`);
|
|
1171
2666
|
if (accepted) {
|
|
1172
2667
|
branchName = suggested;
|
|
1173
2668
|
} else {
|
|
1174
2669
|
branchName = await inputPrompt("Enter branch name", branchName);
|
|
1175
2670
|
}
|
|
2671
|
+
} else {
|
|
2672
|
+
spinner.fail("AI did not return a branch name suggestion.");
|
|
1176
2673
|
}
|
|
1177
2674
|
}
|
|
1178
2675
|
if (!hasPrefix(branchName, branchPrefixes)) {
|
|
1179
|
-
const prefix = await selectPrompt(`Choose a branch type for ${
|
|
2676
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc11.bold(branchName)}:`, branchPrefixes);
|
|
1180
2677
|
branchName = formatBranchName(prefix, branchName);
|
|
1181
2678
|
}
|
|
1182
|
-
|
|
2679
|
+
if (!isValidBranchName(branchName)) {
|
|
2680
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
2681
|
+
process.exit(1);
|
|
2682
|
+
}
|
|
2683
|
+
info(`Creating branch: ${pc11.bold(branchName)}`);
|
|
1183
2684
|
await fetchRemote(syncSource.remote);
|
|
1184
|
-
const
|
|
1185
|
-
if (
|
|
2685
|
+
const updateResult = await updateLocalBranch(baseBranch, syncSource.ref);
|
|
2686
|
+
if (updateResult.exitCode !== 0) {}
|
|
1186
2687
|
const result = await createBranch(branchName, baseBranch);
|
|
1187
2688
|
if (result.exitCode !== 0) {
|
|
1188
2689
|
error(`Failed to create branch: ${result.stderr}`);
|
|
1189
2690
|
process.exit(1);
|
|
1190
2691
|
}
|
|
1191
|
-
success(`✅ Created ${
|
|
2692
|
+
success(`✅ Created ${pc11.bold(branchName)} from latest ${pc11.bold(baseBranch)}`);
|
|
1192
2693
|
}
|
|
1193
2694
|
});
|
|
1194
2695
|
|
|
1195
2696
|
// src/commands/status.ts
|
|
1196
|
-
import { defineCommand as
|
|
1197
|
-
import
|
|
1198
|
-
var status_default =
|
|
2697
|
+
import { defineCommand as defineCommand9 } from "citty";
|
|
2698
|
+
import pc12 from "picocolors";
|
|
2699
|
+
var status_default = defineCommand9({
|
|
1199
2700
|
meta: {
|
|
1200
2701
|
name: "status",
|
|
1201
2702
|
description: "Show sync status of branches"
|
|
@@ -1211,17 +2712,17 @@ var status_default = defineCommand6({
|
|
|
1211
2712
|
process.exit(1);
|
|
1212
2713
|
}
|
|
1213
2714
|
heading("\uD83D\uDCCA contribute-now status");
|
|
1214
|
-
console.log(` ${
|
|
1215
|
-
console.log(` ${
|
|
2715
|
+
console.log(` ${pc12.dim("Workflow:")} ${pc12.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
|
|
2716
|
+
console.log(` ${pc12.dim("Role:")} ${pc12.bold(config.role)}`);
|
|
1216
2717
|
console.log();
|
|
1217
2718
|
await fetchAll();
|
|
1218
2719
|
const currentBranch = await getCurrentBranch();
|
|
1219
2720
|
const { mainBranch, origin, upstream, workflow } = config;
|
|
1220
2721
|
const baseBranch = getBaseBranch(config);
|
|
1221
2722
|
const isContributor = config.role === "contributor";
|
|
1222
|
-
const dirty = await hasUncommittedChanges();
|
|
2723
|
+
const [dirty, fileStatus] = await Promise.all([hasUncommittedChanges(), getFileStatus()]);
|
|
1223
2724
|
if (dirty) {
|
|
1224
|
-
console.log(` ${
|
|
2725
|
+
console.log(` ${pc12.yellow("⚠")} ${pc12.yellow("Uncommitted changes in working tree")}`);
|
|
1225
2726
|
console.log();
|
|
1226
2727
|
}
|
|
1227
2728
|
const mainRemote = `${origin}/${mainBranch}`;
|
|
@@ -1234,34 +2735,134 @@ var status_default = defineCommand6({
|
|
|
1234
2735
|
const devLine = formatStatus(config.devBranch, devRemoteRef, devDiv.ahead, devDiv.behind);
|
|
1235
2736
|
console.log(devLine);
|
|
1236
2737
|
}
|
|
1237
|
-
if (currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
|
|
1238
|
-
const branchDiv = await getDivergence(currentBranch, baseBranch);
|
|
1239
|
-
const branchLine = formatStatus(currentBranch, baseBranch, branchDiv.ahead, branchDiv.behind);
|
|
1240
|
-
console.log(branchLine +
|
|
1241
|
-
} else if (currentBranch) {
|
|
1242
|
-
console.log(
|
|
2738
|
+
if (currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
|
|
2739
|
+
const branchDiv = await getDivergence(currentBranch, baseBranch);
|
|
2740
|
+
const branchLine = formatStatus(currentBranch, baseBranch, branchDiv.ahead, branchDiv.behind);
|
|
2741
|
+
console.log(branchLine + pc12.dim(` (current ${pc12.green("*")})`));
|
|
2742
|
+
} else if (currentBranch) {
|
|
2743
|
+
console.log(pc12.dim(` (on ${pc12.bold(currentBranch)} branch)`));
|
|
2744
|
+
}
|
|
2745
|
+
const hasFiles = fileStatus.staged.length > 0 || fileStatus.modified.length > 0 || fileStatus.untracked.length > 0;
|
|
2746
|
+
if (hasFiles) {
|
|
2747
|
+
console.log();
|
|
2748
|
+
if (fileStatus.staged.length > 0) {
|
|
2749
|
+
console.log(` ${pc12.green("Staged for commit:")}`);
|
|
2750
|
+
for (const { file, status } of fileStatus.staged) {
|
|
2751
|
+
console.log(` ${pc12.green("+")} ${pc12.dim(`${status}:`)} ${file}`);
|
|
2752
|
+
}
|
|
2753
|
+
}
|
|
2754
|
+
if (fileStatus.modified.length > 0) {
|
|
2755
|
+
console.log(` ${pc12.yellow("Unstaged changes:")}`);
|
|
2756
|
+
for (const { file, status } of fileStatus.modified) {
|
|
2757
|
+
console.log(` ${pc12.yellow("~")} ${pc12.dim(`${status}:`)} ${file}`);
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2760
|
+
if (fileStatus.untracked.length > 0) {
|
|
2761
|
+
console.log(` ${pc12.red("Untracked files:")}`);
|
|
2762
|
+
for (const file of fileStatus.untracked) {
|
|
2763
|
+
console.log(` ${pc12.red("?")} ${file}`);
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
} else if (!dirty) {
|
|
2767
|
+
console.log(` ${pc12.green("✓")} ${pc12.dim("Working tree clean")}`);
|
|
2768
|
+
}
|
|
2769
|
+
const tips = [];
|
|
2770
|
+
if (fileStatus.staged.length > 0) {
|
|
2771
|
+
tips.push(`Run ${pc12.bold("contrib commit")} to commit staged changes`);
|
|
2772
|
+
}
|
|
2773
|
+
if (fileStatus.modified.length > 0 || fileStatus.untracked.length > 0) {
|
|
2774
|
+
tips.push(`Run ${pc12.bold("contrib commit")} to stage and commit changes`);
|
|
2775
|
+
}
|
|
2776
|
+
if (fileStatus.staged.length === 0 && fileStatus.modified.length === 0 && fileStatus.untracked.length === 0 && currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
|
|
2777
|
+
const branchDiv = await getDivergence(currentBranch, `${origin}/${currentBranch}`);
|
|
2778
|
+
if (branchDiv.ahead > 0) {
|
|
2779
|
+
tips.push(`Run ${pc12.bold("contrib submit")} to push and create/update your PR`);
|
|
2780
|
+
}
|
|
2781
|
+
}
|
|
2782
|
+
if (tips.length > 0) {
|
|
2783
|
+
console.log();
|
|
2784
|
+
console.log(` ${pc12.dim("\uD83D\uDCA1 Tip:")}`);
|
|
2785
|
+
for (const tip of tips) {
|
|
2786
|
+
console.log(` ${pc12.dim(tip)}`);
|
|
2787
|
+
}
|
|
1243
2788
|
}
|
|
1244
2789
|
console.log();
|
|
1245
2790
|
}
|
|
1246
2791
|
});
|
|
1247
2792
|
function formatStatus(branch, base, ahead, behind) {
|
|
1248
|
-
const label =
|
|
2793
|
+
const label = pc12.bold(branch.padEnd(20));
|
|
1249
2794
|
if (ahead === 0 && behind === 0) {
|
|
1250
|
-
return ` ${
|
|
2795
|
+
return ` ${pc12.green("✓")} ${label} ${pc12.dim(`in sync with ${base}`)}`;
|
|
1251
2796
|
}
|
|
1252
2797
|
if (ahead > 0 && behind === 0) {
|
|
1253
|
-
return ` ${
|
|
2798
|
+
return ` ${pc12.yellow("↑")} ${label} ${pc12.yellow(`${ahead} commit${ahead !== 1 ? "s" : ""} ahead of ${base}`)}`;
|
|
1254
2799
|
}
|
|
1255
2800
|
if (behind > 0 && ahead === 0) {
|
|
1256
|
-
return ` ${
|
|
2801
|
+
return ` ${pc12.red("↓")} ${label} ${pc12.red(`${behind} commit${behind !== 1 ? "s" : ""} behind ${base}`)}`;
|
|
1257
2802
|
}
|
|
1258
|
-
return ` ${
|
|
2803
|
+
return ` ${pc12.red("⚡")} ${label} ${pc12.yellow(`${ahead} ahead`)}${pc12.dim(", ")}${pc12.red(`${behind} behind`)} ${pc12.dim(base)}`;
|
|
1259
2804
|
}
|
|
1260
2805
|
|
|
1261
2806
|
// src/commands/submit.ts
|
|
1262
|
-
import { defineCommand as
|
|
1263
|
-
import
|
|
1264
|
-
|
|
2807
|
+
import { defineCommand as defineCommand10 } from "citty";
|
|
2808
|
+
import pc13 from "picocolors";
|
|
2809
|
+
async function performSquashMerge(origin, baseBranch, featureBranch, options) {
|
|
2810
|
+
info(`Checking out ${pc13.bold(baseBranch)}...`);
|
|
2811
|
+
const coResult = await checkoutBranch2(baseBranch);
|
|
2812
|
+
if (coResult.exitCode !== 0) {
|
|
2813
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
2814
|
+
process.exit(1);
|
|
2815
|
+
}
|
|
2816
|
+
info(`Squash merging ${pc13.bold(featureBranch)} into ${pc13.bold(baseBranch)}...`);
|
|
2817
|
+
const mergeResult = await mergeSquash(featureBranch);
|
|
2818
|
+
if (mergeResult.exitCode !== 0) {
|
|
2819
|
+
error(`Squash merge failed: ${mergeResult.stderr}`);
|
|
2820
|
+
process.exit(1);
|
|
2821
|
+
}
|
|
2822
|
+
let message = options?.defaultMsg;
|
|
2823
|
+
if (!message) {
|
|
2824
|
+
const copilotError = await checkCopilotAvailable();
|
|
2825
|
+
if (!copilotError) {
|
|
2826
|
+
const spinner = createSpinner("Generating AI commit message for squash merge...");
|
|
2827
|
+
const [stagedDiff, stagedFiles] = await Promise.all([getStagedDiff(), getStagedFiles()]);
|
|
2828
|
+
const aiMsg = await generateCommitMessage(stagedDiff, stagedFiles, options?.model, options?.convention ?? "clean-commit");
|
|
2829
|
+
if (aiMsg) {
|
|
2830
|
+
message = aiMsg;
|
|
2831
|
+
spinner.success("AI commit message generated.");
|
|
2832
|
+
} else {
|
|
2833
|
+
spinner.fail("AI did not return a commit message.");
|
|
2834
|
+
}
|
|
2835
|
+
} else {
|
|
2836
|
+
warn(`AI unavailable: ${copilotError}`);
|
|
2837
|
+
}
|
|
2838
|
+
}
|
|
2839
|
+
const fallback = message || `squash merge ${featureBranch}`;
|
|
2840
|
+
const finalMsg = await inputPrompt("Commit message", fallback);
|
|
2841
|
+
const commitResult = await commitWithMessage(finalMsg);
|
|
2842
|
+
if (commitResult.exitCode !== 0) {
|
|
2843
|
+
error(`Commit failed: ${commitResult.stderr}`);
|
|
2844
|
+
process.exit(1);
|
|
2845
|
+
}
|
|
2846
|
+
info(`Pushing ${pc13.bold(baseBranch)} to ${origin}...`);
|
|
2847
|
+
const pushResult = await pushBranch(origin, baseBranch);
|
|
2848
|
+
if (pushResult.exitCode !== 0) {
|
|
2849
|
+
error(`Failed to push ${baseBranch}: ${pushResult.stderr}`);
|
|
2850
|
+
process.exit(1);
|
|
2851
|
+
}
|
|
2852
|
+
info(`Deleting local branch ${pc13.bold(featureBranch)}...`);
|
|
2853
|
+
const delLocal = await forceDeleteBranch(featureBranch);
|
|
2854
|
+
if (delLocal.exitCode !== 0) {
|
|
2855
|
+
warn(`Could not delete local branch: ${delLocal.stderr.trim()}`);
|
|
2856
|
+
}
|
|
2857
|
+
info(`Deleting remote branch ${pc13.bold(featureBranch)}...`);
|
|
2858
|
+
const delRemote = await deleteRemoteBranch(origin, featureBranch);
|
|
2859
|
+
if (delRemote.exitCode !== 0) {
|
|
2860
|
+
warn(`Could not delete remote branch: ${delRemote.stderr.trim()}`);
|
|
2861
|
+
}
|
|
2862
|
+
success(`✅ Squash merged ${pc13.bold(featureBranch)} into ${pc13.bold(baseBranch)} and pushed.`);
|
|
2863
|
+
info(`Run ${pc13.bold("contrib start")} to begin a new feature.`);
|
|
2864
|
+
}
|
|
2865
|
+
var submit_default = defineCommand10({
|
|
1265
2866
|
meta: {
|
|
1266
2867
|
name: "submit",
|
|
1267
2868
|
description: "Push current branch and create a pull request"
|
|
@@ -1301,89 +2902,316 @@ var submit_default = defineCommand7({
|
|
|
1301
2902
|
process.exit(1);
|
|
1302
2903
|
}
|
|
1303
2904
|
if (protectedBranches.includes(currentBranch)) {
|
|
1304
|
-
|
|
1305
|
-
|
|
2905
|
+
heading("\uD83D\uDE80 contrib submit");
|
|
2906
|
+
warn(`You're on ${pc13.bold(currentBranch)}, which is a protected branch. PRs should come from feature branches.`);
|
|
2907
|
+
await fetchAll();
|
|
2908
|
+
const remoteRef = `${origin}/${currentBranch}`;
|
|
2909
|
+
const localWork = await hasLocalWork(origin, currentBranch);
|
|
2910
|
+
const dirty = await hasUncommittedChanges();
|
|
2911
|
+
const hasCommits = localWork.unpushedCommits > 0;
|
|
2912
|
+
const hasAnything = hasCommits || dirty;
|
|
2913
|
+
if (!hasAnything) {
|
|
2914
|
+
error("No local changes or commits to move. Switch to a feature branch first.");
|
|
2915
|
+
info(` Run ${pc13.bold("contrib start")} to create a new feature branch.`);
|
|
2916
|
+
process.exit(1);
|
|
2917
|
+
}
|
|
2918
|
+
if (hasCommits) {
|
|
2919
|
+
info(`Found ${pc13.bold(String(localWork.unpushedCommits))} unpushed commit${localWork.unpushedCommits !== 1 ? "s" : ""} on ${pc13.bold(currentBranch)}.`);
|
|
2920
|
+
}
|
|
2921
|
+
if (dirty) {
|
|
2922
|
+
info("You also have uncommitted changes in the working tree.");
|
|
2923
|
+
}
|
|
2924
|
+
console.log();
|
|
2925
|
+
const MOVE_BRANCH = "Move my changes to a new feature branch";
|
|
2926
|
+
const CANCEL2 = "Cancel (stay on this branch)";
|
|
2927
|
+
const action = await selectPrompt("Let's get you back on track. What would you like to do?", [MOVE_BRANCH, CANCEL2]);
|
|
2928
|
+
if (action === CANCEL2) {
|
|
2929
|
+
info("No changes made. You are still on your current branch.");
|
|
2930
|
+
return;
|
|
2931
|
+
}
|
|
2932
|
+
info(pc13.dim("Tip: Describe what you're going to work on in plain English and we'll generate a branch name."));
|
|
2933
|
+
const description = await inputPrompt("What are you going to work on?");
|
|
2934
|
+
let newBranchName = description;
|
|
2935
|
+
if (looksLikeNaturalLanguage(description)) {
|
|
2936
|
+
const copilotError = await checkCopilotAvailable();
|
|
2937
|
+
if (!copilotError) {
|
|
2938
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
2939
|
+
const suggested = await suggestBranchName(description, args.model);
|
|
2940
|
+
if (suggested) {
|
|
2941
|
+
spinner.success("Branch name suggestion ready.");
|
|
2942
|
+
console.log(`
|
|
2943
|
+
${pc13.dim("AI suggestion:")} ${pc13.bold(pc13.cyan(suggested))}`);
|
|
2944
|
+
const accepted = await confirmPrompt(`Use ${pc13.bold(suggested)} as your branch name?`);
|
|
2945
|
+
newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
|
|
2946
|
+
} else {
|
|
2947
|
+
spinner.fail("AI did not return a suggestion.");
|
|
2948
|
+
newBranchName = await inputPrompt("Enter branch name", description);
|
|
2949
|
+
}
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
if (!hasPrefix(newBranchName, config.branchPrefixes)) {
|
|
2953
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc13.bold(newBranchName)}:`, config.branchPrefixes);
|
|
2954
|
+
newBranchName = formatBranchName(prefix, newBranchName);
|
|
2955
|
+
}
|
|
2956
|
+
if (!isValidBranchName(newBranchName)) {
|
|
2957
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
2958
|
+
process.exit(1);
|
|
2959
|
+
}
|
|
2960
|
+
const branchResult = await createBranch(newBranchName);
|
|
2961
|
+
if (branchResult.exitCode !== 0) {
|
|
2962
|
+
error(`Failed to create branch: ${branchResult.stderr}`);
|
|
2963
|
+
process.exit(1);
|
|
2964
|
+
}
|
|
2965
|
+
success(`Created ${pc13.bold(newBranchName)} with your changes.`);
|
|
2966
|
+
await updateLocalBranch(currentBranch, remoteRef);
|
|
2967
|
+
info(`Reset ${pc13.bold(currentBranch)} back to ${pc13.bold(remoteRef)} — no damage done.`);
|
|
2968
|
+
console.log();
|
|
2969
|
+
success(`You're now on ${pc13.bold(newBranchName)} with all your work intact.`);
|
|
2970
|
+
info(`Run ${pc13.bold("contrib submit")} again to push and create your PR.`);
|
|
2971
|
+
return;
|
|
1306
2972
|
}
|
|
1307
2973
|
heading("\uD83D\uDE80 contrib submit");
|
|
1308
|
-
info(`Pushing ${pc9.bold(currentBranch)} to ${origin}...`);
|
|
1309
|
-
const pushResult = await pushSetUpstream(origin, currentBranch);
|
|
1310
|
-
if (pushResult.exitCode !== 0) {
|
|
1311
|
-
error(`Failed to push: ${pushResult.stderr}`);
|
|
1312
|
-
process.exit(1);
|
|
1313
|
-
}
|
|
1314
2974
|
const ghInstalled = await checkGhInstalled();
|
|
1315
2975
|
const ghAuthed = ghInstalled && await checkGhAuth();
|
|
1316
|
-
if (
|
|
1317
|
-
const
|
|
1318
|
-
if (
|
|
1319
|
-
|
|
2976
|
+
if (ghInstalled && ghAuthed) {
|
|
2977
|
+
const mergedPR = await getMergedPRForBranch(currentBranch);
|
|
2978
|
+
if (mergedPR) {
|
|
2979
|
+
warn(`PR #${mergedPR.number} (${pc13.bold(mergedPR.title)}) was already merged.`);
|
|
2980
|
+
const localWork = await hasLocalWork(origin, currentBranch);
|
|
2981
|
+
const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
|
|
2982
|
+
if (hasWork) {
|
|
2983
|
+
if (localWork.uncommitted) {
|
|
2984
|
+
warn("You have uncommitted changes in your working tree.");
|
|
2985
|
+
}
|
|
2986
|
+
if (localWork.unpushedCommits > 0) {
|
|
2987
|
+
warn(`You have ${pc13.bold(String(localWork.unpushedCommits))} local commit${localWork.unpushedCommits !== 1 ? "s" : ""} not in the merged PR.`);
|
|
2988
|
+
}
|
|
2989
|
+
const SAVE_NEW_BRANCH = "Save changes to a new branch";
|
|
2990
|
+
const DISCARD = "Discard all changes and clean up";
|
|
2991
|
+
const CANCEL2 = "Cancel";
|
|
2992
|
+
const action = await selectPrompt("This branch was merged but you have local changes. What would you like to do?", [SAVE_NEW_BRANCH, DISCARD, CANCEL2]);
|
|
2993
|
+
if (action === CANCEL2) {
|
|
2994
|
+
info("No changes made. You are still on your current branch.");
|
|
2995
|
+
return;
|
|
2996
|
+
}
|
|
2997
|
+
if (action === SAVE_NEW_BRANCH) {
|
|
2998
|
+
info(pc13.dim("Tip: Describe what you're going to work on in plain English and we'll generate a branch name."));
|
|
2999
|
+
const description = await inputPrompt("What are you going to work on?");
|
|
3000
|
+
let newBranchName = description;
|
|
3001
|
+
if (!args["no-ai"] && looksLikeNaturalLanguage(description)) {
|
|
3002
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
3003
|
+
const suggested = await suggestBranchName(description, args.model);
|
|
3004
|
+
if (suggested) {
|
|
3005
|
+
spinner.success("Branch name suggestion ready.");
|
|
3006
|
+
console.log(`
|
|
3007
|
+
${pc13.dim("AI suggestion:")} ${pc13.bold(pc13.cyan(suggested))}`);
|
|
3008
|
+
const accepted = await confirmPrompt(`Use ${pc13.bold(suggested)} as your branch name?`);
|
|
3009
|
+
newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
|
|
3010
|
+
} else {
|
|
3011
|
+
spinner.fail("AI did not return a suggestion.");
|
|
3012
|
+
newBranchName = await inputPrompt("Enter branch name", description);
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
if (!hasPrefix(newBranchName, config.branchPrefixes)) {
|
|
3016
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc13.bold(newBranchName)}:`, config.branchPrefixes);
|
|
3017
|
+
newBranchName = formatBranchName(prefix, newBranchName);
|
|
3018
|
+
}
|
|
3019
|
+
if (!isValidBranchName(newBranchName)) {
|
|
3020
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
3021
|
+
process.exit(1);
|
|
3022
|
+
}
|
|
3023
|
+
const staleUpstream = await getUpstreamRef();
|
|
3024
|
+
const staleUpstreamHash = staleUpstream ? await getCommitHash(staleUpstream) : null;
|
|
3025
|
+
const renameResult = await renameBranch(currentBranch, newBranchName);
|
|
3026
|
+
if (renameResult.exitCode !== 0) {
|
|
3027
|
+
error(`Failed to rename branch: ${renameResult.stderr}`);
|
|
3028
|
+
process.exit(1);
|
|
3029
|
+
}
|
|
3030
|
+
success(`Renamed ${pc13.bold(currentBranch)} → ${pc13.bold(newBranchName)}`);
|
|
3031
|
+
await unsetUpstream();
|
|
3032
|
+
const syncSource2 = getSyncSource(config);
|
|
3033
|
+
info(`Syncing ${pc13.bold(newBranchName)} with latest ${pc13.bold(baseBranch)}...`);
|
|
3034
|
+
await fetchRemote(syncSource2.remote);
|
|
3035
|
+
let rebaseResult;
|
|
3036
|
+
if (staleUpstreamHash) {
|
|
3037
|
+
rebaseResult = await rebaseOnto(syncSource2.ref, staleUpstreamHash);
|
|
3038
|
+
} else {
|
|
3039
|
+
const savedStrategy = await determineRebaseStrategy(newBranchName, syncSource2.ref);
|
|
3040
|
+
rebaseResult = savedStrategy.strategy === "onto" && savedStrategy.ontoOldBase ? await rebaseOnto(syncSource2.ref, savedStrategy.ontoOldBase) : await rebase(syncSource2.ref);
|
|
3041
|
+
}
|
|
3042
|
+
if (rebaseResult.exitCode !== 0) {
|
|
3043
|
+
warn("Rebase encountered conflicts. Resolve them manually, then run:");
|
|
3044
|
+
info(` ${pc13.bold("git rebase --continue")}`);
|
|
3045
|
+
} else {
|
|
3046
|
+
success(`Rebased ${pc13.bold(newBranchName)} onto ${pc13.bold(syncSource2.ref)}.`);
|
|
3047
|
+
}
|
|
3048
|
+
info(`All your changes are preserved. Run ${pc13.bold("contrib submit")} when ready to create a new PR.`);
|
|
3049
|
+
return;
|
|
3050
|
+
}
|
|
3051
|
+
warn("Discarding local changes...");
|
|
3052
|
+
}
|
|
3053
|
+
const syncSource = getSyncSource(config);
|
|
3054
|
+
info(`Switching to ${pc13.bold(baseBranch)} and syncing...`);
|
|
3055
|
+
await fetchRemote(syncSource.remote);
|
|
3056
|
+
const coResult = await checkoutBranch2(baseBranch);
|
|
3057
|
+
if (coResult.exitCode !== 0) {
|
|
3058
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
3059
|
+
process.exit(1);
|
|
3060
|
+
}
|
|
3061
|
+
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
3062
|
+
success(`Synced ${pc13.bold(baseBranch)} with ${pc13.bold(syncSource.ref)}.`);
|
|
3063
|
+
info(`Deleting stale branch ${pc13.bold(currentBranch)}...`);
|
|
3064
|
+
const delResult = await forceDeleteBranch(currentBranch);
|
|
3065
|
+
if (delResult.exitCode === 0) {
|
|
3066
|
+
success(`Deleted ${pc13.bold(currentBranch)}.`);
|
|
3067
|
+
} else {
|
|
3068
|
+
warn(`Could not delete branch: ${delResult.stderr.trim()}`);
|
|
3069
|
+
}
|
|
1320
3070
|
console.log();
|
|
1321
|
-
info(
|
|
1322
|
-
|
|
1323
|
-
} else {
|
|
1324
|
-
info("gh CLI not available. Create your PR manually on GitHub.");
|
|
3071
|
+
info(`You're now on ${pc13.bold(baseBranch)}. Run ${pc13.bold("contrib start")} to begin a new feature.`);
|
|
3072
|
+
return;
|
|
1325
3073
|
}
|
|
1326
|
-
return;
|
|
1327
3074
|
}
|
|
1328
3075
|
let prTitle = null;
|
|
1329
3076
|
let prBody = null;
|
|
1330
|
-
|
|
1331
|
-
const copilotError = await
|
|
3077
|
+
async function tryGenerateAI() {
|
|
3078
|
+
const [copilotError, commits, diff] = await Promise.all([
|
|
3079
|
+
checkCopilotAvailable(),
|
|
3080
|
+
getLog(baseBranch, "HEAD"),
|
|
3081
|
+
getLogDiff(baseBranch, "HEAD")
|
|
3082
|
+
]);
|
|
1332
3083
|
if (!copilotError) {
|
|
1333
|
-
|
|
1334
|
-
const
|
|
1335
|
-
const diff = await getLogDiff(baseBranch, "HEAD");
|
|
1336
|
-
const result = await generatePRDescription(commits, diff, args.model);
|
|
3084
|
+
const spinner = createSpinner("Generating AI PR description...");
|
|
3085
|
+
const result = await generatePRDescription(commits, diff, args.model, config.commitConvention);
|
|
1337
3086
|
if (result) {
|
|
1338
3087
|
prTitle = result.title;
|
|
1339
3088
|
prBody = result.body;
|
|
3089
|
+
spinner.success("PR description generated.");
|
|
1340
3090
|
console.log(`
|
|
1341
|
-
${
|
|
3091
|
+
${pc13.dim("AI title:")} ${pc13.bold(pc13.cyan(prTitle))}`);
|
|
1342
3092
|
console.log(`
|
|
1343
|
-
${
|
|
1344
|
-
console.log(
|
|
3093
|
+
${pc13.dim("AI body preview:")}`);
|
|
3094
|
+
console.log(pc13.dim(prBody.slice(0, 300) + (prBody.length > 300 ? "..." : "")));
|
|
1345
3095
|
} else {
|
|
1346
|
-
|
|
3096
|
+
spinner.fail("AI did not return a PR description.");
|
|
1347
3097
|
}
|
|
1348
3098
|
} else {
|
|
1349
3099
|
warn(`AI unavailable: ${copilotError}`);
|
|
1350
3100
|
}
|
|
1351
3101
|
}
|
|
1352
|
-
if (
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
3102
|
+
if (!args["no-ai"]) {
|
|
3103
|
+
await tryGenerateAI();
|
|
3104
|
+
}
|
|
3105
|
+
const CANCEL = "Cancel";
|
|
3106
|
+
const SQUASH_LOCAL = `Squash merge to ${baseBranch} locally (no PR)`;
|
|
3107
|
+
const REGENERATE = "Regenerate AI description";
|
|
3108
|
+
let submitAction = "cancel";
|
|
3109
|
+
const isMaintainer = config.role === "maintainer";
|
|
3110
|
+
let actionResolved = false;
|
|
3111
|
+
while (!actionResolved) {
|
|
3112
|
+
if (prTitle && prBody) {
|
|
3113
|
+
const choices = ["Use AI description"];
|
|
3114
|
+
if (isMaintainer)
|
|
3115
|
+
choices.push(SQUASH_LOCAL);
|
|
3116
|
+
choices.push("Edit title", "Write manually", "Use gh --fill (auto-fill from commits)", REGENERATE, CANCEL);
|
|
3117
|
+
const action = await selectPrompt("What would you like to do with the PR description?", choices);
|
|
3118
|
+
if (action === CANCEL) {
|
|
3119
|
+
submitAction = "cancel";
|
|
3120
|
+
actionResolved = true;
|
|
3121
|
+
} else if (action === REGENERATE) {
|
|
3122
|
+
prTitle = null;
|
|
3123
|
+
prBody = null;
|
|
3124
|
+
await tryGenerateAI();
|
|
3125
|
+
} else if (action === SQUASH_LOCAL) {
|
|
3126
|
+
submitAction = "squash";
|
|
3127
|
+
actionResolved = true;
|
|
3128
|
+
} else if (action === "Use AI description") {
|
|
3129
|
+
submitAction = "create-pr";
|
|
3130
|
+
actionResolved = true;
|
|
3131
|
+
} else if (action === "Edit title") {
|
|
3132
|
+
prTitle = await inputPrompt("PR title", prTitle);
|
|
3133
|
+
submitAction = "create-pr";
|
|
3134
|
+
actionResolved = true;
|
|
3135
|
+
} else if (action === "Write manually") {
|
|
3136
|
+
prTitle = await inputPrompt("PR title");
|
|
3137
|
+
prBody = await inputPrompt("PR body (markdown)");
|
|
3138
|
+
submitAction = "create-pr";
|
|
3139
|
+
actionResolved = true;
|
|
3140
|
+
} else {
|
|
3141
|
+
submitAction = "fill";
|
|
3142
|
+
actionResolved = true;
|
|
3143
|
+
}
|
|
1364
3144
|
} else {
|
|
1365
|
-
const
|
|
1366
|
-
if (
|
|
1367
|
-
|
|
1368
|
-
|
|
3145
|
+
const choices = [];
|
|
3146
|
+
if (isMaintainer)
|
|
3147
|
+
choices.push(SQUASH_LOCAL);
|
|
3148
|
+
if (!args["no-ai"])
|
|
3149
|
+
choices.push(REGENERATE);
|
|
3150
|
+
choices.push("Write title & body manually", "Use gh --fill (auto-fill from commits)", CANCEL);
|
|
3151
|
+
const action = await selectPrompt("How would you like to create the PR?", choices);
|
|
3152
|
+
if (action === CANCEL) {
|
|
3153
|
+
submitAction = "cancel";
|
|
3154
|
+
actionResolved = true;
|
|
3155
|
+
} else if (action === REGENERATE) {
|
|
3156
|
+
await tryGenerateAI();
|
|
3157
|
+
} else if (action === SQUASH_LOCAL) {
|
|
3158
|
+
submitAction = "squash";
|
|
3159
|
+
actionResolved = true;
|
|
3160
|
+
} else if (action === "Write title & body manually") {
|
|
3161
|
+
prTitle = await inputPrompt("PR title");
|
|
3162
|
+
prBody = await inputPrompt("PR body (markdown)");
|
|
3163
|
+
submitAction = "create-pr";
|
|
3164
|
+
actionResolved = true;
|
|
3165
|
+
} else {
|
|
3166
|
+
submitAction = "fill";
|
|
3167
|
+
actionResolved = true;
|
|
1369
3168
|
}
|
|
1370
|
-
success(`✅ PR created: ${fillResult.stdout.trim()}`);
|
|
1371
|
-
return;
|
|
1372
3169
|
}
|
|
1373
|
-
}
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
3170
|
+
}
|
|
3171
|
+
if (submitAction === "cancel") {
|
|
3172
|
+
warn("Submit cancelled.");
|
|
3173
|
+
return;
|
|
3174
|
+
}
|
|
3175
|
+
if (submitAction === "squash") {
|
|
3176
|
+
await performSquashMerge(origin, baseBranch, currentBranch, {
|
|
3177
|
+
defaultMsg: prTitle ?? undefined,
|
|
3178
|
+
model: args.model,
|
|
3179
|
+
convention: config.commitConvention
|
|
3180
|
+
});
|
|
3181
|
+
return;
|
|
3182
|
+
}
|
|
3183
|
+
info(`Pushing ${pc13.bold(currentBranch)} to ${origin}...`);
|
|
3184
|
+
const pushResult = await pushSetUpstream(origin, currentBranch);
|
|
3185
|
+
if (pushResult.exitCode !== 0) {
|
|
3186
|
+
error(`Failed to push: ${pushResult.stderr}`);
|
|
3187
|
+
process.exit(1);
|
|
3188
|
+
}
|
|
3189
|
+
if (!ghInstalled || !ghAuthed) {
|
|
3190
|
+
const repoInfo = await getRepoInfoFromRemote(origin);
|
|
3191
|
+
if (repoInfo) {
|
|
3192
|
+
const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${baseBranch}...${currentBranch}?expand=1`;
|
|
3193
|
+
console.log();
|
|
3194
|
+
info("Create your PR manually:");
|
|
3195
|
+
console.log(` ${pc13.cyan(prUrl)}`);
|
|
1378
3196
|
} else {
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
3197
|
+
info("gh CLI not available. Create your PR manually on GitHub.");
|
|
3198
|
+
}
|
|
3199
|
+
return;
|
|
3200
|
+
}
|
|
3201
|
+
const existingPR = await getPRForBranch(currentBranch);
|
|
3202
|
+
if (existingPR) {
|
|
3203
|
+
success(`Pushed changes to existing PR #${existingPR.number}: ${pc13.bold(existingPR.title)}`);
|
|
3204
|
+
console.log(` ${pc13.cyan(existingPR.url)}`);
|
|
3205
|
+
return;
|
|
3206
|
+
}
|
|
3207
|
+
if (submitAction === "fill") {
|
|
3208
|
+
const fillResult = await createPRFill(baseBranch, args.draft);
|
|
3209
|
+
if (fillResult.exitCode !== 0) {
|
|
3210
|
+
error(`Failed to create PR: ${fillResult.stderr}`);
|
|
3211
|
+
process.exit(1);
|
|
1386
3212
|
}
|
|
3213
|
+
success(`✅ PR created: ${fillResult.stdout.trim()}`);
|
|
3214
|
+
return;
|
|
1387
3215
|
}
|
|
1388
3216
|
if (!prTitle) {
|
|
1389
3217
|
error("No PR title provided.");
|
|
@@ -1404,9 +3232,9 @@ ${pc9.dim("AI body preview:")}`);
|
|
|
1404
3232
|
});
|
|
1405
3233
|
|
|
1406
3234
|
// src/commands/sync.ts
|
|
1407
|
-
import { defineCommand as
|
|
1408
|
-
import
|
|
1409
|
-
var sync_default =
|
|
3235
|
+
import { defineCommand as defineCommand11 } from "citty";
|
|
3236
|
+
import pc14 from "picocolors";
|
|
3237
|
+
var sync_default = defineCommand11({
|
|
1410
3238
|
meta: {
|
|
1411
3239
|
name: "sync",
|
|
1412
3240
|
description: "Sync your local branches with the remote"
|
|
@@ -1417,6 +3245,15 @@ var sync_default = defineCommand8({
|
|
|
1417
3245
|
alias: "y",
|
|
1418
3246
|
description: "Skip confirmation prompt",
|
|
1419
3247
|
default: false
|
|
3248
|
+
},
|
|
3249
|
+
model: {
|
|
3250
|
+
type: "string",
|
|
3251
|
+
description: "AI model to use for branch name suggestion"
|
|
3252
|
+
},
|
|
3253
|
+
"no-ai": {
|
|
3254
|
+
type: "boolean",
|
|
3255
|
+
description: "Skip AI branch name suggestion",
|
|
3256
|
+
default: false
|
|
1420
3257
|
}
|
|
1421
3258
|
},
|
|
1422
3259
|
async run({ args }) {
|
|
@@ -1448,16 +3285,89 @@ var sync_default = defineCommand8({
|
|
|
1448
3285
|
}
|
|
1449
3286
|
const div = await getDivergence(baseBranch, syncSource.ref);
|
|
1450
3287
|
if (div.ahead > 0 || div.behind > 0) {
|
|
1451
|
-
info(`${
|
|
3288
|
+
info(`${pc14.bold(baseBranch)} is ${pc14.yellow(`${div.ahead} ahead`)} and ${pc14.red(`${div.behind} behind`)} ${syncSource.ref}`);
|
|
1452
3289
|
} else {
|
|
1453
|
-
info(`${
|
|
3290
|
+
info(`${pc14.bold(baseBranch)} is already in sync with ${syncSource.ref}`);
|
|
3291
|
+
}
|
|
3292
|
+
if (div.ahead > 0) {
|
|
3293
|
+
const currentBranch = await getCurrentBranch();
|
|
3294
|
+
const protectedBranches = getProtectedBranches(config);
|
|
3295
|
+
const isOnProtected = currentBranch && protectedBranches.includes(currentBranch);
|
|
3296
|
+
if (isOnProtected) {
|
|
3297
|
+
warn(`You have ${pc14.bold(String(div.ahead))} local commit${div.ahead !== 1 ? "s" : ""} on ${pc14.bold(baseBranch)} that aren't on the remote.`);
|
|
3298
|
+
info("Pulling now could create a merge commit, which breaks clean history.");
|
|
3299
|
+
console.log();
|
|
3300
|
+
const MOVE_BRANCH = "Move my commits to a new feature branch, then sync";
|
|
3301
|
+
const PULL_ANYWAY = "Pull anyway (may create a merge commit)";
|
|
3302
|
+
const CANCEL = "Cancel";
|
|
3303
|
+
const action = await selectPrompt("How would you like to handle this?", [MOVE_BRANCH, PULL_ANYWAY, CANCEL]);
|
|
3304
|
+
if (action === CANCEL) {
|
|
3305
|
+
info("No changes made.");
|
|
3306
|
+
return;
|
|
3307
|
+
}
|
|
3308
|
+
if (action === MOVE_BRANCH) {
|
|
3309
|
+
info(pc14.dim("Tip: Describe what you're going to work on in plain English and we'll generate a branch name."));
|
|
3310
|
+
const description = await inputPrompt("What are you going to work on?");
|
|
3311
|
+
let newBranchName = description;
|
|
3312
|
+
if (!args["no-ai"] && looksLikeNaturalLanguage(description)) {
|
|
3313
|
+
const copilotError = await checkCopilotAvailable();
|
|
3314
|
+
if (!copilotError) {
|
|
3315
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
3316
|
+
const suggested = await suggestBranchName(description, args.model);
|
|
3317
|
+
if (suggested) {
|
|
3318
|
+
spinner.success("Branch name suggestion ready.");
|
|
3319
|
+
console.log(`
|
|
3320
|
+
${pc14.dim("AI suggestion:")} ${pc14.bold(pc14.cyan(suggested))}`);
|
|
3321
|
+
const accepted = await confirmPrompt(`Use ${pc14.bold(suggested)} as your branch name?`);
|
|
3322
|
+
newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
|
|
3323
|
+
} else {
|
|
3324
|
+
spinner.fail("AI did not return a suggestion.");
|
|
3325
|
+
newBranchName = await inputPrompt("Enter branch name", description);
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
if (!hasPrefix(newBranchName, config.branchPrefixes)) {
|
|
3330
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc14.bold(newBranchName)}:`, config.branchPrefixes);
|
|
3331
|
+
newBranchName = formatBranchName(prefix, newBranchName);
|
|
3332
|
+
}
|
|
3333
|
+
if (!isValidBranchName(newBranchName)) {
|
|
3334
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
3335
|
+
process.exit(1);
|
|
3336
|
+
}
|
|
3337
|
+
const branchResult = await createBranch(newBranchName);
|
|
3338
|
+
if (branchResult.exitCode !== 0) {
|
|
3339
|
+
error(`Failed to create branch: ${branchResult.stderr}`);
|
|
3340
|
+
process.exit(1);
|
|
3341
|
+
}
|
|
3342
|
+
success(`Created ${pc14.bold(newBranchName)} with your commits.`);
|
|
3343
|
+
const coResult2 = await checkoutBranch2(baseBranch);
|
|
3344
|
+
if (coResult2.exitCode !== 0) {
|
|
3345
|
+
error(`Failed to checkout ${baseBranch}: ${coResult2.stderr}`);
|
|
3346
|
+
process.exit(1);
|
|
3347
|
+
}
|
|
3348
|
+
const remoteRef = syncSource.ref;
|
|
3349
|
+
await updateLocalBranch(baseBranch, remoteRef);
|
|
3350
|
+
success(`Reset ${pc14.bold(baseBranch)} to ${pc14.bold(remoteRef)}.`);
|
|
3351
|
+
const pullResult2 = await pullBranch(syncSource.remote, baseBranch);
|
|
3352
|
+
if (pullResult2.exitCode !== 0) {
|
|
3353
|
+
error(`Failed to pull: ${pullResult2.stderr}`);
|
|
3354
|
+
process.exit(1);
|
|
3355
|
+
}
|
|
3356
|
+
success(`✅ ${pc14.bold(baseBranch)} is now in sync with ${syncSource.ref}`);
|
|
3357
|
+
console.log();
|
|
3358
|
+
info(`Your commits are safe on ${pc14.bold(newBranchName)}.`);
|
|
3359
|
+
info(`Run ${pc14.bold(`git checkout ${newBranchName}`)} then ${pc14.bold("contrib update")} to rebase onto the synced ${pc14.bold(baseBranch)}.`);
|
|
3360
|
+
return;
|
|
3361
|
+
}
|
|
3362
|
+
warn("Proceeding with pull — a merge commit may be created.");
|
|
3363
|
+
}
|
|
1454
3364
|
}
|
|
1455
3365
|
if (!args.yes) {
|
|
1456
|
-
const ok = await confirmPrompt(`This will pull ${
|
|
3366
|
+
const ok = await confirmPrompt(`This will pull ${pc14.bold(syncSource.ref)} into local ${pc14.bold(baseBranch)}.`);
|
|
1457
3367
|
if (!ok)
|
|
1458
3368
|
process.exit(0);
|
|
1459
3369
|
}
|
|
1460
|
-
const coResult = await
|
|
3370
|
+
const coResult = await checkoutBranch2(baseBranch);
|
|
1461
3371
|
if (coResult.exitCode !== 0) {
|
|
1462
3372
|
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
1463
3373
|
process.exit(1);
|
|
@@ -1471,25 +3381,25 @@ var sync_default = defineCommand8({
|
|
|
1471
3381
|
if (hasDevBranch(workflow) && role === "maintainer") {
|
|
1472
3382
|
const mainDiv = await getDivergence(config.mainBranch, `${origin}/${config.mainBranch}`);
|
|
1473
3383
|
if (mainDiv.behind > 0) {
|
|
1474
|
-
info(`Also syncing ${
|
|
1475
|
-
const mainCoResult = await
|
|
3384
|
+
info(`Also syncing ${pc14.bold(config.mainBranch)}...`);
|
|
3385
|
+
const mainCoResult = await checkoutBranch2(config.mainBranch);
|
|
1476
3386
|
if (mainCoResult.exitCode === 0) {
|
|
1477
3387
|
const mainPullResult = await pullBranch(origin, config.mainBranch);
|
|
1478
3388
|
if (mainPullResult.exitCode === 0) {
|
|
1479
3389
|
success(`✅ ${config.mainBranch} is now in sync with ${origin}/${config.mainBranch}`);
|
|
1480
3390
|
}
|
|
1481
3391
|
}
|
|
1482
|
-
await
|
|
3392
|
+
await checkoutBranch2(baseBranch);
|
|
1483
3393
|
}
|
|
1484
3394
|
}
|
|
1485
3395
|
}
|
|
1486
3396
|
});
|
|
1487
3397
|
|
|
1488
3398
|
// src/commands/update.ts
|
|
1489
|
-
import { readFileSync as
|
|
1490
|
-
import { defineCommand as
|
|
1491
|
-
import
|
|
1492
|
-
var update_default =
|
|
3399
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
3400
|
+
import { defineCommand as defineCommand12 } from "citty";
|
|
3401
|
+
import pc15 from "picocolors";
|
|
3402
|
+
var update_default = defineCommand12({
|
|
1493
3403
|
meta: {
|
|
1494
3404
|
name: "update",
|
|
1495
3405
|
description: "Rebase current branch onto the latest base branch"
|
|
@@ -1524,18 +3434,177 @@ var update_default = defineCommand9({
|
|
|
1524
3434
|
process.exit(1);
|
|
1525
3435
|
}
|
|
1526
3436
|
if (protectedBranches.includes(currentBranch)) {
|
|
1527
|
-
|
|
1528
|
-
|
|
3437
|
+
heading("\uD83D\uDD03 contrib update");
|
|
3438
|
+
warn(`You're on ${pc15.bold(currentBranch)}, which is a protected branch. Updates (rebase) apply to feature branches.`);
|
|
3439
|
+
await fetchAll();
|
|
3440
|
+
const { origin } = config;
|
|
3441
|
+
const remoteRef = `${origin}/${currentBranch}`;
|
|
3442
|
+
const localWork = await hasLocalWork(origin, currentBranch);
|
|
3443
|
+
const dirty = await hasUncommittedChanges();
|
|
3444
|
+
const hasCommits = localWork.unpushedCommits > 0;
|
|
3445
|
+
const hasAnything = hasCommits || dirty;
|
|
3446
|
+
if (!hasAnything) {
|
|
3447
|
+
info(`No local changes found on ${pc15.bold(currentBranch)}.`);
|
|
3448
|
+
info(`Use ${pc15.bold("contrib sync")} to sync protected branches, or ${pc15.bold("contrib start")} to create a feature branch.`);
|
|
3449
|
+
process.exit(1);
|
|
3450
|
+
}
|
|
3451
|
+
if (hasCommits) {
|
|
3452
|
+
info(`Found ${pc15.bold(String(localWork.unpushedCommits))} unpushed commit${localWork.unpushedCommits !== 1 ? "s" : ""} on ${pc15.bold(currentBranch)}.`);
|
|
3453
|
+
}
|
|
3454
|
+
if (dirty) {
|
|
3455
|
+
info("You also have uncommitted changes in the working tree.");
|
|
3456
|
+
}
|
|
3457
|
+
console.log();
|
|
3458
|
+
const MOVE_BRANCH = "Move my changes to a new feature branch";
|
|
3459
|
+
const CANCEL = "Cancel (stay on this branch)";
|
|
3460
|
+
const action = await selectPrompt("Let's get you back on track. What would you like to do?", [MOVE_BRANCH, CANCEL]);
|
|
3461
|
+
if (action === CANCEL) {
|
|
3462
|
+
info("No changes made. You are still on your current branch.");
|
|
3463
|
+
return;
|
|
3464
|
+
}
|
|
3465
|
+
info(pc15.dim("Tip: Describe what you're going to work on in plain English and we'll generate a branch name."));
|
|
3466
|
+
const description = await inputPrompt("What are you going to work on?");
|
|
3467
|
+
let newBranchName = description;
|
|
3468
|
+
if (!args["no-ai"] && looksLikeNaturalLanguage(description)) {
|
|
3469
|
+
const copilotError = await checkCopilotAvailable();
|
|
3470
|
+
if (!copilotError) {
|
|
3471
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
3472
|
+
const suggested = await suggestBranchName(description, args.model);
|
|
3473
|
+
if (suggested) {
|
|
3474
|
+
spinner.success("Branch name suggestion ready.");
|
|
3475
|
+
console.log(`
|
|
3476
|
+
${pc15.dim("AI suggestion:")} ${pc15.bold(pc15.cyan(suggested))}`);
|
|
3477
|
+
const accepted = await confirmPrompt(`Use ${pc15.bold(suggested)} as your branch name?`);
|
|
3478
|
+
newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
|
|
3479
|
+
} else {
|
|
3480
|
+
spinner.fail("AI did not return a suggestion.");
|
|
3481
|
+
newBranchName = await inputPrompt("Enter branch name", description);
|
|
3482
|
+
}
|
|
3483
|
+
}
|
|
3484
|
+
}
|
|
3485
|
+
if (!hasPrefix(newBranchName, config.branchPrefixes)) {
|
|
3486
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc15.bold(newBranchName)}:`, config.branchPrefixes);
|
|
3487
|
+
newBranchName = formatBranchName(prefix, newBranchName);
|
|
3488
|
+
}
|
|
3489
|
+
if (!isValidBranchName(newBranchName)) {
|
|
3490
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
3491
|
+
process.exit(1);
|
|
3492
|
+
}
|
|
3493
|
+
const branchResult = await createBranch(newBranchName);
|
|
3494
|
+
if (branchResult.exitCode !== 0) {
|
|
3495
|
+
error(`Failed to create branch: ${branchResult.stderr}`);
|
|
3496
|
+
process.exit(1);
|
|
3497
|
+
}
|
|
3498
|
+
success(`Created ${pc15.bold(newBranchName)} with your changes.`);
|
|
3499
|
+
await updateLocalBranch(currentBranch, remoteRef);
|
|
3500
|
+
info(`Reset ${pc15.bold(currentBranch)} back to ${pc15.bold(remoteRef)} — no damage done.`);
|
|
3501
|
+
console.log();
|
|
3502
|
+
success(`You're now on ${pc15.bold(newBranchName)} with all your work intact.`);
|
|
3503
|
+
info(`Run ${pc15.bold("contrib update")} again to rebase onto latest ${pc15.bold(baseBranch)}.`);
|
|
3504
|
+
return;
|
|
1529
3505
|
}
|
|
1530
3506
|
if (await hasUncommittedChanges()) {
|
|
1531
3507
|
error("You have uncommitted changes. Please commit or stash them first.");
|
|
1532
3508
|
process.exit(1);
|
|
1533
3509
|
}
|
|
1534
3510
|
heading("\uD83D\uDD03 contrib update");
|
|
1535
|
-
|
|
3511
|
+
const mergedPR = await getMergedPRForBranch(currentBranch);
|
|
3512
|
+
if (mergedPR) {
|
|
3513
|
+
warn(`PR #${mergedPR.number} (${pc15.bold(mergedPR.title)}) has already been merged.`);
|
|
3514
|
+
info(`Link: ${pc15.underline(mergedPR.url)}`);
|
|
3515
|
+
const localWork = await hasLocalWork(syncSource.remote, currentBranch);
|
|
3516
|
+
const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
|
|
3517
|
+
if (hasWork) {
|
|
3518
|
+
if (localWork.uncommitted) {
|
|
3519
|
+
info("You have uncommitted local changes.");
|
|
3520
|
+
}
|
|
3521
|
+
if (localWork.unpushedCommits > 0) {
|
|
3522
|
+
info(`You have ${localWork.unpushedCommits} unpushed commit(s).`);
|
|
3523
|
+
}
|
|
3524
|
+
const SAVE_NEW_BRANCH = "Save changes to a new branch";
|
|
3525
|
+
const DISCARD = "Discard all changes and clean up";
|
|
3526
|
+
const CANCEL = "Cancel";
|
|
3527
|
+
const action = await selectPrompt(`${pc15.bold(currentBranch)} is stale but has local work. What would you like to do?`, [SAVE_NEW_BRANCH, DISCARD, CANCEL]);
|
|
3528
|
+
if (action === CANCEL) {
|
|
3529
|
+
info("No changes made. You are still on your current branch.");
|
|
3530
|
+
return;
|
|
3531
|
+
}
|
|
3532
|
+
if (action === SAVE_NEW_BRANCH) {
|
|
3533
|
+
info(pc15.dim("Tip: Describe what you're going to work on in plain English and we'll generate a branch name."));
|
|
3534
|
+
const description = await inputPrompt("What are you going to work on?");
|
|
3535
|
+
let newBranchName = description;
|
|
3536
|
+
if (!args["no-ai"] && looksLikeNaturalLanguage(description)) {
|
|
3537
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
3538
|
+
const suggested = await suggestBranchName(description, args.model);
|
|
3539
|
+
if (suggested) {
|
|
3540
|
+
spinner.success("Branch name suggestion ready.");
|
|
3541
|
+
console.log(`
|
|
3542
|
+
${pc15.dim("AI suggestion:")} ${pc15.bold(pc15.cyan(suggested))}`);
|
|
3543
|
+
const accepted = await confirmPrompt(`Use ${pc15.bold(suggested)} as your branch name?`);
|
|
3544
|
+
newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
|
|
3545
|
+
} else {
|
|
3546
|
+
spinner.fail("AI did not return a suggestion.");
|
|
3547
|
+
newBranchName = await inputPrompt("Enter branch name", description);
|
|
3548
|
+
}
|
|
3549
|
+
}
|
|
3550
|
+
if (!hasPrefix(newBranchName, config.branchPrefixes)) {
|
|
3551
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc15.bold(newBranchName)}:`, config.branchPrefixes);
|
|
3552
|
+
newBranchName = formatBranchName(prefix, newBranchName);
|
|
3553
|
+
}
|
|
3554
|
+
if (!isValidBranchName(newBranchName)) {
|
|
3555
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
3556
|
+
process.exit(1);
|
|
3557
|
+
}
|
|
3558
|
+
const staleUpstream = await getUpstreamRef();
|
|
3559
|
+
const staleUpstreamHash = staleUpstream ? await getCommitHash(staleUpstream) : null;
|
|
3560
|
+
const renameResult = await renameBranch(currentBranch, newBranchName);
|
|
3561
|
+
if (renameResult.exitCode !== 0) {
|
|
3562
|
+
error(`Failed to rename branch: ${renameResult.stderr}`);
|
|
3563
|
+
process.exit(1);
|
|
3564
|
+
}
|
|
3565
|
+
success(`Renamed ${pc15.bold(currentBranch)} → ${pc15.bold(newBranchName)}`);
|
|
3566
|
+
await unsetUpstream();
|
|
3567
|
+
await fetchRemote(syncSource.remote);
|
|
3568
|
+
let rebaseResult2;
|
|
3569
|
+
if (staleUpstreamHash) {
|
|
3570
|
+
rebaseResult2 = await rebaseOnto(syncSource.ref, staleUpstreamHash);
|
|
3571
|
+
} else {
|
|
3572
|
+
const savedStrategy = await determineRebaseStrategy(newBranchName, syncSource.ref);
|
|
3573
|
+
rebaseResult2 = savedStrategy.strategy === "onto" && savedStrategy.ontoOldBase ? await rebaseOnto(syncSource.ref, savedStrategy.ontoOldBase) : await rebase(syncSource.ref);
|
|
3574
|
+
}
|
|
3575
|
+
if (rebaseResult2.exitCode !== 0) {
|
|
3576
|
+
warn("Rebase encountered conflicts. Resolve them manually, then run:");
|
|
3577
|
+
info(` ${pc15.bold("git rebase --continue")}`);
|
|
3578
|
+
} else {
|
|
3579
|
+
success(`Rebased ${pc15.bold(newBranchName)} onto ${pc15.bold(syncSource.ref)}.`);
|
|
3580
|
+
}
|
|
3581
|
+
info(`All your changes are preserved. Run ${pc15.bold("contrib submit")} when ready to create a new PR.`);
|
|
3582
|
+
return;
|
|
3583
|
+
}
|
|
3584
|
+
warn("Discarding local changes...");
|
|
3585
|
+
}
|
|
3586
|
+
await fetchRemote(syncSource.remote);
|
|
3587
|
+
const coResult = await checkoutBranch2(baseBranch);
|
|
3588
|
+
if (coResult.exitCode !== 0) {
|
|
3589
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
3590
|
+
process.exit(1);
|
|
3591
|
+
}
|
|
3592
|
+
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
3593
|
+
success(`Synced ${pc15.bold(baseBranch)} with ${pc15.bold(syncSource.ref)}.`);
|
|
3594
|
+
info(`Deleting stale branch ${pc15.bold(currentBranch)}...`);
|
|
3595
|
+
await forceDeleteBranch(currentBranch);
|
|
3596
|
+
success(`Deleted ${pc15.bold(currentBranch)}.`);
|
|
3597
|
+
info(`Run ${pc15.bold("contrib start")} to begin a new feature branch.`);
|
|
3598
|
+
return;
|
|
3599
|
+
}
|
|
3600
|
+
info(`Updating ${pc15.bold(currentBranch)} with latest ${pc15.bold(baseBranch)}...`);
|
|
1536
3601
|
await fetchRemote(syncSource.remote);
|
|
1537
|
-
await
|
|
1538
|
-
const
|
|
3602
|
+
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
3603
|
+
const rebaseStrategy = await determineRebaseStrategy(currentBranch, syncSource.ref);
|
|
3604
|
+
if (rebaseStrategy.strategy === "onto" && rebaseStrategy.ontoOldBase) {
|
|
3605
|
+
info(pc15.dim(`Using --onto rebase (branch was based on a different ref)`));
|
|
3606
|
+
}
|
|
3607
|
+
const rebaseResult = rebaseStrategy.strategy === "onto" && rebaseStrategy.ontoOldBase ? await rebaseOnto(syncSource.ref, rebaseStrategy.ontoOldBase) : await rebase(syncSource.ref);
|
|
1539
3608
|
if (rebaseResult.exitCode !== 0) {
|
|
1540
3609
|
warn("Rebase hit conflicts. Resolve them manually.");
|
|
1541
3610
|
console.log();
|
|
@@ -1547,7 +3616,7 @@ var update_default = defineCommand9({
|
|
|
1547
3616
|
let conflictDiff = "";
|
|
1548
3617
|
for (const file of conflictFiles.slice(0, 3)) {
|
|
1549
3618
|
try {
|
|
1550
|
-
const content =
|
|
3619
|
+
const content = readFileSync4(file, "utf-8");
|
|
1551
3620
|
if (content.includes("<<<<<<<")) {
|
|
1552
3621
|
conflictDiff += `
|
|
1553
3622
|
--- ${file} ---
|
|
@@ -1557,34 +3626,38 @@ ${content.slice(0, 2000)}
|
|
|
1557
3626
|
} catch {}
|
|
1558
3627
|
}
|
|
1559
3628
|
if (conflictDiff) {
|
|
3629
|
+
const spinner = createSpinner("Analyzing conflicts with AI...");
|
|
1560
3630
|
const suggestion = await suggestConflictResolution(conflictDiff, args.model);
|
|
1561
3631
|
if (suggestion) {
|
|
3632
|
+
spinner.success("AI conflict guidance ready.");
|
|
1562
3633
|
console.log(`
|
|
1563
|
-
${
|
|
1564
|
-
console.log(
|
|
3634
|
+
${pc15.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
|
|
3635
|
+
console.log(pc15.dim("─".repeat(60)));
|
|
1565
3636
|
console.log(suggestion);
|
|
1566
|
-
console.log(
|
|
3637
|
+
console.log(pc15.dim("─".repeat(60)));
|
|
1567
3638
|
console.log();
|
|
3639
|
+
} else {
|
|
3640
|
+
spinner.fail("AI could not analyze the conflicts.");
|
|
1568
3641
|
}
|
|
1569
3642
|
}
|
|
1570
3643
|
}
|
|
1571
3644
|
}
|
|
1572
|
-
console.log(
|
|
3645
|
+
console.log(pc15.bold("To resolve:"));
|
|
1573
3646
|
console.log(` 1. Fix conflicts in the affected files`);
|
|
1574
|
-
console.log(` 2. ${
|
|
1575
|
-
console.log(` 3. ${
|
|
3647
|
+
console.log(` 2. ${pc15.cyan("git add <resolved-files>")}`);
|
|
3648
|
+
console.log(` 3. ${pc15.cyan("git rebase --continue")}`);
|
|
1576
3649
|
console.log();
|
|
1577
|
-
console.log(` Or abort: ${
|
|
3650
|
+
console.log(` Or abort: ${pc15.cyan("git rebase --abort")}`);
|
|
1578
3651
|
process.exit(1);
|
|
1579
3652
|
}
|
|
1580
|
-
success(`✅ ${
|
|
3653
|
+
success(`✅ ${pc15.bold(currentBranch)} has been rebased onto latest ${pc15.bold(baseBranch)}`);
|
|
1581
3654
|
}
|
|
1582
3655
|
});
|
|
1583
3656
|
|
|
1584
3657
|
// src/commands/validate.ts
|
|
1585
|
-
import { defineCommand as
|
|
1586
|
-
import
|
|
1587
|
-
var validate_default =
|
|
3658
|
+
import { defineCommand as defineCommand13 } from "citty";
|
|
3659
|
+
import pc16 from "picocolors";
|
|
3660
|
+
var validate_default = defineCommand13({
|
|
1588
3661
|
meta: {
|
|
1589
3662
|
name: "validate",
|
|
1590
3663
|
description: "Validate a commit message against the configured convention"
|
|
@@ -1614,7 +3687,7 @@ var validate_default = defineCommand10({
|
|
|
1614
3687
|
}
|
|
1615
3688
|
const errors = getValidationError(convention);
|
|
1616
3689
|
for (const line of errors) {
|
|
1617
|
-
console.error(
|
|
3690
|
+
console.error(pc16.red(` ✗ ${line}`));
|
|
1618
3691
|
}
|
|
1619
3692
|
process.exit(1);
|
|
1620
3693
|
}
|
|
@@ -1622,75 +3695,19 @@ var validate_default = defineCommand10({
|
|
|
1622
3695
|
|
|
1623
3696
|
// src/ui/banner.ts
|
|
1624
3697
|
import figlet from "figlet";
|
|
1625
|
-
import
|
|
1626
|
-
|
|
1627
|
-
var package_default = {
|
|
1628
|
-
name: "contribute-now",
|
|
1629
|
-
version: "0.2.0-dev.e0cfab8",
|
|
1630
|
-
description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
|
|
1631
|
-
type: "module",
|
|
1632
|
-
bin: {
|
|
1633
|
-
contrib: "dist/index.js",
|
|
1634
|
-
contribute: "dist/index.js"
|
|
1635
|
-
},
|
|
1636
|
-
files: [
|
|
1637
|
-
"dist"
|
|
1638
|
-
],
|
|
1639
|
-
scripts: {
|
|
1640
|
-
build: "bun build src/index.ts --outfile dist/index.js --target node --packages external",
|
|
1641
|
-
dev: "bun src/index.ts",
|
|
1642
|
-
test: "bun test",
|
|
1643
|
-
lint: "biome check .",
|
|
1644
|
-
"lint:fix": "biome check --write .",
|
|
1645
|
-
format: "biome format --write .",
|
|
1646
|
-
prepare: "husky || true",
|
|
1647
|
-
"www:dev": "bun run --cwd www dev",
|
|
1648
|
-
"www:build": "bun run --cwd www build",
|
|
1649
|
-
"www:preview": "bun run --cwd www preview"
|
|
1650
|
-
},
|
|
1651
|
-
engines: {
|
|
1652
|
-
node: ">=18",
|
|
1653
|
-
bun: ">=1.0"
|
|
1654
|
-
},
|
|
1655
|
-
keywords: [
|
|
1656
|
-
"git",
|
|
1657
|
-
"workflow",
|
|
1658
|
-
"squash-merge",
|
|
1659
|
-
"sync",
|
|
1660
|
-
"cli",
|
|
1661
|
-
"contribute",
|
|
1662
|
-
"fork",
|
|
1663
|
-
"dev-branch",
|
|
1664
|
-
"clean-commit"
|
|
1665
|
-
],
|
|
1666
|
-
author: "Waren Gonzaga",
|
|
1667
|
-
license: "GPL-3.0",
|
|
1668
|
-
repository: {
|
|
1669
|
-
type: "git",
|
|
1670
|
-
url: "git+https://github.com/warengonzaga/contribute-now.git"
|
|
1671
|
-
},
|
|
1672
|
-
dependencies: {
|
|
1673
|
-
"@github/copilot-sdk": "^0.1.25",
|
|
1674
|
-
"@wgtechlabs/log-engine": "^2.3.1",
|
|
1675
|
-
citty: "^0.1.6",
|
|
1676
|
-
figlet: "^1.10.0",
|
|
1677
|
-
picocolors: "^1.1.1"
|
|
1678
|
-
},
|
|
1679
|
-
devDependencies: {
|
|
1680
|
-
"@biomejs/biome": "^2.4.4",
|
|
1681
|
-
"@types/bun": "latest",
|
|
1682
|
-
"@types/figlet": "^1.7.0",
|
|
1683
|
-
husky: "^9.1.7",
|
|
1684
|
-
typescript: "^5.7.0"
|
|
1685
|
-
}
|
|
1686
|
-
};
|
|
1687
|
-
|
|
1688
|
-
// src/ui/banner.ts
|
|
1689
|
-
var LOGO;
|
|
3698
|
+
import pc17 from "picocolors";
|
|
3699
|
+
var LOGO_BIG;
|
|
1690
3700
|
try {
|
|
1691
|
-
|
|
3701
|
+
LOGO_BIG = figlet.textSync(`Contribute
|
|
3702
|
+
Now`, { font: "ANSI Shadow" });
|
|
1692
3703
|
} catch {
|
|
1693
|
-
|
|
3704
|
+
LOGO_BIG = "Contribute Now";
|
|
3705
|
+
}
|
|
3706
|
+
var LOGO_SMALL;
|
|
3707
|
+
try {
|
|
3708
|
+
LOGO_SMALL = figlet.textSync("Contribute Now", { font: "Slant" });
|
|
3709
|
+
} catch {
|
|
3710
|
+
LOGO_SMALL = "Contribute Now";
|
|
1694
3711
|
}
|
|
1695
3712
|
function getVersion() {
|
|
1696
3713
|
return package_default.version ?? "unknown";
|
|
@@ -1698,24 +3715,30 @@ function getVersion() {
|
|
|
1698
3715
|
function getAuthor() {
|
|
1699
3716
|
return typeof package_default.author === "string" ? package_default.author : "unknown";
|
|
1700
3717
|
}
|
|
1701
|
-
function showBanner(
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
3718
|
+
function showBanner(variant = "small") {
|
|
3719
|
+
const logo = variant === "big" ? LOGO_BIG : LOGO_SMALL;
|
|
3720
|
+
console.log(pc17.cyan(`
|
|
3721
|
+
${logo}`));
|
|
3722
|
+
console.log(` ${pc17.dim(`v${getVersion()}`)} ${pc17.dim("—")} ${pc17.dim(`Built by ${getAuthor()}`)}`);
|
|
3723
|
+
if (variant === "big") {
|
|
1707
3724
|
console.log();
|
|
1708
|
-
console.log(` ${
|
|
1709
|
-
console.log(` ${
|
|
1710
|
-
console.log(` ${
|
|
3725
|
+
console.log(` ${pc17.yellow("Star")} ${pc17.cyan("https://github.com/warengonzaga/contribute-now")}`);
|
|
3726
|
+
console.log(` ${pc17.green("Contribute")} ${pc17.cyan("https://github.com/warengonzaga/contribute-now/blob/main/CONTRIBUTING.md")}`);
|
|
3727
|
+
console.log(` ${pc17.magenta("Sponsor")} ${pc17.cyan("https://warengonzaga.com/sponsor")}`);
|
|
1711
3728
|
}
|
|
1712
3729
|
console.log();
|
|
1713
3730
|
}
|
|
1714
3731
|
|
|
1715
3732
|
// src/index.ts
|
|
1716
|
-
var
|
|
1717
|
-
|
|
1718
|
-
|
|
3733
|
+
var isVersion = process.argv.includes("--version") || process.argv.includes("-v");
|
|
3734
|
+
if (!isVersion) {
|
|
3735
|
+
const subCommands = ["setup", "sync", "start", "commit", "update", "submit", "clean", "status", "log", "branch", "hook", "validate", "doctor"];
|
|
3736
|
+
const isHelp = process.argv.includes("--help") || process.argv.includes("-h");
|
|
3737
|
+
const hasSubCommand = subCommands.some((cmd) => process.argv.includes(cmd));
|
|
3738
|
+
const useBigBanner = isHelp || !hasSubCommand;
|
|
3739
|
+
showBanner(useBigBanner ? "big" : "small");
|
|
3740
|
+
}
|
|
3741
|
+
var main = defineCommand14({
|
|
1719
3742
|
meta: {
|
|
1720
3743
|
name: "contrib",
|
|
1721
3744
|
version: getVersion(),
|
|
@@ -1735,10 +3758,13 @@ var main = defineCommand11({
|
|
|
1735
3758
|
commit: commit_default,
|
|
1736
3759
|
update: update_default,
|
|
1737
3760
|
submit: submit_default,
|
|
3761
|
+
branch: branch_default,
|
|
1738
3762
|
clean: clean_default,
|
|
1739
3763
|
status: status_default,
|
|
3764
|
+
log: log_default,
|
|
1740
3765
|
hook: hook_default,
|
|
1741
|
-
validate: validate_default
|
|
3766
|
+
validate: validate_default,
|
|
3767
|
+
doctor: doctor_default
|
|
1742
3768
|
},
|
|
1743
3769
|
run({ args }) {
|
|
1744
3770
|
if (args.version) {
|
|
@@ -1746,4 +3772,6 @@ var main = defineCommand11({
|
|
|
1746
3772
|
}
|
|
1747
3773
|
}
|
|
1748
3774
|
});
|
|
1749
|
-
runMain(main)
|
|
3775
|
+
runMain(main).then(() => {
|
|
3776
|
+
process.exit(0);
|
|
3777
|
+
});
|