contribute-now 0.2.0-dev.70284d0 → 0.2.0-dev.d4b7ede

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 +941 -296
  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}`);
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
  });
@@ -197,8 +188,16 @@ async function getChangedFiles() {
197
188
  const { exitCode, stdout } = await run(["status", "--porcelain"]);
198
189
  if (exitCode !== 0)
199
190
  return [];
200
- return stdout.trim().split(`
201
- `).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);
202
201
  }
203
202
  async function getDivergence(branch, base) {
204
203
  const { exitCode, stdout } = await run([
@@ -225,6 +224,18 @@ async function getMergedBranches(base) {
225
224
  async function deleteBranch(branch) {
226
225
  return run(["branch", "-d", branch]);
227
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
+ }
228
239
  async function pruneRemote(remote) {
229
240
  return run(["remote", "prune", remote]);
230
241
  }
@@ -245,6 +256,85 @@ async function getLog(base, head) {
245
256
  async function pullBranch(remote, branch) {
246
257
  return run(["pull", remote, branch]);
247
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
+ }
248
338
 
249
339
  // src/utils/logger.ts
250
340
  import { LogEngine, LogMode } from "@wgtechlabs/log-engine";
@@ -387,7 +477,7 @@ ${pc3.bold("Branches to delete:")}`);
387
477
 
388
478
  // src/commands/commit.ts
389
479
  import { defineCommand as defineCommand2 } from "citty";
390
- import pc4 from "picocolors";
480
+ import pc5 from "picocolors";
391
481
 
392
482
  // src/utils/convention.ts
393
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;
@@ -434,121 +524,98 @@ function getValidationError(convention) {
434
524
 
435
525
  // src/utils/copilot.ts
436
526
  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>
439
-
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
452
-
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>
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.
466
543
 
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
544
+ ${conventionBlock}
472
545
 
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
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
+ ]
483
553
 
484
554
  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>
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>
504
563
  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..."
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.`;
521
584
  }
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`;
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.`;
534
586
  function suppressSubprocessWarnings() {
535
- const prev = process.env.NODE_NO_WARNINGS;
536
587
  process.env.NODE_NO_WARNINGS = "1";
537
- return prev;
538
588
  }
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
- }
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
+ });
545
600
  }
601
+ var COPILOT_TIMEOUT_MS = 30000;
602
+ var COPILOT_LONG_TIMEOUT_MS = 90000;
546
603
  async function checkCopilotAvailable() {
547
- let client = null;
548
- const prev = suppressSubprocessWarnings();
549
604
  try {
550
- client = new CopilotClient;
551
- 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;
552
619
  } catch (err) {
553
620
  const msg = err instanceof Error ? err.message : String(err);
554
621
  if (msg.includes("ENOENT") || msg.includes("not found")) {
@@ -556,47 +623,45 @@ async function checkCopilotAvailable() {
556
623
  }
557
624
  return `Failed to start Copilot service: ${msg}`;
558
625
  }
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 {}
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);
575
647
  }
576
- return null;
648
+ return _managedClient;
577
649
  }
578
- async function callCopilot(systemMessage, userMessage, model) {
579
- const prev = suppressSubprocessWarnings();
580
- const client = new CopilotClient;
581
- 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);
582
658
  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
- }
659
+ const response = await withTimeout(session.sendAndWait({ prompt: userMessage }), timeoutMs);
660
+ if (!response?.data?.content)
661
+ return null;
662
+ return response.data.content;
597
663
  } finally {
598
- restoreWarnings(prev);
599
- await client.stop();
664
+ await session.destroy();
600
665
  }
601
666
  }
602
667
  function getCommitSystemPrompt(convention) {
@@ -604,21 +669,53 @@ function getCommitSystemPrompt(convention) {
604
669
  return CONVENTIONAL_COMMIT_SYSTEM_PROMPT;
605
670
  return CLEAN_COMMIT_SYSTEM_PROMPT;
606
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
+ }
607
701
  async function generateCommitMessage(diff, stagedFiles, model, convention = "clean-commit") {
608
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.` : "";
609
706
  const userMessage = `Generate a commit message for these staged changes:
610
707
 
611
708
  Files: ${stagedFiles.join(", ")}
612
709
 
613
710
  Diff:
614
- ${diff.slice(0, 4000)}`;
711
+ ${diff.slice(0, 4000)}${multiFileHint}`;
615
712
  const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
616
713
  return result?.trim() ?? null;
617
714
  } catch {
618
715
  return null;
619
716
  }
620
717
  }
621
- async function generatePRDescription(commits, diff, model) {
718
+ async function generatePRDescription(commits, diff, model, convention = "clean-commit") {
622
719
  try {
623
720
  const userMessage = `Generate a PR description for these changes:
