contribute-now 0.2.0-dev.e0cfab8 → 0.2.0-pr.4dd3c30

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 +961 -282
  2. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -20,7 +20,11 @@ function readConfig(cwd = process.cwd()) {
20
20
  return null;
21
21
  try {
22
22
  const raw = readFileSync(path, "utf-8");
23
- 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}`);
83
- });
84
- process.stdout.write(pc.dim(`Enter number [1-${choices.length}]: `));
85
- const response = await new Promise((resolve) => {
86
- process.stdin.setEncoding("utf-8");
87
- process.stdin.once("data", (data) => {
88
- process.stdin.pause();
89
- resolve(data.toString().trim());
90
- });
91
- process.stdin.resume();
77
+ const result = await clack.select({
78
+ message,
79
+ options: choices.map((choice) => ({ value: choice, label: choice }))
92
80
  });
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
110
89
  });
111
- return response || defaultValue || "";
90
+ handleCancel(result);
91
+ return result || defaultValue || "";
92
+ }
93
+ async function multiSelectPrompt(message, choices) {
94
+ const result = await clack.multiselect({
95
+ message: `${message} ${pc.dim("(space to toggle, enter to confirm)")}`,
96
+ options: choices.map((choice) => ({ value: choice, label: choice })),
97
+ required: false
98
+ });
99
+ handleCancel(result);
100
+ return result;
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
  });
@@ -169,6 +160,13 @@ async function createBranch(branch, from) {
169
160
  async function resetHard(ref) {
170
161
  return run(["reset", "--hard", ref]);
171
162
  }
163
+ async function updateLocalBranch(branch, target) {
164
+ const current = await getCurrentBranch();
165
+ if (current === branch) {
166
+ return resetHard(target);
167
+ }
168
+ return run(["branch", "-f", branch, target]);
169
+ }
172
170
  async function pushSetUpstream(remote, branch) {
173
171
  return run(["push", "-u", remote, branch]);
174
172
  }
@@ -190,8 +188,16 @@ async function getChangedFiles() {
190
188
  const { exitCode, stdout } = await run(["status", "--porcelain"]);
191
189
  if (exitCode !== 0)
192
190
  return [];
193
- return stdout.trim().split(`
194
- `).filter(Boolean).map((l) => l.slice(3));
191
+ return stdout.trimEnd().split(`
192
+ `).filter(Boolean).map((l) => {
193
+ const line = l.replace(/\r$/, "");
194
+ const match = line.match(/^..\s+(.*)/);
195
+ if (!match)
196
+ return "";
197
+ const file = match[1];
198
+ const renameIdx = file.indexOf(" -> ");
199
+ return renameIdx !== -1 ? file.slice(renameIdx + 4) : file;
200
+ }).filter(Boolean);
195
201
  }
196
202
  async function getDivergence(branch, base) {
197
203
  const { exitCode, stdout } = await run([
@@ -218,6 +224,18 @@ async function getMergedBranches(base) {
218
224
  async function deleteBranch(branch) {
219
225
  return run(["branch", "-d", branch]);
220
226
  }
227
+ async function forceDeleteBranch(branch) {
228
+ return run(["branch", "-D", branch]);
229
+ }
230
+ async function deleteRemoteBranch(remote, branch) {
231
+ return run(["push", remote, "--delete", branch]);
232
+ }
233
+ async function mergeSquash(branch) {
234
+ return run(["merge", "--squash", branch]);
235
+ }
236
+ async function pushBranch(remote, branch) {
237
+ return run(["push", remote, branch]);
238
+ }
221
239
  async function pruneRemote(remote) {
222
240
  return run(["remote", "prune", remote]);
223
241
  }
@@ -238,6 +256,85 @@ async function getLog(base, head) {
238
256
  async function pullBranch(remote, branch) {
239
257
  return run(["pull", remote, branch]);
240
258
  }
259
+ async function stageFiles(files) {
260
+ return run(["add", "--", ...files]);
261
+ }
262
+ async function unstageFiles(files) {
263
+ return run(["reset", "HEAD", "--", ...files]);
264
+ }
265
+ async function stageAll() {
266
+ return run(["add", "-A"]);
267
+ }
268
+ async function getFullDiffForFiles(files) {
269
+ const [unstaged, staged, untracked] = await Promise.all([
270
+ run(["diff", "--", ...files]),
271
+ run(["diff", "--cached", "--", ...files]),
272
+ getUntrackedFiles()
273
+ ]);
274
+ const parts = [staged.stdout, unstaged.stdout].filter(Boolean);
275
+ const untrackedSet = new Set(untracked);
276
+ const MAX_FILE_CONTENT = 2000;
277
+ for (const file of files) {
278
+ if (untrackedSet.has(file)) {
279
+ try {
280
+ const content = readFileSync2(join2(process.cwd(), file), "utf-8");
281
+ const truncated = content.length > MAX_FILE_CONTENT ? `${content.slice(0, MAX_FILE_CONTENT)}
282
+ ... (truncated)` : content;
283
+ const lines = truncated.split(`
284
+ `).map((l) => `+${l}`);
285
+ parts.push(`diff --git a/${file} b/${file}
286
+ new file
287
+ --- /dev/null
288
+ +++ b/${file}
289
+ ${lines.join(`
290
+ `)}`);
291
+ } catch {}
292
+ }
293
+ }
294
+ return parts.join(`
295
+ `);
296
+ }
297
+ async function getUntrackedFiles() {
298
+ const { exitCode, stdout } = await run(["ls-files", "--others", "--exclude-standard"]);
299
+ if (exitCode !== 0)
300
+ return [];
301
+ return stdout.trim().split(`
302
+ `).filter(Boolean);
303
+ }
304
+ async function getFileStatus() {
305
+ const { exitCode, stdout } = await run(["status", "--porcelain"]);
306
+ if (exitCode !== 0)
307
+ return { staged: [], modified: [], untracked: [] };
308
+ const result = { staged: [], modified: [], untracked: [] };
309
+ const STATUS_LABELS = {
310
+ A: "new file",
311
+ M: "modified",
312
+ D: "deleted",
313
+ R: "renamed",
314
+ C: "copied",
315
+ T: "type changed"
316
+ };
317
+ for (const raw of stdout.trimEnd().split(`
318
+ `).filter(Boolean)) {
319
+ const line = raw.replace(/\r$/, "");
320
+ const indexStatus = line[0];
321
+ const workTreeStatus = line[1];
322
+ const pathPart = line.slice(3);
323
+ const renameIdx = pathPart.indexOf(" -> ");
324
+ const file = renameIdx !== -1 ? pathPart.slice(renameIdx + 4) : pathPart;
325
+ if (indexStatus === "?" && workTreeStatus === "?") {
326
+ result.untracked.push(file);
327
+ continue;
328
+ }
329
+ if (indexStatus && indexStatus !== " " && indexStatus !== "?") {
330
+ result.staged.push({ file, status: STATUS_LABELS[indexStatus] ?? indexStatus });
331
+ }
332
+ if (workTreeStatus && workTreeStatus !== " " && workTreeStatus !== "?") {
333
+ result.modified.push({ file, status: STATUS_LABELS[workTreeStatus] ?? workTreeStatus });
334
+ }
335
+ }
336
+ return result;
337
+ }
241
338
 
242
339
  // src/utils/logger.ts
243
340
  import { LogEngine, LogMode } from "@wgtechlabs/log-engine";
@@ -380,7 +477,7 @@ ${pc3.bold("Branches to delete:")}`);
380
477
 
381
478
  // src/commands/commit.ts
382
479
  import { defineCommand as defineCommand2 } from "citty";
383
- import pc4 from "picocolors";
480
+ import pc5 from "picocolors";
384
481
 
385
482
  // src/utils/convention.ts
386
483
  var CLEAN_COMMIT_PATTERN = /^(📦|🔧|🗑\uFE0F?|🔒|⚙\uFE0F?|☕|🧪|📖|🚀) (new|update|remove|security|setup|chore|test|docs|release)(!?)( \([a-zA-Z0-9][a-zA-Z0-9-]*\))?: .{1,72}$/u;
