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