624
721
 
@@ -628,10 +725,10 @@ ${commits.join(`
628
725
 
629
726
  Diff (truncated):
630
727
  ${diff.slice(0, 4000)}`;
631
- const result = await callCopilot(PR_DESCRIPTION_SYSTEM_PROMPT, userMessage, model);
728
+ const result = await callCopilot(getPRDescriptionSystemPrompt(convention), userMessage, model);
632
729
  if (!result)
633
730
  return null;
634
- const cleaned = result.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
731
+ const cleaned = extractJson(result);
635
732
  return JSON.parse(cleaned);
636
733
  } catch {
637
734
  return null;
@@ -656,6 +753,124 @@ ${conflictDiff.slice(0, 4000)}`;
656
753
  return null;
657
754
  }
658
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
+ }
659
874
 
660
875
  // src/commands/commit.ts
661
876
  var commit_default = defineCommand2({
@@ -672,6 +887,11 @@ var commit_default = defineCommand2({
672
887
  type: "boolean",
673
888
  description: "Skip AI and write commit message manually",
674
889
  default: false
890
+ },
891
+ group: {
892
+ type: "boolean",
893
+ description: "AI groups related changes into separate atomic commits",
894
+ default: false
675
895
  }
676
896
  },
677
897
  async run({ args }) {
@@ -685,7 +905,11 @@ var commit_default = defineCommand2({
685
905
  process.exit(1);
686
906
  }
687
907
  heading("\uD83D\uDCBE contrib commit");
688
- const stagedFiles = await getStagedFiles();
908
+ if (args.group) {
909
+ await runGroupCommit(args.model, config);
910
+ return;
911
+ }
912
+ let stagedFiles = await getStagedFiles();
689
913
  if (stagedFiles.length === 0) {
690
914
  const changedFiles = await getChangedFiles();
691
915
  if (changedFiles.length === 0) {
@@ -693,31 +917,62 @@ var commit_default = defineCommand2({
693
917
  process.exit(1);
694
918
  }
695
919
  console.log(`
696
- ${pc4.bold("Changed files:")}`);
920
+ ${pc5.bold("Changed files:")}`);
697
921
  for (const f of changedFiles) {
698
- 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);
699
956
  }
700
- console.log();
701
- warn("No staged changes. Stage your files with `git add` and re-run.");
702
- process.exit(1);
703
957
  }
704
958
  info(`Staged files: ${stagedFiles.join(", ")}`);
705
959
  let commitMessage = null;
706
960
  const useAI = !args["no-ai"];
707
961
  if (useAI) {
708
- const copilotError = await checkCopilotAvailable();
962
+ const [copilotError, diff] = await Promise.all([checkCopilotAvailable(), getStagedDiff()]);
709
963
  if (copilotError) {
710
964
  warn(`AI unavailable: ${copilotError}`);
711
965
  warn("Falling back to manual commit message entry.");
712
966
  } else {
713
- info("Generating commit message with AI...");
714
- const diff = await getStagedDiff();
967
+ const spinner = createSpinner("Generating commit message with AI...");
715
968
  commitMessage = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
716
969
  if (commitMessage) {
970
+ spinner.success("AI commit message generated.");
717
971
  console.log(`
718
- ${pc4.dim("AI suggestion:")} ${pc4.bold(pc4.cyan(commitMessage))}`);
972
+ ${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(commitMessage))}`);
719
973
  } else {
720
- 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.");
721
976
  }
722
977
  }
723
978
  }
@@ -734,16 +989,17 @@ ${pc4.bold("Changed files:")}`);
734
989
  } else if (action === "Edit this message") {
735
990
  finalMessage = await inputPrompt("Edit commit message", commitMessage);
736
991
  } else if (action === "Regenerate") {
737
- info("Regenerating...");
992
+ const spinner = createSpinner("Regenerating commit message...");
738
993
  const diff = await getStagedDiff();
739
994
  const regen = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
740
995
  if (regen) {
996
+ spinner.success("Commit message regenerated.");
741
997
  console.log(`
742
- ${pc4.dim("AI suggestion:")} ${pc4.bold(pc4.cyan(regen))}`);
998
+ ${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(regen))}`);
743
999
  const ok = await confirmPrompt("Use this message?");
744
1000
  finalMessage = ok ? regen : await inputPrompt("Enter commit message manually");
745
1001
  } else {
746
- warn("Regeneration failed. Falling back to manual entry.");
1002
+ spinner.fail("Regeneration failed.");
747
1003
  finalMessage = await inputPrompt("Enter commit message");
748
1004
  }
749
1005
  } else {
@@ -754,7 +1010,7 @@ ${pc4.bold("Changed files:")}`);
754
1010
  if (convention2 !== "none") {
755
1011
  console.log();
756
1012
  for (const hint of CONVENTION_FORMAT_HINTS[convention2]) {
757
- console.log(pc4.dim(hint));
1013
+ console.log(pc5.dim(hint));
758
1014
  }
759
1015
  console.log();
760
1016
  }