@@ -427,96 +524,98 @@ function getValidationError(convention) {
427
524
 
428
525
  // src/utils/copilot.ts
429
526
  import { CopilotClient } from "@github/copilot-sdk";
430
- var CONVENTIONAL_COMMIT_SYSTEM_PROMPT = `You are a git commit message generator. Generate a Conventional Commit message following this exact format:
431
- <type>[!][(<scope>)]: <description>
432
-
433
- Types:
434
- feat – a new feature
435
- fix – a bug fix
436
- docs documentation only changes
437
- style – changes that do not affect code meaning (whitespace, formatting)
438
- refactor code change that neither fixes a bug nor adds a feature
439
- perf – performance improvement
440
- test – adding or correcting tests
441
- build – changes to the build system or external dependencies
442
- ci changes to CI configuration files and scripts
443
- chore – other changes that don't modify src or test files
444
- revert – reverts a previous commit
527
+ var CONVENTIONAL_COMMIT_SYSTEM_PROMPT = `Git commit message generator. Format: <type>[!][(<scope>)]: <description>
528
+ Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
529
+ 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.
530
+ Examples: feat: add user auth | fix(auth): resolve token expiry | feat!: redesign auth API`;
531
+ var CLEAN_COMMIT_SYSTEM_PROMPT = `Git commit message generator. EXACT format: <emoji> <type>[!][ (<scope>)]: <description>
532
+ Spacing: EMOJI SPACE TYPE [SPACE OPENPAREN SCOPE CLOSEPAREN] COLON SPACE DESCRIPTION
533
+ Types: \uD83D\uDCE6 new, \uD83D\uDD27 update, \uD83D\uDDD1️ remove, \uD83D\uDD12 security, ⚙️ setup, ☕ chore, \uD83E\uDDEA test, \uD83D\uDCD6 docs, \uD83D\uDE80 release
534
+ Rules: breaking (!) only for new/update/remove/security; imperative mood; max 72 chars; lowercase start; scope optional. Return ONLY the message line.
535
+ Correct: \uD83D\uDCE6 new: add user auth | \uD83D\uDD27 update (api): improve error handling | ⚙️ setup (ci): configure github actions
536
+ WRONG: ⚙️setup(ci): ... | \uD83D\uDD27 update(api): ... ← always space before scope parenthesis`;
537
+ function getGroupingSystemPrompt(convention) {
538
+ const conventionBlock = convention === "conventional" ? `Use Conventional Commit format: <type>[(<scope>)]: <description>
539
+ Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert` : `Use Clean Commit format: <emoji> <type>[!][ (<scope>)]: <description>
540
+ Emoji/type table:
541
+ \uD83D\uDCE6 new, \uD83D\uDD27 update, \uD83D\uDDD1️ remove, \uD83D\uDD12 security, ⚙️ setup, ☕ chore, \uD83E\uDDEA test, \uD83D\uDCD6 docs, \uD83D\uDE80 release`;
542
+ return `You are a smart commit grouping assistant. Given a list of changed files and their diffs, group related changes into logical atomic commits.
445
543
 
446
- Rules:
447
- - Breaking change (!) only for: feat, fix, refactor, perf
448
- - Description: concise, imperative mood, max 72 chars, lowercase start
449
- - Scope: optional, camelCase or kebab-case component name
450
- - Return ONLY the commit message line, nothing else
544
+ ${conventionBlock}
451
545
 
452
- Examples:
453
- feat: add user authentication system
454
- fix(auth): resolve token expiry issue
455
- docs: update contributing guidelines
456
- feat!: redesign authentication API`;
457
- var CLEAN_COMMIT_SYSTEM_PROMPT = `You are a git commit message generator. Generate a Clean Commit message following this exact format:
458
- <emoji> <type>[!][(<scope>)]: <description>
459
-
460
- Emoji and type table:
461
- \uD83D\uDCE6 new – new features, files, or capabilities
462
- \uD83D\uDD27 update – changes, refactoring, improvements
463
- \uD83D\uDDD1️ remove – removing code, files, or dependencies
464
- \uD83D\uDD12 security – security fixes or patches
465
- ⚙️ setup – configs, CI/CD, tooling, build systems
466
- ☕ chore – maintenance, dependency updates
467
- \uD83E\uDDEA test – adding or updating tests
468
- \uD83D\uDCD6 docs – documentation changes
469
- \uD83D\uDE80 release – version releases
546
+ Return a JSON array of commit groups with this EXACT structure (no markdown fences, no explanation):
547
+ [
548
+ {
549
+ "files": ["path/to/file1.ts", "path/to/file2.ts"],
550
+ "message": "<commit message following the convention above>"
551
+ }
552
+ ]
470
553
 
471
554
  Rules:
472
- - Breaking change (!) only for: new, update, remove, security
473
- - Description: concise, imperative mood, max 72 chars
474
- - Scope: optional, camelCase or kebab-case component name
475
- - Return ONLY the commit message line, nothing else
476
-
477
- Examples:
478
- \uD83D\uDCE6 new: user authentication system
479
- \uD83D\uDD27 update (api): improve error handling
480
- ⚙️ setup (ci): configure github actions workflow
481
- \uD83D\uDCE6 new!: completely redesign authentication system`;
482
- var BRANCH_NAME_SYSTEM_PROMPT = `You are a git branch name generator. Convert natural language descriptions into proper git branch names.
483
-
484
- Format: <prefix>/<kebab-case-name>
555
+ - Group files that are logically related (e.g. a utility and its tests, a feature and its types)
556
+ - Each group should represent ONE logical change
557
+ - Every file must appear in exactly one group
558
+ - Commit messages must follow the convention, be concise, imperative, max 72 chars
559
+ - Order groups so foundational changes come first (types, utils) and consumers come after
560
+ - Return ONLY the JSON array, nothing else`;
561
+ }
562
+ var BRANCH_NAME_SYSTEM_PROMPT = `Git branch name generator. Format: <prefix>/<kebab-case-name>
485
563
  Prefixes: feature, fix, docs, chore, test, refactor
486
-
487
- Rules:
488
- - Use lowercase kebab-case for the name part
489
- - Keep it short and descriptive (2-5 words max)
490
- - Return ONLY the branch name, nothing else
491
-
492
- Examples:
493
- Input: "fix the login timeout bug" fix/login-timeout
494
- Input: "add user profile page" feature/user-profile-page
495
- Input: "update readme documentation" docs/update-readme`;
496
- var PR_DESCRIPTION_SYSTEM_PROMPT = `You are a GitHub pull request description generator. Create a clear, structured PR description.
497
-
498
- Return a JSON object with this exact structure:
499
- {
500
- "title": "Brief PR title (50 chars max)",
501
- "body": "## Summary\\n...\\n\\n## Changes\\n...\\n\\n## Test Plan\\n..."
564
+ Rules: lowercase kebab-case, 2-5 words max. Return ONLY the branch name.
565
+ Examples: fix/login-timeout | feature/user-profile-page | docs/update-readme`;
566
+ 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..."}`;
567
+ function getPRDescriptionSystemPrompt(convention) {
568
+ if (convention === "clean-commit") {
569
+ return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
570
+ CRITICAL: The PR title MUST follow the Clean Commit format exactly: <emoji> <type>: <description>
571
+ 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
572
+ Title examples: \uD83D\uDCE6 new: add user authentication | \uD83D\uDD27 update: improve error handling | \uD83D\uDDD1️ remove: drop legacy API
573
+ Rules: title follows convention, present tense, max 72 chars; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
574
+ }
575
+ if (convention === "conventional") {
576
+ return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
577
+ CRITICAL: The PR title MUST follow Conventional Commits format: <type>[(<scope>)]: <description>
578
+ Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
579
+ Title examples: feat: add user authentication | fix(auth): resolve token expiry | docs: update contributing guide
580
+ Rules: title follows convention, present tense, max 72 chars; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
581
+ }
582
+ return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
583
+ Rules: title concise present tense; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
584
+ }
585
+ var CONFLICT_RESOLUTION_SYSTEM_PROMPT = `Git merge conflict advisor. Explain each side, suggest resolution strategy. Never auto-resolve — guidance only. Be concise and actionable.`;
586
+ function suppressSubprocessWarnings() {
587
+ process.env.NODE_NO_WARNINGS = "1";
588
+ }
589
+ function withTimeout(promise, ms) {
590
+ return new Promise((resolve, reject) => {
591
+ const timer = setTimeout(() => reject(new Error(`Copilot request timed out after ${ms / 1000}s`)), ms);
592
+ promise.then((val) => {
593
+ clearTimeout(timer);
594
+ resolve(val);
595
+ }, (err) => {
596
+ clearTimeout(timer);
597
+ reject(err);
598
+ });
599
+ });
502
600
  }
503
-
504
- Rules:
505
- - title: concise, present tense, describes what the PR does
506
- - body: markdown with Summary, Changes (bullet list), and Test Plan sections
507
- - Return ONLY the JSON object, no markdown fences, no extra text`;
508
- var CONFLICT_RESOLUTION_SYSTEM_PROMPT = `You are a git merge conflict resolution advisor. Analyze the conflict markers and provide guidance.
509
-
510
- Rules:
511
- - Explain what each side of the conflict contains
512
- - Suggest the most likely correct resolution strategy
513
- - Never auto-resolve — provide guidance only
514
- - Be concise and actionable`;
601
+ var COPILOT_TIMEOUT_MS = 30000;
602
+ var COPILOT_LONG_TIMEOUT_MS = 90000;
515
603
  async function checkCopilotAvailable() {
516
- let client = null;
517
604
  try {
518
- client = new CopilotClient;
519
- await client.start();
605
+ const client = await getManagedClient();
606
+ try {
607
+ await client.ping();
608
+ } catch (err) {
609
+ const msg = err instanceof Error ? err.message : String(err);
610
+ if (msg.includes("auth") || msg.includes("token") || msg.includes("401") || msg.includes("403")) {
611
+ return "Copilot authentication failed. Run `gh auth login` to refresh your token.";
612
+ }
613
+ if (msg.includes("ECONNREFUSED") || msg.includes("timeout") || msg.includes("network")) {
614
+ return "Could not reach GitHub Copilot service. Check your internet connection.";
615
+ }
616
+ return `Copilot health check failed: ${msg}`;
617
+ }
618
+ return null;
520
619
  } catch (err) {
521
620
  const msg = err instanceof Error ? err.message : String(err);
522
621
  if (msg.includes("ENOENT") || msg.includes("not found")) {
@@ -524,44 +623,45 @@ async function checkCopilotAvailable() {
524
623
  }
525
624
  return `Failed to start Copilot service: ${msg}`;
526
625
  }
527
- try {
528
- await client.ping();
529
- } catch (err) {
530
- const msg = err instanceof Error ? err.message : String(err);
531
- if (msg.includes("auth") || msg.includes("token") || msg.includes("401") || msg.includes("403")) {
532
- return "Copilot authentication failed. Run `gh auth login` to refresh your token.";
533
- }
534
- if (msg.includes("ECONNREFUSED") || msg.includes("timeout") || msg.includes("network")) {
535
- return "Could not reach GitHub Copilot service. Check your internet connection.";
536
- }
537
- return `Copilot health check failed: ${msg}`;
538
- } finally {
539
- try {
540
- await client.stop();
541
- } catch {}
626
+ }
627
+ var _managedClient = null;
628
+ var _clientStarted = false;
629
+ async function getManagedClient() {
630
+ if (!_managedClient || !_clientStarted) {
631
+ suppressSubprocessWarnings();
632
+ _managedClient = new CopilotClient;
633
+ await _managedClient.start();
634
+ _clientStarted = true;
635
+ const cleanup = () => {
636
+ if (_managedClient && _clientStarted) {
637
+ try {
638
+ _managedClient.stop();
639
+ } catch {}
640
+ _clientStarted = false;
641
+ _managedClient = null;
642
+ }
643
+ };
644
+ process.once("exit", cleanup);
645
+ process.once("SIGINT", cleanup);
646
+ process.once("SIGTERM", cleanup);
542
647
  }
543
- return null;
648
+ return _managedClient;
544
649
  }
545
- async function callCopilot(systemMessage, userMessage, model) {
546
- const client = new CopilotClient;
547
- await client.start();
650
+ async function callCopilot(systemMessage, userMessage, model, timeoutMs = COPILOT_TIMEOUT_MS) {
651
+ const client = await getManagedClient();
652
+ const sessionConfig = {
653
+ systemMessage: { mode: "replace", content: systemMessage }
654
+ };
655
+ if (model)
656
+ sessionConfig.model = model;
657
+ const session = await client.createSession(sessionConfig);
548
658
  try {
549
- const sessionConfig = {
550
- systemMessage: { content: systemMessage }
551
- };
552
- if (model)
553
- sessionConfig.model = model;
554
- const session = await client.createSession(sessionConfig);
555
- try {
556
- const response = await session.sendAndWait({ content: userMessage });
557
- if (!response?.data?.content)
558
- return null;
559
- return response.data.content;
560
- } finally {
561
- await session.destroy();
562
- }
659
+ const response = await withTimeout(session.sendAndWait({ prompt: userMessage }), timeoutMs);
660
+ if (!response?.data?.content)
661
+ return null;
662
+ return response.data.content;
563
663
  } finally {
564
- await client.stop();
664
+ await session.destroy();
565
665
  }
566
666
  }
567
667
  function getCommitSystemPrompt(convention) {
@@ -569,21 +669,53 @@ function getCommitSystemPrompt(convention) {
569
669
  return CONVENTIONAL_COMMIT_SYSTEM_PROMPT;
570
670
  return CLEAN_COMMIT_SYSTEM_PROMPT;
571
671
  }
672
+ function extractJson(raw) {
673
+ let text2 = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
674
+ if (text2.startsWith("[") || text2.startsWith("{"))
675
+ return text2;
676
+ const arrayStart = text2.indexOf("[");
677
+ const objStart = text2.indexOf("{");
678
+ let start;
679
+ let closeChar;
680
+ if (arrayStart === -1 && objStart === -1)
681
+ return text2;
682
+ if (arrayStart === -1) {
683
+ start = objStart;
684
+ closeChar = "}";
685
+ } else if (objStart === -1) {
686
+ start = arrayStart;
687
+ closeChar = "]";
688
+ } else if (arrayStart < objStart) {
689
+ start = arrayStart;
690
+ closeChar = "]";
691
+ } else {
692
+ start = objStart;
693
+ closeChar = "}";
694
+ }
695
+ const end = text2.lastIndexOf(closeChar);
696
+ if (end > start) {
697
+ text2 = text2.slice(start, end + 1);
698
+ }
699
+ return text2;
700
+ }
572
701
  async function generateCommitMessage(diff, stagedFiles, model, convention = "clean-commit") {
573
702
  try {
703
+ const multiFileHint = stagedFiles.length > 1 ? `
704
+
705
+ IMPORTANT: Multiple files are staged. Generate ONE commit message that captures the high-level purpose of ALL changes together. Focus on the overall intent, not individual file changes. Be specific but concise — do not list every file.` : "";
574
706
  const userMessage = `Generate a commit message for these staged changes:
575
707
 
576
708
  Files: ${stagedFiles.join(", ")}
577
709
 
578
710
  Diff:
579
- ${diff.slice(0, 4000)}`;
711
+ ${diff.slice(0, 4000)}${multiFileHint}`;
580
712
  const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
581
713
  return result?.trim() ?? null;
582
714
  } catch {
583
715
  return null;
584
716
  }
585
717
  }
