contribute-now 0.2.0-dev.7c81c96 → 0.2.0-dev.88d5119
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +2097 -758
- package/package.json +1 -1
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,6 +16,12 @@ 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
|
+
}
|
|
22
|
+
var VALID_WORKFLOWS = ["clean-flow", "github-flow", "git-flow"];
|
|
23
|
+
var VALID_ROLES = ["maintainer", "contributor"];
|
|
24
|
+
var VALID_CONVENTIONS = ["conventional", "clean-commit", "none"];
|
|
17
25
|
function readConfig(cwd = process.cwd()) {
|
|
18
26
|
const path = getConfigPath(cwd);
|
|
19
27
|
if (!existsSync(path))
|
|
@@ -24,6 +32,38 @@ function readConfig(cwd = process.cwd()) {
|
|
|
24
32
|
if (typeof parsed !== "object" || parsed === null || typeof parsed.workflow !== "string" || typeof parsed.role !== "string" || typeof parsed.mainBranch !== "string" || typeof parsed.upstream !== "string" || typeof parsed.origin !== "string" || !Array.isArray(parsed.branchPrefixes) || typeof parsed.commitConvention !== "string") {
|
|
25
33
|
return null;
|
|
26
34
|
}
|
|
35
|
+
if (!VALID_WORKFLOWS.includes(parsed.workflow)) {
|
|
36
|
+
console.error(`Invalid workflow "${parsed.workflow}" in .contributerc.json. Valid: ${VALID_WORKFLOWS.join(", ")}`);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (!VALID_ROLES.includes(parsed.role)) {
|
|
40
|
+
console.error(`Invalid role "${parsed.role}" in .contributerc.json. Valid: ${VALID_ROLES.join(", ")}`);
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
if (!VALID_CONVENTIONS.includes(parsed.commitConvention)) {
|
|
44
|
+
console.error(`Invalid commitConvention "${parsed.commitConvention}" in .contributerc.json. Valid: ${VALID_CONVENTIONS.join(", ")}`);
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
if (!parsed.mainBranch.trim()) {
|
|
48
|
+
console.error("Invalid .contributerc.json: mainBranch must not be empty.");
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
if (!parsed.origin.trim()) {
|
|
52
|
+
console.error("Invalid .contributerc.json: origin must not be empty.");
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
if (parsed.role === "contributor" && !parsed.upstream.trim()) {
|
|
56
|
+
console.error("Invalid .contributerc.json: upstream must not be empty for contributors.");
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
if (parsed.branchPrefixes.length === 0) {
|
|
60
|
+
console.error("Invalid .contributerc.json: branchPrefixes must not be empty.");
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (!parsed.branchPrefixes.every((p) => typeof p === "string" && p.trim().length > 0)) {
|
|
64
|
+
console.error("Invalid .contributerc.json: all branchPrefixes must be non-empty strings.");
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
27
67
|
return parsed;
|
|
28
68
|
} catch {
|
|
29
69
|
return null;
|
|
@@ -59,50 +99,9 @@ function getDefaultConfig() {
|
|
|
59
99
|
};
|
|
60
100
|
}
|
|
61
101
|
|
|
62
|
-
// src/utils/confirm.ts
|
|
63
|
-
import * as clack from "@clack/prompts";
|
|
64
|
-
import pc from "picocolors";
|
|
65
|
-
function handleCancel(value) {
|
|
66
|
-
if (clack.isCancel(value)) {
|
|
67
|
-
clack.cancel("Cancelled.");
|
|
68
|
-
process.exit(0);
|
|
69
|
-
}
|
|
70
|
-
}
|
|
71
|
-
async function confirmPrompt(message) {
|
|
72
|
-
const result = await clack.confirm({ message });
|
|
73
|
-
handleCancel(result);
|
|
74
|
-
return result;
|
|
75
|
-
}
|
|
76
|
-
async function selectPrompt(message, choices) {
|
|
77
|
-
const result = await clack.select({
|
|
78
|
-
message,
|
|
79
|
-
options: choices.map((choice) => ({ value: choice, label: choice }))
|
|
80
|
-
});
|
|
81
|
-
handleCancel(result);
|
|
82
|
-
return result;
|
|
83
|
-
}
|
|
84
|
-
async function inputPrompt(message, defaultValue) {
|
|
85
|
-
const result = await clack.text({
|
|
86
|
-
message,
|
|
87
|
-
placeholder: defaultValue,
|
|
88
|
-
defaultValue
|
|
89
|
-
});
|
|
90
|
-
handleCancel(result);
|
|
91
|
-
return result || defaultValue || "";
|
|
92
|
-
}
|
|
93
|
-
async function multiSelectPrompt(message, choices) {
|
|
94
|
-
const result = await clack.multiselect({
|
|
95
|
-
message: `${message} ${pc.dim("(space to toggle, enter to confirm)")}`,
|
|
96
|
-
options: choices.map((choice) => ({ value: choice, label: choice })),
|
|
97
|
-
required: false
|
|
98
|
-
});
|
|
99
|
-
handleCancel(result);
|
|
100
|
-
return result;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
102
|
// src/utils/git.ts
|
|
104
103
|
import { execFile as execFileCb } from "node:child_process";
|
|
105
|
-
import { readFileSync as readFileSync2 } from "node:fs";
|
|
104
|
+
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
106
105
|
import { join as join2 } from "node:path";
|
|
107
106
|
function run(args) {
|
|
108
107
|
return new Promise((resolve) => {
|
|
@@ -119,11 +118,69 @@ async function isGitRepo() {
|
|
|
119
118
|
const { exitCode } = await run(["rev-parse", "--is-inside-work-tree"]);
|
|
120
119
|
return exitCode === 0;
|
|
121
120
|
}
|
|
121
|
+
async function getGitDir() {
|
|
122
|
+
const { exitCode, stdout } = await run(["rev-parse", "--git-dir"]);
|
|
123
|
+
if (exitCode !== 0)
|
|
124
|
+
return null;
|
|
125
|
+
return stdout.trim() || null;
|
|
126
|
+
}
|
|
127
|
+
async function checkGitState() {
|
|
128
|
+
const gitDir = await getGitDir();
|
|
129
|
+
if (!gitDir)
|
|
130
|
+
return { lockFile: false, inProgressOp: null, gitDir: null };
|
|
131
|
+
const lockFile = existsSync2(join2(gitDir, "index.lock"));
|
|
132
|
+
let inProgressOp = null;
|
|
133
|
+
if (existsSync2(join2(gitDir, "rebase-merge")) || existsSync2(join2(gitDir, "rebase-apply"))) {
|
|
134
|
+
inProgressOp = "rebase";
|
|
135
|
+
} else if (existsSync2(join2(gitDir, "MERGE_HEAD"))) {
|
|
136
|
+
inProgressOp = "merge";
|
|
137
|
+
} else if (existsSync2(join2(gitDir, "CHERRY_PICK_HEAD"))) {
|
|
138
|
+
inProgressOp = "cherry-pick";
|
|
139
|
+
} else if (existsSync2(join2(gitDir, "BISECT_LOG"))) {
|
|
140
|
+
inProgressOp = "bisect";
|
|
141
|
+
}
|
|
142
|
+
return { lockFile, inProgressOp, gitDir };
|
|
143
|
+
}
|
|
144
|
+
async function isGitOperationInProgress() {
|
|
145
|
+
const { inProgressOp } = await checkGitState();
|
|
146
|
+
return inProgressOp;
|
|
147
|
+
}
|
|
148
|
+
async function hasGitLockFile() {
|
|
149
|
+
const { lockFile } = await checkGitState();
|
|
150
|
+
return lockFile;
|
|
151
|
+
}
|
|
152
|
+
async function assertCleanGitState(action) {
|
|
153
|
+
const { lockFile, inProgressOp, gitDir } = await checkGitState();
|
|
154
|
+
if (lockFile) {
|
|
155
|
+
const lockPath = gitDir ? `${gitDir}/index.lock` : ".git/index.lock";
|
|
156
|
+
console.error("\x1B[31m✖\x1B[0m A git lock file exists (index.lock). Another git process may be running.");
|
|
157
|
+
console.error(`\x1B[36mℹ\x1B[0m If no other git process is running, remove it: rm ${lockPath}`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
if (inProgressOp) {
|
|
161
|
+
console.error(`\x1B[31m✖\x1B[0m A git ${inProgressOp} is in progress. Complete or abort it before ${action}.`);
|
|
162
|
+
console.error(`\x1B[36mℹ\x1B[0m To abort: git ${inProgressOp} --abort`);
|
|
163
|
+
process.exit(1);
|
|
164
|
+
}
|
|
165
|
+
if (await isShallowRepo()) {
|
|
166
|
+
console.error("\x1B[33m⚠\x1B[0m This is a shallow clone — some operations may behave unexpectedly.");
|
|
167
|
+
console.error("\x1B[36mℹ\x1B[0m Consider running `git fetch --unshallow` for full history.");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
async function isShallowRepo() {
|
|
171
|
+
const { exitCode, stdout } = await run(["rev-parse", "--is-shallow-repository"]);
|
|
172
|
+
if (exitCode !== 0)
|
|
173
|
+
return false;
|
|
174
|
+
return stdout.trim() === "true";
|
|
175
|
+
}
|
|
122
176
|
async function getCurrentBranch() {
|
|
123
177
|
const { exitCode, stdout } = await run(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
124
178
|
if (exitCode !== 0)
|
|
125
179
|
return null;
|
|
126
|
-
|
|
180
|
+
const branch = stdout.trim();
|
|
181
|
+
if (!branch || branch === "HEAD")
|
|
182
|
+
return null;
|
|
183
|
+
return branch;
|
|
127
184
|
}
|
|
128
185
|
async function getRemotes() {
|
|
129
186
|
const { exitCode, stdout } = await run(["remote"]);
|
|
@@ -141,12 +198,19 @@ async function getRemoteUrl(remote) {
|
|
|
141
198
|
async function hasUncommittedChanges() {
|
|
142
199
|
const { exitCode, stdout } = await run(["status", "--porcelain"]);
|
|
143
200
|
if (exitCode !== 0)
|
|
144
|
-
return
|
|
201
|
+
return true;
|
|
145
202
|
return stdout.trim().length > 0;
|
|
146
203
|
}
|
|
204
|
+
async function branchExists(branch) {
|
|
205
|
+
const { exitCode } = await run(["rev-parse", "--verify", branch]);
|
|
206
|
+
return exitCode === 0;
|
|
207
|
+
}
|
|
147
208
|
async function fetchRemote(remote) {
|
|
148
209
|
return run(["fetch", remote]);
|
|
149
210
|
}
|
|
211
|
+
async function addRemote(name, url) {
|
|
212
|
+
return run(["remote", "add", name, url]);
|
|
213
|
+
}
|
|
150
214
|
async function fetchAll() {
|
|
151
215
|
return run(["fetch", "--all", "--quiet"]);
|
|
152
216
|
}
|
|
@@ -173,15 +237,64 @@ async function pushSetUpstream(remote, branch) {
|
|
|
173
237
|
async function rebase(branch) {
|
|
174
238
|
return run(["rebase", branch]);
|
|
175
239
|
}
|
|
240
|
+
async function rebaseAbort() {
|
|
241
|
+
return run(["rebase", "--abort"]);
|
|
242
|
+
}
|
|
176
243
|
async function getUpstreamRef() {
|
|
177
|
-
const { exitCode, stdout } = await run([
|
|
244
|
+
const { exitCode, stdout } = await run([
|
|
245
|
+
"rev-parse",
|
|
246
|
+
"--abbrev-ref",
|
|
247
|
+
"--symbolic-full-name",
|
|
248
|
+
"@{u}"
|
|
249
|
+
]);
|
|
178
250
|
if (exitCode !== 0)
|
|
179
251
|
return null;
|
|
180
252
|
return stdout.trim() || null;
|
|
181
253
|
}
|
|
254
|
+
async function unsetUpstream() {
|
|
255
|
+
return run(["branch", "--unset-upstream"]);
|
|
256
|
+
}
|
|
182
257
|
async function rebaseOnto(newBase, oldBase) {
|
|
183
258
|
return run(["rebase", "--onto", newBase, oldBase]);
|
|
184
259
|
}
|
|
260
|
+
async function getMergeBase(ref1, ref2) {
|
|
261
|
+
const { exitCode, stdout } = await run(["merge-base", ref1, ref2]);
|
|
262
|
+
if (exitCode !== 0)
|
|
263
|
+
return null;
|
|
264
|
+
return stdout.trim() || null;
|
|
265
|
+
}
|
|
266
|
+
async function getCommitHash(ref) {
|
|
267
|
+
const { exitCode, stdout } = await run(["rev-parse", ref]);
|
|
268
|
+
if (exitCode !== 0)
|
|
269
|
+
return null;
|
|
270
|
+
return stdout.trim() || null;
|
|
271
|
+
}
|
|
272
|
+
async function determineRebaseStrategy(currentBranch, syncRef) {
|
|
273
|
+
const upstreamRef = await getUpstreamRef();
|
|
274
|
+
if (!upstreamRef) {
|
|
275
|
+
return { strategy: "plain" };
|
|
276
|
+
}
|
|
277
|
+
const upstreamHash = await getCommitHash(upstreamRef);
|
|
278
|
+
if (!upstreamHash) {
|
|
279
|
+
return { strategy: "plain" };
|
|
280
|
+
}
|
|
281
|
+
const slashIdx = upstreamRef.indexOf("/");
|
|
282
|
+
const upstreamBranchName = slashIdx !== -1 ? upstreamRef.slice(slashIdx + 1) : upstreamRef;
|
|
283
|
+
if (upstreamBranchName === currentBranch) {
|
|
284
|
+
return { strategy: "plain" };
|
|
285
|
+
}
|
|
286
|
+
const [forkFromUpstream, forkFromSync] = await Promise.all([
|
|
287
|
+
getMergeBase("HEAD", upstreamRef),
|
|
288
|
+
getMergeBase("HEAD", syncRef)
|
|
289
|
+
]);
|
|
290
|
+
if (forkFromUpstream && forkFromSync && forkFromUpstream === forkFromSync) {
|
|
291
|
+
return { strategy: "plain" };
|
|
292
|
+
}
|
|
293
|
+
if (forkFromUpstream) {
|
|
294
|
+
return { strategy: "onto", ontoOldBase: forkFromUpstream };
|
|
295
|
+
}
|
|
296
|
+
return { strategy: "plain" };
|
|
297
|
+
}
|
|
185
298
|
async function getStagedDiff() {
|
|
186
299
|
const { stdout } = await run(["diff", "--cached"]);
|
|
187
300
|
return stdout;
|
|
@@ -204,7 +317,7 @@ async function getChangedFiles() {
|
|
|
204
317
|
if (!match)
|
|
205
318
|
return "";
|
|
206
319
|
const file = match[1];
|
|
207
|
-
const renameIdx = file.
|
|
320
|
+
const renameIdx = file.lastIndexOf(" -> ");
|
|
208
321
|
return renameIdx !== -1 ? file.slice(renameIdx + 4) : file;
|
|
209
322
|
}).filter(Boolean);
|
|
210
323
|
}
|
|
@@ -235,7 +348,9 @@ async function getGoneBranches() {
|
|
|
235
348
|
if (exitCode !== 0)
|
|
236
349
|
return [];
|
|
237
350
|
return stdout.trimEnd().split(`
|
|
238
|
-
`).filter((line) =>
|
|
351
|
+
`).filter((line) => {
|
|
352
|
+
return /\[\S+: gone\]/.test(line);
|
|
353
|
+
}).map((line) => line.replace(/^\*?\s+/, "").split(/\s+/)[0]).filter(Boolean);
|
|
239
354
|
}
|
|
240
355
|
async function deleteBranch(branch) {
|
|
241
356
|
return run(["branch", "-d", branch]);
|
|
@@ -282,6 +397,13 @@ async function getLog(base, head) {
|
|
|
282
397
|
async function pullBranch(remote, branch) {
|
|
283
398
|
return run(["pull", remote, branch]);
|
|
284
399
|
}
|
|
400
|
+
async function pullFastForwardOnly(remote, branch) {
|
|
401
|
+
return run(["pull", "--ff-only", remote, branch]);
|
|
402
|
+
}
|
|
403
|
+
async function refExists(ref) {
|
|
404
|
+
const { exitCode } = await run(["rev-parse", "--verify", "--quiet", ref]);
|
|
405
|
+
return exitCode === 0;
|
|
406
|
+
}
|
|
285
407
|
async function stageFiles(files) {
|
|
286
408
|
return run(["add", "--", ...files]);
|
|
287
409
|
}
|
|
@@ -346,7 +468,7 @@ async function getFileStatus() {
|
|
|
346
468
|
const indexStatus = line[0];
|
|
347
469
|
const workTreeStatus = line[1];
|
|
348
470
|
const pathPart = line.slice(3);
|
|
349
|
-
const renameIdx = pathPart.
|
|
471
|
+
const renameIdx = pathPart.lastIndexOf(" -> ");
|
|
350
472
|
const file = renameIdx !== -1 ? pathPart.slice(renameIdx + 4) : pathPart;
|
|
351
473
|
if (indexStatus === "?" && workTreeStatus === "?") {
|
|
352
474
|
result.untracked.push(file);
|
|
@@ -361,10 +483,78 @@ async function getFileStatus() {
|
|
|
361
483
|
}
|
|
362
484
|
return result;
|
|
363
485
|
}
|
|
486
|
+
async function getLogGraph(options) {
|
|
487
|
+
const count = options?.count ?? 20;
|
|
488
|
+
const args = [
|
|
489
|
+
"log",
|
|
490
|
+
"--oneline",
|
|
491
|
+
"--graph",
|
|
492
|
+
"--decorate",
|
|
493
|
+
`--max-count=${count}`,
|
|
494
|
+
"--color=never"
|
|
495
|
+
];
|
|
496
|
+
if (options?.all) {
|
|
497
|
+
args.push("--all");
|
|
498
|
+
}
|
|
499
|
+
if (options?.branch) {
|
|
500
|
+
args.push(options.branch);
|
|
501
|
+
}
|
|
502
|
+
const { exitCode, stdout } = await run(args);
|
|
503
|
+
if (exitCode !== 0)
|
|
504
|
+
return [];
|
|
505
|
+
return stdout.trimEnd().split(`
|
|
506
|
+
`);
|
|
507
|
+
}
|
|
508
|
+
async function getLogEntries(options) {
|
|
509
|
+
const count = options?.count ?? 20;
|
|
510
|
+
const args = ["log", `--format=%h||%s||%D`, `--max-count=${count}`];
|
|
511
|
+
if (options?.all) {
|
|
512
|
+
args.push("--all");
|
|
513
|
+
}
|
|
514
|
+
if (options?.branch) {
|
|
515
|
+
args.push(options.branch);
|
|
516
|
+
}
|
|
517
|
+
const { exitCode, stdout } = await run(args);
|
|
518
|
+
if (exitCode !== 0)
|
|
519
|
+
return [];
|
|
520
|
+
return stdout.trimEnd().split(`
|
|
521
|
+
`).filter(Boolean).map((line) => {
|
|
522
|
+
const [hash = "", subject = "", refs = ""] = line.split("||");
|
|
523
|
+
return { hash: hash.trim(), subject: subject.trim(), refs: refs.trim() };
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
async function getLocalBranches() {
|
|
527
|
+
const { exitCode, stdout } = await run(["branch", "-vv", "--no-color"]);
|
|
528
|
+
if (exitCode !== 0)
|
|
529
|
+
return [];
|
|
530
|
+
return stdout.trimEnd().split(`
|
|
531
|
+
`).filter(Boolean).map((line) => {
|
|
532
|
+
const isCurrent = line.startsWith("*");
|
|
533
|
+
const trimmed = line.slice(2);
|
|
534
|
+
const nameMatch = trimmed.match(/^(\S+)/);
|
|
535
|
+
const name = nameMatch?.[1] ?? "";
|
|
536
|
+
const upstreamMatch = trimmed.match(/\[([^\]]+)\]/);
|
|
537
|
+
let upstream = null;
|
|
538
|
+
let gone = false;
|
|
539
|
+
if (upstreamMatch) {
|
|
540
|
+
const bracketContent = upstreamMatch[1];
|
|
541
|
+
gone = bracketContent.includes(": gone");
|
|
542
|
+
upstream = bracketContent.split(":")[0].trim();
|
|
543
|
+
}
|
|
544
|
+
return { name, isCurrent, upstream, gone };
|
|
545
|
+
}).filter((b) => b.name.length > 0);
|
|
546
|
+
}
|
|
547
|
+
async function getRemoteBranches() {
|
|
548
|
+
const { exitCode, stdout } = await run(["branch", "-r", "--no-color"]);
|
|
549
|
+
if (exitCode !== 0)
|
|
550
|
+
return [];
|
|
551
|
+
return stdout.trimEnd().split(`
|
|
552
|
+
`).map((line) => line.trim()).filter((line) => line.length > 0 && !line.includes(" -> "));
|
|
553
|
+
}
|
|
364
554
|
|
|
365
555
|
// src/utils/logger.ts
|
|
366
556
|
import { LogEngine, LogMode } from "@wgtechlabs/log-engine";
|
|
367
|
-
import
|
|
557
|
+
import pc from "picocolors";
|
|
368
558
|
LogEngine.configure({
|
|
369
559
|
mode: LogMode.INFO,
|
|
370
560
|
format: {
|
|
@@ -387,7 +577,7 @@ function info(msg) {
|
|
|
387
577
|
}
|
|
388
578
|
function heading(msg) {
|
|
389
579
|
console.log(`
|
|
390
|
-
${
|
|
580
|
+
${pc.bold(msg)}`);
|
|
391
581
|
}
|
|
392
582
|
|
|
393
583
|
// src/utils/workflow.ts
|
|
@@ -436,78 +626,37 @@ function getProtectedBranches(config) {
|
|
|
436
626
|
}
|
|
437
627
|
return branches;
|
|
438
628
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
if (!config)
|
|
443
|
-
return "skipped";
|
|
444
|
-
const { origin } = config;
|
|
445
|
-
const localWork = await hasLocalWork(origin, currentBranch);
|
|
446
|
-
const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
|
|
447
|
-
if (hasWork) {
|
|
448
|
-
if (localWork.uncommitted) {
|
|
449
|
-
warn("You have uncommitted changes in your working tree.");
|
|
450
|
-
}
|
|
451
|
-
if (localWork.unpushedCommits > 0) {
|
|
452
|
-
warn(`You have ${pc3.bold(String(localWork.unpushedCommits))} local commit${localWork.unpushedCommits !== 1 ? "s" : ""} not pushed.`);
|
|
453
|
-
}
|
|
454
|
-
const SAVE_NEW_BRANCH = "Save changes to a new branch";
|
|
455
|
-
const DISCARD = "Discard all changes and clean up";
|
|
456
|
-
const CANCEL = "Skip this branch";
|
|
457
|
-
const action = await selectPrompt(`${pc3.bold(currentBranch)} has local changes. What would you like to do?`, [SAVE_NEW_BRANCH, DISCARD, CANCEL]);
|
|
458
|
-
if (action === CANCEL)
|
|
459
|
-
return "skipped";
|
|
460
|
-
if (action === SAVE_NEW_BRANCH) {
|
|
461
|
-
const suggestedName = currentBranch.replace(/^(feature|fix|docs|chore|test|refactor)\//, "$1/new-");
|
|
462
|
-
const newBranchName = await inputPrompt("New branch name", suggestedName !== currentBranch ? suggestedName : `${currentBranch}-v2`);
|
|
463
|
-
const renameResult = await renameBranch(currentBranch, newBranchName);
|
|
464
|
-
if (renameResult.exitCode !== 0) {
|
|
465
|
-
error(`Failed to rename branch: ${renameResult.stderr}`);
|
|
466
|
-
return "skipped";
|
|
467
|
-
}
|
|
468
|
-
success(`Renamed ${pc3.bold(currentBranch)} → ${pc3.bold(newBranchName)}`);
|
|
469
|
-
const syncSource2 = getSyncSource(config);
|
|
470
|
-
await fetchRemote(syncSource2.remote);
|
|
471
|
-
const savedUpstreamRef = await getUpstreamRef();
|
|
472
|
-
const rebaseResult = savedUpstreamRef && savedUpstreamRef !== syncSource2.ref ? await rebaseOnto(syncSource2.ref, savedUpstreamRef) : await rebase(syncSource2.ref);
|
|
473
|
-
if (rebaseResult.exitCode !== 0) {
|
|
474
|
-
warn("Rebase encountered conflicts. Resolve them after cleanup:");
|
|
475
|
-
info(` ${pc3.bold(`git checkout ${newBranchName} && git rebase --continue`)}`);
|
|
476
|
-
} else {
|
|
477
|
-
success(`Rebased ${pc3.bold(newBranchName)} onto ${pc3.bold(syncSource2.ref)}.`);
|
|
478
|
-
}
|
|
479
|
-
const coResult2 = await checkoutBranch(baseBranch);
|
|
480
|
-
if (coResult2.exitCode !== 0) {
|
|
481
|
-
error(`Failed to checkout ${baseBranch}: ${coResult2.stderr}`);
|
|
482
|
-
return "saved";
|
|
483
|
-
}
|
|
484
|
-
await updateLocalBranch(baseBranch, syncSource2.ref);
|
|
485
|
-
success(`Synced ${pc3.bold(baseBranch)} with ${pc3.bold(syncSource2.ref)}.`);
|
|
486
|
-
return "saved";
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
const syncSource = getSyncSource(config);
|
|
490
|
-
info(`Switching to ${pc3.bold(baseBranch)} and syncing...`);
|
|
491
|
-
await fetchRemote(syncSource.remote);
|
|
492
|
-
const coResult = await checkoutBranch(baseBranch);
|
|
493
|
-
if (coResult.exitCode !== 0) {
|
|
494
|
-
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
495
|
-
return "skipped";
|
|
629
|
+
function getProtectedPrefixes(config) {
|
|
630
|
+
if (config.workflow === "git-flow") {
|
|
631
|
+
return ["release/", "hotfix/"];
|
|
496
632
|
}
|
|
497
|
-
|
|
498
|
-
success(`Synced ${pc3.bold(baseBranch)} with ${pc3.bold(syncSource.ref)}.`);
|
|
499
|
-
return "switched";
|
|
633
|
+
return [];
|
|
500
634
|
}
|
|
501
|
-
|
|
635
|
+
function isBranchProtected(branch, config) {
|
|
636
|
+
const protectedBranches = getProtectedBranches(config);
|
|
637
|
+
if (protectedBranches.includes(branch))
|
|
638
|
+
return true;
|
|
639
|
+
const protectedPrefixes = getProtectedPrefixes(config);
|
|
640
|
+
return protectedPrefixes.some((prefix) => branch.startsWith(prefix));
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// src/commands/branch.ts
|
|
644
|
+
var branch_default = defineCommand({
|
|
502
645
|
meta: {
|
|
503
|
-
name: "
|
|
504
|
-
description: "
|
|
646
|
+
name: "branch",
|
|
647
|
+
description: "List branches with workflow-aware labels and status"
|
|
505
648
|
},
|
|
506
649
|
args: {
|
|
507
|
-
|
|
650
|
+
all: {
|
|
508
651
|
type: "boolean",
|
|
509
|
-
alias: "
|
|
510
|
-
description: "
|
|
652
|
+
alias: "a",
|
|
653
|
+
description: "Show both local and remote branches",
|
|
654
|
+
default: false
|
|
655
|
+
},
|
|
656
|
+
remote: {
|
|
657
|
+
type: "boolean",
|
|
658
|
+
alias: "r",
|
|
659
|
+
description: "Show only remote branches",
|
|
511
660
|
default: false
|
|
512
661
|
}
|
|
513
662
|
},
|
|
@@ -517,150 +666,203 @@ var clean_default = defineCommand({
|
|
|
517
666
|
process.exit(1);
|
|
518
667
|
}
|
|
519
668
|
const config = readConfig();
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
const goneCandidates = goneBranches.filter((b) => !protectedBranches.has(b) && !mergedCandidates.includes(b));
|
|
540
|
-
if (mergedCandidates.length > 0) {
|
|
541
|
-
console.log(`
|
|
542
|
-
${pc3.bold("Merged branches to delete:")}`);
|
|
543
|
-
for (const b of mergedCandidates) {
|
|
544
|
-
const marker = b === currentBranch ? pc3.yellow(" (current)") : "";
|
|
545
|
-
console.log(` ${pc3.dim("•")} ${b}${marker}`);
|
|
546
|
-
}
|
|
547
|
-
console.log();
|
|
548
|
-
const ok = args.yes || await confirmPrompt(`Delete ${pc3.bold(String(mergedCandidates.length))} merged branch${mergedCandidates.length !== 1 ? "es" : ""}?`);
|
|
549
|
-
if (ok) {
|
|
550
|
-
for (const branch of mergedCandidates) {
|
|
551
|
-
if (branch === currentBranch) {
|
|
552
|
-
const result2 = await handleCurrentBranchDeletion(currentBranch, baseBranch, config);
|
|
553
|
-
if (result2 === "skipped") {
|
|
554
|
-
warn(` Skipped ${branch}.`);
|
|
555
|
-
continue;
|
|
556
|
-
}
|
|
557
|
-
if (result2 === "saved") {
|
|
558
|
-
currentBranch = baseBranch;
|
|
559
|
-
continue;
|
|
560
|
-
}
|
|
561
|
-
currentBranch = baseBranch;
|
|
669
|
+
const protectedBranches = config ? getProtectedBranches(config) : ["main", "master"];
|
|
670
|
+
const currentBranch = await getCurrentBranch();
|
|
671
|
+
const showRemoteOnly = args.remote;
|
|
672
|
+
const showAll = args.all;
|
|
673
|
+
heading("\uD83C\uDF3F branches");
|
|
674
|
+
console.log();
|
|
675
|
+
if (!showRemoteOnly) {
|
|
676
|
+
const localBranches = await getLocalBranches();
|
|
677
|
+
if (localBranches.length === 0) {
|
|
678
|
+
console.log(pc2.dim(" No local branches found."));
|
|
679
|
+
} else {
|
|
680
|
+
console.log(` ${pc2.bold("Local")}`);
|
|
681
|
+
console.log();
|
|
682
|
+
for (const branch of localBranches) {
|
|
683
|
+
const parts = [];
|
|
684
|
+
if (branch.isCurrent) {
|
|
685
|
+
parts.push(pc2.green("* "));
|
|
686
|
+
} else {
|
|
687
|
+
parts.push(" ");
|
|
562
688
|
}
|
|
563
|
-
const
|
|
564
|
-
|
|
565
|
-
|
|
689
|
+
const nameStr = colorBranchName(branch.name, protectedBranches, currentBranch);
|
|
690
|
+
parts.push(nameStr.padEnd(30));
|
|
691
|
+
if (branch.gone) {
|
|
692
|
+
parts.push(pc2.red(" ✗ remote gone"));
|
|
693
|
+
} else if (branch.upstream) {
|
|
694
|
+
parts.push(pc2.dim(` → ${branch.upstream}`));
|
|
566
695
|
} else {
|
|
567
|
-
|
|
696
|
+
parts.push(pc2.dim(" (no remote)"));
|
|
697
|
+
}
|
|
698
|
+
const labels = getBranchLabels(branch.name, protectedBranches, config);
|
|
699
|
+
if (labels.length > 0) {
|
|
700
|
+
parts.push(` ${labels.join(" ")}`);
|
|
568
701
|
}
|
|
702
|
+
console.log(` ${parts.join("")}`);
|
|
569
703
|
}
|
|
570
|
-
} else {
|
|
571
|
-
info("Skipped merged branch deletion.");
|
|
572
704
|
}
|
|
573
705
|
}
|
|
574
|
-
if (
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
const marker = b === currentBranch ? pc3.yellow(" (current)") : "";
|
|
579
|
-
console.log(` ${pc3.dim("•")} ${b}${marker}`);
|
|
706
|
+
if (showRemoteOnly || showAll) {
|
|
707
|
+
const remoteBranches = await getRemoteBranches();
|
|
708
|
+
if (!showRemoteOnly) {
|
|
709
|
+
console.log();
|
|
580
710
|
}
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
continue;
|
|
594
|
-
}
|
|
595
|
-
currentBranch = baseBranch;
|
|
596
|
-
}
|
|
597
|
-
const result = await forceDeleteBranch(branch);
|
|
598
|
-
if (result.exitCode === 0) {
|
|
599
|
-
success(` Deleted ${pc3.bold(branch)}`);
|
|
600
|
-
} else {
|
|
601
|
-
warn(` Failed to delete ${branch}: ${result.stderr.trim()}`);
|
|
711
|
+
if (remoteBranches.length === 0) {
|
|
712
|
+
console.log(pc2.dim(" No remote branches found."));
|
|
713
|
+
} else {
|
|
714
|
+
const grouped = groupByRemote(remoteBranches);
|
|
715
|
+
for (const [remote, branches] of Object.entries(grouped)) {
|
|
716
|
+
console.log(` ${pc2.bold(`Remote: ${remote}`)}`);
|
|
717
|
+
console.log();
|
|
718
|
+
for (const fullRef of branches) {
|
|
719
|
+
const branchName = fullRef.slice(remote.length + 1);
|
|
720
|
+
const nameStr = colorBranchName(branchName, protectedBranches, currentBranch);
|
|
721
|
+
const remotePrefix = pc2.dim(`${remote}/`);
|
|
722
|
+
console.log(` ${remotePrefix}${nameStr}`);
|
|
602
723
|
}
|
|
724
|
+
console.log();
|
|
603
725
|
}
|
|
604
|
-
} else {
|
|
605
|
-
info("Skipped stale branch deletion.");
|
|
606
726
|
}
|
|
607
727
|
}
|
|
608
|
-
|
|
609
|
-
|
|
728
|
+
const tips = [];
|
|
729
|
+
if (!showAll && !showRemoteOnly) {
|
|
730
|
+
tips.push(`Use ${pc2.bold("contrib branch -a")} to include remote branches`);
|
|
610
731
|
}
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
info(`You're on ${pc3.bold(finalBranch)}. Run ${pc3.bold("contrib start")} to begin a new feature.`);
|
|
732
|
+
if (!showRemoteOnly) {
|
|
733
|
+
tips.push(`Use ${pc2.bold("contrib start")} to create a new feature branch`);
|
|
734
|
+
tips.push(`Use ${pc2.bold("contrib clean")} to remove merged/stale branches`);
|
|
615
735
|
}
|
|
736
|
+
if (tips.length > 0) {
|
|
737
|
+
console.log(` ${pc2.dim("\uD83D\uDCA1 Tip:")}`);
|
|
738
|
+
for (const tip of tips) {
|
|
739
|
+
console.log(` ${pc2.dim(tip)}`);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
console.log();
|
|
616
743
|
}
|
|
617
744
|
});
|
|
745
|
+
function colorBranchName(name, protectedBranches, currentBranch) {
|
|
746
|
+
if (name === currentBranch) {
|
|
747
|
+
return pc2.bold(pc2.green(name));
|
|
748
|
+
}
|
|
749
|
+
if (protectedBranches.includes(name)) {
|
|
750
|
+
return pc2.bold(pc2.red(name));
|
|
751
|
+
}
|
|
752
|
+
return name;
|
|
753
|
+
}
|
|
754
|
+
function getBranchLabels(name, protectedBranches, config) {
|
|
755
|
+
const labels = [];
|
|
756
|
+
if (protectedBranches.includes(name)) {
|
|
757
|
+
labels.push(pc2.dim(pc2.red("[protected]")));
|
|
758
|
+
}
|
|
759
|
+
if (config) {
|
|
760
|
+
if (name === config.mainBranch) {
|
|
761
|
+
labels.push(pc2.dim(pc2.cyan("[main]")));
|
|
762
|
+
}
|
|
763
|
+
if (config.devBranch && name === config.devBranch) {
|
|
764
|
+
labels.push(pc2.dim(pc2.cyan("[dev]")));
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
return labels;
|
|
768
|
+
}
|
|
769
|
+
function groupByRemote(branches) {
|
|
770
|
+
const grouped = {};
|
|
771
|
+
for (const ref of branches) {
|
|
772
|
+
const slashIdx = ref.indexOf("/");
|
|
773
|
+
const remote = slashIdx !== -1 ? ref.slice(0, slashIdx) : "unknown";
|
|
774
|
+
if (!grouped[remote]) {
|
|
775
|
+
grouped[remote] = [];
|
|
776
|
+
}
|
|
777
|
+
grouped[remote].push(ref);
|
|
778
|
+
}
|
|
779
|
+
return grouped;
|
|
780
|
+
}
|
|
618
781
|
|
|
619
|
-
// src/commands/
|
|
782
|
+
// src/commands/clean.ts
|
|
620
783
|
import { defineCommand as defineCommand2 } from "citty";
|
|
621
784
|
import pc5 from "picocolors";
|
|
622
785
|
|
|
623
|
-
// src/utils/
|
|
624
|
-
var
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
"
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
786
|
+
// src/utils/branch.ts
|
|
787
|
+
var DEFAULT_PREFIXES = ["feature", "fix", "docs", "chore", "test", "refactor"];
|
|
788
|
+
function hasPrefix(branchName, prefixes = DEFAULT_PREFIXES) {
|
|
789
|
+
return prefixes.some((p) => branchName.startsWith(`${p}/`));
|
|
790
|
+
}
|
|
791
|
+
function formatBranchName(prefix, name) {
|
|
792
|
+
const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
793
|
+
return `${prefix}/${sanitized}`;
|
|
794
|
+
}
|
|
795
|
+
var RESERVED_GIT_NAMES = new Set([
|
|
796
|
+
"HEAD",
|
|
797
|
+
"FETCH_HEAD",
|
|
798
|
+
"ORIG_HEAD",
|
|
799
|
+
"MERGE_HEAD",
|
|
800
|
+
"CHERRY_PICK_HEAD",
|
|
801
|
+
"REBASE_HEAD",
|
|
802
|
+
"BISECT_HEAD"
|
|
803
|
+
]);
|
|
804
|
+
function isValidBranchName(name) {
|
|
805
|
+
if (!name || name.length === 0)
|
|
806
|
+
return false;
|
|
807
|
+
if (RESERVED_GIT_NAMES.has(name))
|
|
808
|
+
return false;
|
|
809
|
+
if (name.startsWith("-"))
|
|
810
|
+
return false;
|
|
811
|
+
if (name.includes("..") || name.includes("@{"))
|
|
812
|
+
return false;
|
|
813
|
+
if (/[\x00-\x1f\x7f ~^:?*[\]\\]/.test(name))
|
|
814
|
+
return false;
|
|
815
|
+
if (name.includes("/.") || name.endsWith(".lock") || name.endsWith("."))
|
|
816
|
+
return false;
|
|
817
|
+
if (!/^[a-zA-Z0-9._/-]+$/.test(name))
|
|
818
|
+
return false;
|
|
819
|
+
if (name.startsWith("/") || name.endsWith("/") || name.includes("//"))
|
|
820
|
+
return false;
|
|
655
821
|
return true;
|
|
656
822
|
}
|
|
657
|
-
function
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
823
|
+
function looksLikeNaturalLanguage(input) {
|
|
824
|
+
return input.includes(" ") && !input.includes("/");
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// src/utils/confirm.ts
|
|
828
|
+
import * as clack from "@clack/prompts";
|
|
829
|
+
import pc3 from "picocolors";
|
|
830
|
+
function handleCancel(value) {
|
|
831
|
+
if (clack.isCancel(value)) {
|
|
832
|
+
clack.cancel("Cancelled.");
|
|
833
|
+
process.exit(0);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
async function confirmPrompt(message) {
|
|
837
|
+
const result = await clack.confirm({ message });
|
|
838
|
+
handleCancel(result);
|
|
839
|
+
return result;
|
|
840
|
+
}
|
|
841
|
+
async function selectPrompt(message, choices) {
|
|
842
|
+
const result = await clack.select({
|
|
843
|
+
message,
|
|
844
|
+
options: choices.map((choice) => ({ value: choice, label: choice }))
|
|
845
|
+
});
|
|
846
|
+
handleCancel(result);
|
|
847
|
+
return result;
|
|
848
|
+
}
|
|
849
|
+
async function inputPrompt(message, defaultValue) {
|
|
850
|
+
const result = await clack.text({
|
|
851
|
+
message,
|
|
852
|
+
placeholder: defaultValue,
|
|
853
|
+
defaultValue
|
|
854
|
+
});
|
|
855
|
+
handleCancel(result);
|
|
856
|
+
return result || defaultValue || "";
|
|
857
|
+
}
|
|
858
|
+
async function multiSelectPrompt(message, choices) {
|
|
859
|
+
const result = await clack.multiselect({
|
|
860
|
+
message: `${message} ${pc3.dim("(space to toggle, enter to confirm)")}`,
|
|
861
|
+
options: choices.map((choice) => ({ value: choice, label: choice })),
|
|
862
|
+
required: false
|
|
863
|
+
});
|
|
864
|
+
handleCancel(result);
|
|
865
|
+
return result;
|
|
664
866
|
}
|
|
665
867
|
|
|
666
868
|
// src/utils/copilot.ts
|
|
@@ -700,28 +902,31 @@ Rules:
|
|
|
700
902
|
- Order groups so foundational changes come first (types, utils) and consumers come after
|
|
701
903
|
- Return ONLY the JSON array, nothing else`;
|
|
702
904
|
}
|
|
703
|
-
var BRANCH_NAME_SYSTEM_PROMPT = `
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
905
|
+
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.
|
|
906
|
+
Output format: <prefix>/<kebab-case-name>
|
|
907
|
+
Valid prefixes: feature, fix, docs, chore, test, refactor
|
|
908
|
+
Rules: lowercase, kebab-case, 2-5 words after the prefix, no punctuation.
|
|
909
|
+
CRITICAL: Output ONLY the branch name on a single line. No explanation. No markdown. No questions. No other text.
|
|
910
|
+
Examples: fix/login-timeout | feature/user-profile-page | docs/update-readme | chore/update-pr-title`;
|
|
911
|
+
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..."}
|
|
912
|
+
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.`;
|
|
708
913
|
function getPRDescriptionSystemPrompt(convention) {
|
|
709
914
|
if (convention === "clean-commit") {
|
|
710
915
|
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
711
916
|
CRITICAL: The PR title MUST follow the Clean Commit format exactly: <emoji> <type>: <description>
|
|
712
917
|
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
|
|
713
918
|
Title examples: \uD83D\uDCE6 new: add user authentication | \uD83D\uDD27 update: improve error handling | \uD83D\uDDD1️ remove: drop legacy API
|
|
714
|
-
Rules: title follows convention, present tense, max 72 chars; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
|
|
919
|
+
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.`;
|
|
715
920
|
}
|
|
716
921
|
if (convention === "conventional") {
|
|
717
922
|
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
718
923
|
CRITICAL: The PR title MUST follow Conventional Commits format: <type>[(<scope>)]: <description>
|
|
719
924
|
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
|
720
925
|
Title examples: feat: add user authentication | fix(auth): resolve token expiry | docs: update contributing guide
|
|
721
|
-
Rules: title follows convention, present tense, max 72 chars; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
|
|
926
|
+
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.`;
|
|
722
927
|
}
|
|
723
928
|
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
724
|
-
Rules: title concise present tense; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
|
|
929
|
+
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.`;
|
|
725
930
|
}
|
|
726
931
|
var CONFLICT_RESOLUTION_SYSTEM_PROMPT = `Git merge conflict advisor. Explain each side, suggest resolution strategy. Never auto-resolve — guidance only. Be concise and actionable.`;
|
|
727
932
|
function suppressSubprocessWarnings() {
|
|
@@ -878,7 +1083,11 @@ ${diff.slice(0, 4000)}`;
|
|
|
878
1083
|
async function suggestBranchName(description, model) {
|
|
879
1084
|
try {
|
|
880
1085
|
const result = await callCopilot(BRANCH_NAME_SYSTEM_PROMPT, description, model);
|
|
881
|
-
|
|
1086
|
+
const trimmed = result?.trim() ?? null;
|
|
1087
|
+
if (trimmed && /^[a-z]+\/[a-z0-9-]+$/.test(trimmed)) {
|
|
1088
|
+
return trimmed;
|
|
1089
|
+
}
|
|
1090
|
+
return null;
|
|
882
1091
|
} catch {
|
|
883
1092
|
return null;
|
|
884
1093
|
}
|
|
@@ -966,6 +1175,144 @@ ${diffs.slice(0, 4000)}`;
|
|
|
966
1175
|
}
|
|
967
1176
|
}
|
|
968
1177
|
|
|
1178
|
+
// src/utils/gh.ts
|
|
1179
|
+
import { execFile as execFileCb2 } from "node:child_process";
|
|
1180
|
+
function run2(args) {
|
|
1181
|
+
return new Promise((resolve) => {
|
|
1182
|
+
execFileCb2("gh", args, (error2, stdout, stderr) => {
|
|
1183
|
+
resolve({
|
|
1184
|
+
exitCode: error2 ? error2.code === "ENOENT" ? 127 : error2.status ?? 1 : 0,
|
|
1185
|
+
stdout: stdout ?? "",
|
|
1186
|
+
stderr: stderr ?? ""
|
|
1187
|
+
});
|
|
1188
|
+
});
|
|
1189
|
+
});
|
|
1190
|
+
}
|
|
1191
|
+
async function checkGhInstalled() {
|
|
1192
|
+
try {
|
|
1193
|
+
const { exitCode } = await run2(["--version"]);
|
|
1194
|
+
return exitCode === 0;
|
|
1195
|
+
} catch {
|
|
1196
|
+
return false;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
async function checkGhAuth() {
|
|
1200
|
+
try {
|
|
1201
|
+
const { exitCode } = await run2(["auth", "status"]);
|
|
1202
|
+
return exitCode === 0;
|
|
1203
|
+
} catch {
|
|
1204
|
+
return false;
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
var SAFE_SLUG = /^[\w.-]+$/;
|
|
1208
|
+
async function checkRepoPermissions(owner, repo) {
|
|
1209
|
+
if (!SAFE_SLUG.test(owner) || !SAFE_SLUG.test(repo))
|
|
1210
|
+
return null;
|
|
1211
|
+
const { exitCode, stdout } = await run2(["api", `repos/${owner}/${repo}`, "--jq", ".permissions"]);
|
|
1212
|
+
if (exitCode !== 0)
|
|
1213
|
+
return null;
|
|
1214
|
+
try {
|
|
1215
|
+
return JSON.parse(stdout.trim());
|
|
1216
|
+
} catch {
|
|
1217
|
+
return null;
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
async function isRepoFork() {
|
|
1221
|
+
const { exitCode, stdout } = await run2(["repo", "view", "--json", "isFork", "-q", ".isFork"]);
|
|
1222
|
+
if (exitCode !== 0)
|
|
1223
|
+
return null;
|
|
1224
|
+
const val = stdout.trim();
|
|
1225
|
+
if (val === "true")
|
|
1226
|
+
return true;
|
|
1227
|
+
if (val === "false")
|
|
1228
|
+
return false;
|
|
1229
|
+
return null;
|
|
1230
|
+
}
|
|
1231
|
+
async function getCurrentRepoInfo() {
|
|
1232
|
+
const { exitCode, stdout } = await run2([
|
|
1233
|
+
"repo",
|
|
1234
|
+
"view",
|
|
1235
|
+
"--json",
|
|
1236
|
+
"nameWithOwner",
|
|
1237
|
+
"-q",
|
|
1238
|
+
".nameWithOwner"
|
|
1239
|
+
]);
|
|
1240
|
+
if (exitCode !== 0)
|
|
1241
|
+
return null;
|
|
1242
|
+
const nameWithOwner = stdout.trim();
|
|
1243
|
+
if (!nameWithOwner)
|
|
1244
|
+
return null;
|
|
1245
|
+
const [owner, repo] = nameWithOwner.split("/");
|
|
1246
|
+
if (!owner || !repo)
|
|
1247
|
+
return null;
|
|
1248
|
+
return { owner, repo };
|
|
1249
|
+
}
|
|
1250
|
+
async function createPR(options) {
|
|
1251
|
+
const args = [
|
|
1252
|
+
"pr",
|
|
1253
|
+
"create",
|
|
1254
|
+
"--base",
|
|
1255
|
+
options.base,
|
|
1256
|
+
"--title",
|
|
1257
|
+
options.title,
|
|
1258
|
+
"--body",
|
|
1259
|
+
options.body
|
|
1260
|
+
];
|
|
1261
|
+
if (options.draft)
|
|
1262
|
+
args.push("--draft");
|
|
1263
|
+
return run2(args);
|
|
1264
|
+
}
|
|
1265
|
+
async function createPRFill(base, draft) {
|
|
1266
|
+
const args = ["pr", "create", "--base", base, "--fill"];
|
|
1267
|
+
if (draft)
|
|
1268
|
+
args.push("--draft");
|
|
1269
|
+
return run2(args);
|
|
1270
|
+
}
|
|
1271
|
+
async function getPRForBranch(headBranch) {
|
|
1272
|
+
const { exitCode, stdout } = await run2([
|
|
1273
|
+
"pr",
|
|
1274
|
+
"list",
|
|
1275
|
+
"--head",
|
|
1276
|
+
headBranch,
|
|
1277
|
+
"--state",
|
|
1278
|
+
"open",
|
|
1279
|
+
"--json",
|
|
1280
|
+
"number,url,title,state",
|
|
1281
|
+
"--limit",
|
|
1282
|
+
"1"
|
|
1283
|
+
]);
|
|
1284
|
+
if (exitCode !== 0)
|
|
1285
|
+
return null;
|
|
1286
|
+
try {
|
|
1287
|
+
const prs = JSON.parse(stdout.trim());
|
|
1288
|
+
return prs.length > 0 ? prs[0] : null;
|
|
1289
|
+
} catch {
|
|
1290
|
+
return null;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
async function getMergedPRForBranch(headBranch) {
|
|
1294
|
+
const { exitCode, stdout } = await run2([
|
|
1295
|
+
"pr",
|
|
1296
|
+
"list",
|
|
1297
|
+
"--head",
|
|
1298
|
+
headBranch,
|
|
1299
|
+
"--state",
|
|
1300
|
+
"merged",
|
|
1301
|
+
"--json",
|
|
1302
|
+
"number,url,title,state",
|
|
1303
|
+
"--limit",
|
|
1304
|
+
"1"
|
|
1305
|
+
]);
|
|
1306
|
+
if (exitCode !== 0)
|
|
1307
|
+
return null;
|
|
1308
|
+
try {
|
|
1309
|
+
const prs = JSON.parse(stdout.trim());
|
|
1310
|
+
return prs.length > 0 ? prs[0] : null;
|
|
1311
|
+
} catch {
|
|
1312
|
+
return null;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
|
|
969
1316
|
// src/utils/spinner.ts
|
|
970
1317
|
import pc4 from "picocolors";
|
|
971
1318
|
var FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
@@ -1010,11 +1357,283 @@ function createSpinner(text2) {
|
|
|
1010
1357
|
stop() {
|
|
1011
1358
|
stop();
|
|
1012
1359
|
}
|
|
1013
|
-
};
|
|
1360
|
+
};
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
// src/commands/clean.ts
|
|
1364
|
+
async function handleCurrentBranchDeletion(currentBranch, baseBranch, config) {
|
|
1365
|
+
if (!config)
|
|
1366
|
+
return "skipped";
|
|
1367
|
+
const { origin } = config;
|
|
1368
|
+
const localWork = await hasLocalWork(origin, currentBranch);
|
|
1369
|
+
const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
|
|
1370
|
+
if (hasWork) {
|
|
1371
|
+
if (localWork.uncommitted) {
|
|
1372
|
+
warn("You have uncommitted changes in your working tree.");
|
|
1373
|
+
}
|
|
1374
|
+
if (localWork.unpushedCommits > 0) {
|
|
1375
|
+
warn(`You have ${pc5.bold(String(localWork.unpushedCommits))} local commit${localWork.unpushedCommits !== 1 ? "s" : ""} not pushed.`);
|
|
1376
|
+
}
|
|
1377
|
+
const SAVE_NEW_BRANCH = "Save changes to a new branch";
|
|
1378
|
+
const DISCARD = "Discard all changes and clean up";
|
|
1379
|
+
const CANCEL = "Skip this branch";
|
|
1380
|
+
const action = await selectPrompt(`${pc5.bold(currentBranch)} has local changes. What would you like to do?`, [SAVE_NEW_BRANCH, DISCARD, CANCEL]);
|
|
1381
|
+
if (action === CANCEL)
|
|
1382
|
+
return "skipped";
|
|
1383
|
+
if (action === SAVE_NEW_BRANCH) {
|
|
1384
|
+
if (!config)
|
|
1385
|
+
return "skipped";
|
|
1386
|
+
info(pc5.dim("Tip: Describe what you're going to work on in plain English and we'll generate a branch name."));
|
|
1387
|
+
const description = await inputPrompt("What are you going to work on?");
|
|
1388
|
+
let newBranchName = description;
|
|
1389
|
+
if (looksLikeNaturalLanguage(description)) {
|
|
1390
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
1391
|
+
const suggested = await suggestBranchName(description);
|
|
1392
|
+
if (suggested) {
|
|
1393
|
+
spinner.success("Branch name suggestion ready.");
|
|
1394
|
+
console.log(`
|
|
1395
|
+
${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(suggested))}`);
|
|
1396
|
+
const accepted = await confirmPrompt(`Use ${pc5.bold(suggested)} as your branch name?`);
|
|
1397
|
+
newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
|
|
1398
|
+
} else {
|
|
1399
|
+
spinner.fail("AI did not return a suggestion.");
|
|
1400
|
+
newBranchName = await inputPrompt("Enter branch name", description);
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
if (!hasPrefix(newBranchName, config.branchPrefixes)) {
|
|
1404
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc5.bold(newBranchName)}:`, config.branchPrefixes);
|
|
1405
|
+
newBranchName = formatBranchName(prefix, newBranchName);
|
|
1406
|
+
}
|
|
1407
|
+
if (!isValidBranchName(newBranchName)) {
|
|
1408
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
1409
|
+
return "skipped";
|
|
1410
|
+
}
|
|
1411
|
+
if (await branchExists(newBranchName)) {
|
|
1412
|
+
error(`Branch ${pc5.bold(newBranchName)} already exists. Choose a different name.`);
|
|
1413
|
+
return "skipped";
|
|
1414
|
+
}
|
|
1415
|
+
const renameResult = await renameBranch(currentBranch, newBranchName);
|
|
1416
|
+
if (renameResult.exitCode !== 0) {
|
|
1417
|
+
error(`Failed to rename branch: ${renameResult.stderr}`);
|
|
1418
|
+
return "skipped";
|
|
1419
|
+
}
|
|
1420
|
+
success(`Renamed ${pc5.bold(currentBranch)} → ${pc5.bold(newBranchName)}`);
|
|
1421
|
+
const syncSource2 = getSyncSource(config);
|
|
1422
|
+
await fetchRemote(syncSource2.remote);
|
|
1423
|
+
const savedUpstreamRef = await getUpstreamRef();
|
|
1424
|
+
const rebaseResult = savedUpstreamRef && savedUpstreamRef !== syncSource2.ref ? await rebaseOnto(syncSource2.ref, savedUpstreamRef) : await rebase(syncSource2.ref);
|
|
1425
|
+
if (rebaseResult.exitCode !== 0) {
|
|
1426
|
+
await rebaseAbort();
|
|
1427
|
+
warn("Rebase had conflicts — aborted to keep the repo in a clean state.");
|
|
1428
|
+
info(`Your work is saved on ${pc5.bold(newBranchName)}. After cleanup, rebase manually:`);
|
|
1429
|
+
info(` ${pc5.bold(`git checkout ${newBranchName} && git rebase ${syncSource2.ref}`)}`);
|
|
1430
|
+
} else {
|
|
1431
|
+
success(`Rebased ${pc5.bold(newBranchName)} onto ${pc5.bold(syncSource2.ref)}.`);
|
|
1432
|
+
}
|
|
1433
|
+
const coResult2 = await checkoutBranch(baseBranch);
|
|
1434
|
+
if (coResult2.exitCode !== 0) {
|
|
1435
|
+
error(`Failed to checkout ${baseBranch}: ${coResult2.stderr}`);
|
|
1436
|
+
return "saved";
|
|
1437
|
+
}
|
|
1438
|
+
await updateLocalBranch(baseBranch, syncSource2.ref);
|
|
1439
|
+
success(`Synced ${pc5.bold(baseBranch)} with ${pc5.bold(syncSource2.ref)}.`);
|
|
1440
|
+
return "saved";
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
const syncSource = getSyncSource(config);
|
|
1444
|
+
info(`Switching to ${pc5.bold(baseBranch)} and syncing...`);
|
|
1445
|
+
await fetchRemote(syncSource.remote);
|
|
1446
|
+
await resetHard("HEAD");
|
|
1447
|
+
const coResult = await checkoutBranch(baseBranch);
|
|
1448
|
+
if (coResult.exitCode !== 0) {
|
|
1449
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
1450
|
+
return "skipped";
|
|
1451
|
+
}
|
|
1452
|
+
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
1453
|
+
success(`Synced ${pc5.bold(baseBranch)} with ${pc5.bold(syncSource.ref)}.`);
|
|
1454
|
+
return "switched";
|
|
1455
|
+
}
|
|
1456
|
+
var clean_default = defineCommand2({
|
|
1457
|
+
meta: {
|
|
1458
|
+
name: "clean",
|
|
1459
|
+
description: "Delete merged branches and prune remote refs"
|
|
1460
|
+
},
|
|
1461
|
+
args: {
|
|
1462
|
+
yes: {
|
|
1463
|
+
type: "boolean",
|
|
1464
|
+
alias: "y",
|
|
1465
|
+
description: "Skip confirmation prompt",
|
|
1466
|
+
default: false
|
|
1467
|
+
}
|
|
1468
|
+
},
|
|
1469
|
+
async run({ args }) {
|
|
1470
|
+
if (!await isGitRepo()) {
|
|
1471
|
+
error("Not inside a git repository.");
|
|
1472
|
+
process.exit(1);
|
|
1473
|
+
}
|
|
1474
|
+
await assertCleanGitState("cleaning");
|
|
1475
|
+
const config = readConfig();
|
|
1476
|
+
if (!config) {
|
|
1477
|
+
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
1478
|
+
process.exit(1);
|
|
1479
|
+
}
|
|
1480
|
+
const { origin } = config;
|
|
1481
|
+
const baseBranch = getBaseBranch(config);
|
|
1482
|
+
let currentBranch = await getCurrentBranch();
|
|
1483
|
+
heading("\uD83E\uDDF9 contrib clean");
|
|
1484
|
+
info(`Pruning ${origin} remote refs...`);
|
|
1485
|
+
const pruneResult = await pruneRemote(origin);
|
|
1486
|
+
if (pruneResult.exitCode === 0) {
|
|
1487
|
+
success(`Pruned ${origin} remote refs.`);
|
|
1488
|
+
} else {
|
|
1489
|
+
warn(`Could not prune remote: ${pruneResult.stderr.trim()}`);
|
|
1490
|
+
}
|
|
1491
|
+
const protectedBranches = new Set(getProtectedBranches(config));
|
|
1492
|
+
const isProtected = (b) => protectedBranches.has(b) || isBranchProtected(b, config);
|
|
1493
|
+
const mergedBranches = await getMergedBranches(baseBranch);
|
|
1494
|
+
const mergedCandidates = mergedBranches.filter((b) => !isProtected(b));
|
|
1495
|
+
const goneBranches = await getGoneBranches();
|
|
1496
|
+
const goneCandidates = goneBranches.filter((b) => !isProtected(b) && !mergedCandidates.includes(b));
|
|
1497
|
+
if (currentBranch && !isProtected(currentBranch) && !mergedCandidates.includes(currentBranch) && !goneCandidates.includes(currentBranch)) {
|
|
1498
|
+
const ghInstalled = await checkGhInstalled();
|
|
1499
|
+
const ghAuthed = ghInstalled && await checkGhAuth();
|
|
1500
|
+
if (ghInstalled && ghAuthed) {
|
|
1501
|
+
const mergedPR = await getMergedPRForBranch(currentBranch);
|
|
1502
|
+
if (mergedPR) {
|
|
1503
|
+
warn(`PR #${mergedPR.number} (${pc5.bold(mergedPR.title)}) has already been merged.`);
|
|
1504
|
+
info(`Link: ${pc5.underline(mergedPR.url)}`);
|
|
1505
|
+
goneCandidates.push(currentBranch);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
if (mergedCandidates.length > 0) {
|
|
1510
|
+
console.log(`
|
|
1511
|
+
${pc5.bold("Merged branches to delete:")}`);
|
|
1512
|
+
for (const b of mergedCandidates) {
|
|
1513
|
+
const marker = b === currentBranch ? pc5.yellow(" (current)") : "";
|
|
1514
|
+
console.log(` ${pc5.dim("•")} ${b}${marker}`);
|
|
1515
|
+
}
|
|
1516
|
+
console.log();
|
|
1517
|
+
const ok = args.yes || await confirmPrompt(`Delete ${pc5.bold(String(mergedCandidates.length))} merged branch${mergedCandidates.length !== 1 ? "es" : ""}?`);
|
|
1518
|
+
if (ok) {
|
|
1519
|
+
for (const branch of mergedCandidates) {
|
|
1520
|
+
if (branch === currentBranch) {
|
|
1521
|
+
const result2 = await handleCurrentBranchDeletion(currentBranch, baseBranch, config);
|
|
1522
|
+
if (result2 === "skipped") {
|
|
1523
|
+
warn(` Skipped ${branch}.`);
|
|
1524
|
+
continue;
|
|
1525
|
+
}
|
|
1526
|
+
if (result2 === "saved") {
|
|
1527
|
+
currentBranch = baseBranch;
|
|
1528
|
+
continue;
|
|
1529
|
+
}
|
|
1530
|
+
currentBranch = baseBranch;
|
|
1531
|
+
}
|
|
1532
|
+
const result = await deleteBranch(branch);
|
|
1533
|
+
if (result.exitCode === 0) {
|
|
1534
|
+
success(` Deleted ${pc5.bold(branch)}`);
|
|
1535
|
+
} else {
|
|
1536
|
+
warn(` Failed to delete ${branch}: ${result.stderr.trim()}`);
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
} else {
|
|
1540
|
+
info("Skipped merged branch deletion.");
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
if (goneCandidates.length > 0) {
|
|
1544
|
+
console.log(`
|
|
1545
|
+
${pc5.bold("Stale branches (remote deleted, likely squash-merged):")}`);
|
|
1546
|
+
for (const b of goneCandidates) {
|
|
1547
|
+
const marker = b === currentBranch ? pc5.yellow(" (current)") : "";
|
|
1548
|
+
console.log(` ${pc5.dim("•")} ${b}${marker}`);
|
|
1549
|
+
}
|
|
1550
|
+
console.log();
|
|
1551
|
+
const ok = args.yes || await confirmPrompt(`Delete ${pc5.bold(String(goneCandidates.length))} stale branch${goneCandidates.length !== 1 ? "es" : ""}?`);
|
|
1552
|
+
if (ok) {
|
|
1553
|
+
for (const branch of goneCandidates) {
|
|
1554
|
+
if (branch === currentBranch) {
|
|
1555
|
+
const result2 = await handleCurrentBranchDeletion(currentBranch, baseBranch, config);
|
|
1556
|
+
if (result2 === "skipped") {
|
|
1557
|
+
warn(` Skipped ${branch}.`);
|
|
1558
|
+
continue;
|
|
1559
|
+
}
|
|
1560
|
+
if (result2 === "saved") {
|
|
1561
|
+
currentBranch = baseBranch;
|
|
1562
|
+
continue;
|
|
1563
|
+
}
|
|
1564
|
+
currentBranch = baseBranch;
|
|
1565
|
+
}
|
|
1566
|
+
const result = await forceDeleteBranch(branch);
|
|
1567
|
+
if (result.exitCode === 0) {
|
|
1568
|
+
success(` Deleted ${pc5.bold(branch)}`);
|
|
1569
|
+
} else {
|
|
1570
|
+
warn(` Failed to delete ${branch}: ${result.stderr.trim()}`);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
} else {
|
|
1574
|
+
info("Skipped stale branch deletion.");
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
if (mergedCandidates.length === 0 && goneCandidates.length === 0) {
|
|
1578
|
+
info("No branches to clean up. Everything is tidy! \uD83E\uDDF9");
|
|
1579
|
+
}
|
|
1580
|
+
const finalBranch = await getCurrentBranch();
|
|
1581
|
+
if (finalBranch && protectedBranches.has(finalBranch)) {
|
|
1582
|
+
console.log();
|
|
1583
|
+
info(`You're on ${pc5.bold(finalBranch)}. Run ${pc5.bold("contrib start")} to begin a new feature.`);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
});
|
|
1587
|
+
|
|
1588
|
+
// src/commands/commit.ts
|
|
1589
|
+
import { defineCommand as defineCommand3 } from "citty";
|
|
1590
|
+
import pc6 from "picocolors";
|
|
1591
|
+
|
|
1592
|
+
// src/utils/convention.ts
|
|
1593
|
+
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;
|
|
1594
|
+
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}$/;
|
|
1595
|
+
var CONVENTION_LABELS = {
|
|
1596
|
+
conventional: "Conventional Commits",
|
|
1597
|
+
"clean-commit": "Clean Commit (by WGTech Labs)",
|
|
1598
|
+
none: "No convention"
|
|
1599
|
+
};
|
|
1600
|
+
var CONVENTION_DESCRIPTIONS = {
|
|
1601
|
+
conventional: "Conventional Commits — feat: | fix: | docs: | chore: etc. (conventionalcommits.org)",
|
|
1602
|
+
"clean-commit": "Clean Commit — \uD83D\uDCE6 new: | \uD83D\uDD27 update: | \uD83D\uDDD1️ remove: etc. (by WGTech Labs)",
|
|
1603
|
+
none: "No commit convention enforcement"
|
|
1604
|
+
};
|
|
1605
|
+
var CONVENTION_FORMAT_HINTS = {
|
|
1606
|
+
conventional: [
|
|
1607
|
+
"Format: <type>[!][(<scope>)]: <description>",
|
|
1608
|
+
"Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert",
|
|
1609
|
+
"Examples: feat: add login page | fix(auth): resolve token expiry | docs: update README"
|
|
1610
|
+
],
|
|
1611
|
+
"clean-commit": [
|
|
1612
|
+
"Format: <emoji> <type>[!][(<scope>)]: <description>",
|
|
1613
|
+
"Types: \uD83D\uDCE6 new | \uD83D\uDD27 update | \uD83D\uDDD1️ remove | \uD83D\uDD12 security | ⚙️ setup | ☕ chore | \uD83E\uDDEA test | \uD83D\uDCD6 docs | \uD83D\uDE80 release",
|
|
1614
|
+
"Examples: \uD83D\uDCE6 new: user auth | \uD83D\uDD27 update (api): improve errors | ⚙️ setup (ci): add workflow"
|
|
1615
|
+
]
|
|
1616
|
+
};
|
|
1617
|
+
function validateCommitMessage(message, convention) {
|
|
1618
|
+
if (convention === "none")
|
|
1619
|
+
return true;
|
|
1620
|
+
if (convention === "clean-commit")
|
|
1621
|
+
return CLEAN_COMMIT_PATTERN.test(message);
|
|
1622
|
+
if (convention === "conventional")
|
|
1623
|
+
return CONVENTIONAL_COMMIT_PATTERN.test(message);
|
|
1624
|
+
return true;
|
|
1625
|
+
}
|
|
1626
|
+
function getValidationError(convention) {
|
|
1627
|
+
if (convention === "none")
|
|
1628
|
+
return [];
|
|
1629
|
+
return [
|
|
1630
|
+
`Commit message does not follow ${CONVENTION_LABELS[convention]} format.`,
|
|
1631
|
+
...CONVENTION_FORMAT_HINTS[convention]
|
|
1632
|
+
];
|
|
1014
1633
|
}
|
|
1015
1634
|
|
|
1016
1635
|
// src/commands/commit.ts
|
|
1017
|
-
var commit_default =
|
|
1636
|
+
var commit_default = defineCommand3({
|
|
1018
1637
|
meta: {
|
|
1019
1638
|
name: "commit",
|
|
1020
1639
|
description: "Stage changes and create a commit message (AI-powered)"
|
|
@@ -1040,6 +1659,7 @@ var commit_default = defineCommand2({
|
|
|
1040
1659
|
error("Not inside a git repository.");
|
|
1041
1660
|
process.exit(1);
|
|
1042
1661
|
}
|
|
1662
|
+
await assertCleanGitState("committing");
|
|
1043
1663
|
const config = readConfig();
|
|
1044
1664
|
if (!config) {
|
|
1045
1665
|
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
@@ -1058,9 +1678,9 @@ var commit_default = defineCommand2({
|
|
|
1058
1678
|
process.exit(1);
|
|
1059
1679
|
}
|
|
1060
1680
|
console.log(`
|
|
1061
|
-
${
|
|
1681
|
+
${pc6.bold("Changed files:")}`);
|
|
1062
1682
|
for (const f of changedFiles) {
|
|
1063
|
-
console.log(` ${
|
|
1683
|
+
console.log(` ${pc6.dim("•")} ${f}`);
|
|
1064
1684
|
}
|
|
1065
1685
|
const stageAction = await selectPrompt("No staged changes. How would you like to stage?", [
|
|
1066
1686
|
"Stage all changes",
|
|
@@ -1110,7 +1730,7 @@ ${pc5.bold("Changed files:")}`);
|
|
|
1110
1730
|
if (commitMessage) {
|
|
1111
1731
|
spinner.success("AI commit message generated.");
|
|
1112
1732
|
console.log(`
|
|
1113
|
-
${
|
|
1733
|
+
${pc6.dim("AI suggestion:")} ${pc6.bold(pc6.cyan(commitMessage))}`);
|
|
1114
1734
|
} else {
|
|
1115
1735
|
spinner.fail("AI did not return a commit message.");
|
|
1116
1736
|
warn("Falling back to manual entry.");
|
|
@@ -1136,7 +1756,7 @@ ${pc5.bold("Changed files:")}`);
|
|
|
1136
1756
|
if (regen) {
|
|
1137
1757
|
spinner.success("Commit message regenerated.");
|
|
1138
1758
|
console.log(`
|
|
1139
|
-
${
|
|
1759
|
+
${pc6.dim("AI suggestion:")} ${pc6.bold(pc6.cyan(regen))}`);
|
|
1140
1760
|
const ok = await confirmPrompt("Use this message?");
|
|
1141
1761
|
finalMessage = ok ? regen : await inputPrompt("Enter commit message manually");
|
|
1142
1762
|
} else {
|
|
@@ -1151,7 +1771,7 @@ ${pc5.bold("Changed files:")}`);
|
|
|
1151
1771
|
if (convention2 !== "none") {
|
|
1152
1772
|
console.log();
|
|
1153
1773
|
for (const hint of CONVENTION_FORMAT_HINTS[convention2]) {
|
|
1154
|
-
console.log(
|
|
1774
|
+
console.log(pc6.dim(hint));
|
|
1155
1775
|
}
|
|
1156
1776
|
console.log();
|
|
1157
1777
|
}
|
|
@@ -1175,7 +1795,7 @@ ${pc5.bold("Changed files:")}`);
|
|
|
1175
1795
|
error(`Failed to commit: ${result.stderr}`);
|
|
1176
1796
|
process.exit(1);
|
|
1177
1797
|
}
|
|
1178
|
-
success(`✅ Committed: ${
|
|
1798
|
+
success(`✅ Committed: ${pc6.bold(finalMessage)}`);
|
|
1179
1799
|
}
|
|
1180
1800
|
});
|
|
1181
1801
|
async function runGroupCommit(model, config) {
|
|
@@ -1192,9 +1812,9 @@ async function runGroupCommit(model, config) {
|
|
|
1192
1812
|
process.exit(1);
|
|
1193
1813
|
}
|
|
1194
1814
|
console.log(`
|
|
1195
|
-
${
|
|
1815
|
+
${pc6.bold("Changed files:")}`);
|
|
1196
1816
|
for (const f of changedFiles) {
|
|
1197
|
-
console.log(` ${
|
|
1817
|
+
console.log(` ${pc6.dim("•")} ${f}`);
|
|
1198
1818
|
}
|
|
1199
1819
|
const spinner = createSpinner(`Asking AI to group ${changedFiles.length} file(s) into logical commits...`);
|
|
1200
1820
|
const diffs = await getFullDiffForFiles(changedFiles);
|
|
@@ -1232,13 +1852,13 @@ ${pc5.bold("Changed files:")}`);
|
|
|
1232
1852
|
let commitAll = false;
|
|
1233
1853
|
while (!proceedToCommit) {
|
|
1234
1854
|
console.log(`
|
|
1235
|
-
${
|
|
1855
|
+
${pc6.bold(`AI suggested ${validGroups.length} commit group(s):`)}
|
|
1236
1856
|
`);
|
|
1237
1857
|
for (let i = 0;i < validGroups.length; i++) {
|
|
1238
1858
|
const g = validGroups[i];
|
|
1239
|
-
console.log(` ${
|
|
1859
|
+
console.log(` ${pc6.cyan(`Group ${i + 1}:`)} ${pc6.bold(g.message)}`);
|
|
1240
1860
|
for (const f of g.files) {
|
|
1241
|
-
console.log(` ${
|
|
1861
|
+
console.log(` ${pc6.dim("•")} ${f}`);
|
|
1242
1862
|
}
|
|
1243
1863
|
console.log();
|
|
1244
1864
|
}
|
|
@@ -1282,16 +1902,16 @@ ${pc5.bold(`AI suggested ${validGroups.length} commit group(s):`)}
|
|
|
1282
1902
|
continue;
|
|
1283
1903
|
}
|
|
1284
1904
|
committed++;
|
|
1285
|
-
success(`✅ Committed group ${i + 1}: ${
|
|
1905
|
+
success(`✅ Committed group ${i + 1}: ${pc6.bold(group.message)}`);
|
|
1286
1906
|
}
|
|
1287
1907
|
} else {
|
|
1288
1908
|
for (let i = 0;i < validGroups.length; i++) {
|
|
1289
1909
|
const group = validGroups[i];
|
|
1290
|
-
console.log(
|
|
1910
|
+
console.log(pc6.bold(`
|
|
1291
1911
|
── Group ${i + 1}/${validGroups.length} ──`));
|
|
1292
|
-
console.log(` ${
|
|
1912
|
+
console.log(` ${pc6.cyan(group.message)}`);
|
|
1293
1913
|
for (const f of group.files) {
|
|
1294
|
-
console.log(` ${
|
|
1914
|
+
console.log(` ${pc6.dim("•")} ${f}`);
|
|
1295
1915
|
}
|
|
1296
1916
|
let message = group.message;
|
|
1297
1917
|
let actionDone = false;
|
|
@@ -1313,7 +1933,7 @@ ${pc5.bold(`AI suggested ${validGroups.length} commit group(s):`)}
|
|
|
1313
1933
|
if (newMsg) {
|
|
1314
1934
|
message = newMsg;
|
|
1315
1935
|
group.message = newMsg;
|
|
1316
|
-
regenSpinner.success(`New message: ${
|
|
1936
|
+
regenSpinner.success(`New message: ${pc6.bold(message)}`);
|
|
1317
1937
|
} else {
|
|
1318
1938
|
regenSpinner.fail("AI could not generate a new message. Keeping current one.");
|
|
1319
1939
|
}
|
|
@@ -1353,25 +1973,413 @@ ${pc5.bold(`AI suggested ${validGroups.length} commit group(s):`)}
|
|
|
1353
1973
|
continue;
|
|
1354
1974
|
}
|
|
1355
1975
|
committed++;
|
|
1356
|
-
success(`✅ Committed group ${i + 1}: ${
|
|
1976
|
+
success(`✅ Committed group ${i + 1}: ${pc6.bold(message)}`);
|
|
1357
1977
|
actionDone = true;
|
|
1358
1978
|
}
|
|
1359
1979
|
}
|
|
1360
1980
|
}
|
|
1361
|
-
if (committed === 0) {
|
|
1362
|
-
warn("No groups were committed.");
|
|
1363
|
-
} else {
|
|
1364
|
-
success(`
|
|
1365
|
-
\uD83C\uDF89 ${committed} of ${validGroups.length} group(s) committed successfully.`);
|
|
1366
|
-
}
|
|
1367
|
-
process.exit(0);
|
|
1368
|
-
}
|
|
1981
|
+
if (committed === 0) {
|
|
1982
|
+
warn("No groups were committed.");
|
|
1983
|
+
} else {
|
|
1984
|
+
success(`
|
|
1985
|
+
\uD83C\uDF89 ${committed} of ${validGroups.length} group(s) committed successfully.`);
|
|
1986
|
+
}
|
|
1987
|
+
process.exit(0);
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// src/commands/doctor.ts
|
|
1991
|
+
import { execFile as execFileCb3 } from "node:child_process";
|
|
1992
|
+
import { defineCommand as defineCommand4 } from "citty";
|
|
1993
|
+
import pc7 from "picocolors";
|
|
1994
|
+
// package.json
|
|
1995
|
+
var package_default = {
|
|
1996
|
+
name: "contribute-now",
|
|
1997
|
+
version: "0.2.0-dev.88d5119",
|
|
1998
|
+
description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
|
|
1999
|
+
type: "module",
|
|
2000
|
+
bin: {
|
|
2001
|
+
contrib: "dist/index.js",
|
|
2002
|
+
contribute: "dist/index.js"
|
|
2003
|
+
},
|
|
2004
|
+
files: [
|
|
2005
|
+
"dist"
|
|
2006
|
+
],
|
|
2007
|
+
scripts: {
|
|
2008
|
+
build: "bun build src/index.ts --outfile dist/index.js --target node --packages external",
|
|
2009
|
+
cli: "bun run src/index.ts --",
|
|
2010
|
+
dev: "bun src/index.ts",
|
|
2011
|
+
test: "bun test",
|
|
2012
|
+
lint: "biome check .",
|
|
2013
|
+
"lint:fix": "biome check --write .",
|
|
2014
|
+
format: "biome format --write .",
|
|
2015
|
+
"www:dev": "bun run --cwd www dev",
|
|
2016
|
+
"www:build": "bun run --cwd www build",
|
|
2017
|
+
"www:preview": "bun run --cwd www preview"
|
|
2018
|
+
},
|
|
2019
|
+
engines: {
|
|
2020
|
+
node: ">=18",
|
|
2021
|
+
bun: ">=1.0"
|
|
2022
|
+
},
|
|
2023
|
+
keywords: [
|
|
2024
|
+
"git",
|
|
2025
|
+
"workflow",
|
|
2026
|
+
"squash-merge",
|
|
2027
|
+
"sync",
|
|
2028
|
+
"cli",
|
|
2029
|
+
"contribute",
|
|
2030
|
+
"fork",
|
|
2031
|
+
"dev-branch",
|
|
2032
|
+
"clean-commit"
|
|
2033
|
+
],
|
|
2034
|
+
author: "Waren Gonzaga",
|
|
2035
|
+
license: "GPL-3.0",
|
|
2036
|
+
repository: {
|
|
2037
|
+
type: "git",
|
|
2038
|
+
url: "git+https://github.com/warengonzaga/contribute-now.git"
|
|
2039
|
+
},
|
|
2040
|
+
dependencies: {
|
|
2041
|
+
"@clack/prompts": "^1.0.1",
|
|
2042
|
+
"@github/copilot-sdk": "^0.1.25",
|
|
2043
|
+
"@wgtechlabs/log-engine": "^2.3.1",
|
|
2044
|
+
citty: "^0.1.6",
|
|
2045
|
+
figlet: "^1.10.0",
|
|
2046
|
+
picocolors: "^1.1.1"
|
|
2047
|
+
},
|
|
2048
|
+
devDependencies: {
|
|
2049
|
+
"@biomejs/biome": "^2.4.4",
|
|
2050
|
+
"@types/bun": "latest",
|
|
2051
|
+
"@types/figlet": "^1.7.0",
|
|
2052
|
+
typescript: "^5.7.0"
|
|
2053
|
+
}
|
|
2054
|
+
};
|
|
2055
|
+
|
|
2056
|
+
// src/utils/remote.ts
|
|
2057
|
+
function parseRepoFromUrl(url) {
|
|
2058
|
+
const httpsMatch = url.match(/https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
2059
|
+
if (httpsMatch) {
|
|
2060
|
+
return { owner: httpsMatch[1], repo: httpsMatch[2] };
|
|
2061
|
+
}
|
|
2062
|
+
const sshMatch = url.match(/git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
2063
|
+
if (sshMatch) {
|
|
2064
|
+
return { owner: sshMatch[1], repo: sshMatch[2] };
|
|
2065
|
+
}
|
|
2066
|
+
return null;
|
|
2067
|
+
}
|
|
2068
|
+
async function detectForkSetup() {
|
|
2069
|
+
const remotes = await getRemotes();
|
|
2070
|
+
const hasOrigin = remotes.includes("origin");
|
|
2071
|
+
const hasUpstream = remotes.includes("upstream");
|
|
2072
|
+
return {
|
|
2073
|
+
isFork: hasUpstream,
|
|
2074
|
+
originRemote: hasOrigin ? "origin" : null,
|
|
2075
|
+
upstreamRemote: hasUpstream ? "upstream" : null
|
|
2076
|
+
};
|
|
2077
|
+
}
|
|
2078
|
+
async function getRepoInfoFromRemote(remote = "origin") {
|
|
2079
|
+
const url = await getRemoteUrl(remote);
|
|
2080
|
+
if (!url)
|
|
2081
|
+
return null;
|
|
2082
|
+
return parseRepoFromUrl(url);
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
// src/commands/doctor.ts
|
|
2086
|
+
var PASS = ` ${pc7.green("✔")} `;
|
|
2087
|
+
var FAIL = ` ${pc7.red("✗")} `;
|
|
2088
|
+
var WARN = ` ${pc7.yellow("⚠")} `;
|
|
2089
|
+
function printReport(report) {
|
|
2090
|
+
for (const section of report.sections) {
|
|
2091
|
+
console.log(`
|
|
2092
|
+
${pc7.bold(pc7.underline(section.title))}`);
|
|
2093
|
+
for (const check of section.checks) {
|
|
2094
|
+
const prefix = check.ok ? check.warning ? WARN : PASS : FAIL;
|
|
2095
|
+
const text2 = check.detail ? `${check.label} ${pc7.dim(`— ${check.detail}`)}` : check.label;
|
|
2096
|
+
console.log(`${prefix}${text2}`);
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
console.log();
|
|
2100
|
+
}
|
|
2101
|
+
function toJson(report) {
|
|
2102
|
+
return JSON.stringify(report.sections.map((s) => ({
|
|
2103
|
+
section: s.title,
|
|
2104
|
+
checks: s.checks.map((c) => ({
|
|
2105
|
+
label: c.label,
|
|
2106
|
+
ok: c.ok,
|
|
2107
|
+
warning: c.warning ?? false,
|
|
2108
|
+
detail: c.detail ?? null
|
|
2109
|
+
}))
|
|
2110
|
+
})), null, 2);
|
|
2111
|
+
}
|
|
2112
|
+
function runCmd(cmd, args) {
|
|
2113
|
+
return new Promise((resolve) => {
|
|
2114
|
+
execFileCb3(cmd, args, (error2, stdout) => {
|
|
2115
|
+
resolve({
|
|
2116
|
+
ok: !error2,
|
|
2117
|
+
stdout: (stdout ?? "").trim()
|
|
2118
|
+
});
|
|
2119
|
+
});
|
|
2120
|
+
});
|
|
2121
|
+
}
|
|
2122
|
+
async function toolSection() {
|
|
2123
|
+
const checks = [];
|
|
2124
|
+
checks.push({
|
|
2125
|
+
label: `contrib v${package_default.version ?? "unknown"}`,
|
|
2126
|
+
ok: true
|
|
2127
|
+
});
|
|
2128
|
+
const runtime = typeof globalThis.Bun !== "undefined" ? `Bun ${globalThis.Bun.version ?? "?"}` : `Node ${process.version}`;
|
|
2129
|
+
checks.push({ label: runtime, ok: true, detail: `${process.platform}-${process.arch}` });
|
|
2130
|
+
return { title: "Tool", checks };
|
|
2131
|
+
}
|
|
2132
|
+
async function depsSection() {
|
|
2133
|
+
const checks = [];
|
|
2134
|
+
const git = await runCmd("git", ["--version"]);
|
|
2135
|
+
checks.push({
|
|
2136
|
+
label: git.ok ? git.stdout.replace("git version ", "git ") : "git not found",
|
|
2137
|
+
ok: git.ok
|
|
2138
|
+
});
|
|
2139
|
+
const ghInstalled = await checkGhInstalled();
|
|
2140
|
+
if (ghInstalled) {
|
|
2141
|
+
const ghVer = await runCmd("gh", ["--version"]);
|
|
2142
|
+
const ver = ghVer.stdout.split(`
|
|
2143
|
+
`)[0] ?? "gh";
|
|
2144
|
+
checks.push({ label: ver, ok: true });
|
|
2145
|
+
const ghAuth = await checkGhAuth();
|
|
2146
|
+
checks.push({
|
|
2147
|
+
label: ghAuth ? "gh authenticated" : "gh not authenticated",
|
|
2148
|
+
ok: ghAuth,
|
|
2149
|
+
warning: !ghAuth,
|
|
2150
|
+
detail: ghAuth ? undefined : "run `gh auth login`"
|
|
2151
|
+
});
|
|
2152
|
+
} else {
|
|
2153
|
+
checks.push({
|
|
2154
|
+
label: "gh CLI not installed",
|
|
2155
|
+
ok: false,
|
|
2156
|
+
detail: "install from https://cli.github.com"
|
|
2157
|
+
});
|
|
2158
|
+
}
|
|
2159
|
+
try {
|
|
2160
|
+
await import("@github/copilot-sdk");
|
|
2161
|
+
checks.push({ label: "Copilot SDK importable", ok: true });
|
|
2162
|
+
} catch {
|
|
2163
|
+
checks.push({
|
|
2164
|
+
label: "Copilot SDK not loadable",
|
|
2165
|
+
ok: false,
|
|
2166
|
+
warning: true,
|
|
2167
|
+
detail: "AI features will be unavailable"
|
|
2168
|
+
});
|
|
2169
|
+
}
|
|
2170
|
+
return { title: "Dependencies", checks };
|
|
2171
|
+
}
|
|
2172
|
+
async function configSection() {
|
|
2173
|
+
const checks = [];
|
|
2174
|
+
const exists = configExists();
|
|
2175
|
+
if (!exists) {
|
|
2176
|
+
checks.push({
|
|
2177
|
+
label: ".contributerc.json not found",
|
|
2178
|
+
ok: false,
|
|
2179
|
+
detail: "run `contrib setup` to create it"
|
|
2180
|
+
});
|
|
2181
|
+
return { title: "Config", checks };
|
|
2182
|
+
}
|
|
2183
|
+
const config = readConfig();
|
|
2184
|
+
if (!config) {
|
|
2185
|
+
checks.push({ label: ".contributerc.json found but invalid", ok: false });
|
|
2186
|
+
return { title: "Config", checks };
|
|
2187
|
+
}
|
|
2188
|
+
checks.push({ label: ".contributerc.json found and valid", ok: true });
|
|
2189
|
+
const desc = WORKFLOW_DESCRIPTIONS[config.workflow] ?? config.workflow;
|
|
2190
|
+
checks.push({
|
|
2191
|
+
label: `Workflow: ${config.workflow}`,
|
|
2192
|
+
ok: true,
|
|
2193
|
+
detail: desc
|
|
2194
|
+
});
|
|
2195
|
+
checks.push({ label: `Role: ${config.role}`, ok: true });
|
|
2196
|
+
checks.push({ label: `Commit convention: ${config.commitConvention}`, ok: true });
|
|
2197
|
+
if (hasDevBranch(config.workflow)) {
|
|
2198
|
+
checks.push({
|
|
2199
|
+
label: `Dev branch: ${config.devBranch ?? "(not set)"}`,
|
|
2200
|
+
ok: !!config.devBranch
|
|
2201
|
+
});
|
|
2202
|
+
}
|
|
2203
|
+
const ignored = isGitignored();
|
|
2204
|
+
checks.push({
|
|
2205
|
+
label: ignored ? ".contributerc.json in .gitignore" : ".contributerc.json NOT in .gitignore",
|
|
2206
|
+
ok: true,
|
|
2207
|
+
warning: !ignored,
|
|
2208
|
+
detail: ignored ? undefined : "consider adding it to .gitignore"
|
|
2209
|
+
});
|
|
2210
|
+
return { title: "Config", checks };
|
|
2211
|
+
}
|
|
2212
|
+
async function gitSection() {
|
|
2213
|
+
const checks = [];
|
|
2214
|
+
const inRepo = await isGitRepo();
|
|
2215
|
+
checks.push({
|
|
2216
|
+
label: inRepo ? "Inside a git repository" : "Not inside a git repository",
|
|
2217
|
+
ok: inRepo
|
|
2218
|
+
});
|
|
2219
|
+
if (!inRepo)
|
|
2220
|
+
return { title: "Git Environment", checks };
|
|
2221
|
+
const branch = await getCurrentBranch();
|
|
2222
|
+
const head = await runCmd("git", ["rev-parse", "--short", "HEAD"]);
|
|
2223
|
+
checks.push({
|
|
2224
|
+
label: `Branch: ${branch ?? "(detached)"}`,
|
|
2225
|
+
ok: !!branch,
|
|
2226
|
+
detail: head.ok ? `HEAD ${head.stdout}` : undefined
|
|
2227
|
+
});
|
|
2228
|
+
const remotes = await getRemotes();
|
|
2229
|
+
if (remotes.length === 0) {
|
|
2230
|
+
checks.push({ label: "No remotes configured", ok: false, warning: true });
|
|
2231
|
+
} else {
|
|
2232
|
+
for (const remote of remotes) {
|
|
2233
|
+
const url = await getRemoteUrl(remote);
|
|
2234
|
+
const repoInfo = url ? parseRepoFromUrl(url) : null;
|
|
2235
|
+
const detail = repoInfo ? `${repoInfo.owner}/${repoInfo.repo}` : url ?? "unknown URL";
|
|
2236
|
+
checks.push({ label: `Remote: ${remote}`, ok: true, detail });
|
|
2237
|
+
}
|
|
2238
|
+
}
|
|
2239
|
+
const dirty = await hasUncommittedChanges();
|
|
2240
|
+
checks.push({
|
|
2241
|
+
label: dirty ? "Uncommitted changes detected" : "Working tree clean",
|
|
2242
|
+
ok: true,
|
|
2243
|
+
warning: dirty
|
|
2244
|
+
});
|
|
2245
|
+
const shallow = await isShallowRepo();
|
|
2246
|
+
if (shallow) {
|
|
2247
|
+
checks.push({
|
|
2248
|
+
label: "Shallow clone detected",
|
|
2249
|
+
ok: true,
|
|
2250
|
+
warning: true,
|
|
2251
|
+
detail: "run `git fetch --unshallow` for full history"
|
|
2252
|
+
});
|
|
2253
|
+
}
|
|
2254
|
+
const inProgressOp = await isGitOperationInProgress();
|
|
2255
|
+
if (inProgressOp) {
|
|
2256
|
+
checks.push({
|
|
2257
|
+
label: `Git ${inProgressOp} in progress`,
|
|
2258
|
+
ok: false,
|
|
2259
|
+
detail: `complete or abort: git ${inProgressOp} --abort`
|
|
2260
|
+
});
|
|
2261
|
+
}
|
|
2262
|
+
if (await hasGitLockFile()) {
|
|
2263
|
+
checks.push({
|
|
2264
|
+
label: "Git lock file detected (index.lock)",
|
|
2265
|
+
ok: true,
|
|
2266
|
+
warning: true,
|
|
2267
|
+
detail: "another git process may be running, or the lock is stale"
|
|
2268
|
+
});
|
|
2269
|
+
}
|
|
2270
|
+
return { title: "Git Environment", checks };
|
|
2271
|
+
}
|
|
2272
|
+
async function forkSection() {
|
|
2273
|
+
const checks = [];
|
|
2274
|
+
const fork = await detectForkSetup();
|
|
2275
|
+
checks.push({
|
|
2276
|
+
label: fork.isFork ? "Fork detected (upstream remote exists)" : "Not a fork (no upstream remote)",
|
|
2277
|
+
ok: true
|
|
2278
|
+
});
|
|
2279
|
+
if (fork.originRemote) {
|
|
2280
|
+
checks.push({ label: `Origin remote: ${fork.originRemote}`, ok: true });
|
|
2281
|
+
}
|
|
2282
|
+
if (fork.upstreamRemote) {
|
|
2283
|
+
checks.push({ label: `Upstream remote: ${fork.upstreamRemote}`, ok: true });
|
|
2284
|
+
}
|
|
2285
|
+
return { title: "Fork Detection", checks };
|
|
2286
|
+
}
|
|
2287
|
+
async function workflowSection() {
|
|
2288
|
+
const checks = [];
|
|
2289
|
+
const config = readConfig();
|
|
2290
|
+
if (!config) {
|
|
2291
|
+
checks.push({
|
|
2292
|
+
label: "Cannot resolve workflow (no config)",
|
|
2293
|
+
ok: false,
|
|
2294
|
+
detail: "run `contrib setup` first"
|
|
2295
|
+
});
|
|
2296
|
+
return { title: "Workflow Resolution", checks };
|
|
2297
|
+
}
|
|
2298
|
+
const baseBranch = getBaseBranch(config);
|
|
2299
|
+
checks.push({ label: `Base branch: ${baseBranch}`, ok: true });
|
|
2300
|
+
const sync = getSyncSource(config);
|
|
2301
|
+
checks.push({
|
|
2302
|
+
label: `Sync source: ${sync.ref}`,
|
|
2303
|
+
ok: true,
|
|
2304
|
+
detail: `strategy: ${sync.strategy}`
|
|
2305
|
+
});
|
|
2306
|
+
checks.push({
|
|
2307
|
+
label: `Branch prefixes: ${config.branchPrefixes.join(", ")}`,
|
|
2308
|
+
ok: config.branchPrefixes.length > 0
|
|
2309
|
+
});
|
|
2310
|
+
return { title: "Workflow Resolution", checks };
|
|
2311
|
+
}
|
|
2312
|
+
function envSection() {
|
|
2313
|
+
const checks = [];
|
|
2314
|
+
const vars = ["GITHUB_TOKEN", "GH_TOKEN", "COPILOT_AGENT_TOKEN", "NO_COLOR", "FORCE_COLOR", "CI"];
|
|
2315
|
+
for (const name of vars) {
|
|
2316
|
+
const val = process.env[name];
|
|
2317
|
+
if (val !== undefined) {
|
|
2318
|
+
const isSecret = name.toLowerCase().includes("token");
|
|
2319
|
+
const display = isSecret ? `${val.slice(0, 4)}${"*".repeat(Math.min(val.length - 4, 12))}` : val;
|
|
2320
|
+
checks.push({ label: `${name} = ${display}`, ok: true });
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
if (checks.length === 0) {
|
|
2324
|
+
checks.push({ label: "No relevant environment variables set", ok: true });
|
|
2325
|
+
}
|
|
2326
|
+
return { title: "Environment", checks };
|
|
2327
|
+
}
|
|
2328
|
+
var doctor_default = defineCommand4({
|
|
2329
|
+
meta: {
|
|
2330
|
+
name: "doctor",
|
|
2331
|
+
description: "Diagnose the contribute-now CLI environment and configuration"
|
|
2332
|
+
},
|
|
2333
|
+
args: {
|
|
2334
|
+
json: {
|
|
2335
|
+
type: "boolean",
|
|
2336
|
+
description: "Output report as JSON",
|
|
2337
|
+
default: false
|
|
2338
|
+
}
|
|
2339
|
+
},
|
|
2340
|
+
async run({ args }) {
|
|
2341
|
+
const isJson = args.json;
|
|
2342
|
+
const [tool, deps, config, git, fork, workflow] = await Promise.all([
|
|
2343
|
+
toolSection(),
|
|
2344
|
+
depsSection(),
|
|
2345
|
+
configSection(),
|
|
2346
|
+
gitSection(),
|
|
2347
|
+
forkSection(),
|
|
2348
|
+
workflowSection()
|
|
2349
|
+
]);
|
|
2350
|
+
const env = envSection();
|
|
2351
|
+
const report = {
|
|
2352
|
+
sections: [tool, deps, config, git, fork, workflow, env]
|
|
2353
|
+
};
|
|
2354
|
+
if (isJson) {
|
|
2355
|
+
console.log(toJson(report));
|
|
2356
|
+
return;
|
|
2357
|
+
}
|
|
2358
|
+
heading("\uD83E\uDE7A contribute-now doctor");
|
|
2359
|
+
printReport(report);
|
|
2360
|
+
const total = report.sections.flatMap((s) => s.checks);
|
|
2361
|
+
const failures = total.filter((c) => !c.ok);
|
|
2362
|
+
const warnings = total.filter((c) => c.ok && c.warning);
|
|
2363
|
+
if (failures.length === 0 && warnings.length === 0) {
|
|
2364
|
+
console.log(` ${pc7.green("All checks passed!")} No issues detected.
|
|
2365
|
+
`);
|
|
2366
|
+
} else {
|
|
2367
|
+
if (failures.length > 0) {
|
|
2368
|
+
console.log(` ${pc7.red(`${failures.length} issue${failures.length !== 1 ? "s" : ""} found.`)}`);
|
|
2369
|
+
}
|
|
2370
|
+
if (warnings.length > 0) {
|
|
2371
|
+
console.log(` ${pc7.yellow(`${warnings.length} warning${warnings.length !== 1 ? "s" : ""}.`)}`);
|
|
2372
|
+
}
|
|
2373
|
+
console.log();
|
|
2374
|
+
}
|
|
2375
|
+
}
|
|
2376
|
+
});
|
|
1369
2377
|
|
|
1370
2378
|
// src/commands/hook.ts
|
|
1371
|
-
import { existsSync as
|
|
2379
|
+
import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1372
2380
|
import { join as join3 } from "node:path";
|
|
1373
|
-
import { defineCommand as
|
|
1374
|
-
import
|
|
2381
|
+
import { defineCommand as defineCommand5 } from "citty";
|
|
2382
|
+
import pc8 from "picocolors";
|
|
1375
2383
|
var HOOK_MARKER = "# managed by contribute-now";
|
|
1376
2384
|
function getHooksDir(cwd = process.cwd()) {
|
|
1377
2385
|
return join3(cwd, ".git", "hooks");
|
|
@@ -1409,7 +2417,7 @@ else
|
|
|
1409
2417
|
fi
|
|
1410
2418
|
`;
|
|
1411
2419
|
}
|
|
1412
|
-
var hook_default =
|
|
2420
|
+
var hook_default = defineCommand5({
|
|
1413
2421
|
meta: {
|
|
1414
2422
|
name: "hook",
|
|
1415
2423
|
description: "Install or uninstall the commit-msg git hook"
|
|
@@ -1452,7 +2460,7 @@ async function installHook() {
|
|
|
1452
2460
|
}
|
|
1453
2461
|
const hookPath = getHookPath();
|
|
1454
2462
|
const hooksDir = getHooksDir();
|
|
1455
|
-
if (
|
|
2463
|
+
if (existsSync3(hookPath)) {
|
|
1456
2464
|
const existing = readFileSync3(hookPath, "utf-8");
|
|
1457
2465
|
if (!existing.includes(HOOK_MARKER)) {
|
|
1458
2466
|
error("A commit-msg hook already exists and was not installed by contribute-now.");
|
|
@@ -1462,18 +2470,19 @@ async function installHook() {
|
|
|
1462
2470
|
}
|
|
1463
2471
|
info("Updating existing contribute-now hook...");
|
|
1464
2472
|
}
|
|
1465
|
-
if (!
|
|
2473
|
+
if (!existsSync3(hooksDir)) {
|
|
1466
2474
|
mkdirSync(hooksDir, { recursive: true });
|
|
1467
2475
|
}
|
|
1468
2476
|
writeFileSync2(hookPath, generateHookScript(), { mode: 493 });
|
|
1469
2477
|
success(`commit-msg hook installed.`);
|
|
1470
|
-
info(`Convention: ${
|
|
1471
|
-
info(`Path: ${
|
|
2478
|
+
info(`Convention: ${pc8.bold(CONVENTION_LABELS[config.commitConvention])}`);
|
|
2479
|
+
info(`Path: ${pc8.dim(hookPath)}`);
|
|
2480
|
+
warn("Note: hooks can be bypassed with `git commit --no-verify`.");
|
|
1472
2481
|
}
|
|
1473
2482
|
async function uninstallHook() {
|
|
1474
2483
|
heading("\uD83E\uDE9D hook uninstall");
|
|
1475
2484
|
const hookPath = getHookPath();
|
|
1476
|
-
if (!
|
|
2485
|
+
if (!existsSync3(hookPath)) {
|
|
1477
2486
|
info("No commit-msg hook found. Nothing to uninstall.");
|
|
1478
2487
|
return;
|
|
1479
2488
|
}
|
|
@@ -1486,169 +2495,165 @@ async function uninstallHook() {
|
|
|
1486
2495
|
success("commit-msg hook removed.");
|
|
1487
2496
|
}
|
|
1488
2497
|
|
|
1489
|
-
// src/commands/
|
|
1490
|
-
import { defineCommand as
|
|
1491
|
-
import
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
2498
|
+
// src/commands/log.ts
|
|
2499
|
+
import { defineCommand as defineCommand6 } from "citty";
|
|
2500
|
+
import pc9 from "picocolors";
|
|
2501
|
+
var log_default = defineCommand6({
|
|
2502
|
+
meta: {
|
|
2503
|
+
name: "log",
|
|
2504
|
+
description: "Show a colorized, workflow-aware commit log with graph"
|
|
2505
|
+
},
|
|
2506
|
+
args: {
|
|
2507
|
+
count: {
|
|
2508
|
+
type: "string",
|
|
2509
|
+
alias: "n",
|
|
2510
|
+
description: "Number of commits to show (default: 20)"
|
|
2511
|
+
},
|
|
2512
|
+
all: {
|
|
2513
|
+
type: "boolean",
|
|
2514
|
+
alias: "a",
|
|
2515
|
+
description: "Show all branches, not just current",
|
|
2516
|
+
default: false
|
|
2517
|
+
},
|
|
2518
|
+
graph: {
|
|
2519
|
+
type: "boolean",
|
|
2520
|
+
alias: "g",
|
|
2521
|
+
description: "Show graph view with branch lines",
|
|
2522
|
+
default: true
|
|
2523
|
+
},
|
|
2524
|
+
branch: {
|
|
2525
|
+
type: "string",
|
|
2526
|
+
alias: "b",
|
|
2527
|
+
description: "Show log for a specific branch"
|
|
2528
|
+
}
|
|
2529
|
+
},
|
|
2530
|
+
async run({ args }) {
|
|
2531
|
+
if (!await isGitRepo()) {
|
|
2532
|
+
error("Not inside a git repository.");
|
|
2533
|
+
process.exit(1);
|
|
2534
|
+
}
|
|
2535
|
+
const config = readConfig();
|
|
2536
|
+
const count = args.count ? Number.parseInt(args.count, 10) : 20;
|
|
2537
|
+
const showAll = args.all;
|
|
2538
|
+
const showGraph = args.graph;
|
|
2539
|
+
const targetBranch = args.branch;
|
|
2540
|
+
const protectedBranches = config ? getProtectedBranches(config) : ["main", "master"];
|
|
2541
|
+
const currentBranch = await getCurrentBranch();
|
|
2542
|
+
heading("\uD83D\uDCDC commit log");
|
|
2543
|
+
if (showGraph) {
|
|
2544
|
+
const lines = await getLogGraph({ count, all: showAll, branch: targetBranch });
|
|
2545
|
+
if (lines.length === 0) {
|
|
2546
|
+
console.log(pc9.dim(" No commits found."));
|
|
2547
|
+
console.log();
|
|
2548
|
+
return;
|
|
2549
|
+
}
|
|
2550
|
+
console.log();
|
|
2551
|
+
for (const line of lines) {
|
|
2552
|
+
console.log(` ${colorizeGraphLine(line, protectedBranches, currentBranch)}`);
|
|
2553
|
+
}
|
|
2554
|
+
} else {
|
|
2555
|
+
const entries = await getLogEntries({ count, all: showAll, branch: targetBranch });
|
|
2556
|
+
if (entries.length === 0) {
|
|
2557
|
+
console.log(pc9.dim(" No commits found."));
|
|
2558
|
+
console.log();
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2561
|
+
console.log();
|
|
2562
|
+
for (const entry of entries) {
|
|
2563
|
+
const hashStr = pc9.yellow(entry.hash);
|
|
2564
|
+
const refsStr = entry.refs ? ` ${colorizeRefs(entry.refs, protectedBranches, currentBranch)}` : "";
|
|
2565
|
+
const subjectStr = colorizeSubject(entry.subject);
|
|
2566
|
+
console.log(` ${hashStr}${refsStr} ${subjectStr}`);
|
|
2567
|
+
}
|
|
2568
|
+
}
|
|
2569
|
+
console.log();
|
|
2570
|
+
console.log(pc9.dim(` Showing ${count} most recent commits${showAll ? " (all branches)" : targetBranch ? ` (${targetBranch})` : ""}`));
|
|
2571
|
+
console.log(pc9.dim(` Use ${pc9.bold("contrib log -n 50")} for more, or ${pc9.bold("contrib log --all")} for all branches`));
|
|
2572
|
+
console.log();
|
|
1520
2573
|
}
|
|
2574
|
+
});
|
|
2575
|
+
function colorizeGraphLine(line, protectedBranches, currentBranch) {
|
|
2576
|
+
const match = line.match(/^([|/\\*\s_.-]*)([a-f0-9]{7,12})(\s+\(([^)]+)\))?\s*(.*)/);
|
|
2577
|
+
if (!match) {
|
|
2578
|
+
return pc9.cyan(line);
|
|
2579
|
+
}
|
|
2580
|
+
const [, graphPart = "", hash, , refs, subject = ""] = match;
|
|
2581
|
+
const parts = [];
|
|
2582
|
+
if (graphPart) {
|
|
2583
|
+
parts.push(colorizeGraphChars(graphPart));
|
|
2584
|
+
}
|
|
2585
|
+
parts.push(pc9.yellow(hash));
|
|
2586
|
+
if (refs) {
|
|
2587
|
+
parts.push(` (${colorizeRefs(refs, protectedBranches, currentBranch)})`);
|
|
2588
|
+
}
|
|
2589
|
+
parts.push(` ${colorizeSubject(subject)}`);
|
|
2590
|
+
return parts.join("");
|
|
2591
|
+
}
|
|
2592
|
+
function colorizeGraphChars(graphPart) {
|
|
2593
|
+
return graphPart.split("").map((ch) => {
|
|
2594
|
+
switch (ch) {
|
|
2595
|
+
case "*":
|
|
2596
|
+
return pc9.green(ch);
|
|
2597
|
+
case "|":
|
|
2598
|
+
return pc9.cyan(ch);
|
|
2599
|
+
case "/":
|
|
2600
|
+
case "\\":
|
|
2601
|
+
return pc9.cyan(ch);
|
|
2602
|
+
case "-":
|
|
2603
|
+
case "_":
|
|
2604
|
+
return pc9.cyan(ch);
|
|
2605
|
+
default:
|
|
2606
|
+
return ch;
|
|
2607
|
+
}
|
|
2608
|
+
}).join("");
|
|
2609
|
+
}
|
|
2610
|
+
function colorizeRefs(refs, protectedBranches, currentBranch) {
|
|
2611
|
+
return refs.split(",").map((ref) => {
|
|
2612
|
+
const trimmed = ref.trim();
|
|
2613
|
+
if (trimmed.startsWith("HEAD ->") || trimmed === "HEAD") {
|
|
2614
|
+
const branchName = trimmed.replace("HEAD -> ", "");
|
|
2615
|
+
if (trimmed === "HEAD") {
|
|
2616
|
+
return pc9.bold(pc9.cyan("HEAD"));
|
|
2617
|
+
}
|
|
2618
|
+
return `${pc9.bold(pc9.cyan("HEAD"))} ${pc9.dim("->")} ${colorizeRefName(branchName, protectedBranches, currentBranch)}`;
|
|
2619
|
+
}
|
|
2620
|
+
if (trimmed.startsWith("tag:")) {
|
|
2621
|
+
return pc9.bold(pc9.magenta(trimmed));
|
|
2622
|
+
}
|
|
2623
|
+
return colorizeRefName(trimmed, protectedBranches, currentBranch);
|
|
2624
|
+
}).join(pc9.dim(", "));
|
|
1521
2625
|
}
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
if (exitCode !== 0)
|
|
1528
|
-
return null;
|
|
1529
|
-
try {
|
|
1530
|
-
return JSON.parse(stdout.trim());
|
|
1531
|
-
} catch {
|
|
1532
|
-
return null;
|
|
2626
|
+
function colorizeRefName(name, protectedBranches, currentBranch) {
|
|
2627
|
+
const isRemote = name.includes("/");
|
|
2628
|
+
const localName = isRemote ? name.split("/").slice(1).join("/") : name;
|
|
2629
|
+
if (protectedBranches.includes(localName)) {
|
|
2630
|
+
return isRemote ? pc9.bold(pc9.red(name)) : pc9.bold(pc9.red(name));
|
|
1533
2631
|
}
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
const { exitCode, stdout } = await run2(["repo", "view", "--json", "isFork", "-q", ".isFork"]);
|
|
1537
|
-
if (exitCode !== 0)
|
|
1538
|
-
return null;
|
|
1539
|
-
const val = stdout.trim();
|
|
1540
|
-
if (val === "true")
|
|
1541
|
-
return true;
|
|
1542
|
-
if (val === "false")
|
|
1543
|
-
return false;
|
|
1544
|
-
return null;
|
|
1545
|
-
}
|
|
1546
|
-
async function getCurrentRepoInfo() {
|
|
1547
|
-
const { exitCode, stdout } = await run2([
|
|
1548
|
-
"repo",
|
|
1549
|
-
"view",
|
|
1550
|
-
"--json",
|
|
1551
|
-
"nameWithOwner",
|
|
1552
|
-
"-q",
|
|
1553
|
-
".nameWithOwner"
|
|
1554
|
-
]);
|
|
1555
|
-
if (exitCode !== 0)
|
|
1556
|
-
return null;
|
|
1557
|
-
const nameWithOwner = stdout.trim();
|
|
1558
|
-
if (!nameWithOwner)
|
|
1559
|
-
return null;
|
|
1560
|
-
const [owner, repo] = nameWithOwner.split("/");
|
|
1561
|
-
if (!owner || !repo)
|
|
1562
|
-
return null;
|
|
1563
|
-
return { owner, repo };
|
|
1564
|
-
}
|
|
1565
|
-
async function createPR(options) {
|
|
1566
|
-
const args = [
|
|
1567
|
-
"pr",
|
|
1568
|
-
"create",
|
|
1569
|
-
"--base",
|
|
1570
|
-
options.base,
|
|
1571
|
-
"--title",
|
|
1572
|
-
options.title,
|
|
1573
|
-
"--body",
|
|
1574
|
-
options.body
|
|
1575
|
-
];
|
|
1576
|
-
if (options.draft)
|
|
1577
|
-
args.push("--draft");
|
|
1578
|
-
return run2(args);
|
|
1579
|
-
}
|
|
1580
|
-
async function createPRFill(base, draft) {
|
|
1581
|
-
const args = ["pr", "create", "--base", base, "--fill"];
|
|
1582
|
-
if (draft)
|
|
1583
|
-
args.push("--draft");
|
|
1584
|
-
return run2(args);
|
|
1585
|
-
}
|
|
1586
|
-
async function getPRForBranch(headBranch) {
|
|
1587
|
-
const { exitCode, stdout } = await run2([
|
|
1588
|
-
"pr",
|
|
1589
|
-
"list",
|
|
1590
|
-
"--head",
|
|
1591
|
-
headBranch,
|
|
1592
|
-
"--state",
|
|
1593
|
-
"open",
|
|
1594
|
-
"--json",
|
|
1595
|
-
"number,url,title,state",
|
|
1596
|
-
"--limit",
|
|
1597
|
-
"1"
|
|
1598
|
-
]);
|
|
1599
|
-
if (exitCode !== 0)
|
|
1600
|
-
return null;
|
|
1601
|
-
try {
|
|
1602
|
-
const prs = JSON.parse(stdout.trim());
|
|
1603
|
-
return prs.length > 0 ? prs[0] : null;
|
|
1604
|
-
} catch {
|
|
1605
|
-
return null;
|
|
2632
|
+
if (localName === currentBranch) {
|
|
2633
|
+
return pc9.bold(pc9.green(name));
|
|
1606
2634
|
}
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
const { exitCode, stdout } = await run2([
|
|
1610
|
-
"pr",
|
|
1611
|
-
"list",
|
|
1612
|
-
"--head",
|
|
1613
|
-
headBranch,
|
|
1614
|
-
"--state",
|
|
1615
|
-
"merged",
|
|
1616
|
-
"--json",
|
|
1617
|
-
"number,url,title,state",
|
|
1618
|
-
"--limit",
|
|
1619
|
-
"1"
|
|
1620
|
-
]);
|
|
1621
|
-
if (exitCode !== 0)
|
|
1622
|
-
return null;
|
|
1623
|
-
try {
|
|
1624
|
-
const prs = JSON.parse(stdout.trim());
|
|
1625
|
-
return prs.length > 0 ? prs[0] : null;
|
|
1626
|
-
} catch {
|
|
1627
|
-
return null;
|
|
2635
|
+
if (isRemote) {
|
|
2636
|
+
return pc9.blue(name);
|
|
1628
2637
|
}
|
|
2638
|
+
return pc9.green(name);
|
|
1629
2639
|
}
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
return {
|
|
2640
|
+
function colorizeSubject(subject) {
|
|
2641
|
+
const emojiMatch = subject.match(/^((?:\p{Emoji_Presentation}|\p{Emoji}\uFE0F)+\s*)/u);
|
|
2642
|
+
if (emojiMatch) {
|
|
2643
|
+
const emoji = emojiMatch[1];
|
|
2644
|
+
const rest = subject.slice(emoji.length);
|
|
2645
|
+
return `${emoji}${pc9.white(rest)}`;
|
|
1636
2646
|
}
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
return { owner: sshMatch[1], repo: sshMatch[2] };
|
|
2647
|
+
if (subject.startsWith("Merge ")) {
|
|
2648
|
+
return pc9.dim(subject);
|
|
1640
2649
|
}
|
|
1641
|
-
return
|
|
1642
|
-
}
|
|
1643
|
-
async function getRepoInfoFromRemote(remote = "origin") {
|
|
1644
|
-
const url = await getRemoteUrl(remote);
|
|
1645
|
-
if (!url)
|
|
1646
|
-
return null;
|
|
1647
|
-
return parseRepoFromUrl(url);
|
|
2650
|
+
return pc9.white(subject);
|
|
1648
2651
|
}
|
|
1649
2652
|
|
|
1650
2653
|
// src/commands/setup.ts
|
|
1651
|
-
|
|
2654
|
+
import { defineCommand as defineCommand7 } from "citty";
|
|
2655
|
+
import pc10 from "picocolors";
|
|
2656
|
+
var setup_default = defineCommand7({
|
|
1652
2657
|
meta: {
|
|
1653
2658
|
name: "setup",
|
|
1654
2659
|
description: "Initialize contribute-now config for this repo (.contributerc.json)"
|
|
@@ -1669,7 +2674,7 @@ var setup_default = defineCommand4({
|
|
|
1669
2674
|
workflow = "github-flow";
|
|
1670
2675
|
else if (workflowChoice.startsWith("Git Flow"))
|
|
1671
2676
|
workflow = "git-flow";
|
|
1672
|
-
info(`Workflow: ${
|
|
2677
|
+
info(`Workflow: ${pc10.bold(WORKFLOW_DESCRIPTIONS[workflow])}`);
|
|
1673
2678
|
const conventionChoice = await selectPrompt("Which commit convention should this project use?", [
|
|
1674
2679
|
`${CONVENTION_DESCRIPTIONS["clean-commit"]} (recommended)`,
|
|
1675
2680
|
CONVENTION_DESCRIPTIONS.conventional,
|
|
@@ -1722,8 +2727,8 @@ var setup_default = defineCommand4({
|
|
|
1722
2727
|
detectedRole = roleChoice;
|
|
1723
2728
|
detectionSource = "user selection";
|
|
1724
2729
|
} else {
|
|
1725
|
-
info(`Detected role: ${
|
|
1726
|
-
const confirmed = await confirmPrompt(`Role detected as ${
|
|
2730
|
+
info(`Detected role: ${pc10.bold(detectedRole)} (via ${detectionSource})`);
|
|
2731
|
+
const confirmed = await confirmPrompt(`Role detected as ${pc10.bold(detectedRole)}. Is this correct?`);
|
|
1727
2732
|
if (!confirmed) {
|
|
1728
2733
|
const roleChoice = await selectPrompt("Select your role:", ["maintainer", "contributor"]);
|
|
1729
2734
|
detectedRole = roleChoice;
|
|
@@ -1746,8 +2751,17 @@ var setup_default = defineCommand4({
|
|
|
1746
2751
|
const repoInfo = originUrl ? parseRepoFromUrl(originUrl) : null;
|
|
1747
2752
|
const upstreamUrl = await inputPrompt("Enter upstream repository URL to add", repoInfo ? `https://github.com/${repoInfo.owner}/${repoInfo.repo}` : undefined);
|
|
1748
2753
|
if (upstreamUrl) {
|
|
1749
|
-
|
|
1750
|
-
|
|
2754
|
+
const addResult = await addRemote(upstreamRemote, upstreamUrl);
|
|
2755
|
+
if (addResult.exitCode !== 0) {
|
|
2756
|
+
error(`Failed to add remote "${upstreamRemote}": ${addResult.stderr.trim()}`);
|
|
2757
|
+
error("Setup cannot continue without the upstream remote for contributors.");
|
|
2758
|
+
process.exit(1);
|
|
2759
|
+
}
|
|
2760
|
+
success(`Added remote ${pc10.bold(upstreamRemote)} → ${upstreamUrl}`);
|
|
2761
|
+
} else {
|
|
2762
|
+
error("An upstream remote URL is required for contributors.");
|
|
2763
|
+
info("Add it manually: git remote add upstream <url>");
|
|
2764
|
+
process.exit(1);
|
|
1751
2765
|
}
|
|
1752
2766
|
}
|
|
1753
2767
|
}
|
|
@@ -1763,45 +2777,42 @@ var setup_default = defineCommand4({
|
|
|
1763
2777
|
};
|
|
1764
2778
|
writeConfig(config);
|
|
1765
2779
|
success(`✅ Config written to .contributerc.json`);
|
|
2780
|
+
const syncRemote = config.role === "contributor" ? config.upstream : config.origin;
|
|
2781
|
+
info(`Fetching ${pc10.bold(syncRemote)} to verify branch configuration...`);
|
|
2782
|
+
await fetchRemote(syncRemote);
|
|
2783
|
+
const mainRef = `${syncRemote}/${config.mainBranch}`;
|
|
2784
|
+
if (!await refExists(mainRef)) {
|
|
2785
|
+
warn(`Main branch ref ${pc10.bold(mainRef)} not found on remote.`);
|
|
2786
|
+
warn("Config was saved — verify the branch name and re-run setup if needed.");
|
|
2787
|
+
}
|
|
2788
|
+
if (config.devBranch) {
|
|
2789
|
+
const devRef = `${syncRemote}/${config.devBranch}`;
|
|
2790
|
+
if (!await refExists(devRef)) {
|
|
2791
|
+
warn(`Dev branch ref ${pc10.bold(devRef)} not found on remote.`);
|
|
2792
|
+
warn("Config was saved — verify the branch name and re-run setup if needed.");
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
1766
2795
|
if (!isGitignored()) {
|
|
1767
2796
|
warn(".contributerc.json is not in .gitignore. Add it to avoid committing personal config.");
|
|
1768
2797
|
warn(' echo ".contributerc.json" >> .gitignore');
|
|
1769
2798
|
}
|
|
1770
2799
|
console.log();
|
|
1771
|
-
info(`Workflow: ${
|
|
1772
|
-
info(`Convention: ${
|
|
1773
|
-
info(`Role: ${
|
|
2800
|
+
info(`Workflow: ${pc10.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
|
|
2801
|
+
info(`Convention: ${pc10.bold(CONVENTION_DESCRIPTIONS[config.commitConvention])}`);
|
|
2802
|
+
info(`Role: ${pc10.bold(config.role)}`);
|
|
1774
2803
|
if (config.devBranch) {
|
|
1775
|
-
info(`Main: ${
|
|
2804
|
+
info(`Main: ${pc10.bold(config.mainBranch)} | Dev: ${pc10.bold(config.devBranch)}`);
|
|
1776
2805
|
} else {
|
|
1777
|
-
info(`Main: ${
|
|
2806
|
+
info(`Main: ${pc10.bold(config.mainBranch)}`);
|
|
1778
2807
|
}
|
|
1779
|
-
info(`Origin: ${
|
|
2808
|
+
info(`Origin: ${pc10.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc10.bold(config.upstream)}` : ""}`);
|
|
1780
2809
|
}
|
|
1781
2810
|
});
|
|
1782
2811
|
|
|
1783
2812
|
// src/commands/start.ts
|
|
1784
|
-
import { defineCommand as
|
|
1785
|
-
import
|
|
1786
|
-
|
|
1787
|
-
// src/utils/branch.ts
|
|
1788
|
-
var DEFAULT_PREFIXES = ["feature", "fix", "docs", "chore", "test", "refactor"];
|
|
1789
|
-
function hasPrefix(branchName, prefixes = DEFAULT_PREFIXES) {
|
|
1790
|
-
return prefixes.some((p) => branchName.startsWith(`${p}/`));
|
|
1791
|
-
}
|
|
1792
|
-
function formatBranchName(prefix, name) {
|
|
1793
|
-
const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1794
|
-
return `${prefix}/${sanitized}`;
|
|
1795
|
-
}
|
|
1796
|
-
function isValidBranchName(name) {
|
|
1797
|
-
return /^[a-zA-Z0-9._/-]+$/.test(name) && !name.startsWith("/") && !name.endsWith("/");
|
|
1798
|
-
}
|
|
1799
|
-
function looksLikeNaturalLanguage(input) {
|
|
1800
|
-
return input.includes(" ") && !input.includes("/");
|
|
1801
|
-
}
|
|
1802
|
-
|
|
1803
|
-
// src/commands/start.ts
|
|
1804
|
-
var start_default = defineCommand5({
|
|
2813
|
+
import { defineCommand as defineCommand8 } from "citty";
|
|
2814
|
+
import pc11 from "picocolors";
|
|
2815
|
+
var start_default = defineCommand8({
|
|
1805
2816
|
meta: {
|
|
1806
2817
|
name: "start",
|
|
1807
2818
|
description: "Create a new feature branch from the latest base branch"
|
|
@@ -1809,8 +2820,8 @@ var start_default = defineCommand5({
|
|
|
1809
2820
|
args: {
|
|
1810
2821
|
name: {
|
|
1811
2822
|
type: "positional",
|
|
1812
|
-
description: "Branch name or description",
|
|
1813
|
-
required:
|
|
2823
|
+
description: "Branch name or description (prompted if omitted)",
|
|
2824
|
+
required: false
|
|
1814
2825
|
},
|
|
1815
2826
|
model: {
|
|
1816
2827
|
type: "string",
|
|
@@ -1827,6 +2838,7 @@ var start_default = defineCommand5({
|
|
|
1827
2838
|
error("Not inside a git repository.");
|
|
1828
2839
|
process.exit(1);
|
|
1829
2840
|
}
|
|
2841
|
+
await assertCleanGitState("starting a new branch");
|
|
1830
2842
|
const config = readConfig();
|
|
1831
2843
|
if (!config) {
|
|
1832
2844
|
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
@@ -1841,6 +2853,14 @@ var start_default = defineCommand5({
|
|
|
1841
2853
|
const syncSource = getSyncSource(config);
|
|
1842
2854
|
let branchName = args.name;
|
|
1843
2855
|
heading("\uD83C\uDF3F contrib start");
|
|
2856
|
+
if (!branchName) {
|
|
2857
|
+
branchName = await inputPrompt("What are you going to work on?");
|
|
2858
|
+
if (!branchName || branchName.trim().length === 0) {
|
|
2859
|
+
error("A branch name or description is required.");
|
|
2860
|
+
process.exit(1);
|
|
2861
|
+
}
|
|
2862
|
+
branchName = branchName.trim();
|
|
2863
|
+
}
|
|
1844
2864
|
const useAI = !args["no-ai"] && looksLikeNaturalLanguage(branchName);
|
|
1845
2865
|
if (useAI) {
|
|
1846
2866
|
const spinner = createSpinner("Generating branch name suggestion...");
|
|
@@ -1848,8 +2868,8 @@ var start_default = defineCommand5({
|
|
|
1848
2868
|
if (suggested) {
|
|
1849
2869
|
spinner.success("Branch name suggestion ready.");
|
|
1850
2870
|
console.log(`
|
|
1851
|
-
${
|
|
1852
|
-
const accepted = await confirmPrompt(`Use ${
|
|
2871
|
+
${pc11.dim("AI suggestion:")} ${pc11.bold(pc11.cyan(suggested))}`);
|
|
2872
|
+
const accepted = await confirmPrompt(`Use ${pc11.bold(suggested)} as your branch name?`);
|
|
1853
2873
|
if (accepted) {
|
|
1854
2874
|
branchName = suggested;
|
|
1855
2875
|
} else {
|
|
@@ -1860,30 +2880,51 @@ var start_default = defineCommand5({
|
|
|
1860
2880
|
}
|
|
1861
2881
|
}
|
|
1862
2882
|
if (!hasPrefix(branchName, branchPrefixes)) {
|
|
1863
|
-
const prefix = await selectPrompt(`Choose a branch type for ${
|
|
2883
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc11.bold(branchName)}:`, branchPrefixes);
|
|
1864
2884
|
branchName = formatBranchName(prefix, branchName);
|
|
1865
2885
|
}
|
|
1866
2886
|
if (!isValidBranchName(branchName)) {
|
|
1867
2887
|
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
1868
2888
|
process.exit(1);
|
|
1869
2889
|
}
|
|
1870
|
-
info(`Creating branch: ${
|
|
2890
|
+
info(`Creating branch: ${pc11.bold(branchName)}`);
|
|
2891
|
+
if (await branchExists(branchName)) {
|
|
2892
|
+
error(`Branch ${pc11.bold(branchName)} already exists.`);
|
|
2893
|
+
info(` Use ${pc11.bold(`git checkout ${branchName}`)} to switch to it, or choose a different name.`);
|
|
2894
|
+
process.exit(1);
|
|
2895
|
+
}
|
|
1871
2896
|
await fetchRemote(syncSource.remote);
|
|
2897
|
+
if (!await refExists(syncSource.ref)) {
|
|
2898
|
+
warn(`Remote ref ${pc11.bold(syncSource.ref)} not found. Creating branch from local ${pc11.bold(baseBranch)}.`);
|
|
2899
|
+
}
|
|
1872
2900
|
const updateResult = await updateLocalBranch(baseBranch, syncSource.ref);
|
|
1873
|
-
if (updateResult.exitCode !== 0) {
|
|
2901
|
+
if (updateResult.exitCode !== 0) {
|
|
2902
|
+
if (await refExists(syncSource.ref)) {
|
|
2903
|
+
const result2 = await createBranch(branchName, syncSource.ref);
|
|
2904
|
+
if (result2.exitCode !== 0) {
|
|
2905
|
+
error(`Failed to create branch: ${result2.stderr}`);
|
|
2906
|
+
process.exit(1);
|
|
2907
|
+
}
|
|
2908
|
+
success(`✅ Created ${pc11.bold(branchName)} from ${pc11.bold(syncSource.ref)}`);
|
|
2909
|
+
return;
|
|
2910
|
+
}
|
|
2911
|
+
error(`Failed to update ${pc11.bold(baseBranch)}: ${updateResult.stderr}`);
|
|
2912
|
+
info("Make sure your base branch exists locally or the remote ref is available.");
|
|
2913
|
+
process.exit(1);
|
|
2914
|
+
}
|
|
1874
2915
|
const result = await createBranch(branchName, baseBranch);
|
|
1875
2916
|
if (result.exitCode !== 0) {
|
|
1876
2917
|
error(`Failed to create branch: ${result.stderr}`);
|
|
1877
2918
|
process.exit(1);
|
|
1878
2919
|
}
|
|
1879
|
-
success(`✅ Created ${
|
|
2920
|
+
success(`✅ Created ${pc11.bold(branchName)} from latest ${pc11.bold(baseBranch)}`);
|
|
1880
2921
|
}
|
|
1881
2922
|
});
|
|
1882
2923
|
|
|
1883
2924
|
// src/commands/status.ts
|
|
1884
|
-
import { defineCommand as
|
|
1885
|
-
import
|
|
1886
|
-
var status_default =
|
|
2925
|
+
import { defineCommand as defineCommand9 } from "citty";
|
|
2926
|
+
import pc12 from "picocolors";
|
|
2927
|
+
var status_default = defineCommand9({
|
|
1887
2928
|
meta: {
|
|
1888
2929
|
name: "status",
|
|
1889
2930
|
description: "Show sync status of branches"
|
|
@@ -1899,20 +2940,17 @@ var status_default = defineCommand6({
|
|
|
1899
2940
|
process.exit(1);
|
|
1900
2941
|
}
|
|
1901
2942
|
heading("\uD83D\uDCCA contribute-now status");
|
|
1902
|
-
console.log(` ${
|
|
1903
|
-
console.log(` ${
|
|
2943
|
+
console.log(` ${pc12.dim("Workflow:")} ${pc12.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
|
|
2944
|
+
console.log(` ${pc12.dim("Role:")} ${pc12.bold(config.role)}`);
|
|
1904
2945
|
console.log();
|
|
1905
2946
|
await fetchAll();
|
|
1906
2947
|
const currentBranch = await getCurrentBranch();
|
|
1907
2948
|
const { mainBranch, origin, upstream, workflow } = config;
|
|
1908
2949
|
const baseBranch = getBaseBranch(config);
|
|
1909
2950
|
const isContributor = config.role === "contributor";
|
|
1910
|
-
const [dirty, fileStatus] = await Promise.all([
|
|
1911
|
-
hasUncommittedChanges(),
|
|
1912
|
-
getFileStatus()
|
|
1913
|
-
]);
|
|
2951
|
+
const [dirty, fileStatus] = await Promise.all([hasUncommittedChanges(), getFileStatus()]);
|
|
1914
2952
|
if (dirty) {
|
|
1915
|
-
console.log(` ${
|
|
2953
|
+
console.log(` ${pc12.yellow("⚠")} ${pc12.yellow("Uncommitted changes in working tree")}`);
|
|
1916
2954
|
console.log();
|
|
1917
2955
|
}
|
|
1918
2956
|
const mainRemote = `${origin}/${mainBranch}`;
|
|
@@ -1928,82 +2966,82 @@ var status_default = defineCommand6({
|
|
|
1928
2966
|
if (currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
|
|
1929
2967
|
const branchDiv = await getDivergence(currentBranch, baseBranch);
|
|
1930
2968
|
const branchLine = formatStatus(currentBranch, baseBranch, branchDiv.ahead, branchDiv.behind);
|
|
1931
|
-
console.log(branchLine +
|
|
2969
|
+
console.log(branchLine + pc12.dim(` (current ${pc12.green("*")})`));
|
|
1932
2970
|
} else if (currentBranch) {
|
|
1933
|
-
console.log(
|
|
2971
|
+
console.log(pc12.dim(` (on ${pc12.bold(currentBranch)} branch)`));
|
|
1934
2972
|
}
|
|
1935
2973
|
const hasFiles = fileStatus.staged.length > 0 || fileStatus.modified.length > 0 || fileStatus.untracked.length > 0;
|
|
1936
2974
|
if (hasFiles) {
|
|
1937
2975
|
console.log();
|
|
1938
2976
|
if (fileStatus.staged.length > 0) {
|
|
1939
|
-
console.log(` ${
|
|
2977
|
+
console.log(` ${pc12.green("Staged for commit:")}`);
|
|
1940
2978
|
for (const { file, status } of fileStatus.staged) {
|
|
1941
|
-
console.log(` ${
|
|
2979
|
+
console.log(` ${pc12.green("+")} ${pc12.dim(`${status}:`)} ${file}`);
|
|
1942
2980
|
}
|
|
1943
2981
|
}
|
|
1944
2982
|
if (fileStatus.modified.length > 0) {
|
|
1945
|
-
console.log(` ${
|
|
2983
|
+
console.log(` ${pc12.yellow("Unstaged changes:")}`);
|
|
1946
2984
|
for (const { file, status } of fileStatus.modified) {
|
|
1947
|
-
console.log(` ${
|
|
2985
|
+
console.log(` ${pc12.yellow("~")} ${pc12.dim(`${status}:`)} ${file}`);
|
|
1948
2986
|
}
|
|
1949
2987
|
}
|
|
1950
2988
|
if (fileStatus.untracked.length > 0) {
|
|
1951
|
-
console.log(` ${
|
|
2989
|
+
console.log(` ${pc12.red("Untracked files:")}`);
|
|
1952
2990
|
for (const file of fileStatus.untracked) {
|
|
1953
|
-
console.log(` ${
|
|
2991
|
+
console.log(` ${pc12.red("?")} ${file}`);
|
|
1954
2992
|
}
|
|
1955
2993
|
}
|
|
1956
2994
|
} else if (!dirty) {
|
|
1957
|
-
console.log(` ${
|
|
2995
|
+
console.log(` ${pc12.green("✓")} ${pc12.dim("Working tree clean")}`);
|
|
1958
2996
|
}
|
|
1959
2997
|
const tips = [];
|
|
1960
2998
|
if (fileStatus.staged.length > 0) {
|
|
1961
|
-
tips.push(`Run ${
|
|
2999
|
+
tips.push(`Run ${pc12.bold("contrib commit")} to commit staged changes`);
|
|
1962
3000
|
}
|
|
1963
3001
|
if (fileStatus.modified.length > 0 || fileStatus.untracked.length > 0) {
|
|
1964
|
-
tips.push(`Run ${
|
|
3002
|
+
tips.push(`Run ${pc12.bold("contrib commit")} to stage and commit changes`);
|
|
1965
3003
|
}
|
|
1966
3004
|
if (fileStatus.staged.length === 0 && fileStatus.modified.length === 0 && fileStatus.untracked.length === 0 && currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
|
|
1967
3005
|
const branchDiv = await getDivergence(currentBranch, `${origin}/${currentBranch}`);
|
|
1968
3006
|
if (branchDiv.ahead > 0) {
|
|
1969
|
-
tips.push(`Run ${
|
|
3007
|
+
tips.push(`Run ${pc12.bold("contrib submit")} to push and create/update your PR`);
|
|
1970
3008
|
}
|
|
1971
3009
|
}
|
|
1972
3010
|
if (tips.length > 0) {
|
|
1973
3011
|
console.log();
|
|
1974
|
-
console.log(` ${
|
|
3012
|
+
console.log(` ${pc12.dim("\uD83D\uDCA1 Tip:")}`);
|
|
1975
3013
|
for (const tip of tips) {
|
|
1976
|
-
console.log(` ${
|
|
3014
|
+
console.log(` ${pc12.dim(tip)}`);
|
|
1977
3015
|
}
|
|
1978
3016
|
}
|
|
1979
3017
|
console.log();
|
|
1980
3018
|
}
|
|
1981
3019
|
});
|
|
1982
3020
|
function formatStatus(branch, base, ahead, behind) {
|
|
1983
|
-
const label =
|
|
3021
|
+
const label = pc12.bold(branch.padEnd(20));
|
|
1984
3022
|
if (ahead === 0 && behind === 0) {
|
|
1985
|
-
return ` ${
|
|
3023
|
+
return ` ${pc12.green("✓")} ${label} ${pc12.dim(`in sync with ${base}`)}`;
|
|
1986
3024
|
}
|
|
1987
3025
|
if (ahead > 0 && behind === 0) {
|
|
1988
|
-
return ` ${
|
|
3026
|
+
return ` ${pc12.yellow("↑")} ${label} ${pc12.yellow(`${ahead} commit${ahead !== 1 ? "s" : ""} ahead of ${base}`)}`;
|
|
1989
3027
|
}
|
|
1990
3028
|
if (behind > 0 && ahead === 0) {
|
|
1991
|
-
return ` ${
|
|
3029
|
+
return ` ${pc12.red("↓")} ${label} ${pc12.red(`${behind} commit${behind !== 1 ? "s" : ""} behind ${base}`)}`;
|
|
1992
3030
|
}
|
|
1993
|
-
return ` ${
|
|
3031
|
+
return ` ${pc12.red("⚡")} ${label} ${pc12.yellow(`${ahead} ahead`)}${pc12.dim(", ")}${pc12.red(`${behind} behind`)} ${pc12.dim(base)}`;
|
|
1994
3032
|
}
|
|
1995
3033
|
|
|
1996
3034
|
// src/commands/submit.ts
|
|
1997
|
-
import { defineCommand as
|
|
1998
|
-
import
|
|
3035
|
+
import { defineCommand as defineCommand10 } from "citty";
|
|
3036
|
+
import pc13 from "picocolors";
|
|
1999
3037
|
async function performSquashMerge(origin, baseBranch, featureBranch, options) {
|
|
2000
|
-
info(`Checking out ${
|
|
3038
|
+
info(`Checking out ${pc13.bold(baseBranch)}...`);
|
|
2001
3039
|
const coResult = await checkoutBranch(baseBranch);
|
|
2002
3040
|
if (coResult.exitCode !== 0) {
|
|
2003
3041
|
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
2004
3042
|
process.exit(1);
|
|
2005
3043
|
}
|
|
2006
|
-
info(`Squash merging ${
|
|
3044
|
+
info(`Squash merging ${pc13.bold(featureBranch)} into ${pc13.bold(baseBranch)}...`);
|
|
2007
3045
|
const mergeResult = await mergeSquash(featureBranch);
|
|
2008
3046
|
if (mergeResult.exitCode !== 0) {
|
|
2009
3047
|
error(`Squash merge failed: ${mergeResult.stderr}`);
|
|
@@ -2027,32 +3065,42 @@ async function performSquashMerge(origin, baseBranch, featureBranch, options) {
|
|
|
2027
3065
|
}
|
|
2028
3066
|
}
|
|
2029
3067
|
const fallback = message || `squash merge ${featureBranch}`;
|
|
2030
|
-
|
|
3068
|
+
let finalMsg;
|
|
3069
|
+
if (message) {
|
|
3070
|
+
console.log(` ${pc13.dim("Commit message:")} ${pc13.bold(message)}`);
|
|
3071
|
+
finalMsg = message;
|
|
3072
|
+
} else {
|
|
3073
|
+
finalMsg = await inputPrompt("Commit message", fallback);
|
|
3074
|
+
}
|
|
2031
3075
|
const commitResult = await commitWithMessage(finalMsg);
|
|
2032
3076
|
if (commitResult.exitCode !== 0) {
|
|
2033
3077
|
error(`Commit failed: ${commitResult.stderr}`);
|
|
2034
3078
|
process.exit(1);
|
|
2035
3079
|
}
|
|
2036
|
-
info(`Pushing ${
|
|
3080
|
+
info(`Pushing ${pc13.bold(baseBranch)} to ${origin}...`);
|
|
2037
3081
|
const pushResult = await pushBranch(origin, baseBranch);
|
|
2038
3082
|
if (pushResult.exitCode !== 0) {
|
|
2039
3083
|
error(`Failed to push ${baseBranch}: ${pushResult.stderr}`);
|
|
2040
3084
|
process.exit(1);
|
|
2041
3085
|
}
|
|
2042
|
-
info(`Deleting local branch ${
|
|
3086
|
+
info(`Deleting local branch ${pc13.bold(featureBranch)}...`);
|
|
2043
3087
|
const delLocal = await forceDeleteBranch(featureBranch);
|
|
2044
3088
|
if (delLocal.exitCode !== 0) {
|
|
2045
3089
|
warn(`Could not delete local branch: ${delLocal.stderr.trim()}`);
|
|
2046
3090
|
}
|
|
2047
|
-
|
|
2048
|
-
const
|
|
2049
|
-
if (
|
|
2050
|
-
|
|
3091
|
+
const remoteBranchRef = `${origin}/${featureBranch}`;
|
|
3092
|
+
const remoteExists = await branchExists(remoteBranchRef);
|
|
3093
|
+
if (remoteExists) {
|
|
3094
|
+
info(`Deleting remote branch ${pc13.bold(featureBranch)}...`);
|
|
3095
|
+
const delRemote = await deleteRemoteBranch(origin, featureBranch);
|
|
3096
|
+
if (delRemote.exitCode !== 0) {
|
|
3097
|
+
warn(`Could not delete remote branch: ${delRemote.stderr.trim()}`);
|
|
3098
|
+
}
|
|
2051
3099
|
}
|
|
2052
|
-
success(`✅ Squash merged ${
|
|
2053
|
-
info(`Run ${
|
|
3100
|
+
success(`✅ Squash merged ${pc13.bold(featureBranch)} into ${pc13.bold(baseBranch)} and pushed.`);
|
|
3101
|
+
info(`Run ${pc13.bold("contrib start")} to begin a new feature.`);
|
|
2054
3102
|
}
|
|
2055
|
-
var submit_default =
|
|
3103
|
+
var submit_default = defineCommand10({
|
|
2056
3104
|
meta: {
|
|
2057
3105
|
name: "submit",
|
|
2058
3106
|
description: "Push current branch and create a pull request"
|
|
@@ -2078,6 +3126,7 @@ var submit_default = defineCommand7({
|
|
|
2078
3126
|
error("Not inside a git repository.");
|
|
2079
3127
|
process.exit(1);
|
|
2080
3128
|
}
|
|
3129
|
+
await assertCleanGitState("submitting");
|
|
2081
3130
|
const config = readConfig();
|
|
2082
3131
|
if (!config) {
|
|
2083
3132
|
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
@@ -2092,8 +3141,80 @@ var submit_default = defineCommand7({
|
|
|
2092
3141
|
process.exit(1);
|
|
2093
3142
|
}
|
|
2094
3143
|
if (protectedBranches.includes(currentBranch)) {
|
|
2095
|
-
|
|
2096
|
-
|
|
3144
|
+
heading("\uD83D\uDE80 contrib submit");
|
|
3145
|
+
warn(`You're on ${pc13.bold(currentBranch)}, which is a protected branch. PRs should come from feature branches.`);
|
|
3146
|
+
await fetchAll();
|
|
3147
|
+
const remoteRef = `${origin}/${currentBranch}`;
|
|
3148
|
+
const localWork = await hasLocalWork(origin, currentBranch);
|
|
3149
|
+
const dirty = await hasUncommittedChanges();
|
|
3150
|
+
const hasCommits = localWork.unpushedCommits > 0;
|
|
3151
|
+
const hasAnything = hasCommits || dirty;
|
|
3152
|
+
if (!hasAnything) {
|
|
3153
|
+
error("No local changes or commits to move. Switch to a feature branch first.");
|
|
3154
|
+
info(` Run ${pc13.bold("contrib start")} to create a new feature branch.`);
|
|
3155
|
+
process.exit(1);
|
|
3156
|
+
}
|
|
3157
|
+
if (hasCommits) {
|
|
3158
|
+
info(`Found ${pc13.bold(String(localWork.unpushedCommits))} unpushed commit${localWork.unpushedCommits !== 1 ? "s" : ""} on ${pc13.bold(currentBranch)}.`);
|
|
3159
|
+
}
|
|
3160
|
+
if (dirty) {
|
|
3161
|
+
info("You also have uncommitted changes in the working tree.");
|
|
3162
|
+
}
|
|
3163
|
+
console.log();
|
|
3164
|
+
const MOVE_BRANCH = "Move my changes to a new feature branch";
|
|
3165
|
+
const CANCEL2 = "Cancel (stay on this branch)";
|
|
3166
|
+
const action = await selectPrompt("Let's get you back on track. What would you like to do?", [
|
|
3167
|
+
MOVE_BRANCH,
|
|
3168
|
+
CANCEL2
|
|
3169
|
+
]);
|
|
3170
|
+
if (action === CANCEL2) {
|
|
3171
|
+
info("No changes made. You are still on your current branch.");
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
3174
|
+
info(pc13.dim("Tip: Describe what you're going to work on in plain English and we'll generate a branch name."));
|
|
3175
|
+
const description = await inputPrompt("What are you going to work on?");
|
|
3176
|
+
let newBranchName = description;
|
|
3177
|
+
if (looksLikeNaturalLanguage(description)) {
|
|
3178
|
+
const copilotError = await checkCopilotAvailable();
|
|
3179
|
+
if (!copilotError) {
|
|
3180
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
3181
|
+
const suggested = await suggestBranchName(description, args.model);
|
|
3182
|
+
if (suggested) {
|
|
3183
|
+
spinner.success("Branch name suggestion ready.");
|
|
3184
|
+
console.log(`
|
|
3185
|
+
${pc13.dim("AI suggestion:")} ${pc13.bold(pc13.cyan(suggested))}`);
|
|
3186
|
+
const accepted = await confirmPrompt(`Use ${pc13.bold(suggested)} as your branch name?`);
|
|
3187
|
+
newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
|
|
3188
|
+
} else {
|
|
3189
|
+
spinner.fail("AI did not return a suggestion.");
|
|
3190
|
+
newBranchName = await inputPrompt("Enter branch name", description);
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
}
|
|
3194
|
+
if (!hasPrefix(newBranchName, config.branchPrefixes)) {
|
|
3195
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc13.bold(newBranchName)}:`, config.branchPrefixes);
|
|
3196
|
+
newBranchName = formatBranchName(prefix, newBranchName);
|
|
3197
|
+
}
|
|
3198
|
+
if (!isValidBranchName(newBranchName)) {
|
|
3199
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
3200
|
+
process.exit(1);
|
|
3201
|
+
}
|
|
3202
|
+
if (await branchExists(newBranchName)) {
|
|
3203
|
+
error(`Branch ${pc13.bold(newBranchName)} already exists. Choose a different name.`);
|
|
3204
|
+
process.exit(1);
|
|
3205
|
+
}
|
|
3206
|
+
const branchResult = await createBranch(newBranchName);
|
|
3207
|
+
if (branchResult.exitCode !== 0) {
|
|
3208
|
+
error(`Failed to create branch: ${branchResult.stderr}`);
|
|
3209
|
+
process.exit(1);
|
|
3210
|
+
}
|
|
3211
|
+
success(`Created ${pc13.bold(newBranchName)} with your changes.`);
|
|
3212
|
+
await updateLocalBranch(currentBranch, remoteRef);
|
|
3213
|
+
info(`Reset ${pc13.bold(currentBranch)} back to ${pc13.bold(remoteRef)} — no damage done.`);
|
|
3214
|
+
console.log();
|
|
3215
|
+
success(`You're now on ${pc13.bold(newBranchName)} with all your work intact.`);
|
|
3216
|
+
info(`Run ${pc13.bold("contrib submit")} again to push and create your PR.`);
|
|
3217
|
+
return;
|
|
2097
3218
|
}
|
|
2098
3219
|
heading("\uD83D\uDE80 contrib submit");
|
|
2099
3220
|
const ghInstalled = await checkGhInstalled();
|
|
@@ -2101,7 +3222,7 @@ var submit_default = defineCommand7({
|
|
|
2101
3222
|
if (ghInstalled && ghAuthed) {
|
|
2102
3223
|
const mergedPR = await getMergedPRForBranch(currentBranch);
|
|
2103
3224
|
if (mergedPR) {
|
|
2104
|
-
warn(`PR #${mergedPR.number} (${
|
|
3225
|
+
warn(`PR #${mergedPR.number} (${pc13.bold(mergedPR.title)}) was already merged.`);
|
|
2105
3226
|
const localWork = await hasLocalWork(origin, currentBranch);
|
|
2106
3227
|
const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
|
|
2107
3228
|
if (hasWork) {
|
|
@@ -2109,7 +3230,7 @@ var submit_default = defineCommand7({
|
|
|
2109
3230
|
warn("You have uncommitted changes in your working tree.");
|
|
2110
3231
|
}
|
|
2111
3232
|
if (localWork.unpushedCommits > 0) {
|
|
2112
|
-
warn(`You have ${
|
|
3233
|
+
warn(`You have ${pc13.bold(String(localWork.unpushedCommits))} local commit${localWork.unpushedCommits !== 1 ? "s" : ""} not in the merged PR.`);
|
|
2113
3234
|
}
|
|
2114
3235
|
const SAVE_NEW_BRANCH = "Save changes to a new branch";
|
|
2115
3236
|
const DISCARD = "Discard all changes and clean up";
|
|
@@ -2120,79 +3241,91 @@ var submit_default = defineCommand7({
|
|
|
2120
3241
|
return;
|
|
2121
3242
|
}
|
|
2122
3243
|
if (action === SAVE_NEW_BRANCH) {
|
|
2123
|
-
|
|
2124
|
-
const
|
|
3244
|
+
info(pc13.dim("Tip: Describe what you're going to work on in plain English and we'll generate a branch name."));
|
|
3245
|
+
const description = await inputPrompt("What are you going to work on?");
|
|
3246
|
+
let newBranchName = description;
|
|
3247
|
+
if (!args["no-ai"] && looksLikeNaturalLanguage(description)) {
|
|
3248
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
3249
|
+
const suggested = await suggestBranchName(description, args.model);
|
|
3250
|
+
if (suggested) {
|
|
3251
|
+
spinner.success("Branch name suggestion ready.");
|
|
3252
|
+
console.log(`
|
|
3253
|
+
${pc13.dim("AI suggestion:")} ${pc13.bold(pc13.cyan(suggested))}`);
|
|
3254
|
+
const accepted = await confirmPrompt(`Use ${pc13.bold(suggested)} as your branch name?`);
|
|
3255
|
+
newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
|
|
3256
|
+
} else {
|
|
3257
|
+
spinner.fail("AI did not return a suggestion.");
|
|
3258
|
+
newBranchName = await inputPrompt("Enter branch name", description);
|
|
3259
|
+
}
|
|
3260
|
+
}
|
|
3261
|
+
if (!hasPrefix(newBranchName, config.branchPrefixes)) {
|
|
3262
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc13.bold(newBranchName)}:`, config.branchPrefixes);
|
|
3263
|
+
newBranchName = formatBranchName(prefix, newBranchName);
|
|
3264
|
+
}
|
|
3265
|
+
if (!isValidBranchName(newBranchName)) {
|
|
3266
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
3267
|
+
process.exit(1);
|
|
3268
|
+
}
|
|
3269
|
+
const staleUpstream = await getUpstreamRef();
|
|
3270
|
+
const staleUpstreamHash = staleUpstream ? await getCommitHash(staleUpstream) : null;
|
|
3271
|
+
if (await branchExists(newBranchName)) {
|
|
3272
|
+
error(`Branch ${pc13.bold(newBranchName)} already exists. Choose a different name.`);
|
|
3273
|
+
process.exit(1);
|
|
3274
|
+
}
|
|
2125
3275
|
const renameResult = await renameBranch(currentBranch, newBranchName);
|
|
2126
3276
|
if (renameResult.exitCode !== 0) {
|
|
2127
3277
|
error(`Failed to rename branch: ${renameResult.stderr}`);
|
|
2128
3278
|
process.exit(1);
|
|
2129
3279
|
}
|
|
2130
|
-
success(`Renamed ${
|
|
3280
|
+
success(`Renamed ${pc13.bold(currentBranch)} → ${pc13.bold(newBranchName)}`);
|
|
3281
|
+
await unsetUpstream();
|
|
2131
3282
|
const syncSource2 = getSyncSource(config);
|
|
2132
|
-
info(`Syncing ${
|
|
3283
|
+
info(`Syncing ${pc13.bold(newBranchName)} with latest ${pc13.bold(baseBranch)}...`);
|
|
2133
3284
|
await fetchRemote(syncSource2.remote);
|
|
2134
|
-
|
|
2135
|
-
|
|
3285
|
+
let rebaseResult;
|
|
3286
|
+
if (staleUpstreamHash) {
|
|
3287
|
+
rebaseResult = await rebaseOnto(syncSource2.ref, staleUpstreamHash);
|
|
3288
|
+
} else {
|
|
3289
|
+
const savedStrategy = await determineRebaseStrategy(newBranchName, syncSource2.ref);
|
|
3290
|
+
rebaseResult = savedStrategy.strategy === "onto" && savedStrategy.ontoOldBase ? await rebaseOnto(syncSource2.ref, savedStrategy.ontoOldBase) : await rebase(syncSource2.ref);
|
|
3291
|
+
}
|
|
2136
3292
|
if (rebaseResult.exitCode !== 0) {
|
|
2137
3293
|
warn("Rebase encountered conflicts. Resolve them manually, then run:");
|
|
2138
|
-
info(` ${
|
|
3294
|
+
info(` ${pc13.bold("git rebase --continue")}`);
|
|
2139
3295
|
} else {
|
|
2140
|
-
success(`Rebased ${
|
|
3296
|
+
success(`Rebased ${pc13.bold(newBranchName)} onto ${pc13.bold(syncSource2.ref)}.`);
|
|
2141
3297
|
}
|
|
2142
|
-
info(`All your changes are preserved. Run ${
|
|
3298
|
+
info(`All your changes are preserved. Run ${pc13.bold("contrib submit")} when ready to create a new PR.`);
|
|
2143
3299
|
return;
|
|
2144
3300
|
}
|
|
2145
3301
|
warn("Discarding local changes...");
|
|
2146
3302
|
}
|
|
2147
3303
|
const syncSource = getSyncSource(config);
|
|
2148
|
-
info(`Switching to ${
|
|
3304
|
+
info(`Switching to ${pc13.bold(baseBranch)} and syncing...`);
|
|
2149
3305
|
await fetchRemote(syncSource.remote);
|
|
3306
|
+
await resetHard("HEAD");
|
|
2150
3307
|
const coResult = await checkoutBranch(baseBranch);
|
|
2151
3308
|
if (coResult.exitCode !== 0) {
|
|
2152
3309
|
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
2153
3310
|
process.exit(1);
|
|
2154
3311
|
}
|
|
2155
3312
|
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
2156
|
-
success(`Synced ${
|
|
2157
|
-
info(`Deleting stale branch ${
|
|
3313
|
+
success(`Synced ${pc13.bold(baseBranch)} with ${pc13.bold(syncSource.ref)}.`);
|
|
3314
|
+
info(`Deleting stale branch ${pc13.bold(currentBranch)}...`);
|
|
2158
3315
|
const delResult = await forceDeleteBranch(currentBranch);
|
|
2159
3316
|
if (delResult.exitCode === 0) {
|
|
2160
|
-
success(`Deleted ${
|
|
3317
|
+
success(`Deleted ${pc13.bold(currentBranch)}.`);
|
|
2161
3318
|
} else {
|
|
2162
3319
|
warn(`Could not delete branch: ${delResult.stderr.trim()}`);
|
|
2163
3320
|
}
|
|
2164
3321
|
console.log();
|
|
2165
|
-
info(`You're now on ${
|
|
3322
|
+
info(`You're now on ${pc13.bold(baseBranch)}. Run ${pc13.bold("contrib start")} to begin a new feature.`);
|
|
2166
3323
|
return;
|
|
2167
3324
|
}
|
|
2168
3325
|
}
|
|
2169
|
-
info(`Pushing ${pc10.bold(currentBranch)} to ${origin}...`);
|
|
2170
|
-
const pushResult = await pushSetUpstream(origin, currentBranch);
|
|
2171
|
-
if (pushResult.exitCode !== 0) {
|
|
2172
|
-
error(`Failed to push: ${pushResult.stderr}`);
|
|
2173
|
-
process.exit(1);
|
|
2174
|
-
}
|
|
2175
|
-
if (!ghInstalled || !ghAuthed) {
|
|
2176
|
-
const repoInfo = await getRepoInfoFromRemote(origin);
|
|
2177
|
-
if (repoInfo) {
|
|
2178
|
-
const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${baseBranch}...${currentBranch}?expand=1`;
|
|
2179
|
-
console.log();
|
|
2180
|
-
info("Create your PR manually:");
|
|
2181
|
-
console.log(` ${pc10.cyan(prUrl)}`);
|
|
2182
|
-
} else {
|
|
2183
|
-
info("gh CLI not available. Create your PR manually on GitHub.");
|
|
2184
|
-
}
|
|
2185
|
-
return;
|
|
2186
|
-
}
|
|
2187
|
-
const existingPR = await getPRForBranch(currentBranch);
|
|
2188
|
-
if (existingPR) {
|
|
2189
|
-
success(`Pushed changes to existing PR #${existingPR.number}: ${pc10.bold(existingPR.title)}`);
|
|
2190
|
-
console.log(` ${pc10.cyan(existingPR.url)}`);
|
|
2191
|
-
return;
|
|
2192
|
-
}
|
|
2193
3326
|
let prTitle = null;
|
|
2194
3327
|
let prBody = null;
|
|
2195
|
-
|
|
3328
|
+
async function tryGenerateAI() {
|
|
2196
3329
|
const [copilotError, commits, diff] = await Promise.all([
|
|
2197
3330
|
checkCopilotAvailable(),
|
|
2198
3331
|
getLog(baseBranch, "HEAD"),
|
|
@@ -2206,10 +3339,10 @@ var submit_default = defineCommand7({
|
|
|
2206
3339
|
prBody = result.body;
|
|
2207
3340
|
spinner.success("PR description generated.");
|
|
2208
3341
|
console.log(`
|
|
2209
|
-
${
|
|
3342
|
+
${pc13.dim("AI title:")} ${pc13.bold(pc13.cyan(prTitle))}`);
|
|
2210
3343
|
console.log(`
|
|
2211
|
-
${
|
|
2212
|
-
console.log(
|
|
3344
|
+
${pc13.dim("AI body preview:")}`);
|
|
3345
|
+
console.log(pc13.dim(prBody.slice(0, 300) + (prBody.length > 300 ? "..." : "")));
|
|
2213
3346
|
} else {
|
|
2214
3347
|
spinner.fail("AI did not return a PR description.");
|
|
2215
3348
|
}
|
|
@@ -2217,77 +3350,126 @@ ${pc10.dim("AI body preview:")}`);
|
|
|
2217
3350
|
warn(`AI unavailable: ${copilotError}`);
|
|
2218
3351
|
}
|
|
2219
3352
|
}
|
|
3353
|
+
if (!args["no-ai"]) {
|
|
3354
|
+
await tryGenerateAI();
|
|
3355
|
+
}
|
|
2220
3356
|
const CANCEL = "Cancel";
|
|
2221
3357
|
const SQUASH_LOCAL = `Squash merge to ${baseBranch} locally (no PR)`;
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
3358
|
+
const REGENERATE = "Regenerate AI description";
|
|
3359
|
+
let submitAction = "cancel";
|
|
3360
|
+
const isMaintainer = config.role === "maintainer";
|
|
3361
|
+
let actionResolved = false;
|
|
3362
|
+
while (!actionResolved) {
|
|
3363
|
+
if (prTitle && prBody) {
|
|
3364
|
+
const choices = ["Use AI description"];
|
|
3365
|
+
if (isMaintainer)
|
|
3366
|
+
choices.push(SQUASH_LOCAL);
|
|
3367
|
+
choices.push("Edit title", "Write manually", "Use gh --fill (auto-fill from commits)", REGENERATE, CANCEL);
|
|
3368
|
+
const action = await selectPrompt("What would you like to do with the PR description?", choices);
|
|
3369
|
+
if (action === CANCEL) {
|
|
3370
|
+
submitAction = "cancel";
|
|
3371
|
+
actionResolved = true;
|
|
3372
|
+
} else if (action === REGENERATE) {
|
|
3373
|
+
prTitle = null;
|
|
3374
|
+
prBody = null;
|
|
3375
|
+
await tryGenerateAI();
|
|
3376
|
+
} else if (action === SQUASH_LOCAL) {
|
|
3377
|
+
submitAction = "squash";
|
|
3378
|
+
actionResolved = true;
|
|
3379
|
+
} else if (action === "Use AI description") {
|
|
3380
|
+
submitAction = "create-pr";
|
|
3381
|
+
actionResolved = true;
|
|
3382
|
+
} else if (action === "Edit title") {
|
|
3383
|
+
prTitle = await inputPrompt("PR title", prTitle);
|
|
3384
|
+
submitAction = "create-pr";
|
|
3385
|
+
actionResolved = true;
|
|
3386
|
+
} else if (action === "Write manually") {
|
|
3387
|
+
prTitle = await inputPrompt("PR title");
|
|
3388
|
+
prBody = await inputPrompt("PR body (markdown)");
|
|
3389
|
+
submitAction = "create-pr";
|
|
3390
|
+
actionResolved = true;
|
|
3391
|
+
} else {
|
|
3392
|
+
submitAction = "fill";
|
|
3393
|
+
actionResolved = true;
|
|
3394
|
+
}
|
|
2250
3395
|
} else {
|
|
2251
|
-
const
|
|
2252
|
-
if (
|
|
2253
|
-
|
|
2254
|
-
|
|
3396
|
+
const choices = [];
|
|
3397
|
+
if (isMaintainer)
|
|
3398
|
+
choices.push(SQUASH_LOCAL);
|
|
3399
|
+
if (!args["no-ai"])
|
|
3400
|
+
choices.push(REGENERATE);
|
|
3401
|
+
choices.push("Write title & body manually", "Use gh --fill (auto-fill from commits)", CANCEL);
|
|
3402
|
+
const action = await selectPrompt("How would you like to create the PR?", choices);
|
|
3403
|
+
if (action === CANCEL) {
|
|
3404
|
+
submitAction = "cancel";
|
|
3405
|
+
actionResolved = true;
|
|
3406
|
+
} else if (action === REGENERATE) {
|
|
3407
|
+
await tryGenerateAI();
|
|
3408
|
+
} else if (action === SQUASH_LOCAL) {
|
|
3409
|
+
submitAction = "squash";
|
|
3410
|
+
actionResolved = true;
|
|
3411
|
+
} else if (action === "Write title & body manually") {
|
|
3412
|
+
prTitle = await inputPrompt("PR title");
|
|
3413
|
+
prBody = await inputPrompt("PR body (markdown)");
|
|
3414
|
+
submitAction = "create-pr";
|
|
3415
|
+
actionResolved = true;
|
|
3416
|
+
} else {
|
|
3417
|
+
submitAction = "fill";
|
|
3418
|
+
actionResolved = true;
|
|
2255
3419
|
}
|
|
2256
|
-
success(`✅ PR created: ${fillResult.stdout.trim()}`);
|
|
2257
|
-
return;
|
|
2258
|
-
}
|
|
2259
|
-
} else {
|
|
2260
|
-
const choices = [
|
|
2261
|
-
"Write title & body manually",
|
|
2262
|
-
"Use gh --fill (auto-fill from commits)"
|
|
2263
|
-
];
|
|
2264
|
-
if (config.role === "maintainer")
|
|
2265
|
-
choices.push(SQUASH_LOCAL);
|
|
2266
|
-
choices.push(CANCEL);
|
|
2267
|
-
const action = await selectPrompt("How would you like to create the PR?", choices);
|
|
2268
|
-
if (action === CANCEL) {
|
|
2269
|
-
warn("Submit cancelled.");
|
|
2270
|
-
return;
|
|
2271
3420
|
}
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
3421
|
+
}
|
|
3422
|
+
if (submitAction === "cancel") {
|
|
3423
|
+
warn("Submit cancelled.");
|
|
3424
|
+
return;
|
|
3425
|
+
}
|
|
3426
|
+
if (submitAction === "squash") {
|
|
3427
|
+
await performSquashMerge(origin, baseBranch, currentBranch, {
|
|
3428
|
+
defaultMsg: prTitle ?? undefined,
|
|
3429
|
+
model: args.model,
|
|
3430
|
+
convention: config.commitConvention
|
|
3431
|
+
});
|
|
3432
|
+
return;
|
|
3433
|
+
}
|
|
3434
|
+
info(`Pushing ${pc13.bold(currentBranch)} to ${origin}...`);
|
|
3435
|
+
const pushResult = await pushSetUpstream(origin, currentBranch);
|
|
3436
|
+
if (pushResult.exitCode !== 0) {
|
|
3437
|
+
error(`Failed to push: ${pushResult.stderr}`);
|
|
3438
|
+
if (pushResult.stderr.includes("rejected") || pushResult.stderr.includes("non-fast-forward")) {
|
|
3439
|
+
warn("The remote branch has diverged. Try:");
|
|
3440
|
+
info(` git pull --rebase ${origin} ${currentBranch}`);
|
|
3441
|
+
info(" Then run `contrib submit` again.");
|
|
3442
|
+
info("If you need to force push (use with caution):");
|
|
3443
|
+
info(` git push --force-with-lease ${origin} ${currentBranch}`);
|
|
2278
3444
|
}
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
3445
|
+
process.exit(1);
|
|
3446
|
+
}
|
|
3447
|
+
if (!ghInstalled || !ghAuthed) {
|
|
3448
|
+
const repoInfo = await getRepoInfoFromRemote(origin);
|
|
3449
|
+
if (repoInfo) {
|
|
3450
|
+
const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${baseBranch}...${currentBranch}?expand=1`;
|
|
3451
|
+
console.log();
|
|
3452
|
+
info("Create your PR manually:");
|
|
3453
|
+
console.log(` ${pc13.cyan(prUrl)}`);
|
|
2282
3454
|
} else {
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
3455
|
+
info("gh CLI not available. Create your PR manually on GitHub.");
|
|
3456
|
+
}
|
|
3457
|
+
return;
|
|
3458
|
+
}
|
|
3459
|
+
const existingPR = await getPRForBranch(currentBranch);
|
|
3460
|
+
if (existingPR) {
|
|
3461
|
+
success(`Pushed changes to existing PR #${existingPR.number}: ${pc13.bold(existingPR.title)}`);
|
|
3462
|
+
console.log(` ${pc13.cyan(existingPR.url)}`);
|
|
3463
|
+
return;
|
|
3464
|
+
}
|
|
3465
|
+
if (submitAction === "fill") {
|
|
3466
|
+
const fillResult = await createPRFill(baseBranch, args.draft);
|
|
3467
|
+
if (fillResult.exitCode !== 0) {
|
|
3468
|
+
error(`Failed to create PR: ${fillResult.stderr}`);
|
|
3469
|
+
process.exit(1);
|
|
2290
3470
|
}
|
|
3471
|
+
success(`✅ PR created: ${fillResult.stdout.trim()}`);
|
|
3472
|
+
return;
|
|
2291
3473
|
}
|
|
2292
3474
|
if (!prTitle) {
|
|
2293
3475
|
error("No PR title provided.");
|
|
@@ -2308,9 +3490,9 @@ ${pc10.dim("AI body preview:")}`);
|
|
|
2308
3490
|
});
|
|
2309
3491
|
|
|
2310
3492
|
// src/commands/sync.ts
|
|
2311
|
-
import { defineCommand as
|
|
2312
|
-
import
|
|
2313
|
-
var sync_default =
|
|
3493
|
+
import { defineCommand as defineCommand11 } from "citty";
|
|
3494
|
+
import pc14 from "picocolors";
|
|
3495
|
+
var sync_default = defineCommand11({
|
|
2314
3496
|
meta: {
|
|
2315
3497
|
name: "sync",
|
|
2316
3498
|
description: "Sync your local branches with the remote"
|
|
@@ -2321,6 +3503,15 @@ var sync_default = defineCommand8({
|
|
|
2321
3503
|
alias: "y",
|
|
2322
3504
|
description: "Skip confirmation prompt",
|
|
2323
3505
|
default: false
|
|
3506
|
+
},
|
|
3507
|
+
model: {
|
|
3508
|
+
type: "string",
|
|
3509
|
+
description: "AI model to use for branch name suggestion"
|
|
3510
|
+
},
|
|
3511
|
+
"no-ai": {
|
|
3512
|
+
type: "boolean",
|
|
3513
|
+
description: "Skip AI branch name suggestion",
|
|
3514
|
+
default: false
|
|
2324
3515
|
}
|
|
2325
3516
|
},
|
|
2326
3517
|
async run({ args }) {
|
|
@@ -2328,6 +3519,7 @@ var sync_default = defineCommand8({
|
|
|
2328
3519
|
error("Not inside a git repository.");
|
|
2329
3520
|
process.exit(1);
|
|
2330
3521
|
}
|
|
3522
|
+
await assertCleanGitState("syncing");
|
|
2331
3523
|
const config = readConfig();
|
|
2332
3524
|
if (!config) {
|
|
2333
3525
|
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
@@ -2350,14 +3542,98 @@ var sync_default = defineCommand8({
|
|
|
2350
3542
|
if (role === "contributor" && syncSource.remote !== origin) {
|
|
2351
3543
|
await fetchRemote(origin);
|
|
2352
3544
|
}
|
|
3545
|
+
if (!await refExists(syncSource.ref)) {
|
|
3546
|
+
error(`Remote ref ${pc14.bold(syncSource.ref)} does not exist.`);
|
|
3547
|
+
info("This can happen if the branch was renamed or deleted on the remote.");
|
|
3548
|
+
info(`Check your config: the base branch may need updating via ${pc14.bold("contrib setup")}.`);
|
|
3549
|
+
process.exit(1);
|
|
3550
|
+
}
|
|
3551
|
+
let allowMergeCommit = false;
|
|
2353
3552
|
const div = await getDivergence(baseBranch, syncSource.ref);
|
|
2354
3553
|
if (div.ahead > 0 || div.behind > 0) {
|
|
2355
|
-
info(`${
|
|
3554
|
+
info(`${pc14.bold(baseBranch)} is ${pc14.yellow(`${div.ahead} ahead`)} and ${pc14.red(`${div.behind} behind`)} ${syncSource.ref}`);
|
|
2356
3555
|
} else {
|
|
2357
|
-
info(`${
|
|
3556
|
+
info(`${pc14.bold(baseBranch)} is already in sync with ${syncSource.ref}`);
|
|
3557
|
+
}
|
|
3558
|
+
if (div.ahead > 0) {
|
|
3559
|
+
const currentBranch = await getCurrentBranch();
|
|
3560
|
+
const protectedBranches = getProtectedBranches(config);
|
|
3561
|
+
const isOnProtected = currentBranch && protectedBranches.includes(currentBranch);
|
|
3562
|
+
if (isOnProtected) {
|
|
3563
|
+
warn(`You have ${pc14.bold(String(div.ahead))} local commit${div.ahead !== 1 ? "s" : ""} on ${pc14.bold(baseBranch)} that aren't on the remote.`);
|
|
3564
|
+
info("Pulling now could create a merge commit, which breaks clean history.");
|
|
3565
|
+
console.log();
|
|
3566
|
+
const MOVE_BRANCH = "Move my commits to a new feature branch, then sync";
|
|
3567
|
+
const PULL_ANYWAY = "Pull anyway (may create a merge commit)";
|
|
3568
|
+
const CANCEL = "Cancel";
|
|
3569
|
+
const action = await selectPrompt("How would you like to handle this?", [
|
|
3570
|
+
MOVE_BRANCH,
|
|
3571
|
+
PULL_ANYWAY,
|
|
3572
|
+
CANCEL
|
|
3573
|
+
]);
|
|
3574
|
+
if (action === CANCEL) {
|
|
3575
|
+
info("No changes made.");
|
|
3576
|
+
return;
|
|
3577
|
+
}
|
|
3578
|
+
if (action === MOVE_BRANCH) {
|
|
3579
|
+
info(pc14.dim("Tip: Describe what you're going to work on in plain English and we'll generate a branch name."));
|
|
3580
|
+
const description = await inputPrompt("What are you going to work on?");
|
|
3581
|
+
let newBranchName = description;
|
|
3582
|
+
if (!args["no-ai"] && looksLikeNaturalLanguage(description)) {
|
|
3583
|
+
const copilotError = await checkCopilotAvailable();
|
|
3584
|
+
if (!copilotError) {
|
|
3585
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
3586
|
+
const suggested = await suggestBranchName(description, args.model);
|
|
3587
|
+
if (suggested) {
|
|
3588
|
+
spinner.success("Branch name suggestion ready.");
|
|
3589
|
+
console.log(`
|
|
3590
|
+
${pc14.dim("AI suggestion:")} ${pc14.bold(pc14.cyan(suggested))}`);
|
|
3591
|
+
const accepted = await confirmPrompt(`Use ${pc14.bold(suggested)} as your branch name?`);
|
|
3592
|
+
newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
|
|
3593
|
+
} else {
|
|
3594
|
+
spinner.fail("AI did not return a suggestion.");
|
|
3595
|
+
newBranchName = await inputPrompt("Enter branch name", description);
|
|
3596
|
+
}
|
|
3597
|
+
}
|
|
3598
|
+
}
|
|
3599
|
+
if (!hasPrefix(newBranchName, config.branchPrefixes)) {
|
|
3600
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc14.bold(newBranchName)}:`, config.branchPrefixes);
|
|
3601
|
+
newBranchName = formatBranchName(prefix, newBranchName);
|
|
3602
|
+
}
|
|
3603
|
+
if (!isValidBranchName(newBranchName)) {
|
|
3604
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
3605
|
+
process.exit(1);
|
|
3606
|
+
}
|
|
3607
|
+
if (await branchExists(newBranchName)) {
|
|
3608
|
+
error(`Branch ${pc14.bold(newBranchName)} already exists. Choose a different name.`);
|
|
3609
|
+
process.exit(1);
|
|
3610
|
+
}
|
|
3611
|
+
const branchResult = await createBranch(newBranchName);
|
|
3612
|
+
if (branchResult.exitCode !== 0) {
|
|
3613
|
+
error(`Failed to create branch: ${branchResult.stderr}`);
|
|
3614
|
+
process.exit(1);
|
|
3615
|
+
}
|
|
3616
|
+
success(`Created ${pc14.bold(newBranchName)} with your commits.`);
|
|
3617
|
+
const coResult2 = await checkoutBranch(baseBranch);
|
|
3618
|
+
if (coResult2.exitCode !== 0) {
|
|
3619
|
+
error(`Failed to checkout ${baseBranch}: ${coResult2.stderr}`);
|
|
3620
|
+
process.exit(1);
|
|
3621
|
+
}
|
|
3622
|
+
const remoteRef = syncSource.ref;
|
|
3623
|
+
await updateLocalBranch(baseBranch, remoteRef);
|
|
3624
|
+
success(`Reset ${pc14.bold(baseBranch)} to ${pc14.bold(remoteRef)}.`);
|
|
3625
|
+
success(`✅ ${pc14.bold(baseBranch)} is now in sync with ${syncSource.ref}`);
|
|
3626
|
+
console.log();
|
|
3627
|
+
info(`Your commits are safe on ${pc14.bold(newBranchName)}.`);
|
|
3628
|
+
info(`Run ${pc14.bold(`git checkout ${newBranchName}`)} then ${pc14.bold("contrib update")} to rebase onto the synced ${pc14.bold(baseBranch)}.`);
|
|
3629
|
+
return;
|
|
3630
|
+
}
|
|
3631
|
+
allowMergeCommit = true;
|
|
3632
|
+
warn("Proceeding with pull — a merge commit may be created.");
|
|
3633
|
+
}
|
|
2358
3634
|
}
|
|
2359
3635
|
if (!args.yes) {
|
|
2360
|
-
const ok = await confirmPrompt(`This will pull ${
|
|
3636
|
+
const ok = await confirmPrompt(`This will pull ${pc14.bold(syncSource.ref)} into local ${pc14.bold(baseBranch)}.`);
|
|
2361
3637
|
if (!ok)
|
|
2362
3638
|
process.exit(0);
|
|
2363
3639
|
}
|
|
@@ -2366,19 +3642,24 @@ var sync_default = defineCommand8({
|
|
|
2366
3642
|
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
2367
3643
|
process.exit(1);
|
|
2368
3644
|
}
|
|
2369
|
-
const pullResult = await pullBranch(syncSource.remote, baseBranch);
|
|
3645
|
+
const pullResult = allowMergeCommit ? await pullBranch(syncSource.remote, baseBranch) : await pullFastForwardOnly(syncSource.remote, baseBranch);
|
|
2370
3646
|
if (pullResult.exitCode !== 0) {
|
|
2371
|
-
|
|
3647
|
+
if (allowMergeCommit) {
|
|
3648
|
+
error(`Pull failed: ${pullResult.stderr.trim()}`);
|
|
3649
|
+
} else {
|
|
3650
|
+
error(`Fast-forward pull failed. Your local ${pc14.bold(baseBranch)} may have diverged.`);
|
|
3651
|
+
info(`Use ${pc14.bold("contrib sync")} again and choose "Move my commits to a new feature branch" to fix this.`);
|
|
3652
|
+
}
|
|
2372
3653
|
process.exit(1);
|
|
2373
3654
|
}
|
|
2374
3655
|
success(`✅ ${baseBranch} is now in sync with ${syncSource.ref}`);
|
|
2375
3656
|
if (hasDevBranch(workflow) && role === "maintainer") {
|
|
2376
3657
|
const mainDiv = await getDivergence(config.mainBranch, `${origin}/${config.mainBranch}`);
|
|
2377
3658
|
if (mainDiv.behind > 0) {
|
|
2378
|
-
info(`Also syncing ${
|
|
3659
|
+
info(`Also syncing ${pc14.bold(config.mainBranch)}...`);
|
|
2379
3660
|
const mainCoResult = await checkoutBranch(config.mainBranch);
|
|
2380
3661
|
if (mainCoResult.exitCode === 0) {
|
|
2381
|
-
const mainPullResult = await
|
|
3662
|
+
const mainPullResult = await pullFastForwardOnly(origin, config.mainBranch);
|
|
2382
3663
|
if (mainPullResult.exitCode === 0) {
|
|
2383
3664
|
success(`✅ ${config.mainBranch} is now in sync with ${origin}/${config.mainBranch}`);
|
|
2384
3665
|
}
|
|
@@ -2391,9 +3672,9 @@ var sync_default = defineCommand8({
|
|
|
2391
3672
|
|
|
2392
3673
|
// src/commands/update.ts
|
|
2393
3674
|
import { readFileSync as readFileSync4 } from "node:fs";
|
|
2394
|
-
import { defineCommand as
|
|
2395
|
-
import
|
|
2396
|
-
var update_default =
|
|
3675
|
+
import { defineCommand as defineCommand12 } from "citty";
|
|
3676
|
+
import pc15 from "picocolors";
|
|
3677
|
+
var update_default = defineCommand12({
|
|
2397
3678
|
meta: {
|
|
2398
3679
|
name: "update",
|
|
2399
3680
|
description: "Rebase current branch onto the latest base branch"
|
|
@@ -2414,6 +3695,7 @@ var update_default = defineCommand9({
|
|
|
2414
3695
|
error("Not inside a git repository.");
|
|
2415
3696
|
process.exit(1);
|
|
2416
3697
|
}
|
|
3698
|
+
await assertCleanGitState("updating");
|
|
2417
3699
|
const config = readConfig();
|
|
2418
3700
|
if (!config) {
|
|
2419
3701
|
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
@@ -2428,8 +3710,77 @@ var update_default = defineCommand9({
|
|
|
2428
3710
|
process.exit(1);
|
|
2429
3711
|
}
|
|
2430
3712
|
if (protectedBranches.includes(currentBranch)) {
|
|
2431
|
-
|
|
2432
|
-
|
|
3713
|
+
heading("\uD83D\uDD03 contrib update");
|
|
3714
|
+
warn(`You're on ${pc15.bold(currentBranch)}, which is a protected branch. Updates (rebase) apply to feature branches.`);
|
|
3715
|
+
await fetchAll();
|
|
3716
|
+
const { origin } = config;
|
|
3717
|
+
const remoteRef = `${origin}/${currentBranch}`;
|
|
3718
|
+
const localWork = await hasLocalWork(origin, currentBranch);
|
|
3719
|
+
const dirty = await hasUncommittedChanges();
|
|
3720
|
+
const hasCommits = localWork.unpushedCommits > 0;
|
|
3721
|
+
const hasAnything = hasCommits || dirty;
|
|
3722
|
+
if (!hasAnything) {
|
|
3723
|
+
info(`No local changes found on ${pc15.bold(currentBranch)}.`);
|
|
3724
|
+
info(`Use ${pc15.bold("contrib sync")} to sync protected branches, or ${pc15.bold("contrib start")} to create a feature branch.`);
|
|
3725
|
+
process.exit(1);
|
|
3726
|
+
}
|
|
3727
|
+
if (hasCommits) {
|
|
3728
|
+
info(`Found ${pc15.bold(String(localWork.unpushedCommits))} unpushed commit${localWork.unpushedCommits !== 1 ? "s" : ""} on ${pc15.bold(currentBranch)}.`);
|
|
3729
|
+
}
|
|
3730
|
+
if (dirty) {
|
|
3731
|
+
info("You also have uncommitted changes in the working tree.");
|
|
3732
|
+
}
|
|
3733
|
+
console.log();
|
|
3734
|
+
const MOVE_BRANCH = "Move my changes to a new feature branch";
|
|
3735
|
+
const CANCEL = "Cancel (stay on this branch)";
|
|
3736
|
+
const action = await selectPrompt("Let's get you back on track. What would you like to do?", [
|
|
3737
|
+
MOVE_BRANCH,
|
|
3738
|
+
CANCEL
|
|
3739
|
+
]);
|
|
3740
|
+
if (action === CANCEL) {
|
|
3741
|
+
info("No changes made. You are still on your current branch.");
|
|
3742
|
+
return;
|
|
3743
|
+
}
|
|
3744
|
+
info(pc15.dim("Tip: Describe what you're going to work on in plain English and we'll generate a branch name."));
|
|
3745
|
+
const description = await inputPrompt("What are you going to work on?");
|
|
3746
|
+
let newBranchName = description;
|
|
3747
|
+
if (!args["no-ai"] && looksLikeNaturalLanguage(description)) {
|
|
3748
|
+
const copilotError = await checkCopilotAvailable();
|
|
3749
|
+
if (!copilotError) {
|
|
3750
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
3751
|
+
const suggested = await suggestBranchName(description, args.model);
|
|
3752
|
+
if (suggested) {
|
|
3753
|
+
spinner.success("Branch name suggestion ready.");
|
|
3754
|
+
console.log(`
|
|
3755
|
+
${pc15.dim("AI suggestion:")} ${pc15.bold(pc15.cyan(suggested))}`);
|
|
3756
|
+
const accepted = await confirmPrompt(`Use ${pc15.bold(suggested)} as your branch name?`);
|
|
3757
|
+
newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
|
|
3758
|
+
} else {
|
|
3759
|
+
spinner.fail("AI did not return a suggestion.");
|
|
3760
|
+
newBranchName = await inputPrompt("Enter branch name", description);
|
|
3761
|
+
}
|
|
3762
|
+
}
|
|
3763
|
+
}
|
|
3764
|
+
if (!hasPrefix(newBranchName, config.branchPrefixes)) {
|
|
3765
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc15.bold(newBranchName)}:`, config.branchPrefixes);
|
|
3766
|
+
newBranchName = formatBranchName(prefix, newBranchName);
|
|
3767
|
+
}
|
|
3768
|
+
if (!isValidBranchName(newBranchName)) {
|
|
3769
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
3770
|
+
process.exit(1);
|
|
3771
|
+
}
|
|
3772
|
+
const branchResult = await createBranch(newBranchName);
|
|
3773
|
+
if (branchResult.exitCode !== 0) {
|
|
3774
|
+
error(`Failed to create branch: ${branchResult.stderr}`);
|
|
3775
|
+
process.exit(1);
|
|
3776
|
+
}
|
|
3777
|
+
success(`Created ${pc15.bold(newBranchName)} with your changes.`);
|
|
3778
|
+
await updateLocalBranch(currentBranch, remoteRef);
|
|
3779
|
+
info(`Reset ${pc15.bold(currentBranch)} back to ${pc15.bold(remoteRef)} — no damage done.`);
|
|
3780
|
+
console.log();
|
|
3781
|
+
success(`You're now on ${pc15.bold(newBranchName)} with all your work intact.`);
|
|
3782
|
+
info(`Run ${pc15.bold("contrib update")} again to rebase onto latest ${pc15.bold(baseBranch)}.`);
|
|
3783
|
+
return;
|
|
2433
3784
|
}
|
|
2434
3785
|
if (await hasUncommittedChanges()) {
|
|
2435
3786
|
error("You have uncommitted changes. Please commit or stash them first.");
|
|
@@ -2438,8 +3789,8 @@ var update_default = defineCommand9({
|
|
|
2438
3789
|
heading("\uD83D\uDD03 contrib update");
|
|
2439
3790
|
const mergedPR = await getMergedPRForBranch(currentBranch);
|
|
2440
3791
|
if (mergedPR) {
|
|
2441
|
-
warn(`PR #${mergedPR.number} (${
|
|
2442
|
-
info(`Link: ${
|
|
3792
|
+
warn(`PR #${mergedPR.number} (${pc15.bold(mergedPR.title)}) has already been merged.`);
|
|
3793
|
+
info(`Link: ${pc15.underline(mergedPR.url)}`);
|
|
2443
3794
|
const localWork = await hasLocalWork(syncSource.remote, currentBranch);
|
|
2444
3795
|
const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
|
|
2445
3796
|
if (hasWork) {
|
|
@@ -2452,14 +3803,14 @@ var update_default = defineCommand9({
|
|
|
2452
3803
|
const SAVE_NEW_BRANCH = "Save changes to a new branch";
|
|
2453
3804
|
const DISCARD = "Discard all changes and clean up";
|
|
2454
3805
|
const CANCEL = "Cancel";
|
|
2455
|
-
const action = await selectPrompt(`${
|
|
3806
|
+
const action = await selectPrompt(`${pc15.bold(currentBranch)} is stale but has local work. What would you like to do?`, [SAVE_NEW_BRANCH, DISCARD, CANCEL]);
|
|
2456
3807
|
if (action === CANCEL) {
|
|
2457
3808
|
info("No changes made. You are still on your current branch.");
|
|
2458
3809
|
return;
|
|
2459
3810
|
}
|
|
2460
3811
|
if (action === SAVE_NEW_BRANCH) {
|
|
2461
|
-
info(
|
|
2462
|
-
const description = await inputPrompt("What are you
|
|
3812
|
+
info(pc15.dim("Tip: Describe what you're going to work on in plain English and we'll generate a branch name."));
|
|
3813
|
+
const description = await inputPrompt("What are you going to work on?");
|
|
2463
3814
|
let newBranchName = description;
|
|
2464
3815
|
if (!args["no-ai"] && looksLikeNaturalLanguage(description)) {
|
|
2465
3816
|
const spinner = createSpinner("Generating branch name suggestion...");
|
|
@@ -2467,8 +3818,8 @@ var update_default = defineCommand9({
|
|
|
2467
3818
|
if (suggested) {
|
|
2468
3819
|
spinner.success("Branch name suggestion ready.");
|
|
2469
3820
|
console.log(`
|
|
2470
|
-
${
|
|
2471
|
-
const accepted = await confirmPrompt(`Use ${
|
|
3821
|
+
${pc15.dim("AI suggestion:")} ${pc15.bold(pc15.cyan(suggested))}`);
|
|
3822
|
+
const accepted = await confirmPrompt(`Use ${pc15.bold(suggested)} as your branch name?`);
|
|
2472
3823
|
newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
|
|
2473
3824
|
} else {
|
|
2474
3825
|
spinner.fail("AI did not return a suggestion.");
|
|
@@ -2476,52 +3827,73 @@ var update_default = defineCommand9({
|
|
|
2476
3827
|
}
|
|
2477
3828
|
}
|
|
2478
3829
|
if (!hasPrefix(newBranchName, config.branchPrefixes)) {
|
|
2479
|
-
const prefix = await selectPrompt(`Choose a branch type for ${
|
|
3830
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc15.bold(newBranchName)}:`, config.branchPrefixes);
|
|
2480
3831
|
newBranchName = formatBranchName(prefix, newBranchName);
|
|
2481
3832
|
}
|
|
2482
3833
|
if (!isValidBranchName(newBranchName)) {
|
|
2483
3834
|
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
2484
3835
|
process.exit(1);
|
|
2485
3836
|
}
|
|
3837
|
+
const staleUpstream = await getUpstreamRef();
|
|
3838
|
+
const staleUpstreamHash = staleUpstream ? await getCommitHash(staleUpstream) : null;
|
|
3839
|
+
if (await branchExists(newBranchName)) {
|
|
3840
|
+
error(`Branch ${pc15.bold(newBranchName)} already exists. Choose a different name.`);
|
|
3841
|
+
process.exit(1);
|
|
3842
|
+
}
|
|
2486
3843
|
const renameResult = await renameBranch(currentBranch, newBranchName);
|
|
2487
3844
|
if (renameResult.exitCode !== 0) {
|
|
2488
3845
|
error(`Failed to rename branch: ${renameResult.stderr}`);
|
|
2489
3846
|
process.exit(1);
|
|
2490
3847
|
}
|
|
2491
|
-
success(`Renamed ${
|
|
3848
|
+
success(`Renamed ${pc15.bold(currentBranch)} → ${pc15.bold(newBranchName)}`);
|
|
3849
|
+
await unsetUpstream();
|
|
2492
3850
|
await fetchRemote(syncSource.remote);
|
|
2493
|
-
|
|
2494
|
-
|
|
3851
|
+
let rebaseResult2;
|
|
3852
|
+
if (staleUpstreamHash) {
|
|
3853
|
+
rebaseResult2 = await rebaseOnto(syncSource.ref, staleUpstreamHash);
|
|
3854
|
+
} else {
|
|
3855
|
+
const savedStrategy = await determineRebaseStrategy(newBranchName, syncSource.ref);
|
|
3856
|
+
rebaseResult2 = savedStrategy.strategy === "onto" && savedStrategy.ontoOldBase ? await rebaseOnto(syncSource.ref, savedStrategy.ontoOldBase) : await rebase(syncSource.ref);
|
|
3857
|
+
}
|
|
2495
3858
|
if (rebaseResult2.exitCode !== 0) {
|
|
2496
3859
|
warn("Rebase encountered conflicts. Resolve them manually, then run:");
|
|
2497
|
-
info(` ${
|
|
3860
|
+
info(` ${pc15.bold("git rebase --continue")}`);
|
|
2498
3861
|
} else {
|
|
2499
|
-
success(`Rebased ${
|
|
3862
|
+
success(`Rebased ${pc15.bold(newBranchName)} onto ${pc15.bold(syncSource.ref)}.`);
|
|
2500
3863
|
}
|
|
2501
|
-
info(`All your changes are preserved. Run ${
|
|
3864
|
+
info(`All your changes are preserved. Run ${pc15.bold("contrib submit")} when ready to create a new PR.`);
|
|
2502
3865
|
return;
|
|
2503
3866
|
}
|
|
2504
3867
|
warn("Discarding local changes...");
|
|
2505
3868
|
}
|
|
2506
3869
|
await fetchRemote(syncSource.remote);
|
|
3870
|
+
await resetHard("HEAD");
|
|
2507
3871
|
const coResult = await checkoutBranch(baseBranch);
|
|
2508
3872
|
if (coResult.exitCode !== 0) {
|
|
2509
3873
|
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
2510
3874
|
process.exit(1);
|
|
2511
3875
|
}
|
|
2512
3876
|
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
2513
|
-
success(`Synced ${
|
|
2514
|
-
info(`Deleting stale branch ${
|
|
3877
|
+
success(`Synced ${pc15.bold(baseBranch)} with ${pc15.bold(syncSource.ref)}.`);
|
|
3878
|
+
info(`Deleting stale branch ${pc15.bold(currentBranch)}...`);
|
|
2515
3879
|
await forceDeleteBranch(currentBranch);
|
|
2516
|
-
success(`Deleted ${
|
|
2517
|
-
info(`Run ${
|
|
3880
|
+
success(`Deleted ${pc15.bold(currentBranch)}.`);
|
|
3881
|
+
info(`Run ${pc15.bold("contrib start")} to begin a new feature branch.`);
|
|
2518
3882
|
return;
|
|
2519
3883
|
}
|
|
2520
|
-
info(`Updating ${
|
|
3884
|
+
info(`Updating ${pc15.bold(currentBranch)} with latest ${pc15.bold(baseBranch)}...`);
|
|
2521
3885
|
await fetchRemote(syncSource.remote);
|
|
3886
|
+
if (!await refExists(syncSource.ref)) {
|
|
3887
|
+
error(`Remote ref ${pc15.bold(syncSource.ref)} does not exist.`);
|
|
3888
|
+
error("Run `git fetch --all` and verify your remote configuration.");
|
|
3889
|
+
process.exit(1);
|
|
3890
|
+
}
|
|
2522
3891
|
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
2523
|
-
const
|
|
2524
|
-
|
|
3892
|
+
const rebaseStrategy = await determineRebaseStrategy(currentBranch, syncSource.ref);
|
|
3893
|
+
if (rebaseStrategy.strategy === "onto" && rebaseStrategy.ontoOldBase) {
|
|
3894
|
+
info(pc15.dim(`Using --onto rebase (branch was based on a different ref)`));
|
|
3895
|
+
}
|
|
3896
|
+
const rebaseResult = rebaseStrategy.strategy === "onto" && rebaseStrategy.ontoOldBase ? await rebaseOnto(syncSource.ref, rebaseStrategy.ontoOldBase) : await rebase(syncSource.ref);
|
|
2525
3897
|
if (rebaseResult.exitCode !== 0) {
|
|
2526
3898
|
warn("Rebase hit conflicts. Resolve them manually.");
|
|
2527
3899
|
console.log();
|
|
@@ -2548,10 +3920,10 @@ ${content.slice(0, 2000)}
|
|
|
2548
3920
|
if (suggestion) {
|
|
2549
3921
|
spinner.success("AI conflict guidance ready.");
|
|
2550
3922
|
console.log(`
|
|
2551
|
-
${
|
|
2552
|
-
console.log(
|
|
3923
|
+
${pc15.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
|
|
3924
|
+
console.log(pc15.dim("─".repeat(60)));
|
|
2553
3925
|
console.log(suggestion);
|
|
2554
|
-
console.log(
|
|
3926
|
+
console.log(pc15.dim("─".repeat(60)));
|
|
2555
3927
|
console.log();
|
|
2556
3928
|
} else {
|
|
2557
3929
|
spinner.fail("AI could not analyze the conflicts.");
|
|
@@ -2559,22 +3931,22 @@ ${pc12.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
|
|
|
2559
3931
|
}
|
|
2560
3932
|
}
|
|
2561
3933
|
}
|
|
2562
|
-
console.log(
|
|
3934
|
+
console.log(pc15.bold("To resolve:"));
|
|
2563
3935
|
console.log(` 1. Fix conflicts in the affected files`);
|
|
2564
|
-
console.log(` 2. ${
|
|
2565
|
-
console.log(` 3. ${
|
|
3936
|
+
console.log(` 2. ${pc15.cyan("git add <resolved-files>")}`);
|
|
3937
|
+
console.log(` 3. ${pc15.cyan("git rebase --continue")}`);
|
|
2566
3938
|
console.log();
|
|
2567
|
-
console.log(` Or abort: ${
|
|
3939
|
+
console.log(` Or abort: ${pc15.cyan("git rebase --abort")}`);
|
|
2568
3940
|
process.exit(1);
|
|
2569
3941
|
}
|
|
2570
|
-
success(`✅ ${
|
|
3942
|
+
success(`✅ ${pc15.bold(currentBranch)} has been rebased onto latest ${pc15.bold(baseBranch)}`);
|
|
2571
3943
|
}
|
|
2572
3944
|
});
|
|
2573
3945
|
|
|
2574
3946
|
// src/commands/validate.ts
|
|
2575
|
-
import { defineCommand as
|
|
2576
|
-
import
|
|
2577
|
-
var validate_default =
|
|
3947
|
+
import { defineCommand as defineCommand13 } from "citty";
|
|
3948
|
+
import pc16 from "picocolors";
|
|
3949
|
+
var validate_default = defineCommand13({
|
|
2578
3950
|
meta: {
|
|
2579
3951
|
name: "validate",
|
|
2580
3952
|
description: "Validate a commit message against the configured convention"
|
|
@@ -2604,7 +3976,7 @@ var validate_default = defineCommand10({
|
|
|
2604
3976
|
}
|
|
2605
3977
|
const errors = getValidationError(convention);
|
|
2606
3978
|
for (const line of errors) {
|
|
2607
|
-
console.error(
|
|
3979
|
+
console.error(pc16.red(` ✗ ${line}`));
|
|
2608
3980
|
}
|
|
2609
3981
|
process.exit(1);
|
|
2610
3982
|
}
|
|
@@ -2612,76 +3984,19 @@ var validate_default = defineCommand10({
|
|
|
2612
3984
|
|
|
2613
3985
|
// src/ui/banner.ts
|
|
2614
3986
|
import figlet from "figlet";
|
|
2615
|
-
import
|
|
2616
|
-
|
|
2617
|
-
var package_default = {
|
|
2618
|
-
name: "contribute-now",
|
|
2619
|
-
version: "0.2.0-dev.7c81c96",
|
|
2620
|
-
description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
|
|
2621
|
-
type: "module",
|
|
2622
|
-
bin: {
|
|
2623
|
-
contrib: "dist/index.js",
|
|
2624
|
-
contribute: "dist/index.js"
|
|
2625
|
-
},
|
|
2626
|
-
files: [
|
|
2627
|
-
"dist"
|
|
2628
|
-
],
|
|
2629
|
-
scripts: {
|
|
2630
|
-
build: "bun build src/index.ts --outfile dist/index.js --target node --packages external",
|
|
2631
|
-
cli: "bun run src/index.ts --",
|
|
2632
|
-
dev: "bun src/index.ts",
|
|
2633
|
-
test: "bun test",
|
|
2634
|
-
lint: "biome check .",
|
|
2635
|
-
"lint:fix": "biome check --write .",
|
|
2636
|
-
format: "biome format --write .",
|
|
2637
|
-
"www:dev": "bun run --cwd www dev",
|
|
2638
|
-
"www:build": "bun run --cwd www build",
|
|
2639
|
-
"www:preview": "bun run --cwd www preview"
|
|
2640
|
-
},
|
|
2641
|
-
engines: {
|
|
2642
|
-
node: ">=18",
|
|
2643
|
-
bun: ">=1.0"
|
|
2644
|
-
},
|
|
2645
|
-
keywords: [
|
|
2646
|
-
"git",
|
|
2647
|
-
"workflow",
|
|
2648
|
-
"squash-merge",
|
|
2649
|
-
"sync",
|
|
2650
|
-
"cli",
|
|
2651
|
-
"contribute",
|
|
2652
|
-
"fork",
|
|
2653
|
-
"dev-branch",
|
|
2654
|
-
"clean-commit"
|
|
2655
|
-
],
|
|
2656
|
-
author: "Waren Gonzaga",
|
|
2657
|
-
license: "GPL-3.0",
|
|
2658
|
-
repository: {
|
|
2659
|
-
type: "git",
|
|
2660
|
-
url: "git+https://github.com/warengonzaga/contribute-now.git"
|
|
2661
|
-
},
|
|
2662
|
-
dependencies: {
|
|
2663
|
-
"@clack/prompts": "^1.0.1",
|
|
2664
|
-
"@github/copilot-sdk": "^0.1.25",
|
|
2665
|
-
"@wgtechlabs/log-engine": "^2.3.1",
|
|
2666
|
-
citty: "^0.1.6",
|
|
2667
|
-
figlet: "^1.10.0",
|
|
2668
|
-
picocolors: "^1.1.1"
|
|
2669
|
-
},
|
|
2670
|
-
devDependencies: {
|
|
2671
|
-
"@biomejs/biome": "^2.4.4",
|
|
2672
|
-
"@types/bun": "latest",
|
|
2673
|
-
"@types/figlet": "^1.7.0",
|
|
2674
|
-
typescript: "^5.7.0"
|
|
2675
|
-
}
|
|
2676
|
-
};
|
|
2677
|
-
|
|
2678
|
-
// src/ui/banner.ts
|
|
2679
|
-
var LOGO;
|
|
3987
|
+
import pc17 from "picocolors";
|
|
3988
|
+
var LOGO_BIG;
|
|
2680
3989
|
try {
|
|
2681
|
-
|
|
3990
|
+
LOGO_BIG = figlet.textSync(`Contribute
|
|
2682
3991
|
Now`, { font: "ANSI Shadow" });
|
|
2683
3992
|
} catch {
|
|
2684
|
-
|
|
3993
|
+
LOGO_BIG = "Contribute Now";
|
|
3994
|
+
}
|
|
3995
|
+
var LOGO_SMALL;
|
|
3996
|
+
try {
|
|
3997
|
+
LOGO_SMALL = figlet.textSync("Contribute Now", { font: "Slant" });
|
|
3998
|
+
} catch {
|
|
3999
|
+
LOGO_SMALL = "Contribute Now";
|
|
2685
4000
|
}
|
|
2686
4001
|
function getVersion() {
|
|
2687
4002
|
return package_default.version ?? "unknown";
|
|
@@ -2689,23 +4004,44 @@ function getVersion() {
|
|
|
2689
4004
|
function getAuthor() {
|
|
2690
4005
|
return typeof package_default.author === "string" ? package_default.author : "unknown";
|
|
2691
4006
|
}
|
|
2692
|
-
function showBanner(
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
4007
|
+
function showBanner(variant = "small") {
|
|
4008
|
+
const logo = variant === "big" ? LOGO_BIG : LOGO_SMALL;
|
|
4009
|
+
console.log(pc17.cyan(`
|
|
4010
|
+
${logo}`));
|
|
4011
|
+
console.log(` ${pc17.dim(`v${getVersion()}`)} ${pc17.dim("—")} ${pc17.dim(`Built by ${getAuthor()}`)}`);
|
|
4012
|
+
if (variant === "big") {
|
|
2697
4013
|
console.log();
|
|
2698
|
-
console.log(` ${
|
|
2699
|
-
console.log(` ${
|
|
2700
|
-
console.log(` ${
|
|
4014
|
+
console.log(` ${pc17.yellow("Star")} ${pc17.cyan("https://github.com/warengonzaga/contribute-now")}`);
|
|
4015
|
+
console.log(` ${pc17.green("Contribute")} ${pc17.cyan("https://github.com/warengonzaga/contribute-now/blob/main/CONTRIBUTING.md")}`);
|
|
4016
|
+
console.log(` ${pc17.magenta("Sponsor")} ${pc17.cyan("https://warengonzaga.com/sponsor")}`);
|
|
2701
4017
|
}
|
|
2702
4018
|
console.log();
|
|
2703
4019
|
}
|
|
2704
4020
|
|
|
2705
4021
|
// src/index.ts
|
|
2706
|
-
var
|
|
2707
|
-
|
|
2708
|
-
|
|
4022
|
+
var isVersion = process.argv.includes("--version") || process.argv.includes("-v");
|
|
4023
|
+
if (!isVersion) {
|
|
4024
|
+
const subCommands = [
|
|
4025
|
+
"setup",
|
|
4026
|
+
"sync",
|
|
4027
|
+
"start",
|
|
4028
|
+
"commit",
|
|
4029
|
+
"update",
|
|
4030
|
+
"submit",
|
|
4031
|
+
"clean",
|
|
4032
|
+
"status",
|
|
4033
|
+
"log",
|
|
4034
|
+
"branch",
|
|
4035
|
+
"hook",
|
|
4036
|
+
"validate",
|
|
4037
|
+
"doctor"
|
|
4038
|
+
];
|
|
4039
|
+
const isHelp = process.argv.includes("--help") || process.argv.includes("-h");
|
|
4040
|
+
const hasSubCommand = subCommands.some((cmd) => process.argv.includes(cmd));
|
|
4041
|
+
const useBigBanner = isHelp || !hasSubCommand;
|
|
4042
|
+
showBanner(useBigBanner ? "big" : "small");
|
|
4043
|
+
}
|
|
4044
|
+
var main = defineCommand14({
|
|
2709
4045
|
meta: {
|
|
2710
4046
|
name: "contrib",
|
|
2711
4047
|
version: getVersion(),
|
|
@@ -2725,10 +4061,13 @@ var main = defineCommand11({
|
|
|
2725
4061
|
commit: commit_default,
|
|
2726
4062
|
update: update_default,
|
|
2727
4063
|
submit: submit_default,
|
|
4064
|
+
branch: branch_default,
|
|
2728
4065
|
clean: clean_default,
|
|
2729
4066
|
status: status_default,
|
|
4067
|
+
log: log_default,
|
|
2730
4068
|
hook: hook_default,
|
|
2731
|
-
validate: validate_default
|
|
4069
|
+
validate: validate_default,
|
|
4070
|
+
doctor: doctor_default
|
|
2732
4071
|
},
|
|
2733
4072
|
run({ args }) {
|
|
2734
4073
|
if (args.version) {
|