@@ -778,21 +1034,209 @@ ${pc4.bold("Changed files:")}`);
778
1034
  error(`Failed to commit: ${result.stderr}`);
779
1035
  process.exit(1);
780
1036
  }
781
- success(`✅ Committed: ${pc4.bold(finalMessage)}`);
1037
+ success(`✅ Committed: ${pc5.bold(finalMessage)}`);
782
1038
  }
783
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
+ }
784
1228
 
785
1229
  // 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";
1230
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
1231
+ import { join as join3 } from "node:path";
788
1232
  import { defineCommand as defineCommand3 } from "citty";
789
- import pc5 from "picocolors";
1233
+ import pc6 from "picocolors";
790
1234
  var HOOK_MARKER = "# managed by contribute-now";
791
1235
  function getHooksDir(cwd = process.cwd()) {
792
- return join2(cwd, ".git", "hooks");
1236
+ return join3(cwd, ".git", "hooks");
793
1237
  }
794
1238
  function getHookPath(cwd = process.cwd()) {
795
- return join2(getHooksDir(cwd), "commit-msg");
1239
+ return join3(getHooksDir(cwd), "commit-msg");
796
1240
  }
797
1241
  function generateHookScript() {
798
1242
  return `#!/bin/sh
@@ -809,8 +1253,19 @@ case "$commit_msg" in
809
1253
  Merge\\ *|fixup!*|squash!*|amend!*) exit 0 ;;
810
1254
  esac
811
1255
 