586
- async function generatePRDescription(commits, diff, model) {
718
+ async function generatePRDescription(commits, diff, model, convention = "clean-commit") {
587
719
  try {
588
720
  const userMessage = `Generate a PR description for these changes:
589
721
 
@@ -593,10 +725,10 @@ ${commits.join(`
593
725
 
594
726
  Diff (truncated):
595
727
  ${diff.slice(0, 4000)}`;
596
- const result = await callCopilot(PR_DESCRIPTION_SYSTEM_PROMPT, userMessage, model);
728
+ const result = await callCopilot(getPRDescriptionSystemPrompt(convention), userMessage, model);
597
729
  if (!result)
598
730
  return null;
599
- const cleaned = result.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
731
+ const cleaned = extractJson(result);
600
732
  return JSON.parse(cleaned);
601
733
  } catch {
602
734
  return null;
@@ -621,6 +753,124 @@ ${conflictDiff.slice(0, 4000)}`;
621
753
  return null;
622
754
  }
623
755
  }
756
+ async function generateCommitGroups(files, diffs, model, convention = "clean-commit") {
757
+ const userMessage = `Group these changed files into logical atomic commits:
758
+
759
+ Files:
760
+ ${files.join(`
761
+ `)}
762
+
763
+ Diffs (truncated):
764
+ ${diffs.slice(0, 6000)}`;
765
+ const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
766
+ if (!result) {
767
+ throw new Error("AI returned an empty response");
768
+ }
769
+ const cleaned = extractJson(result);
770
+ let parsed;
771
+ try {
772
+ parsed = JSON.parse(cleaned);
773
+ } catch {
774
+ throw new Error(`AI response is not valid JSON. Raw start: "${result.slice(0, 120)}..."`);
775
+ }
776
+ const groups = parsed;
777
+ if (!Array.isArray(groups) || groups.length === 0) {
778
+ throw new Error("AI response was not a valid JSON array of commit groups");
779
+ }
780
+ for (const group of groups) {
781
+ if (!Array.isArray(group.files) || typeof group.message !== "string") {
782
+ throw new Error("AI returned groups with invalid structure (missing files or message)");
783
+ }
784
+ }
785
+ return groups;
786
+ }
787
+ async function regenerateAllGroupMessages(groups, diffs, model, convention = "clean-commit") {
788
+ const groupSummary = groups.map((g, i) => `Group ${i + 1}: [${g.files.join(", ")}]`).join(`
789
+ `);
790
+ const userMessage = `Regenerate ONLY the commit messages for these pre-defined file groups. Do NOT change the file groupings.
791
+
792
+ Groups:
793
+ ${groupSummary}
794
+
795
+ Diffs (truncated):
796
+ ${diffs.slice(0, 6000)}`;
797
+ const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
798
+ if (!result)
799
+ return groups;
800
+ try {
801
+ const cleaned = extractJson(result);
802
+ const parsed = JSON.parse(cleaned);
803
+ if (!Array.isArray(parsed) || parsed.length !== groups.length)
804
+ return groups;
805
+ return groups.map((g, i) => ({
806
+ files: g.files,
807
+ message: typeof parsed[i]?.message === "string" ? parsed[i].message : g.message
808
+ }));
809
+ } catch {
810
+ return groups;
811
+ }
812
+ }
813
+ async function regenerateGroupMessage(files, diffs, model, convention = "clean-commit") {
814
+ try {
815
+ const userMessage = `Generate a single commit message for these files:
816
+
817
+ Files: ${files.join(", ")}
818
+
819
+ Diff:
820
+ ${diffs.slice(0, 4000)}`;
821
+ const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
822
+ return result?.trim() ?? null;
823
+ } catch {
824
+ return null;
825
+ }
826
+ }
827
+
828
+ // src/utils/spinner.ts
829
+ import pc4 from "picocolors";
830
+ var FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
831
+ function createSpinner(text2) {
832
+ let frameIdx = 0;
833
+ let currentText = text2;
834
+ let stopped = false;
835
+ const clearLine = () => {
836
+ process.stderr.write("\r\x1B[K");
837
+ };
838
+ const render = () => {
839
+ if (stopped)
840
+ return;
841
+ const frame = pc4.cyan(FRAMES[frameIdx % FRAMES.length]);
842
+ clearLine();
843
+ process.stderr.write(`${frame} ${currentText}`);
844
+ frameIdx++;
845
+ };
846
+ const timer = setInterval(render, 80);
847
+ render();
848
+ const stop = () => {
849
+ if (stopped)
850
+ return;
851
+ stopped = true;
852
+ clearInterval(timer);
853
+ clearLine();
854
+ };
855
+ return {
856
+ update(newText) {
857
+ currentText = newText;
858
+ },
859
+ success(msg) {
860
+ stop();
861
+ process.stderr.write(`${pc4.green("✔")} ${msg}
862
+ `);
863
+ },
864
+ fail(msg) {
865
+ stop();
866
+ process.stderr.write(`${pc4.red("✖")} ${msg}
867
+ `);
868
+ },
869
+ stop() {
870
+ stop();
871
+ }
872
+ };
873
+ }
624
874
 
625
875
  // src/commands/commit.ts
626
876
  var commit_default = defineCommand2({
@@ -637,6 +887,11 @@ var commit_default = defineCommand2({
637
887
  type: "boolean",
638
888
  description: "Skip AI and write commit message manually",
639
889
  default: false
890
+ },
891
+ group: {
892
+ type: "boolean",
893
+ description: "AI groups related changes into separate atomic commits",
894
+ default: false
640
895
  }
641
896
  },
642
897
  async run({ args }) {
@@ -650,7 +905,11 @@ var commit_default = defineCommand2({
650
905
  process.exit(1);
651
906
  }
652
907
  heading("\uD83D\uDCBE contrib commit");
653
- const stagedFiles = await getStagedFiles();
908
+ if (args.group) {
909
+ await runGroupCommit(args.model, config);
910
+ return;
911
+ }
912
+ let stagedFiles = await getStagedFiles();
654
913
  if (stagedFiles.length === 0) {
655
914
  const changedFiles = await getChangedFiles();
656
915
  if (changedFiles.length === 0) {
@@ -658,31 +917,62 @@ var commit_default = defineCommand2({
658
917
  process.exit(1);
659
918
  }
660
919
  console.log(`
661
- ${pc4.bold("Changed files:")}`);
920
+ ${pc5.bold("Changed files:")}`);
662
921
  for (const f of changedFiles) {
663
- console.log(` ${pc4.dim("•")} ${f}`);
922
+ console.log(` ${pc5.dim("•")} ${f}`);
923
+ }
924
+ const stageAction = await selectPrompt("No staged changes. How would you like to stage?", [
925
+ "Stage all changes",
926
+ "Select files to stage",
927
+ "Cancel"
928
+ ]);
929
+ if (stageAction === "Cancel") {
930
+ process.exit(0);
931
+ }
932
+ if (stageAction === "Stage all changes") {
933
+ const result2 = await stageAll();
934
+ if (result2.exitCode !== 0) {
935
+ error(`Failed to stage files: ${result2.stderr}`);
936
+ process.exit(1);
937
+ }
938
+ success("Staged all changes.");
939
+ } else {
940
+ const selected = await multiSelectPrompt("Select files to stage:", changedFiles);
941
+ if (selected.length === 0) {
942
+ error("No files selected.");
943
+ process.exit(1);
944
+ }
945
+ const result2 = await stageFiles(selected);
946
+ if (result2.exitCode !== 0) {
947
+ error(`Failed to stage files: ${result2.stderr}`);
948
+ process.exit(1);
949
+ }
950
+ success(`Staged ${selected.length} file(s).`);
951
+ }
952
+ stagedFiles = await getStagedFiles();
953
+ if (stagedFiles.length === 0) {
954
+ error("No staged changes after staging attempt.");
955
+ process.exit(1);
664
956
  }
665
- console.log();
666
- warn("No staged changes. Stage your files with `git add` and re-run.");
667
- process.exit(1);
668
957
  }
669
958
  info(`Staged files: ${stagedFiles.join(", ")}`);
670
959
  let commitMessage = null;
671
960
  const useAI = !args["no-ai"];
672
961
  if (useAI) {
673
- const copilotError = await checkCopilotAvailable();
962
+ const [copilotError, diff] = await Promise.all([checkCopilotAvailable(), getStagedDiff()]);
674
963
  if (copilotError) {
675
964
  warn(`AI unavailable: ${copilotError}`);
676
965
  warn("Falling back to manual commit message entry.");
677
966
  } else {
678
- info("Generating commit message with AI...");
679
- const diff = await getStagedDiff();
967
+ const spinner = createSpinner("Generating commit message with AI...");
680
968
  commitMessage = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
681
969
  if (commitMessage) {
970
+ spinner.success("AI commit message generated.");
682
971
  console.log(`
683
- ${pc4.dim("AI suggestion:")} ${pc4.bold(pc4.cyan(commitMessage))}`);
972
+ ${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(commitMessage))}`);
684
973
  } else {
685
- warn("AI did not return a commit message. Falling back to manual entry.");
974
+ spinner.fail("AI did not return a commit message.");
975
+ warn("Falling back to manual entry.");
686
976
  }
687
977
  }
688
978
  }
@@ -699,16 +989,17 @@ ${pc4.bold("Changed files:")}`);
699
989
  } else if (action === "Edit this message") {
700
990
  finalMessage = await inputPrompt("Edit commit message", commitMessage);
701
991
  } else if (action === "Regenerate") {
702
- info("Regenerating...");
992
+ const spinner = createSpinner("Regenerating commit message...");
703
993
  const diff = await getStagedDiff();
704
994
  const regen = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
705
995
  if (regen) {
996
+ spinner.success("Commit message regenerated.");
706
997
  console.log(`
707
- ${pc4.dim("AI suggestion:")} ${pc4.bold(pc4.cyan(regen))}`);
998
+ ${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(regen))}`);
708
999
  const ok = await confirmPrompt("Use this message?");
709
1000
  finalMessage = ok ? regen : await inputPrompt("Enter commit message manually");
710
1001
  } else {
711
- warn("Regeneration failed. Falling back to manual entry.");
1002
+ spinner.fail("Regeneration failed.");
712
1003
  finalMessage = await inputPrompt("Enter commit message");
713
1004
  }
714
1005
  } else {
@@ -719,7 +1010,7 @@ ${pc4.bold("Changed files:")}`);
719
1010
  if (convention2 !== "none") {
720
1011
  console.log();
721
1012
  for (const hint of CONVENTION_FORMAT_HINTS[convention2]) {
722
- console.log(pc4.dim(hint));
1013
+ console.log(pc5.dim(hint));
723
1014
  }
724
1015
  console.log();
725
1016
  }
@@ -743,21 +1034,209 @@ ${pc4.bold("Changed files:")}`);
743
1034
  error(`Failed to commit: ${result.stderr}`);
744
1035
  process.exit(1);
745
1036
  }
