contribute-now 0.2.0-dev.70284d0 → 0.2.0-dev.8e07e2c

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