812
- # Validate using contrib CLI
813
- 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
814
1269
  `;
815
1270
  }
816
1271
  var hook_default = defineCommand3({
@@ -857,7 +1312,7 @@ async function installHook() {
857
1312
  const hookPath = getHookPath();
858
1313
  const hooksDir = getHooksDir();
859
1314
  if (existsSync2(hookPath)) {
860
- const existing = readFileSync2(hookPath, "utf-8");
1315
+ const existing = readFileSync3(hookPath, "utf-8");
861
1316
  if (!existing.includes(HOOK_MARKER)) {
862
1317
  error("A commit-msg hook already exists and was not installed by contribute-now.");
863
1318
  warn(`Path: ${hookPath}`);
@@ -871,8 +1326,8 @@ async function installHook() {
871
1326
  }
872
1327
  writeFileSync2(hookPath, generateHookScript(), { mode: 493 });
873
1328
  success(`commit-msg hook installed.`);
874
- info(`Convention: ${pc5.bold(CONVENTION_LABELS[config.commitConvention])}`);
875
- info(`Path: ${pc5.dim(hookPath)}`);
1329
+ info(`Convention: ${pc6.bold(CONVENTION_LABELS[config.commitConvention])}`);
1330
+ info(`Path: ${pc6.dim(hookPath)}`);
876
1331
  }
877
1332
  async function uninstallHook() {
878
1333
  heading("\uD83E\uDE9D hook uninstall");
@@ -881,7 +1336,7 @@ async function uninstallHook() {
881
1336
  info("No commit-msg hook found. Nothing to uninstall.");
882
1337
  return;
883
1338
  }
884
- const content = readFileSync2(hookPath, "utf-8");
1339
+ const content = readFileSync3(hookPath, "utf-8");
885
1340
  if (!content.includes(HOOK_MARKER)) {
886
1341
  error("The commit-msg hook was not installed by contribute-now. Leaving it untouched.");
887
1342
  process.exit(1);
@@ -892,7 +1347,7 @@ async function uninstallHook() {
892
1347
 
893
1348
  // src/commands/setup.ts
894
1349
  import { defineCommand as defineCommand4 } from "citty";
895
- import pc6 from "picocolors";
1350
+ import pc7 from "picocolors";
896
1351
 
897
1352
  // src/utils/gh.ts
898
1353
  import { execFile as execFileCb2 } from "node:child_process";
@@ -900,7 +1355,7 @@ function run2(args) {
900
1355
  return new Promise((resolve) => {
901
1356
  execFileCb2("gh", args, (error2, stdout, stderr) => {
902
1357
  resolve({
903
- exitCode: error2 ? error2.code != null ? Number(error2.code) : 1 : 0,
1358
+ exitCode: error2 ? error2.code === "ENOENT" ? 127 : error2.status ?? 1 : 0,
904
1359
  stdout: stdout ?? "",
905
1360
  stderr: stderr ?? ""
906
1361
  });
@@ -923,7 +1378,10 @@ async function checkGhAuth() {
923
1378
  return false;
924
1379
  }
925
1380
  }
1381
+ var SAFE_SLUG = /^[\w.-]+$/;
926
1382
  async function checkRepoPermissions(owner, repo) {
1383
+ if (!SAFE_SLUG.test(owner) || !SAFE_SLUG.test(repo))
1384
+ return null;
927
1385
  const { exitCode, stdout } = await run2(["api", `repos/${owner}/${repo}`, "--jq", ".permissions"]);
928
1386
  if (exitCode !== 0)
929
1387
  return null;
@@ -984,6 +1442,28 @@ async function createPRFill(base, draft) {
984
1442
  args.push("--draft");
985
1443
  return run2(args);
986
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
+ }
987
1467
 
988
1468
  // src/utils/remote.ts
989
1469
  function parseRepoFromUrl(url) {
@@ -1026,7 +1506,7 @@ var setup_default = defineCommand4({
1026
1506
  workflow = "github-flow";
1027
1507
  else if (workflowChoice.startsWith("Git Flow"))
1028
1508
  workflow = "git-flow";
1029
- info(`Workflow: ${pc6.bold(WORKFLOW_DESCRIPTIONS[workflow])}`);
1509
+ info(`Workflow: ${pc7.bold(WORKFLOW_DESCRIPTIONS[workflow])}`);
1030
1510
  const conventionChoice = await selectPrompt("Which commit convention should this project use?", [
1031
1511
  `${CONVENTION_DESCRIPTIONS["clean-commit"]} (recommended)`,
1032
1512
  CONVENTION_DESCRIPTIONS.conventional,
@@ -1079,8 +1559,8 @@ var setup_default = defineCommand4({
1079
1559
  detectedRole = roleChoice;
1080
1560
  detectionSource = "user selection";
1081
1561
  } else {
1082
- info(`Detected role: ${pc6.bold(detectedRole)} (via ${detectionSource})`);
1083
- 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?`);
1084
1564
  if (!confirmed) {
1085
1565
  const roleChoice = await selectPrompt("Select your role:", ["maintainer", "contributor"]);
1086
1566
  detectedRole = roleChoice;
@@ -1125,21 +1605,21 @@ var setup_default = defineCommand4({
1125
1605
  warn(' echo ".contributerc.json" >> .gitignore');
1126
1606
  }
1127
1607
  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)}`);
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)}`);
1131
1611
  if (config.devBranch) {
1132
- info(`Main: ${pc6.bold(config.mainBranch)} | Dev: ${pc6.bold(config.devBranch)}`);
1612
+ info(`Main: ${pc7.bold(config.mainBranch)} | Dev: ${pc7.bold(config.devBranch)}`);
1133
1613
  } else {
1134
- info(`Main: ${pc6.bold(config.mainBranch)}`);
1614
+ info(`Main: ${pc7.bold(config.mainBranch)}`);
1135
1615
  }
1136
- 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)}` : ""}`);
1137
1617
  }
1138
1618
  });
1139
1619
 
1140
1620
  // src/commands/start.ts
1141
1621
  import { defineCommand as defineCommand5 } from "citty";
1142
- import pc7 from "picocolors";
1622
+ import pc8 from "picocolors";
1143
1623
 
1144
1624
  // src/utils/branch.ts
1145
1625
  var DEFAULT_PREFIXES = ["feature", "fix", "docs", "chore", "test", "refactor"];
@@ -1150,6 +1630,9 @@ function formatBranchName(prefix, name) {
1150
1630
  const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1151
1631
  return `${prefix}/${sanitized}`;
1152
1632
  }
1633
+ function isValidBranchName(name) {
1634
+ return /^[a-zA-Z0-9._/-]+$/.test(name) && !name.startsWith("/") && !name.endsWith("/");
1635
+ }
1153
1636
  function looksLikeNaturalLanguage(input) {
1154
1637
  return input.includes(" ") && !input.includes("/");
1155
1638
  }