746
- success(`✅ Committed: ${pc4.bold(finalMessage)}`);
1037
+ success(`✅ Committed: ${pc5.bold(finalMessage)}`);
747
1038
  }
748
1039
  });
1040
+ async function runGroupCommit(model, config) {
1041
+ const [copilotError, changedFiles] = await Promise.all([
1042
+ checkCopilotAvailable(),
1043
+ getChangedFiles()
1044
+ ]);
1045
+ if (copilotError) {
1046
+ error(`AI is required for --group mode but unavailable: ${copilotError}`);
1047
+ process.exit(1);
1048
+ }
1049
+ if (changedFiles.length === 0) {
1050
+ error("No changes to group-commit.");
1051
+ process.exit(1);
1052
+ }
1053
+ console.log(`
1054
+ ${pc5.bold("Changed files:")}`);
1055
+ for (const f of changedFiles) {
1056
+ console.log(` ${pc5.dim("•")} ${f}`);
1057
+ }
1058
+ const spinner = createSpinner(`Asking AI to group ${changedFiles.length} file(s) into logical commits...`);
1059
+ const diffs = await getFullDiffForFiles(changedFiles);
1060
+ if (!diffs.trim()) {
1061
+ spinner.stop();
1062
+ warn("Could not retrieve diff context for any files. AI needs diffs to produce groups.");
1063
+ }
1064
+ let groups;
1065
+ try {
1066
+ groups = await generateCommitGroups(changedFiles, diffs, model, config.commitConvention);
1067
+ spinner.success(`AI generated ${groups.length} commit group(s).`);
1068
+ } catch (err) {
1069
+ const reason = err instanceof Error ? err.message : String(err);
1070
+ spinner.fail(`AI grouping failed: ${reason}`);
1071
+ process.exit(1);
1072
+ }
1073
+ if (groups.length === 0) {
1074
+ error("AI could not produce commit groups. Try committing files manually.");
1075
+ process.exit(1);
1076
+ }
1077
+ const changedSet = new Set(changedFiles);
1078
+ for (const group of groups) {
1079
+ const invalid = group.files.filter((f) => !changedSet.has(f));
1080
+ if (invalid.length > 0) {
1081
+ warn(`AI suggested unknown file(s): ${invalid.join(", ")} — removed from group.`);
1082
+ }
1083
+ group.files = group.files.filter((f) => changedSet.has(f));
1084
+ }
1085
+ let validGroups = groups.filter((g) => g.files.length > 0);
1086
+ if (validGroups.length === 0) {
1087
+ error("No valid groups remain after validation. Try committing files manually.");
1088
+ process.exit(1);
1089
+ }
1090
+ let proceedToCommit = false;
1091
+ let commitAll = false;
1092
+ while (!proceedToCommit) {
1093
+ console.log(`
1094
+ ${pc5.bold(`AI suggested ${validGroups.length} commit group(s):`)}
1095
+ `);
1096
+ for (let i = 0;i < validGroups.length; i++) {
1097
+ const g = validGroups[i];
1098
+ console.log(` ${pc5.cyan(`Group ${i + 1}:`)} ${pc5.bold(g.message)}`);
1099
+ for (const f of g.files) {
1100
+ console.log(` ${pc5.dim("•")} ${f}`);
1101
+ }
1102
+ console.log();
1103
+ }
1104
+ const summaryAction = await selectPrompt("What would you like to do?", [
1105
+ "Commit all",
1106
+ "Review each group",
1107
+ "Regenerate all messages",
1108
+ "Cancel"
1109
+ ]);
1110
+ if (summaryAction === "Cancel") {
1111
+ warn("Group commit cancelled.");
1112
+ process.exit(0);
1113
+ }
1114
+ if (summaryAction === "Regenerate all messages") {
1115
+ const regenSpinner = createSpinner("Regenerating all commit messages...");
1116
+ try {
1117
+ validGroups = await regenerateAllGroupMessages(validGroups, diffs, model, config.commitConvention);
1118
+ regenSpinner.success("All commit messages regenerated.");
1119
+ } catch {
1120
+ regenSpinner.fail("Failed to regenerate messages. Keeping current ones.");
1121
+ }
1122
+ continue;
1123
+ }
1124
+ proceedToCommit = true;
1125
+ commitAll = summaryAction === "Commit all";
1126
+ }
1127
+ let committed = 0;
1128
+ if (commitAll) {
1129
+ for (let i = 0;i < validGroups.length; i++) {
1130
+ const group = validGroups[i];
1131
+ const stageResult = await stageFiles(group.files);
1132
+ if (stageResult.exitCode !== 0) {
1133
+ error(`Failed to stage group ${i + 1}: ${stageResult.stderr}`);
1134
+ continue;
1135
+ }
1136
+ const commitResult = await commitWithMessage(group.message);
1137
+ if (commitResult.exitCode !== 0) {
1138
+ const detail = (commitResult.stderr || commitResult.stdout).trim();
1139
+ error(`Failed to commit group ${i + 1}: ${detail}`);
1140
+ await unstageFiles(group.files);
1141
+ continue;
1142
+ }
1143
+ committed++;
1144
+ success(`✅ Committed group ${i + 1}: ${pc5.bold(group.message)}`);
1145
+ }
1146
+ } else {
1147
+ for (let i = 0;i < validGroups.length; i++) {
1148
+ const group = validGroups[i];
1149
+ console.log(pc5.bold(`
1150
+ ── Group ${i + 1}/${validGroups.length} ──`));
1151
+ console.log(` ${pc5.cyan(group.message)}`);
1152
+ for (const f of group.files) {
1153
+ console.log(` ${pc5.dim("•")} ${f}`);
1154
+ }
1155
+ let message = group.message;
1156
+ let actionDone = false;
1157
+ while (!actionDone) {
1158
+ const action = await selectPrompt("Action for this group:", [
1159
+ "Commit as-is",
1160
+ "Edit message and commit",
1161
+ "Regenerate message",
1162
+ "Skip this group"
1163
+ ]);
1164
+ if (action === "Skip this group") {
1165
+ warn(`Skipped group ${i + 1}.`);
1166
+ actionDone = true;
1167
+ continue;
1168
+ }
1169
+ if (action === "Regenerate message") {
1170
+ const regenSpinner = createSpinner("Regenerating commit message for this group...");
1171
+ const newMsg = await regenerateGroupMessage(group.files, diffs, model, config.commitConvention);
1172
+ if (newMsg) {
1173
+ message = newMsg;
1174
+ group.message = newMsg;
1175
+ regenSpinner.success(`New message: ${pc5.bold(message)}`);
1176
+ } else {
1177
+ regenSpinner.fail("AI could not generate a new message. Keeping current one.");
1178
+ }
1179
+ continue;
1180
+ }
1181
+ if (action === "Edit message and commit") {
1182
+ message = await inputPrompt("Edit commit message", message);
1183
+ if (!message) {
1184
+ warn(`Skipped group ${i + 1} (empty message).`);
1185
+ actionDone = true;
1186
+ continue;
1187
+ }
1188
+ }
1189
+ if (!validateCommitMessage(message, config.commitConvention)) {
1190
+ for (const line of getValidationError(config.commitConvention)) {
1191
+ warn(line);
1192
+ }
1193
+ const proceed = await confirmPrompt("Commit anyway?");
1194
+ if (!proceed) {
1195
+ warn(`Skipped group ${i + 1}.`);
1196
+ actionDone = true;
1197
+ continue;
1198
+ }
1199
+ }
1200
+ const stageResult = await stageFiles(group.files);
1201
+ if (stageResult.exitCode !== 0) {
1202
+ error(`Failed to stage group ${i + 1}: ${stageResult.stderr}`);
1203
+ actionDone = true;
1204
+ continue;
1205
+ }
1206
+ const commitResult = await commitWithMessage(message);
1207
+ if (commitResult.exitCode !== 0) {
1208
+ const detail = (commitResult.stderr || commitResult.stdout).trim();
1209
+ error(`Failed to commit group ${i + 1}: ${detail}`);
1210
+ await unstageFiles(group.files);
1211
+ actionDone = true;
1212
+ continue;
1213
+ }
1214
+ committed++;
1215
+ success(`✅ Committed group ${i + 1}: ${pc5.bold(message)}`);
1216
+ actionDone = true;
1217
+ }
1218
+ }
1219
+ }
1220
+ if (committed === 0) {
1221
+ warn("No groups were committed.");
1222
+ } else {
1223
+ success(`
1224
+ \uD83C\uDF89 ${committed} of ${validGroups.length} group(s) committed successfully.`);
1225
+ }
1226
+ process.exit(0);
1227
+ }
749
1228
 
