contribute-now 0.2.0-dev.e0cfab8 → 0.2.0-pr.17db3d2
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 +1298 -305
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -20,7 +20,11 @@ function readConfig(cwd = process.cwd()) {
|
|
|
20
20
|
return null;
|
|
21
21
|
try {
|
|
22
22
|
const raw = readFileSync(path, "utf-8");
|
|
23
|
-
|
|
23
|
+
const parsed = JSON.parse(raw);
|
|
24
|
+
if (typeof parsed !== "object" || parsed === null || typeof parsed.workflow !== "string" || typeof parsed.role !== "string" || typeof parsed.mainBranch !== "string" || typeof parsed.upstream !== "string" || typeof parsed.origin !== "string" || !Array.isArray(parsed.branchPrefixes) || typeof parsed.commitConvention !== "string") {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
return parsed;
|
|
24
28
|
} catch {
|
|
25
29
|
return null;
|
|
26
30
|
}
|
|
@@ -56,68 +60,55 @@ function getDefaultConfig() {
|
|
|
56
60
|
}
|
|
57
61
|
|
|
58
62
|
// src/utils/confirm.ts
|
|
63
|
+
import * as clack from "@clack/prompts";
|
|
59
64
|
import pc from "picocolors";
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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;
|
|
65
|
+
function handleCancel(value) {
|
|
66
|
+
if (clack.isCancel(value)) {
|
|
67
|
+
clack.cancel("Cancelled.");
|
|
68
|
+
process.exit(0);
|
|
75
69
|
}
|
|
76
|
-
|
|
70
|
+
}
|
|
71
|
+
async function confirmPrompt(message) {
|
|
72
|
+
const result = await clack.confirm({ message });
|
|
73
|
+
handleCancel(result);
|
|
74
|
+
return result;
|
|
77
75
|
}
|
|
78
76
|
async function selectPrompt(message, choices) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
console.log(` ${pc.dim(`${i + 1}.`)} ${choice}`);
|
|
77
|
+
const result = await clack.select({
|
|
78
|
+
message,
|
|
79
|
+
options: choices.map((choice) => ({ value: choice, label: choice }))
|
|
83
80
|
});
|
|
84
|
-
|
|
85
|
-
|
|
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];
|
|
81
|
+
handleCancel(result);
|
|
82
|
+
return result;
|
|
98
83
|
}
|
|
99
84
|
async function inputPrompt(message, defaultValue) {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
85
|
+
const result = await clack.text({
|
|
86
|
+
message,
|
|
87
|
+
placeholder: defaultValue,
|
|
88
|
+
defaultValue
|
|
89
|
+
});
|
|
90
|
+
handleCancel(result);
|
|
91
|
+
return result || defaultValue || "";
|
|
92
|
+
}
|
|
93
|
+
async function multiSelectPrompt(message, choices) {
|
|
94
|
+
const result = await clack.multiselect({
|
|
95
|
+
message: `${message} ${pc.dim("(space to toggle, enter to confirm)")}`,
|
|
96
|
+
options: choices.map((choice) => ({ value: choice, label: choice })),
|
|
97
|
+
required: false
|
|
110
98
|
});
|
|
111
|
-
|
|
99
|
+
handleCancel(result);
|
|
100
|
+
return result;
|
|
112
101
|
}
|
|
113
102
|
|
|
114
103
|
// src/utils/git.ts
|
|
115
104
|
import { execFile as execFileCb } from "node:child_process";
|
|
105
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
106
|
+
import { join as join2 } from "node:path";
|
|
116
107
|
function run(args) {
|
|
117
108
|
return new Promise((resolve) => {
|
|
118
109
|
execFileCb("git", args, (error, stdout, stderr) => {
|
|
119
110
|
resolve({
|
|
120
|
-
exitCode: error ? error.code === "ENOENT" ? 127 : error.
|
|
111
|
+
exitCode: error ? error.code === "ENOENT" ? 127 : error.status ?? 1 : 0,
|
|
121
112
|
stdout: stdout ?? "",
|
|
122
113
|
stderr: stderr ?? ""
|
|
123
114
|
});
|
|
@@ -169,12 +160,28 @@ async function createBranch(branch, from) {
|
|
|
169
160
|
async function resetHard(ref) {
|
|
170
161
|
return run(["reset", "--hard", ref]);
|
|
171
162
|
}
|
|
163
|
+
async function updateLocalBranch(branch, target) {
|
|
164
|
+
const current = await getCurrentBranch();
|
|
165
|
+
if (current === branch) {
|
|
166
|
+
return resetHard(target);
|
|
167
|
+
}
|
|
168
|
+
return run(["branch", "-f", branch, target]);
|
|
169
|
+
}
|
|
172
170
|
async function pushSetUpstream(remote, branch) {
|
|
173
171
|
return run(["push", "-u", remote, branch]);
|
|
174
172
|
}
|
|
175
173
|
async function rebase(branch) {
|
|
176
174
|
return run(["rebase", branch]);
|
|
177
175
|
}
|
|
176
|
+
async function getUpstreamRef() {
|
|
177
|
+
const { exitCode, stdout } = await run(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
178
|
+
if (exitCode !== 0)
|
|
179
|
+
return null;
|
|
180
|
+
return stdout.trim() || null;
|
|
181
|
+
}
|
|
182
|
+
async function rebaseOnto(newBase, oldBase) {
|
|
183
|
+
return run(["rebase", "--onto", newBase, oldBase]);
|
|
184
|
+
}
|
|
178
185
|
async function getStagedDiff() {
|
|
179
186
|
const { stdout } = await run(["diff", "--cached"]);
|
|
180
187
|
return stdout;
|
|
@@ -190,8 +197,16 @@ async function getChangedFiles() {
|
|
|
190
197
|
const { exitCode, stdout } = await run(["status", "--porcelain"]);
|
|
191
198
|
if (exitCode !== 0)
|
|
192
199
|
return [];
|
|
193
|
-
return stdout.
|
|
194
|
-
`).filter(Boolean).map((l) =>
|
|
200
|
+
return stdout.trimEnd().split(`
|
|
201
|
+
`).filter(Boolean).map((l) => {
|
|
202
|
+
const line = l.replace(/\r$/, "");
|
|
203
|
+
const match = line.match(/^..\s+(.*)/);
|
|
204
|
+
if (!match)
|
|
205
|
+
return "";
|
|
206
|
+
const file = match[1];
|
|
207
|
+
const renameIdx = file.indexOf(" -> ");
|
|
208
|
+
return renameIdx !== -1 ? file.slice(renameIdx + 4) : file;
|
|
209
|
+
}).filter(Boolean);
|
|
195
210
|
}
|
|
196
211
|
async function getDivergence(branch, base) {
|
|
197
212
|
const { exitCode, stdout } = await run([
|
|
@@ -215,9 +230,38 @@ async function getMergedBranches(base) {
|
|
|
215
230
|
return stdout.trim().split(`
|
|
216
231
|
`).map((b) => b.replace(/^\*?\s+/, "").trim()).filter(Boolean);
|
|
217
232
|
}
|
|
233
|
+
async function getGoneBranches() {
|
|
234
|
+
const { exitCode, stdout } = await run(["branch", "-vv"]);
|
|
235
|
+
if (exitCode !== 0)
|
|
236
|
+
return [];
|
|
237
|
+
return stdout.trimEnd().split(`
|
|
238
|
+
`).filter((line) => line.includes(": gone]")).map((line) => line.replace(/^\*?\s+/, "").split(/\s+/)[0]).filter(Boolean);
|
|
239
|
+
}
|
|
218
240
|
async function deleteBranch(branch) {
|
|
219
241
|
return run(["branch", "-d", branch]);
|
|
220
242
|
}
|
|
243
|
+
async function forceDeleteBranch(branch) {
|
|
244
|
+
return run(["branch", "-D", branch]);
|
|
245
|
+
}
|
|
246
|
+
async function renameBranch(oldName, newName) {
|
|
247
|
+
return run(["branch", "-m", oldName, newName]);
|
|
248
|
+
}
|
|
249
|
+
async function hasLocalWork(remote, branch) {
|
|
250
|
+
const uncommitted = await hasUncommittedChanges();
|
|
251
|
+
const trackingRef = `${remote}/${branch}`;
|
|
252
|
+
const { exitCode, stdout } = await run(["rev-list", "--count", `${trackingRef}..${branch}`]);
|
|
253
|
+
const unpushedCommits = exitCode === 0 ? Number.parseInt(stdout.trim(), 10) || 0 : 0;
|
|
254
|
+
return { uncommitted, unpushedCommits };
|
|
255
|
+
}
|
|
256
|
+
async function deleteRemoteBranch(remote, branch) {
|
|
257
|
+
return run(["push", remote, "--delete", branch]);
|
|
258
|
+
}
|
|
259
|
+
async function mergeSquash(branch) {
|
|
260
|
+
return run(["merge", "--squash", branch]);
|
|
261
|
+
}
|
|
262
|
+
async function pushBranch(remote, branch) {
|
|
263
|
+
return run(["push", remote, branch]);
|
|
264
|
+
}
|
|
221
265
|
async function pruneRemote(remote) {
|
|
222
266
|
return run(["remote", "prune", remote]);
|
|
223
267
|
}
|
|
@@ -238,6 +282,85 @@ async function getLog(base, head) {
|
|
|
238
282
|
async function pullBranch(remote, branch) {
|
|
239
283
|
return run(["pull", remote, branch]);
|
|
240
284
|
}
|
|
285
|
+
async function stageFiles(files) {
|
|
286
|
+
return run(["add", "--", ...files]);
|
|
287
|
+
}
|
|
288
|
+
async function unstageFiles(files) {
|
|
289
|
+
return run(["reset", "HEAD", "--", ...files]);
|
|
290
|
+
}
|
|
291
|
+
async function stageAll() {
|
|
292
|
+
return run(["add", "-A"]);
|
|
293
|
+
}
|
|
294
|
+
async function getFullDiffForFiles(files) {
|
|
295
|
+
const [unstaged, staged, untracked] = await Promise.all([
|
|
296
|
+
run(["diff", "--", ...files]),
|
|
297
|
+
run(["diff", "--cached", "--", ...files]),
|
|
298
|
+
getUntrackedFiles()
|
|
299
|
+
]);
|
|
300
|
+
const parts = [staged.stdout, unstaged.stdout].filter(Boolean);
|
|
301
|
+
const untrackedSet = new Set(untracked);
|
|
302
|
+
const MAX_FILE_CONTENT = 2000;
|
|
303
|
+
for (const file of files) {
|
|
304
|
+
if (untrackedSet.has(file)) {
|
|
305
|
+
try {
|
|
306
|
+
const content = readFileSync2(join2(process.cwd(), file), "utf-8");
|
|
307
|
+
const truncated = content.length > MAX_FILE_CONTENT ? `${content.slice(0, MAX_FILE_CONTENT)}
|
|
308
|
+
... (truncated)` : content;
|
|
309
|
+
const lines = truncated.split(`
|
|
310
|
+
`).map((l) => `+${l}`);
|
|
311
|
+
parts.push(`diff --git a/${file} b/${file}
|
|
312
|
+
new file
|
|
313
|
+
--- /dev/null
|
|
314
|
+
+++ b/${file}
|
|
315
|
+
${lines.join(`
|
|
316
|
+
`)}`);
|
|
317
|
+
} catch {}
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
return parts.join(`
|
|
321
|
+
`);
|
|
322
|
+
}
|
|
323
|
+
async function getUntrackedFiles() {
|
|
324
|
+
const { exitCode, stdout } = await run(["ls-files", "--others", "--exclude-standard"]);
|
|
325
|
+
if (exitCode !== 0)
|
|
326
|
+
return [];
|
|
327
|
+
return stdout.trim().split(`
|
|
328
|
+
`).filter(Boolean);
|
|
329
|
+
}
|
|
330
|
+
async function getFileStatus() {
|
|
331
|
+
const { exitCode, stdout } = await run(["status", "--porcelain"]);
|
|
332
|
+
if (exitCode !== 0)
|
|
333
|
+
return { staged: [], modified: [], untracked: [] };
|
|
334
|
+
const result = { staged: [], modified: [], untracked: [] };
|
|
335
|
+
const STATUS_LABELS = {
|
|
336
|
+
A: "new file",
|
|
337
|
+
M: "modified",
|
|
338
|
+
D: "deleted",
|
|
339
|
+
R: "renamed",
|
|
340
|
+
C: "copied",
|
|
341
|
+
T: "type changed"
|
|
342
|
+
};
|
|
343
|
+
for (const raw of stdout.trimEnd().split(`
|
|
344
|
+
`).filter(Boolean)) {
|
|
345
|
+
const line = raw.replace(/\r$/, "");
|
|
346
|
+
const indexStatus = line[0];
|
|
347
|
+
const workTreeStatus = line[1];
|
|
348
|
+
const pathPart = line.slice(3);
|
|
349
|
+
const renameIdx = pathPart.indexOf(" -> ");
|
|
350
|
+
const file = renameIdx !== -1 ? pathPart.slice(renameIdx + 4) : pathPart;
|
|
351
|
+
if (indexStatus === "?" && workTreeStatus === "?") {
|
|
352
|
+
result.untracked.push(file);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
if (indexStatus && indexStatus !== " " && indexStatus !== "?") {
|
|
356
|
+
result.staged.push({ file, status: STATUS_LABELS[indexStatus] ?? indexStatus });
|
|
357
|
+
}
|
|
358
|
+
if (workTreeStatus && workTreeStatus !== " " && workTreeStatus !== "?") {
|
|
359
|
+
result.modified.push({ file, status: STATUS_LABELS[workTreeStatus] ?? workTreeStatus });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return result;
|
|
363
|
+
}
|
|
241
364
|
|
|
242
365
|
// src/utils/logger.ts
|
|
243
366
|
import { LogEngine, LogMode } from "@wgtechlabs/log-engine";
|
|
@@ -315,6 +438,66 @@ function getProtectedBranches(config) {
|
|
|
315
438
|
}
|
|
316
439
|
|
|
317
440
|
// src/commands/clean.ts
|
|
441
|
+
async function handleCurrentBranchDeletion(currentBranch, baseBranch, config) {
|
|
442
|
+
if (!config)
|
|
443
|
+
return "skipped";
|
|
444
|
+
const { origin } = config;
|
|
445
|
+
const localWork = await hasLocalWork(origin, currentBranch);
|
|
446
|
+
const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
|
|
447
|
+
if (hasWork) {
|
|
448
|
+
if (localWork.uncommitted) {
|
|
449
|
+
warn("You have uncommitted changes in your working tree.");
|
|
450
|
+
}
|
|
451
|
+
if (localWork.unpushedCommits > 0) {
|
|
452
|
+
warn(`You have ${pc3.bold(String(localWork.unpushedCommits))} local commit${localWork.unpushedCommits !== 1 ? "s" : ""} not pushed.`);
|
|
453
|
+
}
|
|
454
|
+
const SAVE_NEW_BRANCH = "Save changes to a new branch";
|
|
455
|
+
const DISCARD = "Discard all changes and clean up";
|
|
456
|
+
const CANCEL = "Skip this branch";
|
|
457
|
+
const action = await selectPrompt(`${pc3.bold(currentBranch)} has local changes. What would you like to do?`, [SAVE_NEW_BRANCH, DISCARD, CANCEL]);
|
|
458
|
+
if (action === CANCEL)
|
|
459
|
+
return "skipped";
|
|
460
|
+
if (action === SAVE_NEW_BRANCH) {
|
|
461
|
+
const suggestedName = currentBranch.replace(/^(feature|fix|docs|chore|test|refactor)\//, "$1/new-");
|
|
462
|
+
const newBranchName = await inputPrompt("New branch name", suggestedName !== currentBranch ? suggestedName : `${currentBranch}-v2`);
|
|
463
|
+
const renameResult = await renameBranch(currentBranch, newBranchName);
|
|
464
|
+
if (renameResult.exitCode !== 0) {
|
|
465
|
+
error(`Failed to rename branch: ${renameResult.stderr}`);
|
|
466
|
+
return "skipped";
|
|
467
|
+
}
|
|
468
|
+
success(`Renamed ${pc3.bold(currentBranch)} → ${pc3.bold(newBranchName)}`);
|
|
469
|
+
const syncSource2 = getSyncSource(config);
|
|
470
|
+
await fetchRemote(syncSource2.remote);
|
|
471
|
+
const savedUpstreamRef = await getUpstreamRef();
|
|
472
|
+
const rebaseResult = savedUpstreamRef && savedUpstreamRef !== syncSource2.ref ? await rebaseOnto(syncSource2.ref, savedUpstreamRef) : await rebase(syncSource2.ref);
|
|
473
|
+
if (rebaseResult.exitCode !== 0) {
|
|
474
|
+
warn("Rebase encountered conflicts. Resolve them after cleanup:");
|
|
475
|
+
info(` ${pc3.bold(`git checkout ${newBranchName} && git rebase --continue`)}`);
|
|
476
|
+
} else {
|
|
477
|
+
success(`Rebased ${pc3.bold(newBranchName)} onto ${pc3.bold(syncSource2.ref)}.`);
|
|
478
|
+
}
|
|
479
|
+
const coResult2 = await checkoutBranch(baseBranch);
|
|
480
|
+
if (coResult2.exitCode !== 0) {
|
|
481
|
+
error(`Failed to checkout ${baseBranch}: ${coResult2.stderr}`);
|
|
482
|
+
return "saved";
|
|
483
|
+
}
|
|
484
|
+
await updateLocalBranch(baseBranch, syncSource2.ref);
|
|
485
|
+
success(`Synced ${pc3.bold(baseBranch)} with ${pc3.bold(syncSource2.ref)}.`);
|
|
486
|
+
return "saved";
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
const syncSource = getSyncSource(config);
|
|
490
|
+
info(`Switching to ${pc3.bold(baseBranch)} and syncing...`);
|
|
491
|
+
await fetchRemote(syncSource.remote);
|
|
492
|
+
const coResult = await checkoutBranch(baseBranch);
|
|
493
|
+
if (coResult.exitCode !== 0) {
|
|
494
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
495
|
+
return "skipped";
|
|
496
|
+
}
|
|
497
|
+
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
498
|
+
success(`Synced ${pc3.bold(baseBranch)} with ${pc3.bold(syncSource.ref)}.`);
|
|
499
|
+
return "switched";
|
|
500
|
+
}
|
|
318
501
|
var clean_default = defineCommand({
|
|
319
502
|
meta: {
|
|
320
503
|
name: "clean",
|
|
@@ -340,25 +523,43 @@ var clean_default = defineCommand({
|
|
|
340
523
|
}
|
|
341
524
|
const { origin } = config;
|
|
342
525
|
const baseBranch = getBaseBranch(config);
|
|
343
|
-
|
|
526
|
+
let currentBranch = await getCurrentBranch();
|
|
344
527
|
heading("\uD83E\uDDF9 contrib clean");
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
info("No merged branches to clean up.");
|
|
528
|
+
info(`Pruning ${origin} remote refs...`);
|
|
529
|
+
const pruneResult = await pruneRemote(origin);
|
|
530
|
+
if (pruneResult.exitCode === 0) {
|
|
531
|
+
success(`Pruned ${origin} remote refs.`);
|
|
350
532
|
} else {
|
|
533
|
+
warn(`Could not prune remote: ${pruneResult.stderr.trim()}`);
|
|
534
|
+
}
|
|
535
|
+
const protectedBranches = new Set(getProtectedBranches(config));
|
|
536
|
+
const mergedBranches = await getMergedBranches(baseBranch);
|
|
537
|
+
const mergedCandidates = mergedBranches.filter((b) => !protectedBranches.has(b));
|
|
538
|
+
const goneBranches = await getGoneBranches();
|
|
539
|
+
const goneCandidates = goneBranches.filter((b) => !protectedBranches.has(b) && !mergedCandidates.includes(b));
|
|
540
|
+
if (mergedCandidates.length > 0) {
|
|
351
541
|
console.log(`
|
|
352
|
-
${pc3.bold("
|
|
353
|
-
for (const b of
|
|
354
|
-
|
|
542
|
+
${pc3.bold("Merged branches to delete:")}`);
|
|
543
|
+
for (const b of mergedCandidates) {
|
|
544
|
+
const marker = b === currentBranch ? pc3.yellow(" (current)") : "";
|
|
545
|
+
console.log(` ${pc3.dim("•")} ${b}${marker}`);
|
|
355
546
|
}
|
|
356
547
|
console.log();
|
|
357
|
-
const ok = args.yes || await confirmPrompt(`Delete ${pc3.bold(String(
|
|
358
|
-
if (
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
548
|
+
const ok = args.yes || await confirmPrompt(`Delete ${pc3.bold(String(mergedCandidates.length))} merged branch${mergedCandidates.length !== 1 ? "es" : ""}?`);
|
|
549
|
+
if (ok) {
|
|
550
|
+
for (const branch of mergedCandidates) {
|
|
551
|
+
if (branch === currentBranch) {
|
|
552
|
+
const result2 = await handleCurrentBranchDeletion(currentBranch, baseBranch, config);
|
|
553
|
+
if (result2 === "skipped") {
|
|
554
|
+
warn(` Skipped ${branch}.`);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
557
|
+
if (result2 === "saved") {
|
|
558
|
+
currentBranch = baseBranch;
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
currentBranch = baseBranch;
|
|
562
|
+
}
|
|
362
563
|
const result = await deleteBranch(branch);
|
|
363
564
|
if (result.exitCode === 0) {
|
|
364
565
|
success(` Deleted ${pc3.bold(branch)}`);
|
|
@@ -366,21 +567,58 @@ ${pc3.bold("Branches to delete:")}`);
|
|
|
366
567
|
warn(` Failed to delete ${branch}: ${result.stderr.trim()}`);
|
|
367
568
|
}
|
|
368
569
|
}
|
|
570
|
+
} else {
|
|
571
|
+
info("Skipped merged branch deletion.");
|
|
369
572
|
}
|
|
370
573
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
574
|
+
if (goneCandidates.length > 0) {
|
|
575
|
+
console.log(`
|
|
576
|
+
${pc3.bold("Stale branches (remote deleted, likely squash-merged):")}`);
|
|
577
|
+
for (const b of goneCandidates) {
|
|
578
|
+
const marker = b === currentBranch ? pc3.yellow(" (current)") : "";
|
|
579
|
+
console.log(` ${pc3.dim("•")} ${b}${marker}`);
|
|
580
|
+
}
|
|
581
|
+
console.log();
|
|
582
|
+
const ok = args.yes || await confirmPrompt(`Delete ${pc3.bold(String(goneCandidates.length))} stale branch${goneCandidates.length !== 1 ? "es" : ""}?`);
|
|
583
|
+
if (ok) {
|
|
584
|
+
for (const branch of goneCandidates) {
|
|
585
|
+
if (branch === currentBranch) {
|
|
586
|
+
const result2 = await handleCurrentBranchDeletion(currentBranch, baseBranch, config);
|
|
587
|
+
if (result2 === "skipped") {
|
|
588
|
+
warn(` Skipped ${branch}.`);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
if (result2 === "saved") {
|
|
592
|
+
currentBranch = baseBranch;
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
currentBranch = baseBranch;
|
|
596
|
+
}
|
|
597
|
+
const result = await forceDeleteBranch(branch);
|
|
598
|
+
if (result.exitCode === 0) {
|
|
599
|
+
success(` Deleted ${pc3.bold(branch)}`);
|
|
600
|
+
} else {
|
|
601
|
+
warn(` Failed to delete ${branch}: ${result.stderr.trim()}`);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
} else {
|
|
605
|
+
info("Skipped stale branch deletion.");
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (mergedCandidates.length === 0 && goneCandidates.length === 0) {
|
|
609
|
+
info("No branches to clean up. Everything is tidy! \uD83E\uDDF9");
|
|
610
|
+
}
|
|
611
|
+
const finalBranch = await getCurrentBranch();
|
|
612
|
+
if (finalBranch && protectedBranches.has(finalBranch)) {
|
|
613
|
+
console.log();
|
|
614
|
+
info(`You're on ${pc3.bold(finalBranch)}. Run ${pc3.bold("contrib start")} to begin a new feature.`);
|
|
377
615
|
}
|
|
378
616
|
}
|
|
379
617
|
});
|
|
380
618
|
|
|
381
619
|
// src/commands/commit.ts
|
|
382
620
|
import { defineCommand as defineCommand2 } from "citty";
|
|
383
|
-
import
|
|
621
|
+
import pc5 from "picocolors";
|
|
384
622
|
|
|
385
623
|
// src/utils/convention.ts
|
|
386
624
|
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;
|
|
@@ -427,96 +665,99 @@ function getValidationError(convention) {
|
|
|
427
665
|
|
|
428
666
|
// src/utils/copilot.ts
|
|
429
667
|
import { CopilotClient } from "@github/copilot-sdk";
|
|
430
|
-
var CONVENTIONAL_COMMIT_SYSTEM_PROMPT = `
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
docs
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
ci
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
Rules:
|
|
447
|
-
- Breaking change (!) only for: feat, fix, refactor, perf
|
|
448
|
-
- Description: concise, imperative mood, max 72 chars, lowercase start
|
|
449
|
-
- Scope: optional, camelCase or kebab-case component name
|
|
450
|
-
- Return ONLY the commit message line, nothing else
|
|
668
|
+
var CONVENTIONAL_COMMIT_SYSTEM_PROMPT = `Git commit message generator. Format: <type>[!][(<scope>)]: <description>
|
|
669
|
+
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
|
670
|
+
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.
|
|
671
|
+
Examples: feat: add user auth | fix(auth): resolve token expiry | feat!: redesign auth API`;
|
|
672
|
+
var CLEAN_COMMIT_SYSTEM_PROMPT = `Git commit message generator. EXACT format: <emoji> <type>[!][ (<scope>)]: <description>
|
|
673
|
+
Spacing: EMOJI SPACE TYPE [SPACE OPENPAREN SCOPE CLOSEPAREN] COLON SPACE DESCRIPTION
|
|
674
|
+
Types: \uD83D\uDCE6 new, \uD83D\uDD27 update, \uD83D\uDDD1️ remove, \uD83D\uDD12 security, ⚙️ setup, ☕ chore, \uD83E\uDDEA test, \uD83D\uDCD6 docs, \uD83D\uDE80 release
|
|
675
|
+
Rules: breaking (!) only for new/update/remove/security; imperative mood; max 72 chars; lowercase start; scope optional. Return ONLY the message line.
|
|
676
|
+
Correct: \uD83D\uDCE6 new: add user auth | \uD83D\uDD27 update (api): improve error handling | ⚙️ setup (ci): configure github actions
|
|
677
|
+
WRONG: ⚙️setup(ci): ... | \uD83D\uDD27 update(api): ... ← always space before scope parenthesis`;
|
|
678
|
+
function getGroupingSystemPrompt(convention) {
|
|
679
|
+
const conventionBlock = convention === "conventional" ? `Use Conventional Commit format: <type>[(<scope>)]: <description>
|
|
680
|
+
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert` : `Use Clean Commit format: <emoji> <type>[!][ (<scope>)]: <description>
|
|
681
|
+
Emoji/type table:
|
|
682
|
+
\uD83D\uDCE6 new, \uD83D\uDD27 update, \uD83D\uDDD1️ remove, \uD83D\uDD12 security, ⚙️ setup, ☕ chore, \uD83E\uDDEA test, \uD83D\uDCD6 docs, \uD83D\uDE80 release`;
|
|
683
|
+
return `You are a smart commit grouping assistant. Given a list of changed files and their diffs, group related changes into logical atomic commits.
|
|
451
684
|
|
|
452
|
-
|
|
453
|
-
feat: add user authentication system
|
|
454
|
-
fix(auth): resolve token expiry issue
|
|
455
|
-
docs: update contributing guidelines
|
|
456
|
-
feat!: redesign authentication API`;
|
|
457
|
-
var CLEAN_COMMIT_SYSTEM_PROMPT = `You are a git commit message generator. Generate a Clean Commit message following this exact format:
|
|
458
|
-
<emoji> <type>[!][(<scope>)]: <description>
|
|
685
|
+
${conventionBlock}
|
|
459
686
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
\uD83E\uDDEA test – adding or updating tests
|
|
468
|
-
\uD83D\uDCD6 docs – documentation changes
|
|
469
|
-
\uD83D\uDE80 release – version releases
|
|
687
|
+
Return a JSON array of commit groups with this EXACT structure (no markdown fences, no explanation):
|
|
688
|
+
[
|
|
689
|
+
{
|
|
690
|
+
"files": ["path/to/file1.ts", "path/to/file2.ts"],
|
|
691
|
+
"message": "<commit message following the convention above>"
|
|
692
|
+
}
|
|
693
|
+
]
|
|
470
694
|
|
|
471
695
|
Rules:
|
|
472
|
-
-
|
|
473
|
-
-
|
|
474
|
-
-
|
|
475
|
-
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
⚙️ setup (ci): configure github actions workflow
|
|
481
|
-
\uD83D\uDCE6 new!: completely redesign authentication system`;
|
|
482
|
-
var BRANCH_NAME_SYSTEM_PROMPT = `You are a git branch name generator. Convert natural language descriptions into proper git branch names.
|
|
483
|
-
|
|
484
|
-
Format: <prefix>/<kebab-case-name>
|
|
696
|
+
- Group files that are logically related (e.g. a utility and its tests, a feature and its types)
|
|
697
|
+
- Each group should represent ONE logical change
|
|
698
|
+
- Every file must appear in exactly one group
|
|
699
|
+
- Commit messages must follow the convention, be concise, imperative, max 72 chars
|
|
700
|
+
- Order groups so foundational changes come first (types, utils) and consumers come after
|
|
701
|
+
- Return ONLY the JSON array, nothing else`;
|
|
702
|
+
}
|
|
703
|
+
var BRANCH_NAME_SYSTEM_PROMPT = `Git branch name generator. Format: <prefix>/<kebab-case-name>
|
|
485
704
|
Prefixes: feature, fix, docs, chore, test, refactor
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
{
|
|
500
|
-
|
|
501
|
-
|
|
705
|
+
Rules: lowercase kebab-case, 2-5 words max. Return ONLY the branch name.
|
|
706
|
+
Examples: fix/login-timeout | feature/user-profile-page | docs/update-readme`;
|
|
707
|
+
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..."}
|
|
708
|
+
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.`;
|
|
709
|
+
function getPRDescriptionSystemPrompt(convention) {
|
|
710
|
+
if (convention === "clean-commit") {
|
|
711
|
+
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
712
|
+
CRITICAL: The PR title MUST follow the Clean Commit format exactly: <emoji> <type>: <description>
|
|
713
|
+
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
|
|
714
|
+
Title examples: \uD83D\uDCE6 new: add user authentication | \uD83D\uDD27 update: improve error handling | \uD83D\uDDD1️ remove: drop legacy API
|
|
715
|
+
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.`;
|
|
716
|
+
}
|
|
717
|
+
if (convention === "conventional") {
|
|
718
|
+
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
719
|
+
CRITICAL: The PR title MUST follow Conventional Commits format: <type>[(<scope>)]: <description>
|
|
720
|
+
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
|
721
|
+
Title examples: feat: add user authentication | fix(auth): resolve token expiry | docs: update contributing guide
|
|
722
|
+
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.`;
|
|
723
|
+
}
|
|
724
|
+
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
725
|
+
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.`;
|
|
726
|
+
}
|
|
727
|
+
var CONFLICT_RESOLUTION_SYSTEM_PROMPT = `Git merge conflict advisor. Explain each side, suggest resolution strategy. Never auto-resolve — guidance only. Be concise and actionable.`;
|
|
728
|
+
function suppressSubprocessWarnings() {
|
|
729
|
+
process.env.NODE_NO_WARNINGS = "1";
|
|
730
|
+
}
|
|
731
|
+
function withTimeout(promise, ms) {
|
|
732
|
+
return new Promise((resolve, reject) => {
|
|
733
|
+
const timer = setTimeout(() => reject(new Error(`Copilot request timed out after ${ms / 1000}s`)), ms);
|
|
734
|
+
promise.then((val) => {
|
|
735
|
+
clearTimeout(timer);
|
|
736
|
+
resolve(val);
|
|
737
|
+
}, (err) => {
|
|
738
|
+
clearTimeout(timer);
|
|
739
|
+
reject(err);
|
|
740
|
+
});
|
|
741
|
+
});
|
|
502
742
|
}
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
- title: concise, present tense, describes what the PR does
|
|
506
|
-
- body: markdown with Summary, Changes (bullet list), and Test Plan sections
|
|
507
|
-
- Return ONLY the JSON object, no markdown fences, no extra text`;
|
|
508
|
-
var CONFLICT_RESOLUTION_SYSTEM_PROMPT = `You are a git merge conflict resolution advisor. Analyze the conflict markers and provide guidance.
|
|
509
|
-
|
|
510
|
-
Rules:
|
|
511
|
-
- Explain what each side of the conflict contains
|
|
512
|
-
- Suggest the most likely correct resolution strategy
|
|
513
|
-
- Never auto-resolve — provide guidance only
|
|
514
|
-
- Be concise and actionable`;
|
|
743
|
+
var COPILOT_TIMEOUT_MS = 30000;
|
|
744
|
+
var COPILOT_LONG_TIMEOUT_MS = 90000;
|
|
515
745
|
async function checkCopilotAvailable() {
|
|
516
|
-
let client = null;
|
|
517
746
|
try {
|
|
518
|
-
client =
|
|
519
|
-
|
|
747
|
+
const client = await getManagedClient();
|
|
748
|
+
try {
|
|
749
|
+
await client.ping();
|
|
750
|
+
} catch (err) {
|
|
751
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
752
|
+
if (msg.includes("auth") || msg.includes("token") || msg.includes("401") || msg.includes("403")) {
|
|
753
|
+
return "Copilot authentication failed. Run `gh auth login` to refresh your token.";
|
|
754
|
+
}
|
|
755
|
+
if (msg.includes("ECONNREFUSED") || msg.includes("timeout") || msg.includes("network")) {
|
|
756
|
+
return "Could not reach GitHub Copilot service. Check your internet connection.";
|
|
757
|
+
}
|
|
758
|
+
return `Copilot health check failed: ${msg}`;
|
|
759
|
+
}
|
|
760
|
+
return null;
|
|
520
761
|
} catch (err) {
|
|
521
762
|
const msg = err instanceof Error ? err.message : String(err);
|
|
522
763
|
if (msg.includes("ENOENT") || msg.includes("not found")) {
|
|
@@ -524,44 +765,45 @@ async function checkCopilotAvailable() {
|
|
|
524
765
|
}
|
|
525
766
|
return `Failed to start Copilot service: ${msg}`;
|
|
526
767
|
}
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
768
|
+
}
|
|
769
|
+
var _managedClient = null;
|
|
770
|
+
var _clientStarted = false;
|
|
771
|
+
async function getManagedClient() {
|
|
772
|
+
if (!_managedClient || !_clientStarted) {
|
|
773
|
+
suppressSubprocessWarnings();
|
|
774
|
+
_managedClient = new CopilotClient;
|
|
775
|
+
await _managedClient.start();
|
|
776
|
+
_clientStarted = true;
|
|
777
|
+
const cleanup = () => {
|
|
778
|
+
if (_managedClient && _clientStarted) {
|
|
779
|
+
try {
|
|
780
|
+
_managedClient.stop();
|
|
781
|
+
} catch {}
|
|
782
|
+
_clientStarted = false;
|
|
783
|
+
_managedClient = null;
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
process.once("exit", cleanup);
|
|
787
|
+
process.once("SIGINT", cleanup);
|
|
788
|
+
process.once("SIGTERM", cleanup);
|
|
542
789
|
}
|
|
543
|
-
return
|
|
790
|
+
return _managedClient;
|
|
544
791
|
}
|
|
545
|
-
async function callCopilot(systemMessage, userMessage, model) {
|
|
546
|
-
const client =
|
|
547
|
-
|
|
792
|
+
async function callCopilot(systemMessage, userMessage, model, timeoutMs = COPILOT_TIMEOUT_MS) {
|
|
793
|
+
const client = await getManagedClient();
|
|
794
|
+
const sessionConfig = {
|
|
795
|
+
systemMessage: { mode: "replace", content: systemMessage }
|
|
796
|
+
};
|
|
797
|
+
if (model)
|
|
798
|
+
sessionConfig.model = model;
|
|
799
|
+
const session = await client.createSession(sessionConfig);
|
|
548
800
|
try {
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
sessionConfig.model = model;
|
|
554
|
-
const session = await client.createSession(sessionConfig);
|
|
555
|
-
try {
|
|
556
|
-
const response = await session.sendAndWait({ content: userMessage });
|
|
557
|
-
if (!response?.data?.content)
|
|
558
|
-
return null;
|
|
559
|
-
return response.data.content;
|
|
560
|
-
} finally {
|
|
561
|
-
await session.destroy();
|
|
562
|
-
}
|
|
801
|
+
const response = await withTimeout(session.sendAndWait({ prompt: userMessage }), timeoutMs);
|
|
802
|
+
if (!response?.data?.content)
|
|
803
|
+
return null;
|
|
804
|
+
return response.data.content;
|
|
563
805
|
} finally {
|
|
564
|
-
await
|
|
806
|
+
await session.destroy();
|
|
565
807
|
}
|
|
566
808
|
}
|
|
567
809
|
function getCommitSystemPrompt(convention) {
|
|
@@ -569,21 +811,53 @@ function getCommitSystemPrompt(convention) {
|
|
|
569
811
|
return CONVENTIONAL_COMMIT_SYSTEM_PROMPT;
|
|
570
812
|
return CLEAN_COMMIT_SYSTEM_PROMPT;
|
|
571
813
|
}
|
|
814
|
+
function extractJson(raw) {
|
|
815
|
+
let text2 = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
|
|
816
|
+
if (text2.startsWith("[") || text2.startsWith("{"))
|
|
817
|
+
return text2;
|
|
818
|
+
const arrayStart = text2.indexOf("[");
|
|
819
|
+
const objStart = text2.indexOf("{");
|
|
820
|
+
let start;
|
|
821
|
+
let closeChar;
|
|
822
|
+
if (arrayStart === -1 && objStart === -1)
|
|
823
|
+
return text2;
|
|
824
|
+
if (arrayStart === -1) {
|
|
825
|
+
start = objStart;
|
|
826
|
+
closeChar = "}";
|
|
827
|
+
} else if (objStart === -1) {
|
|
828
|
+
start = arrayStart;
|
|
829
|
+
closeChar = "]";
|
|
830
|
+
} else if (arrayStart < objStart) {
|
|
831
|
+
start = arrayStart;
|
|
832
|
+
closeChar = "]";
|
|
833
|
+
} else {
|
|
834
|
+
start = objStart;
|
|
835
|
+
closeChar = "}";
|
|
836
|
+
}
|
|
837
|
+
const end = text2.lastIndexOf(closeChar);
|
|
838
|
+
if (end > start) {
|
|
839
|
+
text2 = text2.slice(start, end + 1);
|
|
840
|
+
}
|
|
841
|
+
return text2;
|
|
842
|
+
}
|
|
572
843
|
async function generateCommitMessage(diff, stagedFiles, model, convention = "clean-commit") {
|
|
573
844
|
try {
|
|
845
|
+
const multiFileHint = stagedFiles.length > 1 ? `
|
|
846
|
+
|
|
847
|
+
IMPORTANT: Multiple files are staged. Generate ONE commit message that captures the high-level purpose of ALL changes together. Focus on the overall intent, not individual file changes. Be specific but concise — do not list every file.` : "";
|
|
574
848
|
const userMessage = `Generate a commit message for these staged changes:
|
|
575
849
|
|
|
576
850
|
Files: ${stagedFiles.join(", ")}
|
|
577
851
|
|
|
578
852
|
Diff:
|
|
579
|
-
${diff.slice(0, 4000)}`;
|
|
853
|
+
${diff.slice(0, 4000)}${multiFileHint}`;
|
|
580
854
|
const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
|
|
581
855
|
return result?.trim() ?? null;
|
|
582
856
|
} catch {
|
|
583
857
|
return null;
|
|
584
858
|
}
|
|
585
859
|
}
|
|
586
|
-
async function generatePRDescription(commits, diff, model) {
|
|
860
|
+
async function generatePRDescription(commits, diff, model, convention = "clean-commit") {
|
|
587
861
|
try {
|
|
588
862
|
const userMessage = `Generate a PR description for these changes:
|
|
589
863
|
|
|
@@ -593,10 +867,10 @@ ${commits.join(`
|
|
|
593
867
|
|
|
594
868
|
Diff (truncated):
|
|
595
869
|
${diff.slice(0, 4000)}`;
|
|
596
|
-
const result = await callCopilot(
|
|
870
|
+
const result = await callCopilot(getPRDescriptionSystemPrompt(convention), userMessage, model);
|
|
597
871
|
if (!result)
|
|
598
872
|
return null;
|
|
599
|
-
const cleaned = result
|
|
873
|
+
const cleaned = extractJson(result);
|
|
600
874
|
return JSON.parse(cleaned);
|
|
601
875
|
} catch {
|
|
602
876
|
return null;
|
|
@@ -621,6 +895,124 @@ ${conflictDiff.slice(0, 4000)}`;
|
|
|
621
895
|
return null;
|
|
622
896
|
}
|
|
623
897
|
}
|
|
898
|
+
async function generateCommitGroups(files, diffs, model, convention = "clean-commit") {
|
|
899
|
+
const userMessage = `Group these changed files into logical atomic commits:
|
|
900
|
+
|
|
901
|
+
Files:
|
|
902
|
+
${files.join(`
|
|
903
|
+
`)}
|
|
904
|
+
|
|
905
|
+
Diffs (truncated):
|
|
906
|
+
${diffs.slice(0, 6000)}`;
|
|
907
|
+
const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
|
|
908
|
+
if (!result) {
|
|
909
|
+
throw new Error("AI returned an empty response");
|
|
910
|
+
}
|
|
911
|
+
const cleaned = extractJson(result);
|
|
912
|
+
let parsed;
|
|
913
|
+
try {
|
|
914
|
+
parsed = JSON.parse(cleaned);
|
|
915
|
+
} catch {
|
|
916
|
+
throw new Error(`AI response is not valid JSON. Raw start: "${result.slice(0, 120)}..."`);
|
|
917
|
+
}
|
|
918
|
+
const groups = parsed;
|
|
919
|
+
if (!Array.isArray(groups) || groups.length === 0) {
|
|
920
|
+
throw new Error("AI response was not a valid JSON array of commit groups");
|
|
921
|
+
}
|
|
922
|
+
for (const group of groups) {
|
|
923
|
+
if (!Array.isArray(group.files) || typeof group.message !== "string") {
|
|
924
|
+
throw new Error("AI returned groups with invalid structure (missing files or message)");
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
return groups;
|
|
928
|
+
}
|
|
929
|
+
async function regenerateAllGroupMessages(groups, diffs, model, convention = "clean-commit") {
|
|
930
|
+
const groupSummary = groups.map((g, i) => `Group ${i + 1}: [${g.files.join(", ")}]`).join(`
|
|
931
|
+
`);
|
|
932
|
+
const userMessage = `Regenerate ONLY the commit messages for these pre-defined file groups. Do NOT change the file groupings.
|
|
933
|
+
|
|
934
|
+
Groups:
|
|
935
|
+
${groupSummary}
|
|
936
|
+
|
|
937
|
+
Diffs (truncated):
|
|
938
|
+
${diffs.slice(0, 6000)}`;
|
|
939
|
+
const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
|
|
940
|
+
if (!result)
|
|
941
|
+
return groups;
|
|
942
|
+
try {
|
|
943
|
+
const cleaned = extractJson(result);
|
|
944
|
+
const parsed = JSON.parse(cleaned);
|
|
945
|
+
if (!Array.isArray(parsed) || parsed.length !== groups.length)
|
|
946
|
+
return groups;
|
|
947
|
+
return groups.map((g, i) => ({
|
|
948
|
+
files: g.files,
|
|
949
|
+
message: typeof parsed[i]?.message === "string" ? parsed[i].message : g.message
|
|
950
|
+
}));
|
|
951
|
+
} catch {
|
|
952
|
+
return groups;
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
async function regenerateGroupMessage(files, diffs, model, convention = "clean-commit") {
|
|
956
|
+
try {
|
|
957
|
+
const userMessage = `Generate a single commit message for these files:
|
|
958
|
+
|
|
959
|
+
Files: ${files.join(", ")}
|
|
960
|
+
|
|
961
|
+
Diff:
|
|
962
|
+
${diffs.slice(0, 4000)}`;
|
|
963
|
+
const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
|
|
964
|
+
return result?.trim() ?? null;
|
|
965
|
+
} catch {
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
// src/utils/spinner.ts
|
|
971
|
+
import pc4 from "picocolors";
|
|
972
|
+
var FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
973
|
+
function createSpinner(text2) {
|
|
974
|
+
let frameIdx = 0;
|
|
975
|
+
let currentText = text2;
|
|
976
|
+
let stopped = false;
|
|
977
|
+
const clearLine = () => {
|
|
978
|
+
process.stderr.write("\r\x1B[K");
|
|
979
|
+
};
|
|
980
|
+
const render = () => {
|
|
981
|
+
if (stopped)
|
|
982
|
+
return;
|
|
983
|
+
const frame = pc4.cyan(FRAMES[frameIdx % FRAMES.length]);
|
|
984
|
+
clearLine();
|
|
985
|
+
process.stderr.write(`${frame} ${currentText}`);
|
|
986
|
+
frameIdx++;
|
|
987
|
+
};
|
|
988
|
+
const timer = setInterval(render, 80);
|
|
989
|
+
render();
|
|
990
|
+
const stop = () => {
|
|
991
|
+
if (stopped)
|
|
992
|
+
return;
|
|
993
|
+
stopped = true;
|
|
994
|
+
clearInterval(timer);
|
|
995
|
+
clearLine();
|
|
996
|
+
};
|
|
997
|
+
return {
|
|
998
|
+
update(newText) {
|
|
999
|
+
currentText = newText;
|
|
1000
|
+
},
|
|
1001
|
+
success(msg) {
|
|
1002
|
+
stop();
|
|
1003
|
+
process.stderr.write(`${pc4.green("✔")} ${msg}
|
|
1004
|
+
`);
|
|
1005
|
+
},
|
|
1006
|
+
fail(msg) {
|
|
1007
|
+
stop();
|
|
1008
|
+
process.stderr.write(`${pc4.red("✖")} ${msg}
|
|
1009
|
+
`);
|
|
1010
|
+
},
|
|
1011
|
+
stop() {
|
|
1012
|
+
stop();
|
|
1013
|
+
}
|
|
1014
|
+
};
|
|
1015
|
+
}
|
|
624
1016
|
|
|
625
1017
|
// src/commands/commit.ts
|
|
626
1018
|
var commit_default = defineCommand2({
|
|
@@ -637,6 +1029,11 @@ var commit_default = defineCommand2({
|
|
|
637
1029
|
type: "boolean",
|
|
638
1030
|
description: "Skip AI and write commit message manually",
|
|
639
1031
|
default: false
|
|
1032
|
+
},
|
|
1033
|
+
group: {
|
|
1034
|
+
type: "boolean",
|
|
1035
|
+
description: "AI groups related changes into separate atomic commits",
|
|
1036
|
+
default: false
|
|
640
1037
|
}
|
|
641
1038
|
},
|
|
642
1039
|
async run({ args }) {
|
|
@@ -650,7 +1047,11 @@ var commit_default = defineCommand2({
|
|
|
650
1047
|
process.exit(1);
|
|
651
1048
|
}
|
|
652
1049
|
heading("\uD83D\uDCBE contrib commit");
|
|
653
|
-
|
|
1050
|
+
if (args.group) {
|
|
1051
|
+
await runGroupCommit(args.model, config);
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
let stagedFiles = await getStagedFiles();
|
|
654
1055
|
if (stagedFiles.length === 0) {
|
|
655
1056
|
const changedFiles = await getChangedFiles();
|
|
656
1057
|
if (changedFiles.length === 0) {
|
|
@@ -658,31 +1059,62 @@ var commit_default = defineCommand2({
|
|
|
658
1059
|
process.exit(1);
|
|
659
1060
|
}
|
|
660
1061
|
console.log(`
|
|
661
|
-
${
|
|
1062
|
+
${pc5.bold("Changed files:")}`);
|
|
662
1063
|
for (const f of changedFiles) {
|
|
663
|
-
console.log(` ${
|
|
1064
|
+
console.log(` ${pc5.dim("•")} ${f}`);
|
|
1065
|
+
}
|
|
1066
|
+
const stageAction = await selectPrompt("No staged changes. How would you like to stage?", [
|
|
1067
|
+
"Stage all changes",
|
|
1068
|
+
"Select files to stage",
|
|
1069
|
+
"Cancel"
|
|
1070
|
+
]);
|
|
1071
|
+
if (stageAction === "Cancel") {
|
|
1072
|
+
process.exit(0);
|
|
1073
|
+
}
|
|
1074
|
+
if (stageAction === "Stage all changes") {
|
|
1075
|
+
const result2 = await stageAll();
|
|
1076
|
+
if (result2.exitCode !== 0) {
|
|
1077
|
+
error(`Failed to stage files: ${result2.stderr}`);
|
|
1078
|
+
process.exit(1);
|
|
1079
|
+
}
|
|
1080
|
+
success("Staged all changes.");
|
|
1081
|
+
} else {
|
|
1082
|
+
const selected = await multiSelectPrompt("Select files to stage:", changedFiles);
|
|
1083
|
+
if (selected.length === 0) {
|
|
1084
|
+
error("No files selected.");
|
|
1085
|
+
process.exit(1);
|
|
1086
|
+
}
|
|
1087
|
+
const result2 = await stageFiles(selected);
|
|
1088
|
+
if (result2.exitCode !== 0) {
|
|
1089
|
+
error(`Failed to stage files: ${result2.stderr}`);
|
|
1090
|
+
process.exit(1);
|
|
1091
|
+
}
|
|
1092
|
+
success(`Staged ${selected.length} file(s).`);
|
|
1093
|
+
}
|
|
1094
|
+
stagedFiles = await getStagedFiles();
|
|
1095
|
+
if (stagedFiles.length === 0) {
|
|
1096
|
+
error("No staged changes after staging attempt.");
|
|
1097
|
+
process.exit(1);
|
|
664
1098
|
}
|
|
665
|
-
console.log();
|
|
666
|
-
warn("No staged changes. Stage your files with `git add` and re-run.");
|
|
667
|
-
process.exit(1);
|
|
668
1099
|
}
|
|
669
1100
|
info(`Staged files: ${stagedFiles.join(", ")}`);
|
|
670
1101
|
let commitMessage = null;
|
|
671
1102
|
const useAI = !args["no-ai"];
|
|
672
1103
|
if (useAI) {
|
|
673
|
-
const copilotError = await checkCopilotAvailable();
|
|
1104
|
+
const [copilotError, diff] = await Promise.all([checkCopilotAvailable(), getStagedDiff()]);
|
|
674
1105
|
if (copilotError) {
|
|
675
1106
|
warn(`AI unavailable: ${copilotError}`);
|
|
676
1107
|
warn("Falling back to manual commit message entry.");
|
|
677
1108
|
} else {
|
|
678
|
-
|
|
679
|
-
const diff = await getStagedDiff();
|
|
1109
|
+
const spinner = createSpinner("Generating commit message with AI...");
|
|
680
1110
|
commitMessage = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
|
|
681
1111
|
if (commitMessage) {
|
|
1112
|
+
spinner.success("AI commit message generated.");
|
|
682
1113
|
console.log(`
|
|
683
|
-
${
|
|
1114
|
+
${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(commitMessage))}`);
|
|
684
1115
|
} else {
|
|
685
|
-
|
|
1116
|
+
spinner.fail("AI did not return a commit message.");
|
|
1117
|
+
warn("Falling back to manual entry.");
|
|
686
1118
|
}
|
|
687
1119
|
}
|
|
688
1120
|
}
|
|
@@ -699,16 +1131,17 @@ ${pc4.bold("Changed files:")}`);
|
|
|
699
1131
|
} else if (action === "Edit this message") {
|
|
700
1132
|
finalMessage = await inputPrompt("Edit commit message", commitMessage);
|
|
701
1133
|
} else if (action === "Regenerate") {
|
|
702
|
-
|
|
1134
|
+
const spinner = createSpinner("Regenerating commit message...");
|
|
703
1135
|
const diff = await getStagedDiff();
|
|
704
1136
|
const regen = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
|
|
705
1137
|
if (regen) {
|
|
1138
|
+
spinner.success("Commit message regenerated.");
|
|
706
1139
|
console.log(`
|
|
707
|
-
${
|
|
1140
|
+
${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(regen))}`);
|
|
708
1141
|
const ok = await confirmPrompt("Use this message?");
|
|
709
1142
|
finalMessage = ok ? regen : await inputPrompt("Enter commit message manually");
|
|
710
1143
|
} else {
|
|
711
|
-
|
|
1144
|
+
spinner.fail("Regeneration failed.");
|
|
712
1145
|
finalMessage = await inputPrompt("Enter commit message");
|
|
713
1146
|
}
|
|
714
1147
|
} else {
|
|
@@ -719,7 +1152,7 @@ ${pc4.bold("Changed files:")}`);
|
|
|
719
1152
|
if (convention2 !== "none") {
|
|
720
1153
|
console.log();
|
|
721
1154
|
for (const hint of CONVENTION_FORMAT_HINTS[convention2]) {
|
|
722
|
-
console.log(
|
|
1155
|
+
console.log(pc5.dim(hint));
|
|
723
1156
|
}
|
|
724
1157
|
console.log();
|
|
725
1158
|
}
|
|
@@ -743,21 +1176,209 @@ ${pc4.bold("Changed files:")}`);
|
|
|
743
1176
|
error(`Failed to commit: ${result.stderr}`);
|
|
744
1177
|
process.exit(1);
|
|
745
1178
|
}
|
|
746
|
-
success(`✅ Committed: ${
|
|
1179
|
+
success(`✅ Committed: ${pc5.bold(finalMessage)}`);
|
|
747
1180
|
}
|
|
748
1181
|
});
|
|
1182
|
+
async function runGroupCommit(model, config) {
|
|
1183
|
+
const [copilotError, changedFiles] = await Promise.all([
|
|
1184
|
+
checkCopilotAvailable(),
|
|
1185
|
+
getChangedFiles()
|
|
1186
|
+
]);
|
|
1187
|
+
if (copilotError) {
|
|
1188
|
+
error(`AI is required for --group mode but unavailable: ${copilotError}`);
|
|
1189
|
+
process.exit(1);
|
|
1190
|
+
}
|
|
1191
|
+
if (changedFiles.length === 0) {
|
|
1192
|
+
error("No changes to group-commit.");
|
|
1193
|
+
process.exit(1);
|
|
1194
|
+
}
|
|
1195
|
+
console.log(`
|
|
1196
|
+
${pc5.bold("Changed files:")}`);
|
|
1197
|
+
for (const f of changedFiles) {
|
|
1198
|
+
console.log(` ${pc5.dim("•")} ${f}`);
|
|
1199
|
+
}
|
|
1200
|
+
const spinner = createSpinner(`Asking AI to group ${changedFiles.length} file(s) into logical commits...`);
|
|
1201
|
+
const diffs = await getFullDiffForFiles(changedFiles);
|
|
1202
|
+
if (!diffs.trim()) {
|
|
1203
|
+
spinner.stop();
|
|
1204
|
+
warn("Could not retrieve diff context for any files. AI needs diffs to produce groups.");
|
|
1205
|
+
}
|
|
1206
|
+
let groups;
|
|
1207
|
+
try {
|
|
1208
|
+
groups = await generateCommitGroups(changedFiles, diffs, model, config.commitConvention);
|
|
1209
|
+
spinner.success(`AI generated ${groups.length} commit group(s).`);
|
|
1210
|
+
} catch (err) {
|
|
1211
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1212
|
+
spinner.fail(`AI grouping failed: ${reason}`);
|
|
1213
|
+
process.exit(1);
|
|
1214
|
+
}
|
|
1215
|
+
if (groups.length === 0) {
|
|
1216
|
+
error("AI could not produce commit groups. Try committing files manually.");
|
|
1217
|
+
process.exit(1);
|
|
1218
|
+
}
|
|
1219
|
+
const changedSet = new Set(changedFiles);
|
|
1220
|
+
for (const group of groups) {
|
|
1221
|
+
const invalid = group.files.filter((f) => !changedSet.has(f));
|
|
1222
|
+
if (invalid.length > 0) {
|
|
1223
|
+
warn(`AI suggested unknown file(s): ${invalid.join(", ")} — removed from group.`);
|
|
1224
|
+
}
|
|
1225
|
+
group.files = group.files.filter((f) => changedSet.has(f));
|
|
1226
|
+
}
|
|
1227
|
+
let validGroups = groups.filter((g) => g.files.length > 0);
|
|
1228
|
+
if (validGroups.length === 0) {
|
|
1229
|
+
error("No valid groups remain after validation. Try committing files manually.");
|
|
1230
|
+
process.exit(1);
|
|
1231
|
+
}
|
|
1232
|
+
let proceedToCommit = false;
|
|
1233
|
+
let commitAll = false;
|
|
1234
|
+
while (!proceedToCommit) {
|
|
1235
|
+
console.log(`
|
|
1236
|
+
${pc5.bold(`AI suggested ${validGroups.length} commit group(s):`)}
|
|
1237
|
+
`);
|
|
1238
|
+
for (let i = 0;i < validGroups.length; i++) {
|
|
1239
|
+
const g = validGroups[i];
|
|
1240
|
+
console.log(` ${pc5.cyan(`Group ${i + 1}:`)} ${pc5.bold(g.message)}`);
|
|
1241
|
+
for (const f of g.files) {
|
|
1242
|
+
console.log(` ${pc5.dim("•")} ${f}`);
|
|
1243
|
+
}
|
|
1244
|
+
console.log();
|
|
1245
|
+
}
|
|
1246
|
+
const summaryAction = await selectPrompt("What would you like to do?", [
|
|
1247
|
+
"Commit all",
|
|
1248
|
+
"Review each group",
|
|
1249
|
+
"Regenerate all messages",
|
|
1250
|
+
"Cancel"
|
|
1251
|
+
]);
|
|
1252
|
+
if (summaryAction === "Cancel") {
|
|
1253
|
+
warn("Group commit cancelled.");
|
|
1254
|
+
process.exit(0);
|
|
1255
|
+
}
|
|
1256
|
+
if (summaryAction === "Regenerate all messages") {
|
|
1257
|
+
const regenSpinner = createSpinner("Regenerating all commit messages...");
|
|
1258
|
+
try {
|
|
1259
|
+
validGroups = await regenerateAllGroupMessages(validGroups, diffs, model, config.commitConvention);
|
|
1260
|
+
regenSpinner.success("All commit messages regenerated.");
|
|
1261
|
+
} catch {
|
|
1262
|
+
regenSpinner.fail("Failed to regenerate messages. Keeping current ones.");
|
|
1263
|
+
}
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1266
|
+
proceedToCommit = true;
|
|
1267
|
+
commitAll = summaryAction === "Commit all";
|
|
1268
|
+
}
|
|
1269
|
+
let committed = 0;
|
|
1270
|
+
if (commitAll) {
|
|
1271
|
+
for (let i = 0;i < validGroups.length; i++) {
|
|
1272
|
+
const group = validGroups[i];
|
|
1273
|
+
const stageResult = await stageFiles(group.files);
|
|
1274
|
+
if (stageResult.exitCode !== 0) {
|
|
1275
|
+
error(`Failed to stage group ${i + 1}: ${stageResult.stderr}`);
|
|
1276
|
+
continue;
|
|
1277
|
+
}
|
|
1278
|
+
const commitResult = await commitWithMessage(group.message);
|
|
1279
|
+
if (commitResult.exitCode !== 0) {
|
|
1280
|
+
const detail = (commitResult.stderr || commitResult.stdout).trim();
|
|
1281
|
+
error(`Failed to commit group ${i + 1}: ${detail}`);
|
|
1282
|
+
await unstageFiles(group.files);
|
|
1283
|
+
continue;
|
|
1284
|
+
}
|
|
1285
|
+
committed++;
|
|
1286
|
+
success(`✅ Committed group ${i + 1}: ${pc5.bold(group.message)}`);
|
|
1287
|
+
}
|
|
1288
|
+
} else {
|
|
1289
|
+
for (let i = 0;i < validGroups.length; i++) {
|
|
1290
|
+
const group = validGroups[i];
|
|
1291
|
+
console.log(pc5.bold(`
|
|
1292
|
+
── Group ${i + 1}/${validGroups.length} ──`));
|
|
1293
|
+
console.log(` ${pc5.cyan(group.message)}`);
|
|
1294
|
+
for (const f of group.files) {
|
|
1295
|
+
console.log(` ${pc5.dim("•")} ${f}`);
|
|
1296
|
+
}
|
|
1297
|
+
let message = group.message;
|
|
1298
|
+
let actionDone = false;
|
|
1299
|
+
while (!actionDone) {
|
|
1300
|
+
const action = await selectPrompt("Action for this group:", [
|
|
1301
|
+
"Commit as-is",
|
|
1302
|
+
"Edit message and commit",
|
|
1303
|
+
"Regenerate message",
|
|
1304
|
+
"Skip this group"
|
|
1305
|
+
]);
|
|
1306
|
+
if (action === "Skip this group") {
|
|
1307
|
+
warn(`Skipped group ${i + 1}.`);
|
|
1308
|
+
actionDone = true;
|
|
1309
|
+
continue;
|
|
1310
|
+
}
|
|
1311
|
+
if (action === "Regenerate message") {
|
|
1312
|
+
const regenSpinner = createSpinner("Regenerating commit message for this group...");
|
|
1313
|
+
const newMsg = await regenerateGroupMessage(group.files, diffs, model, config.commitConvention);
|
|
1314
|
+
if (newMsg) {
|
|
1315
|
+
message = newMsg;
|
|
1316
|
+
group.message = newMsg;
|
|
1317
|
+
regenSpinner.success(`New message: ${pc5.bold(message)}`);
|
|
1318
|
+
} else {
|
|
1319
|
+
regenSpinner.fail("AI could not generate a new message. Keeping current one.");
|
|
1320
|
+
}
|
|
1321
|
+
continue;
|
|
1322
|
+
}
|
|
1323
|
+
if (action === "Edit message and commit") {
|
|
1324
|
+
message = await inputPrompt("Edit commit message", message);
|
|
1325
|
+
if (!message) {
|
|
1326
|
+
warn(`Skipped group ${i + 1} (empty message).`);
|
|
1327
|
+
actionDone = true;
|
|
1328
|
+
continue;
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1331
|
+
if (!validateCommitMessage(message, config.commitConvention)) {
|
|
1332
|
+
for (const line of getValidationError(config.commitConvention)) {
|
|
1333
|
+
warn(line);
|
|
1334
|
+
}
|
|
1335
|
+
const proceed = await confirmPrompt("Commit anyway?");
|
|
1336
|
+
if (!proceed) {
|
|
1337
|
+
warn(`Skipped group ${i + 1}.`);
|
|
1338
|
+
actionDone = true;
|
|
1339
|
+
continue;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
const stageResult = await stageFiles(group.files);
|
|
1343
|
+
if (stageResult.exitCode !== 0) {
|
|
1344
|
+
error(`Failed to stage group ${i + 1}: ${stageResult.stderr}`);
|
|
1345
|
+
actionDone = true;
|
|
1346
|
+
continue;
|
|
1347
|
+
}
|
|
1348
|
+
const commitResult = await commitWithMessage(message);
|
|
1349
|
+
if (commitResult.exitCode !== 0) {
|
|
1350
|
+
const detail = (commitResult.stderr || commitResult.stdout).trim();
|
|
1351
|
+
error(`Failed to commit group ${i + 1}: ${detail}`);
|
|
1352
|
+
await unstageFiles(group.files);
|
|
1353
|
+
actionDone = true;
|
|
1354
|
+
continue;
|
|
1355
|
+
}
|
|
1356
|
+
committed++;
|
|
1357
|
+
success(`✅ Committed group ${i + 1}: ${pc5.bold(message)}`);
|
|
1358
|
+
actionDone = true;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
if (committed === 0) {
|
|
1363
|
+
warn("No groups were committed.");
|
|
1364
|
+
} else {
|
|
1365
|
+
success(`
|
|
1366
|
+
\uD83C\uDF89 ${committed} of ${validGroups.length} group(s) committed successfully.`);
|
|
1367
|
+
}
|
|
1368
|
+
process.exit(0);
|
|
1369
|
+
}
|
|
749
1370
|
|
|
750
1371
|
// src/commands/hook.ts
|
|
751
|
-
import { existsSync as existsSync2, mkdirSync, readFileSync as
|
|
752
|
-
import { join as
|
|
1372
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1373
|
+
import { join as join3 } from "node:path";
|
|
753
1374
|
import { defineCommand as defineCommand3 } from "citty";
|
|
754
|
-
import
|
|
1375
|
+
import pc6 from "picocolors";
|
|
755
1376
|
var HOOK_MARKER = "# managed by contribute-now";
|
|
756
1377
|
function getHooksDir(cwd = process.cwd()) {
|
|
757
|
-
return
|
|
1378
|
+
return join3(cwd, ".git", "hooks");
|
|
758
1379
|
}
|
|
759
1380
|
function getHookPath(cwd = process.cwd()) {
|
|
760
|
-
return
|
|
1381
|
+
return join3(getHooksDir(cwd), "commit-msg");
|
|
761
1382
|
}
|
|
762
1383
|
function generateHookScript() {
|
|
763
1384
|
return `#!/bin/sh
|
|
@@ -774,8 +1395,19 @@ case "$commit_msg" in
|
|
|
774
1395
|
Merge\\ *|fixup!*|squash!*|amend!*) exit 0 ;;
|
|
775
1396
|
esac
|
|
776
1397
|
|
|
777
|
-
#
|
|
778
|
-
|
|
1398
|
+
# Detect available package runner
|
|
1399
|
+
if command -v contrib >/dev/null 2>&1; then
|
|
1400
|
+
contrib validate "$commit_msg"
|
|
1401
|
+
elif command -v bunx >/dev/null 2>&1; then
|
|
1402
|
+
bunx contrib validate "$commit_msg"
|
|
1403
|
+
elif command -v pnpx >/dev/null 2>&1; then
|
|
1404
|
+
pnpx contrib validate "$commit_msg"
|
|
1405
|
+
elif command -v npx >/dev/null 2>&1; then
|
|
1406
|
+
npx contrib validate "$commit_msg"
|
|
1407
|
+
else
|
|
1408
|
+
echo "Warning: No package runner found. Skipping commit message validation."
|
|
1409
|
+
exit 0
|
|
1410
|
+
fi
|
|
779
1411
|
`;
|
|
780
1412
|
}
|
|
781
1413
|
var hook_default = defineCommand3({
|
|
@@ -822,7 +1454,7 @@ async function installHook() {
|
|
|
822
1454
|
const hookPath = getHookPath();
|
|
823
1455
|
const hooksDir = getHooksDir();
|
|
824
1456
|
if (existsSync2(hookPath)) {
|
|
825
|
-
const existing =
|
|
1457
|
+
const existing = readFileSync3(hookPath, "utf-8");
|
|
826
1458
|
if (!existing.includes(HOOK_MARKER)) {
|
|
827
1459
|
error("A commit-msg hook already exists and was not installed by contribute-now.");
|
|
828
1460
|
warn(`Path: ${hookPath}`);
|
|
@@ -836,8 +1468,8 @@ async function installHook() {
|
|
|
836
1468
|
}
|
|
837
1469
|
writeFileSync2(hookPath, generateHookScript(), { mode: 493 });
|
|
838
1470
|
success(`commit-msg hook installed.`);
|
|
839
|
-
info(`Convention: ${
|
|
840
|
-
info(`Path: ${
|
|
1471
|
+
info(`Convention: ${pc6.bold(CONVENTION_LABELS[config.commitConvention])}`);
|
|
1472
|
+
info(`Path: ${pc6.dim(hookPath)}`);
|
|
841
1473
|
}
|
|
842
1474
|
async function uninstallHook() {
|
|
843
1475
|
heading("\uD83E\uDE9D hook uninstall");
|
|
@@ -846,7 +1478,7 @@ async function uninstallHook() {
|
|
|
846
1478
|
info("No commit-msg hook found. Nothing to uninstall.");
|
|
847
1479
|
return;
|
|
848
1480
|
}
|
|
849
|
-
const content =
|
|
1481
|
+
const content = readFileSync3(hookPath, "utf-8");
|
|
850
1482
|
if (!content.includes(HOOK_MARKER)) {
|
|
851
1483
|
error("The commit-msg hook was not installed by contribute-now. Leaving it untouched.");
|
|
852
1484
|
process.exit(1);
|
|
@@ -857,7 +1489,7 @@ async function uninstallHook() {
|
|
|
857
1489
|
|
|
858
1490
|
// src/commands/setup.ts
|
|
859
1491
|
import { defineCommand as defineCommand4 } from "citty";
|
|
860
|
-
import
|
|
1492
|
+
import pc7 from "picocolors";
|
|
861
1493
|
|
|
862
1494
|
// src/utils/gh.ts
|
|
863
1495
|
import { execFile as execFileCb2 } from "node:child_process";
|
|
@@ -865,7 +1497,7 @@ function run2(args) {
|
|
|
865
1497
|
return new Promise((resolve) => {
|
|
866
1498
|
execFileCb2("gh", args, (error2, stdout, stderr) => {
|
|
867
1499
|
resolve({
|
|
868
|
-
exitCode: error2 ? error2.code
|
|
1500
|
+
exitCode: error2 ? error2.code === "ENOENT" ? 127 : error2.status ?? 1 : 0,
|
|
869
1501
|
stdout: stdout ?? "",
|
|
870
1502
|
stderr: stderr ?? ""
|
|
871
1503
|
});
|
|
@@ -888,7 +1520,10 @@ async function checkGhAuth() {
|
|
|
888
1520
|
return false;
|
|
889
1521
|
}
|
|
890
1522
|
}
|
|
1523
|
+
var SAFE_SLUG = /^[\w.-]+$/;
|
|
891
1524
|
async function checkRepoPermissions(owner, repo) {
|
|
1525
|
+
if (!SAFE_SLUG.test(owner) || !SAFE_SLUG.test(repo))
|
|
1526
|
+
return null;
|
|
892
1527
|
const { exitCode, stdout } = await run2(["api", `repos/${owner}/${repo}`, "--jq", ".permissions"]);
|
|
893
1528
|
if (exitCode !== 0)
|
|
894
1529
|
return null;
|
|
@@ -949,6 +1584,50 @@ async function createPRFill(base, draft) {
|
|
|
949
1584
|
args.push("--draft");
|
|
950
1585
|
return run2(args);
|
|
951
1586
|
}
|
|
1587
|
+
async function getPRForBranch(headBranch) {
|
|
1588
|
+
const { exitCode, stdout } = await run2([
|
|
1589
|
+
"pr",
|
|
1590
|
+
"list",
|
|
1591
|
+
"--head",
|
|
1592
|
+
headBranch,
|
|
1593
|
+
"--state",
|
|
1594
|
+
"open",
|
|
1595
|
+
"--json",
|
|
1596
|
+
"number,url,title,state",
|
|
1597
|
+
"--limit",
|
|
1598
|
+
"1"
|
|
1599
|
+
]);
|
|
1600
|
+
if (exitCode !== 0)
|
|
1601
|
+
return null;
|
|
1602
|
+
try {
|
|
1603
|
+
const prs = JSON.parse(stdout.trim());
|
|
1604
|
+
return prs.length > 0 ? prs[0] : null;
|
|
1605
|
+
} catch {
|
|
1606
|
+
return null;
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
async function getMergedPRForBranch(headBranch) {
|
|
1610
|
+
const { exitCode, stdout } = await run2([
|
|
1611
|
+
"pr",
|
|
1612
|
+
"list",
|
|
1613
|
+
"--head",
|
|
1614
|
+
headBranch,
|
|
1615
|
+
"--state",
|
|
1616
|
+
"merged",
|
|
1617
|
+
"--json",
|
|
1618
|
+
"number,url,title,state",
|
|
1619
|
+
"--limit",
|
|
1620
|
+
"1"
|
|
1621
|
+
]);
|
|
1622
|
+
if (exitCode !== 0)
|
|
1623
|
+
return null;
|
|
1624
|
+
try {
|
|
1625
|
+
const prs = JSON.parse(stdout.trim());
|
|
1626
|
+
return prs.length > 0 ? prs[0] : null;
|
|
1627
|
+
} catch {
|
|
1628
|
+
return null;
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
952
1631
|
|
|
953
1632
|
// src/utils/remote.ts
|
|
954
1633
|
function parseRepoFromUrl(url) {
|
|
@@ -991,7 +1670,7 @@ var setup_default = defineCommand4({
|
|
|
991
1670
|
workflow = "github-flow";
|
|
992
1671
|
else if (workflowChoice.startsWith("Git Flow"))
|
|
993
1672
|
workflow = "git-flow";
|
|
994
|
-
info(`Workflow: ${
|
|
1673
|
+
info(`Workflow: ${pc7.bold(WORKFLOW_DESCRIPTIONS[workflow])}`);
|
|
995
1674
|
const conventionChoice = await selectPrompt("Which commit convention should this project use?", [
|
|
996
1675
|
`${CONVENTION_DESCRIPTIONS["clean-commit"]} (recommended)`,
|
|
997
1676
|
CONVENTION_DESCRIPTIONS.conventional,
|
|
@@ -1044,8 +1723,8 @@ var setup_default = defineCommand4({
|
|
|
1044
1723
|
detectedRole = roleChoice;
|
|
1045
1724
|
detectionSource = "user selection";
|
|
1046
1725
|
} else {
|
|
1047
|
-
info(`Detected role: ${
|
|
1048
|
-
const confirmed = await confirmPrompt(`Role detected as ${
|
|
1726
|
+
info(`Detected role: ${pc7.bold(detectedRole)} (via ${detectionSource})`);
|
|
1727
|
+
const confirmed = await confirmPrompt(`Role detected as ${pc7.bold(detectedRole)}. Is this correct?`);
|
|
1049
1728
|
if (!confirmed) {
|
|
1050
1729
|
const roleChoice = await selectPrompt("Select your role:", ["maintainer", "contributor"]);
|
|
1051
1730
|
detectedRole = roleChoice;
|
|
@@ -1090,21 +1769,21 @@ var setup_default = defineCommand4({
|
|
|
1090
1769
|
warn(' echo ".contributerc.json" >> .gitignore');
|
|
1091
1770
|
}
|
|
1092
1771
|
console.log();
|
|
1093
|
-
info(`Workflow: ${
|
|
1094
|
-
info(`Convention: ${
|
|
1095
|
-
info(`Role: ${
|
|
1772
|
+
info(`Workflow: ${pc7.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
|
|
1773
|
+
info(`Convention: ${pc7.bold(CONVENTION_DESCRIPTIONS[config.commitConvention])}`);
|
|
1774
|
+
info(`Role: ${pc7.bold(config.role)}`);
|
|
1096
1775
|
if (config.devBranch) {
|
|
1097
|
-
info(`Main: ${
|
|
1776
|
+
info(`Main: ${pc7.bold(config.mainBranch)} | Dev: ${pc7.bold(config.devBranch)}`);
|
|
1098
1777
|
} else {
|
|
1099
|
-
info(`Main: ${
|
|
1778
|
+
info(`Main: ${pc7.bold(config.mainBranch)}`);
|
|
1100
1779
|
}
|
|
1101
|
-
info(`Origin: ${
|
|
1780
|
+
info(`Origin: ${pc7.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc7.bold(config.upstream)}` : ""}`);
|
|
1102
1781
|
}
|
|
1103
1782
|
});
|
|
1104
1783
|
|
|
1105
1784
|
// src/commands/start.ts
|
|
1106
1785
|
import { defineCommand as defineCommand5 } from "citty";
|
|
1107
|
-
import
|
|
1786
|
+
import pc8 from "picocolors";
|
|
1108
1787
|
|
|
1109
1788
|
// src/utils/branch.ts
|
|
1110
1789
|
var DEFAULT_PREFIXES = ["feature", "fix", "docs", "chore", "test", "refactor"];
|
|
@@ -1115,6 +1794,9 @@ function formatBranchName(prefix, name) {
|
|
|
1115
1794
|
const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1116
1795
|
return `${prefix}/${sanitized}`;
|
|
1117
1796
|
}
|
|
1797
|
+
function isValidBranchName(name) {
|
|
1798
|
+
return /^[a-zA-Z0-9._/-]+$/.test(name) && !name.startsWith("/") && !name.endsWith("/");
|
|
1799
|
+
}
|
|
1118
1800
|
function looksLikeNaturalLanguage(input) {
|
|
1119
1801
|
return input.includes(" ") && !input.includes("/");
|
|
1120
1802
|
}
|
|
@@ -1162,39 +1844,46 @@ var start_default = defineCommand5({
|
|
|
1162
1844
|
heading("\uD83C\uDF3F contrib start");
|
|
1163
1845
|
const useAI = !args["no-ai"] && looksLikeNaturalLanguage(branchName);
|
|
1164
1846
|
if (useAI) {
|
|
1165
|
-
|
|
1847
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
1166
1848
|
const suggested = await suggestBranchName(branchName, args.model);
|
|
1167
1849
|
if (suggested) {
|
|
1850
|
+
spinner.success("Branch name suggestion ready.");
|
|
1168
1851
|
console.log(`
|
|
1169
|
-
${
|
|
1170
|
-
const accepted = await confirmPrompt(`Use ${
|
|
1852
|
+
${pc8.dim("AI suggestion:")} ${pc8.bold(pc8.cyan(suggested))}`);
|
|
1853
|
+
const accepted = await confirmPrompt(`Use ${pc8.bold(suggested)} as your branch name?`);
|
|
1171
1854
|
if (accepted) {
|
|
1172
1855
|
branchName = suggested;
|
|
1173
1856
|
} else {
|
|
1174
1857
|
branchName = await inputPrompt("Enter branch name", branchName);
|
|
1175
1858
|
}
|
|
1859
|
+
} else {
|
|
1860
|
+
spinner.fail("AI did not return a branch name suggestion.");
|
|
1176
1861
|
}
|
|
1177
1862
|
}
|
|
1178
1863
|
if (!hasPrefix(branchName, branchPrefixes)) {
|
|
1179
|
-
const prefix = await selectPrompt(`Choose a branch type for ${
|
|
1864
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc8.bold(branchName)}:`, branchPrefixes);
|
|
1180
1865
|
branchName = formatBranchName(prefix, branchName);
|
|
1181
1866
|
}
|
|
1182
|
-
|
|
1867
|
+
if (!isValidBranchName(branchName)) {
|
|
1868
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
1869
|
+
process.exit(1);
|
|
1870
|
+
}
|
|
1871
|
+
info(`Creating branch: ${pc8.bold(branchName)}`);
|
|
1183
1872
|
await fetchRemote(syncSource.remote);
|
|
1184
|
-
const
|
|
1185
|
-
if (
|
|
1873
|
+
const updateResult = await updateLocalBranch(baseBranch, syncSource.ref);
|
|
1874
|
+
if (updateResult.exitCode !== 0) {}
|
|
1186
1875
|
const result = await createBranch(branchName, baseBranch);
|
|
1187
1876
|
if (result.exitCode !== 0) {
|
|
1188
1877
|
error(`Failed to create branch: ${result.stderr}`);
|
|
1189
1878
|
process.exit(1);
|
|
1190
1879
|
}
|
|
1191
|
-
success(`✅ Created ${
|
|
1880
|
+
success(`✅ Created ${pc8.bold(branchName)} from latest ${pc8.bold(baseBranch)}`);
|
|
1192
1881
|
}
|
|
1193
1882
|
});
|
|
1194
1883
|
|
|
1195
1884
|
// src/commands/status.ts
|
|
1196
1885
|
import { defineCommand as defineCommand6 } from "citty";
|
|
1197
|
-
import
|
|
1886
|
+
import pc9 from "picocolors";
|
|
1198
1887
|
var status_default = defineCommand6({
|
|
1199
1888
|
meta: {
|
|
1200
1889
|
name: "status",
|
|
@@ -1211,17 +1900,20 @@ var status_default = defineCommand6({
|
|
|
1211
1900
|
process.exit(1);
|
|
1212
1901
|
}
|
|
1213
1902
|
heading("\uD83D\uDCCA contribute-now status");
|
|
1214
|
-
console.log(` ${
|
|
1215
|
-
console.log(` ${
|
|
1903
|
+
console.log(` ${pc9.dim("Workflow:")} ${pc9.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
|
|
1904
|
+
console.log(` ${pc9.dim("Role:")} ${pc9.bold(config.role)}`);
|
|
1216
1905
|
console.log();
|
|
1217
1906
|
await fetchAll();
|
|
1218
1907
|
const currentBranch = await getCurrentBranch();
|
|
1219
1908
|
const { mainBranch, origin, upstream, workflow } = config;
|
|
1220
1909
|
const baseBranch = getBaseBranch(config);
|
|
1221
1910
|
const isContributor = config.role === "contributor";
|
|
1222
|
-
const dirty = await
|
|
1911
|
+
const [dirty, fileStatus] = await Promise.all([
|
|
1912
|
+
hasUncommittedChanges(),
|
|
1913
|
+
getFileStatus()
|
|
1914
|
+
]);
|
|
1223
1915
|
if (dirty) {
|
|
1224
|
-
console.log(` ${
|
|
1916
|
+
console.log(` ${pc9.yellow("⚠")} ${pc9.yellow("Uncommitted changes in working tree")}`);
|
|
1225
1917
|
console.log();
|
|
1226
1918
|
}
|
|
1227
1919
|
const mainRemote = `${origin}/${mainBranch}`;
|
|
@@ -1237,30 +1929,130 @@ var status_default = defineCommand6({
|
|
|
1237
1929
|
if (currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
|
|
1238
1930
|
const branchDiv = await getDivergence(currentBranch, baseBranch);
|
|
1239
1931
|
const branchLine = formatStatus(currentBranch, baseBranch, branchDiv.ahead, branchDiv.behind);
|
|
1240
|
-
console.log(branchLine +
|
|
1932
|
+
console.log(branchLine + pc9.dim(` (current ${pc9.green("*")})`));
|
|
1241
1933
|
} else if (currentBranch) {
|
|
1242
|
-
console.log(
|
|
1934
|
+
console.log(pc9.dim(` (on ${pc9.bold(currentBranch)} branch)`));
|
|
1935
|
+
}
|
|
1936
|
+
const hasFiles = fileStatus.staged.length > 0 || fileStatus.modified.length > 0 || fileStatus.untracked.length > 0;
|
|
1937
|
+
if (hasFiles) {
|
|
1938
|
+
console.log();
|
|
1939
|
+
if (fileStatus.staged.length > 0) {
|
|
1940
|
+
console.log(` ${pc9.green("Staged for commit:")}`);
|
|
1941
|
+
for (const { file, status } of fileStatus.staged) {
|
|
1942
|
+
console.log(` ${pc9.green("+")} ${pc9.dim(`${status}:`)} ${file}`);
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
if (fileStatus.modified.length > 0) {
|
|
1946
|
+
console.log(` ${pc9.yellow("Unstaged changes:")}`);
|
|
1947
|
+
for (const { file, status } of fileStatus.modified) {
|
|
1948
|
+
console.log(` ${pc9.yellow("~")} ${pc9.dim(`${status}:`)} ${file}`);
|
|
1949
|
+
}
|
|
1950
|
+
}
|
|
1951
|
+
if (fileStatus.untracked.length > 0) {
|
|
1952
|
+
console.log(` ${pc9.red("Untracked files:")}`);
|
|
1953
|
+
for (const file of fileStatus.untracked) {
|
|
1954
|
+
console.log(` ${pc9.red("?")} ${file}`);
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
} else if (!dirty) {
|
|
1958
|
+
console.log(` ${pc9.green("✓")} ${pc9.dim("Working tree clean")}`);
|
|
1959
|
+
}
|
|
1960
|
+
const tips = [];
|
|
1961
|
+
if (fileStatus.staged.length > 0) {
|
|
1962
|
+
tips.push(`Run ${pc9.bold("contrib commit")} to commit staged changes`);
|
|
1963
|
+
}
|
|
1964
|
+
if (fileStatus.modified.length > 0 || fileStatus.untracked.length > 0) {
|
|
1965
|
+
tips.push(`Run ${pc9.bold("contrib commit")} to stage and commit changes`);
|
|
1966
|
+
}
|
|
1967
|
+
if (fileStatus.staged.length === 0 && fileStatus.modified.length === 0 && fileStatus.untracked.length === 0 && currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
|
|
1968
|
+
const branchDiv = await getDivergence(currentBranch, `${origin}/${currentBranch}`);
|
|
1969
|
+
if (branchDiv.ahead > 0) {
|
|
1970
|
+
tips.push(`Run ${pc9.bold("contrib submit")} to push and create/update your PR`);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
if (tips.length > 0) {
|
|
1974
|
+
console.log();
|
|
1975
|
+
console.log(` ${pc9.dim("\uD83D\uDCA1 Tip:")}`);
|
|
1976
|
+
for (const tip of tips) {
|
|
1977
|
+
console.log(` ${pc9.dim(tip)}`);
|
|
1978
|
+
}
|
|
1243
1979
|
}
|
|
1244
1980
|
console.log();
|
|
1245
1981
|
}
|
|
1246
1982
|
});
|
|
1247
1983
|
function formatStatus(branch, base, ahead, behind) {
|
|
1248
|
-
const label =
|
|
1984
|
+
const label = pc9.bold(branch.padEnd(20));
|
|
1249
1985
|
if (ahead === 0 && behind === 0) {
|
|
1250
|
-
return ` ${
|
|
1986
|
+
return ` ${pc9.green("✓")} ${label} ${pc9.dim(`in sync with ${base}`)}`;
|
|
1251
1987
|
}
|
|
1252
1988
|
if (ahead > 0 && behind === 0) {
|
|
1253
|
-
return ` ${
|
|
1989
|
+
return ` ${pc9.yellow("↑")} ${label} ${pc9.yellow(`${ahead} commit${ahead !== 1 ? "s" : ""} ahead of ${base}`)}`;
|
|
1254
1990
|
}
|
|
1255
1991
|
if (behind > 0 && ahead === 0) {
|
|
1256
|
-
return ` ${
|
|
1992
|
+
return ` ${pc9.red("↓")} ${label} ${pc9.red(`${behind} commit${behind !== 1 ? "s" : ""} behind ${base}`)}`;
|
|
1257
1993
|
}
|
|
1258
|
-
return ` ${
|
|
1994
|
+
return ` ${pc9.red("⚡")} ${label} ${pc9.yellow(`${ahead} ahead`)}${pc9.dim(", ")}${pc9.red(`${behind} behind`)} ${pc9.dim(base)}`;
|
|
1259
1995
|
}
|
|
1260
1996
|
|
|
1261
1997
|
// src/commands/submit.ts
|
|
1262
1998
|
import { defineCommand as defineCommand7 } from "citty";
|
|
1263
|
-
import
|
|
1999
|
+
import pc10 from "picocolors";
|
|
2000
|
+
async function performSquashMerge(origin, baseBranch, featureBranch, options) {
|
|
2001
|
+
info(`Checking out ${pc10.bold(baseBranch)}...`);
|
|
2002
|
+
const coResult = await checkoutBranch(baseBranch);
|
|
2003
|
+
if (coResult.exitCode !== 0) {
|
|
2004
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
2005
|
+
process.exit(1);
|
|
2006
|
+
}
|
|
2007
|
+
info(`Squash merging ${pc10.bold(featureBranch)} into ${pc10.bold(baseBranch)}...`);
|
|
2008
|
+
const mergeResult = await mergeSquash(featureBranch);
|
|
2009
|
+
if (mergeResult.exitCode !== 0) {
|
|
2010
|
+
error(`Squash merge failed: ${mergeResult.stderr}`);
|
|
2011
|
+
process.exit(1);
|
|
2012
|
+
}
|
|
2013
|
+
let message = options?.defaultMsg;
|
|
2014
|
+
if (!message) {
|
|
2015
|
+
const copilotError = await checkCopilotAvailable();
|
|
2016
|
+
if (!copilotError) {
|
|
2017
|
+
const spinner = createSpinner("Generating AI commit message for squash merge...");
|
|
2018
|
+
const [stagedDiff, stagedFiles] = await Promise.all([getStagedDiff(), getStagedFiles()]);
|
|
2019
|
+
const aiMsg = await generateCommitMessage(stagedDiff, stagedFiles, options?.model, options?.convention ?? "clean-commit");
|
|
2020
|
+
if (aiMsg) {
|
|
2021
|
+
message = aiMsg;
|
|
2022
|
+
spinner.success("AI commit message generated.");
|
|
2023
|
+
} else {
|
|
2024
|
+
spinner.fail("AI did not return a commit message.");
|
|
2025
|
+
}
|
|
2026
|
+
} else {
|
|
2027
|
+
warn(`AI unavailable: ${copilotError}`);
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
const fallback = message || `squash merge ${featureBranch}`;
|
|
2031
|
+
const finalMsg = await inputPrompt("Commit message", fallback);
|
|
2032
|
+
const commitResult = await commitWithMessage(finalMsg);
|
|
2033
|
+
if (commitResult.exitCode !== 0) {
|
|
2034
|
+
error(`Commit failed: ${commitResult.stderr}`);
|
|
2035
|
+
process.exit(1);
|
|
2036
|
+
}
|
|
2037
|
+
info(`Pushing ${pc10.bold(baseBranch)} to ${origin}...`);
|
|
2038
|
+
const pushResult = await pushBranch(origin, baseBranch);
|
|
2039
|
+
if (pushResult.exitCode !== 0) {
|
|
2040
|
+
error(`Failed to push ${baseBranch}: ${pushResult.stderr}`);
|
|
2041
|
+
process.exit(1);
|
|
2042
|
+
}
|
|
2043
|
+
info(`Deleting local branch ${pc10.bold(featureBranch)}...`);
|
|
2044
|
+
const delLocal = await forceDeleteBranch(featureBranch);
|
|
2045
|
+
if (delLocal.exitCode !== 0) {
|
|
2046
|
+
warn(`Could not delete local branch: ${delLocal.stderr.trim()}`);
|
|
2047
|
+
}
|
|
2048
|
+
info(`Deleting remote branch ${pc10.bold(featureBranch)}...`);
|
|
2049
|
+
const delRemote = await deleteRemoteBranch(origin, featureBranch);
|
|
2050
|
+
if (delRemote.exitCode !== 0) {
|
|
2051
|
+
warn(`Could not delete remote branch: ${delRemote.stderr.trim()}`);
|
|
2052
|
+
}
|
|
2053
|
+
success(`✅ Squash merged ${pc10.bold(featureBranch)} into ${pc10.bold(baseBranch)} and pushed.`);
|
|
2054
|
+
info(`Run ${pc10.bold("contrib start")} to begin a new feature.`);
|
|
2055
|
+
}
|
|
1264
2056
|
var submit_default = defineCommand7({
|
|
1265
2057
|
meta: {
|
|
1266
2058
|
name: "submit",
|
|
@@ -1301,61 +2093,156 @@ var submit_default = defineCommand7({
|
|
|
1301
2093
|
process.exit(1);
|
|
1302
2094
|
}
|
|
1303
2095
|
if (protectedBranches.includes(currentBranch)) {
|
|
1304
|
-
error(`Cannot submit ${protectedBranches.map((b) =>
|
|
2096
|
+
error(`Cannot submit ${protectedBranches.map((b) => pc10.bold(b)).join(" or ")} as a PR. Switch to your feature branch.`);
|
|
1305
2097
|
process.exit(1);
|
|
1306
2098
|
}
|
|
1307
2099
|
heading("\uD83D\uDE80 contrib submit");
|
|
1308
|
-
|
|
2100
|
+
const ghInstalled = await checkGhInstalled();
|
|
2101
|
+
const ghAuthed = ghInstalled && await checkGhAuth();
|
|
2102
|
+
if (ghInstalled && ghAuthed) {
|
|
2103
|
+
const mergedPR = await getMergedPRForBranch(currentBranch);
|
|
2104
|
+
if (mergedPR) {
|
|
2105
|
+
warn(`PR #${mergedPR.number} (${pc10.bold(mergedPR.title)}) was already merged.`);
|
|
2106
|
+
const localWork = await hasLocalWork(origin, currentBranch);
|
|
2107
|
+
const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
|
|
2108
|
+
if (hasWork) {
|
|
2109
|
+
if (localWork.uncommitted) {
|
|
2110
|
+
warn("You have uncommitted changes in your working tree.");
|
|
2111
|
+
}
|
|
2112
|
+
if (localWork.unpushedCommits > 0) {
|
|
2113
|
+
warn(`You have ${pc10.bold(String(localWork.unpushedCommits))} local commit${localWork.unpushedCommits !== 1 ? "s" : ""} not in the merged PR.`);
|
|
2114
|
+
}
|
|
2115
|
+
const SAVE_NEW_BRANCH = "Save changes to a new branch";
|
|
2116
|
+
const DISCARD = "Discard all changes and clean up";
|
|
2117
|
+
const CANCEL2 = "Cancel";
|
|
2118
|
+
const action = await selectPrompt("This branch was merged but you have local changes. What would you like to do?", [SAVE_NEW_BRANCH, DISCARD, CANCEL2]);
|
|
2119
|
+
if (action === CANCEL2) {
|
|
2120
|
+
info("No changes made. You are still on your current branch.");
|
|
2121
|
+
return;
|
|
2122
|
+
}
|
|
2123
|
+
if (action === SAVE_NEW_BRANCH) {
|
|
2124
|
+
const suggestedName = currentBranch.replace(/^(feature|fix|docs|chore|test|refactor)\//, "$1/new-");
|
|
2125
|
+
const newBranchName = await inputPrompt("New branch name", suggestedName !== currentBranch ? suggestedName : `${currentBranch}-v2`);
|
|
2126
|
+
const renameResult = await renameBranch(currentBranch, newBranchName);
|
|
2127
|
+
if (renameResult.exitCode !== 0) {
|
|
2128
|
+
error(`Failed to rename branch: ${renameResult.stderr}`);
|
|
2129
|
+
process.exit(1);
|
|
2130
|
+
}
|
|
2131
|
+
success(`Renamed ${pc10.bold(currentBranch)} → ${pc10.bold(newBranchName)}`);
|
|
2132
|
+
const syncSource2 = getSyncSource(config);
|
|
2133
|
+
info(`Syncing ${pc10.bold(newBranchName)} with latest ${pc10.bold(baseBranch)}...`);
|
|
2134
|
+
await fetchRemote(syncSource2.remote);
|
|
2135
|
+
const savedUpstreamRef = await getUpstreamRef();
|
|
2136
|
+
const rebaseResult = savedUpstreamRef && savedUpstreamRef !== syncSource2.ref ? await rebaseOnto(syncSource2.ref, savedUpstreamRef) : await rebase(syncSource2.ref);
|
|
2137
|
+
if (rebaseResult.exitCode !== 0) {
|
|
2138
|
+
warn("Rebase encountered conflicts. Resolve them manually, then run:");
|
|
2139
|
+
info(` ${pc10.bold("git rebase --continue")}`);
|
|
2140
|
+
} else {
|
|
2141
|
+
success(`Rebased ${pc10.bold(newBranchName)} onto ${pc10.bold(syncSource2.ref)}.`);
|
|
2142
|
+
}
|
|
2143
|
+
info(`All your changes are preserved. Run ${pc10.bold("contrib submit")} when ready to create a new PR.`);
|
|
2144
|
+
return;
|
|
2145
|
+
}
|
|
2146
|
+
warn("Discarding local changes...");
|
|
2147
|
+
}
|
|
2148
|
+
const syncSource = getSyncSource(config);
|
|
2149
|
+
info(`Switching to ${pc10.bold(baseBranch)} and syncing...`);
|
|
2150
|
+
await fetchRemote(syncSource.remote);
|
|
2151
|
+
const coResult = await checkoutBranch(baseBranch);
|
|
2152
|
+
if (coResult.exitCode !== 0) {
|
|
2153
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
2154
|
+
process.exit(1);
|
|
2155
|
+
}
|
|
2156
|
+
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
2157
|
+
success(`Synced ${pc10.bold(baseBranch)} with ${pc10.bold(syncSource.ref)}.`);
|
|
2158
|
+
info(`Deleting stale branch ${pc10.bold(currentBranch)}...`);
|
|
2159
|
+
const delResult = await forceDeleteBranch(currentBranch);
|
|
2160
|
+
if (delResult.exitCode === 0) {
|
|
2161
|
+
success(`Deleted ${pc10.bold(currentBranch)}.`);
|
|
2162
|
+
} else {
|
|
2163
|
+
warn(`Could not delete branch: ${delResult.stderr.trim()}`);
|
|
2164
|
+
}
|
|
2165
|
+
console.log();
|
|
2166
|
+
info(`You're now on ${pc10.bold(baseBranch)}. Run ${pc10.bold("contrib start")} to begin a new feature.`);
|
|
2167
|
+
return;
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
info(`Pushing ${pc10.bold(currentBranch)} to ${origin}...`);
|
|
1309
2171
|
const pushResult = await pushSetUpstream(origin, currentBranch);
|
|
1310
2172
|
if (pushResult.exitCode !== 0) {
|
|
1311
2173
|
error(`Failed to push: ${pushResult.stderr}`);
|
|
1312
2174
|
process.exit(1);
|
|
1313
2175
|
}
|
|
1314
|
-
const ghInstalled = await checkGhInstalled();
|
|
1315
|
-
const ghAuthed = ghInstalled && await checkGhAuth();
|
|
1316
2176
|
if (!ghInstalled || !ghAuthed) {
|
|
1317
2177
|
const repoInfo = await getRepoInfoFromRemote(origin);
|
|
1318
2178
|
if (repoInfo) {
|
|
1319
2179
|
const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${baseBranch}...${currentBranch}?expand=1`;
|
|
1320
2180
|
console.log();
|
|
1321
2181
|
info("Create your PR manually:");
|
|
1322
|
-
console.log(` ${
|
|
2182
|
+
console.log(` ${pc10.cyan(prUrl)}`);
|
|
1323
2183
|
} else {
|
|
1324
2184
|
info("gh CLI not available. Create your PR manually on GitHub.");
|
|
1325
2185
|
}
|
|
1326
2186
|
return;
|
|
1327
2187
|
}
|
|
2188
|
+
const existingPR = await getPRForBranch(currentBranch);
|
|
2189
|
+
if (existingPR) {
|
|
2190
|
+
success(`Pushed changes to existing PR #${existingPR.number}: ${pc10.bold(existingPR.title)}`);
|
|
2191
|
+
console.log(` ${pc10.cyan(existingPR.url)}`);
|
|
2192
|
+
return;
|
|
2193
|
+
}
|
|
1328
2194
|
let prTitle = null;
|
|
1329
2195
|
let prBody = null;
|
|
1330
2196
|
if (!args["no-ai"]) {
|
|
1331
|
-
const copilotError = await
|
|
2197
|
+
const [copilotError, commits, diff] = await Promise.all([
|
|
2198
|
+
checkCopilotAvailable(),
|
|
2199
|
+
getLog(baseBranch, "HEAD"),
|
|
2200
|
+
getLogDiff(baseBranch, "HEAD")
|
|
2201
|
+
]);
|
|
1332
2202
|
if (!copilotError) {
|
|
1333
|
-
|
|
1334
|
-
const
|
|
1335
|
-
const diff = await getLogDiff(baseBranch, "HEAD");
|
|
1336
|
-
const result = await generatePRDescription(commits, diff, args.model);
|
|
2203
|
+
const spinner = createSpinner("Generating AI PR description...");
|
|
2204
|
+
const result = await generatePRDescription(commits, diff, args.model, config.commitConvention);
|
|
1337
2205
|
if (result) {
|
|
1338
2206
|
prTitle = result.title;
|
|
1339
2207
|
prBody = result.body;
|
|
2208
|
+
spinner.success("PR description generated.");
|
|
1340
2209
|
console.log(`
|
|
1341
|
-
${
|
|
2210
|
+
${pc10.dim("AI title:")} ${pc10.bold(pc10.cyan(prTitle))}`);
|
|
1342
2211
|
console.log(`
|
|
1343
|
-
${
|
|
1344
|
-
console.log(
|
|
2212
|
+
${pc10.dim("AI body preview:")}`);
|
|
2213
|
+
console.log(pc10.dim(prBody.slice(0, 300) + (prBody.length > 300 ? "..." : "")));
|
|
1345
2214
|
} else {
|
|
1346
|
-
|
|
2215
|
+
spinner.fail("AI did not return a PR description.");
|
|
1347
2216
|
}
|
|
1348
2217
|
} else {
|
|
1349
2218
|
warn(`AI unavailable: ${copilotError}`);
|
|
1350
2219
|
}
|
|
1351
2220
|
}
|
|
2221
|
+
const CANCEL = "Cancel";
|
|
2222
|
+
const SQUASH_LOCAL = `Squash merge to ${baseBranch} locally (no PR)`;
|
|
1352
2223
|
if (prTitle && prBody) {
|
|
1353
|
-
const
|
|
2224
|
+
const choices = [
|
|
1354
2225
|
"Use AI description",
|
|
1355
2226
|
"Edit title",
|
|
1356
2227
|
"Write manually",
|
|
1357
2228
|
"Use gh --fill (auto-fill from commits)"
|
|
1358
|
-
]
|
|
2229
|
+
];
|
|
2230
|
+
if (config.role === "maintainer")
|
|
2231
|
+
choices.push(SQUASH_LOCAL);
|
|
2232
|
+
choices.push(CANCEL);
|
|
2233
|
+
const action = await selectPrompt("What would you like to do with the PR description?", choices);
|
|
2234
|
+
if (action === CANCEL) {
|
|
2235
|
+
warn("Submit cancelled.");
|
|
2236
|
+
return;
|
|
2237
|
+
}
|
|
2238
|
+
if (action === SQUASH_LOCAL) {
|
|
2239
|
+
await performSquashMerge(origin, baseBranch, currentBranch, {
|
|
2240
|
+
defaultMsg: prTitle ?? undefined,
|
|
2241
|
+
model: args.model,
|
|
2242
|
+
convention: config.commitConvention
|
|
2243
|
+
});
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
1359
2246
|
if (action === "Use AI description") {} else if (action === "Edit title") {
|
|
1360
2247
|
prTitle = await inputPrompt("PR title", prTitle);
|
|
1361
2248
|
} else if (action === "Write manually") {
|
|
@@ -1371,8 +2258,26 @@ ${pc9.dim("AI body preview:")}`);
|
|
|
1371
2258
|
return;
|
|
1372
2259
|
}
|
|
1373
2260
|
} else {
|
|
1374
|
-
const
|
|
1375
|
-
|
|
2261
|
+
const choices = [
|
|
2262
|
+
"Write title & body manually",
|
|
2263
|
+
"Use gh --fill (auto-fill from commits)"
|
|
2264
|
+
];
|
|
2265
|
+
if (config.role === "maintainer")
|
|
2266
|
+
choices.push(SQUASH_LOCAL);
|
|
2267
|
+
choices.push(CANCEL);
|
|
2268
|
+
const action = await selectPrompt("How would you like to create the PR?", choices);
|
|
2269
|
+
if (action === CANCEL) {
|
|
2270
|
+
warn("Submit cancelled.");
|
|
2271
|
+
return;
|
|
2272
|
+
}
|
|
2273
|
+
if (action === SQUASH_LOCAL) {
|
|
2274
|
+
await performSquashMerge(origin, baseBranch, currentBranch, {
|
|
2275
|
+
model: args.model,
|
|
2276
|
+
convention: config.commitConvention
|
|
2277
|
+
});
|
|
2278
|
+
return;
|
|
2279
|
+
}
|
|
2280
|
+
if (action === "Write title & body manually") {
|
|
1376
2281
|
prTitle = await inputPrompt("PR title");
|
|
1377
2282
|
prBody = await inputPrompt("PR body (markdown)");
|
|
1378
2283
|
} else {
|
|
@@ -1405,7 +2310,7 @@ ${pc9.dim("AI body preview:")}`);
|
|
|
1405
2310
|
|
|
1406
2311
|
// src/commands/sync.ts
|
|
1407
2312
|
import { defineCommand as defineCommand8 } from "citty";
|
|
1408
|
-
import
|
|
2313
|
+
import pc11 from "picocolors";
|
|
1409
2314
|
var sync_default = defineCommand8({
|
|
1410
2315
|
meta: {
|
|
1411
2316
|
name: "sync",
|
|
@@ -1448,12 +2353,12 @@ var sync_default = defineCommand8({
|
|
|
1448
2353
|
}
|
|
1449
2354
|
const div = await getDivergence(baseBranch, syncSource.ref);
|
|
1450
2355
|
if (div.ahead > 0 || div.behind > 0) {
|
|
1451
|
-
info(`${
|
|
2356
|
+
info(`${pc11.bold(baseBranch)} is ${pc11.yellow(`${div.ahead} ahead`)} and ${pc11.red(`${div.behind} behind`)} ${syncSource.ref}`);
|
|
1452
2357
|
} else {
|
|
1453
|
-
info(`${
|
|
2358
|
+
info(`${pc11.bold(baseBranch)} is already in sync with ${syncSource.ref}`);
|
|
1454
2359
|
}
|
|
1455
2360
|
if (!args.yes) {
|
|
1456
|
-
const ok = await confirmPrompt(`This will pull ${
|
|
2361
|
+
const ok = await confirmPrompt(`This will pull ${pc11.bold(syncSource.ref)} into local ${pc11.bold(baseBranch)}.`);
|
|
1457
2362
|
if (!ok)
|
|
1458
2363
|
process.exit(0);
|
|
1459
2364
|
}
|
|
@@ -1471,7 +2376,7 @@ var sync_default = defineCommand8({
|
|
|
1471
2376
|
if (hasDevBranch(workflow) && role === "maintainer") {
|
|
1472
2377
|
const mainDiv = await getDivergence(config.mainBranch, `${origin}/${config.mainBranch}`);
|
|
1473
2378
|
if (mainDiv.behind > 0) {
|
|
1474
|
-
info(`Also syncing ${
|
|
2379
|
+
info(`Also syncing ${pc11.bold(config.mainBranch)}...`);
|
|
1475
2380
|
const mainCoResult = await checkoutBranch(config.mainBranch);
|
|
1476
2381
|
if (mainCoResult.exitCode === 0) {
|
|
1477
2382
|
const mainPullResult = await pullBranch(origin, config.mainBranch);
|
|
@@ -1486,9 +2391,9 @@ var sync_default = defineCommand8({
|
|
|
1486
2391
|
});
|
|
1487
2392
|
|
|
1488
2393
|
// src/commands/update.ts
|
|
1489
|
-
import { readFileSync as
|
|
2394
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
1490
2395
|
import { defineCommand as defineCommand9 } from "citty";
|
|
1491
|
-
import
|
|
2396
|
+
import pc12 from "picocolors";
|
|
1492
2397
|
var update_default = defineCommand9({
|
|
1493
2398
|
meta: {
|
|
1494
2399
|
name: "update",
|
|
@@ -1524,7 +2429,7 @@ var update_default = defineCommand9({
|
|
|
1524
2429
|
process.exit(1);
|
|
1525
2430
|
}
|
|
1526
2431
|
if (protectedBranches.includes(currentBranch)) {
|
|
1527
|
-
error(`Use \`contrib sync\` to update ${protectedBranches.map((b) =>
|
|
2432
|
+
error(`Use \`contrib sync\` to update ${protectedBranches.map((b) => pc12.bold(b)).join(" or ")} branches.`);
|
|
1528
2433
|
process.exit(1);
|
|
1529
2434
|
}
|
|
1530
2435
|
if (await hasUncommittedChanges()) {
|
|
@@ -1532,10 +2437,92 @@ var update_default = defineCommand9({
|
|
|
1532
2437
|
process.exit(1);
|
|
1533
2438
|
}
|
|
1534
2439
|
heading("\uD83D\uDD03 contrib update");
|
|
1535
|
-
|
|
2440
|
+
const mergedPR = await getMergedPRForBranch(currentBranch);
|
|
2441
|
+
if (mergedPR) {
|
|
2442
|
+
warn(`PR #${mergedPR.number} (${pc12.bold(mergedPR.title)}) has already been merged.`);
|
|
2443
|
+
info(`Link: ${pc12.underline(mergedPR.url)}`);
|
|
2444
|
+
const localWork = await hasLocalWork(syncSource.remote, currentBranch);
|
|
2445
|
+
const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
|
|
2446
|
+
if (hasWork) {
|
|
2447
|
+
if (localWork.uncommitted) {
|
|
2448
|
+
info("You have uncommitted local changes.");
|
|
2449
|
+
}
|
|
2450
|
+
if (localWork.unpushedCommits > 0) {
|
|
2451
|
+
info(`You have ${localWork.unpushedCommits} unpushed commit(s).`);
|
|
2452
|
+
}
|
|
2453
|
+
const SAVE_NEW_BRANCH = "Save changes to a new branch";
|
|
2454
|
+
const DISCARD = "Discard all changes and clean up";
|
|
2455
|
+
const CANCEL = "Cancel";
|
|
2456
|
+
const action = await selectPrompt(`${pc12.bold(currentBranch)} is stale but has local work. What would you like to do?`, [SAVE_NEW_BRANCH, DISCARD, CANCEL]);
|
|
2457
|
+
if (action === CANCEL) {
|
|
2458
|
+
info("No changes made. You are still on your current branch.");
|
|
2459
|
+
return;
|
|
2460
|
+
}
|
|
2461
|
+
if (action === SAVE_NEW_BRANCH) {
|
|
2462
|
+
info(pc12.dim("Tip: Describe what you're working on in plain English and we'll generate a branch name."));
|
|
2463
|
+
const description = await inputPrompt("What are you working on?");
|
|
2464
|
+
let newBranchName = description;
|
|
2465
|
+
if (!args["no-ai"] && looksLikeNaturalLanguage(description)) {
|
|
2466
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
2467
|
+
const suggested = await suggestBranchName(description, args.model);
|
|
2468
|
+
if (suggested) {
|
|
2469
|
+
spinner.success("Branch name suggestion ready.");
|
|
2470
|
+
console.log(`
|
|
2471
|
+
${pc12.dim("AI suggestion:")} ${pc12.bold(pc12.cyan(suggested))}`);
|
|
2472
|
+
const accepted = await confirmPrompt(`Use ${pc12.bold(suggested)} as your branch name?`);
|
|
2473
|
+
newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
|
|
2474
|
+
} else {
|
|
2475
|
+
spinner.fail("AI did not return a suggestion.");
|
|
2476
|
+
newBranchName = await inputPrompt("Enter branch name", description);
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
if (!hasPrefix(newBranchName, config.branchPrefixes)) {
|
|
2480
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc12.bold(newBranchName)}:`, config.branchPrefixes);
|
|
2481
|
+
newBranchName = formatBranchName(prefix, newBranchName);
|
|
2482
|
+
}
|
|
2483
|
+
if (!isValidBranchName(newBranchName)) {
|
|
2484
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
2485
|
+
process.exit(1);
|
|
2486
|
+
}
|
|
2487
|
+
const renameResult = await renameBranch(currentBranch, newBranchName);
|
|
2488
|
+
if (renameResult.exitCode !== 0) {
|
|
2489
|
+
error(`Failed to rename branch: ${renameResult.stderr}`);
|
|
2490
|
+
process.exit(1);
|
|
2491
|
+
}
|
|
2492
|
+
success(`Renamed ${pc12.bold(currentBranch)} → ${pc12.bold(newBranchName)}`);
|
|
2493
|
+
await fetchRemote(syncSource.remote);
|
|
2494
|
+
const savedUpstreamRef = await getUpstreamRef();
|
|
2495
|
+
const rebaseResult2 = savedUpstreamRef && savedUpstreamRef !== syncSource.ref ? await rebaseOnto(syncSource.ref, savedUpstreamRef) : await rebase(syncSource.ref);
|
|
2496
|
+
if (rebaseResult2.exitCode !== 0) {
|
|
2497
|
+
warn("Rebase encountered conflicts. Resolve them manually, then run:");
|
|
2498
|
+
info(` ${pc12.bold("git rebase --continue")}`);
|
|
2499
|
+
} else {
|
|
2500
|
+
success(`Rebased ${pc12.bold(newBranchName)} onto ${pc12.bold(syncSource.ref)}.`);
|
|
2501
|
+
}
|
|
2502
|
+
info(`All your changes are preserved. Run ${pc12.bold("contrib submit")} when ready to create a new PR.`);
|
|
2503
|
+
return;
|
|
2504
|
+
}
|
|
2505
|
+
warn("Discarding local changes...");
|
|
2506
|
+
}
|
|
2507
|
+
await fetchRemote(syncSource.remote);
|
|
2508
|
+
const coResult = await checkoutBranch(baseBranch);
|
|
2509
|
+
if (coResult.exitCode !== 0) {
|
|
2510
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
2511
|
+
process.exit(1);
|
|
2512
|
+
}
|
|
2513
|
+
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
2514
|
+
success(`Synced ${pc12.bold(baseBranch)} with ${pc12.bold(syncSource.ref)}.`);
|
|
2515
|
+
info(`Deleting stale branch ${pc12.bold(currentBranch)}...`);
|
|
2516
|
+
await forceDeleteBranch(currentBranch);
|
|
2517
|
+
success(`Deleted ${pc12.bold(currentBranch)}.`);
|
|
2518
|
+
info(`Run ${pc12.bold("contrib start")} to begin a new feature branch.`);
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
info(`Updating ${pc12.bold(currentBranch)} with latest ${pc12.bold(baseBranch)}...`);
|
|
1536
2522
|
await fetchRemote(syncSource.remote);
|
|
1537
|
-
await
|
|
1538
|
-
const
|
|
2523
|
+
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
2524
|
+
const upstreamRef = await getUpstreamRef();
|
|
2525
|
+
const rebaseResult = upstreamRef && upstreamRef !== syncSource.ref ? await rebaseOnto(syncSource.ref, upstreamRef) : await rebase(syncSource.ref);
|
|
1539
2526
|
if (rebaseResult.exitCode !== 0) {
|
|
1540
2527
|
warn("Rebase hit conflicts. Resolve them manually.");
|
|
1541
2528
|
console.log();
|
|
@@ -1547,7 +2534,7 @@ var update_default = defineCommand9({
|
|
|
1547
2534
|
let conflictDiff = "";
|
|
1548
2535
|
for (const file of conflictFiles.slice(0, 3)) {
|
|
1549
2536
|
try {
|
|
1550
|
-
const content =
|
|
2537
|
+
const content = readFileSync4(file, "utf-8");
|
|
1551
2538
|
if (content.includes("<<<<<<<")) {
|
|
1552
2539
|
conflictDiff += `
|
|
1553
2540
|
--- ${file} ---
|
|
@@ -1557,33 +2544,37 @@ ${content.slice(0, 2000)}
|
|
|
1557
2544
|
} catch {}
|
|
1558
2545
|
}
|
|
1559
2546
|
if (conflictDiff) {
|
|
2547
|
+
const spinner = createSpinner("Analyzing conflicts with AI...");
|
|
1560
2548
|
const suggestion = await suggestConflictResolution(conflictDiff, args.model);
|
|
1561
2549
|
if (suggestion) {
|
|
2550
|
+
spinner.success("AI conflict guidance ready.");
|
|
1562
2551
|
console.log(`
|
|
1563
|
-
${
|
|
1564
|
-
console.log(
|
|
2552
|
+
${pc12.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
|
|
2553
|
+
console.log(pc12.dim("─".repeat(60)));
|
|
1565
2554
|
console.log(suggestion);
|
|
1566
|
-
console.log(
|
|
2555
|
+
console.log(pc12.dim("─".repeat(60)));
|
|
1567
2556
|
console.log();
|
|
2557
|
+
} else {
|
|
2558
|
+
spinner.fail("AI could not analyze the conflicts.");
|
|
1568
2559
|
}
|
|
1569
2560
|
}
|
|
1570
2561
|
}
|
|
1571
2562
|
}
|
|
1572
|
-
console.log(
|
|
2563
|
+
console.log(pc12.bold("To resolve:"));
|
|
1573
2564
|
console.log(` 1. Fix conflicts in the affected files`);
|
|
1574
|
-
console.log(` 2. ${
|
|
1575
|
-
console.log(` 3. ${
|
|
2565
|
+
console.log(` 2. ${pc12.cyan("git add <resolved-files>")}`);
|
|
2566
|
+
console.log(` 3. ${pc12.cyan("git rebase --continue")}`);
|
|
1576
2567
|
console.log();
|
|
1577
|
-
console.log(` Or abort: ${
|
|
2568
|
+
console.log(` Or abort: ${pc12.cyan("git rebase --abort")}`);
|
|
1578
2569
|
process.exit(1);
|
|
1579
2570
|
}
|
|
1580
|
-
success(`✅ ${
|
|
2571
|
+
success(`✅ ${pc12.bold(currentBranch)} has been rebased onto latest ${pc12.bold(baseBranch)}`);
|
|
1581
2572
|
}
|
|
1582
2573
|
});
|
|
1583
2574
|
|
|
1584
2575
|
// src/commands/validate.ts
|
|
1585
2576
|
import { defineCommand as defineCommand10 } from "citty";
|
|
1586
|
-
import
|
|
2577
|
+
import pc13 from "picocolors";
|
|
1587
2578
|
var validate_default = defineCommand10({
|
|
1588
2579
|
meta: {
|
|
1589
2580
|
name: "validate",
|
|
@@ -1614,7 +2605,7 @@ var validate_default = defineCommand10({
|
|
|
1614
2605
|
}
|
|
1615
2606
|
const errors = getValidationError(convention);
|
|
1616
2607
|
for (const line of errors) {
|
|
1617
|
-
console.error(
|
|
2608
|
+
console.error(pc13.red(` ✗ ${line}`));
|
|
1618
2609
|
}
|
|
1619
2610
|
process.exit(1);
|
|
1620
2611
|
}
|
|
@@ -1622,11 +2613,11 @@ var validate_default = defineCommand10({
|
|
|
1622
2613
|
|
|
1623
2614
|
// src/ui/banner.ts
|
|
1624
2615
|
import figlet from "figlet";
|
|
1625
|
-
import
|
|
2616
|
+
import pc14 from "picocolors";
|
|
1626
2617
|
// package.json
|
|
1627
2618
|
var package_default = {
|
|
1628
2619
|
name: "contribute-now",
|
|
1629
|
-
version: "0.2.0-
|
|
2620
|
+
version: "0.2.0-pr.17db3d2",
|
|
1630
2621
|
description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
|
|
1631
2622
|
type: "module",
|
|
1632
2623
|
bin: {
|
|
@@ -1638,12 +2629,12 @@ var package_default = {
|
|
|
1638
2629
|
],
|
|
1639
2630
|
scripts: {
|
|
1640
2631
|
build: "bun build src/index.ts --outfile dist/index.js --target node --packages external",
|
|
2632
|
+
cli: "bun run src/index.ts --",
|
|
1641
2633
|
dev: "bun src/index.ts",
|
|
1642
2634
|
test: "bun test",
|
|
1643
2635
|
lint: "biome check .",
|
|
1644
2636
|
"lint:fix": "biome check --write .",
|
|
1645
2637
|
format: "biome format --write .",
|
|
1646
|
-
prepare: "husky || true",
|
|
1647
2638
|
"www:dev": "bun run --cwd www dev",
|
|
1648
2639
|
"www:build": "bun run --cwd www build",
|
|
1649
2640
|
"www:preview": "bun run --cwd www preview"
|
|
@@ -1670,6 +2661,7 @@ var package_default = {
|
|
|
1670
2661
|
url: "git+https://github.com/warengonzaga/contribute-now.git"
|
|
1671
2662
|
},
|
|
1672
2663
|
dependencies: {
|
|
2664
|
+
"@clack/prompts": "^1.0.1",
|
|
1673
2665
|
"@github/copilot-sdk": "^0.1.25",
|
|
1674
2666
|
"@wgtechlabs/log-engine": "^2.3.1",
|
|
1675
2667
|
citty: "^0.1.6",
|
|
@@ -1680,7 +2672,6 @@ var package_default = {
|
|
|
1680
2672
|
"@biomejs/biome": "^2.4.4",
|
|
1681
2673
|
"@types/bun": "latest",
|
|
1682
2674
|
"@types/figlet": "^1.7.0",
|
|
1683
|
-
husky: "^9.1.7",
|
|
1684
2675
|
typescript: "^5.7.0"
|
|
1685
2676
|
}
|
|
1686
2677
|
};
|
|
@@ -1688,9 +2679,10 @@ var package_default = {
|
|
|
1688
2679
|
// src/ui/banner.ts
|
|
1689
2680
|
var LOGO;
|
|
1690
2681
|
try {
|
|
1691
|
-
LOGO = figlet.textSync(
|
|
2682
|
+
LOGO = figlet.textSync(`Contribute
|
|
2683
|
+
Now`, { font: "ANSI Shadow" });
|
|
1692
2684
|
} catch {
|
|
1693
|
-
LOGO = "
|
|
2685
|
+
LOGO = "Contribute Now";
|
|
1694
2686
|
}
|
|
1695
2687
|
function getVersion() {
|
|
1696
2688
|
return package_default.version ?? "unknown";
|
|
@@ -1698,16 +2690,15 @@ function getVersion() {
|
|
|
1698
2690
|
function getAuthor() {
|
|
1699
2691
|
return typeof package_default.author === "string" ? package_default.author : "unknown";
|
|
1700
2692
|
}
|
|
1701
|
-
function showBanner(
|
|
1702
|
-
console.log(
|
|
2693
|
+
function showBanner(showLinks = false) {
|
|
2694
|
+
console.log(pc14.cyan(`
|
|
1703
2695
|
${LOGO}`));
|
|
1704
|
-
console.log(` ${
|
|
1705
|
-
if (
|
|
1706
|
-
console.log(` ${pc13.dim(package_default.description)}`);
|
|
2696
|
+
console.log(` ${pc14.dim(`v${getVersion()}`)} ${pc14.dim("—")} ${pc14.dim(`Built by ${getAuthor()}`)}`);
|
|
2697
|
+
if (showLinks) {
|
|
1707
2698
|
console.log();
|
|
1708
|
-
console.log(` ${
|
|
1709
|
-
console.log(` ${
|
|
1710
|
-
console.log(` ${
|
|
2699
|
+
console.log(` ${pc14.yellow("Star")} ${pc14.cyan("https://github.com/warengonzaga/contribute-now")}`);
|
|
2700
|
+
console.log(` ${pc14.green("Contribute")} ${pc14.cyan("https://github.com/warengonzaga/contribute-now/blob/main/CONTRIBUTING.md")}`);
|
|
2701
|
+
console.log(` ${pc14.magenta("Sponsor")} ${pc14.cyan("https://warengonzaga.com/sponsor")}`);
|
|
1711
2702
|
}
|
|
1712
2703
|
console.log();
|
|
1713
2704
|
}
|
|
@@ -1746,4 +2737,6 @@ var main = defineCommand11({
|
|
|
1746
2737
|
}
|
|
1747
2738
|
}
|
|
1748
2739
|
});
|
|
1749
|
-
runMain(main)
|
|
2740
|
+
runMain(main).then(() => {
|
|
2741
|
+
process.exit(0);
|
|
2742
|
+
});
|