@@ -1197,24 +1680,31 @@ var start_default = defineCommand5({
1197
1680
  heading("\uD83C\uDF3F contrib start");
1198
1681
  const useAI = !args["no-ai"] && looksLikeNaturalLanguage(branchName);
1199
1682
  if (useAI) {
1200
- info("Generating branch name suggestion from description...");
1683
+ const spinner = createSpinner("Generating branch name suggestion...");
1201
1684
  const suggested = await suggestBranchName(branchName, args.model);
1202
1685
  if (suggested) {
1686
+ spinner.success("Branch name suggestion ready.");
1203
1687
  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?`);
1688
+ ${pc8.dim("AI suggestion:")} ${pc8.bold(pc8.cyan(suggested))}`);
1689
+ const accepted = await confirmPrompt(`Use ${pc8.bold(suggested)} as your branch name?`);
1206
1690
  if (accepted) {
1207
1691
  branchName = suggested;
1208
1692
  } else {
1209
1693
  branchName = await inputPrompt("Enter branch name", branchName);
1210
1694
  }
1695
+ } else {
1696
+ spinner.fail("AI did not return a branch name suggestion.");
1211
1697
  }
1212
1698
  }
1213
1699
  if (!hasPrefix(branchName, branchPrefixes)) {
1214
- 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);
1215
1701
  branchName = formatBranchName(prefix, branchName);
1216
1702
  }
1217
- 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)}`);
1218
1708
  await fetchRemote(syncSource.remote);
1219
1709
  const updateResult = await updateLocalBranch(baseBranch, syncSource.ref);
1220
1710
  if (updateResult.exitCode !== 0) {}
@@ -1223,13 +1713,13 @@ var start_default = defineCommand5({
1223
1713
  error(`Failed to create branch: ${result.stderr}`);
1224
1714
  process.exit(1);
1225
1715
  }
1226
- success(`✅ Created ${pc7.bold(branchName)} from latest ${pc7.bold(baseBranch)}`);
1716
+ success(`✅ Created ${pc8.bold(branchName)} from latest ${pc8.bold(baseBranch)}`);
1227
1717
  }
1228
1718
  });
1229
1719
 
1230
1720
  // src/commands/status.ts
1231
1721
  import { defineCommand as defineCommand6 } from "citty";
1232
- import pc8 from "picocolors";
1722
+ import pc9 from "picocolors";
1233
1723
  var status_default = defineCommand6({
1234
1724
  meta: {
1235
1725
  name: "status",
@@ -1246,17 +1736,20 @@ var status_default = defineCommand6({
1246
1736
  process.exit(1);
1247
1737
  }
1248
1738
  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)}`);
1739
+ console.log(` ${pc9.dim("Workflow:")} ${pc9.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
1740
+ console.log(` ${pc9.dim("Role:")} ${pc9.bold(config.role)}`);
1251
1741
  console.log();
1252
1742
  await fetchAll();
1253
1743
  const currentBranch = await getCurrentBranch();
1254
1744
  const { mainBranch, origin, upstream, workflow } = config;
1255
1745
  const baseBranch = getBaseBranch(config);
1256
1746
  const isContributor = config.role === "contributor";
1257
- const dirty = await hasUncommittedChanges();
1747
+ const [dirty, fileStatus] = await Promise.all([
1748
+ hasUncommittedChanges(),
1749
+ getFileStatus()
1750
+ ]);
1258
1751
  if (dirty) {
1259
- console.log(` ${pc8.yellow("⚠")} ${pc8.yellow("Uncommitted changes in working tree")}`);
1752
+ console.log(` ${pc9.yellow("⚠")} ${pc9.yellow("Uncommitted changes in working tree")}`);
1260
1753
  console.log();
1261
1754
  }
1262
1755
  const mainRemote = `${origin}/${mainBranch}`;
@@ -1272,30 +1765,130 @@ var status_default = defineCommand6({
1272
1765
  if (currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
1273
1766
  const branchDiv = await getDivergence(currentBranch, baseBranch);
1274
1767
  const branchLine = formatStatus(currentBranch, baseBranch, branchDiv.ahead, branchDiv.behind);
1275
- console.log(branchLine + pc8.dim(` (current ${pc8.green("*")})`));
1768
+ console.log(branchLine + pc9.dim(` (current ${pc9.green("*")})`));
1276
1769
  } else if (currentBranch) {
1277
- 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
+ }
1278
1815
  }
1279
1816
  console.log();
1280
1817
  }
1281
1818
  });
1282
1819
  function formatStatus(branch, base, ahead, behind) {
1283
- const label = pc8.bold(branch.padEnd(20));
1820
+ const label = pc9.bold(branch.padEnd(20));
1284
1821
  if (ahead === 0 && behind === 0) {
1285
- return ` ${pc8.green("✓")} ${label} ${pc8.dim(`in sync with ${base}`)}`;
1822
+ return ` ${pc9.green("✓")} ${label} ${pc9.dim(`in sync with ${base}`)}`;
1286
1823
  }
1287
1824
  if (ahead > 0 && behind === 0) {
1288
- 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}`)}`;
1289
1826
  }
1290
1827
  if (behind > 0 && ahead === 0) {
1291
- 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}`)}`;
1292
1829
  }
1293
- 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)}`;
1294
1831
  }