750
1229
  // src/commands/hook.ts
751
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
752
- import { join as join2 } from "node:path";
1230
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
1231
+ import { join as join3 } from "node:path";
753
1232
  import { defineCommand as defineCommand3 } from "citty";
754
- import pc5 from "picocolors";
1233
+ import pc6 from "picocolors";
755
1234
  var HOOK_MARKER = "# managed by contribute-now";
756
1235
  function getHooksDir(cwd = process.cwd()) {
757
- return join2(cwd, ".git", "hooks");
1236
+ return join3(cwd, ".git", "hooks");
758
1237
  }
759
1238
  function getHookPath(cwd = process.cwd()) {
760
- return join2(getHooksDir(cwd), "commit-msg");
1239
+ return join3(getHooksDir(cwd), "commit-msg");
761
1240
  }
762
1241
  function generateHookScript() {
763
1242
  return `#!/bin/sh
@@ -774,8 +1253,19 @@ case "$commit_msg" in
774
1253
  Merge\\ *|fixup!*|squash!*|amend!*) exit 0 ;;
775
1254
  esac
776
1255
 
777
- # Validate using contrib CLI
778
- npx contrib validate "$commit_msg"
1256
+ # Detect available package runner
1257
+ if command -v contrib >/dev/null 2>&1; then
1258
+ contrib validate "$commit_msg"
1259
+ elif command -v bunx >/dev/null 2>&1; then
1260
+ bunx contrib validate "$commit_msg"
1261
+ elif command -v pnpx >/dev/null 2>&1; then
1262
+ pnpx contrib validate "$commit_msg"
1263
+ elif command -v npx >/dev/null 2>&1; then
1264
+ npx contrib validate "$commit_msg"
1265
+ else
1266
+ echo "Warning: No package runner found. Skipping commit message validation."
1267
+ exit 0
1268
+ fi
779
1269
  `;
780
1270
  }
