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