1295
1832
 
1296
1833
  // src/commands/submit.ts
1297
1834
  import { defineCommand as defineCommand7 } from "citty";
1298
- 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
+ }
1299
1892
  var submit_default = defineCommand7({
1300
1893
  meta: {
1301
1894
  name: "submit",
@@ -1336,11 +1929,11 @@ var submit_default = defineCommand7({
1336
1929
  process.exit(1);
1337
1930
  }
1338
1931
  if (protectedBranches.includes(currentBranch)) {
1339
- 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.`);
1340
1933
  process.exit(1);
1341
1934
  }
1342
1935
  heading("\uD83D\uDE80 contrib submit");
1343
- info(`Pushing ${pc9.bold(currentBranch)} to ${origin}...`);
1936
+ info(`Pushing ${pc10.bold(currentBranch)} to ${origin}...`);
1344
1937
  const pushResult = await pushSetUpstream(origin, currentBranch);
1345
1938
  if (pushResult.exitCode !== 0) {
1346
1939
  error(`Failed to push: ${pushResult.stderr}`);
@@ -1354,43 +1947,70 @@ var submit_default = defineCommand7({
1354
1947
  const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${baseBranch}...${currentBranch}?expand=1`;
1355
1948
  console.log();
1356
1949
  info("Create your PR manually:");
1357
- console.log(` ${pc9.cyan(prUrl)}`);
1950
+ console.log(` ${pc10.cyan(prUrl)}`);
1358
1951
  } else {
1359
1952
  info("gh CLI not available. Create your PR manually on GitHub.");
1360
1953
  }
1361
1954
  return;
1362
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
+ }
1363
1962
  let prTitle = null;
1364
1963
  let prBody = null;
1365
1964
  if (!args["no-ai"]) {
1366
- const copilotError = await checkCopilotAvailable();
1965
+ const [copilotError, commits, diff] = await Promise.all([
1966
+ checkCopilotAvailable(),
1967
+ getLog(baseBranch, "HEAD"),
1968
+ getLogDiff(baseBranch, "HEAD")
1969
+ ]);
1367
1970
  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);
1971
+ const spinner = createSpinner("Generating AI PR description...");
1972
+ const result = await generatePRDescription(commits, diff, args.model, config.commitConvention);
1372
1973
  if (result) {
1373
1974
  prTitle = result.title;
1374
1975
  prBody = result.body;
1976
+ spinner.success("PR description generated.");
1375
1977
  console.log(`
1376
- ${pc9.dim("AI title:")} ${pc9.bold(pc9.cyan(prTitle))}`);
1978
+ ${pc10.dim("AI title:")} ${pc10.bold(pc10.cyan(prTitle))}`);
1377
1979
  console.log(`
1378
- ${pc9.dim("AI body preview:")}`);
1379
- 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 ? "..." : "")));
1380
1982
  } else {
1381
- warn("AI did not return a PR description.");
1983
+ spinner.fail("AI did not return a PR description.");
1382
1984
  }
1383
1985
  } else {
1384
1986
  warn(`AI unavailable: ${copilotError}`);
1385
1987
  }
1386
1988
  }
1989
+ const CANCEL = "Cancel";
1990
+ const SQUASH_LOCAL = `Squash merge to ${baseBranch} locally (no PR)`;
1387
1991
  if (prTitle && prBody) {
1388
- const action = await selectPrompt("What would you like to do with the PR description?", [
1992
+ const choices = [
1389
1993
  "Use AI description",
1390
1994
  "Edit title",
1391
1995
  "Write manually",
1392
1996
  "Use gh --fill (auto-fill from commits)"
1393
- ]);
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
+ }
1394
2014
  if (action === "Use AI description") {} else if (action === "Edit title") {
1395
2015
  prTitle = await inputPrompt("PR title", prTitle);
1396
2016
  } else if (action === "Write manually") {
@@ -1406,8 +2026,26 @@ ${pc9.dim("AI body preview:")}`);
1406
2026
  return;
1407
2027
  }
1408
2028
  } else {
1409
- const useManual = await confirmPrompt("Create PR with manual title/body?");
1410
- 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") {
1411
2049
  prTitle = await inputPrompt("PR title");
1412
2050
  prBody = await inputPrompt("PR body (markdown)");
1413
2051
  } else {
@@ -1440,7 +2078,7 @@ ${pc9.dim("AI body preview:")}`);
1440
2078
 
1441
2079
  // src/commands/sync.ts
1442
2080
  import { defineCommand as defineCommand8 } from "citty";
1443
- import pc10 from "picocolors";
2081
+ import pc11 from "picocolors";
1444
2082
  var sync_default = defineCommand8({
1445
2083
  meta: {
1446
2084
  name: "sync",
@@ -1483,12 +2121,12 @@ var sync_default = defineCommand8({
1483
2121
  }
1484
2122
  const div = await getDivergence(baseBranch, syncSource.ref);
1485
2123
  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}`);
2124
+ info(`${pc11.bold(baseBranch)} is ${pc11.yellow(`${div.ahead} ahead`)} and ${pc11.red(`${div.behind} behind`)} ${syncSource.ref}`);
1487
2125
  } else {
1488
- info(`${pc10.bold(baseBranch)} is already in sync with ${syncSource.ref}`);
2126
+ info(`${pc11.bold(baseBranch)} is already in sync with ${syncSource.ref}`);
1489
2127
  }