781
1271
  var hook_default = defineCommand3({
@@ -822,7 +1312,7 @@ async function installHook() {
822
1312
  const hookPath = getHookPath();
823
1313
  const hooksDir = getHooksDir();
824
1314
  if (existsSync2(hookPath)) {
825
- const existing = readFileSync2(hookPath, "utf-8");
1315
+ const existing = readFileSync3(hookPath, "utf-8");
826
1316
  if (!existing.includes(HOOK_MARKER)) {
827
1317
  error("A commit-msg hook already exists and was not installed by contribute-now.");
828
1318
  warn(`Path: ${hookPath}`);
@@ -836,8 +1326,8 @@ async function installHook() {
836
1326
  }
837
1327
  writeFileSync2(hookPath, generateHookScript(), { mode: 493 });
838
1328
  success(`commit-msg hook installed.`);
839
- info(`Convention: ${pc5.bold(CONVENTION_LABELS[config.commitConvention])}`);
840
- info(`Path: ${pc5.dim(hookPath)}`);
1329
+ info(`Convention: ${pc6.bold(CONVENTION_LABELS[config.commitConvention])}`);
1330
+ info(`Path: ${pc6.dim(hookPath)}`);
841
1331
  }
842
1332
  async function uninstallHook() {
843
1333
  heading("\uD83E\uDE9D hook uninstall");
@@ -846,7 +1336,7 @@ async function uninstallHook() {
846
1336
  info("No commit-msg hook found. Nothing to uninstall.");
847
1337
  return;
848
1338
  }
849
- const content = readFileSync2(hookPath, "utf-8");
1339
+ const content = readFileSync3(hookPath, "utf-8");
850
1340
  if (!content.includes(HOOK_MARKER)) {
851
1341
  error("The commit-msg hook was not installed by contribute-now. Leaving it untouched.");
852
1342
  process.exit(1);
@@ -857,7 +1347,7 @@ async function uninstallHook() {
857
1347
 
858
1348
  // src/commands/setup.ts
859
1349
  import { defineCommand as defineCommand4 } from "citty";
860
- import pc6 from "picocolors";
1350
+ import pc7 from "picocolors";
861
1351
 
862
1352
  // src/utils/gh.ts
863
1353
  import { execFile as execFileCb2 } from "node:child_process";
@@ -865,7 +1355,7 @@ function run2(args) {
865
1355
  return new Promise((resolve) => {
866
1356
  execFileCb2("gh", args, (error2, stdout, stderr) => {
867
1357
  resolve({
868
- exitCode: error2 ? error2.code != null ? Number(error2.code) : 1 : 0,
1358
+ exitCode: error2 ? error2.code === "ENOENT" ? 127 : error2.status ?? 1 : 0,
869
1359
  stdout: stdout ?? "",
870
1360
  stderr: stderr ?? ""
871
1361
  });
@@ -888,7 +1378,10 @@ async function checkGhAuth() {
888
1378
  return false;
889
1379
  }
890
1380
  }
1381
+ var SAFE_SLUG = /^[\w.-]+$/;
891
1382
  async function checkRepoPermissions(owner, repo) {
1383
+ if (!SAFE_SLUG.test(owner) || !SAFE_SLUG.test(repo))
1384
+ return null;
892
1385
  const { exitCode, stdout } = await run2(["api", `repos/${owner}/${repo}`, "--jq", ".permissions"]);
893
1386
  if (exitCode !== 0)
894
1387
  return null;
@@ -949,6 +1442,28 @@ async function createPRFill(base, draft) {
949
1442
  args.push("--draft");
950
1443
  return run2(args);
951
1444
  }
1445
+ async function getPRForBranch(headBranch) {
1446
+ const { exitCode, stdout } = await run2([
1447
+ "pr",
1448
+ "list",
1449
+ "--head",
1450
+ headBranch,
1451
+ "--state",
1452
+ "open",
1453
+ "--json",
1454
+ "number,url,title,state",
1455
+ "--limit",
1456
+ "1"
1457
+ ]);
1458
+ if (exitCode !== 0)
1459
+ return null;
1460
+ try {
1461
+ const prs = JSON.parse(stdout.trim());
1462
+ return prs.length > 0 ? prs[0] : null;
1463
+ } catch {
1464
+ return null;
1465
+ }
1466
+ }
952
1467
 
953
1468
  // src/utils/remote.ts
954
1469
  function parseRepoFromUrl(url) {
@@ -991,7 +1506,7 @@ var setup_default = defineCommand4({
991
1506
  workflow = "github-flow";
992
1507
  else if (workflowChoice.startsWith("Git Flow"))
993
1508
  workflow = "git-flow";
994
- info(`Workflow: ${pc6.bold(WORKFLOW_DESCRIPTIONS[workflow])}`);
1509
+ info(`Workflow: ${pc7.bold(WORKFLOW_DESCRIPTIONS[workflow])}`);
995
1510
  const conventionChoice = await selectPrompt("Which commit convention should this project use?", [
996
1511
  `${CONVENTION_DESCRIPTIONS["clean-commit"]} (recommended)`,
997
1512
  CONVENTION_DESCRIPTIONS.conventional,
@@ -1044,8 +1559,8 @@ var setup_default = defineCommand4({
1044
1559
  detectedRole = roleChoice;
1045
1560
  detectionSource = "user selection";
1046
1561
  } else {
1047
- info(`Detected role: ${pc6.bold(detectedRole)} (via ${detectionSource})`);
1048
- const confirmed = await confirmPrompt(`Role detected as ${pc6.bold(detectedRole)}. Is this correct?`);
1562
+ info(`Detected role: ${pc7.bold(detectedRole)} (via ${detectionSource})`);
1563
+ const confirmed = await confirmPrompt(`Role detected as ${pc7.bold(detectedRole)}. Is this correct?`);
1049
1564
  if (!confirmed) {
1050
1565
  const roleChoice = await selectPrompt("Select your role:", ["maintainer", "contributor"]);
1051
1566
  detectedRole = roleChoice;
@@ -1090,21 +1605,21 @@ var setup_default = defineCommand4({
1090
1605
  warn(' echo ".contributerc.json" >> .gitignore');
1091
1606
  }
1092
1607
  console.log();
1093
- info(`Workflow: ${pc6.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
1094
- info(`Convention: ${pc6.bold(CONVENTION_DESCRIPTIONS[config.commitConvention])}`);
1095
- info(`Role: ${pc6.bold(config.role)}`);
1608
+ info(`Workflow: ${pc7.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
1609
+ info(`Convention: ${pc7.bold(CONVENTION_DESCRIPTIONS[config.commitConvention])}`);
1610
+ info(`Role: ${pc7.bold(config.role)}`);
1096
1611
  if (config.devBranch) {
1097
- info(`Main: ${pc6.bold(config.mainBranch)} | Dev: ${pc6.bold(config.devBranch)}`);
1612
+ info(`Main: ${pc7.bold(config.mainBranch)} | Dev: ${pc7.bold(config.devBranch)}`);
1098
1613
  } else {
1099
- info(`Main: ${pc6.bold(config.mainBranch)}`);
1614
+ info(`Main: ${pc7.bold(config.mainBranch)}`);
1100
1615
  }
1101
- info(`Origin: ${pc6.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc6.bold(config.upstream)}` : ""}`);
1616
+ info(`Origin: ${pc7.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc7.bold(config.upstream)}` : ""}`);
1102
1617
  }
1103
1618
  });
1104
1619
 
1105
1620
  // src/commands/start.ts
1106
1621
  import { defineCommand as defineCommand5 } from "citty";
1107
- import pc7 from "picocolors";
1622
+ import pc8 from "picocolors";
1108
1623
 
1109
1624
  // src/utils/branch.ts
1110
1625
  var DEFAULT_PREFIXES = ["feature", "fix", "docs", "chore", "test", "refactor"];
@@ -1115,6 +1630,9 @@ function formatBranchName(prefix, name) {
1115
1630
  const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1116
1631
  return `${prefix}/${sanitized}`;
1117
1632
  }
1633
+ function isValidBranchName(name) {
1634
+ return /^[a-zA-Z0-9._/-]+$/.test(name) && !name.startsWith("/") && !name.endsWith("/");
1635
+ }
1118
1636
  function looksLikeNaturalLanguage(input) {
1119
1637
  return input.includes(" ") && !input.includes("/");
1120
1638
  }
@@ -1162,39 +1680,46 @@ var start_default = defineCommand5({
1162
1680
  heading("\uD83C\uDF3F contrib start");
1163
1681
  const useAI = !args["no-ai"] && looksLikeNaturalLanguage(branchName);
1164
1682
  if (useAI) {
1165
- info("Generating branch name suggestion from description...");
1683
+ const spinner = createSpinner("Generating branch name suggestion...");
1166
1684
  const suggested = await suggestBranchName(branchName, args.model);
1167
1685
  if (suggested) {
1686
+ spinner.success("Branch name suggestion ready.");
1168
1687
  console.log(`
1169
- ${pc7.dim("AI suggestion:")} ${pc7.bold(pc7.cyan(suggested))}`);
1170
- const accepted = await confirmPrompt(`Use ${pc7.bold(suggested)} as your branch name?`);
1688
+ ${pc8.dim("AI suggestion:")} ${pc8.bold(pc8.cyan(suggested))}`);
1689
+ const accepted = await confirmPrompt(`Use ${pc8.bold(suggested)} as your branch name?`);
1171
1690
  if (accepted) {
1172
1691
  branchName = suggested;
1173
1692
  } else {
1174
1693
  branchName = await inputPrompt("Enter branch name", branchName);
1175
1694
  }
1695
+ } else {
1696
+ spinner.fail("AI did not return a branch name suggestion.");
1176
1697
  }
1177
1698
  }
1178
1699
  if (!hasPrefix(branchName, branchPrefixes)) {
1179
- const prefix = await selectPrompt(`Choose a branch type for ${pc7.bold(branchName)}:`, branchPrefixes);
1700
+ const prefix = await selectPrompt(`Choose a branch type for ${pc8.bold(branchName)}:`, branchPrefixes);
1180
1701
  branchName = formatBranchName(prefix, branchName);
1181
1702
  }
1182
- info(`Creating branch: ${pc7.bold(branchName)}`);
1703
+ if (!isValidBranchName(branchName)) {
1704
+ error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
1705
+ process.exit(1);
1706
+ }
1707
+ info(`Creating branch: ${pc8.bold(branchName)}`);
1183
1708
  await fetchRemote(syncSource.remote);
1184
- const resetResult = await resetHard(syncSource.ref);
1185
- if (resetResult.exitCode !== 0) {}
1709
+ const updateResult = await updateLocalBranch(baseBranch, syncSource.ref);
1710
+ if (updateResult.exitCode !== 0) {}
1186
1711
  const result = await createBranch(branchName, baseBranch);
1187
1712
  if (result.exitCode !== 0) {
1188
1713
  error(`Failed to create branch: ${result.stderr}`);
1189
1714
  process.exit(1);
1190
1715
  }
1191
- success(`✅ Created ${pc7.bold(branchName)} from latest ${pc7.bold(baseBranch)}`);
1716
+ success(`✅ Created ${pc8.bold(branchName)} from latest ${pc8.bold(baseBranch)}`);
1192
1717
  }
1193
1718
  });
1194
1719
 
1195
1720
  // src/commands/status.ts
1196
1721
  import { defineCommand as defineCommand6 } from "citty";
1197
- import pc8 from "picocolors";
1722
+ import pc9 from "picocolors";
1198
1723
  var status_default = defineCommand6({
1199
1724
  meta: {
1200
1725
  name: "status",
@@ -1211,17 +1736,20 @@ var status_default = defineCommand6({
1211
1736
  process.exit(1);
1212
1737
  }
1213
1738
  heading("\uD83D\uDCCA contribute-now status");
1214
- console.log(` ${pc8.dim("Workflow:")} ${pc8.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
1215
- console.log(` ${pc8.dim("Role:")} ${pc8.bold(config.role)}`);
1739
+ console.log(` ${pc9.dim("Workflow:")} ${pc9.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
1740
+ console.log(` ${pc9.dim("Role:")} ${pc9.bold(config.role)}`);
1216
1741
  console.log();
1217
1742
  await fetchAll();
1218
1743
  const currentBranch = await getCurrentBranch();
1219
1744
  const { mainBranch, origin, upstream, workflow } = config;
1220
1745
  const baseBranch = getBaseBranch(config);
1221
1746
  const isContributor = config.role === "contributor";
1222
- const dirty = await hasUncommittedChanges();
1747
+ const [dirty, fileStatus] = await Promise.all([
1748
+ hasUncommittedChanges(),
1749
+ getFileStatus()
1750
+ ]);
1223
1751
  if (dirty) {
1224
- console.log(` ${pc8.yellow("⚠")} ${pc8.yellow("Uncommitted changes in working tree")}`);
1752
+ console.log(` ${pc9.yellow("⚠")} ${pc9.yellow("Uncommitted changes in working tree")}`);
1225
1753
  console.log();
1226
1754
  }
1227
1755
  const mainRemote = `${origin}/${mainBranch}`;
@@ -1237,30 +1765,130 @@ var status_default = defineCommand6({
1237
1765
  if (currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
1238
1766
  const branchDiv = await getDivergence(currentBranch, baseBranch);
1239
1767
  const branchLine = formatStatus(currentBranch, baseBranch, branchDiv.ahead, branchDiv.behind);
1240
- console.log(branchLine + pc8.dim(` (current ${pc8.green("*")})`));
1768
+ console.log(branchLine + pc9.dim(` (current ${pc9.green("*")})`));
1241
1769
  } else if (currentBranch) {
1242
- console.log(pc8.dim(` (on ${pc8.bold(currentBranch)} branch)`));
1770
+ console.log(pc9.dim(` (on ${pc9.bold(currentBranch)} branch)`));
1771
+ }
1772
+ const hasFiles = fileStatus.staged.length > 0 || fileStatus.modified.length > 0 || fileStatus.untracked.length > 0;
1773
+ if (hasFiles) {
1774
+ console.log();
1775
+ if (fileStatus.staged.length > 0) {
1776
+ console.log(` ${pc9.green("Staged for commit:")}`);
1777
+ for (const { file, status } of fileStatus.staged) {
1778
+ console.log(` ${pc9.green("+")} ${pc9.dim(`${status}:`)} ${file}`);
1779
+ }
1780
+ }
1781
+ if (fileStatus.modified.length > 0) {
1782
+ console.log(` ${pc9.yellow("Unstaged changes:")}`);
1783
+ for (const { file, status } of fileStatus.modified) {
1784
+ console.log(` ${pc9.yellow("~")} ${pc9.dim(`${status}:`)} ${file}`);
1785
+ }
1786
+ }
1787
+ if (fileStatus.untracked.length > 0) {
1788
+ console.log(` ${pc9.red("Untracked files:")}`);
1789
+ for (const file of fileStatus.untracked) {
1790
+ console.log(` ${pc9.red("?")} ${file}`);
1791
+ }
1792
+ }
1793
+ } else if (!dirty) {
1794
+ console.log(` ${pc9.green("✓")} ${pc9.dim("Working tree clean")}`);
1795
+ }
1796
+ const tips = [];
1797
+ if (fileStatus.staged.length > 0) {
1798
+ tips.push(`Run ${pc9.bold("contrib commit")} to commit staged changes`);
1799
+ }
1800
+ if (fileStatus.modified.length > 0 || fileStatus.untracked.length > 0) {
1801
+ tips.push(`Run ${pc9.bold("contrib commit")} to stage and commit changes`);
1802
+ }
1803
+ if (fileStatus.staged.length === 0 && fileStatus.modified.length === 0 && fileStatus.untracked.length === 0 && currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
1804
+ const branchDiv = await getDivergence(currentBranch, `${origin}/${currentBranch}`);
1805
+ if (branchDiv.ahead > 0) {
1806
+ tips.push(`Run ${pc9.bold("contrib submit")} to push and create/update your PR`);
1807
+ }
1808
+ }
1809
+ if (tips.length > 0) {
1810
+ console.log();
1811
+ console.log(` ${pc9.dim("\uD83D\uDCA1 Tip:")}`);
1812
+ for (const tip of tips) {
1813
+ console.log(` ${pc9.dim(tip)}`);
1814
+ }
1243
1815
  }
1244
1816
  console.log();
1245
1817
  }
1246
1818
  });
1247
1819
  function formatStatus(branch, base, ahead, behind) {
1248
- const label = pc8.bold(branch.padEnd(20));
1820
+ const label = pc9.bold(branch.padEnd(20));
1249
1821
  if (ahead === 0 && behind === 0) {
1250
- return ` ${pc8.green("✓")} ${label} ${pc8.dim(`in sync with ${base}`)}`;
1822
+ return ` ${pc9.green("✓")} ${label} ${pc9.dim(`in sync with ${base}`)}`;
1251
1823
  }
1252
1824
  if (ahead > 0 && behind === 0) {
1253
- return ` ${pc8.yellow("↑")} ${label} ${pc8.yellow(`${ahead} commit${ahead !== 1 ? "s" : ""} ahead of ${base}`)}`;
1825
+ return ` ${pc9.yellow("↑")} ${label} ${pc9.yellow(`${ahead} commit${ahead !== 1 ? "s" : ""} ahead of ${base}`)}`;
1254
1826
  }
1255
1827
  if (behind > 0 && ahead === 0) {
1256
- return ` ${pc8.red("↓")} ${label} ${pc8.red(`${behind} commit${behind !== 1 ? "s" : ""} behind ${base}`)}`;
1828
+ return ` ${pc9.red("↓")} ${label} ${pc9.red(`${behind} commit${behind !== 1 ? "s" : ""} behind ${base}`)}`;
1257
1829
  }
1258
- return ` ${pc8.red("⚡")} ${label} ${pc8.yellow(`${ahead} ahead`)}${pc8.dim(", ")}${pc8.red(`${behind} behind`)} ${pc8.dim(base)}`;
1830
+ return ` ${pc9.red("⚡")} ${label} ${pc9.yellow(`${ahead} ahead`)}${pc9.dim(", ")}${pc9.red(`${behind} behind`)} ${pc9.dim(base)}`;
1259
1831
  }
1260
1832
 
1261
1833
  // src/commands/submit.ts
1262
1834
  import { defineCommand as defineCommand7 } from "citty";
1263
- import pc9 from "picocolors";
1835
+ import pc10 from "picocolors";
1836
+ async function performSquashMerge(origin, baseBranch, featureBranch, options) {
1837
+ info(`Checking out ${pc10.bold(baseBranch)}...`);
1838
+ const coResult = await checkoutBranch(baseBranch);
1839
+ if (coResult.exitCode !== 0) {
1840
+ error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
1841
+ process.exit(1);
1842
+ }
1843
+ info(`Squash merging ${pc10.bold(featureBranch)} into ${pc10.bold(baseBranch)}...`);
1844
+ const mergeResult = await mergeSquash(featureBranch);
1845
+ if (mergeResult.exitCode !== 0) {
1846
+ error(`Squash merge failed: ${mergeResult.stderr}`);
1847
+ process.exit(1);
1848
+ }
1849
+ let message = options?.defaultMsg;
1850
+ if (!message) {
1851
+ const copilotError = await checkCopilotAvailable();
1852
+ if (!copilotError) {
1853
+ const spinner = createSpinner("Generating AI commit message for squash merge...");
1854
+ const [stagedDiff, stagedFiles] = await Promise.all([getStagedDiff(), getStagedFiles()]);
1855
+ const aiMsg = await generateCommitMessage(stagedDiff, stagedFiles, options?.model, options?.convention ?? "clean-commit");
1856
+ if (aiMsg) {
1857
+ message = aiMsg;
1858
+ spinner.success("AI commit message generated.");
1859
+ } else {
1860
+ spinner.fail("AI did not return a commit message.");
1861
+ }
1862
+ } else {
1863
+ warn(`AI unavailable: ${copilotError}`);
1864
+ }
1865
+ }
1866
+ const fallback = message || `squash merge ${featureBranch}`;
1867
+ const finalMsg = await inputPrompt("Commit message", fallback);
1868
+ const commitResult = await commitWithMessage(finalMsg);
1869
+ if (commitResult.exitCode !== 0) {
1870
+ error(`Commit failed: ${commitResult.stderr}`);
1871
+ process.exit(1);
1872
+ }
1873
+ info(`Pushing ${pc10.bold(baseBranch)} to ${origin}...`);
1874
+ const pushResult = await pushBranch(origin, baseBranch);
1875
+ if (pushResult.exitCode !== 0) {
1876
+ error(`Failed to push ${baseBranch}: ${pushResult.stderr}`);
1877
+ process.exit(1);
1878
+ }
1879
+ info(`Deleting local branch ${pc10.bold(featureBranch)}...`);
1880
+ const delLocal = await forceDeleteBranch(featureBranch);
1881
+ if (delLocal.exitCode !== 0) {
1882
+ warn(`Could not delete local branch: ${delLocal.stderr.trim()}`);
1883
+ }
1884
+ info(`Deleting remote branch ${pc10.bold(featureBranch)}...`);
1885
+ const delRemote = await deleteRemoteBranch(origin, featureBranch);
1886
+ if (delRemote.exitCode !== 0) {
1887
+ warn(`Could not delete remote branch: ${delRemote.stderr.trim()}`);
1888
+ }
1889
+ success(`✅ Squash merged ${pc10.bold(featureBranch)} into ${pc10.bold(baseBranch)} and pushed.`);
1890
+ info(`Run ${pc10.bold("contrib start")} to begin a new feature.`);
1891
+ }
1264
1892
  var submit_default = defineCommand7({
1265
1893
  meta: {
1266
1894
  name: "submit",
@@ -1301,11 +1929,11 @@ var submit_default = defineCommand7({
1301
1929
  process.exit(1);
1302
1930
  }
1303
1931
  if (protectedBranches.includes(currentBranch)) {
1304
- error(`Cannot submit ${protectedBranches.map((b) => pc9.bold(b)).join(" or ")} as a PR. Switch to your feature branch.`);
1932
+ error(`Cannot submit ${protectedBranches.map((b) => pc10.bold(b)).join(" or ")} as a PR. Switch to your feature branch.`);
1305
1933
  process.exit(1);
1306
1934
  }
1307
1935
  heading("\uD83D\uDE80 contrib submit");
1308
- info(`Pushing ${pc9.bold(currentBranch)} to ${origin}...`);
1936
+ info(`Pushing ${pc10.bold(currentBranch)} to ${origin}...`);
1309
1937
  const pushResult = await pushSetUpstream(origin, currentBranch);
1310
1938
  if (pushResult.exitCode !== 0) {
1311
1939
  error(`Failed to push: ${pushResult.stderr}`);
@@ -1319,43 +1947,70 @@ var submit_default = defineCommand7({
1319
1947
  const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${baseBranch}...${currentBranch}?expand=1`;
1320
1948
  console.log();
1321
1949
  info("Create your PR manually:");
1322
- console.log(` ${pc9.cyan(prUrl)}`);
1950
+ console.log(` ${pc10.cyan(prUrl)}`);
1323
1951
  } else {
1324
1952
  info("gh CLI not available. Create your PR manually on GitHub.");
1325
1953
  }
1326
1954
  return;
1327
1955
  }
1956
+ const existingPR = await getPRForBranch(currentBranch);
1957
+ if (existingPR) {
1958
+ success(`Pushed changes to existing PR #${existingPR.number}: ${pc10.bold(existingPR.title)}`);
1959
+ console.log(` ${pc10.cyan(existingPR.url)}`);
1960
+ return;
1961
+ }
1328
1962
  let prTitle = null;
1329
1963
  let prBody = null;
1330
1964
  if (!args["no-ai"]) {
1331
- const copilotError = await checkCopilotAvailable();
1965
+ const [copilotError, commits, diff] = await Promise.all([
1966
+ checkCopilotAvailable(),
1967
+ getLog(baseBranch, "HEAD"),
1968
+ getLogDiff(baseBranch, "HEAD")
1969
+ ]);
1332
1970
  if (!copilotError) {
1333
- info("Generating AI PR description...");
1334
- const commits = await getLog(baseBranch, "HEAD");
1335
- const diff = await getLogDiff(baseBranch, "HEAD");
1336
- const result = await generatePRDescription(commits, diff, args.model);
1971
+ const spinner = createSpinner("Generating AI PR description...");
1972
+ const result = await generatePRDescription(commits, diff, args.model, config.commitConvention);
1337
1973
  if (result) {
1338
1974
  prTitle = result.title;
1339
1975
  prBody = result.body;
1976
+ spinner.success("PR description generated.");
1340
1977
  console.log(`
1341
- ${pc9.dim("AI title:")} ${pc9.bold(pc9.cyan(prTitle))}`);
1978
+ ${pc10.dim("AI title:")} ${pc10.bold(pc10.cyan(prTitle))}`);
1342
1979
  console.log(`
1343
- ${pc9.dim("AI body preview:")}`);
1344
- console.log(pc9.dim(prBody.slice(0, 300) + (prBody.length > 300 ? "..." : "")));
1980
+ ${pc10.dim("AI body preview:")}`);
1981
+ console.log(pc10.dim(prBody.slice(0, 300) + (prBody.length > 300 ? "..." : "")));
1345
1982
  } else {
1346
- warn("AI did not return a PR description.");
1983
+ spinner.fail("AI did not return a PR description.");
1347
1984
  }
1348
1985
  } else {
1349
1986
  warn(`AI unavailable: ${copilotError}`);
1350
1987
  }
1351
1988
  }
1989
+ const CANCEL = "Cancel";
1990
+ const SQUASH_LOCAL = `Squash merge to ${baseBranch} locally (no PR)`;
1352
1991
  if (prTitle && prBody) {
1353
- const action = await selectPrompt("What would you like to do with the PR description?", [
1992
+ const choices = [
1354
1993
  "Use AI description",
1355
1994
  "Edit title",
1356
1995
  "Write manually",
1357
1996
  "Use gh --fill (auto-fill from commits)"
1358
- ]);
1997
+ ];
1998
+ if (config.role === "maintainer")
1999
+ choices.push(SQUASH_LOCAL);
2000
+ choices.push(CANCEL);
2001
+ const action = await selectPrompt("What would you like to do with the PR description?", choices);
2002
+ if (action === CANCEL) {
2003
+ warn("Submit cancelled.");
2004
+ return;
2005
+ }
2006
+ if (action === SQUASH_LOCAL) {
2007
+ await performSquashMerge(origin, baseBranch, currentBranch, {
2008
+ defaultMsg: prTitle ?? undefined,
2009
+ model: args.model,
2010
+ convention: config.commitConvention
2011
+ });
2012
+ return;
2013
+ }
1359
2014
  if (action === "Use AI description") {} else if (action === "Edit title") {
1360
2015
  prTitle = await inputPrompt("PR title", prTitle);
1361
2016
  } else if (action === "Write manually") {
@@ -1371,8 +2026,26 @@ ${pc9.dim("AI body preview:")}`);
1371
2026
  return;
1372
2027
  }
1373
2028
  } else {
1374
- const useManual = await confirmPrompt("Create PR with manual title/body?");
1375
- if (useManual) {
2029
+ const choices = [
2030
+ "Write title & body manually",
2031
+ "Use gh --fill (auto-fill from commits)"
2032
+ ];
2033
+ if (config.role === "maintainer")
2034
+ choices.push(SQUASH_LOCAL);
2035
+ choices.push(CANCEL);
2036
+ const action = await selectPrompt("How would you like to create the PR?", choices);
2037
+ if (action === CANCEL) {
2038
+ warn("Submit cancelled.");
2039
+ return;
2040
+ }
2041
+ if (action === SQUASH_LOCAL) {
2042
+ await performSquashMerge(origin, baseBranch, currentBranch, {
2043
+ model: args.model,
2044
+ convention: config.commitConvention
2045
+ });
2046
+ return;
2047
+ }
2048
+ if (action === "Write title & body manually") {
1376
2049
  prTitle = await inputPrompt("PR title");
1377
2050
  prBody = await inputPrompt("PR body (markdown)");
1378
2051
  } else {
@@ -1405,7 +2078,7 @@ ${pc9.dim("AI body preview:")}`);
1405
2078
 
1406
2079
  // src/commands/sync.ts
1407
2080
  import { defineCommand as defineCommand8 } from "citty";
1408
- import pc10 from "picocolors";
2081
+ import pc11 from "picocolors";
1409
2082
  var sync_default = defineCommand8({
1410
2083
  meta: {
1411
2084
  name: "sync",
@@ -1448,12 +2121,12 @@ var sync_default = defineCommand8({
1448
2121
  }
1449
2122
  const div = await getDivergence(baseBranch, syncSource.ref);
1450
2123
  if (div.ahead > 0 || div.behind > 0) {
1451
- info(`${pc10.bold(baseBranch)} is ${pc10.yellow(`${div.ahead} ahead`)} and ${pc10.red(`${div.behind} behind`)} ${syncSource.ref}`);
2124
+ info(`${pc11.bold(baseBranch)} is ${pc11.yellow(`${div.ahead} ahead`)} and ${pc11.red(`${div.behind} behind`)} ${syncSource.ref}`);
1452
2125
  } else {
1453
- info(`${pc10.bold(baseBranch)} is already in sync with ${syncSource.ref}`);
2126
+ info(`${pc11.bold(baseBranch)} is already in sync with ${syncSource.ref}`);
1454
2127
  }
1455
2128
  if (!args.yes) {
1456
- const ok = await confirmPrompt(`This will pull ${pc10.bold(syncSource.ref)} into local ${pc10.bold(baseBranch)}.`);
2129
+ const ok = await confirmPrompt(`This will pull ${pc11.bold(syncSource.ref)} into local ${pc11.bold(baseBranch)}.`);
1457
2130
  if (!ok)
1458
2131
  process.exit(0);
1459
2132
  }
@@ -1471,7 +2144,7 @@ var sync_default = defineCommand8({
1471
2144
  if (hasDevBranch(workflow) && role === "maintainer") {
1472
2145
  const mainDiv = await getDivergence(config.mainBranch, `${origin}/${config.mainBranch}`);
1473
2146
  if (mainDiv.behind > 0) {
1474
- info(`Also syncing ${pc10.bold(config.mainBranch)}...`);
2147
+ info(`Also syncing ${pc11.bold(config.mainBranch)}...`);
1475
2148
  const mainCoResult = await checkoutBranch(config.mainBranch);
1476
2149
  if (mainCoResult.exitCode === 0) {
1477
2150
  const mainPullResult = await pullBranch(origin, config.mainBranch);
@@ -1486,9 +2159,9 @@ var sync_default = defineCommand8({
1486
2159
  });
1487
2160
 
1488
2161
  // src/commands/update.ts
1489
- import { readFileSync as readFileSync3 } from "node:fs";
2162
+ import { readFileSync as readFileSync4 } from "node:fs";
1490
2163
  import { defineCommand as defineCommand9 } from "citty";
1491
- import pc11 from "picocolors";
2164
+ import pc12 from "picocolors";
1492
2165
  var update_default = defineCommand9({
1493
2166
  meta: {
1494
2167
  name: "update",
@@ -1524,7 +2197,7 @@ var update_default = defineCommand9({
1524
2197
  process.exit(1);
1525
2198
  }
1526
2199
  if (protectedBranches.includes(currentBranch)) {
1527
- error(`Use \`contrib sync\` to update ${protectedBranches.map((b) => pc11.bold(b)).join(" or ")} branches.`);
2200
+ error(`Use \`contrib sync\` to update ${protectedBranches.map((b) => pc12.bold(b)).join(" or ")} branches.`);
1528
2201
  process.exit(1);
1529
2202
  }
1530
2203
  if (await hasUncommittedChanges()) {
@@ -1532,9 +2205,9 @@ var update_default = defineCommand9({
1532
2205
  process.exit(1);
1533
2206
  }
1534
2207
  heading("\uD83D\uDD03 contrib update");
1535
- info(`Updating ${pc11.bold(currentBranch)} with latest ${pc11.bold(baseBranch)}...`);
2208
+ info(`Updating ${pc12.bold(currentBranch)} with latest ${pc12.bold(baseBranch)}...`);
1536
2209
  await fetchRemote(syncSource.remote);
1537
- await resetHard(syncSource.ref);
2210
+ await updateLocalBranch(baseBranch, syncSource.ref);
1538
2211
  const rebaseResult = await rebase(baseBranch);
1539
2212
  if (rebaseResult.exitCode !== 0) {
1540
2213
  warn("Rebase hit conflicts. Resolve them manually.");
@@ -1547,7 +2220,7 @@ var update_default = defineCommand9({
1547
2220
  let conflictDiff = "";
1548
2221
  for (const file of conflictFiles.slice(0, 3)) {
1549
2222
  try {
1550
- const content = readFileSync3(file, "utf-8");
2223
+ const content = readFileSync4(file, "utf-8");
1551
2224
  if (content.includes("<<<<<<<")) {
1552
2225
  conflictDiff += `
1553
2226
  --- ${file} ---
@@ -1557,33 +2230,37 @@ ${content.slice(0, 2000)}
1557
2230
  } catch {}
1558
2231
  }
1559
2232
  if (conflictDiff) {
2233
+ const spinner = createSpinner("Analyzing conflicts with AI...");
1560
2234
  const suggestion = await suggestConflictResolution(conflictDiff, args.model);
1561
2235
  if (suggestion) {
2236
+ spinner.success("AI conflict guidance ready.");
1562
2237
  console.log(`
1563
- ${pc11.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
1564
- console.log(pc11.dim("─".repeat(60)));
2238
+ ${pc12.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
2239
+ console.log(pc12.dim("─".repeat(60)));
1565
2240
  console.log(suggestion);
1566
- console.log(pc11.dim("─".repeat(60)));
2241
+ console.log(pc12.dim("─".repeat(60)));
1567
2242
  console.log();
2243
+ } else {
2244
+ spinner.fail("AI could not analyze the conflicts.");
1568
2245
  }
1569
2246
  }
1570
2247
  }
1571
2248
  }
1572
- console.log(pc11.bold("To resolve:"));
2249
+ console.log(pc12.bold("To resolve:"));
1573
2250
  console.log(` 1. Fix conflicts in the affected files`);
1574
- console.log(` 2. ${pc11.cyan("git add <resolved-files>")}`);
1575
- console.log(` 3. ${pc11.cyan("git rebase --continue")}`);
2251
+ console.log(` 2. ${pc12.cyan("git add <resolved-files>")}`);
2252
+ console.log(` 3. ${pc12.cyan("git rebase --continue")}`);
1576
2253
  console.log();
1577
- console.log(` Or abort: ${pc11.cyan("git rebase --abort")}`);
2254
+ console.log(` Or abort: ${pc12.cyan("git rebase --abort")}`);
1578
2255
  process.exit(1);
1579
2256
  }
1580
- success(`✅ ${pc11.bold(currentBranch)} has been rebased onto latest ${pc11.bold(baseBranch)}`);
2257
+ success(`✅ ${pc12.bold(currentBranch)} has been rebased onto latest ${pc12.bold(baseBranch)}`);
1581
2258
  }
1582
2259
  });
1583
2260
 
1584
2261
  // src/commands/validate.ts
1585
2262
  import { defineCommand as defineCommand10 } from "citty";
1586
- import pc12 from "picocolors";
2263
+ import pc13 from "picocolors";
1587
2264
  var validate_default = defineCommand10({
1588
2265
  meta: {
1589
2266
  name: "validate",
@@ -1614,7 +2291,7 @@ var validate_default = defineCommand10({
1614
2291
  }
1615
2292
  const errors = getValidationError(convention);
1616
2293
  for (const line of errors) {
1617
- console.error(pc12.red(` ✗ ${line}`));
2294
+ console.error(pc13.red(` ✗ ${line}`));
1618
2295
  }
1619
2296
  process.exit(1);
1620
2297
  }
@@ -1622,11 +2299,11 @@ var validate_default = defineCommand10({
1622
2299
 
1623
2300
  // src/ui/banner.ts
1624
2301
  import figlet from "figlet";
1625
- import pc13 from "picocolors";
2302
+ import pc14 from "picocolors";
1626
2303
  // package.json
1627
2304
  var package_default = {
1628
2305
  name: "contribute-now",
1629
- version: "0.2.0-dev.e0cfab8",
2306
+ version: "0.2.0-pr.4dd3c30",
1630
2307
  description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
1631
2308
  type: "module",
1632
2309
  bin: {
@@ -1638,12 +2315,12 @@ var package_default = {
1638
2315
  ],
1639
2316
  scripts: {
1640
2317
  build: "bun build src/index.ts --outfile dist/index.js --target node --packages external",
2318
+ cli: "bun run src/index.ts --",
1641
2319
  dev: "bun src/index.ts",
1642
2320
  test: "bun test",
1643
2321
  lint: "biome check .",
1644
2322
  "lint:fix": "biome check --write .",
1645
2323
  format: "biome format --write .",
1646
- prepare: "husky || true",
1647
2324
  "www:dev": "bun run --cwd www dev",
1648
2325
  "www:build": "bun run --cwd www build",
1649
2326
  "www:preview": "bun run --cwd www preview"
@@ -1670,6 +2347,7 @@ var package_default = {
1670
2347
  url: "git+https://github.com/warengonzaga/contribute-now.git"
1671
2348
  },
1672
2349
  dependencies: {
2350
+ "@clack/prompts": "^1.0.1",
1673
2351
  "@github/copilot-sdk": "^0.1.25",
1674
2352
  "@wgtechlabs/log-engine": "^2.3.1",
1675
2353
  citty: "^0.1.6",
@@ -1680,7 +2358,6 @@ var package_default = {
1680
2358
  "@biomejs/biome": "^2.4.4",
1681
2359
  "@types/bun": "latest",
1682
2360
  "@types/figlet": "^1.7.0",
1683
- husky: "^9.1.7",
1684
2361
  typescript: "^5.7.0"
1685
2362
  }
1686
2363
  };
@@ -1688,9 +2365,10 @@ var package_default = {
1688
2365
  // src/ui/banner.ts
1689
2366
  var LOGO;
1690
2367
  try {
1691
- LOGO = figlet.textSync("contrib", { font: "ANSI Shadow" });
2368
+ LOGO = figlet.textSync(`Contribute
2369
+ Now`, { font: "ANSI Shadow" });
1692
2370
  } catch {
1693
- LOGO = "contribute-now";
2371
+ LOGO = "Contribute Now";
1694
2372
  }
1695
2373
  function getVersion() {
1696
2374
  return package_default.version ?? "unknown";
@@ -1698,16 +2376,15 @@ function getVersion() {
1698
2376
  function getAuthor() {
1699
2377
  return typeof package_default.author === "string" ? package_default.author : "unknown";
1700
2378
  }
1701
- function showBanner(minimal = false) {
1702
- console.log(pc13.cyan(`
2379
+ function showBanner(showLinks = false) {
2380
+ console.log(pc14.cyan(`
1703
2381
  ${LOGO}`));
1704
- console.log(` ${pc13.dim(`v${getVersion()}`)} ${pc13.dim("—")} ${pc13.dim(`Built by ${getAuthor()}`)}`);
1705
- if (!minimal) {
1706
- console.log(` ${pc13.dim(package_default.description)}`);
2382
+ console.log(` ${pc14.dim(`v${getVersion()}`)} ${pc14.dim("—")} ${pc14.dim(`Built by ${getAuthor()}`)}`);
2383
+ if (showLinks) {
1707
2384
  console.log();
1708
- console.log(` ${pc13.yellow("Star")} ${pc13.cyan("https://github.com/warengonzaga/contribute-now")}`);
1709
- console.log(` ${pc13.green("Contribute")} ${pc13.cyan("https://github.com/warengonzaga/contribute-now/blob/main/CONTRIBUTING.md")}`);
1710
- console.log(` ${pc13.magenta("Sponsor")} ${pc13.cyan("https://warengonzaga.com/sponsor")}`);
2385
+ console.log(` ${pc14.yellow("Star")} ${pc14.cyan("https://github.com/warengonzaga/contribute-now")}`);
2386
+ console.log(` ${pc14.green("Contribute")} ${pc14.cyan("https://github.com/warengonzaga/contribute-now/blob/main/CONTRIBUTING.md")}`);
2387
+ console.log(` ${pc14.magenta("Sponsor")} ${pc14.cyan("https://warengonzaga.com/sponsor")}`);
1711
2388
  }
1712
2389
  console.log();
1713
2390
  }
@@ -1746,4 +2423,6 @@ var main = defineCommand11({
1746
2423
  }
1747
2424
  }
1748
2425
  });
1749
- runMain(main);
2426
+ runMain(main).then(() => {
2427
+ process.exit(0);
2428
+ });