contribute-now 0.2.0-dev.70284d0 → 0.2.0-dev.7c81c96
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 +1277 -319
- package/package.json +2 -1
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
|
});
|
|
@@ -182,6 +173,15 @@ async function pushSetUpstream(remote, branch) {
|
|
|
182
173
|
async function rebase(branch) {
|
|
183
174
|
return run(["rebase", branch]);
|
|
184
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
|
+
}
|
|
185
185
|
async function getStagedDiff() {
|
|
186
186
|
const { stdout } = await run(["diff", "--cached"]);
|
|
187
187
|
return stdout;
|
|
@@ -197,8 +197,16 @@ async function getChangedFiles() {
|
|
|
197
197
|
const { exitCode, stdout } = await run(["status", "--porcelain"]);
|
|
198
198
|
if (exitCode !== 0)
|
|
199
199
|
return [];
|
|
200
|
-
return stdout.
|
|
201
|
-
`).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);
|
|
202
210
|
}
|
|
203
211
|
async function getDivergence(branch, base) {
|
|
204
212
|
const { exitCode, stdout } = await run([
|
|
@@ -222,9 +230,38 @@ async function getMergedBranches(base) {
|
|
|
222
230
|
return stdout.trim().split(`
|
|
223
231
|
`).map((b) => b.replace(/^\*?\s+/, "").trim()).filter(Boolean);
|
|
224
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
|
+
}
|
|
225
240
|
async function deleteBranch(branch) {
|
|
226
241
|
return run(["branch", "-d", branch]);
|
|
227
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
|
+
}
|
|
228
265
|
async function pruneRemote(remote) {
|
|
229
266
|
return run(["remote", "prune", remote]);
|
|
230
267
|
}
|
|
@@ -245,6 +282,85 @@ async function getLog(base, head) {
|
|
|
245
282
|
async function pullBranch(remote, branch) {
|
|
246
283
|
return run(["pull", remote, branch]);
|
|
247
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
|
+
}
|
|
248
364
|
|
|
249
365
|
// src/utils/logger.ts
|
|
250
366
|
import { LogEngine, LogMode } from "@wgtechlabs/log-engine";
|
|
@@ -322,6 +438,66 @@ function getProtectedBranches(config) {
|
|
|
322
438
|
}
|
|
323
439
|
|
|
324
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
|
+
}
|
|
325
501
|
var clean_default = defineCommand({
|
|
326
502
|
meta: {
|
|
327
503
|
name: "clean",
|
|
@@ -347,25 +523,43 @@ var clean_default = defineCommand({
|
|
|
347
523
|
}
|
|
348
524
|
const { origin } = config;
|
|
349
525
|
const baseBranch = getBaseBranch(config);
|
|
350
|
-
|
|
526
|
+
let currentBranch = await getCurrentBranch();
|
|
351
527
|
heading("\uD83E\uDDF9 contrib clean");
|
|
352
|
-
|
|
353
|
-
const
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
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.`);
|
|
357
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) {
|
|
358
541
|
console.log(`
|
|
359
|
-
${pc3.bold("
|
|
360
|
-
for (const b of
|
|
361
|
-
|
|
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}`);
|
|
362
546
|
}
|
|
363
547
|
console.log();
|
|
364
|
-
const ok = args.yes || await confirmPrompt(`Delete ${pc3.bold(String(
|
|
365
|
-
if (
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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
|
+
}
|
|
369
563
|
const result = await deleteBranch(branch);
|
|
370
564
|
if (result.exitCode === 0) {
|
|
371
565
|
success(` Deleted ${pc3.bold(branch)}`);
|
|
@@ -373,21 +567,58 @@ ${pc3.bold("Branches to delete:")}`);
|
|
|
373
567
|
warn(` Failed to delete ${branch}: ${result.stderr.trim()}`);
|
|
374
568
|
}
|
|
375
569
|
}
|
|
570
|
+
} else {
|
|
571
|
+
info("Skipped merged branch deletion.");
|
|
572
|
+
}
|
|
573
|
+
}
|
|
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.");
|
|
376
606
|
}
|
|
377
607
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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.`);
|
|
384
615
|
}
|
|
385
616
|
}
|
|
386
617
|
});
|
|
387
618
|
|
|
388
619
|
// src/commands/commit.ts
|
|
389
620
|
import { defineCommand as defineCommand2 } from "citty";
|
|
390
|
-
import
|
|
621
|
+
import pc5 from "picocolors";
|
|
391
622
|
|
|
392
623
|
// src/utils/convention.ts
|
|
393
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;
|
|
@@ -434,121 +665,98 @@ function getValidationError(convention) {
|
|
|
434
665
|
|
|
435
666
|
// src/utils/copilot.ts
|
|
436
667
|
import { CopilotClient } from "@github/copilot-sdk";
|
|
437
|
-
var CONVENTIONAL_COMMIT_SYSTEM_PROMPT = `
|
|
438
|
-
|
|
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.
|
|
439
684
|
|
|
440
|
-
|
|
441
|
-
feat – a new feature
|
|
442
|
-
fix – a bug fix
|
|
443
|
-
docs – documentation only changes
|
|
444
|
-
style – changes that do not affect code meaning (whitespace, formatting)
|
|
445
|
-
refactor – code change that neither fixes a bug nor adds a feature
|
|
446
|
-
perf – performance improvement
|
|
447
|
-
test – adding or correcting tests
|
|
448
|
-
build – changes to the build system or external dependencies
|
|
449
|
-
ci – changes to CI configuration files and scripts
|
|
450
|
-
chore – other changes that don't modify src or test files
|
|
451
|
-
revert – reverts a previous commit
|
|
685
|
+
${conventionBlock}
|
|
452
686
|
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
feat: add user authentication system
|
|
461
|
-
fix(auth): resolve token expiry issue
|
|
462
|
-
docs: update contributing guidelines
|
|
463
|
-
feat!: redesign authentication API`;
|
|
464
|
-
var CLEAN_COMMIT_SYSTEM_PROMPT = `You are a git commit message generator. Generate a Clean Commit message following this EXACT format:
|
|
465
|
-
<emoji> <type>[!][ (<scope>)]: <description>
|
|
466
|
-
|
|
467
|
-
CRITICAL spacing rules (must follow exactly):
|
|
468
|
-
- There MUST be a space between the emoji and the type
|
|
469
|
-
- If a scope is used, there MUST be a space before the opening parenthesis
|
|
470
|
-
- There MUST be a colon and a space after the type or scope before the description
|
|
471
|
-
- Pattern: EMOJI SPACE TYPE SPACE OPENPAREN SCOPE CLOSEPAREN COLON SPACE DESCRIPTION
|
|
472
|
-
|
|
473
|
-
Emoji and type table:
|
|
474
|
-
\uD83D\uDCE6 new – new features, files, or capabilities
|
|
475
|
-
\uD83D\uDD27 update – changes, refactoring, improvements
|
|
476
|
-
\uD83D\uDDD1️ remove – removing code, files, or dependencies
|
|
477
|
-
\uD83D\uDD12 security – security fixes or patches
|
|
478
|
-
⚙️ setup – configs, CI/CD, tooling, build systems
|
|
479
|
-
☕ chore – maintenance, dependency updates
|
|
480
|
-
\uD83E\uDDEA test – adding or updating tests
|
|
481
|
-
\uD83D\uDCD6 docs – documentation changes
|
|
482
|
-
\uD83D\uDE80 release – version releases
|
|
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
|
+
]
|
|
483
694
|
|
|
484
695
|
Rules:
|
|
485
|
-
-
|
|
486
|
-
-
|
|
487
|
-
-
|
|
488
|
-
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
⚙️ setup (ci): configure github actions workflow
|
|
494
|
-
\uD83D\uDCE6 new!: redesign authentication system
|
|
495
|
-
\uD83D\uDDD1️ remove (deps): drop unused lodash dependency
|
|
496
|
-
|
|
497
|
-
WRONG (never do this):
|
|
498
|
-
⚙️setup(ci): ... ← missing spaces
|
|
499
|
-
\uD83D\uDCE6new: ... ← missing space after emoji
|
|
500
|
-
\uD83D\uDD27 update(api): ... ← missing space before scope`;
|
|
501
|
-
var BRANCH_NAME_SYSTEM_PROMPT = `You are a git branch name generator. Convert natural language descriptions into proper git branch names.
|
|
502
|
-
|
|
503
|
-
Format: <prefix>/<kebab-case-name>
|
|
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>
|
|
504
704
|
Prefixes: feature, fix, docs, chore, test, refactor
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
+
function getPRDescriptionSystemPrompt(convention) {
|
|
709
|
+
if (convention === "clean-commit") {
|
|
710
|
+
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
711
|
+
CRITICAL: The PR title MUST follow the Clean Commit format exactly: <emoji> <type>: <description>
|
|
712
|
+
Emoji/type table: \uD83D\uDCE6 new, \uD83D\uDD27 update, \uD83D\uDDD1️ remove, \uD83D\uDD12 security, ⚙️ setup, ☕ chore, \uD83E\uDDEA test, \uD83D\uDCD6 docs, \uD83D\uDE80 release
|
|
713
|
+
Title examples: \uD83D\uDCE6 new: add user authentication | \uD83D\uDD27 update: improve error handling | \uD83D\uDDD1️ remove: drop legacy API
|
|
714
|
+
Rules: title follows convention, present tense, max 72 chars; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
|
|
715
|
+
}
|
|
716
|
+
if (convention === "conventional") {
|
|
717
|
+
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
718
|
+
CRITICAL: The PR title MUST follow Conventional Commits format: <type>[(<scope>)]: <description>
|
|
719
|
+
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
|
720
|
+
Title examples: feat: add user authentication | fix(auth): resolve token expiry | docs: update contributing guide
|
|
721
|
+
Rules: title follows convention, present tense, max 72 chars; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
|
|
722
|
+
}
|
|
723
|
+
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
724
|
+
Rules: title concise present tense; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
|
|
521
725
|
}
|
|
522
|
-
|
|
523
|
-
Rules:
|
|
524
|
-
- title: concise, present tense, describes what the PR does
|
|
525
|
-
- body: markdown with Summary, Changes (bullet list), and Test Plan sections
|
|
526
|
-
- Return ONLY the JSON object, no markdown fences, no extra text`;
|
|
527
|
-
var CONFLICT_RESOLUTION_SYSTEM_PROMPT = `You are a git merge conflict resolution advisor. Analyze the conflict markers and provide guidance.
|
|
528
|
-
|
|
529
|
-
Rules:
|
|
530
|
-
- Explain what each side of the conflict contains
|
|
531
|
-
- Suggest the most likely correct resolution strategy
|
|
532
|
-
- Never auto-resolve — provide guidance only
|
|
533
|
-
- Be concise and actionable`;
|
|
726
|
+
var CONFLICT_RESOLUTION_SYSTEM_PROMPT = `Git merge conflict advisor. Explain each side, suggest resolution strategy. Never auto-resolve — guidance only. Be concise and actionable.`;
|
|
534
727
|
function suppressSubprocessWarnings() {
|
|
535
|
-
const prev = process.env.NODE_NO_WARNINGS;
|
|
536
728
|
process.env.NODE_NO_WARNINGS = "1";
|
|
537
|
-
return prev;
|
|
538
729
|
}
|
|
539
|
-
function
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
730
|
+
function withTimeout(promise, ms) {
|
|
731
|
+
return new Promise((resolve, reject) => {
|
|
732
|
+
const timer = setTimeout(() => reject(new Error(`Copilot request timed out after ${ms / 1000}s`)), ms);
|
|
733
|
+
promise.then((val) => {
|
|
734
|
+
clearTimeout(timer);
|
|
735
|
+
resolve(val);
|
|
736
|
+
}, (err) => {
|
|
737
|
+
clearTimeout(timer);
|
|
738
|
+
reject(err);
|
|
739
|
+
});
|
|
740
|
+
});
|
|
545
741
|
}
|
|
742
|
+
var COPILOT_TIMEOUT_MS = 30000;
|
|
743
|
+
var COPILOT_LONG_TIMEOUT_MS = 90000;
|
|
546
744
|
async function checkCopilotAvailable() {
|
|
547
|
-
let client = null;
|
|
548
|
-
const prev = suppressSubprocessWarnings();
|
|
549
745
|
try {
|
|
550
|
-
client =
|
|
551
|
-
|
|
746
|
+
const client = await getManagedClient();
|
|
747
|
+
try {
|
|
748
|
+
await client.ping();
|
|
749
|
+
} catch (err) {
|
|
750
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
751
|
+
if (msg.includes("auth") || msg.includes("token") || msg.includes("401") || msg.includes("403")) {
|
|
752
|
+
return "Copilot authentication failed. Run `gh auth login` to refresh your token.";
|
|
753
|
+
}
|
|
754
|
+
if (msg.includes("ECONNREFUSED") || msg.includes("timeout") || msg.includes("network")) {
|
|
755
|
+
return "Could not reach GitHub Copilot service. Check your internet connection.";
|
|
756
|
+
}
|
|
757
|
+
return `Copilot health check failed: ${msg}`;
|
|
758
|
+
}
|
|
759
|
+
return null;
|
|
552
760
|
} catch (err) {
|
|
553
761
|
const msg = err instanceof Error ? err.message : String(err);
|
|
554
762
|
if (msg.includes("ENOENT") || msg.includes("not found")) {
|
|
@@ -556,47 +764,45 @@ async function checkCopilotAvailable() {
|
|
|
556
764
|
}
|
|
557
765
|
return `Failed to start Copilot service: ${msg}`;
|
|
558
766
|
}
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
767
|
+
}
|
|
768
|
+
var _managedClient = null;
|
|
769
|
+
var _clientStarted = false;
|
|
770
|
+
async function getManagedClient() {
|
|
771
|
+
if (!_managedClient || !_clientStarted) {
|
|
772
|
+
suppressSubprocessWarnings();
|
|
773
|
+
_managedClient = new CopilotClient;
|
|
774
|
+
await _managedClient.start();
|
|
775
|
+
_clientStarted = true;
|
|
776
|
+
const cleanup = () => {
|
|
777
|
+
if (_managedClient && _clientStarted) {
|
|
778
|
+
try {
|
|
779
|
+
_managedClient.stop();
|
|
780
|
+
} catch {}
|
|
781
|
+
_clientStarted = false;
|
|
782
|
+
_managedClient = null;
|
|
783
|
+
}
|
|
784
|
+
};
|
|
785
|
+
process.once("exit", cleanup);
|
|
786
|
+
process.once("SIGINT", cleanup);
|
|
787
|
+
process.once("SIGTERM", cleanup);
|
|
575
788
|
}
|
|
576
|
-
return
|
|
789
|
+
return _managedClient;
|
|
577
790
|
}
|
|
578
|
-
async function callCopilot(systemMessage, userMessage, model) {
|
|
579
|
-
const
|
|
580
|
-
const
|
|
581
|
-
|
|
791
|
+
async function callCopilot(systemMessage, userMessage, model, timeoutMs = COPILOT_TIMEOUT_MS) {
|
|
792
|
+
const client = await getManagedClient();
|
|
793
|
+
const sessionConfig = {
|
|
794
|
+
systemMessage: { mode: "replace", content: systemMessage }
|
|
795
|
+
};
|
|
796
|
+
if (model)
|
|
797
|
+
sessionConfig.model = model;
|
|
798
|
+
const session = await client.createSession(sessionConfig);
|
|
582
799
|
try {
|
|
583
|
-
const
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
sessionConfig.model = model;
|
|
588
|
-
const session = await client.createSession(sessionConfig);
|
|
589
|
-
try {
|
|
590
|
-
const response = await session.sendAndWait({ prompt: userMessage });
|
|
591
|
-
if (!response?.data?.content)
|
|
592
|
-
return null;
|
|
593
|
-
return response.data.content;
|
|
594
|
-
} finally {
|
|
595
|
-
await session.destroy();
|
|
596
|
-
}
|
|
800
|
+
const response = await withTimeout(session.sendAndWait({ prompt: userMessage }), timeoutMs);
|
|
801
|
+
if (!response?.data?.content)
|
|
802
|
+
return null;
|
|
803
|
+
return response.data.content;
|
|
597
804
|
} finally {
|
|
598
|
-
|
|
599
|
-
await client.stop();
|
|
805
|
+
await session.destroy();
|
|
600
806
|
}
|
|
601
807
|
}
|
|
602
808
|
function getCommitSystemPrompt(convention) {
|
|
@@ -604,21 +810,53 @@ function getCommitSystemPrompt(convention) {
|
|
|
604
810
|
return CONVENTIONAL_COMMIT_SYSTEM_PROMPT;
|
|
605
811
|
return CLEAN_COMMIT_SYSTEM_PROMPT;
|
|
606
812
|
}
|
|
813
|
+
function extractJson(raw) {
|
|
814
|
+
let text2 = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
|
|
815
|
+
if (text2.startsWith("[") || text2.startsWith("{"))
|
|
816
|
+
return text2;
|
|
817
|
+
const arrayStart = text2.indexOf("[");
|
|
818
|
+
const objStart = text2.indexOf("{");
|
|
819
|
+
let start;
|
|
820
|
+
let closeChar;
|
|
821
|
+
if (arrayStart === -1 && objStart === -1)
|
|
822
|
+
return text2;
|
|
823
|
+
if (arrayStart === -1) {
|
|
824
|
+
start = objStart;
|
|
825
|
+
closeChar = "}";
|
|
826
|
+
} else if (objStart === -1) {
|
|
827
|
+
start = arrayStart;
|
|
828
|
+
closeChar = "]";
|
|
829
|
+
} else if (arrayStart < objStart) {
|
|
830
|
+
start = arrayStart;
|
|
831
|
+
closeChar = "]";
|
|
832
|
+
} else {
|
|
833
|
+
start = objStart;
|
|
834
|
+
closeChar = "}";
|
|
835
|
+
}
|
|
836
|
+
const end = text2.lastIndexOf(closeChar);
|
|
837
|
+
if (end > start) {
|
|
838
|
+
text2 = text2.slice(start, end + 1);
|
|
839
|
+
}
|
|
840
|
+
return text2;
|
|
841
|
+
}
|
|
607
842
|
async function generateCommitMessage(diff, stagedFiles, model, convention = "clean-commit") {
|
|
608
843
|
try {
|
|
844
|
+
const multiFileHint = stagedFiles.length > 1 ? `
|
|
845
|
+
|
|
846
|
+
IMPORTANT: Multiple files are staged. Generate ONE commit message that captures the high-level purpose of ALL changes together. Focus on the overall intent, not individual file changes. Be specific but concise — do not list every file.` : "";
|
|
609
847
|
const userMessage = `Generate a commit message for these staged changes:
|
|
610
848
|
|
|
611
849
|
Files: ${stagedFiles.join(", ")}
|
|
612
850
|
|
|
613
851
|
Diff:
|
|
614
|
-
${diff.slice(0, 4000)}`;
|
|
852
|
+
${diff.slice(0, 4000)}${multiFileHint}`;
|
|
615
853
|
const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
|
|
616
854
|
return result?.trim() ?? null;
|
|
617
855
|
} catch {
|
|
618
856
|
return null;
|
|
619
857
|
}
|
|
620
858
|
}
|
|
621
|
-
async function generatePRDescription(commits, diff, model) {
|
|
859
|
+
async function generatePRDescription(commits, diff, model, convention = "clean-commit") {
|
|
622
860
|
try {
|
|
623
861
|
const userMessage = `Generate a PR description for these changes:
|
|
624
862
|
|
|
@@ -628,10 +866,10 @@ ${commits.join(`
|
|
|
628
866
|
|
|
629
867
|
Diff (truncated):
|
|
630
868
|
${diff.slice(0, 4000)}`;
|
|
631
|
-
const result = await callCopilot(
|
|
869
|
+
const result = await callCopilot(getPRDescriptionSystemPrompt(convention), userMessage, model);
|
|
632
870
|
if (!result)
|
|
633
871
|
return null;
|
|
634
|
-
const cleaned = result
|
|
872
|
+
const cleaned = extractJson(result);
|
|
635
873
|
return JSON.parse(cleaned);
|
|
636
874
|
} catch {
|
|
637
875
|
return null;
|
|
@@ -656,6 +894,124 @@ ${conflictDiff.slice(0, 4000)}`;
|
|
|
656
894
|
return null;
|
|
657
895
|
}
|
|
658
896
|
}
|
|
897
|
+
async function generateCommitGroups(files, diffs, model, convention = "clean-commit") {
|
|
898
|
+
const userMessage = `Group these changed files into logical atomic commits:
|
|
899
|
+
|
|
900
|
+
Files:
|
|
901
|
+
${files.join(`
|
|
902
|
+
`)}
|
|
903
|
+
|
|
904
|
+
Diffs (truncated):
|
|
905
|
+
${diffs.slice(0, 6000)}`;
|
|
906
|
+
const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
|
|
907
|
+
if (!result) {
|
|
908
|
+
throw new Error("AI returned an empty response");
|
|
909
|
+
}
|
|
910
|
+
const cleaned = extractJson(result);
|
|
911
|
+
let parsed;
|
|
912
|
+
try {
|
|
913
|
+
parsed = JSON.parse(cleaned);
|
|
914
|
+
} catch {
|
|
915
|
+
throw new Error(`AI response is not valid JSON. Raw start: "${result.slice(0, 120)}..."`);
|
|
916
|
+
}
|
|
917
|
+
const groups = parsed;
|
|
918
|
+
if (!Array.isArray(groups) || groups.length === 0) {
|
|
919
|
+
throw new Error("AI response was not a valid JSON array of commit groups");
|
|
920
|
+
}
|
|
921
|
+
for (const group of groups) {
|
|
922
|
+
if (!Array.isArray(group.files) || typeof group.message !== "string") {
|
|
923
|
+
throw new Error("AI returned groups with invalid structure (missing files or message)");
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return groups;
|
|
927
|
+
}
|
|
928
|
+
async function regenerateAllGroupMessages(groups, diffs, model, convention = "clean-commit") {
|
|
929
|
+
const groupSummary = groups.map((g, i) => `Group ${i + 1}: [${g.files.join(", ")}]`).join(`
|
|
930
|
+
`);
|
|
931
|
+
const userMessage = `Regenerate ONLY the commit messages for these pre-defined file groups. Do NOT change the file groupings.
|
|
932
|
+
|
|
933
|
+
Groups:
|
|
934
|
+
${groupSummary}
|
|
935
|
+
|
|
936
|
+
Diffs (truncated):
|
|
937
|
+
${diffs.slice(0, 6000)}`;
|
|
938
|
+
const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
|
|
939
|
+
if (!result)
|
|
940
|
+
return groups;
|
|
941
|
+
try {
|
|
942
|
+
const cleaned = extractJson(result);
|
|
943
|
+
const parsed = JSON.parse(cleaned);
|
|
944
|
+
if (!Array.isArray(parsed) || parsed.length !== groups.length)
|
|
945
|
+
return groups;
|
|
946
|
+
return groups.map((g, i) => ({
|
|
947
|
+
files: g.files,
|
|
948
|
+
message: typeof parsed[i]?.message === "string" ? parsed[i].message : g.message
|
|
949
|
+
}));
|
|
950
|
+
} catch {
|
|
951
|
+
return groups;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
async function regenerateGroupMessage(files, diffs, model, convention = "clean-commit") {
|
|
955
|
+
try {
|
|
956
|
+
const userMessage = `Generate a single commit message for these files:
|
|
957
|
+
|
|
958
|
+
Files: ${files.join(", ")}
|
|
959
|
+
|
|
960
|
+
Diff:
|
|
961
|
+
${diffs.slice(0, 4000)}`;
|
|
962
|
+
const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
|
|
963
|
+
return result?.trim() ?? null;
|
|
964
|
+
} catch {
|
|
965
|
+
return null;
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// src/utils/spinner.ts
|
|
970
|
+
import pc4 from "picocolors";
|
|
971
|
+
var FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
972
|
+
function createSpinner(text2) {
|
|
973
|
+
let frameIdx = 0;
|
|
974
|
+
let currentText = text2;
|
|
975
|
+
let stopped = false;
|
|
976
|
+
const clearLine = () => {
|
|
977
|
+
process.stderr.write("\r\x1B[K");
|
|
978
|
+
};
|
|
979
|
+
const render = () => {
|
|
980
|
+
if (stopped)
|
|
981
|
+
return;
|
|
982
|
+
const frame = pc4.cyan(FRAMES[frameIdx % FRAMES.length]);
|
|
983
|
+
clearLine();
|
|
984
|
+
process.stderr.write(`${frame} ${currentText}`);
|
|
985
|
+
frameIdx++;
|
|
986
|
+
};
|
|
987
|
+
const timer = setInterval(render, 80);
|
|
988
|
+
render();
|
|
989
|
+
const stop = () => {
|
|
990
|
+
if (stopped)
|
|
991
|
+
return;
|
|
992
|
+
stopped = true;
|
|
993
|
+
clearInterval(timer);
|
|
994
|
+
clearLine();
|
|
995
|
+
};
|
|
996
|
+
return {
|
|
997
|
+
update(newText) {
|
|
998
|
+
currentText = newText;
|
|
999
|
+
},
|
|
1000
|
+
success(msg) {
|
|
1001
|
+
stop();
|
|
1002
|
+
process.stderr.write(`${pc4.green("✔")} ${msg}
|
|
1003
|
+
`);
|
|
1004
|
+
},
|
|
1005
|
+
fail(msg) {
|
|
1006
|
+
stop();
|
|
1007
|
+
process.stderr.write(`${pc4.red("✖")} ${msg}
|
|
1008
|
+
`);
|
|
1009
|
+
},
|
|
1010
|
+
stop() {
|
|
1011
|
+
stop();
|
|
1012
|
+
}
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
659
1015
|
|
|
660
1016
|
// src/commands/commit.ts
|
|
661
1017
|
var commit_default = defineCommand2({
|
|
@@ -672,6 +1028,11 @@ var commit_default = defineCommand2({
|
|
|
672
1028
|
type: "boolean",
|
|
673
1029
|
description: "Skip AI and write commit message manually",
|
|
674
1030
|
default: false
|
|
1031
|
+
},
|
|
1032
|
+
group: {
|
|
1033
|
+
type: "boolean",
|
|
1034
|
+
description: "AI groups related changes into separate atomic commits",
|
|
1035
|
+
default: false
|
|
675
1036
|
}
|
|
676
1037
|
},
|
|
677
1038
|
async run({ args }) {
|
|
@@ -685,7 +1046,11 @@ var commit_default = defineCommand2({
|
|
|
685
1046
|
process.exit(1);
|
|
686
1047
|
}
|
|
687
1048
|
heading("\uD83D\uDCBE contrib commit");
|
|
688
|
-
|
|
1049
|
+
if (args.group) {
|
|
1050
|
+
await runGroupCommit(args.model, config);
|
|
1051
|
+
return;
|
|
1052
|
+
}
|
|
1053
|
+
let stagedFiles = await getStagedFiles();
|
|
689
1054
|
if (stagedFiles.length === 0) {
|
|
690
1055
|
const changedFiles = await getChangedFiles();
|
|
691
1056
|
if (changedFiles.length === 0) {
|
|
@@ -693,31 +1058,62 @@ var commit_default = defineCommand2({
|
|
|
693
1058
|
process.exit(1);
|
|
694
1059
|
}
|
|
695
1060
|
console.log(`
|
|
696
|
-
${
|
|
1061
|
+
${pc5.bold("Changed files:")}`);
|
|
697
1062
|
for (const f of changedFiles) {
|
|
698
|
-
console.log(` ${
|
|
1063
|
+
console.log(` ${pc5.dim("•")} ${f}`);
|
|
1064
|
+
}
|
|
1065
|
+
const stageAction = await selectPrompt("No staged changes. How would you like to stage?", [
|
|
1066
|
+
"Stage all changes",
|
|
1067
|
+
"Select files to stage",
|
|
1068
|
+
"Cancel"
|
|
1069
|
+
]);
|
|
1070
|
+
if (stageAction === "Cancel") {
|
|
1071
|
+
process.exit(0);
|
|
1072
|
+
}
|
|
1073
|
+
if (stageAction === "Stage all changes") {
|
|
1074
|
+
const result2 = await stageAll();
|
|
1075
|
+
if (result2.exitCode !== 0) {
|
|
1076
|
+
error(`Failed to stage files: ${result2.stderr}`);
|
|
1077
|
+
process.exit(1);
|
|
1078
|
+
}
|
|
1079
|
+
success("Staged all changes.");
|
|
1080
|
+
} else {
|
|
1081
|
+
const selected = await multiSelectPrompt("Select files to stage:", changedFiles);
|
|
1082
|
+
if (selected.length === 0) {
|
|
1083
|
+
error("No files selected.");
|
|
1084
|
+
process.exit(1);
|
|
1085
|
+
}
|
|
1086
|
+
const result2 = await stageFiles(selected);
|
|
1087
|
+
if (result2.exitCode !== 0) {
|
|
1088
|
+
error(`Failed to stage files: ${result2.stderr}`);
|
|
1089
|
+
process.exit(1);
|
|
1090
|
+
}
|
|
1091
|
+
success(`Staged ${selected.length} file(s).`);
|
|
1092
|
+
}
|
|
1093
|
+
stagedFiles = await getStagedFiles();
|
|
1094
|
+
if (stagedFiles.length === 0) {
|
|
1095
|
+
error("No staged changes after staging attempt.");
|
|
1096
|
+
process.exit(1);
|
|
699
1097
|
}
|
|
700
|
-
console.log();
|
|
701
|
-
warn("No staged changes. Stage your files with `git add` and re-run.");
|
|
702
|
-
process.exit(1);
|
|
703
1098
|
}
|
|
704
1099
|
info(`Staged files: ${stagedFiles.join(", ")}`);
|
|
705
1100
|
let commitMessage = null;
|
|
706
1101
|
const useAI = !args["no-ai"];
|
|
707
1102
|
if (useAI) {
|
|
708
|
-
const copilotError = await checkCopilotAvailable();
|
|
1103
|
+
const [copilotError, diff] = await Promise.all([checkCopilotAvailable(), getStagedDiff()]);
|
|
709
1104
|
if (copilotError) {
|
|
710
1105
|
warn(`AI unavailable: ${copilotError}`);
|
|
711
1106
|
warn("Falling back to manual commit message entry.");
|
|
712
1107
|
} else {
|
|
713
|
-
|
|
714
|
-
const diff = await getStagedDiff();
|
|
1108
|
+
const spinner = createSpinner("Generating commit message with AI...");
|
|
715
1109
|
commitMessage = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
|
|
716
1110
|
if (commitMessage) {
|
|
1111
|
+
spinner.success("AI commit message generated.");
|
|
717
1112
|
console.log(`
|
|
718
|
-
${
|
|
1113
|
+
${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(commitMessage))}`);
|
|
719
1114
|
} else {
|
|
720
|
-
|
|
1115
|
+
spinner.fail("AI did not return a commit message.");
|
|
1116
|
+
warn("Falling back to manual entry.");
|
|
721
1117
|
}
|
|
722
1118
|
}
|
|
723
1119
|
}
|
|
@@ -734,16 +1130,17 @@ ${pc4.bold("Changed files:")}`);
|
|
|
734
1130
|
} else if (action === "Edit this message") {
|
|
735
1131
|
finalMessage = await inputPrompt("Edit commit message", commitMessage);
|
|
736
1132
|
} else if (action === "Regenerate") {
|
|
737
|
-
|
|
1133
|
+
const spinner = createSpinner("Regenerating commit message...");
|
|
738
1134
|
const diff = await getStagedDiff();
|
|
739
1135
|
const regen = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
|
|
740
1136
|
if (regen) {
|
|
1137
|
+
spinner.success("Commit message regenerated.");
|
|
741
1138
|
console.log(`
|
|
742
|
-
${
|
|
1139
|
+
${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(regen))}`);
|
|
743
1140
|
const ok = await confirmPrompt("Use this message?");
|
|
744
1141
|
finalMessage = ok ? regen : await inputPrompt("Enter commit message manually");
|
|
745
1142
|
} else {
|
|
746
|
-
|
|
1143
|
+
spinner.fail("Regeneration failed.");
|
|
747
1144
|
finalMessage = await inputPrompt("Enter commit message");
|
|
748
1145
|
}
|
|
749
1146
|
} else {
|
|
@@ -754,7 +1151,7 @@ ${pc4.bold("Changed files:")}`);
|
|
|
754
1151
|
if (convention2 !== "none") {
|
|
755
1152
|
console.log();
|
|
756
1153
|
for (const hint of CONVENTION_FORMAT_HINTS[convention2]) {
|
|
757
|
-
console.log(
|
|
1154
|
+
console.log(pc5.dim(hint));
|
|
758
1155
|
}
|
|
759
1156
|
console.log();
|
|
760
1157
|
}
|
|
@@ -778,21 +1175,209 @@ ${pc4.bold("Changed files:")}`);
|
|
|
778
1175
|
error(`Failed to commit: ${result.stderr}`);
|
|
779
1176
|
process.exit(1);
|
|
780
1177
|
}
|
|
781
|
-
success(`✅ Committed: ${
|
|
1178
|
+
success(`✅ Committed: ${pc5.bold(finalMessage)}`);
|
|
782
1179
|
}
|
|
783
1180
|
});
|
|
1181
|
+
async function runGroupCommit(model, config) {
|
|
1182
|
+
const [copilotError, changedFiles] = await Promise.all([
|
|
1183
|
+
checkCopilotAvailable(),
|
|
1184
|
+
getChangedFiles()
|
|
1185
|
+
]);
|
|
1186
|
+
if (copilotError) {
|
|
1187
|
+
error(`AI is required for --group mode but unavailable: ${copilotError}`);
|
|
1188
|
+
process.exit(1);
|
|
1189
|
+
}
|
|
1190
|
+
if (changedFiles.length === 0) {
|
|
1191
|
+
error("No changes to group-commit.");
|
|
1192
|
+
process.exit(1);
|
|
1193
|
+
}
|
|
1194
|
+
console.log(`
|
|
1195
|
+
${pc5.bold("Changed files:")}`);
|
|
1196
|
+
for (const f of changedFiles) {
|
|
1197
|
+
console.log(` ${pc5.dim("•")} ${f}`);
|
|
1198
|
+
}
|
|
1199
|
+
const spinner = createSpinner(`Asking AI to group ${changedFiles.length} file(s) into logical commits...`);
|
|
1200
|
+
const diffs = await getFullDiffForFiles(changedFiles);
|
|
1201
|
+
if (!diffs.trim()) {
|
|
1202
|
+
spinner.stop();
|
|
1203
|
+
warn("Could not retrieve diff context for any files. AI needs diffs to produce groups.");
|
|
1204
|
+
}
|
|
1205
|
+
let groups;
|
|
1206
|
+
try {
|
|
1207
|
+
groups = await generateCommitGroups(changedFiles, diffs, model, config.commitConvention);
|
|
1208
|
+
spinner.success(`AI generated ${groups.length} commit group(s).`);
|
|
1209
|
+
} catch (err) {
|
|
1210
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1211
|
+
spinner.fail(`AI grouping failed: ${reason}`);
|
|
1212
|
+
process.exit(1);
|
|
1213
|
+
}
|
|
1214
|
+
if (groups.length === 0) {
|
|
1215
|
+
error("AI could not produce commit groups. Try committing files manually.");
|
|
1216
|
+
process.exit(1);
|
|
1217
|
+
}
|
|
1218
|
+
const changedSet = new Set(changedFiles);
|
|
1219
|
+
for (const group of groups) {
|
|
1220
|
+
const invalid = group.files.filter((f) => !changedSet.has(f));
|
|
1221
|
+
if (invalid.length > 0) {
|
|
1222
|
+
warn(`AI suggested unknown file(s): ${invalid.join(", ")} — removed from group.`);
|
|
1223
|
+
}
|
|
1224
|
+
group.files = group.files.filter((f) => changedSet.has(f));
|
|
1225
|
+
}
|
|
1226
|
+
let validGroups = groups.filter((g) => g.files.length > 0);
|
|
1227
|
+
if (validGroups.length === 0) {
|
|
1228
|
+
error("No valid groups remain after validation. Try committing files manually.");
|
|
1229
|
+
process.exit(1);
|
|
1230
|
+
}
|
|
1231
|
+
let proceedToCommit = false;
|
|
1232
|
+
let commitAll = false;
|
|
1233
|
+
while (!proceedToCommit) {
|
|
1234
|
+
console.log(`
|
|
1235
|
+
${pc5.bold(`AI suggested ${validGroups.length} commit group(s):`)}
|
|
1236
|
+
`);
|
|
1237
|
+
for (let i = 0;i < validGroups.length; i++) {
|
|
1238
|
+
const g = validGroups[i];
|
|
1239
|
+
console.log(` ${pc5.cyan(`Group ${i + 1}:`)} ${pc5.bold(g.message)}`);
|
|
1240
|
+
for (const f of g.files) {
|
|
1241
|
+
console.log(` ${pc5.dim("•")} ${f}`);
|
|
1242
|
+
}
|
|
1243
|
+
console.log();
|
|
1244
|
+
}
|
|
1245
|
+
const summaryAction = await selectPrompt("What would you like to do?", [
|
|
1246
|
+
"Commit all",
|
|
1247
|
+
"Review each group",
|
|
1248
|
+
"Regenerate all messages",
|
|
1249
|
+
"Cancel"
|
|
1250
|
+
]);
|
|
1251
|
+
if (summaryAction === "Cancel") {
|
|
1252
|
+
warn("Group commit cancelled.");
|
|
1253
|
+
process.exit(0);
|
|
1254
|
+
}
|
|
1255
|
+
if (summaryAction === "Regenerate all messages") {
|
|
1256
|
+
const regenSpinner = createSpinner("Regenerating all commit messages...");
|
|
1257
|
+
try {
|
|
1258
|
+
validGroups = await regenerateAllGroupMessages(validGroups, diffs, model, config.commitConvention);
|
|
1259
|
+
regenSpinner.success("All commit messages regenerated.");
|
|
1260
|
+
} catch {
|
|
1261
|
+
regenSpinner.fail("Failed to regenerate messages. Keeping current ones.");
|
|
1262
|
+
}
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
proceedToCommit = true;
|
|
1266
|
+
commitAll = summaryAction === "Commit all";
|
|
1267
|
+
}
|
|
1268
|
+
let committed = 0;
|
|
1269
|
+
if (commitAll) {
|
|
1270
|
+
for (let i = 0;i < validGroups.length; i++) {
|
|
1271
|
+
const group = validGroups[i];
|
|
1272
|
+
const stageResult = await stageFiles(group.files);
|
|
1273
|
+
if (stageResult.exitCode !== 0) {
|
|
1274
|
+
error(`Failed to stage group ${i + 1}: ${stageResult.stderr}`);
|
|
1275
|
+
continue;
|
|
1276
|
+
}
|
|
1277
|
+
const commitResult = await commitWithMessage(group.message);
|
|
1278
|
+
if (commitResult.exitCode !== 0) {
|
|
1279
|
+
const detail = (commitResult.stderr || commitResult.stdout).trim();
|
|
1280
|
+
error(`Failed to commit group ${i + 1}: ${detail}`);
|
|
1281
|
+
await unstageFiles(group.files);
|
|
1282
|
+
continue;
|
|
1283
|
+
}
|
|
1284
|
+
committed++;
|
|
1285
|
+
success(`✅ Committed group ${i + 1}: ${pc5.bold(group.message)}`);
|
|
1286
|
+
}
|
|
1287
|
+
} else {
|
|
1288
|
+
for (let i = 0;i < validGroups.length; i++) {
|
|
1289
|
+
const group = validGroups[i];
|
|
1290
|
+
console.log(pc5.bold(`
|
|
1291
|
+
── Group ${i + 1}/${validGroups.length} ──`));
|
|
1292
|
+
console.log(` ${pc5.cyan(group.message)}`);
|
|
1293
|
+
for (const f of group.files) {
|
|
1294
|
+
console.log(` ${pc5.dim("•")} ${f}`);
|
|
1295
|
+
}
|
|
1296
|
+
let message = group.message;
|
|
1297
|
+
let actionDone = false;
|
|
1298
|
+
while (!actionDone) {
|
|
1299
|
+
const action = await selectPrompt("Action for this group:", [
|
|
1300
|
+
"Commit as-is",
|
|
1301
|
+
"Edit message and commit",
|
|
1302
|
+
"Regenerate message",
|
|
1303
|
+
"Skip this group"
|
|
1304
|
+
]);
|
|
1305
|
+
if (action === "Skip this group") {
|
|
1306
|
+
warn(`Skipped group ${i + 1}.`);
|
|
1307
|
+
actionDone = true;
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
if (action === "Regenerate message") {
|
|
1311
|
+
const regenSpinner = createSpinner("Regenerating commit message for this group...");
|
|
1312
|
+
const newMsg = await regenerateGroupMessage(group.files, diffs, model, config.commitConvention);
|
|
1313
|
+
if (newMsg) {
|
|
1314
|
+
message = newMsg;
|
|
1315
|
+
group.message = newMsg;
|
|
1316
|
+
regenSpinner.success(`New message: ${pc5.bold(message)}`);
|
|
1317
|
+
} else {
|
|
1318
|
+
regenSpinner.fail("AI could not generate a new message. Keeping current one.");
|
|
1319
|
+
}
|
|
1320
|
+
continue;
|
|
1321
|
+
}
|
|
1322
|
+
if (action === "Edit message and commit") {
|
|
1323
|
+
message = await inputPrompt("Edit commit message", message);
|
|
1324
|
+
if (!message) {
|
|
1325
|
+
warn(`Skipped group ${i + 1} (empty message).`);
|
|
1326
|
+
actionDone = true;
|
|
1327
|
+
continue;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
if (!validateCommitMessage(message, config.commitConvention)) {
|
|
1331
|
+
for (const line of getValidationError(config.commitConvention)) {
|
|
1332
|
+
warn(line);
|
|
1333
|
+
}
|
|
1334
|
+
const proceed = await confirmPrompt("Commit anyway?");
|
|
1335
|
+
if (!proceed) {
|
|
1336
|
+
warn(`Skipped group ${i + 1}.`);
|
|
1337
|
+
actionDone = true;
|
|
1338
|
+
continue;
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
const stageResult = await stageFiles(group.files);
|
|
1342
|
+
if (stageResult.exitCode !== 0) {
|
|
1343
|
+
error(`Failed to stage group ${i + 1}: ${stageResult.stderr}`);
|
|
1344
|
+
actionDone = true;
|
|
1345
|
+
continue;
|
|
1346
|
+
}
|
|
1347
|
+
const commitResult = await commitWithMessage(message);
|
|
1348
|
+
if (commitResult.exitCode !== 0) {
|
|
1349
|
+
const detail = (commitResult.stderr || commitResult.stdout).trim();
|
|
1350
|
+
error(`Failed to commit group ${i + 1}: ${detail}`);
|
|
1351
|
+
await unstageFiles(group.files);
|
|
1352
|
+
actionDone = true;
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
committed++;
|
|
1356
|
+
success(`✅ Committed group ${i + 1}: ${pc5.bold(message)}`);
|
|
1357
|
+
actionDone = true;
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
if (committed === 0) {
|
|
1362
|
+
warn("No groups were committed.");
|
|
1363
|
+
} else {
|
|
1364
|
+
success(`
|
|
1365
|
+
\uD83C\uDF89 ${committed} of ${validGroups.length} group(s) committed successfully.`);
|
|
1366
|
+
}
|
|
1367
|
+
process.exit(0);
|
|
1368
|
+
}
|
|
784
1369
|
|
|
785
1370
|
// src/commands/hook.ts
|
|
786
|
-
import { existsSync as existsSync2, mkdirSync, readFileSync as
|
|
787
|
-
import { join as
|
|
1371
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1372
|
+
import { join as join3 } from "node:path";
|
|
788
1373
|
import { defineCommand as defineCommand3 } from "citty";
|
|
789
|
-
import
|
|
1374
|
+
import pc6 from "picocolors";
|
|
790
1375
|
var HOOK_MARKER = "# managed by contribute-now";
|
|
791
1376
|
function getHooksDir(cwd = process.cwd()) {
|
|
792
|
-
return
|
|
1377
|
+
return join3(cwd, ".git", "hooks");
|
|
793
1378
|
}
|
|
794
1379
|
function getHookPath(cwd = process.cwd()) {
|
|
795
|
-
return
|
|
1380
|
+
return join3(getHooksDir(cwd), "commit-msg");
|
|
796
1381
|
}
|
|
797
1382
|
function generateHookScript() {
|
|
798
1383
|
return `#!/bin/sh
|
|
@@ -809,8 +1394,19 @@ case "$commit_msg" in
|
|
|
809
1394
|
Merge\\ *|fixup!*|squash!*|amend!*) exit 0 ;;
|
|
810
1395
|
esac
|
|
811
1396
|
|
|
812
|
-
#
|
|
813
|
-
|
|
1397
|
+
# Detect available package runner
|
|
1398
|
+
if command -v contrib >/dev/null 2>&1; then
|
|
1399
|
+
contrib validate "$commit_msg"
|
|
1400
|
+
elif command -v bunx >/dev/null 2>&1; then
|
|
1401
|
+
bunx contrib validate "$commit_msg"
|
|
1402
|
+
elif command -v pnpx >/dev/null 2>&1; then
|
|
1403
|
+
pnpx contrib validate "$commit_msg"
|
|
1404
|
+
elif command -v npx >/dev/null 2>&1; then
|
|
1405
|
+
npx contrib validate "$commit_msg"
|
|
1406
|
+
else
|
|
1407
|
+
echo "Warning: No package runner found. Skipping commit message validation."
|
|
1408
|
+
exit 0
|
|
1409
|
+
fi
|
|
814
1410
|
`;
|
|
815
1411
|
}
|
|
816
1412
|
var hook_default = defineCommand3({
|
|
@@ -857,7 +1453,7 @@ async function installHook() {
|
|
|
857
1453
|
const hookPath = getHookPath();
|
|
858
1454
|
const hooksDir = getHooksDir();
|
|
859
1455
|
if (existsSync2(hookPath)) {
|
|
860
|
-
const existing =
|
|
1456
|
+
const existing = readFileSync3(hookPath, "utf-8");
|
|
861
1457
|
if (!existing.includes(HOOK_MARKER)) {
|
|
862
1458
|
error("A commit-msg hook already exists and was not installed by contribute-now.");
|
|
863
1459
|
warn(`Path: ${hookPath}`);
|
|
@@ -871,8 +1467,8 @@ async function installHook() {
|
|
|
871
1467
|
}
|
|
872
1468
|
writeFileSync2(hookPath, generateHookScript(), { mode: 493 });
|
|
873
1469
|
success(`commit-msg hook installed.`);
|
|
874
|
-
info(`Convention: ${
|
|
875
|
-
info(`Path: ${
|
|
1470
|
+
info(`Convention: ${pc6.bold(CONVENTION_LABELS[config.commitConvention])}`);
|
|
1471
|
+
info(`Path: ${pc6.dim(hookPath)}`);
|
|
876
1472
|
}
|
|
877
1473
|
async function uninstallHook() {
|
|
878
1474
|
heading("\uD83E\uDE9D hook uninstall");
|
|
@@ -881,7 +1477,7 @@ async function uninstallHook() {
|
|
|
881
1477
|
info("No commit-msg hook found. Nothing to uninstall.");
|
|
882
1478
|
return;
|
|
883
1479
|
}
|
|
884
|
-
const content =
|
|
1480
|
+
const content = readFileSync3(hookPath, "utf-8");
|
|
885
1481
|
if (!content.includes(HOOK_MARKER)) {
|
|
886
1482
|
error("The commit-msg hook was not installed by contribute-now. Leaving it untouched.");
|
|
887
1483
|
process.exit(1);
|
|
@@ -892,7 +1488,7 @@ async function uninstallHook() {
|
|
|
892
1488
|
|
|
893
1489
|
// src/commands/setup.ts
|
|
894
1490
|
import { defineCommand as defineCommand4 } from "citty";
|
|
895
|
-
import
|
|
1491
|
+
import pc7 from "picocolors";
|
|
896
1492
|
|
|
897
1493
|
// src/utils/gh.ts
|
|
898
1494
|
import { execFile as execFileCb2 } from "node:child_process";
|
|
@@ -900,7 +1496,7 @@ function run2(args) {
|
|
|
900
1496
|
return new Promise((resolve) => {
|
|
901
1497
|
execFileCb2("gh", args, (error2, stdout, stderr) => {
|
|
902
1498
|
resolve({
|
|
903
|
-
exitCode: error2 ? error2.code
|
|
1499
|
+
exitCode: error2 ? error2.code === "ENOENT" ? 127 : error2.status ?? 1 : 0,
|
|
904
1500
|
stdout: stdout ?? "",
|
|
905
1501
|
stderr: stderr ?? ""
|
|
906
1502
|
});
|
|
@@ -923,7 +1519,10 @@ async function checkGhAuth() {
|
|
|
923
1519
|
return false;
|
|
924
1520
|
}
|
|
925
1521
|
}
|
|
1522
|
+
var SAFE_SLUG = /^[\w.-]+$/;
|
|
926
1523
|
async function checkRepoPermissions(owner, repo) {
|
|
1524
|
+
if (!SAFE_SLUG.test(owner) || !SAFE_SLUG.test(repo))
|
|
1525
|
+
return null;
|
|
927
1526
|
const { exitCode, stdout } = await run2(["api", `repos/${owner}/${repo}`, "--jq", ".permissions"]);
|
|
928
1527
|
if (exitCode !== 0)
|
|
929
1528
|
return null;
|
|
@@ -984,6 +1583,50 @@ async function createPRFill(base, draft) {
|
|
|
984
1583
|
args.push("--draft");
|
|
985
1584
|
return run2(args);
|
|
986
1585
|
}
|
|
1586
|
+
async function getPRForBranch(headBranch) {
|
|
1587
|
+
const { exitCode, stdout } = await run2([
|
|
1588
|
+
"pr",
|
|
1589
|
+
"list",
|
|
1590
|
+
"--head",
|
|
1591
|
+
headBranch,
|
|
1592
|
+
"--state",
|
|
1593
|
+
"open",
|
|
1594
|
+
"--json",
|
|
1595
|
+
"number,url,title,state",
|
|
1596
|
+
"--limit",
|
|
1597
|
+
"1"
|
|
1598
|
+
]);
|
|
1599
|
+
if (exitCode !== 0)
|
|
1600
|
+
return null;
|
|
1601
|
+
try {
|
|
1602
|
+
const prs = JSON.parse(stdout.trim());
|
|
1603
|
+
return prs.length > 0 ? prs[0] : null;
|
|
1604
|
+
} catch {
|
|
1605
|
+
return null;
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
async function getMergedPRForBranch(headBranch) {
|
|
1609
|
+
const { exitCode, stdout } = await run2([
|
|
1610
|
+
"pr",
|
|
1611
|
+
"list",
|
|
1612
|
+
"--head",
|
|
1613
|
+
headBranch,
|
|
1614
|
+
"--state",
|
|
1615
|
+
"merged",
|
|
1616
|
+
"--json",
|
|
1617
|
+
"number,url,title,state",
|
|
1618
|
+
"--limit",
|
|
1619
|
+
"1"
|
|
1620
|
+
]);
|
|
1621
|
+
if (exitCode !== 0)
|
|
1622
|
+
return null;
|
|
1623
|
+
try {
|
|
1624
|
+
const prs = JSON.parse(stdout.trim());
|
|
1625
|
+
return prs.length > 0 ? prs[0] : null;
|
|
1626
|
+
} catch {
|
|
1627
|
+
return null;
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
987
1630
|
|
|
988
1631
|
// src/utils/remote.ts
|
|
989
1632
|
function parseRepoFromUrl(url) {
|
|
@@ -1026,7 +1669,7 @@ var setup_default = defineCommand4({
|
|
|
1026
1669
|
workflow = "github-flow";
|
|
1027
1670
|
else if (workflowChoice.startsWith("Git Flow"))
|
|
1028
1671
|
workflow = "git-flow";
|
|
1029
|
-
info(`Workflow: ${
|
|
1672
|
+
info(`Workflow: ${pc7.bold(WORKFLOW_DESCRIPTIONS[workflow])}`);
|
|
1030
1673
|
const conventionChoice = await selectPrompt("Which commit convention should this project use?", [
|
|
1031
1674
|
`${CONVENTION_DESCRIPTIONS["clean-commit"]} (recommended)`,
|
|
1032
1675
|
CONVENTION_DESCRIPTIONS.conventional,
|
|
@@ -1079,8 +1722,8 @@ var setup_default = defineCommand4({
|
|
|
1079
1722
|
detectedRole = roleChoice;
|
|
1080
1723
|
detectionSource = "user selection";
|
|
1081
1724
|
} else {
|
|
1082
|
-
info(`Detected role: ${
|
|
1083
|
-
const confirmed = await confirmPrompt(`Role detected as ${
|
|
1725
|
+
info(`Detected role: ${pc7.bold(detectedRole)} (via ${detectionSource})`);
|
|
1726
|
+
const confirmed = await confirmPrompt(`Role detected as ${pc7.bold(detectedRole)}. Is this correct?`);
|
|
1084
1727
|
if (!confirmed) {
|
|
1085
1728
|
const roleChoice = await selectPrompt("Select your role:", ["maintainer", "contributor"]);
|
|
1086
1729
|
detectedRole = roleChoice;
|
|
@@ -1125,21 +1768,21 @@ var setup_default = defineCommand4({
|
|
|
1125
1768
|
warn(' echo ".contributerc.json" >> .gitignore');
|
|
1126
1769
|
}
|
|
1127
1770
|
console.log();
|
|
1128
|
-
info(`Workflow: ${
|
|
1129
|
-
info(`Convention: ${
|
|
1130
|
-
info(`Role: ${
|
|
1771
|
+
info(`Workflow: ${pc7.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
|
|
1772
|
+
info(`Convention: ${pc7.bold(CONVENTION_DESCRIPTIONS[config.commitConvention])}`);
|
|
1773
|
+
info(`Role: ${pc7.bold(config.role)}`);
|
|
1131
1774
|
if (config.devBranch) {
|
|
1132
|
-
info(`Main: ${
|
|
1775
|
+
info(`Main: ${pc7.bold(config.mainBranch)} | Dev: ${pc7.bold(config.devBranch)}`);
|
|
1133
1776
|
} else {
|
|
1134
|
-
info(`Main: ${
|
|
1777
|
+
info(`Main: ${pc7.bold(config.mainBranch)}`);
|
|
1135
1778
|
}
|
|
1136
|
-
info(`Origin: ${
|
|
1779
|
+
info(`Origin: ${pc7.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc7.bold(config.upstream)}` : ""}`);
|
|
1137
1780
|
}
|
|
1138
1781
|
});
|
|
1139
1782
|
|
|
1140
1783
|
// src/commands/start.ts
|
|
1141
1784
|
import { defineCommand as defineCommand5 } from "citty";
|
|
1142
|
-
import
|
|
1785
|
+
import pc8 from "picocolors";
|
|
1143
1786
|
|
|
1144
1787
|
// src/utils/branch.ts
|
|
1145
1788
|
var DEFAULT_PREFIXES = ["feature", "fix", "docs", "chore", "test", "refactor"];
|
|
@@ -1150,6 +1793,9 @@ function formatBranchName(prefix, name) {
|
|
|
1150
1793
|
const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1151
1794
|
return `${prefix}/${sanitized}`;
|
|
1152
1795
|
}
|
|
1796
|
+
function isValidBranchName(name) {
|
|
1797
|
+
return /^[a-zA-Z0-9._/-]+$/.test(name) && !name.startsWith("/") && !name.endsWith("/");
|
|
1798
|
+
}
|
|
1153
1799
|
function looksLikeNaturalLanguage(input) {
|
|
1154
1800
|
return input.includes(" ") && !input.includes("/");
|
|
1155
1801
|
}
|
|
@@ -1197,24 +1843,31 @@ var start_default = defineCommand5({
|
|
|
1197
1843
|
heading("\uD83C\uDF3F contrib start");
|
|
1198
1844
|
const useAI = !args["no-ai"] && looksLikeNaturalLanguage(branchName);
|
|
1199
1845
|
if (useAI) {
|
|
1200
|
-
|
|
1846
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
1201
1847
|
const suggested = await suggestBranchName(branchName, args.model);
|
|
1202
1848
|
if (suggested) {
|
|
1849
|
+
spinner.success("Branch name suggestion ready.");
|
|
1203
1850
|
console.log(`
|
|
1204
|
-
${
|
|
1205
|
-
const accepted = await confirmPrompt(`Use ${
|
|
1851
|
+
${pc8.dim("AI suggestion:")} ${pc8.bold(pc8.cyan(suggested))}`);
|
|
1852
|
+
const accepted = await confirmPrompt(`Use ${pc8.bold(suggested)} as your branch name?`);
|
|
1206
1853
|
if (accepted) {
|
|
1207
1854
|
branchName = suggested;
|
|
1208
1855
|
} else {
|
|
1209
1856
|
branchName = await inputPrompt("Enter branch name", branchName);
|
|
1210
1857
|
}
|
|
1858
|
+
} else {
|
|
1859
|
+
spinner.fail("AI did not return a branch name suggestion.");
|
|
1211
1860
|
}
|
|
1212
1861
|
}
|
|
1213
1862
|
if (!hasPrefix(branchName, branchPrefixes)) {
|
|
1214
|
-
const prefix = await selectPrompt(`Choose a branch type for ${
|
|
1863
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc8.bold(branchName)}:`, branchPrefixes);
|
|
1215
1864
|
branchName = formatBranchName(prefix, branchName);
|
|
1216
1865
|
}
|
|
1217
|
-
|
|
1866
|
+
if (!isValidBranchName(branchName)) {
|
|
1867
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
1868
|
+
process.exit(1);
|
|
1869
|
+
}
|
|
1870
|
+
info(`Creating branch: ${pc8.bold(branchName)}`);
|
|
1218
1871
|
await fetchRemote(syncSource.remote);
|
|
1219
1872
|
const updateResult = await updateLocalBranch(baseBranch, syncSource.ref);
|
|
1220
1873
|
if (updateResult.exitCode !== 0) {}
|
|
@@ -1223,13 +1876,13 @@ var start_default = defineCommand5({
|
|
|
1223
1876
|
error(`Failed to create branch: ${result.stderr}`);
|
|
1224
1877
|
process.exit(1);
|
|
1225
1878
|
}
|
|
1226
|
-
success(`✅ Created ${
|
|
1879
|
+
success(`✅ Created ${pc8.bold(branchName)} from latest ${pc8.bold(baseBranch)}`);
|
|
1227
1880
|
}
|
|
1228
1881
|
});
|
|
1229
1882
|
|
|
1230
1883
|
// src/commands/status.ts
|
|
1231
1884
|
import { defineCommand as defineCommand6 } from "citty";
|
|
1232
|
-
import
|
|
1885
|
+
import pc9 from "picocolors";
|
|
1233
1886
|
var status_default = defineCommand6({
|
|
1234
1887
|
meta: {
|
|
1235
1888
|
name: "status",
|
|
@@ -1246,17 +1899,20 @@ var status_default = defineCommand6({
|
|
|
1246
1899
|
process.exit(1);
|
|
1247
1900
|
}
|
|
1248
1901
|
heading("\uD83D\uDCCA contribute-now status");
|
|
1249
|
-
console.log(` ${
|
|
1250
|
-
console.log(` ${
|
|
1902
|
+
console.log(` ${pc9.dim("Workflow:")} ${pc9.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
|
|
1903
|
+
console.log(` ${pc9.dim("Role:")} ${pc9.bold(config.role)}`);
|
|
1251
1904
|
console.log();
|
|
1252
1905
|
await fetchAll();
|
|
1253
1906
|
const currentBranch = await getCurrentBranch();
|
|
1254
1907
|
const { mainBranch, origin, upstream, workflow } = config;
|
|
1255
1908
|
const baseBranch = getBaseBranch(config);
|
|
1256
1909
|
const isContributor = config.role === "contributor";
|
|
1257
|
-
const dirty = await
|
|
1910
|
+
const [dirty, fileStatus] = await Promise.all([
|
|
1911
|
+
hasUncommittedChanges(),
|
|
1912
|
+
getFileStatus()
|
|
1913
|
+
]);
|
|
1258
1914
|
if (dirty) {
|
|
1259
|
-
console.log(` ${
|
|
1915
|
+
console.log(` ${pc9.yellow("⚠")} ${pc9.yellow("Uncommitted changes in working tree")}`);
|
|
1260
1916
|
console.log();
|
|
1261
1917
|
}
|
|
1262
1918
|
const mainRemote = `${origin}/${mainBranch}`;
|
|
@@ -1272,30 +1928,130 @@ var status_default = defineCommand6({
|
|
|
1272
1928
|
if (currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
|
|
1273
1929
|
const branchDiv = await getDivergence(currentBranch, baseBranch);
|
|
1274
1930
|
const branchLine = formatStatus(currentBranch, baseBranch, branchDiv.ahead, branchDiv.behind);
|
|
1275
|
-
console.log(branchLine +
|
|
1931
|
+
console.log(branchLine + pc9.dim(` (current ${pc9.green("*")})`));
|
|
1276
1932
|
} else if (currentBranch) {
|
|
1277
|
-
console.log(
|
|
1933
|
+
console.log(pc9.dim(` (on ${pc9.bold(currentBranch)} branch)`));
|
|
1934
|
+
}
|
|
1935
|
+
const hasFiles = fileStatus.staged.length > 0 || fileStatus.modified.length > 0 || fileStatus.untracked.length > 0;
|
|
1936
|
+
if (hasFiles) {
|
|
1937
|
+
console.log();
|
|
1938
|
+
if (fileStatus.staged.length > 0) {
|
|
1939
|
+
console.log(` ${pc9.green("Staged for commit:")}`);
|
|
1940
|
+
for (const { file, status } of fileStatus.staged) {
|
|
1941
|
+
console.log(` ${pc9.green("+")} ${pc9.dim(`${status}:`)} ${file}`);
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
if (fileStatus.modified.length > 0) {
|
|
1945
|
+
console.log(` ${pc9.yellow("Unstaged changes:")}`);
|
|
1946
|
+
for (const { file, status } of fileStatus.modified) {
|
|
1947
|
+
console.log(` ${pc9.yellow("~")} ${pc9.dim(`${status}:`)} ${file}`);
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
if (fileStatus.untracked.length > 0) {
|
|
1951
|
+
console.log(` ${pc9.red("Untracked files:")}`);
|
|
1952
|
+
for (const file of fileStatus.untracked) {
|
|
1953
|
+
console.log(` ${pc9.red("?")} ${file}`);
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
} else if (!dirty) {
|
|
1957
|
+
console.log(` ${pc9.green("✓")} ${pc9.dim("Working tree clean")}`);
|
|
1958
|
+
}
|
|
1959
|
+
const tips = [];
|
|
1960
|
+
if (fileStatus.staged.length > 0) {
|
|
1961
|
+
tips.push(`Run ${pc9.bold("contrib commit")} to commit staged changes`);
|
|
1962
|
+
}
|
|
1963
|
+
if (fileStatus.modified.length > 0 || fileStatus.untracked.length > 0) {
|
|
1964
|
+
tips.push(`Run ${pc9.bold("contrib commit")} to stage and commit changes`);
|
|
1965
|
+
}
|
|
1966
|
+
if (fileStatus.staged.length === 0 && fileStatus.modified.length === 0 && fileStatus.untracked.length === 0 && currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
|
|
1967
|
+
const branchDiv = await getDivergence(currentBranch, `${origin}/${currentBranch}`);
|
|
1968
|
+
if (branchDiv.ahead > 0) {
|
|
1969
|
+
tips.push(`Run ${pc9.bold("contrib submit")} to push and create/update your PR`);
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
if (tips.length > 0) {
|
|
1973
|
+
console.log();
|
|
1974
|
+
console.log(` ${pc9.dim("\uD83D\uDCA1 Tip:")}`);
|
|
1975
|
+
for (const tip of tips) {
|
|
1976
|
+
console.log(` ${pc9.dim(tip)}`);
|
|
1977
|
+
}
|
|
1278
1978
|
}
|
|
1279
1979
|
console.log();
|
|
1280
1980
|
}
|
|
1281
1981
|
});
|
|
1282
1982
|
function formatStatus(branch, base, ahead, behind) {
|
|
1283
|
-
const label =
|
|
1983
|
+
const label = pc9.bold(branch.padEnd(20));
|
|
1284
1984
|
if (ahead === 0 && behind === 0) {
|
|
1285
|
-
return ` ${
|
|
1985
|
+
return ` ${pc9.green("✓")} ${label} ${pc9.dim(`in sync with ${base}`)}`;
|
|
1286
1986
|
}
|
|
1287
1987
|
if (ahead > 0 && behind === 0) {
|
|
1288
|
-
return ` ${
|
|
1988
|
+
return ` ${pc9.yellow("↑")} ${label} ${pc9.yellow(`${ahead} commit${ahead !== 1 ? "s" : ""} ahead of ${base}`)}`;
|
|
1289
1989
|
}
|
|
1290
1990
|
if (behind > 0 && ahead === 0) {
|
|
1291
|
-
return ` ${
|
|
1991
|
+
return ` ${pc9.red("↓")} ${label} ${pc9.red(`${behind} commit${behind !== 1 ? "s" : ""} behind ${base}`)}`;
|
|
1292
1992
|
}
|
|
1293
|
-
return ` ${
|
|
1993
|
+
return ` ${pc9.red("⚡")} ${label} ${pc9.yellow(`${ahead} ahead`)}${pc9.dim(", ")}${pc9.red(`${behind} behind`)} ${pc9.dim(base)}`;
|
|
1294
1994
|
}
|
|
1295
1995
|
|
|
1296
1996
|
// src/commands/submit.ts
|
|
1297
1997
|
import { defineCommand as defineCommand7 } from "citty";
|
|
1298
|
-
import
|
|
1998
|
+
import pc10 from "picocolors";
|
|
1999
|
+
async function performSquashMerge(origin, baseBranch, featureBranch, options) {
|
|
2000
|
+
info(`Checking out ${pc10.bold(baseBranch)}...`);
|
|
2001
|
+
const coResult = await checkoutBranch(baseBranch);
|
|
2002
|
+
if (coResult.exitCode !== 0) {
|
|
2003
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
2004
|
+
process.exit(1);
|
|
2005
|
+
}
|
|
2006
|
+
info(`Squash merging ${pc10.bold(featureBranch)} into ${pc10.bold(baseBranch)}...`);
|
|
2007
|
+
const mergeResult = await mergeSquash(featureBranch);
|
|
2008
|
+
if (mergeResult.exitCode !== 0) {
|
|
2009
|
+
error(`Squash merge failed: ${mergeResult.stderr}`);
|
|
2010
|
+
process.exit(1);
|
|
2011
|
+
}
|
|
2012
|
+
let message = options?.defaultMsg;
|
|
2013
|
+
if (!message) {
|
|
2014
|
+
const copilotError = await checkCopilotAvailable();
|
|
2015
|
+
if (!copilotError) {
|
|
2016
|
+
const spinner = createSpinner("Generating AI commit message for squash merge...");
|
|
2017
|
+
const [stagedDiff, stagedFiles] = await Promise.all([getStagedDiff(), getStagedFiles()]);
|
|
2018
|
+
const aiMsg = await generateCommitMessage(stagedDiff, stagedFiles, options?.model, options?.convention ?? "clean-commit");
|
|
2019
|
+
if (aiMsg) {
|
|
2020
|
+
message = aiMsg;
|
|
2021
|
+
spinner.success("AI commit message generated.");
|
|
2022
|
+
} else {
|
|
2023
|
+
spinner.fail("AI did not return a commit message.");
|
|
2024
|
+
}
|
|
2025
|
+
} else {
|
|
2026
|
+
warn(`AI unavailable: ${copilotError}`);
|
|
2027
|
+
}
|
|
2028
|
+
}
|
|
2029
|
+
const fallback = message || `squash merge ${featureBranch}`;
|
|
2030
|
+
const finalMsg = await inputPrompt("Commit message", fallback);
|
|
2031
|
+
const commitResult = await commitWithMessage(finalMsg);
|
|
2032
|
+
if (commitResult.exitCode !== 0) {
|
|
2033
|
+
error(`Commit failed: ${commitResult.stderr}`);
|
|
2034
|
+
process.exit(1);
|
|
2035
|
+
}
|
|
2036
|
+
info(`Pushing ${pc10.bold(baseBranch)} to ${origin}...`);
|
|
2037
|
+
const pushResult = await pushBranch(origin, baseBranch);
|
|
2038
|
+
if (pushResult.exitCode !== 0) {
|
|
2039
|
+
error(`Failed to push ${baseBranch}: ${pushResult.stderr}`);
|
|
2040
|
+
process.exit(1);
|
|
2041
|
+
}
|
|
2042
|
+
info(`Deleting local branch ${pc10.bold(featureBranch)}...`);
|
|
2043
|
+
const delLocal = await forceDeleteBranch(featureBranch);
|
|
2044
|
+
if (delLocal.exitCode !== 0) {
|
|
2045
|
+
warn(`Could not delete local branch: ${delLocal.stderr.trim()}`);
|
|
2046
|
+
}
|
|
2047
|
+
info(`Deleting remote branch ${pc10.bold(featureBranch)}...`);
|
|
2048
|
+
const delRemote = await deleteRemoteBranch(origin, featureBranch);
|
|
2049
|
+
if (delRemote.exitCode !== 0) {
|
|
2050
|
+
warn(`Could not delete remote branch: ${delRemote.stderr.trim()}`);
|
|
2051
|
+
}
|
|
2052
|
+
success(`✅ Squash merged ${pc10.bold(featureBranch)} into ${pc10.bold(baseBranch)} and pushed.`);
|
|
2053
|
+
info(`Run ${pc10.bold("contrib start")} to begin a new feature.`);
|
|
2054
|
+
}
|
|
1299
2055
|
var submit_default = defineCommand7({
|
|
1300
2056
|
meta: {
|
|
1301
2057
|
name: "submit",
|
|
@@ -1336,61 +2092,156 @@ var submit_default = defineCommand7({
|
|
|
1336
2092
|
process.exit(1);
|
|
1337
2093
|
}
|
|
1338
2094
|
if (protectedBranches.includes(currentBranch)) {
|
|
1339
|
-
error(`Cannot submit ${protectedBranches.map((b) =>
|
|
2095
|
+
error(`Cannot submit ${protectedBranches.map((b) => pc10.bold(b)).join(" or ")} as a PR. Switch to your feature branch.`);
|
|
1340
2096
|
process.exit(1);
|
|
1341
2097
|
}
|
|
1342
2098
|
heading("\uD83D\uDE80 contrib submit");
|
|
1343
|
-
|
|
2099
|
+
const ghInstalled = await checkGhInstalled();
|
|
2100
|
+
const ghAuthed = ghInstalled && await checkGhAuth();
|
|
2101
|
+
if (ghInstalled && ghAuthed) {
|
|
2102
|
+
const mergedPR = await getMergedPRForBranch(currentBranch);
|
|
2103
|
+
if (mergedPR) {
|
|
2104
|
+
warn(`PR #${mergedPR.number} (${pc10.bold(mergedPR.title)}) was already merged.`);
|
|
2105
|
+
const localWork = await hasLocalWork(origin, currentBranch);
|
|
2106
|
+
const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
|
|
2107
|
+
if (hasWork) {
|
|
2108
|
+
if (localWork.uncommitted) {
|
|
2109
|
+
warn("You have uncommitted changes in your working tree.");
|
|
2110
|
+
}
|
|
2111
|
+
if (localWork.unpushedCommits > 0) {
|
|
2112
|
+
warn(`You have ${pc10.bold(String(localWork.unpushedCommits))} local commit${localWork.unpushedCommits !== 1 ? "s" : ""} not in the merged PR.`);
|
|
2113
|
+
}
|
|
2114
|
+
const SAVE_NEW_BRANCH = "Save changes to a new branch";
|
|
2115
|
+
const DISCARD = "Discard all changes and clean up";
|
|
2116
|
+
const CANCEL2 = "Cancel";
|
|
2117
|
+
const action = await selectPrompt("This branch was merged but you have local changes. What would you like to do?", [SAVE_NEW_BRANCH, DISCARD, CANCEL2]);
|
|
2118
|
+
if (action === CANCEL2) {
|
|
2119
|
+
info("No changes made. You are still on your current branch.");
|
|
2120
|
+
return;
|
|
2121
|
+
}
|
|
2122
|
+
if (action === SAVE_NEW_BRANCH) {
|
|
2123
|
+
const suggestedName = currentBranch.replace(/^(feature|fix|docs|chore|test|refactor)\//, "$1/new-");
|
|
2124
|
+
const newBranchName = await inputPrompt("New branch name", suggestedName !== currentBranch ? suggestedName : `${currentBranch}-v2`);
|
|
2125
|
+
const renameResult = await renameBranch(currentBranch, newBranchName);
|
|
2126
|
+
if (renameResult.exitCode !== 0) {
|
|
2127
|
+
error(`Failed to rename branch: ${renameResult.stderr}`);
|
|
2128
|
+
process.exit(1);
|
|
2129
|
+
}
|
|
2130
|
+
success(`Renamed ${pc10.bold(currentBranch)} → ${pc10.bold(newBranchName)}`);
|
|
2131
|
+
const syncSource2 = getSyncSource(config);
|
|
2132
|
+
info(`Syncing ${pc10.bold(newBranchName)} with latest ${pc10.bold(baseBranch)}...`);
|
|
2133
|
+
await fetchRemote(syncSource2.remote);
|
|
2134
|
+
const savedUpstreamRef = await getUpstreamRef();
|
|
2135
|
+
const rebaseResult = savedUpstreamRef && savedUpstreamRef !== syncSource2.ref ? await rebaseOnto(syncSource2.ref, savedUpstreamRef) : await rebase(syncSource2.ref);
|
|
2136
|
+
if (rebaseResult.exitCode !== 0) {
|
|
2137
|
+
warn("Rebase encountered conflicts. Resolve them manually, then run:");
|
|
2138
|
+
info(` ${pc10.bold("git rebase --continue")}`);
|
|
2139
|
+
} else {
|
|
2140
|
+
success(`Rebased ${pc10.bold(newBranchName)} onto ${pc10.bold(syncSource2.ref)}.`);
|
|
2141
|
+
}
|
|
2142
|
+
info(`All your changes are preserved. Run ${pc10.bold("contrib submit")} when ready to create a new PR.`);
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
warn("Discarding local changes...");
|
|
2146
|
+
}
|
|
2147
|
+
const syncSource = getSyncSource(config);
|
|
2148
|
+
info(`Switching to ${pc10.bold(baseBranch)} and syncing...`);
|
|
2149
|
+
await fetchRemote(syncSource.remote);
|
|
2150
|
+
const coResult = await checkoutBranch(baseBranch);
|
|
2151
|
+
if (coResult.exitCode !== 0) {
|
|
2152
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
2153
|
+
process.exit(1);
|
|
2154
|
+
}
|
|
2155
|
+
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
2156
|
+
success(`Synced ${pc10.bold(baseBranch)} with ${pc10.bold(syncSource.ref)}.`);
|
|
2157
|
+
info(`Deleting stale branch ${pc10.bold(currentBranch)}...`);
|
|
2158
|
+
const delResult = await forceDeleteBranch(currentBranch);
|
|
2159
|
+
if (delResult.exitCode === 0) {
|
|
2160
|
+
success(`Deleted ${pc10.bold(currentBranch)}.`);
|
|
2161
|
+
} else {
|
|
2162
|
+
warn(`Could not delete branch: ${delResult.stderr.trim()}`);
|
|
2163
|
+
}
|
|
2164
|
+
console.log();
|
|
2165
|
+
info(`You're now on ${pc10.bold(baseBranch)}. Run ${pc10.bold("contrib start")} to begin a new feature.`);
|
|
2166
|
+
return;
|
|
2167
|
+
}
|
|
2168
|
+
}
|
|
2169
|
+
info(`Pushing ${pc10.bold(currentBranch)} to ${origin}...`);
|
|
1344
2170
|
const pushResult = await pushSetUpstream(origin, currentBranch);
|
|
1345
2171
|
if (pushResult.exitCode !== 0) {
|
|
1346
2172
|
error(`Failed to push: ${pushResult.stderr}`);
|
|
1347
2173
|
process.exit(1);
|
|
1348
2174
|
}
|
|
1349
|
-
const ghInstalled = await checkGhInstalled();
|
|
1350
|
-
const ghAuthed = ghInstalled && await checkGhAuth();
|
|
1351
2175
|
if (!ghInstalled || !ghAuthed) {
|
|
1352
2176
|
const repoInfo = await getRepoInfoFromRemote(origin);
|
|
1353
2177
|
if (repoInfo) {
|
|
1354
2178
|
const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${baseBranch}...${currentBranch}?expand=1`;
|
|
1355
2179
|
console.log();
|
|
1356
2180
|
info("Create your PR manually:");
|
|
1357
|
-
console.log(` ${
|
|
2181
|
+
console.log(` ${pc10.cyan(prUrl)}`);
|
|
1358
2182
|
} else {
|
|
1359
2183
|
info("gh CLI not available. Create your PR manually on GitHub.");
|
|
1360
2184
|
}
|
|
1361
2185
|
return;
|
|
1362
2186
|
}
|
|
2187
|
+
const existingPR = await getPRForBranch(currentBranch);
|
|
2188
|
+
if (existingPR) {
|
|
2189
|
+
success(`Pushed changes to existing PR #${existingPR.number}: ${pc10.bold(existingPR.title)}`);
|
|
2190
|
+
console.log(` ${pc10.cyan(existingPR.url)}`);
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
1363
2193
|
let prTitle = null;
|
|
1364
2194
|
let prBody = null;
|
|
1365
2195
|
if (!args["no-ai"]) {
|
|
1366
|
-
const copilotError = await
|
|
2196
|
+
const [copilotError, commits, diff] = await Promise.all([
|
|
2197
|
+
checkCopilotAvailable(),
|
|
2198
|
+
getLog(baseBranch, "HEAD"),
|
|
2199
|
+
getLogDiff(baseBranch, "HEAD")
|
|
2200
|
+
]);
|
|
1367
2201
|
if (!copilotError) {
|
|
1368
|
-
|
|
1369
|
-
const
|
|
1370
|
-
const diff = await getLogDiff(baseBranch, "HEAD");
|
|
1371
|
-
const result = await generatePRDescription(commits, diff, args.model);
|
|
2202
|
+
const spinner = createSpinner("Generating AI PR description...");
|
|
2203
|
+
const result = await generatePRDescription(commits, diff, args.model, config.commitConvention);
|
|
1372
2204
|
if (result) {
|
|
1373
2205
|
prTitle = result.title;
|
|
1374
2206
|
prBody = result.body;
|
|
2207
|
+
spinner.success("PR description generated.");
|
|
1375
2208
|
console.log(`
|
|
1376
|
-
${
|
|
2209
|
+
${pc10.dim("AI title:")} ${pc10.bold(pc10.cyan(prTitle))}`);
|
|
1377
2210
|
console.log(`
|
|
1378
|
-
${
|
|
1379
|
-
console.log(
|
|
2211
|
+
${pc10.dim("AI body preview:")}`);
|
|
2212
|
+
console.log(pc10.dim(prBody.slice(0, 300) + (prBody.length > 300 ? "..." : "")));
|
|
1380
2213
|
} else {
|
|
1381
|
-
|
|
2214
|
+
spinner.fail("AI did not return a PR description.");
|
|
1382
2215
|
}
|
|
1383
2216
|
} else {
|
|
1384
2217
|
warn(`AI unavailable: ${copilotError}`);
|
|
1385
2218
|
}
|
|
1386
2219
|
}
|
|
2220
|
+
const CANCEL = "Cancel";
|
|
2221
|
+
const SQUASH_LOCAL = `Squash merge to ${baseBranch} locally (no PR)`;
|
|
1387
2222
|
if (prTitle && prBody) {
|
|
1388
|
-
const
|
|
2223
|
+
const choices = [
|
|
1389
2224
|
"Use AI description",
|
|
1390
2225
|
"Edit title",
|
|
1391
2226
|
"Write manually",
|
|
1392
2227
|
"Use gh --fill (auto-fill from commits)"
|
|
1393
|
-
]
|
|
2228
|
+
];
|
|
2229
|
+
if (config.role === "maintainer")
|
|
2230
|
+
choices.push(SQUASH_LOCAL);
|
|
2231
|
+
choices.push(CANCEL);
|
|
2232
|
+
const action = await selectPrompt("What would you like to do with the PR description?", choices);
|
|
2233
|
+
if (action === CANCEL) {
|
|
2234
|
+
warn("Submit cancelled.");
|
|
2235
|
+
return;
|
|
2236
|
+
}
|
|
2237
|
+
if (action === SQUASH_LOCAL) {
|
|
2238
|
+
await performSquashMerge(origin, baseBranch, currentBranch, {
|
|
2239
|
+
defaultMsg: prTitle ?? undefined,
|
|
2240
|
+
model: args.model,
|
|
2241
|
+
convention: config.commitConvention
|
|
2242
|
+
});
|
|
2243
|
+
return;
|
|
2244
|
+
}
|
|
1394
2245
|
if (action === "Use AI description") {} else if (action === "Edit title") {
|
|
1395
2246
|
prTitle = await inputPrompt("PR title", prTitle);
|
|
1396
2247
|
} else if (action === "Write manually") {
|
|
@@ -1406,8 +2257,26 @@ ${pc9.dim("AI body preview:")}`);
|
|
|
1406
2257
|
return;
|
|
1407
2258
|
}
|
|
1408
2259
|
} else {
|
|
1409
|
-
const
|
|
1410
|
-
|
|
2260
|
+
const choices = [
|
|
2261
|
+
"Write title & body manually",
|
|
2262
|
+
"Use gh --fill (auto-fill from commits)"
|
|
2263
|
+
];
|
|
2264
|
+
if (config.role === "maintainer")
|
|
2265
|
+
choices.push(SQUASH_LOCAL);
|
|
2266
|
+
choices.push(CANCEL);
|
|
2267
|
+
const action = await selectPrompt("How would you like to create the PR?", choices);
|
|
2268
|
+
if (action === CANCEL) {
|
|
2269
|
+
warn("Submit cancelled.");
|
|
2270
|
+
return;
|
|
2271
|
+
}
|
|
2272
|
+
if (action === SQUASH_LOCAL) {
|
|
2273
|
+
await performSquashMerge(origin, baseBranch, currentBranch, {
|
|
2274
|
+
model: args.model,
|
|
2275
|
+
convention: config.commitConvention
|
|
2276
|
+
});
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
2279
|
+
if (action === "Write title & body manually") {
|
|
1411
2280
|
prTitle = await inputPrompt("PR title");
|
|
1412
2281
|
prBody = await inputPrompt("PR body (markdown)");
|
|
1413
2282
|
} else {
|
|
@@ -1440,7 +2309,7 @@ ${pc9.dim("AI body preview:")}`);
|
|
|
1440
2309
|
|
|
1441
2310
|
// src/commands/sync.ts
|
|
1442
2311
|
import { defineCommand as defineCommand8 } from "citty";
|
|
1443
|
-
import
|
|
2312
|
+
import pc11 from "picocolors";
|
|
1444
2313
|
var sync_default = defineCommand8({
|
|
1445
2314
|
meta: {
|
|
1446
2315
|
name: "sync",
|
|
@@ -1483,12 +2352,12 @@ var sync_default = defineCommand8({
|
|
|
1483
2352
|
}
|
|
1484
2353
|
const div = await getDivergence(baseBranch, syncSource.ref);
|
|
1485
2354
|
if (div.ahead > 0 || div.behind > 0) {
|
|
1486
|
-
info(`${
|
|
2355
|
+
info(`${pc11.bold(baseBranch)} is ${pc11.yellow(`${div.ahead} ahead`)} and ${pc11.red(`${div.behind} behind`)} ${syncSource.ref}`);
|
|
1487
2356
|
} else {
|
|
1488
|
-
info(`${
|
|
2357
|
+
info(`${pc11.bold(baseBranch)} is already in sync with ${syncSource.ref}`);
|
|
1489
2358
|
}
|
|
1490
2359
|
if (!args.yes) {
|
|
1491
|
-
const ok = await confirmPrompt(`This will pull ${
|
|
2360
|
+
const ok = await confirmPrompt(`This will pull ${pc11.bold(syncSource.ref)} into local ${pc11.bold(baseBranch)}.`);
|
|
1492
2361
|
if (!ok)
|
|
1493
2362
|
process.exit(0);
|
|
1494
2363
|
}
|
|
@@ -1506,7 +2375,7 @@ var sync_default = defineCommand8({
|
|
|
1506
2375
|
if (hasDevBranch(workflow) && role === "maintainer") {
|
|
1507
2376
|
const mainDiv = await getDivergence(config.mainBranch, `${origin}/${config.mainBranch}`);
|
|
1508
2377
|
if (mainDiv.behind > 0) {
|
|
1509
|
-
info(`Also syncing ${
|
|
2378
|
+
info(`Also syncing ${pc11.bold(config.mainBranch)}...`);
|
|
1510
2379
|
const mainCoResult = await checkoutBranch(config.mainBranch);
|
|
1511
2380
|
if (mainCoResult.exitCode === 0) {
|
|
1512
2381
|
const mainPullResult = await pullBranch(origin, config.mainBranch);
|
|
@@ -1521,9 +2390,9 @@ var sync_default = defineCommand8({
|
|
|
1521
2390
|
});
|
|
1522
2391
|
|
|
1523
2392
|
// src/commands/update.ts
|
|
1524
|
-
import { readFileSync as
|
|
2393
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
1525
2394
|
import { defineCommand as defineCommand9 } from "citty";
|
|
1526
|
-
import
|
|
2395
|
+
import pc12 from "picocolors";
|
|
1527
2396
|
var update_default = defineCommand9({
|
|
1528
2397
|
meta: {
|
|
1529
2398
|
name: "update",
|
|
@@ -1559,7 +2428,7 @@ var update_default = defineCommand9({
|
|
|
1559
2428
|
process.exit(1);
|
|
1560
2429
|
}
|
|
1561
2430
|
if (protectedBranches.includes(currentBranch)) {
|
|
1562
|
-
error(`Use \`contrib sync\` to update ${protectedBranches.map((b) =>
|
|
2431
|
+
error(`Use \`contrib sync\` to update ${protectedBranches.map((b) => pc12.bold(b)).join(" or ")} branches.`);
|
|
1563
2432
|
process.exit(1);
|
|
1564
2433
|
}
|
|
1565
2434
|
if (await hasUncommittedChanges()) {
|
|
@@ -1567,10 +2436,92 @@ var update_default = defineCommand9({
|
|
|
1567
2436
|
process.exit(1);
|
|
1568
2437
|
}
|
|
1569
2438
|
heading("\uD83D\uDD03 contrib update");
|
|
1570
|
-
|
|
2439
|
+
const mergedPR = await getMergedPRForBranch(currentBranch);
|
|
2440
|
+
if (mergedPR) {
|
|
2441
|
+
warn(`PR #${mergedPR.number} (${pc12.bold(mergedPR.title)}) has already been merged.`);
|
|
2442
|
+
info(`Link: ${pc12.underline(mergedPR.url)}`);
|
|
2443
|
+
const localWork = await hasLocalWork(syncSource.remote, currentBranch);
|
|
2444
|
+
const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
|
|
2445
|
+
if (hasWork) {
|
|
2446
|
+
if (localWork.uncommitted) {
|
|
2447
|
+
info("You have uncommitted local changes.");
|
|
2448
|
+
}
|
|
2449
|
+
if (localWork.unpushedCommits > 0) {
|
|
2450
|
+
info(`You have ${localWork.unpushedCommits} unpushed commit(s).`);
|
|
2451
|
+
}
|
|
2452
|
+
const SAVE_NEW_BRANCH = "Save changes to a new branch";
|
|
2453
|
+
const DISCARD = "Discard all changes and clean up";
|
|
2454
|
+
const CANCEL = "Cancel";
|
|
2455
|
+
const action = await selectPrompt(`${pc12.bold(currentBranch)} is stale but has local work. What would you like to do?`, [SAVE_NEW_BRANCH, DISCARD, CANCEL]);
|
|
2456
|
+
if (action === CANCEL) {
|
|
2457
|
+
info("No changes made. You are still on your current branch.");
|
|
2458
|
+
return;
|
|
2459
|
+
}
|
|
2460
|
+
if (action === SAVE_NEW_BRANCH) {
|
|
2461
|
+
info(pc12.dim("Tip: Describe what you're working on in plain English and we'll generate a branch name."));
|
|
2462
|
+
const description = await inputPrompt("What are you working on?");
|
|
2463
|
+
let newBranchName = description;
|
|
2464
|
+
if (!args["no-ai"] && looksLikeNaturalLanguage(description)) {
|
|
2465
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
2466
|
+
const suggested = await suggestBranchName(description, args.model);
|
|
2467
|
+
if (suggested) {
|
|
2468
|
+
spinner.success("Branch name suggestion ready.");
|
|
2469
|
+
console.log(`
|
|
2470
|
+
${pc12.dim("AI suggestion:")} ${pc12.bold(pc12.cyan(suggested))}`);
|
|
2471
|
+
const accepted = await confirmPrompt(`Use ${pc12.bold(suggested)} as your branch name?`);
|
|
2472
|
+
newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
|
|
2473
|
+
} else {
|
|
2474
|
+
spinner.fail("AI did not return a suggestion.");
|
|
2475
|
+
newBranchName = await inputPrompt("Enter branch name", description);
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
if (!hasPrefix(newBranchName, config.branchPrefixes)) {
|
|
2479
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc12.bold(newBranchName)}:`, config.branchPrefixes);
|
|
2480
|
+
newBranchName = formatBranchName(prefix, newBranchName);
|
|
2481
|
+
}
|
|
2482
|
+
if (!isValidBranchName(newBranchName)) {
|
|
2483
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
2484
|
+
process.exit(1);
|
|
2485
|
+
}
|
|
2486
|
+
const renameResult = await renameBranch(currentBranch, newBranchName);
|
|
2487
|
+
if (renameResult.exitCode !== 0) {
|
|
2488
|
+
error(`Failed to rename branch: ${renameResult.stderr}`);
|
|
2489
|
+
process.exit(1);
|
|
2490
|
+
}
|
|
2491
|
+
success(`Renamed ${pc12.bold(currentBranch)} → ${pc12.bold(newBranchName)}`);
|
|
2492
|
+
await fetchRemote(syncSource.remote);
|
|
2493
|
+
const savedUpstreamRef = await getUpstreamRef();
|
|
2494
|
+
const rebaseResult2 = savedUpstreamRef && savedUpstreamRef !== syncSource.ref ? await rebaseOnto(syncSource.ref, savedUpstreamRef) : await rebase(syncSource.ref);
|
|
2495
|
+
if (rebaseResult2.exitCode !== 0) {
|
|
2496
|
+
warn("Rebase encountered conflicts. Resolve them manually, then run:");
|
|
2497
|
+
info(` ${pc12.bold("git rebase --continue")}`);
|
|
2498
|
+
} else {
|
|
2499
|
+
success(`Rebased ${pc12.bold(newBranchName)} onto ${pc12.bold(syncSource.ref)}.`);
|
|
2500
|
+
}
|
|
2501
|
+
info(`All your changes are preserved. Run ${pc12.bold("contrib submit")} when ready to create a new PR.`);
|
|
2502
|
+
return;
|
|
2503
|
+
}
|
|
2504
|
+
warn("Discarding local changes...");
|
|
2505
|
+
}
|
|
2506
|
+
await fetchRemote(syncSource.remote);
|
|
2507
|
+
const coResult = await checkoutBranch(baseBranch);
|
|
2508
|
+
if (coResult.exitCode !== 0) {
|
|
2509
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
2510
|
+
process.exit(1);
|
|
2511
|
+
}
|
|
2512
|
+
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
2513
|
+
success(`Synced ${pc12.bold(baseBranch)} with ${pc12.bold(syncSource.ref)}.`);
|
|
2514
|
+
info(`Deleting stale branch ${pc12.bold(currentBranch)}...`);
|
|
2515
|
+
await forceDeleteBranch(currentBranch);
|
|
2516
|
+
success(`Deleted ${pc12.bold(currentBranch)}.`);
|
|
2517
|
+
info(`Run ${pc12.bold("contrib start")} to begin a new feature branch.`);
|
|
2518
|
+
return;
|
|
2519
|
+
}
|
|
2520
|
+
info(`Updating ${pc12.bold(currentBranch)} with latest ${pc12.bold(baseBranch)}...`);
|
|
1571
2521
|
await fetchRemote(syncSource.remote);
|
|
1572
2522
|
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
1573
|
-
const
|
|
2523
|
+
const upstreamRef = await getUpstreamRef();
|
|
2524
|
+
const rebaseResult = upstreamRef && upstreamRef !== syncSource.ref ? await rebaseOnto(syncSource.ref, upstreamRef) : await rebase(syncSource.ref);
|
|
1574
2525
|
if (rebaseResult.exitCode !== 0) {
|
|
1575
2526
|
warn("Rebase hit conflicts. Resolve them manually.");
|
|
1576
2527
|
console.log();
|
|
@@ -1582,7 +2533,7 @@ var update_default = defineCommand9({
|
|
|
1582
2533
|
let conflictDiff = "";
|
|
1583
2534
|
for (const file of conflictFiles.slice(0, 3)) {
|
|
1584
2535
|
try {
|
|
1585
|
-
const content =
|
|
2536
|
+
const content = readFileSync4(file, "utf-8");
|
|
1586
2537
|
if (content.includes("<<<<<<<")) {
|
|
1587
2538
|
conflictDiff += `
|
|
1588
2539
|
--- ${file} ---
|
|
@@ -1592,33 +2543,37 @@ ${content.slice(0, 2000)}
|
|
|
1592
2543
|
} catch {}
|
|
1593
2544
|
}
|
|
1594
2545
|
if (conflictDiff) {
|
|
2546
|
+
const spinner = createSpinner("Analyzing conflicts with AI...");
|
|
1595
2547
|
const suggestion = await suggestConflictResolution(conflictDiff, args.model);
|
|
1596
2548
|
if (suggestion) {
|
|
2549
|
+
spinner.success("AI conflict guidance ready.");
|
|
1597
2550
|
console.log(`
|
|
1598
|
-
${
|
|
1599
|
-
console.log(
|
|
2551
|
+
${pc12.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
|
|
2552
|
+
console.log(pc12.dim("─".repeat(60)));
|
|
1600
2553
|
console.log(suggestion);
|
|
1601
|
-
console.log(
|
|
2554
|
+
console.log(pc12.dim("─".repeat(60)));
|
|
1602
2555
|
console.log();
|
|
2556
|
+
} else {
|
|
2557
|
+
spinner.fail("AI could not analyze the conflicts.");
|
|
1603
2558
|
}
|
|
1604
2559
|
}
|
|
1605
2560
|
}
|
|
1606
2561
|
}
|
|
1607
|
-
console.log(
|
|
2562
|
+
console.log(pc12.bold("To resolve:"));
|
|
1608
2563
|
console.log(` 1. Fix conflicts in the affected files`);
|
|
1609
|
-
console.log(` 2. ${
|
|
1610
|
-
console.log(` 3. ${
|
|
2564
|
+
console.log(` 2. ${pc12.cyan("git add <resolved-files>")}`);
|
|
2565
|
+
console.log(` 3. ${pc12.cyan("git rebase --continue")}`);
|
|
1611
2566
|
console.log();
|
|
1612
|
-
console.log(` Or abort: ${
|
|
2567
|
+
console.log(` Or abort: ${pc12.cyan("git rebase --abort")}`);
|
|
1613
2568
|
process.exit(1);
|
|
1614
2569
|
}
|
|
1615
|
-
success(`✅ ${
|
|
2570
|
+
success(`✅ ${pc12.bold(currentBranch)} has been rebased onto latest ${pc12.bold(baseBranch)}`);
|
|
1616
2571
|
}
|
|
1617
2572
|
});
|
|
1618
2573
|
|
|
1619
2574
|
// src/commands/validate.ts
|
|
1620
2575
|
import { defineCommand as defineCommand10 } from "citty";
|
|
1621
|
-
import
|
|
2576
|
+
import pc13 from "picocolors";
|
|
1622
2577
|
var validate_default = defineCommand10({
|
|
1623
2578
|
meta: {
|
|
1624
2579
|
name: "validate",
|
|
@@ -1649,7 +2604,7 @@ var validate_default = defineCommand10({
|
|
|
1649
2604
|
}
|
|
1650
2605
|
const errors = getValidationError(convention);
|
|
1651
2606
|
for (const line of errors) {
|
|
1652
|
-
console.error(
|
|
2607
|
+
console.error(pc13.red(` ✗ ${line}`));
|
|
1653
2608
|
}
|
|
1654
2609
|
process.exit(1);
|
|
1655
2610
|
}
|
|
@@ -1657,11 +2612,11 @@ var validate_default = defineCommand10({
|
|
|
1657
2612
|
|
|
1658
2613
|
// src/ui/banner.ts
|
|
1659
2614
|
import figlet from "figlet";
|
|
1660
|
-
import
|
|
2615
|
+
import pc14 from "picocolors";
|
|
1661
2616
|
// package.json
|
|
1662
2617
|
var package_default = {
|
|
1663
2618
|
name: "contribute-now",
|
|
1664
|
-
version: "0.2.0-dev.
|
|
2619
|
+
version: "0.2.0-dev.7c81c96",
|
|
1665
2620
|
description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
|
|
1666
2621
|
type: "module",
|
|
1667
2622
|
bin: {
|
|
@@ -1705,6 +2660,7 @@ var package_default = {
|
|
|
1705
2660
|
url: "git+https://github.com/warengonzaga/contribute-now.git"
|
|
1706
2661
|
},
|
|
1707
2662
|
dependencies: {
|
|
2663
|
+
"@clack/prompts": "^1.0.1",
|
|
1708
2664
|
"@github/copilot-sdk": "^0.1.25",
|
|
1709
2665
|
"@wgtechlabs/log-engine": "^2.3.1",
|
|
1710
2666
|
citty: "^0.1.6",
|
|
@@ -1734,14 +2690,14 @@ function getAuthor() {
|
|
|
1734
2690
|
return typeof package_default.author === "string" ? package_default.author : "unknown";
|
|
1735
2691
|
}
|
|
1736
2692
|
function showBanner(showLinks = false) {
|
|
1737
|
-
console.log(
|
|
2693
|
+
console.log(pc14.cyan(`
|
|
1738
2694
|
${LOGO}`));
|
|
1739
|
-
console.log(` ${
|
|
2695
|
+
console.log(` ${pc14.dim(`v${getVersion()}`)} ${pc14.dim("—")} ${pc14.dim(`Built by ${getAuthor()}`)}`);
|
|
1740
2696
|
if (showLinks) {
|
|
1741
2697
|
console.log();
|
|
1742
|
-
console.log(` ${
|
|
1743
|
-
console.log(` ${
|
|
1744
|
-
console.log(` ${
|
|
2698
|
+
console.log(` ${pc14.yellow("Star")} ${pc14.cyan("https://github.com/warengonzaga/contribute-now")}`);
|
|
2699
|
+
console.log(` ${pc14.green("Contribute")} ${pc14.cyan("https://github.com/warengonzaga/contribute-now/blob/main/CONTRIBUTING.md")}`);
|
|
2700
|
+
console.log(` ${pc14.magenta("Sponsor")} ${pc14.cyan("https://warengonzaga.com/sponsor")}`);
|
|
1745
2701
|
}
|
|
1746
2702
|
console.log();
|
|
1747
2703
|
}
|
|
@@ -1780,4 +2736,6 @@ var main = defineCommand11({
|
|
|
1780
2736
|
}
|
|
1781
2737
|
}
|
|
1782
2738
|
});
|
|
1783
|
-
runMain(main)
|
|
2739
|
+
runMain(main).then(() => {
|
|
2740
|
+
process.exit(0);
|
|
2741
|
+
});
|