1490
2128
  if (!args.yes) {
1491
- 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)}.`);
1492
2130
  if (!ok)
1493
2131
  process.exit(0);
1494
2132
  }
@@ -1506,7 +2144,7 @@ var sync_default = defineCommand8({
1506
2144
  if (hasDevBranch(workflow) && role === "maintainer") {
1507
2145
  const mainDiv = await getDivergence(config.mainBranch, `${origin}/${config.mainBranch}`);
1508
2146
  if (mainDiv.behind > 0) {
1509
- info(`Also syncing ${pc10.bold(config.mainBranch)}...`);
2147
+ info(`Also syncing ${pc11.bold(config.mainBranch)}...`);
1510
2148
  const mainCoResult = await checkoutBranch(config.mainBranch);
1511
2149
  if (mainCoResult.exitCode === 0) {
1512
2150
  const mainPullResult = await pullBranch(origin, config.mainBranch);
@@ -1521,9 +2159,9 @@ var sync_default = defineCommand8({
1521
2159
  });
1522
2160
 
1523
2161
  // src/commands/update.ts
1524
- import { readFileSync as readFileSync3 } from "node:fs";
2162
+ import { readFileSync as readFileSync4 } from "node:fs";
1525
2163
  import { defineCommand as defineCommand9 } from "citty";
1526
- import pc11 from "picocolors";
2164
+ import pc12 from "picocolors";
1527
2165
  var update_default = defineCommand9({
1528
2166
  meta: {
1529
2167
  name: "update",
@@ -1559,7 +2197,7 @@ var update_default = defineCommand9({
1559
2197
  process.exit(1);
1560
2198
  }
1561
2199
  if (protectedBranches.includes(currentBranch)) {
1562
- 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.`);
1563
2201
  process.exit(1);
1564
2202
  }
1565
2203
  if (await hasUncommittedChanges()) {
@@ -1567,7 +2205,7 @@ var update_default = defineCommand9({
1567
2205
  process.exit(1);
1568
2206
  }
1569
2207
  heading("\uD83D\uDD03 contrib update");
1570
- info(`Updating ${pc11.bold(currentBranch)} with latest ${pc11.bold(baseBranch)}...`);
2208
+ info(`Updating ${pc12.bold(currentBranch)} with latest ${pc12.bold(baseBranch)}...`);
1571
2209
  await fetchRemote(syncSource.remote);
1572
2210
  await updateLocalBranch(baseBranch, syncSource.ref);
1573
2211
  const rebaseResult = await rebase(baseBranch);
@@ -1582,7 +2220,7 @@ var update_default = defineCommand9({
1582
2220
  let conflictDiff = "";
1583
2221
  for (const file of conflictFiles.slice(0, 3)) {
1584
2222
  try {
1585
- const content = readFileSync3(file, "utf-8");
2223
+ const content = readFileSync4(file, "utf-8");
1586
2224
  if (content.includes("<<<<<<<")) {
1587
2225
  conflictDiff += `
1588
2226
  --- ${file} ---
@@ -1592,33 +2230,37 @@ ${content.slice(0, 2000)}
1592
2230
  } catch {}
1593
2231
  }
1594
2232
  if (conflictDiff) {
2233
+ const spinner = createSpinner("Analyzing conflicts with AI...");
1595
2234
  const suggestion = await suggestConflictResolution(conflictDiff, args.model);
1596
2235
  if (suggestion) {
2236
+ spinner.success("AI conflict guidance ready.");
1597
2237
  console.log(`
1598
- ${pc11.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
1599
- console.log(pc11.dim("─".repeat(60)));
2238
+ ${pc12.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
2239
+ console.log(pc12.dim("─".repeat(60)));
1600
2240
  console.log(suggestion);
1601
- console.log(pc11.dim("─".repeat(60)));
2241
+ console.log(pc12.dim("─".repeat(60)));
1602
2242
  console.log();
2243
+ } else {
2244
+ spinner.fail("AI could not analyze the conflicts.");
1603
2245
  }
1604
2246
  }
1605
2247
  }
1606
2248
  }
1607
- console.log(pc11.bold("To resolve:"));
2249
+ console.log(pc12.bold("To resolve:"));
1608
2250
  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")}`);
2251
+ console.log(` 2. ${pc12.cyan("git add <resolved-files>")}`);
2252
+ console.log(` 3. ${pc12.cyan("git rebase --continue")}`);
1611
2253
  console.log();
1612
- console.log(` Or abort: ${pc11.cyan("git rebase --abort")}`);
2254
+ console.log(` Or abort: ${pc12.cyan("git rebase --abort")}`);
1613
2255
  process.exit(1);
1614
2256
  }
1615
- 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)}`);
1616
2258
  }
1617
2259
  });
1618
2260
 
1619
2261
  // src/commands/validate.ts
1620
2262
  import { defineCommand as defineCommand10 } from "citty";
1621
- import pc12 from "picocolors";
2263
+ import pc13 from "picocolors";
1622
2264
  var validate_default = defineCommand10({
1623
2265
  meta: {
1624
2266
  name: "validate",
@@ -1649,7 +2291,7 @@ var validate_default = defineCommand10({
1649
2291
  }
1650
2292
  const errors = getValidationError(convention);
1651
2293
  for (const line of errors) {
1652
- console.error(pc12.red(` ✗ ${line}`));
2294
+ console.error(pc13.red(` ✗ ${line}`));
1653
2295
  }
1654
2296
  process.exit(1);
1655
2297
  }
@@ -1657,11 +2299,11 @@ var validate_default = defineCommand10({
1657
2299
 
1658
2300
  // src/ui/banner.ts
1659
2301
  import figlet from "figlet";
1660
- import pc13 from "picocolors";
2302
+ import pc14 from "picocolors";
1661
2303
  // package.json
1662
2304
  var package_default = {
1663
2305
  name: "contribute-now",
1664
- version: "0.2.0-dev.70284d0",
2306
+ version: "0.2.0-dev.d4b7ede",
1665
2307
  description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
1666
2308
  type: "module",
1667
2309
  bin: {
@@ -1705,6 +2347,7 @@ var package_default = {
1705
2347
  url: "git+https://github.com/warengonzaga/contribute-now.git"
1706
2348
  },
1707
2349
  dependencies: {
2350
+ "@clack/prompts": "^1.0.1",
1708
2351
  "@github/copilot-sdk": "^0.1.25",
1709
2352
  "@wgtechlabs/log-engine": "^2.3.1",
1710
2353
  citty: "^0.1.6",
@@ -1734,14 +2377,14 @@ function getAuthor() {
1734
2377
  return typeof package_default.author === "string" ? package_default.author : "unknown";
1735
2378
  }
1736
2379
  function showBanner(showLinks = false) {
1737
- console.log(pc13.cyan(`
2380
+ console.log(pc14.cyan(`
1738
2381
  ${LOGO}`));
1739
- console.log(` ${pc13.dim(`v${getVersion()}`)} ${pc13.dim("—")} ${pc13.dim(`Built by ${getAuthor()}`)}`);
2382
+ console.log(` ${pc14.dim(`v${getVersion()}`)} ${pc14.dim("—")} ${pc14.dim(`Built by ${getAuthor()}`)}`);
1740
2383
  if (showLinks) {
1741
2384
  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")}`);
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")}`);
1745
2388
  }
1746
2389
  console.log();
1747
2390
  }
@@ -1780,4 +2423,6 @@ var main = defineCommand11({
1780
2423
  }
1781
2424
  }
1782
2425
  });
1783
- runMain(main);
2426
+ runMain(main).then(() => {
2427
+ process.exit(0);
2428
+ });