contribute-now 0.1.2 → 0.2.0-dev.2621ffa

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 (3) hide show
  1. package/README.md +162 -133
  2. package/dist/index.js +2876 -712
  3. package/package.json +3 -3
package/dist/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
2
4
 
3
5
  // src/index.ts
4
- import { defineCommand as defineCommand9, runMain } from "citty";
6
+ import { defineCommand as defineCommand14, runMain } from "citty";
5
7
 
6
- // src/commands/clean.ts
8
+ // src/commands/branch.ts
7
9
  import { defineCommand } from "citty";
8
- import pc3 from "picocolors";
10
+ import pc2 from "picocolors";
9
11
 
10
12
  // src/utils/config.ts
11
13
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
@@ -14,13 +16,20 @@ var CONFIG_FILENAME = ".contributerc.json";
14
16
  function getConfigPath(cwd = process.cwd()) {
15
17
  return join(cwd, CONFIG_FILENAME);
16
18
  }
19
+ function configExists(cwd = process.cwd()) {
20
+ return existsSync(getConfigPath(cwd));
21
+ }
17
22
  function readConfig(cwd = process.cwd()) {
18
23
  const path = getConfigPath(cwd);
19
24
  if (!existsSync(path))
20
25
  return null;
21
26
  try {
22
27
  const raw = readFileSync(path, "utf-8");
23
- return JSON.parse(raw);
28
+ const parsed = JSON.parse(raw);
29
+ 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") {
30
+ return null;
31
+ }
32
+ return parsed;
24
33
  } catch {
25
34
  return null;
26
35
  }
@@ -44,78 +53,26 @@ function isGitignored(cwd = process.cwd()) {
44
53
  }
45
54
  function getDefaultConfig() {
46
55
  return {
56
+ workflow: "clean-flow",
47
57
  role: "contributor",
48
58
  mainBranch: "main",
49
59
  devBranch: "dev",
50
60
  upstream: "upstream",
51
61
  origin: "origin",
52
- branchPrefixes: ["feature", "fix", "docs", "chore", "test", "refactor"]
62
+ branchPrefixes: ["feature", "fix", "docs", "chore", "test", "refactor"],
63
+ commitConvention: "clean-commit"
53
64
  };
54
65
  }
55
66
 
56
- // src/utils/confirm.ts
57
- import pc from "picocolors";
58
- async function confirmPrompt(message) {
59
- console.log(`
60
- ${message}`);
61
- process.stdout.write(`${pc.dim("Continue? [y/N] ")}`);
62
- const response = await new Promise((resolve) => {
63
- process.stdin.setEncoding("utf-8");
64
- process.stdin.once("data", (data) => {
65
- process.stdin.pause();
66
- resolve(data.toString().trim());
67
- });
68
- process.stdin.resume();
69
- });
70
- if (response.toLowerCase() !== "y") {
71
- console.log(pc.yellow("Aborted."));
72
- return false;
73
- }
74
- return true;
75
- }
76
- async function selectPrompt(message, choices) {
77
- console.log(`
78
- ${message}`);
79
- choices.forEach((choice, i) => {
80
- console.log(` ${pc.dim(`${i + 1}.`)} ${choice}`);
81
- });
82
- process.stdout.write(pc.dim(`Enter number [1-${choices.length}]: `));
83
- const response = await new Promise((resolve) => {
84
- process.stdin.setEncoding("utf-8");
85
- process.stdin.once("data", (data) => {
86
- process.stdin.pause();
87
- resolve(data.toString().trim());
88
- });
89
- process.stdin.resume();
90
- });
91
- const index = Number.parseInt(response, 10) - 1;
92
- if (index >= 0 && index < choices.length) {
93
- return choices[index];
94
- }
95
- return choices[0];
96
- }
97
- async function inputPrompt(message, defaultValue) {
98
- const hint = defaultValue ? ` ${pc.dim(`[${defaultValue}]`)}` : "";
99
- process.stdout.write(`
100
- ${message}${hint}: `);
101
- const response = await new Promise((resolve) => {
102
- process.stdin.setEncoding("utf-8");
103
- process.stdin.once("data", (data) => {
104
- process.stdin.pause();
105
- resolve(data.toString().trim());
106
- });
107
- process.stdin.resume();
108
- });
109
- return response || defaultValue || "";
110
- }
111
-
112
67
  // src/utils/git.ts
113
68
  import { execFile as execFileCb } from "node:child_process";
69
+ import { readFileSync as readFileSync2 } from "node:fs";
70
+ import { join as join2 } from "node:path";
114
71
  function run(args) {
115
72
  return new Promise((resolve) => {
116
73
  execFileCb("git", args, (error, stdout, stderr) => {
117
74
  resolve({
118
- exitCode: error ? error.code === "ENOENT" ? 127 : error.code != null ? Number(error.code) : 1 : 0,
75
+ exitCode: error ? error.code === "ENOENT" ? 127 : error.status ?? 1 : 0,
119
76
  stdout: stdout ?? "",
120
77
  stderr: stderr ?? ""
121
78
  });
@@ -157,7 +114,7 @@ async function fetchRemote(remote) {
157
114
  async function fetchAll() {
158
115
  return run(["fetch", "--all", "--quiet"]);
159
116
  }
160
- async function checkoutBranch(branch) {
117
+ async function checkoutBranch2(branch) {
161
118
  return run(["checkout", branch]);
162
119
  }
163
120
  async function createBranch(branch, from) {
@@ -167,8 +124,12 @@ async function createBranch(branch, from) {
167
124
  async function resetHard(ref) {
168
125
  return run(["reset", "--hard", ref]);
169
126
  }
170
- async function pushForceWithLease(remote, branch) {
171
- return run(["push", "--force-with-lease", remote, branch]);
127
+ async function updateLocalBranch(branch, target) {
128
+ const current = await getCurrentBranch();
129
+ if (current === branch) {
130
+ return resetHard(target);
131
+ }
132
+ return run(["branch", "-f", branch, target]);
172
133
  }
173
134
  async function pushSetUpstream(remote, branch) {
174
135
  return run(["push", "-u", remote, branch]);
@@ -176,6 +137,61 @@ async function pushSetUpstream(remote, branch) {
176
137
  async function rebase(branch) {
177
138
  return run(["rebase", branch]);
178
139
  }
140
+ async function getUpstreamRef() {
141
+ const { exitCode, stdout } = await run([
142
+ "rev-parse",
143
+ "--abbrev-ref",
144
+ "--symbolic-full-name",
145
+ "@{u}"
146
+ ]);
147
+ if (exitCode !== 0)
148
+ return null;
149
+ return stdout.trim() || null;
150
+ }
151
+ async function unsetUpstream() {
152
+ return run(["branch", "--unset-upstream"]);
153
+ }
154
+ async function rebaseOnto(newBase, oldBase) {
155
+ return run(["rebase", "--onto", newBase, oldBase]);
156
+ }
157
+ async function getMergeBase(ref1, ref2) {
158
+ const { exitCode, stdout } = await run(["merge-base", ref1, ref2]);
159
+ if (exitCode !== 0)
160
+ return null;
161
+ return stdout.trim() || null;
162
+ }
163
+ async function getCommitHash(ref) {
164
+ const { exitCode, stdout } = await run(["rev-parse", ref]);
165
+ if (exitCode !== 0)
166
+ return null;
167
+ return stdout.trim() || null;
168
+ }
169
+ async function determineRebaseStrategy(currentBranch, syncRef) {
170
+ const upstreamRef = await getUpstreamRef();
171
+ if (!upstreamRef) {
172
+ return { strategy: "plain" };
173
+ }
174
+ const upstreamHash = await getCommitHash(upstreamRef);
175
+ if (!upstreamHash) {
176
+ return { strategy: "plain" };
177
+ }
178
+ const slashIdx = upstreamRef.indexOf("/");
179
+ const upstreamBranchName = slashIdx !== -1 ? upstreamRef.slice(slashIdx + 1) : upstreamRef;
180
+ if (upstreamBranchName === currentBranch) {
181
+ return { strategy: "plain" };
182
+ }
183
+ const [forkFromUpstream, forkFromSync] = await Promise.all([
184
+ getMergeBase("HEAD", upstreamRef),
185
+ getMergeBase("HEAD", syncRef)
186
+ ]);
187
+ if (forkFromUpstream && forkFromSync && forkFromUpstream === forkFromSync) {
188
+ return { strategy: "plain" };
189
+ }
190
+ if (forkFromUpstream) {
191
+ return { strategy: "onto", ontoOldBase: forkFromUpstream };
192
+ }
193
+ return { strategy: "plain" };
194
+ }
179
195
  async function getStagedDiff() {
180
196
  const { stdout } = await run(["diff", "--cached"]);
181
197
  return stdout;
@@ -191,8 +207,16 @@ async function getChangedFiles() {
191
207
  const { exitCode, stdout } = await run(["status", "--porcelain"]);
192
208
  if (exitCode !== 0)
193
209
  return [];
194
- return stdout.trim().split(`
195
- `).filter(Boolean).map((l) => l.slice(3));
210
+ return stdout.trimEnd().split(`
211
+ `).filter(Boolean).map((l) => {
212
+ const line = l.replace(/\r$/, "");
213
+ const match = line.match(/^..\s+(.*)/);
214
+ if (!match)
215
+ return "";
216
+ const file = match[1];
217
+ const renameIdx = file.indexOf(" -> ");
218
+ return renameIdx !== -1 ? file.slice(renameIdx + 4) : file;
219
+ }).filter(Boolean);
196
220
  }
197
221
  async function getDivergence(branch, base) {
198
222
  const { exitCode, stdout } = await run([
@@ -216,9 +240,38 @@ async function getMergedBranches(base) {
216
240
  return stdout.trim().split(`
217
241
  `).map((b) => b.replace(/^\*?\s+/, "").trim()).filter(Boolean);
218
242
  }
243
+ async function getGoneBranches() {
244
+ const { exitCode, stdout } = await run(["branch", "-vv"]);
245
+ if (exitCode !== 0)
246
+ return [];
247
+ return stdout.trimEnd().split(`
248
+ `).filter((line) => line.includes(": gone]")).map((line) => line.replace(/^\*?\s+/, "").split(/\s+/)[0]).filter(Boolean);
249
+ }
219
250
  async function deleteBranch(branch) {
220
251
  return run(["branch", "-d", branch]);
221
252
  }
253
+ async function forceDeleteBranch(branch) {
254
+ return run(["branch", "-D", branch]);
255
+ }
256
+ async function renameBranch(oldName, newName) {
257
+ return run(["branch", "-m", oldName, newName]);
258
+ }
259
+ async function hasLocalWork(remote, branch) {
260
+ const uncommitted = await hasUncommittedChanges();
261
+ const trackingRef = `${remote}/${branch}`;
262
+ const { exitCode, stdout } = await run(["rev-list", "--count", `${trackingRef}..${branch}`]);
263
+ const unpushedCommits = exitCode === 0 ? Number.parseInt(stdout.trim(), 10) || 0 : 0;
264
+ return { uncommitted, unpushedCommits };
265
+ }
266
+ async function deleteRemoteBranch(remote, branch) {
267
+ return run(["push", remote, "--delete", branch]);
268
+ }
269
+ async function mergeSquash(branch) {
270
+ return run(["merge", "--squash", branch]);
271
+ }
272
+ async function pushBranch(remote, branch) {
273
+ return run(["push", remote, branch]);
274
+ }
222
275
  async function pruneRemote(remote) {
223
276
  return run(["remote", "prune", remote]);
224
277
  }
@@ -236,10 +289,164 @@ async function getLog(base, head) {
236
289
  return stdout.trim().split(`
237
290
  `).filter(Boolean);
238
291
  }
292
+ async function pullBranch(remote, branch) {
293
+ return run(["pull", remote, branch]);
294
+ }
295
+ async function stageFiles(files) {
296
+ return run(["add", "--", ...files]);
297
+ }
298
+ async function unstageFiles(files) {
299
+ return run(["reset", "HEAD", "--", ...files]);
300
+ }
301
+ async function stageAll() {
302
+ return run(["add", "-A"]);
303
+ }
304
+ async function getFullDiffForFiles(files) {
305
+ const [unstaged, staged, untracked] = await Promise.all([
306
+ run(["diff", "--", ...files]),
307
+ run(["diff", "--cached", "--", ...files]),
308
+ getUntrackedFiles()
309
+ ]);
310
+ const parts = [staged.stdout, unstaged.stdout].filter(Boolean);
311
+ const untrackedSet = new Set(untracked);
312
+ const MAX_FILE_CONTENT = 2000;
313
+ for (const file of files) {
314
+ if (untrackedSet.has(file)) {
315
+ try {
316
+ const content = readFileSync2(join2(process.cwd(), file), "utf-8");
317
+ const truncated = content.length > MAX_FILE_CONTENT ? `${content.slice(0, MAX_FILE_CONTENT)}
318
+ ... (truncated)` : content;
319
+ const lines = truncated.split(`
320
+ `).map((l) => `+${l}`);
321
+ parts.push(`diff --git a/${file} b/${file}
322
+ new file
323
+ --- /dev/null
324
+ +++ b/${file}
325
+ ${lines.join(`
326
+ `)}`);
327
+ } catch {}
328
+ }
329
+ }
330
+ return parts.join(`
331
+ `);
332
+ }
333
+ async function getUntrackedFiles() {
334
+ const { exitCode, stdout } = await run(["ls-files", "--others", "--exclude-standard"]);
335
+ if (exitCode !== 0)
336
+ return [];
337
+ return stdout.trim().split(`
338
+ `).filter(Boolean);
339
+ }
340
+ async function getFileStatus() {
341
+ const { exitCode, stdout } = await run(["status", "--porcelain"]);
342
+ if (exitCode !== 0)
343
+ return { staged: [], modified: [], untracked: [] };
344
+ const result = { staged: [], modified: [], untracked: [] };
345
+ const STATUS_LABELS = {
346
+ A: "new file",
347
+ M: "modified",
348
+ D: "deleted",
349
+ R: "renamed",
350
+ C: "copied",
351
+ T: "type changed"
352
+ };
353
+ for (const raw of stdout.trimEnd().split(`
354
+ `).filter(Boolean)) {
355
+ const line = raw.replace(/\r$/, "");
356
+ const indexStatus = line[0];
357
+ const workTreeStatus = line[1];
358
+ const pathPart = line.slice(3);
359
+ const renameIdx = pathPart.indexOf(" -> ");
360
+ const file = renameIdx !== -1 ? pathPart.slice(renameIdx + 4) : pathPart;
361
+ if (indexStatus === "?" && workTreeStatus === "?") {
362
+ result.untracked.push(file);
363
+ continue;
364
+ }
365
+ if (indexStatus && indexStatus !== " " && indexStatus !== "?") {
366
+ result.staged.push({ file, status: STATUS_LABELS[indexStatus] ?? indexStatus });
367
+ }
368
+ if (workTreeStatus && workTreeStatus !== " " && workTreeStatus !== "?") {
369
+ result.modified.push({ file, status: STATUS_LABELS[workTreeStatus] ?? workTreeStatus });
370
+ }
371
+ }
372
+ return result;
373
+ }
374
+ async function getLogGraph(options) {
375
+ const count = options?.count ?? 20;
376
+ const args = [
377
+ "log",
378
+ "--oneline",
379
+ "--graph",
380
+ "--decorate",
381
+ `--max-count=${count}`,
382
+ "--color=never"
383
+ ];
384
+ if (options?.all) {
385
+ args.push("--all");
386
+ }
387
+ if (options?.branch) {
388
+ args.push(options.branch);
389
+ }
390
+ const { exitCode, stdout } = await run(args);
391
+ if (exitCode !== 0)
392
+ return [];
393
+ return stdout.trimEnd().split(`
394
+ `);
395
+ }
396
+ async function getLogEntries(options) {
397
+ const count = options?.count ?? 20;
398
+ const args = [
399
+ "log",
400
+ `--format=%h||%s||%D`,
401
+ `--max-count=${count}`
402
+ ];
403
+ if (options?.all) {
404
+ args.push("--all");
405
+ }
406
+ if (options?.branch) {
407
+ args.push(options.branch);
408
+ }
409
+ const { exitCode, stdout } = await run(args);
410
+ if (exitCode !== 0)
411
+ return [];
412
+ return stdout.trimEnd().split(`
413
+ `).filter(Boolean).map((line) => {
414
+ const [hash = "", subject = "", refs = ""] = line.split("||");
415
+ return { hash: hash.trim(), subject: subject.trim(), refs: refs.trim() };
416
+ });
417
+ }
418
+ async function getLocalBranches() {
419
+ const { exitCode, stdout } = await run(["branch", "-vv", "--no-color"]);
420
+ if (exitCode !== 0)
421
+ return [];
422
+ return stdout.trimEnd().split(`
423
+ `).filter(Boolean).map((line) => {
424
+ const isCurrent = line.startsWith("*");
425
+ const trimmed = line.slice(2);
426
+ const nameMatch = trimmed.match(/^(\S+)/);
427
+ const name = nameMatch?.[1] ?? "";
428
+ const upstreamMatch = trimmed.match(/\[([^\]]+)\]/);
429
+ let upstream = null;
430
+ let gone = false;
431
+ if (upstreamMatch) {
432
+ const bracketContent = upstreamMatch[1];
433
+ gone = bracketContent.includes(": gone");
434
+ upstream = bracketContent.split(":")[0].trim();
435
+ }
436
+ return { name, isCurrent, upstream, gone };
437
+ }).filter((b) => b.name.length > 0);
438
+ }
439
+ async function getRemoteBranches() {
440
+ const { exitCode, stdout } = await run(["branch", "-r", "--no-color"]);
441
+ if (exitCode !== 0)
442
+ return [];
443
+ return stdout.trimEnd().split(`
444
+ `).map((line) => line.trim()).filter((line) => line.length > 0 && !line.includes(" -> "));
445
+ }
239
446
 
240
447
  // src/utils/logger.ts
241
448
  import { LogEngine, LogMode } from "@wgtechlabs/log-engine";
242
- import pc2 from "picocolors";
449
+ import pc from "picocolors";
243
450
  LogEngine.configure({
244
451
  mode: LogMode.INFO,
245
452
  format: {
@@ -262,20 +469,73 @@ function info(msg) {
262
469
  }
263
470
  function heading(msg) {
264
471
  console.log(`
265
- ${pc2.bold(msg)}`);
472
+ ${pc.bold(msg)}`);
266
473
  }
267
474
 
268
- // src/commands/clean.ts
269
- var clean_default = defineCommand({
475
+ // src/utils/workflow.ts
476
+ var WORKFLOW_DESCRIPTIONS = {
477
+ "clean-flow": "Clean Flow — main + dev, squash features into dev, merge dev into main",
478
+ "github-flow": "GitHub Flow — main + feature branches, squash/merge into main",
479
+ "git-flow": "Git Flow — main + develop + release + hotfix branches"
480
+ };
481
+ function getBaseBranch(config) {
482
+ switch (config.workflow) {
483
+ case "clean-flow":
484
+ case "git-flow":
485
+ return config.devBranch ?? "dev";
486
+ case "github-flow":
487
+ return config.mainBranch;
488
+ }
489
+ }
490
+ function hasDevBranch(workflow) {
491
+ return workflow === "clean-flow" || workflow === "git-flow";
492
+ }
493
+ function getSyncSource(config) {
494
+ const { workflow, role, mainBranch, origin, upstream } = config;
495
+ const devBranch = config.devBranch ?? "dev";
496
+ switch (workflow) {
497
+ case "clean-flow":
498
+ if (role === "contributor") {
499
+ return { remote: upstream, ref: `${upstream}/${devBranch}`, strategy: "pull" };
500
+ }
501
+ return { remote: origin, ref: `${origin}/${devBranch}`, strategy: "pull" };
502
+ case "github-flow":
503
+ if (role === "contributor") {
504
+ return { remote: upstream, ref: `${upstream}/${mainBranch}`, strategy: "pull" };
505
+ }
506
+ return { remote: origin, ref: `${origin}/${mainBranch}`, strategy: "pull" };
507
+ case "git-flow":
508
+ if (role === "contributor") {
509
+ return { remote: upstream, ref: `${upstream}/${devBranch}`, strategy: "pull" };
510
+ }
511
+ return { remote: origin, ref: `${origin}/${devBranch}`, strategy: "pull" };
512
+ }
513
+ }
514
+ function getProtectedBranches(config) {
515
+ const branches = [config.mainBranch];
516
+ if (hasDevBranch(config.workflow) && config.devBranch) {
517
+ branches.push(config.devBranch);
518
+ }
519
+ return branches;
520
+ }
521
+
522
+ // src/commands/branch.ts
523
+ var branch_default = defineCommand({
270
524
  meta: {
271
- name: "clean",
272
- description: "Delete merged branches and prune remote refs"
525
+ name: "branch",
526
+ description: "List branches with workflow-aware labels and status"
273
527
  },
274
528
  args: {
275
- yes: {
529
+ all: {
276
530
  type: "boolean",
277
- alias: "y",
278
- description: "Skip confirmation prompt",
531
+ alias: "a",
532
+ description: "Show both local and remote branches",
533
+ default: false
534
+ },
535
+ remote: {
536
+ type: "boolean",
537
+ alias: "r",
538
+ description: "Show only remote branches",
279
539
  default: false
280
540
  }
281
541
  },
@@ -285,118 +545,277 @@ var clean_default = defineCommand({
285
545
  process.exit(1);
286
546
  }
287
547
  const config = readConfig();
288
- if (!config) {
289
- error("No .contributerc.json found. Run `contrib setup` first.");
290
- process.exit(1);
291
- }
292
- const { mainBranch, devBranch, origin } = config;
548
+ const protectedBranches = config ? getProtectedBranches(config) : ["main", "master"];
293
549
  const currentBranch = await getCurrentBranch();
294
- heading("\uD83E\uDDF9 contrib clean");
295
- const mergedBranches = await getMergedBranches(devBranch);
296
- const protected_ = new Set([mainBranch, devBranch, currentBranch ?? ""]);
297
- const candidates = mergedBranches.filter((b) => !protected_.has(b));
298
- if (candidates.length === 0) {
299
- info("No merged branches to clean up.");
300
- } else {
301
- console.log(`
302
- ${pc3.bold("Branches to delete:")}`);
303
- for (const b of candidates) {
304
- console.log(` ${pc3.dim("•")} ${b}`);
305
- }
306
- console.log();
307
- const ok = args.yes || await confirmPrompt(`Delete ${pc3.bold(String(candidates.length))} merged branch${candidates.length !== 1 ? "es" : ""}?`);
308
- if (!ok) {
309
- info("Skipped branch deletion.");
550
+ const showRemoteOnly = args.remote;
551
+ const showAll = args.all;
552
+ heading("\uD83C\uDF3F branches");
553
+ console.log();
554
+ if (!showRemoteOnly) {
555
+ const localBranches = await getLocalBranches();
556
+ if (localBranches.length === 0) {
557
+ console.log(pc2.dim(" No local branches found."));
310
558
  } else {
311
- for (const branch of candidates) {
312
- const result = await deleteBranch(branch);
313
- if (result.exitCode === 0) {
314
- success(` Deleted ${pc3.bold(branch)}`);
559
+ console.log(` ${pc2.bold("Local")}`);
560
+ console.log();
561
+ for (const branch of localBranches) {
562
+ const parts = [];
563
+ if (branch.isCurrent) {
564
+ parts.push(pc2.green("* "));
315
565
  } else {
316
- warn(` Failed to delete ${branch}: ${result.stderr.trim()}`);
566
+ parts.push(" ");
567
+ }
568
+ const nameStr = colorBranchName(branch.name, protectedBranches, currentBranch);
569
+ parts.push(nameStr.padEnd(30));
570
+ if (branch.gone) {
571
+ parts.push(pc2.red(" ✗ remote gone"));
572
+ } else if (branch.upstream) {
573
+ parts.push(pc2.dim(` → ${branch.upstream}`));
574
+ } else {
575
+ parts.push(pc2.dim(" (no remote)"));
576
+ }
577
+ const labels = getBranchLabels(branch.name, protectedBranches, config);
578
+ if (labels.length > 0) {
579
+ parts.push(` ${labels.join(" ")}`);
317
580
  }
581
+ console.log(` ${parts.join("")}`);
318
582
  }
319
583
  }
320
584
  }
321
- info(`Pruning ${origin} remote refs...`);
322
- const pruneResult = await pruneRemote(origin);
323
- if (pruneResult.exitCode === 0) {
324
- success(`✅ Pruned ${origin} remote refs.`);
325
- } else {
326
- warn(`Could not prune remote: ${pruneResult.stderr.trim()}`);
585
+ if (showRemoteOnly || showAll) {
586
+ const remoteBranches = await getRemoteBranches();
587
+ if (!showRemoteOnly) {
588
+ console.log();
589
+ }
590
+ if (remoteBranches.length === 0) {
591
+ console.log(pc2.dim(" No remote branches found."));
592
+ } else {
593
+ const grouped = groupByRemote(remoteBranches);
594
+ for (const [remote, branches] of Object.entries(grouped)) {
595
+ console.log(` ${pc2.bold(`Remote: ${remote}`)}`);
596
+ console.log();
597
+ for (const fullRef of branches) {
598
+ const branchName = fullRef.slice(remote.length + 1);
599
+ const nameStr = colorBranchName(branchName, protectedBranches, currentBranch);
600
+ const remotePrefix = pc2.dim(`${remote}/`);
601
+ console.log(` ${remotePrefix}${nameStr}`);
602
+ }
603
+ console.log();
604
+ }
605
+ }
606
+ }
607
+ const tips = [];
608
+ if (!showAll && !showRemoteOnly) {
609
+ tips.push(`Use ${pc2.bold("contrib branch -a")} to include remote branches`);
610
+ }
611
+ if (!showRemoteOnly) {
612
+ tips.push(`Use ${pc2.bold("contrib start")} to create a new feature branch`);
613
+ tips.push(`Use ${pc2.bold("contrib clean")} to remove merged/stale branches`);
614
+ }
615
+ if (tips.length > 0) {
616
+ console.log(` ${pc2.dim("\uD83D\uDCA1 Tip:")}`);
617
+ for (const tip of tips) {
618
+ console.log(` ${pc2.dim(tip)}`);
619
+ }
327
620
  }
621
+ console.log();
328
622
  }
329
623
  });
624
+ function colorBranchName(name, protectedBranches, currentBranch) {
625
+ if (name === currentBranch) {
626
+ return pc2.bold(pc2.green(name));
627
+ }
628
+ if (protectedBranches.includes(name)) {
629
+ return pc2.bold(pc2.red(name));
630
+ }
631
+ return name;
632
+ }
633
+ function getBranchLabels(name, protectedBranches, config) {
634
+ const labels = [];
635
+ if (protectedBranches.includes(name)) {
636
+ labels.push(pc2.dim(pc2.red("[protected]")));
637
+ }
638
+ if (config) {
639
+ if (name === config.mainBranch) {
640
+ labels.push(pc2.dim(pc2.cyan("[main]")));
641
+ }
642
+ if (config.devBranch && name === config.devBranch) {
643
+ labels.push(pc2.dim(pc2.cyan("[dev]")));
644
+ }
645
+ }
646
+ return labels;
647
+ }
648
+ function groupByRemote(branches) {
649
+ const grouped = {};
650
+ for (const ref of branches) {
651
+ const slashIdx = ref.indexOf("/");
652
+ const remote = slashIdx !== -1 ? ref.slice(0, slashIdx) : "unknown";
653
+ if (!grouped[remote]) {
654
+ grouped[remote] = [];
655
+ }
656
+ grouped[remote].push(ref);
657
+ }
658
+ return grouped;
659
+ }
330
660
 
331
- // src/commands/commit.ts
661
+ // src/commands/clean.ts
332
662
  import { defineCommand as defineCommand2 } from "citty";
333
- import pc4 from "picocolors";
663
+ import pc5 from "picocolors";
334
664
 
335
- // src/utils/copilot.ts
336
- import { CopilotClient } from "@github/copilot-sdk";
337
- var CLEAN_COMMIT_SYSTEM_PROMPT = `You are a git commit message generator. Generate a Clean Commit message following this exact format:
338
- <emoji> <type>[!][(<scope>)]: <description>
665
+ // src/utils/branch.ts
666
+ var DEFAULT_PREFIXES = ["feature", "fix", "docs", "chore", "test", "refactor"];
667
+ function hasPrefix(branchName, prefixes = DEFAULT_PREFIXES) {
668
+ return prefixes.some((p) => branchName.startsWith(`${p}/`));
669
+ }
670
+ function formatBranchName(prefix, name) {
671
+ const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
672
+ return `${prefix}/${sanitized}`;
673
+ }
674
+ function isValidBranchName(name) {
675
+ return /^[a-zA-Z0-9._/-]+$/.test(name) && !name.startsWith("/") && !name.endsWith("/");
676
+ }
677
+ function looksLikeNaturalLanguage(input) {
678
+ return input.includes(" ") && !input.includes("/");
679
+ }
339
680
 
340
- Emoji and type table:
341
- \uD83D\uDCE6 new – new features, files, or capabilities
342
- \uD83D\uDD27 update – changes, refactoring, improvements
343
- \uD83D\uDDD1️ remove – removing code, files, or dependencies
344
- \uD83D\uDD12 security – security fixes or patches
345
- ⚙️ setup – configs, CI/CD, tooling, build systems
346
- ☕ chore – maintenance, dependency updates
347
- \uD83E\uDDEA test – adding or updating tests
348
- \uD83D\uDCD6 docs – documentation changes
349
- \uD83D\uDE80 release – version releases
681
+ // src/utils/confirm.ts
682
+ import * as clack from "@clack/prompts";
683
+ import pc3 from "picocolors";
684
+ function handleCancel(value) {
685
+ if (clack.isCancel(value)) {
686
+ clack.cancel("Cancelled.");
687
+ process.exit(0);
688
+ }
689
+ }
690
+ async function confirmPrompt(message) {
691
+ const result = await clack.confirm({ message });
692
+ handleCancel(result);
693
+ return result;
694
+ }
695
+ async function selectPrompt(message, choices) {
696
+ const result = await clack.select({
697
+ message,
698
+ options: choices.map((choice) => ({ value: choice, label: choice }))
699
+ });
700
+ handleCancel(result);
701
+ return result;
702
+ }
703
+ async function inputPrompt(message, defaultValue) {
704
+ const result = await clack.text({
705
+ message,
706
+ placeholder: defaultValue,
707
+ defaultValue
708
+ });
709
+ handleCancel(result);
710
+ return result || defaultValue || "";
711
+ }
712
+ async function multiSelectPrompt(message, choices) {
713
+ const result = await clack.multiselect({
714
+ message: `${message} ${pc3.dim("(space to toggle, enter to confirm)")}`,
715
+ options: choices.map((choice) => ({ value: choice, label: choice })),
716
+ required: false
717
+ });
718
+ handleCancel(result);
719
+ return result;
720
+ }
350
721
 
351
- Rules:
352
- - Breaking change (!) only for: new, update, remove, security
353
- - Description: concise, imperative mood, max 72 chars
354
- - Scope: optional, camelCase or kebab-case component name
355
- - Return ONLY the commit message line, nothing else
722
+ // src/utils/copilot.ts
723
+ import { CopilotClient } from "@github/copilot-sdk";
724
+ var CONVENTIONAL_COMMIT_SYSTEM_PROMPT = `Git commit message generator. Format: <type>[!][(<scope>)]: <description>
725
+ Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
726
+ 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.
727
+ Examples: feat: add user auth | fix(auth): resolve token expiry | feat!: redesign auth API`;
728
+ var CLEAN_COMMIT_SYSTEM_PROMPT = `Git commit message generator. EXACT format: <emoji> <type>[!][ (<scope>)]: <description>
729
+ Spacing: EMOJI SPACE TYPE [SPACE OPENPAREN SCOPE CLOSEPAREN] COLON SPACE DESCRIPTION
730
+ Types: \uD83D\uDCE6 new, \uD83D\uDD27 update, \uD83D\uDDD1️ remove, \uD83D\uDD12 security, ⚙️ setup, ☕ chore, \uD83E\uDDEA test, \uD83D\uDCD6 docs, \uD83D\uDE80 release
731
+ Rules: breaking (!) only for new/update/remove/security; imperative mood; max 72 chars; lowercase start; scope optional. Return ONLY the message line.
732
+ Correct: \uD83D\uDCE6 new: add user auth | \uD83D\uDD27 update (api): improve error handling | ⚙️ setup (ci): configure github actions
733
+ WRONG: ⚙️setup(ci): ... | \uD83D\uDD27 update(api): ... ← always space before scope parenthesis`;
734
+ function getGroupingSystemPrompt(convention) {
735
+ const conventionBlock = convention === "conventional" ? `Use Conventional Commit format: <type>[(<scope>)]: <description>
736
+ Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert` : `Use Clean Commit format: <emoji> <type>[!][ (<scope>)]: <description>
737
+ Emoji/type table:
738
+ \uD83D\uDCE6 new, \uD83D\uDD27 update, \uD83D\uDDD1️ remove, \uD83D\uDD12 security, ⚙️ setup, ☕ chore, \uD83E\uDDEA test, \uD83D\uDCD6 docs, \uD83D\uDE80 release`;
739
+ return `You are a smart commit grouping assistant. Given a list of changed files and their diffs, group related changes into logical atomic commits.
356
740
 
357
- Examples:
358
- \uD83D\uDCE6 new: user authentication system
359
- \uD83D\uDD27 update (api): improve error handling
360
- ⚙️ setup (ci): configure github actions workflow
361
- \uD83D\uDCE6 new!: completely redesign authentication system`;
362
- var BRANCH_NAME_SYSTEM_PROMPT = `You are a git branch name generator. Convert natural language descriptions into proper git branch names.
741
+ ${conventionBlock}
363
742
 
364
- Format: <prefix>/<kebab-case-name>
365
- Prefixes: feature, fix, docs, chore, test, refactor
743
+ Return a JSON array of commit groups with this EXACT structure (no markdown fences, no explanation):
744
+ [
745
+ {
746
+ "files": ["path/to/file1.ts", "path/to/file2.ts"],
747
+ "message": "<commit message following the convention above>"
748
+ }
749
+ ]
366
750
 
367
751
  Rules:
368
- - Use lowercase kebab-case for the name part
369
- - Keep it short and descriptive (2-5 words max)
370
- - Return ONLY the branch name, nothing else
371
-
372
- Examples:
373
- Input: "fix the login timeout bug" → fix/login-timeout
374
- Input: "add user profile page" → feature/user-profile-page
375
- Input: "update readme documentation" → docs/update-readme`;
376
- var PR_DESCRIPTION_SYSTEM_PROMPT = `You are a GitHub pull request description generator. Create a clear, structured PR description.
377
-
378
- Return a JSON object with this exact structure:
379
- {
380
- "title": "Brief PR title (50 chars max)",
381
- "body": "## Summary\\n...\\n\\n## Changes\\n...\\n\\n## Test Plan\\n..."
752
+ - Group files that are logically related (e.g. a utility and its tests, a feature and its types)
753
+ - Each group should represent ONE logical change
754
+ - Every file must appear in exactly one group
755
+ - Commit messages must follow the convention, be concise, imperative, max 72 chars
756
+ - Order groups so foundational changes come first (types, utils) and consumers come after
757
+ - Return ONLY the JSON array, nothing else`;
382
758
  }
383
-
384
- Rules:
385
- - title: concise, present tense, describes what the PR does
386
- - body: markdown with Summary, Changes (bullet list), and Test Plan sections
387
- - Return ONLY the JSON object, no markdown fences, no extra text`;
388
- var CONFLICT_RESOLUTION_SYSTEM_PROMPT = `You are a git merge conflict resolution advisor. Analyze the conflict markers and provide guidance.
389
-
390
- Rules:
391
- - Explain what each side of the conflict contains
392
- - Suggest the most likely correct resolution strategy
393
- - Never auto-resolve — provide guidance only
394
- - Be concise and actionable`;
759
+ var BRANCH_NAME_SYSTEM_PROMPT = `You are a git branch name generator. Your ONLY job is to output a single git branch name. NOTHING ELSE.
760
+ Output format: <prefix>/<kebab-case-name>
761
+ Valid prefixes: feature, fix, docs, chore, test, refactor
762
+ Rules: lowercase, kebab-case, 2-5 words after the prefix, no punctuation.
763
+ CRITICAL: Output ONLY the branch name on a single line. No explanation. No markdown. No questions. No other text.
764
+ Examples: fix/login-timeout | feature/user-profile-page | docs/update-readme | chore/update-pr-title`;
765
+ 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..."}
766
+ IMPORTANT: The title must capture the overall theme or goal of the PR — NOT enumerate individual changes. Think: what problem does this PR solve or what capability does it add? Keep it focused and specific but high-level.`;
767
+ function getPRDescriptionSystemPrompt(convention) {
768
+ if (convention === "clean-commit") {
769
+ return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
770
+ CRITICAL: The PR title MUST follow the Clean Commit format exactly: <emoji> <type>: <description>
771
+ 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
772
+ Title examples: \uD83D\uDCE6 new: add user authentication | \uD83D\uDD27 update: improve error handling | \uD83D\uDDD1️ remove: drop legacy API
773
+ Rules: title follows convention, present tense, max 72 chars, describes the PR theme not individual commits; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
774
+ }
775
+ if (convention === "conventional") {
776
+ return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
777
+ CRITICAL: The PR title MUST follow Conventional Commits format: <type>[(<scope>)]: <description>
778
+ Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
779
+ Title examples: feat: add user authentication | fix(auth): resolve token expiry | docs: update contributing guide
780
+ Rules: title follows convention, present tense, max 72 chars, describes the PR theme not individual commits; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
781
+ }
782
+ return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
783
+ Rules: title concise present tense, describes the PR theme not individual commits; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
784
+ }
785
+ var CONFLICT_RESOLUTION_SYSTEM_PROMPT = `Git merge conflict advisor. Explain each side, suggest resolution strategy. Never auto-resolve — guidance only. Be concise and actionable.`;
786
+ function suppressSubprocessWarnings() {
787
+ process.env.NODE_NO_WARNINGS = "1";
788
+ }
789
+ function withTimeout(promise, ms) {
790
+ return new Promise((resolve, reject) => {
791
+ const timer = setTimeout(() => reject(new Error(`Copilot request timed out after ${ms / 1000}s`)), ms);
792
+ promise.then((val) => {
793
+ clearTimeout(timer);
794
+ resolve(val);
795
+ }, (err) => {
796
+ clearTimeout(timer);
797
+ reject(err);
798
+ });
799
+ });
800
+ }
801
+ var COPILOT_TIMEOUT_MS = 30000;
802
+ var COPILOT_LONG_TIMEOUT_MS = 90000;
395
803
  async function checkCopilotAvailable() {
396
- let client = null;
397
804
  try {
398
- client = new CopilotClient;
399
- await client.start();
805
+ const client = await getManagedClient();
806
+ try {
807
+ await client.ping();
808
+ } catch (err) {
809
+ const msg = err instanceof Error ? err.message : String(err);
810
+ if (msg.includes("auth") || msg.includes("token") || msg.includes("401") || msg.includes("403")) {
811
+ return "Copilot authentication failed. Run `gh auth login` to refresh your token.";
812
+ }
813
+ if (msg.includes("ECONNREFUSED") || msg.includes("timeout") || msg.includes("network")) {
814
+ return "Could not reach GitHub Copilot service. Check your internet connection.";
815
+ }
816
+ return `Copilot health check failed: ${msg}`;
817
+ }
818
+ return null;
400
819
  } catch (err) {
401
820
  const msg = err instanceof Error ? err.message : String(err);
402
821
  if (msg.includes("ENOENT") || msg.includes("not found")) {
@@ -404,61 +823,99 @@ async function checkCopilotAvailable() {
404
823
  }
405
824
  return `Failed to start Copilot service: ${msg}`;
406
825
  }
407
- try {
408
- await client.ping();
409
- } catch (err) {
410
- const msg = err instanceof Error ? err.message : String(err);
411
- if (msg.includes("auth") || msg.includes("token") || msg.includes("401") || msg.includes("403")) {
412
- return "Copilot authentication failed. Run `gh auth login` to refresh your token.";
413
- }
414
- if (msg.includes("ECONNREFUSED") || msg.includes("timeout") || msg.includes("network")) {
415
- return "Could not reach GitHub Copilot service. Check your internet connection.";
416
- }
417
- return `Copilot health check failed: ${msg}`;
418
- } finally {
419
- try {
420
- await client.stop();
421
- } catch {}
826
+ }
827
+ var _managedClient = null;
828
+ var _clientStarted = false;
829
+ async function getManagedClient() {
830
+ if (!_managedClient || !_clientStarted) {
831
+ suppressSubprocessWarnings();
832
+ _managedClient = new CopilotClient;
833
+ await _managedClient.start();
834
+ _clientStarted = true;
835
+ const cleanup = () => {
836
+ if (_managedClient && _clientStarted) {
837
+ try {
838
+ _managedClient.stop();
839
+ } catch {}
840
+ _clientStarted = false;
841
+ _managedClient = null;
842
+ }
843
+ };
844
+ process.once("exit", cleanup);
845
+ process.once("SIGINT", cleanup);
846
+ process.once("SIGTERM", cleanup);
422
847
  }
423
- return null;
848
+ return _managedClient;
424
849
  }
425
- async function callCopilot(systemMessage, userMessage, model) {
426
- const client = new CopilotClient;
427
- await client.start();
850
+ async function callCopilot(systemMessage, userMessage, model, timeoutMs = COPILOT_TIMEOUT_MS) {
851
+ const client = await getManagedClient();
852
+ const sessionConfig = {
853
+ systemMessage: { mode: "replace", content: systemMessage }
854
+ };
855
+ if (model)
856
+ sessionConfig.model = model;
857
+ const session = await client.createSession(sessionConfig);
428
858
  try {
429
- const sessionConfig = {
430
- systemMessage: { content: systemMessage }
431
- };
432
- if (model)
433
- sessionConfig.model = model;
434
- const session = await client.createSession(sessionConfig);
435
- try {
436
- const response = await session.sendAndWait({ content: userMessage });
437
- if (!response?.data?.content)
438
- return null;
439
- return response.data.content;
440
- } finally {
441
- await session.destroy();
442
- }
859
+ const response = await withTimeout(session.sendAndWait({ prompt: userMessage }), timeoutMs);
860
+ if (!response?.data?.content)
861
+ return null;
862
+ return response.data.content;
443
863
  } finally {
444
- await client.stop();
864
+ await session.destroy();
865
+ }
866
+ }
867
+ function getCommitSystemPrompt(convention) {
868
+ if (convention === "conventional")
869
+ return CONVENTIONAL_COMMIT_SYSTEM_PROMPT;
870
+ return CLEAN_COMMIT_SYSTEM_PROMPT;
871
+ }
872
+ function extractJson(raw) {
873
+ let text2 = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
874
+ if (text2.startsWith("[") || text2.startsWith("{"))
875
+ return text2;
876
+ const arrayStart = text2.indexOf("[");
877
+ const objStart = text2.indexOf("{");
878
+ let start;
879
+ let closeChar;
880
+ if (arrayStart === -1 && objStart === -1)
881
+ return text2;
882
+ if (arrayStart === -1) {
883
+ start = objStart;
884
+ closeChar = "}";
885
+ } else if (objStart === -1) {
886
+ start = arrayStart;
887
+ closeChar = "]";
888
+ } else if (arrayStart < objStart) {
889
+ start = arrayStart;
890
+ closeChar = "]";
891
+ } else {
892
+ start = objStart;
893
+ closeChar = "}";
894
+ }
895
+ const end = text2.lastIndexOf(closeChar);
896
+ if (end > start) {
897
+ text2 = text2.slice(start, end + 1);
445
898
  }
899
+ return text2;
446
900
  }
447
- async function generateCommitMessage(diff, stagedFiles, model) {
901
+ async function generateCommitMessage(diff, stagedFiles, model, convention = "clean-commit") {
448
902
  try {
903
+ const multiFileHint = stagedFiles.length > 1 ? `
904
+
905
+ 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.` : "";
449
906
  const userMessage = `Generate a commit message for these staged changes:
450
907
 
451
908
  Files: ${stagedFiles.join(", ")}
452
909
 
453
910
  Diff:
454
- ${diff.slice(0, 4000)}`;
455
- const result = await callCopilot(CLEAN_COMMIT_SYSTEM_PROMPT, userMessage, model);
911
+ ${diff.slice(0, 4000)}${multiFileHint}`;
912
+ const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
456
913
  return result?.trim() ?? null;
457
914
  } catch {
458
915
  return null;
459
916
  }
460
917
  }
461
- async function generatePRDescription(commits, diff, model) {
918
+ async function generatePRDescription(commits, diff, model, convention = "clean-commit") {
462
919
  try {
463
920
  const userMessage = `Generate a PR description for these changes:
464
921
 
@@ -468,10 +925,10 @@ ${commits.join(`
468
925
 
469
926
  Diff (truncated):
470
927
  ${diff.slice(0, 4000)}`;
471
- const result = await callCopilot(PR_DESCRIPTION_SYSTEM_PROMPT, userMessage, model);
928
+ const result = await callCopilot(getPRDescriptionSystemPrompt(convention), userMessage, model);
472
929
  if (!result)
473
930
  return null;
474
- const cleaned = result.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
931
+ const cleaned = extractJson(result);
475
932
  return JSON.parse(cleaned);
476
933
  } catch {
477
934
  return null;
@@ -480,7 +937,11 @@ ${diff.slice(0, 4000)}`;
480
937
  async function suggestBranchName(description, model) {
481
938
  try {
482
939
  const result = await callCopilot(BRANCH_NAME_SYSTEM_PROMPT, description, model);
483
- return result?.trim() ?? null;
940
+ const trimmed = result?.trim() ?? null;
941
+ if (trimmed && /^[a-z]+\/[a-z0-9-]+$/.test(trimmed)) {
942
+ return trimmed;
943
+ }
944
+ return null;
484
945
  } catch {
485
946
  return null;
486
947
  }
@@ -496,26 +957,1392 @@ ${conflictDiff.slice(0, 4000)}`;
496
957
  return null;
497
958
  }
498
959
  }
960
+ async function generateCommitGroups(files, diffs, model, convention = "clean-commit") {
961
+ const userMessage = `Group these changed files into logical atomic commits:
499
962
 
500
- // src/commands/commit.ts
501
- 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;
502
- function validateCleanCommit(msg) {
503
- return CLEAN_COMMIT_PATTERN.test(msg);
504
- }
505
- var commit_default = defineCommand2({
963
+ Files:
964
+ ${files.join(`
965
+ `)}
966
+
967
+ Diffs (truncated):
968
+ ${diffs.slice(0, 6000)}`;
969
+ const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
970
+ if (!result) {
971
+ throw new Error("AI returned an empty response");
972
+ }
973
+ const cleaned = extractJson(result);
974
+ let parsed;
975
+ try {
976
+ parsed = JSON.parse(cleaned);
977
+ } catch {
978
+ throw new Error(`AI response is not valid JSON. Raw start: "${result.slice(0, 120)}..."`);
979
+ }
980
+ const groups = parsed;
981
+ if (!Array.isArray(groups) || groups.length === 0) {
982
+ throw new Error("AI response was not a valid JSON array of commit groups");
983
+ }
984
+ for (const group of groups) {
985
+ if (!Array.isArray(group.files) || typeof group.message !== "string") {
986
+ throw new Error("AI returned groups with invalid structure (missing files or message)");
987
+ }
988
+ }
989
+ return groups;
990
+ }
991
+ async function regenerateAllGroupMessages(groups, diffs, model, convention = "clean-commit") {
992
+ const groupSummary = groups.map((g, i) => `Group ${i + 1}: [${g.files.join(", ")}]`).join(`
993
+ `);
994
+ const userMessage = `Regenerate ONLY the commit messages for these pre-defined file groups. Do NOT change the file groupings.
995
+
996
+ Groups:
997
+ ${groupSummary}
998
+
999
+ Diffs (truncated):
1000
+ ${diffs.slice(0, 6000)}`;
1001
+ const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
1002
+ if (!result)
1003
+ return groups;
1004
+ try {
1005
+ const cleaned = extractJson(result);
1006
+ const parsed = JSON.parse(cleaned);
1007
+ if (!Array.isArray(parsed) || parsed.length !== groups.length)
1008
+ return groups;
1009
+ return groups.map((g, i) => ({
1010
+ files: g.files,
1011
+ message: typeof parsed[i]?.message === "string" ? parsed[i].message : g.message
1012
+ }));
1013
+ } catch {
1014
+ return groups;
1015
+ }
1016
+ }
1017
+ async function regenerateGroupMessage(files, diffs, model, convention = "clean-commit") {
1018
+ try {
1019
+ const userMessage = `Generate a single commit message for these files:
1020
+
1021
+ Files: ${files.join(", ")}
1022
+
1023
+ Diff:
1024
+ ${diffs.slice(0, 4000)}`;
1025
+ const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
1026
+ return result?.trim() ?? null;
1027
+ } catch {
1028
+ return null;
1029
+ }
1030
+ }
1031
+
1032
+ // src/utils/gh.ts
1033
+ import { execFile as execFileCb2 } from "node:child_process";
1034
+ function run2(args) {
1035
+ return new Promise((resolve) => {
1036
+ execFileCb2("gh", args, (error2, stdout, stderr) => {
1037
+ resolve({
1038
+ exitCode: error2 ? error2.code === "ENOENT" ? 127 : error2.status ?? 1 : 0,
1039
+ stdout: stdout ?? "",
1040
+ stderr: stderr ?? ""
1041
+ });
1042
+ });
1043
+ });
1044
+ }
1045
+ async function checkGhInstalled() {
1046
+ try {
1047
+ const { exitCode } = await run2(["--version"]);
1048
+ return exitCode === 0;
1049
+ } catch {
1050
+ return false;
1051
+ }
1052
+ }
1053
+ async function checkGhAuth() {
1054
+ try {
1055
+ const { exitCode } = await run2(["auth", "status"]);
1056
+ return exitCode === 0;
1057
+ } catch {
1058
+ return false;
1059
+ }
1060
+ }
1061
+ var SAFE_SLUG = /^[\w.-]+$/;
1062
+ async function checkRepoPermissions(owner, repo) {
1063
+ if (!SAFE_SLUG.test(owner) || !SAFE_SLUG.test(repo))
1064
+ return null;
1065
+ const { exitCode, stdout } = await run2(["api", `repos/${owner}/${repo}`, "--jq", ".permissions"]);
1066
+ if (exitCode !== 0)
1067
+ return null;
1068
+ try {
1069
+ return JSON.parse(stdout.trim());
1070
+ } catch {
1071
+ return null;
1072
+ }
1073
+ }
1074
+ async function isRepoFork() {
1075
+ const { exitCode, stdout } = await run2(["repo", "view", "--json", "isFork", "-q", ".isFork"]);
1076
+ if (exitCode !== 0)
1077
+ return null;
1078
+ const val = stdout.trim();
1079
+ if (val === "true")
1080
+ return true;
1081
+ if (val === "false")
1082
+ return false;
1083
+ return null;
1084
+ }
1085
+ async function getCurrentRepoInfo() {
1086
+ const { exitCode, stdout } = await run2([
1087
+ "repo",
1088
+ "view",
1089
+ "--json",
1090
+ "nameWithOwner",
1091
+ "-q",
1092
+ ".nameWithOwner"
1093
+ ]);
1094
+ if (exitCode !== 0)
1095
+ return null;
1096
+ const nameWithOwner = stdout.trim();
1097
+ if (!nameWithOwner)
1098
+ return null;
1099
+ const [owner, repo] = nameWithOwner.split("/");
1100
+ if (!owner || !repo)
1101
+ return null;
1102
+ return { owner, repo };
1103
+ }
1104
+ async function createPR(options) {
1105
+ const args = [
1106
+ "pr",
1107
+ "create",
1108
+ "--base",
1109
+ options.base,
1110
+ "--title",
1111
+ options.title,
1112
+ "--body",
1113
+ options.body
1114
+ ];
1115
+ if (options.draft)
1116
+ args.push("--draft");
1117
+ return run2(args);
1118
+ }
1119
+ async function createPRFill(base, draft) {
1120
+ const args = ["pr", "create", "--base", base, "--fill"];
1121
+ if (draft)
1122
+ args.push("--draft");
1123
+ return run2(args);
1124
+ }
1125
+ async function getPRForBranch(headBranch) {
1126
+ const { exitCode, stdout } = await run2([
1127
+ "pr",
1128
+ "list",
1129
+ "--head",
1130
+ headBranch,
1131
+ "--state",
1132
+ "open",
1133
+ "--json",
1134
+ "number,url,title,state",
1135
+ "--limit",
1136
+ "1"
1137
+ ]);
1138
+ if (exitCode !== 0)
1139
+ return null;
1140
+ try {
1141
+ const prs = JSON.parse(stdout.trim());
1142
+ return prs.length > 0 ? prs[0] : null;
1143
+ } catch {
1144
+ return null;
1145
+ }
1146
+ }
1147
+ async function getMergedPRForBranch(headBranch) {
1148
+ const { exitCode, stdout } = await run2([
1149
+ "pr",
1150
+ "list",
1151
+ "--head",
1152
+ headBranch,
1153
+ "--state",
1154
+ "merged",
1155
+ "--json",
1156
+ "number,url,title,state",
1157
+ "--limit",
1158
+ "1"
1159
+ ]);
1160
+ if (exitCode !== 0)
1161
+ return null;
1162
+ try {
1163
+ const prs = JSON.parse(stdout.trim());
1164
+ return prs.length > 0 ? prs[0] : null;
1165
+ } catch {
1166
+ return null;
1167
+ }
1168
+ }
1169
+
1170
+ // src/utils/spinner.ts
1171
+ import pc4 from "picocolors";
1172
+ var FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1173
+ function createSpinner(text2) {
1174
+ let frameIdx = 0;
1175
+ let currentText = text2;
1176
+ let stopped = false;
1177
+ const clearLine = () => {
1178
+ process.stderr.write("\r\x1B[K");
1179
+ };
1180
+ const render = () => {
1181
+ if (stopped)
1182
+ return;
1183
+ const frame = pc4.cyan(FRAMES[frameIdx % FRAMES.length]);
1184
+ clearLine();
1185
+ process.stderr.write(`${frame} ${currentText}`);
1186
+ frameIdx++;
1187
+ };
1188
+ const timer = setInterval(render, 80);
1189
+ render();
1190
+ const stop = () => {
1191
+ if (stopped)
1192
+ return;
1193
+ stopped = true;
1194
+ clearInterval(timer);
1195
+ clearLine();
1196
+ };
1197
+ return {
1198
+ update(newText) {
1199
+ currentText = newText;
1200
+ },
1201
+ success(msg) {
1202
+ stop();
1203
+ process.stderr.write(`${pc4.green("✔")} ${msg}
1204
+ `);
1205
+ },
1206
+ fail(msg) {
1207
+ stop();
1208
+ process.stderr.write(`${pc4.red("✖")} ${msg}
1209
+ `);
1210
+ },
1211
+ stop() {
1212
+ stop();
1213
+ }
1214
+ };
1215
+ }
1216
+
1217
+ // src/commands/clean.ts
1218
+ async function handleCurrentBranchDeletion(currentBranch, baseBranch, config) {
1219
+ if (!config)
1220
+ return "skipped";
1221
+ const { origin } = config;
1222
+ const localWork = await hasLocalWork(origin, currentBranch);
1223
+ const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
1224
+ if (hasWork) {
1225
+ if (localWork.uncommitted) {
1226
+ warn("You have uncommitted changes in your working tree.");
1227
+ }
1228
+ if (localWork.unpushedCommits > 0) {
1229
+ warn(`You have ${pc5.bold(String(localWork.unpushedCommits))} local commit${localWork.unpushedCommits !== 1 ? "s" : ""} not pushed.`);
1230
+ }
1231
+ const SAVE_NEW_BRANCH = "Save changes to a new branch";
1232
+ const DISCARD = "Discard all changes and clean up";
1233
+ const CANCEL = "Skip this branch";
1234
+ const action = await selectPrompt(`${pc5.bold(currentBranch)} has local changes. What would you like to do?`, [SAVE_NEW_BRANCH, DISCARD, CANCEL]);
1235
+ if (action === CANCEL)
1236
+ return "skipped";
1237
+ if (action === SAVE_NEW_BRANCH) {
1238
+ if (!config)
1239
+ return "skipped";
1240
+ info(pc5.dim("Tip: Describe what you're working on in plain English and we'll generate a branch name."));
1241
+ const description = await inputPrompt("What are you working on?");
1242
+ let newBranchName = description;
1243
+ if (looksLikeNaturalLanguage(description)) {
1244
+ const spinner = createSpinner("Generating branch name suggestion...");
1245
+ const suggested = await suggestBranchName(description);
1246
+ if (suggested) {
1247
+ spinner.success("Branch name suggestion ready.");
1248
+ console.log(`
1249
+ ${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(suggested))}`);
1250
+ const accepted = await confirmPrompt(`Use ${pc5.bold(suggested)} as your branch name?`);
1251
+ newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
1252
+ } else {
1253
+ spinner.fail("AI did not return a suggestion.");
1254
+ newBranchName = await inputPrompt("Enter branch name", description);
1255
+ }
1256
+ }
1257
+ if (!hasPrefix(newBranchName, config.branchPrefixes)) {
1258
+ const prefix = await selectPrompt(`Choose a branch type for ${pc5.bold(newBranchName)}:`, config.branchPrefixes);
1259
+ newBranchName = formatBranchName(prefix, newBranchName);
1260
+ }
1261
+ if (!isValidBranchName(newBranchName)) {
1262
+ error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
1263
+ return "skipped";
1264
+ }
1265
+ const renameResult = await renameBranch(currentBranch, newBranchName);
1266
+ if (renameResult.exitCode !== 0) {
1267
+ error(`Failed to rename branch: ${renameResult.stderr}`);
1268
+ return "skipped";
1269
+ }
1270
+ success(`Renamed ${pc5.bold(currentBranch)} → ${pc5.bold(newBranchName)}`);
1271
+ const syncSource2 = getSyncSource(config);
1272
+ await fetchRemote(syncSource2.remote);
1273
+ const savedUpstreamRef = await getUpstreamRef();
1274
+ const rebaseResult = savedUpstreamRef && savedUpstreamRef !== syncSource2.ref ? await rebaseOnto(syncSource2.ref, savedUpstreamRef) : await rebase(syncSource2.ref);
1275
+ if (rebaseResult.exitCode !== 0) {
1276
+ warn("Rebase encountered conflicts. Resolve them after cleanup:");
1277
+ info(` ${pc5.bold(`git checkout ${newBranchName} && git rebase --continue`)}`);
1278
+ } else {
1279
+ success(`Rebased ${pc5.bold(newBranchName)} onto ${pc5.bold(syncSource2.ref)}.`);
1280
+ }
1281
+ const coResult2 = await checkoutBranch(baseBranch);
1282
+ if (coResult2.exitCode !== 0) {
1283
+ error(`Failed to checkout ${baseBranch}: ${coResult2.stderr}`);
1284
+ return "saved";
1285
+ }
1286
+ await updateLocalBranch(baseBranch, syncSource2.ref);
1287
+ success(`Synced ${pc5.bold(baseBranch)} with ${pc5.bold(syncSource2.ref)}.`);
1288
+ return "saved";
1289
+ }
1290
+ }
1291
+ const syncSource = getSyncSource(config);
1292
+ info(`Switching to ${pc5.bold(baseBranch)} and syncing...`);
1293
+ await fetchRemote(syncSource.remote);
1294
+ const coResult = await checkoutBranch(baseBranch);
1295
+ if (coResult.exitCode !== 0) {
1296
+ error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
1297
+ return "skipped";
1298
+ }
1299
+ await updateLocalBranch(baseBranch, syncSource.ref);
1300
+ success(`Synced ${pc5.bold(baseBranch)} with ${pc5.bold(syncSource.ref)}.`);
1301
+ return "switched";
1302
+ }
1303
+ var clean_default = defineCommand2({
1304
+ meta: {
1305
+ name: "clean",
1306
+ description: "Delete merged branches and prune remote refs"
1307
+ },
1308
+ args: {
1309
+ yes: {
1310
+ type: "boolean",
1311
+ alias: "y",
1312
+ description: "Skip confirmation prompt",
1313
+ default: false
1314
+ }
1315
+ },
1316
+ async run({ args }) {
1317
+ if (!await isGitRepo()) {
1318
+ error("Not inside a git repository.");
1319
+ process.exit(1);
1320
+ }
1321
+ const config = readConfig();
1322
+ if (!config) {
1323
+ error("No .contributerc.json found. Run `contrib setup` first.");
1324
+ process.exit(1);
1325
+ }
1326
+ const { origin } = config;
1327
+ const baseBranch = getBaseBranch(config);
1328
+ let currentBranch = await getCurrentBranch();
1329
+ heading("\uD83E\uDDF9 contrib clean");
1330
+ info(`Pruning ${origin} remote refs...`);
1331
+ const pruneResult = await pruneRemote(origin);
1332
+ if (pruneResult.exitCode === 0) {
1333
+ success(`Pruned ${origin} remote refs.`);
1334
+ } else {
1335
+ warn(`Could not prune remote: ${pruneResult.stderr.trim()}`);
1336
+ }
1337
+ const protectedBranches = new Set(getProtectedBranches(config));
1338
+ const mergedBranches = await getMergedBranches(baseBranch);
1339
+ const mergedCandidates = mergedBranches.filter((b) => !protectedBranches.has(b));
1340
+ const goneBranches = await getGoneBranches();
1341
+ const goneCandidates = goneBranches.filter((b) => !protectedBranches.has(b) && !mergedCandidates.includes(b));
1342
+ if (currentBranch && !protectedBranches.has(currentBranch) && !mergedCandidates.includes(currentBranch) && !goneCandidates.includes(currentBranch)) {
1343
+ const ghInstalled = await checkGhInstalled();
1344
+ const ghAuthed = ghInstalled && await checkGhAuth();
1345
+ if (ghInstalled && ghAuthed) {
1346
+ const mergedPR = await getMergedPRForBranch(currentBranch);
1347
+ if (mergedPR) {
1348
+ warn(`PR #${mergedPR.number} (${pc5.bold(mergedPR.title)}) has already been merged.`);
1349
+ info(`Link: ${pc5.underline(mergedPR.url)}`);
1350
+ goneCandidates.push(currentBranch);
1351
+ }
1352
+ }
1353
+ }
1354
+ if (mergedCandidates.length > 0) {
1355
+ console.log(`
1356
+ ${pc5.bold("Merged branches to delete:")}`);
1357
+ for (const b of mergedCandidates) {
1358
+ const marker = b === currentBranch ? pc5.yellow(" (current)") : "";
1359
+ console.log(` ${pc5.dim("•")} ${b}${marker}`);
1360
+ }
1361
+ console.log();
1362
+ const ok = args.yes || await confirmPrompt(`Delete ${pc5.bold(String(mergedCandidates.length))} merged branch${mergedCandidates.length !== 1 ? "es" : ""}?`);
1363
+ if (ok) {
1364
+ for (const branch of mergedCandidates) {
1365
+ if (branch === currentBranch) {
1366
+ const result2 = await handleCurrentBranchDeletion(currentBranch, baseBranch, config);
1367
+ if (result2 === "skipped") {
1368
+ warn(` Skipped ${branch}.`);
1369
+ continue;
1370
+ }
1371
+ if (result2 === "saved") {
1372
+ currentBranch = baseBranch;
1373
+ continue;
1374
+ }
1375
+ currentBranch = baseBranch;
1376
+ }
1377
+ const result = await deleteBranch(branch);
1378
+ if (result.exitCode === 0) {
1379
+ success(` Deleted ${pc5.bold(branch)}`);
1380
+ } else {
1381
+ warn(` Failed to delete ${branch}: ${result.stderr.trim()}`);
1382
+ }
1383
+ }
1384
+ } else {
1385
+ info("Skipped merged branch deletion.");
1386
+ }
1387
+ }
1388
+ if (goneCandidates.length > 0) {
1389
+ console.log(`
1390
+ ${pc5.bold("Stale branches (remote deleted, likely squash-merged):")}`);
1391
+ for (const b of goneCandidates) {
1392
+ const marker = b === currentBranch ? pc5.yellow(" (current)") : "";
1393
+ console.log(` ${pc5.dim("•")} ${b}${marker}`);
1394
+ }
1395
+ console.log();
1396
+ const ok = args.yes || await confirmPrompt(`Delete ${pc5.bold(String(goneCandidates.length))} stale branch${goneCandidates.length !== 1 ? "es" : ""}?`);
1397
+ if (ok) {
1398
+ for (const branch of goneCandidates) {
1399
+ if (branch === currentBranch) {
1400
+ const result2 = await handleCurrentBranchDeletion(currentBranch, baseBranch, config);
1401
+ if (result2 === "skipped") {
1402
+ warn(` Skipped ${branch}.`);
1403
+ continue;
1404
+ }
1405
+ if (result2 === "saved") {
1406
+ currentBranch = baseBranch;
1407
+ continue;
1408
+ }
1409
+ currentBranch = baseBranch;
1410
+ }
1411
+ const result = await forceDeleteBranch(branch);
1412
+ if (result.exitCode === 0) {
1413
+ success(` Deleted ${pc5.bold(branch)}`);
1414
+ } else {
1415
+ warn(` Failed to delete ${branch}: ${result.stderr.trim()}`);
1416
+ }
1417
+ }
1418
+ } else {
1419
+ info("Skipped stale branch deletion.");
1420
+ }
1421
+ }
1422
+ if (mergedCandidates.length === 0 && goneCandidates.length === 0) {
1423
+ info("No branches to clean up. Everything is tidy! \uD83E\uDDF9");
1424
+ }
1425
+ const finalBranch = await getCurrentBranch();
1426
+ if (finalBranch && protectedBranches.has(finalBranch)) {
1427
+ console.log();
1428
+ info(`You're on ${pc5.bold(finalBranch)}. Run ${pc5.bold("contrib start")} to begin a new feature.`);
1429
+ }
1430
+ }
1431
+ });
1432
+
1433
+ // src/commands/commit.ts
1434
+ import { defineCommand as defineCommand3 } from "citty";
1435
+ import pc6 from "picocolors";
1436
+
1437
+ // src/utils/convention.ts
1438
+ 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;
1439
+ var CONVENTIONAL_COMMIT_PATTERN = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(!?)(\([a-zA-Z0-9][a-zA-Z0-9._-]*\))?: .{1,72}$/;
1440
+ var CONVENTION_LABELS = {
1441
+ conventional: "Conventional Commits",
1442
+ "clean-commit": "Clean Commit (by WGTech Labs)",
1443
+ none: "No convention"
1444
+ };
1445
+ var CONVENTION_DESCRIPTIONS = {
1446
+ conventional: "Conventional Commits — feat: | fix: | docs: | chore: etc. (conventionalcommits.org)",
1447
+ "clean-commit": "Clean Commit — \uD83D\uDCE6 new: | \uD83D\uDD27 update: | \uD83D\uDDD1️ remove: etc. (by WGTech Labs)",
1448
+ none: "No commit convention enforcement"
1449
+ };
1450
+ var CONVENTION_FORMAT_HINTS = {
1451
+ conventional: [
1452
+ "Format: <type>[!][(<scope>)]: <description>",
1453
+ "Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert",
1454
+ "Examples: feat: add login page | fix(auth): resolve token expiry | docs: update README"
1455
+ ],
1456
+ "clean-commit": [
1457
+ "Format: <emoji> <type>[!][(<scope>)]: <description>",
1458
+ "Types: \uD83D\uDCE6 new | \uD83D\uDD27 update | \uD83D\uDDD1️ remove | \uD83D\uDD12 security | ⚙️ setup | ☕ chore | \uD83E\uDDEA test | \uD83D\uDCD6 docs | \uD83D\uDE80 release",
1459
+ "Examples: \uD83D\uDCE6 new: user auth | \uD83D\uDD27 update (api): improve errors | ⚙️ setup (ci): add workflow"
1460
+ ]
1461
+ };
1462
+ function validateCommitMessage(message, convention) {
1463
+ if (convention === "none")
1464
+ return true;
1465
+ if (convention === "clean-commit")
1466
+ return CLEAN_COMMIT_PATTERN.test(message);
1467
+ if (convention === "conventional")
1468
+ return CONVENTIONAL_COMMIT_PATTERN.test(message);
1469
+ return true;
1470
+ }
1471
+ function getValidationError(convention) {
1472
+ if (convention === "none")
1473
+ return [];
1474
+ return [
1475
+ `Commit message does not follow ${CONVENTION_LABELS[convention]} format.`,
1476
+ ...CONVENTION_FORMAT_HINTS[convention]
1477
+ ];
1478
+ }
1479
+
1480
+ // src/commands/commit.ts
1481
+ var commit_default = defineCommand3({
1482
+ meta: {
1483
+ name: "commit",
1484
+ description: "Stage changes and create a commit message (AI-powered)"
1485
+ },
1486
+ args: {
1487
+ model: {
1488
+ type: "string",
1489
+ description: "AI model to use for commit message generation"
1490
+ },
1491
+ "no-ai": {
1492
+ type: "boolean",
1493
+ description: "Skip AI and write commit message manually",
1494
+ default: false
1495
+ },
1496
+ group: {
1497
+ type: "boolean",
1498
+ description: "AI groups related changes into separate atomic commits",
1499
+ default: false
1500
+ }
1501
+ },
1502
+ async run({ args }) {
1503
+ if (!await isGitRepo()) {
1504
+ error("Not inside a git repository.");
1505
+ process.exit(1);
1506
+ }
1507
+ const config = readConfig();
1508
+ if (!config) {
1509
+ error("No .contributerc.json found. Run `contrib setup` first.");
1510
+ process.exit(1);
1511
+ }
1512
+ heading("\uD83D\uDCBE contrib commit");
1513
+ if (args.group) {
1514
+ await runGroupCommit(args.model, config);
1515
+ return;
1516
+ }
1517
+ let stagedFiles = await getStagedFiles();
1518
+ if (stagedFiles.length === 0) {
1519
+ const changedFiles = await getChangedFiles();
1520
+ if (changedFiles.length === 0) {
1521
+ error("No changes to commit.");
1522
+ process.exit(1);
1523
+ }
1524
+ console.log(`
1525
+ ${pc6.bold("Changed files:")}`);
1526
+ for (const f of changedFiles) {
1527
+ console.log(` ${pc6.dim("•")} ${f}`);
1528
+ }
1529
+ const stageAction = await selectPrompt("No staged changes. How would you like to stage?", [
1530
+ "Stage all changes",
1531
+ "Select files to stage",
1532
+ "Cancel"
1533
+ ]);
1534
+ if (stageAction === "Cancel") {
1535
+ process.exit(0);
1536
+ }
1537
+ if (stageAction === "Stage all changes") {
1538
+ const result2 = await stageAll();
1539
+ if (result2.exitCode !== 0) {
1540
+ error(`Failed to stage files: ${result2.stderr}`);
1541
+ process.exit(1);
1542
+ }
1543
+ success("Staged all changes.");
1544
+ } else {
1545
+ const selected = await multiSelectPrompt("Select files to stage:", changedFiles);
1546
+ if (selected.length === 0) {
1547
+ error("No files selected.");
1548
+ process.exit(1);
1549
+ }
1550
+ const result2 = await stageFiles(selected);
1551
+ if (result2.exitCode !== 0) {
1552
+ error(`Failed to stage files: ${result2.stderr}`);
1553
+ process.exit(1);
1554
+ }
1555
+ success(`Staged ${selected.length} file(s).`);
1556
+ }
1557
+ stagedFiles = await getStagedFiles();
1558
+ if (stagedFiles.length === 0) {
1559
+ error("No staged changes after staging attempt.");
1560
+ process.exit(1);
1561
+ }
1562
+ }
1563
+ info(`Staged files: ${stagedFiles.join(", ")}`);
1564
+ let commitMessage = null;
1565
+ const useAI = !args["no-ai"];
1566
+ if (useAI) {
1567
+ const [copilotError, diff] = await Promise.all([checkCopilotAvailable(), getStagedDiff()]);
1568
+ if (copilotError) {
1569
+ warn(`AI unavailable: ${copilotError}`);
1570
+ warn("Falling back to manual commit message entry.");
1571
+ } else {
1572
+ const spinner = createSpinner("Generating commit message with AI...");
1573
+ commitMessage = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
1574
+ if (commitMessage) {
1575
+ spinner.success("AI commit message generated.");
1576
+ console.log(`
1577
+ ${pc6.dim("AI suggestion:")} ${pc6.bold(pc6.cyan(commitMessage))}`);
1578
+ } else {
1579
+ spinner.fail("AI did not return a commit message.");
1580
+ warn("Falling back to manual entry.");
1581
+ }
1582
+ }
1583
+ }
1584
+ let finalMessage = null;
1585
+ if (commitMessage) {
1586
+ const action = await selectPrompt("What would you like to do?", [
1587
+ "Accept this message",
1588
+ "Edit this message",
1589
+ "Regenerate",
1590
+ "Write manually"
1591
+ ]);
1592
+ if (action === "Accept this message") {
1593
+ finalMessage = commitMessage;
1594
+ } else if (action === "Edit this message") {
1595
+ finalMessage = await inputPrompt("Edit commit message", commitMessage);
1596
+ } else if (action === "Regenerate") {
1597
+ const spinner = createSpinner("Regenerating commit message...");
1598
+ const diff = await getStagedDiff();
1599
+ const regen = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
1600
+ if (regen) {
1601
+ spinner.success("Commit message regenerated.");
1602
+ console.log(`
1603
+ ${pc6.dim("AI suggestion:")} ${pc6.bold(pc6.cyan(regen))}`);
1604
+ const ok = await confirmPrompt("Use this message?");
1605
+ finalMessage = ok ? regen : await inputPrompt("Enter commit message manually");
1606
+ } else {
1607
+ spinner.fail("Regeneration failed.");
1608
+ finalMessage = await inputPrompt("Enter commit message");
1609
+ }
1610
+ } else {
1611
+ finalMessage = await inputPrompt("Enter commit message");
1612
+ }
1613
+ } else {
1614
+ const convention2 = config.commitConvention;
1615
+ if (convention2 !== "none") {
1616
+ console.log();
1617
+ for (const hint of CONVENTION_FORMAT_HINTS[convention2]) {
1618
+ console.log(pc6.dim(hint));
1619
+ }
1620
+ console.log();
1621
+ }
1622
+ finalMessage = await inputPrompt("Enter commit message");
1623
+ }
1624
+ if (!finalMessage) {
1625
+ error("No commit message provided.");
1626
+ process.exit(1);
1627
+ }
1628
+ const convention = config.commitConvention;
1629
+ if (!validateCommitMessage(finalMessage, convention)) {
1630
+ for (const line of getValidationError(convention)) {
1631
+ warn(line);
1632
+ }
1633
+ const proceed = await confirmPrompt("Commit anyway?");
1634
+ if (!proceed)
1635
+ process.exit(1);
1636
+ }
1637
+ const result = await commitWithMessage(finalMessage);
1638
+ if (result.exitCode !== 0) {
1639
+ error(`Failed to commit: ${result.stderr}`);
1640
+ process.exit(1);
1641
+ }
1642
+ success(`✅ Committed: ${pc6.bold(finalMessage)}`);
1643
+ }
1644
+ });
1645
+ async function runGroupCommit(model, config) {
1646
+ const [copilotError, changedFiles] = await Promise.all([
1647
+ checkCopilotAvailable(),
1648
+ getChangedFiles()
1649
+ ]);
1650
+ if (copilotError) {
1651
+ error(`AI is required for --group mode but unavailable: ${copilotError}`);
1652
+ process.exit(1);
1653
+ }
1654
+ if (changedFiles.length === 0) {
1655
+ error("No changes to group-commit.");
1656
+ process.exit(1);
1657
+ }
1658
+ console.log(`
1659
+ ${pc6.bold("Changed files:")}`);
1660
+ for (const f of changedFiles) {
1661
+ console.log(` ${pc6.dim("•")} ${f}`);
1662
+ }
1663
+ const spinner = createSpinner(`Asking AI to group ${changedFiles.length} file(s) into logical commits...`);
1664
+ const diffs = await getFullDiffForFiles(changedFiles);
1665
+ if (!diffs.trim()) {
1666
+ spinner.stop();
1667
+ warn("Could not retrieve diff context for any files. AI needs diffs to produce groups.");
1668
+ }
1669
+ let groups;
1670
+ try {
1671
+ groups = await generateCommitGroups(changedFiles, diffs, model, config.commitConvention);
1672
+ spinner.success(`AI generated ${groups.length} commit group(s).`);
1673
+ } catch (err) {
1674
+ const reason = err instanceof Error ? err.message : String(err);
1675
+ spinner.fail(`AI grouping failed: ${reason}`);
1676
+ process.exit(1);
1677
+ }
1678
+ if (groups.length === 0) {
1679
+ error("AI could not produce commit groups. Try committing files manually.");
1680
+ process.exit(1);
1681
+ }
1682
+ const changedSet = new Set(changedFiles);
1683
+ for (const group of groups) {
1684
+ const invalid = group.files.filter((f) => !changedSet.has(f));
1685
+ if (invalid.length > 0) {
1686
+ warn(`AI suggested unknown file(s): ${invalid.join(", ")} — removed from group.`);
1687
+ }
1688
+ group.files = group.files.filter((f) => changedSet.has(f));
1689
+ }
1690
+ let validGroups = groups.filter((g) => g.files.length > 0);
1691
+ if (validGroups.length === 0) {
1692
+ error("No valid groups remain after validation. Try committing files manually.");
1693
+ process.exit(1);
1694
+ }
1695
+ let proceedToCommit = false;
1696
+ let commitAll = false;
1697
+ while (!proceedToCommit) {
1698
+ console.log(`
1699
+ ${pc6.bold(`AI suggested ${validGroups.length} commit group(s):`)}
1700
+ `);
1701
+ for (let i = 0;i < validGroups.length; i++) {
1702
+ const g = validGroups[i];
1703
+ console.log(` ${pc6.cyan(`Group ${i + 1}:`)} ${pc6.bold(g.message)}`);
1704
+ for (const f of g.files) {
1705
+ console.log(` ${pc6.dim("•")} ${f}`);
1706
+ }
1707
+ console.log();
1708
+ }
1709
+ const summaryAction = await selectPrompt("What would you like to do?", [
1710
+ "Commit all",
1711
+ "Review each group",
1712
+ "Regenerate all messages",
1713
+ "Cancel"
1714
+ ]);
1715
+ if (summaryAction === "Cancel") {
1716
+ warn("Group commit cancelled.");
1717
+ process.exit(0);
1718
+ }
1719
+ if (summaryAction === "Regenerate all messages") {
1720
+ const regenSpinner = createSpinner("Regenerating all commit messages...");
1721
+ try {
1722
+ validGroups = await regenerateAllGroupMessages(validGroups, diffs, model, config.commitConvention);
1723
+ regenSpinner.success("All commit messages regenerated.");
1724
+ } catch {
1725
+ regenSpinner.fail("Failed to regenerate messages. Keeping current ones.");
1726
+ }
1727
+ continue;
1728
+ }
1729
+ proceedToCommit = true;
1730
+ commitAll = summaryAction === "Commit all";
1731
+ }
1732
+ let committed = 0;
1733
+ if (commitAll) {
1734
+ for (let i = 0;i < validGroups.length; i++) {
1735
+ const group = validGroups[i];
1736
+ const stageResult = await stageFiles(group.files);
1737
+ if (stageResult.exitCode !== 0) {
1738
+ error(`Failed to stage group ${i + 1}: ${stageResult.stderr}`);
1739
+ continue;
1740
+ }
1741
+ const commitResult = await commitWithMessage(group.message);
1742
+ if (commitResult.exitCode !== 0) {
1743
+ const detail = (commitResult.stderr || commitResult.stdout).trim();
1744
+ error(`Failed to commit group ${i + 1}: ${detail}`);
1745
+ await unstageFiles(group.files);
1746
+ continue;
1747
+ }
1748
+ committed++;
1749
+ success(`✅ Committed group ${i + 1}: ${pc6.bold(group.message)}`);
1750
+ }
1751
+ } else {
1752
+ for (let i = 0;i < validGroups.length; i++) {
1753
+ const group = validGroups[i];
1754
+ console.log(pc6.bold(`
1755
+ ── Group ${i + 1}/${validGroups.length} ──`));
1756
+ console.log(` ${pc6.cyan(group.message)}`);
1757
+ for (const f of group.files) {
1758
+ console.log(` ${pc6.dim("•")} ${f}`);
1759
+ }
1760
+ let message = group.message;
1761
+ let actionDone = false;
1762
+ while (!actionDone) {
1763
+ const action = await selectPrompt("Action for this group:", [
1764
+ "Commit as-is",
1765
+ "Edit message and commit",
1766
+ "Regenerate message",
1767
+ "Skip this group"
1768
+ ]);
1769
+ if (action === "Skip this group") {
1770
+ warn(`Skipped group ${i + 1}.`);
1771
+ actionDone = true;
1772
+ continue;
1773
+ }
1774
+ if (action === "Regenerate message") {
1775
+ const regenSpinner = createSpinner("Regenerating commit message for this group...");
1776
+ const newMsg = await regenerateGroupMessage(group.files, diffs, model, config.commitConvention);
1777
+ if (newMsg) {
1778
+ message = newMsg;
1779
+ group.message = newMsg;
1780
+ regenSpinner.success(`New message: ${pc6.bold(message)}`);
1781
+ } else {
1782
+ regenSpinner.fail("AI could not generate a new message. Keeping current one.");
1783
+ }
1784
+ continue;
1785
+ }
1786
+ if (action === "Edit message and commit") {
1787
+ message = await inputPrompt("Edit commit message", message);
1788
+ if (!message) {
1789
+ warn(`Skipped group ${i + 1} (empty message).`);
1790
+ actionDone = true;
1791
+ continue;
1792
+ }
1793
+ }
1794
+ if (!validateCommitMessage(message, config.commitConvention)) {
1795
+ for (const line of getValidationError(config.commitConvention)) {
1796
+ warn(line);
1797
+ }
1798
+ const proceed = await confirmPrompt("Commit anyway?");
1799
+ if (!proceed) {
1800
+ warn(`Skipped group ${i + 1}.`);
1801
+ actionDone = true;
1802
+ continue;
1803
+ }
1804
+ }
1805
+ const stageResult = await stageFiles(group.files);
1806
+ if (stageResult.exitCode !== 0) {
1807
+ error(`Failed to stage group ${i + 1}: ${stageResult.stderr}`);
1808
+ actionDone = true;
1809
+ continue;
1810
+ }
1811
+ const commitResult = await commitWithMessage(message);
1812
+ if (commitResult.exitCode !== 0) {
1813
+ const detail = (commitResult.stderr || commitResult.stdout).trim();
1814
+ error(`Failed to commit group ${i + 1}: ${detail}`);
1815
+ await unstageFiles(group.files);
1816
+ actionDone = true;
1817
+ continue;
1818
+ }
1819
+ committed++;
1820
+ success(`✅ Committed group ${i + 1}: ${pc6.bold(message)}`);
1821
+ actionDone = true;
1822
+ }
1823
+ }
1824
+ }
1825
+ if (committed === 0) {
1826
+ warn("No groups were committed.");
1827
+ } else {
1828
+ success(`
1829
+ \uD83C\uDF89 ${committed} of ${validGroups.length} group(s) committed successfully.`);
1830
+ }
1831
+ process.exit(0);
1832
+ }
1833
+
1834
+ // src/commands/doctor.ts
1835
+ import { execFile as execFileCb3 } from "node:child_process";
1836
+ import { defineCommand as defineCommand4 } from "citty";
1837
+ import pc7 from "picocolors";
1838
+ // package.json
1839
+ var package_default = {
1840
+ name: "contribute-now",
1841
+ version: "0.2.0-dev.2621ffa",
1842
+ description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
1843
+ type: "module",
1844
+ bin: {
1845
+ contrib: "dist/index.js",
1846
+ contribute: "dist/index.js"
1847
+ },
1848
+ files: [
1849
+ "dist"
1850
+ ],
1851
+ scripts: {
1852
+ build: "bun build src/index.ts --outfile dist/index.js --target node --packages external",
1853
+ cli: "bun run src/index.ts --",
1854
+ dev: "bun src/index.ts",
1855
+ test: "bun test",
1856
+ lint: "biome check .",
1857
+ "lint:fix": "biome check --write .",
1858
+ format: "biome format --write .",
1859
+ "www:dev": "bun run --cwd www dev",
1860
+ "www:build": "bun run --cwd www build",
1861
+ "www:preview": "bun run --cwd www preview"
1862
+ },
1863
+ engines: {
1864
+ node: ">=18",
1865
+ bun: ">=1.0"
1866
+ },
1867
+ keywords: [
1868
+ "git",
1869
+ "workflow",
1870
+ "squash-merge",
1871
+ "sync",
1872
+ "cli",
1873
+ "contribute",
1874
+ "fork",
1875
+ "dev-branch",
1876
+ "clean-commit"
1877
+ ],
1878
+ author: "Waren Gonzaga",
1879
+ license: "GPL-3.0",
1880
+ repository: {
1881
+ type: "git",
1882
+ url: "git+https://github.com/warengonzaga/contribute-now.git"
1883
+ },
1884
+ dependencies: {
1885
+ "@clack/prompts": "^1.0.1",
1886
+ "@github/copilot-sdk": "^0.1.25",
1887
+ "@wgtechlabs/log-engine": "^2.3.1",
1888
+ citty: "^0.1.6",
1889
+ figlet: "^1.10.0",
1890
+ picocolors: "^1.1.1"
1891
+ },
1892
+ devDependencies: {
1893
+ "@biomejs/biome": "^2.4.4",
1894
+ "@types/bun": "latest",
1895
+ "@types/figlet": "^1.7.0",
1896
+ typescript: "^5.7.0"
1897
+ }
1898
+ };
1899
+
1900
+ // src/utils/remote.ts
1901
+ function parseRepoFromUrl(url) {
1902
+ const httpsMatch = url.match(/https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
1903
+ if (httpsMatch) {
1904
+ return { owner: httpsMatch[1], repo: httpsMatch[2] };
1905
+ }
1906
+ const sshMatch = url.match(/git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
1907
+ if (sshMatch) {
1908
+ return { owner: sshMatch[1], repo: sshMatch[2] };
1909
+ }
1910
+ return null;
1911
+ }
1912
+ async function detectForkSetup() {
1913
+ const remotes = await getRemotes();
1914
+ const hasOrigin = remotes.includes("origin");
1915
+ const hasUpstream = remotes.includes("upstream");
1916
+ return {
1917
+ isFork: hasUpstream,
1918
+ originRemote: hasOrigin ? "origin" : null,
1919
+ upstreamRemote: hasUpstream ? "upstream" : null
1920
+ };
1921
+ }
1922
+ async function getRepoInfoFromRemote(remote = "origin") {
1923
+ const url = await getRemoteUrl(remote);
1924
+ if (!url)
1925
+ return null;
1926
+ return parseRepoFromUrl(url);
1927
+ }
1928
+
1929
+ // src/commands/doctor.ts
1930
+ var PASS = ` ${pc7.green("✔")} `;
1931
+ var FAIL = ` ${pc7.red("✗")} `;
1932
+ var WARN = ` ${pc7.yellow("⚠")} `;
1933
+ function printReport(report) {
1934
+ for (const section of report.sections) {
1935
+ console.log(`
1936
+ ${pc7.bold(pc7.underline(section.title))}`);
1937
+ for (const check of section.checks) {
1938
+ const prefix = check.ok ? check.warning ? WARN : PASS : FAIL;
1939
+ const text2 = check.detail ? `${check.label} ${pc7.dim(`— ${check.detail}`)}` : check.label;
1940
+ console.log(`${prefix}${text2}`);
1941
+ }
1942
+ }
1943
+ console.log();
1944
+ }
1945
+ function toJson(report) {
1946
+ return JSON.stringify(report.sections.map((s) => ({
1947
+ section: s.title,
1948
+ checks: s.checks.map((c) => ({
1949
+ label: c.label,
1950
+ ok: c.ok,
1951
+ warning: c.warning ?? false,
1952
+ detail: c.detail ?? null
1953
+ }))
1954
+ })), null, 2);
1955
+ }
1956
+ function runCmd(cmd, args) {
1957
+ return new Promise((resolve) => {
1958
+ execFileCb3(cmd, args, (error2, stdout) => {
1959
+ resolve({
1960
+ ok: !error2,
1961
+ stdout: (stdout ?? "").trim()
1962
+ });
1963
+ });
1964
+ });
1965
+ }
1966
+ async function toolSection() {
1967
+ const checks = [];
1968
+ checks.push({
1969
+ label: `contrib v${package_default.version ?? "unknown"}`,
1970
+ ok: true
1971
+ });
1972
+ const runtime = typeof globalThis.Bun !== "undefined" ? `Bun ${globalThis.Bun.version ?? "?"}` : `Node ${process.version}`;
1973
+ checks.push({ label: runtime, ok: true, detail: `${process.platform}-${process.arch}` });
1974
+ return { title: "Tool", checks };
1975
+ }
1976
+ async function depsSection() {
1977
+ const checks = [];
1978
+ const git = await runCmd("git", ["--version"]);
1979
+ checks.push({
1980
+ label: git.ok ? git.stdout.replace("git version ", "git ") : "git not found",
1981
+ ok: git.ok
1982
+ });
1983
+ const ghInstalled = await checkGhInstalled();
1984
+ if (ghInstalled) {
1985
+ const ghVer = await runCmd("gh", ["--version"]);
1986
+ const ver = ghVer.stdout.split(`
1987
+ `)[0] ?? "gh";
1988
+ checks.push({ label: ver, ok: true });
1989
+ const ghAuth = await checkGhAuth();
1990
+ checks.push({
1991
+ label: ghAuth ? "gh authenticated" : "gh not authenticated",
1992
+ ok: ghAuth,
1993
+ warning: !ghAuth,
1994
+ detail: ghAuth ? undefined : "run `gh auth login`"
1995
+ });
1996
+ } else {
1997
+ checks.push({
1998
+ label: "gh CLI not installed",
1999
+ ok: false,
2000
+ detail: "install from https://cli.github.com"
2001
+ });
2002
+ }
2003
+ try {
2004
+ await import("@github/copilot-sdk");
2005
+ checks.push({ label: "Copilot SDK importable", ok: true });
2006
+ } catch {
2007
+ checks.push({
2008
+ label: "Copilot SDK not loadable",
2009
+ ok: false,
2010
+ warning: true,
2011
+ detail: "AI features will be unavailable"
2012
+ });
2013
+ }
2014
+ return { title: "Dependencies", checks };
2015
+ }
2016
+ async function configSection() {
2017
+ const checks = [];
2018
+ const exists = configExists();
2019
+ if (!exists) {
2020
+ checks.push({
2021
+ label: ".contributerc.json not found",
2022
+ ok: false,
2023
+ detail: "run `contrib setup` to create it"
2024
+ });
2025
+ return { title: "Config", checks };
2026
+ }
2027
+ const config = readConfig();
2028
+ if (!config) {
2029
+ checks.push({ label: ".contributerc.json found but invalid", ok: false });
2030
+ return { title: "Config", checks };
2031
+ }
2032
+ checks.push({ label: ".contributerc.json found and valid", ok: true });
2033
+ const desc = WORKFLOW_DESCRIPTIONS[config.workflow] ?? config.workflow;
2034
+ checks.push({
2035
+ label: `Workflow: ${config.workflow}`,
2036
+ ok: true,
2037
+ detail: desc
2038
+ });
2039
+ checks.push({ label: `Role: ${config.role}`, ok: true });
2040
+ checks.push({ label: `Commit convention: ${config.commitConvention}`, ok: true });
2041
+ if (hasDevBranch(config.workflow)) {
2042
+ checks.push({
2043
+ label: `Dev branch: ${config.devBranch ?? "(not set)"}`,
2044
+ ok: !!config.devBranch
2045
+ });
2046
+ }
2047
+ const ignored = isGitignored();
2048
+ checks.push({
2049
+ label: ignored ? ".contributerc.json in .gitignore" : ".contributerc.json NOT in .gitignore",
2050
+ ok: true,
2051
+ warning: !ignored,
2052
+ detail: ignored ? undefined : "consider adding it to .gitignore"
2053
+ });
2054
+ return { title: "Config", checks };
2055
+ }
2056
+ async function gitSection() {
2057
+ const checks = [];
2058
+ const inRepo = await isGitRepo();
2059
+ checks.push({
2060
+ label: inRepo ? "Inside a git repository" : "Not inside a git repository",
2061
+ ok: inRepo
2062
+ });
2063
+ if (!inRepo)
2064
+ return { title: "Git Environment", checks };
2065
+ const branch = await getCurrentBranch();
2066
+ const head = await runCmd("git", ["rev-parse", "--short", "HEAD"]);
2067
+ checks.push({
2068
+ label: `Branch: ${branch ?? "(detached)"}`,
2069
+ ok: !!branch,
2070
+ detail: head.ok ? `HEAD ${head.stdout}` : undefined
2071
+ });
2072
+ const remotes = await getRemotes();
2073
+ if (remotes.length === 0) {
2074
+ checks.push({ label: "No remotes configured", ok: false, warning: true });
2075
+ } else {
2076
+ for (const remote of remotes) {
2077
+ const url = await getRemoteUrl(remote);
2078
+ const repoInfo = url ? parseRepoFromUrl(url) : null;
2079
+ const detail = repoInfo ? `${repoInfo.owner}/${repoInfo.repo}` : url ?? "unknown URL";
2080
+ checks.push({ label: `Remote: ${remote}`, ok: true, detail });
2081
+ }
2082
+ }
2083
+ const dirty = await hasUncommittedChanges();
2084
+ checks.push({
2085
+ label: dirty ? "Uncommitted changes detected" : "Working tree clean",
2086
+ ok: true,
2087
+ warning: dirty
2088
+ });
2089
+ return { title: "Git Environment", checks };
2090
+ }
2091
+ async function forkSection() {
2092
+ const checks = [];
2093
+ const fork = await detectForkSetup();
2094
+ checks.push({
2095
+ label: fork.isFork ? "Fork detected (upstream remote exists)" : "Not a fork (no upstream remote)",
2096
+ ok: true
2097
+ });
2098
+ if (fork.originRemote) {
2099
+ checks.push({ label: `Origin remote: ${fork.originRemote}`, ok: true });
2100
+ }
2101
+ if (fork.upstreamRemote) {
2102
+ checks.push({ label: `Upstream remote: ${fork.upstreamRemote}`, ok: true });
2103
+ }
2104
+ return { title: "Fork Detection", checks };
2105
+ }
2106
+ async function workflowSection() {
2107
+ const checks = [];
2108
+ const config = readConfig();
2109
+ if (!config) {
2110
+ checks.push({
2111
+ label: "Cannot resolve workflow (no config)",
2112
+ ok: false,
2113
+ detail: "run `contrib setup` first"
2114
+ });
2115
+ return { title: "Workflow Resolution", checks };
2116
+ }
2117
+ const baseBranch = getBaseBranch(config);
2118
+ checks.push({ label: `Base branch: ${baseBranch}`, ok: true });
2119
+ const sync = getSyncSource(config);
2120
+ checks.push({
2121
+ label: `Sync source: ${sync.ref}`,
2122
+ ok: true,
2123
+ detail: `strategy: ${sync.strategy}`
2124
+ });
2125
+ checks.push({
2126
+ label: `Branch prefixes: ${config.branchPrefixes.join(", ")}`,
2127
+ ok: config.branchPrefixes.length > 0
2128
+ });
2129
+ return { title: "Workflow Resolution", checks };
2130
+ }
2131
+ function envSection() {
2132
+ const checks = [];
2133
+ const vars = ["GITHUB_TOKEN", "GH_TOKEN", "COPILOT_AGENT_TOKEN", "NO_COLOR", "FORCE_COLOR", "CI"];
2134
+ for (const name of vars) {
2135
+ const val = process.env[name];
2136
+ if (val !== undefined) {
2137
+ const isSecret = name.toLowerCase().includes("token");
2138
+ const display = isSecret ? `${val.slice(0, 4)}${"*".repeat(Math.min(val.length - 4, 12))}` : val;
2139
+ checks.push({ label: `${name} = ${display}`, ok: true });
2140
+ }
2141
+ }
2142
+ if (checks.length === 0) {
2143
+ checks.push({ label: "No relevant environment variables set", ok: true });
2144
+ }
2145
+ return { title: "Environment", checks };
2146
+ }
2147
+ var doctor_default = defineCommand4({
2148
+ meta: {
2149
+ name: "doctor",
2150
+ description: "Diagnose the contribute-now CLI environment and configuration"
2151
+ },
2152
+ args: {
2153
+ json: {
2154
+ type: "boolean",
2155
+ description: "Output report as JSON",
2156
+ default: false
2157
+ }
2158
+ },
2159
+ async run({ args }) {
2160
+ const isJson = args.json;
2161
+ const [tool, deps, config, git, fork, workflow] = await Promise.all([
2162
+ toolSection(),
2163
+ depsSection(),
2164
+ configSection(),
2165
+ gitSection(),
2166
+ forkSection(),
2167
+ workflowSection()
2168
+ ]);
2169
+ const env = envSection();
2170
+ const report = {
2171
+ sections: [tool, deps, config, git, fork, workflow, env]
2172
+ };
2173
+ if (isJson) {
2174
+ console.log(toJson(report));
2175
+ return;
2176
+ }
2177
+ heading("\uD83E\uDE7A contribute-now doctor");
2178
+ printReport(report);
2179
+ const total = report.sections.flatMap((s) => s.checks);
2180
+ const failures = total.filter((c) => !c.ok);
2181
+ const warnings = total.filter((c) => c.ok && c.warning);
2182
+ if (failures.length === 0 && warnings.length === 0) {
2183
+ console.log(` ${pc7.green("All checks passed!")} No issues detected.
2184
+ `);
2185
+ } else {
2186
+ if (failures.length > 0) {
2187
+ console.log(` ${pc7.red(`${failures.length} issue${failures.length !== 1 ? "s" : ""} found.`)}`);
2188
+ }
2189
+ if (warnings.length > 0) {
2190
+ console.log(` ${pc7.yellow(`${warnings.length} warning${warnings.length !== 1 ? "s" : ""}.`)}`);
2191
+ }
2192
+ console.log();
2193
+ }
2194
+ }
2195
+ });
2196
+
2197
+ // src/commands/hook.ts
2198
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
2199
+ import { join as join3 } from "node:path";
2200
+ import { defineCommand as defineCommand5 } from "citty";
2201
+ import pc8 from "picocolors";
2202
+ var HOOK_MARKER = "# managed by contribute-now";
2203
+ function getHooksDir(cwd = process.cwd()) {
2204
+ return join3(cwd, ".git", "hooks");
2205
+ }
2206
+ function getHookPath(cwd = process.cwd()) {
2207
+ return join3(getHooksDir(cwd), "commit-msg");
2208
+ }
2209
+ function generateHookScript() {
2210
+ return `#!/bin/sh
2211
+ ${HOOK_MARKER}
2212
+ # Validates commit messages against your configured convention.
2213
+ # Install: contrib hook install
2214
+ # Uninstall: contrib hook uninstall
2215
+
2216
+ commit_msg_file="$1"
2217
+ commit_msg=$(head -1 "$commit_msg_file")
2218
+
2219
+ # Skip merge commits and fixup/squash commits
2220
+ case "$commit_msg" in
2221
+ Merge\\ *|fixup!*|squash!*|amend!*) exit 0 ;;
2222
+ esac
2223
+
2224
+ # Detect available package runner
2225
+ if command -v contrib >/dev/null 2>&1; then
2226
+ contrib validate "$commit_msg"
2227
+ elif command -v bunx >/dev/null 2>&1; then
2228
+ bunx contrib validate "$commit_msg"
2229
+ elif command -v pnpx >/dev/null 2>&1; then
2230
+ pnpx contrib validate "$commit_msg"
2231
+ elif command -v npx >/dev/null 2>&1; then
2232
+ npx contrib validate "$commit_msg"
2233
+ else
2234
+ echo "Warning: No package runner found. Skipping commit message validation."
2235
+ exit 0
2236
+ fi
2237
+ `;
2238
+ }
2239
+ var hook_default = defineCommand5({
2240
+ meta: {
2241
+ name: "hook",
2242
+ description: "Install or uninstall the commit-msg git hook"
2243
+ },
2244
+ args: {
2245
+ action: {
2246
+ type: "positional",
2247
+ description: "Action to perform: install or uninstall",
2248
+ required: true
2249
+ }
2250
+ },
2251
+ async run({ args }) {
2252
+ if (!await isGitRepo()) {
2253
+ error("Not inside a git repository.");
2254
+ process.exit(1);
2255
+ }
2256
+ const action = args.action;
2257
+ if (action !== "install" && action !== "uninstall") {
2258
+ error(`Unknown action "${action}". Use "install" or "uninstall".`);
2259
+ process.exit(1);
2260
+ }
2261
+ if (action === "install") {
2262
+ await installHook();
2263
+ } else {
2264
+ await uninstallHook();
2265
+ }
2266
+ }
2267
+ });
2268
+ async function installHook() {
2269
+ heading("\uD83E\uDE9D hook install");
2270
+ const config = readConfig();
2271
+ if (!config) {
2272
+ error("No .contributerc.json found. Run `contrib setup` first.");
2273
+ process.exit(1);
2274
+ }
2275
+ if (config.commitConvention === "none") {
2276
+ warn('Commit convention is set to "none". No hook to install.');
2277
+ info("Change your convention with `contrib setup` first.");
2278
+ process.exit(0);
2279
+ }
2280
+ const hookPath = getHookPath();
2281
+ const hooksDir = getHooksDir();
2282
+ if (existsSync2(hookPath)) {
2283
+ const existing = readFileSync3(hookPath, "utf-8");
2284
+ if (!existing.includes(HOOK_MARKER)) {
2285
+ error("A commit-msg hook already exists and was not installed by contribute-now.");
2286
+ warn(`Path: ${hookPath}`);
2287
+ warn("Remove it manually or back it up before installing.");
2288
+ process.exit(1);
2289
+ }
2290
+ info("Updating existing contribute-now hook...");
2291
+ }
2292
+ if (!existsSync2(hooksDir)) {
2293
+ mkdirSync(hooksDir, { recursive: true });
2294
+ }
2295
+ writeFileSync2(hookPath, generateHookScript(), { mode: 493 });
2296
+ success(`commit-msg hook installed.`);
2297
+ info(`Convention: ${pc8.bold(CONVENTION_LABELS[config.commitConvention])}`);
2298
+ info(`Path: ${pc8.dim(hookPath)}`);
2299
+ }
2300
+ async function uninstallHook() {
2301
+ heading("\uD83E\uDE9D hook uninstall");
2302
+ const hookPath = getHookPath();
2303
+ if (!existsSync2(hookPath)) {
2304
+ info("No commit-msg hook found. Nothing to uninstall.");
2305
+ return;
2306
+ }
2307
+ const content = readFileSync3(hookPath, "utf-8");
2308
+ if (!content.includes(HOOK_MARKER)) {
2309
+ error("The commit-msg hook was not installed by contribute-now. Leaving it untouched.");
2310
+ process.exit(1);
2311
+ }
2312
+ rmSync(hookPath);
2313
+ success("commit-msg hook removed.");
2314
+ }
2315
+
2316
+ // src/commands/log.ts
2317
+ import { defineCommand as defineCommand6 } from "citty";
2318
+ import pc9 from "picocolors";
2319
+ var log_default = defineCommand6({
506
2320
  meta: {
507
- name: "commit",
508
- description: "Stage changes and create a Clean Commit message (AI-powered)"
2321
+ name: "log",
2322
+ description: "Show a colorized, workflow-aware commit log with graph"
509
2323
  },
510
2324
  args: {
511
- model: {
2325
+ count: {
512
2326
  type: "string",
513
- description: "AI model to use for commit message generation"
2327
+ alias: "n",
2328
+ description: "Number of commits to show (default: 20)"
514
2329
  },
515
- "no-ai": {
2330
+ all: {
516
2331
  type: "boolean",
517
- description: "Skip AI and write commit message manually",
2332
+ alias: "a",
2333
+ description: "Show all branches, not just current",
518
2334
  default: false
2335
+ },
2336
+ graph: {
2337
+ type: "boolean",
2338
+ alias: "g",
2339
+ description: "Show graph view with branch lines",
2340
+ default: true
2341
+ },
2342
+ branch: {
2343
+ type: "string",
2344
+ alias: "b",
2345
+ description: "Show log for a specific branch"
519
2346
  }
520
2347
  },
521
2348
  async run({ args }) {
@@ -524,218 +2351,127 @@ var commit_default = defineCommand2({
524
2351
  process.exit(1);
525
2352
  }
526
2353
  const config = readConfig();
527
- if (!config) {
528
- error("No .contributerc.json found. Run `contrib setup` first.");
529
- process.exit(1);
530
- }
531
- heading("\uD83D\uDCBE contrib commit");
532
- const stagedFiles = await getStagedFiles();
533
- if (stagedFiles.length === 0) {
534
- const changedFiles = await getChangedFiles();
535
- if (changedFiles.length === 0) {
536
- error("No changes to commit.");
537
- process.exit(1);
538
- }
539
- console.log(`
540
- ${pc4.bold("Changed files:")}`);
541
- for (const f of changedFiles) {
542
- console.log(` ${pc4.dim("•")} ${f}`);
2354
+ const count = args.count ? Number.parseInt(args.count, 10) : 20;
2355
+ const showAll = args.all;
2356
+ const showGraph = args.graph;
2357
+ const targetBranch = args.branch;
2358
+ const protectedBranches = config ? getProtectedBranches(config) : ["main", "master"];
2359
+ const currentBranch = await getCurrentBranch();
2360
+ heading("\uD83D\uDCDC commit log");
2361
+ if (showGraph) {
2362
+ const lines = await getLogGraph({ count, all: showAll, branch: targetBranch });
2363
+ if (lines.length === 0) {
2364
+ console.log(pc9.dim(" No commits found."));
2365
+ console.log();
2366
+ return;
543
2367
  }
544
2368
  console.log();
545
- warn("No staged changes. Stage your files with `git add` and re-run.");
546
- process.exit(1);
547
- }
548
- info(`Staged files: ${stagedFiles.join(", ")}`);
549
- let commitMessage = null;
550
- const useAI = !args["no-ai"];
551
- if (useAI) {
552
- const copilotError = await checkCopilotAvailable();
553
- if (copilotError) {
554
- warn(`AI unavailable: ${copilotError}`);
555
- warn("Falling back to manual commit message entry.");
556
- } else {
557
- info("Generating commit message with AI...");
558
- const diff = await getStagedDiff();
559
- commitMessage = await generateCommitMessage(diff, stagedFiles, args.model);
560
- if (commitMessage) {
561
- console.log(`
562
- ${pc4.dim("AI suggestion:")} ${pc4.bold(pc4.cyan(commitMessage))}`);
563
- } else {
564
- warn("AI did not return a commit message. Falling back to manual entry.");
565
- }
566
- }
567
- }
568
- let finalMessage = null;
569
- if (commitMessage) {
570
- const action = await selectPrompt("What would you like to do?", [
571
- "Accept this message",
572
- "Edit this message",
573
- "Regenerate",
574
- "Write manually"
575
- ]);
576
- if (action === "Accept this message") {
577
- finalMessage = commitMessage;
578
- } else if (action === "Edit this message") {
579
- finalMessage = await inputPrompt("Edit commit message", commitMessage);
580
- } else if (action === "Regenerate") {
581
- info("Regenerating...");
582
- const diff = await getStagedDiff();
583
- const regen = await generateCommitMessage(diff, stagedFiles, args.model);
584
- if (regen) {
585
- console.log(`
586
- ${pc4.dim("AI suggestion:")} ${pc4.bold(pc4.cyan(regen))}`);
587
- const ok = await confirmPrompt("Use this message?");
588
- finalMessage = ok ? regen : await inputPrompt("Enter commit message manually");
589
- } else {
590
- warn("Regeneration failed. Falling back to manual entry.");
591
- finalMessage = await inputPrompt("Enter commit message");
592
- }
593
- } else {
594
- finalMessage = await inputPrompt("Enter commit message");
2369
+ for (const line of lines) {
2370
+ console.log(` ${colorizeGraphLine(line, protectedBranches, currentBranch)}`);
595
2371
  }
596
2372
  } else {
2373
+ const entries = await getLogEntries({ count, all: showAll, branch: targetBranch });
2374
+ if (entries.length === 0) {
2375
+ console.log(pc9.dim(" No commits found."));
2376
+ console.log();
2377
+ return;
2378
+ }
597
2379
  console.log();
598
- console.log(pc4.dim("Clean Commit format: <emoji> <type>[!][(<scope>)]: <description>"));
599
- console.log(pc4.dim("Examples: \uD83D\uDCE6 new: user auth | \uD83D\uDD27 update (api): improve errors"));
600
- console.log();
601
- finalMessage = await inputPrompt("Enter commit message");
602
- }
603
- if (!finalMessage) {
604
- error("No commit message provided.");
605
- process.exit(1);
606
- }
607
- if (!validateCleanCommit(finalMessage)) {
608
- warn("Commit message does not follow Clean Commit format.");
609
- warn("Format: <emoji> <type>[!][(<scope>)]: <description>");
610
- const proceed = await confirmPrompt("Commit anyway?");
611
- if (!proceed)
612
- process.exit(1);
613
- }
614
- const result = await commitWithMessage(finalMessage);
615
- if (result.exitCode !== 0) {
616
- error(`Failed to commit: ${result.stderr}`);
617
- process.exit(1);
2380
+ for (const entry of entries) {
2381
+ const hashStr = pc9.yellow(entry.hash);
2382
+ const refsStr = entry.refs ? ` ${colorizeRefs(entry.refs, protectedBranches, currentBranch)}` : "";
2383
+ const subjectStr = colorizeSubject(entry.subject);
2384
+ console.log(` ${hashStr}${refsStr} ${subjectStr}`);
2385
+ }
618
2386
  }
619
- success(`✅ Committed: ${pc4.bold(finalMessage)}`);
2387
+ console.log();
2388
+ console.log(pc9.dim(` Showing ${count} most recent commits${showAll ? " (all branches)" : targetBranch ? ` (${targetBranch})` : ""}`));
2389
+ console.log(pc9.dim(` Use ${pc9.bold("contrib log -n 50")} for more, or ${pc9.bold("contrib log --all")} for all branches`));
2390
+ console.log();
620
2391
  }
621
2392
  });
622
-
623
- // src/commands/setup.ts
624
- import { defineCommand as defineCommand3 } from "citty";
625
- import pc5 from "picocolors";
626
-
627
- // src/utils/gh.ts
628
- import { execFile as execFileCb2 } from "node:child_process";
629
- function run2(args) {
630
- return new Promise((resolve) => {
631
- execFileCb2("gh", args, (error2, stdout, stderr) => {
632
- resolve({
633
- exitCode: error2 ? error2.code != null ? Number(error2.code) : 1 : 0,
634
- stdout: stdout ?? "",
635
- stderr: stderr ?? ""
636
- });
637
- });
638
- });
639
- }
640
- async function checkGhInstalled() {
641
- try {
642
- const { exitCode } = await run2(["--version"]);
643
- return exitCode === 0;
644
- } catch {
645
- return false;
2393
+ function colorizeGraphLine(line, protectedBranches, currentBranch) {
2394
+ const match = line.match(/^([|/\\*\s_.-]*)([a-f0-9]{7,12})(\s+\(([^)]+)\))?\s*(.*)/);
2395
+ if (!match) {
2396
+ return pc9.cyan(line);
646
2397
  }
647
- }
648
- async function checkGhAuth() {
649
- try {
650
- const { exitCode } = await run2(["auth", "status"]);
651
- return exitCode === 0;
652
- } catch {
653
- return false;
2398
+ const [, graphPart = "", hash, , refs, subject = ""] = match;
2399
+ const parts = [];
2400
+ if (graphPart) {
2401
+ parts.push(colorizeGraphChars(graphPart));
654
2402
  }
655
- }
656
- async function checkRepoPermissions(owner, repo) {
657
- const { exitCode, stdout } = await run2(["api", `repos/${owner}/${repo}`, "--jq", ".permissions"]);
658
- if (exitCode !== 0)
659
- return null;
660
- try {
661
- return JSON.parse(stdout.trim());
662
- } catch {
663
- return null;
2403
+ parts.push(pc9.yellow(hash));
2404
+ if (refs) {
2405
+ parts.push(` (${colorizeRefs(refs, protectedBranches, currentBranch)})`);
664
2406
  }
2407
+ parts.push(` ${colorizeSubject(subject)}`);
2408
+ return parts.join("");
665
2409
  }
666
- async function isRepoFork() {
667
- const { exitCode, stdout } = await run2(["repo", "view", "--json", "isFork", "-q", ".isFork"]);
668
- if (exitCode !== 0)
669
- return null;
670
- const val = stdout.trim();
671
- if (val === "true")
672
- return true;
673
- if (val === "false")
674
- return false;
675
- return null;
676
- }
677
- async function getCurrentRepoInfo() {
678
- const { exitCode, stdout } = await run2([
679
- "repo",
680
- "view",
681
- "--json",
682
- "nameWithOwner",
683
- "-q",
684
- ".nameWithOwner"
685
- ]);
686
- if (exitCode !== 0)
687
- return null;
688
- const nameWithOwner = stdout.trim();
689
- if (!nameWithOwner)
690
- return null;
691
- const [owner, repo] = nameWithOwner.split("/");
692
- if (!owner || !repo)
693
- return null;
694
- return { owner, repo };
695
- }
696
- async function createPR(options) {
697
- const args = [
698
- "pr",
699
- "create",
700
- "--base",
701
- options.base,
702
- "--title",
703
- options.title,
704
- "--body",
705
- options.body
706
- ];
707
- if (options.draft)
708
- args.push("--draft");
709
- return run2(args);
2410
+ function colorizeGraphChars(graphPart) {
2411
+ return graphPart.split("").map((ch) => {
2412
+ switch (ch) {
2413
+ case "*":
2414
+ return pc9.green(ch);
2415
+ case "|":
2416
+ return pc9.cyan(ch);
2417
+ case "/":
2418
+ case "\\":
2419
+ return pc9.cyan(ch);
2420
+ case "-":
2421
+ case "_":
2422
+ return pc9.cyan(ch);
2423
+ default:
2424
+ return ch;
2425
+ }
2426
+ }).join("");
710
2427
  }
711
- async function createPRFill(base, draft) {
712
- const args = ["pr", "create", "--base", base, "--fill"];
713
- if (draft)
714
- args.push("--draft");
715
- return run2(args);
2428
+ function colorizeRefs(refs, protectedBranches, currentBranch) {
2429
+ return refs.split(",").map((ref) => {
2430
+ const trimmed = ref.trim();
2431
+ if (trimmed.startsWith("HEAD ->") || trimmed === "HEAD") {
2432
+ const branchName = trimmed.replace("HEAD -> ", "");
2433
+ if (trimmed === "HEAD") {
2434
+ return pc9.bold(pc9.cyan("HEAD"));
2435
+ }
2436
+ return `${pc9.bold(pc9.cyan("HEAD"))} ${pc9.dim("->")} ${colorizeRefName(branchName, protectedBranches, currentBranch)}`;
2437
+ }
2438
+ if (trimmed.startsWith("tag:")) {
2439
+ return pc9.bold(pc9.magenta(trimmed));
2440
+ }
2441
+ return colorizeRefName(trimmed, protectedBranches, currentBranch);
2442
+ }).join(pc9.dim(", "));
716
2443
  }
717
-
718
- // src/utils/remote.ts
719
- function parseRepoFromUrl(url) {
720
- const httpsMatch = url.match(/https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
721
- if (httpsMatch) {
722
- return { owner: httpsMatch[1], repo: httpsMatch[2] };
2444
+ function colorizeRefName(name, protectedBranches, currentBranch) {
2445
+ const isRemote = name.includes("/");
2446
+ const localName = isRemote ? name.split("/").slice(1).join("/") : name;
2447
+ if (protectedBranches.includes(localName)) {
2448
+ return isRemote ? pc9.bold(pc9.red(name)) : pc9.bold(pc9.red(name));
723
2449
  }
724
- const sshMatch = url.match(/git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
725
- if (sshMatch) {
726
- return { owner: sshMatch[1], repo: sshMatch[2] };
2450
+ if (localName === currentBranch) {
2451
+ return pc9.bold(pc9.green(name));
727
2452
  }
728
- return null;
2453
+ if (isRemote) {
2454
+ return pc9.blue(name);
2455
+ }
2456
+ return pc9.green(name);
729
2457
  }
730
- async function getRepoInfoFromRemote(remote = "origin") {
731
- const url = await getRemoteUrl(remote);
732
- if (!url)
733
- return null;
734
- return parseRepoFromUrl(url);
2458
+ function colorizeSubject(subject) {
2459
+ const emojiMatch = subject.match(/^([\p{Emoji_Presentation}\p{Emoji}\uFE0F]+\s*)/u);
2460
+ if (emojiMatch) {
2461
+ const emoji = emojiMatch[1];
2462
+ const rest = subject.slice(emoji.length);
2463
+ return `${emoji}${pc9.white(rest)}`;
2464
+ }
2465
+ if (subject.startsWith("Merge ")) {
2466
+ return pc9.dim(subject);
2467
+ }
2468
+ return pc9.white(subject);
735
2469
  }
736
2470
 
737
2471
  // src/commands/setup.ts
738
- var setup_default = defineCommand3({
2472
+ import { defineCommand as defineCommand7 } from "citty";
2473
+ import pc10 from "picocolors";
2474
+ var setup_default = defineCommand7({
739
2475
  meta: {
740
2476
  name: "setup",
741
2477
  description: "Initialize contribute-now config for this repo (.contributerc.json)"
@@ -746,6 +2482,27 @@ var setup_default = defineCommand3({
746
2482
  process.exit(1);
747
2483
  }
748
2484
  heading("\uD83D\uDD27 contribute-now setup");
2485
+ const workflowChoice = await selectPrompt("Which git workflow does this project use?", [
2486
+ "Clean Flow — main + dev, squash features into dev, merge dev into main (recommended)",
2487
+ "GitHub Flow — main + feature branches, squash/merge into main",
2488
+ "Git Flow — main + develop + release + hotfix branches"
2489
+ ]);
2490
+ let workflow = "clean-flow";
2491
+ if (workflowChoice.startsWith("GitHub"))
2492
+ workflow = "github-flow";
2493
+ else if (workflowChoice.startsWith("Git Flow"))
2494
+ workflow = "git-flow";
2495
+ info(`Workflow: ${pc10.bold(WORKFLOW_DESCRIPTIONS[workflow])}`);
2496
+ const conventionChoice = await selectPrompt("Which commit convention should this project use?", [
2497
+ `${CONVENTION_DESCRIPTIONS["clean-commit"]} (recommended)`,
2498
+ CONVENTION_DESCRIPTIONS.conventional,
2499
+ CONVENTION_DESCRIPTIONS.none
2500
+ ]);
2501
+ let commitConvention = "clean-commit";
2502
+ if (conventionChoice.includes("Conventional Commits"))
2503
+ commitConvention = "conventional";
2504
+ else if (conventionChoice.includes("No commit"))
2505
+ commitConvention = "none";
749
2506
  const remotes = await getRemotes();
750
2507
  if (remotes.length === 0) {
751
2508
  error("No git remotes found. Add a remote first (e.g., git remote add origin <url>).");
@@ -788,8 +2545,8 @@ var setup_default = defineCommand3({
788
2545
  detectedRole = roleChoice;
789
2546
  detectionSource = "user selection";
790
2547
  } else {
791
- info(`Detected role: ${pc5.bold(detectedRole)} (via ${detectionSource})`);
792
- const confirmed = await confirmPrompt(`Role detected as ${pc5.bold(detectedRole)}. Is this correct?`);
2548
+ info(`Detected role: ${pc10.bold(detectedRole)} (via ${detectionSource})`);
2549
+ const confirmed = await confirmPrompt(`Role detected as ${pc10.bold(detectedRole)}. Is this correct?`);
793
2550
  if (!confirmed) {
794
2551
  const roleChoice = await selectPrompt("Select your role:", ["maintainer", "contributor"]);
795
2552
  detectedRole = roleChoice;
@@ -797,7 +2554,11 @@ var setup_default = defineCommand3({
797
2554
  }
798
2555
  const defaultConfig = getDefaultConfig();
799
2556
  const mainBranch = await inputPrompt("Main branch name", defaultConfig.mainBranch);
800
- const devBranch = await inputPrompt("Dev branch name", defaultConfig.devBranch);
2557
+ let devBranch;
2558
+ if (hasDevBranch(workflow)) {
2559
+ const defaultDev = workflow === "git-flow" ? "develop" : "dev";
2560
+ devBranch = await inputPrompt("Dev/develop branch name", defaultDev);
2561
+ }
801
2562
  const originRemote = await inputPrompt("Origin remote name", defaultConfig.origin);
802
2563
  let upstreamRemote = defaultConfig.upstream;
803
2564
  if (detectedRole === "contributor") {
@@ -814,12 +2575,14 @@ var setup_default = defineCommand3({
814
2575
  }
815
2576
  }
816
2577
  const config = {
2578
+ workflow,
817
2579
  role: detectedRole,
818
2580
  mainBranch,
819
- devBranch,
2581
+ ...devBranch ? { devBranch } : {},
820
2582
  upstream: upstreamRemote,
821
2583
  origin: originRemote,
822
- branchPrefixes: defaultConfig.branchPrefixes
2584
+ branchPrefixes: defaultConfig.branchPrefixes,
2585
+ commitConvention
823
2586
  };
824
2587
  writeConfig(config);
825
2588
  success(`✅ Config written to .contributerc.json`);
@@ -828,34 +2591,25 @@ var setup_default = defineCommand3({
828
2591
  warn(' echo ".contributerc.json" >> .gitignore');
829
2592
  }
830
2593
  console.log();
831
- info(`Role: ${pc5.bold(config.role)}`);
832
- info(`Main: ${pc5.bold(config.mainBranch)} | Dev: ${pc5.bold(config.devBranch)}`);
833
- info(`Origin: ${pc5.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc5.bold(config.upstream)}` : ""}`);
2594
+ info(`Workflow: ${pc10.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
2595
+ info(`Convention: ${pc10.bold(CONVENTION_DESCRIPTIONS[config.commitConvention])}`);
2596
+ info(`Role: ${pc10.bold(config.role)}`);
2597
+ if (config.devBranch) {
2598
+ info(`Main: ${pc10.bold(config.mainBranch)} | Dev: ${pc10.bold(config.devBranch)}`);
2599
+ } else {
2600
+ info(`Main: ${pc10.bold(config.mainBranch)}`);
2601
+ }
2602
+ info(`Origin: ${pc10.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc10.bold(config.upstream)}` : ""}`);
834
2603
  }
835
2604
  });
836
2605
 
837
2606
  // src/commands/start.ts
838
- import { defineCommand as defineCommand4 } from "citty";
839
- import pc6 from "picocolors";
840
-
841
- // src/utils/branch.ts
842
- var DEFAULT_PREFIXES = ["feature", "fix", "docs", "chore", "test", "refactor"];
843
- function hasPrefix(branchName, prefixes = DEFAULT_PREFIXES) {
844
- return prefixes.some((p) => branchName.startsWith(`${p}/`));
845
- }
846
- function formatBranchName(prefix, name) {
847
- const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
848
- return `${prefix}/${sanitized}`;
849
- }
850
- function looksLikeNaturalLanguage(input) {
851
- return input.includes(" ") && !input.includes("/");
852
- }
853
-
854
- // src/commands/start.ts
855
- var start_default = defineCommand4({
2607
+ import { defineCommand as defineCommand8 } from "citty";
2608
+ import pc11 from "picocolors";
2609
+ var start_default = defineCommand8({
856
2610
  meta: {
857
2611
  name: "start",
858
- description: "Create a new feature branch from the latest dev"
2612
+ description: "Create a new feature branch from the latest base branch"
859
2613
  },
860
2614
  args: {
861
2615
  name: {
@@ -887,50 +2641,57 @@ var start_default = defineCommand4({
887
2641
  error("You have uncommitted changes. Please commit or stash them before creating a branch.");
888
2642
  process.exit(1);
889
2643
  }
890
- const { devBranch, origin, upstream, branchPrefixes, role } = config;
2644
+ const { branchPrefixes } = config;
2645
+ const baseBranch = getBaseBranch(config);
2646
+ const syncSource = getSyncSource(config);
891
2647
  let branchName = args.name;
892
2648
  heading("\uD83C\uDF3F contrib start");
893
2649
  const useAI = !args["no-ai"] && looksLikeNaturalLanguage(branchName);
894
2650
  if (useAI) {
895
- info("Generating branch name suggestion from description...");
2651
+ const spinner = createSpinner("Generating branch name suggestion...");
896
2652
  const suggested = await suggestBranchName(branchName, args.model);
897
2653
  if (suggested) {
2654
+ spinner.success("Branch name suggestion ready.");
898
2655
  console.log(`
899
- ${pc6.dim("AI suggestion:")} ${pc6.bold(pc6.cyan(suggested))}`);
900
- const accepted = await confirmPrompt(`Use ${pc6.bold(suggested)} as your branch name?`);
2656
+ ${pc11.dim("AI suggestion:")} ${pc11.bold(pc11.cyan(suggested))}`);
2657
+ const accepted = await confirmPrompt(`Use ${pc11.bold(suggested)} as your branch name?`);
901
2658
  if (accepted) {
902
2659
  branchName = suggested;
903
2660
  } else {
904
2661
  branchName = await inputPrompt("Enter branch name", branchName);
905
2662
  }
2663
+ } else {
2664
+ spinner.fail("AI did not return a branch name suggestion.");
906
2665
  }
907
2666
  }
908
2667
  if (!hasPrefix(branchName, branchPrefixes)) {
909
- const prefix = await selectPrompt(`Choose a branch type for ${pc6.bold(branchName)}:`, branchPrefixes);
2668
+ const prefix = await selectPrompt(`Choose a branch type for ${pc11.bold(branchName)}:`, branchPrefixes);
910
2669
  branchName = formatBranchName(prefix, branchName);
911
2670
  }
912
- info(`Creating branch: ${pc6.bold(branchName)}`);
913
- const remote = role === "contributor" ? upstream : origin;
914
- const remoteDevRef = role === "contributor" ? `${upstream}/${devBranch}` : `${origin}/${devBranch}`;
915
- await fetchRemote(remote);
916
- const resetResult = await resetHard(remoteDevRef);
917
- if (resetResult.exitCode !== 0) {}
918
- const result = await createBranch(branchName, devBranch);
2671
+ if (!isValidBranchName(branchName)) {
2672
+ error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
2673
+ process.exit(1);
2674
+ }
2675
+ info(`Creating branch: ${pc11.bold(branchName)}`);
2676
+ await fetchRemote(syncSource.remote);
2677
+ const updateResult = await updateLocalBranch(baseBranch, syncSource.ref);
2678
+ if (updateResult.exitCode !== 0) {}
2679
+ const result = await createBranch(branchName, baseBranch);
919
2680
  if (result.exitCode !== 0) {
920
2681
  error(`Failed to create branch: ${result.stderr}`);
921
2682
  process.exit(1);
922
2683
  }
923
- success(`✅ Created ${pc6.bold(branchName)} from latest ${pc6.bold(devBranch)}`);
2684
+ success(`✅ Created ${pc11.bold(branchName)} from latest ${pc11.bold(baseBranch)}`);
924
2685
  }
925
2686
  });
926
2687
 
927
- // src/commands/status.ts
928
- import { defineCommand as defineCommand5 } from "citty";
929
- import pc7 from "picocolors";
930
- var status_default = defineCommand5({
2688
+ // src/commands/status.ts
2689
+ import { defineCommand as defineCommand9 } from "citty";
2690
+ import pc12 from "picocolors";
2691
+ var status_default = defineCommand9({
931
2692
  meta: {
932
2693
  name: "status",
933
- description: "Show sync status of main, dev, and current branch"
2694
+ description: "Show sync status of branches"
934
2695
  },
935
2696
  async run() {
936
2697
  if (!await isGitRepo()) {
@@ -943,60 +2704,157 @@ var status_default = defineCommand5({
943
2704
  process.exit(1);
944
2705
  }
945
2706
  heading("\uD83D\uDCCA contribute-now status");
2707
+ console.log(` ${pc12.dim("Workflow:")} ${pc12.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
2708
+ console.log(` ${pc12.dim("Role:")} ${pc12.bold(config.role)}`);
2709
+ console.log();
946
2710
  await fetchAll();
947
2711
  const currentBranch = await getCurrentBranch();
948
- const { mainBranch, devBranch, origin, upstream } = config;
2712
+ const { mainBranch, origin, upstream, workflow } = config;
2713
+ const baseBranch = getBaseBranch(config);
949
2714
  const isContributor = config.role === "contributor";
950
- const dirty = await hasUncommittedChanges();
2715
+ const [dirty, fileStatus] = await Promise.all([hasUncommittedChanges(), getFileStatus()]);
951
2716
  if (dirty) {
952
- console.log(` ${pc7.yellow("⚠")} ${pc7.yellow("Uncommitted changes in working tree")}`);
2717
+ console.log(` ${pc12.yellow("⚠")} ${pc12.yellow("Uncommitted changes in working tree")}`);
953
2718
  console.log();
954
2719
  }
955
2720
  const mainRemote = `${origin}/${mainBranch}`;
956
2721
  const mainDiv = await getDivergence(mainBranch, mainRemote);
957
2722
  const mainStatus = formatStatus(mainBranch, mainRemote, mainDiv.ahead, mainDiv.behind);
958
2723
  console.log(mainStatus);
959
- const devRemoteRef = isContributor ? `${upstream}/${devBranch}` : `${origin}/${mainBranch}`;
960
- const devDiv = await getDivergence(devBranch, devRemoteRef);
961
- let devLine = formatStatus(devBranch, devRemoteRef, devDiv.ahead, devDiv.behind);
962
- if (!isContributor && devDiv.ahead > 0 && devDiv.behind > 0) {
963
- devLine += pc7.red(" (needs sync! squash-merge divergence detected)");
964
- } else if (devDiv.ahead > 0 && devDiv.behind === 0) {
965
- devLine += pc7.yellow(" (needs sync!)");
966
- }
967
- console.log(devLine);
968
- if (currentBranch && currentBranch !== mainBranch && currentBranch !== devBranch) {
969
- const branchDiv = await getDivergence(currentBranch, devBranch);
970
- const branchLine = formatStatus(currentBranch, devBranch, branchDiv.ahead, branchDiv.behind);
971
- console.log(branchLine + pc7.dim(` (current ${pc7.green("*")})`));
2724
+ if (hasDevBranch(workflow) && config.devBranch) {
2725
+ const devRemoteRef = isContributor ? `${upstream}/${config.devBranch}` : `${origin}/${config.devBranch}`;
2726
+ const devDiv = await getDivergence(config.devBranch, devRemoteRef);
2727
+ const devLine = formatStatus(config.devBranch, devRemoteRef, devDiv.ahead, devDiv.behind);
2728
+ console.log(devLine);
2729
+ }
2730
+ if (currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
2731
+ const branchDiv = await getDivergence(currentBranch, baseBranch);
2732
+ const branchLine = formatStatus(currentBranch, baseBranch, branchDiv.ahead, branchDiv.behind);
2733
+ console.log(branchLine + pc12.dim(` (current ${pc12.green("*")})`));
972
2734
  } else if (currentBranch) {
973
- if (currentBranch === mainBranch) {
974
- console.log(pc7.dim(` (on ${pc7.bold(mainBranch)} branch)`));
975
- } else if (currentBranch === devBranch) {
976
- console.log(pc7.dim(` (on ${pc7.bold(devBranch)} branch)`));
2735
+ console.log(pc12.dim(` (on ${pc12.bold(currentBranch)} branch)`));
2736
+ }
2737
+ const hasFiles = fileStatus.staged.length > 0 || fileStatus.modified.length > 0 || fileStatus.untracked.length > 0;
2738
+ if (hasFiles) {
2739
+ console.log();
2740
+ if (fileStatus.staged.length > 0) {
2741
+ console.log(` ${pc12.green("Staged for commit:")}`);
2742
+ for (const { file, status } of fileStatus.staged) {
2743
+ console.log(` ${pc12.green("+")} ${pc12.dim(`${status}:`)} ${file}`);
2744
+ }
2745
+ }
2746
+ if (fileStatus.modified.length > 0) {
2747
+ console.log(` ${pc12.yellow("Unstaged changes:")}`);
2748
+ for (const { file, status } of fileStatus.modified) {
2749
+ console.log(` ${pc12.yellow("~")} ${pc12.dim(`${status}:`)} ${file}`);
2750
+ }
2751
+ }
2752
+ if (fileStatus.untracked.length > 0) {
2753
+ console.log(` ${pc12.red("Untracked files:")}`);
2754
+ for (const file of fileStatus.untracked) {
2755
+ console.log(` ${pc12.red("?")} ${file}`);
2756
+ }
2757
+ }
2758
+ } else if (!dirty) {
2759
+ console.log(` ${pc12.green("✓")} ${pc12.dim("Working tree clean")}`);
2760
+ }
2761
+ const tips = [];
2762
+ if (fileStatus.staged.length > 0) {
2763
+ tips.push(`Run ${pc12.bold("contrib commit")} to commit staged changes`);
2764
+ }
2765
+ if (fileStatus.modified.length > 0 || fileStatus.untracked.length > 0) {
2766
+ tips.push(`Run ${pc12.bold("contrib commit")} to stage and commit changes`);
2767
+ }
2768
+ if (fileStatus.staged.length === 0 && fileStatus.modified.length === 0 && fileStatus.untracked.length === 0 && currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
2769
+ const branchDiv = await getDivergence(currentBranch, `${origin}/${currentBranch}`);
2770
+ if (branchDiv.ahead > 0) {
2771
+ tips.push(`Run ${pc12.bold("contrib submit")} to push and create/update your PR`);
2772
+ }
2773
+ }
2774
+ if (tips.length > 0) {
2775
+ console.log();
2776
+ console.log(` ${pc12.dim("\uD83D\uDCA1 Tip:")}`);
2777
+ for (const tip of tips) {
2778
+ console.log(` ${pc12.dim(tip)}`);
977
2779
  }
978
2780
  }
979
2781
  console.log();
980
2782
  }
981
2783
  });
982
2784
  function formatStatus(branch, base, ahead, behind) {
983
- const label = pc7.bold(branch.padEnd(20));
2785
+ const label = pc12.bold(branch.padEnd(20));
984
2786
  if (ahead === 0 && behind === 0) {
985
- return ` ${pc7.green("✓")} ${label} ${pc7.dim(`in sync with ${base}`)}`;
2787
+ return ` ${pc12.green("✓")} ${label} ${pc12.dim(`in sync with ${base}`)}`;
986
2788
  }
987
2789
  if (ahead > 0 && behind === 0) {
988
- return ` ${pc7.yellow("↑")} ${label} ${pc7.yellow(`${ahead} commit${ahead !== 1 ? "s" : ""} ahead of ${base}`)}`;
2790
+ return ` ${pc12.yellow("↑")} ${label} ${pc12.yellow(`${ahead} commit${ahead !== 1 ? "s" : ""} ahead of ${base}`)}`;
989
2791
  }
990
2792
  if (behind > 0 && ahead === 0) {
991
- return ` ${pc7.red("↓")} ${label} ${pc7.red(`${behind} commit${behind !== 1 ? "s" : ""} behind ${base}`)}`;
2793
+ return ` ${pc12.red("↓")} ${label} ${pc12.red(`${behind} commit${behind !== 1 ? "s" : ""} behind ${base}`)}`;
992
2794
  }
993
- return ` ${pc7.red("⚡")} ${label} ${pc7.yellow(`${ahead} ahead`)}${pc7.dim(", ")}${pc7.red(`${behind} behind`)} ${pc7.dim(base)}`;
2795
+ return ` ${pc12.red("⚡")} ${label} ${pc12.yellow(`${ahead} ahead`)}${pc12.dim(", ")}${pc12.red(`${behind} behind`)} ${pc12.dim(base)}`;
994
2796
  }
995
2797
 
996
2798
  // src/commands/submit.ts
997
- import { defineCommand as defineCommand6 } from "citty";
998
- import pc8 from "picocolors";
999
- var submit_default = defineCommand6({
2799
+ import { defineCommand as defineCommand10 } from "citty";
2800
+ import pc13 from "picocolors";
2801
+ async function performSquashMerge(origin, baseBranch, featureBranch, options) {
2802
+ info(`Checking out ${pc13.bold(baseBranch)}...`);
2803
+ const coResult = await checkoutBranch2(baseBranch);
2804
+ if (coResult.exitCode !== 0) {
2805
+ error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
2806
+ process.exit(1);
2807
+ }
2808
+ info(`Squash merging ${pc13.bold(featureBranch)} into ${pc13.bold(baseBranch)}...`);
2809
+ const mergeResult = await mergeSquash(featureBranch);
2810
+ if (mergeResult.exitCode !== 0) {
2811
+ error(`Squash merge failed: ${mergeResult.stderr}`);
2812
+ process.exit(1);
2813
+ }
2814
+ let message = options?.defaultMsg;
2815
+ if (!message) {
2816
+ const copilotError = await checkCopilotAvailable();
2817
+ if (!copilotError) {
2818
+ const spinner = createSpinner("Generating AI commit message for squash merge...");
2819
+ const [stagedDiff, stagedFiles] = await Promise.all([getStagedDiff(), getStagedFiles()]);
2820
+ const aiMsg = await generateCommitMessage(stagedDiff, stagedFiles, options?.model, options?.convention ?? "clean-commit");
2821
+ if (aiMsg) {
2822
+ message = aiMsg;
2823
+ spinner.success("AI commit message generated.");
2824
+ } else {
2825
+ spinner.fail("AI did not return a commit message.");
2826
+ }
2827
+ } else {
2828
+ warn(`AI unavailable: ${copilotError}`);
2829
+ }
2830
+ }
2831
+ const fallback = message || `squash merge ${featureBranch}`;
2832
+ const finalMsg = await inputPrompt("Commit message", fallback);
2833
+ const commitResult = await commitWithMessage(finalMsg);
2834
+ if (commitResult.exitCode !== 0) {
2835
+ error(`Commit failed: ${commitResult.stderr}`);
2836
+ process.exit(1);
2837
+ }
2838
+ info(`Pushing ${pc13.bold(baseBranch)} to ${origin}...`);
2839
+ const pushResult = await pushBranch(origin, baseBranch);
2840
+ if (pushResult.exitCode !== 0) {
2841
+ error(`Failed to push ${baseBranch}: ${pushResult.stderr}`);
2842
+ process.exit(1);
2843
+ }
2844
+ info(`Deleting local branch ${pc13.bold(featureBranch)}...`);
2845
+ const delLocal = await forceDeleteBranch(featureBranch);
2846
+ if (delLocal.exitCode !== 0) {
2847
+ warn(`Could not delete local branch: ${delLocal.stderr.trim()}`);
2848
+ }
2849
+ info(`Deleting remote branch ${pc13.bold(featureBranch)}...`);
2850
+ const delRemote = await deleteRemoteBranch(origin, featureBranch);
2851
+ if (delRemote.exitCode !== 0) {
2852
+ warn(`Could not delete remote branch: ${delRemote.stderr.trim()}`);
2853
+ }
2854
+ success(`✅ Squash merged ${pc13.bold(featureBranch)} into ${pc13.bold(baseBranch)} and pushed.`);
2855
+ info(`Run ${pc13.bold("contrib start")} to begin a new feature.`);
2856
+ }
2857
+ var submit_default = defineCommand10({
1000
2858
  meta: {
1001
2859
  name: "submit",
1002
2860
  description: "Push current branch and create a pull request"
@@ -1027,103 +2885,332 @@ var submit_default = defineCommand6({
1027
2885
  error("No .contributerc.json found. Run `contrib setup` first.");
1028
2886
  process.exit(1);
1029
2887
  }
1030
- const { mainBranch, devBranch, origin } = config;
2888
+ const { origin } = config;
2889
+ const baseBranch = getBaseBranch(config);
2890
+ const protectedBranches = getProtectedBranches(config);
1031
2891
  const currentBranch = await getCurrentBranch();
1032
2892
  if (!currentBranch) {
1033
2893
  error("Could not determine current branch.");
1034
2894
  process.exit(1);
1035
2895
  }
1036
- if (currentBranch === mainBranch || currentBranch === devBranch) {
1037
- error(`Cannot submit ${pc8.bold(mainBranch)} or ${pc8.bold(devBranch)} as a PR. Switch to your feature branch.`);
1038
- process.exit(1);
2896
+ if (protectedBranches.includes(currentBranch)) {
2897
+ heading("\uD83D\uDE80 contrib submit");
2898
+ warn(`You're on ${pc13.bold(currentBranch)}, which is a protected branch. PRs should come from feature branches.`);
2899
+ await fetchAll();
2900
+ const remoteRef = `${origin}/${currentBranch}`;
2901
+ const localWork = await hasLocalWork(origin, currentBranch);
2902
+ const dirty = await hasUncommittedChanges();
2903
+ const hasCommits = localWork.unpushedCommits > 0;
2904
+ const hasAnything = hasCommits || dirty;
2905
+ if (!hasAnything) {
2906
+ error("No local changes or commits to move. Switch to a feature branch first.");
2907
+ info(` Run ${pc13.bold("contrib start")} to create a new feature branch.`);
2908
+ process.exit(1);
2909
+ }
2910
+ if (hasCommits) {
2911
+ info(`Found ${pc13.bold(String(localWork.unpushedCommits))} unpushed commit${localWork.unpushedCommits !== 1 ? "s" : ""} on ${pc13.bold(currentBranch)}.`);
2912
+ }
2913
+ if (dirty) {
2914
+ info("You also have uncommitted changes in the working tree.");
2915
+ }
2916
+ console.log();
2917
+ const MOVE_BRANCH = "Move my changes to a new feature branch";
2918
+ const CANCEL2 = "Cancel (stay on this branch)";
2919
+ const action = await selectPrompt("Let's get you back on track. What would you like to do?", [MOVE_BRANCH, CANCEL2]);
2920
+ if (action === CANCEL2) {
2921
+ info("No changes made. You are still on your current branch.");
2922
+ return;
2923
+ }
2924
+ info(pc13.dim("Tip: Describe what you're working on in plain English and we'll generate a branch name."));
2925
+ const description = await inputPrompt("What are you working on?");
2926
+ let newBranchName = description;
2927
+ if (looksLikeNaturalLanguage(description)) {
2928
+ const copilotError = await checkCopilotAvailable();
2929
+ if (!copilotError) {
2930
+ const spinner = createSpinner("Generating branch name suggestion...");
2931
+ const suggested = await suggestBranchName(description, args.model);
2932
+ if (suggested) {
2933
+ spinner.success("Branch name suggestion ready.");
2934
+ console.log(`
2935
+ ${pc13.dim("AI suggestion:")} ${pc13.bold(pc13.cyan(suggested))}`);
2936
+ const accepted = await confirmPrompt(`Use ${pc13.bold(suggested)} as your branch name?`);
2937
+ newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
2938
+ } else {
2939
+ spinner.fail("AI did not return a suggestion.");
2940
+ newBranchName = await inputPrompt("Enter branch name", description);
2941
+ }
2942
+ }
2943
+ }
2944
+ if (!hasPrefix(newBranchName, config.branchPrefixes)) {
2945
+ const prefix = await selectPrompt(`Choose a branch type for ${pc13.bold(newBranchName)}:`, config.branchPrefixes);
2946
+ newBranchName = formatBranchName(prefix, newBranchName);
2947
+ }
2948
+ if (!isValidBranchName(newBranchName)) {
2949
+ error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
2950
+ process.exit(1);
2951
+ }
2952
+ const branchResult = await createBranch(newBranchName);
2953
+ if (branchResult.exitCode !== 0) {
2954
+ error(`Failed to create branch: ${branchResult.stderr}`);
2955
+ process.exit(1);
2956
+ }
2957
+ success(`Created ${pc13.bold(newBranchName)} with your changes.`);
2958
+ await updateLocalBranch(currentBranch, remoteRef);
2959
+ info(`Reset ${pc13.bold(currentBranch)} back to ${pc13.bold(remoteRef)} — no damage done.`);
2960
+ console.log();
2961
+ success(`You're now on ${pc13.bold(newBranchName)} with all your work intact.`);
2962
+ info(`Run ${pc13.bold("contrib submit")} again to push and create your PR.`);
2963
+ return;
1039
2964
  }
1040
2965
  heading("\uD83D\uDE80 contrib submit");
1041
- info(`Pushing ${pc8.bold(currentBranch)} to ${origin}...`);
1042
- const pushResult = await pushSetUpstream(origin, currentBranch);
1043
- if (pushResult.exitCode !== 0) {
1044
- error(`Failed to push: ${pushResult.stderr}`);
1045
- process.exit(1);
1046
- }
1047
2966
  const ghInstalled = await checkGhInstalled();
1048
2967
  const ghAuthed = ghInstalled && await checkGhAuth();
1049
- if (!ghInstalled || !ghAuthed) {
1050
- const repoInfo = await getRepoInfoFromRemote(origin);
1051
- if (repoInfo) {
1052
- const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${devBranch}...${currentBranch}?expand=1`;
2968
+ if (ghInstalled && ghAuthed) {
2969
+ const mergedPR = await getMergedPRForBranch(currentBranch);
2970
+ if (mergedPR) {
2971
+ warn(`PR #${mergedPR.number} (${pc13.bold(mergedPR.title)}) was already merged.`);
2972
+ const localWork = await hasLocalWork(origin, currentBranch);
2973
+ const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
2974
+ if (hasWork) {
2975
+ if (localWork.uncommitted) {
2976
+ warn("You have uncommitted changes in your working tree.");
2977
+ }
2978
+ if (localWork.unpushedCommits > 0) {
2979
+ warn(`You have ${pc13.bold(String(localWork.unpushedCommits))} local commit${localWork.unpushedCommits !== 1 ? "s" : ""} not in the merged PR.`);
2980
+ }
2981
+ const SAVE_NEW_BRANCH = "Save changes to a new branch";
2982
+ const DISCARD = "Discard all changes and clean up";
2983
+ const CANCEL2 = "Cancel";
2984
+ const action = await selectPrompt("This branch was merged but you have local changes. What would you like to do?", [SAVE_NEW_BRANCH, DISCARD, CANCEL2]);
2985
+ if (action === CANCEL2) {
2986
+ info("No changes made. You are still on your current branch.");
2987
+ return;
2988
+ }
2989
+ if (action === SAVE_NEW_BRANCH) {
2990
+ info(pc13.dim("Tip: Describe what you're working on in plain English and we'll generate a branch name."));
2991
+ const description = await inputPrompt("What are you working on?");
2992
+ let newBranchName = description;
2993
+ if (!args["no-ai"] && looksLikeNaturalLanguage(description)) {
2994
+ const spinner = createSpinner("Generating branch name suggestion...");
2995
+ const suggested = await suggestBranchName(description, args.model);
2996
+ if (suggested) {
2997
+ spinner.success("Branch name suggestion ready.");
2998
+ console.log(`
2999
+ ${pc13.dim("AI suggestion:")} ${pc13.bold(pc13.cyan(suggested))}`);
3000
+ const accepted = await confirmPrompt(`Use ${pc13.bold(suggested)} as your branch name?`);
3001
+ newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
3002
+ } else {
3003
+ spinner.fail("AI did not return a suggestion.");
3004
+ newBranchName = await inputPrompt("Enter branch name", description);
3005
+ }
3006
+ }
3007
+ if (!hasPrefix(newBranchName, config.branchPrefixes)) {
3008
+ const prefix = await selectPrompt(`Choose a branch type for ${pc13.bold(newBranchName)}:`, config.branchPrefixes);
3009
+ newBranchName = formatBranchName(prefix, newBranchName);
3010
+ }
3011
+ if (!isValidBranchName(newBranchName)) {
3012
+ error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
3013
+ process.exit(1);
3014
+ }
3015
+ const staleUpstream = await getUpstreamRef();
3016
+ const staleUpstreamHash = staleUpstream ? await getCommitHash(staleUpstream) : null;
3017
+ const renameResult = await renameBranch(currentBranch, newBranchName);
3018
+ if (renameResult.exitCode !== 0) {
3019
+ error(`Failed to rename branch: ${renameResult.stderr}`);
3020
+ process.exit(1);
3021
+ }
3022
+ success(`Renamed ${pc13.bold(currentBranch)} → ${pc13.bold(newBranchName)}`);
3023
+ await unsetUpstream();
3024
+ const syncSource2 = getSyncSource(config);
3025
+ info(`Syncing ${pc13.bold(newBranchName)} with latest ${pc13.bold(baseBranch)}...`);
3026
+ await fetchRemote(syncSource2.remote);
3027
+ let rebaseResult;
3028
+ if (staleUpstreamHash) {
3029
+ rebaseResult = await rebaseOnto(syncSource2.ref, staleUpstreamHash);
3030
+ } else {
3031
+ const savedStrategy = await determineRebaseStrategy(newBranchName, syncSource2.ref);
3032
+ rebaseResult = savedStrategy.strategy === "onto" && savedStrategy.ontoOldBase ? await rebaseOnto(syncSource2.ref, savedStrategy.ontoOldBase) : await rebase(syncSource2.ref);
3033
+ }
3034
+ if (rebaseResult.exitCode !== 0) {
3035
+ warn("Rebase encountered conflicts. Resolve them manually, then run:");
3036
+ info(` ${pc13.bold("git rebase --continue")}`);
3037
+ } else {
3038
+ success(`Rebased ${pc13.bold(newBranchName)} onto ${pc13.bold(syncSource2.ref)}.`);
3039
+ }
3040
+ info(`All your changes are preserved. Run ${pc13.bold("contrib submit")} when ready to create a new PR.`);
3041
+ return;
3042
+ }
3043
+ warn("Discarding local changes...");
3044
+ }
3045
+ const syncSource = getSyncSource(config);
3046
+ info(`Switching to ${pc13.bold(baseBranch)} and syncing...`);
3047
+ await fetchRemote(syncSource.remote);
3048
+ const coResult = await checkoutBranch2(baseBranch);
3049
+ if (coResult.exitCode !== 0) {
3050
+ error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
3051
+ process.exit(1);
3052
+ }
3053
+ await updateLocalBranch(baseBranch, syncSource.ref);
3054
+ success(`Synced ${pc13.bold(baseBranch)} with ${pc13.bold(syncSource.ref)}.`);
3055
+ info(`Deleting stale branch ${pc13.bold(currentBranch)}...`);
3056
+ const delResult = await forceDeleteBranch(currentBranch);
3057
+ if (delResult.exitCode === 0) {
3058
+ success(`Deleted ${pc13.bold(currentBranch)}.`);
3059
+ } else {
3060
+ warn(`Could not delete branch: ${delResult.stderr.trim()}`);
3061
+ }
1053
3062
  console.log();
1054
- info("Create your PR manually:");
1055
- console.log(` ${pc8.cyan(prUrl)}`);
1056
- } else {
1057
- info("gh CLI not available. Create your PR manually on GitHub.");
3063
+ info(`You're now on ${pc13.bold(baseBranch)}. Run ${pc13.bold("contrib start")} to begin a new feature.`);
3064
+ return;
1058
3065
  }
1059
- return;
1060
3066
  }
1061
3067
  let prTitle = null;
1062
3068
  let prBody = null;
1063
- if (!args["no-ai"]) {
1064
- const copilotError = await checkCopilotAvailable();
3069
+ async function tryGenerateAI() {
3070
+ const [copilotError, commits, diff] = await Promise.all([
3071
+ checkCopilotAvailable(),
3072
+ getLog(baseBranch, "HEAD"),
3073
+ getLogDiff(baseBranch, "HEAD")
3074
+ ]);
1065
3075
  if (!copilotError) {
1066
- info("Generating AI PR description...");
1067
- const commits = await getLog(devBranch, "HEAD");
1068
- const diff = await getLogDiff(devBranch, "HEAD");
1069
- const result = await generatePRDescription(commits, diff, args.model);
3076
+ const spinner = createSpinner("Generating AI PR description...");
3077
+ const result = await generatePRDescription(commits, diff, args.model, config.commitConvention);
1070
3078
  if (result) {
1071
3079
  prTitle = result.title;
1072
3080
  prBody = result.body;
3081
+ spinner.success("PR description generated.");
1073
3082
  console.log(`
1074
- ${pc8.dim("AI title:")} ${pc8.bold(pc8.cyan(prTitle))}`);
3083
+ ${pc13.dim("AI title:")} ${pc13.bold(pc13.cyan(prTitle))}`);
1075
3084
  console.log(`
1076
- ${pc8.dim("AI body preview:")}`);
1077
- console.log(pc8.dim(prBody.slice(0, 300) + (prBody.length > 300 ? "..." : "")));
3085
+ ${pc13.dim("AI body preview:")}`);
3086
+ console.log(pc13.dim(prBody.slice(0, 300) + (prBody.length > 300 ? "..." : "")));
1078
3087
  } else {
1079
- warn("AI did not return a PR description.");
3088
+ spinner.fail("AI did not return a PR description.");
1080
3089
  }
1081
3090
  } else {
1082
3091
  warn(`AI unavailable: ${copilotError}`);
1083
3092
  }
1084
3093
  }
1085
- if (prTitle && prBody) {
1086
- const action = await selectPrompt("What would you like to do with the PR description?", [
1087
- "Use AI description",
1088
- "Edit title",
1089
- "Write manually",
1090
- "Use gh --fill (auto-fill from commits)"
1091
- ]);
1092
- if (action === "Use AI description") {} else if (action === "Edit title") {
1093
- prTitle = await inputPrompt("PR title", prTitle);
1094
- } else if (action === "Write manually") {
1095
- prTitle = await inputPrompt("PR title");
1096
- prBody = await inputPrompt("PR body (markdown)");
3094
+ if (!args["no-ai"]) {
3095
+ await tryGenerateAI();
3096
+ }
3097
+ const CANCEL = "Cancel";
3098
+ const SQUASH_LOCAL = `Squash merge to ${baseBranch} locally (no PR)`;
3099
+ const REGENERATE = "Regenerate AI description";
3100
+ let submitAction = "cancel";
3101
+ const isMaintainer = config.role === "maintainer";
3102
+ let actionResolved = false;
3103
+ while (!actionResolved) {
3104
+ if (prTitle && prBody) {
3105
+ const choices = ["Use AI description"];
3106
+ if (isMaintainer)
3107
+ choices.push(SQUASH_LOCAL);
3108
+ choices.push("Edit title", "Write manually", "Use gh --fill (auto-fill from commits)", REGENERATE, CANCEL);
3109
+ const action = await selectPrompt("What would you like to do with the PR description?", choices);
3110
+ if (action === CANCEL) {
3111
+ submitAction = "cancel";
3112
+ actionResolved = true;
3113
+ } else if (action === REGENERATE) {
3114
+ prTitle = null;
3115
+ prBody = null;
3116
+ await tryGenerateAI();
3117
+ } else if (action === SQUASH_LOCAL) {
3118
+ submitAction = "squash";
3119
+ actionResolved = true;
3120
+ } else if (action === "Use AI description") {
3121
+ submitAction = "create-pr";
3122
+ actionResolved = true;
3123
+ } else if (action === "Edit title") {
3124
+ prTitle = await inputPrompt("PR title", prTitle);
3125
+ submitAction = "create-pr";
3126
+ actionResolved = true;
3127
+ } else if (action === "Write manually") {
3128
+ prTitle = await inputPrompt("PR title");
3129
+ prBody = await inputPrompt("PR body (markdown)");
3130
+ submitAction = "create-pr";
3131
+ actionResolved = true;
3132
+ } else {
3133
+ submitAction = "fill";
3134
+ actionResolved = true;
3135
+ }
1097
3136
  } else {
1098
- const fillResult = await createPRFill(devBranch, args.draft);
1099
- if (fillResult.exitCode !== 0) {
1100
- error(`Failed to create PR: ${fillResult.stderr}`);
1101
- process.exit(1);
3137
+ const choices = [];
3138
+ if (isMaintainer)
3139
+ choices.push(SQUASH_LOCAL);
3140
+ if (!args["no-ai"])
3141
+ choices.push(REGENERATE);
3142
+ choices.push("Write title & body manually", "Use gh --fill (auto-fill from commits)", CANCEL);
3143
+ const action = await selectPrompt("How would you like to create the PR?", choices);
3144
+ if (action === CANCEL) {
3145
+ submitAction = "cancel";
3146
+ actionResolved = true;
3147
+ } else if (action === REGENERATE) {
3148
+ await tryGenerateAI();
3149
+ } else if (action === SQUASH_LOCAL) {
3150
+ submitAction = "squash";
3151
+ actionResolved = true;
3152
+ } else if (action === "Write title & body manually") {
3153
+ prTitle = await inputPrompt("PR title");
3154
+ prBody = await inputPrompt("PR body (markdown)");
3155
+ submitAction = "create-pr";
3156
+ actionResolved = true;
3157
+ } else {
3158
+ submitAction = "fill";
3159
+ actionResolved = true;
1102
3160
  }
1103
- success(`✅ PR created: ${fillResult.stdout.trim()}`);
1104
- return;
1105
3161
  }
1106
- } else {
1107
- const useManual = await confirmPrompt("Create PR with manual title/body?");
1108
- if (useManual) {
1109
- prTitle = await inputPrompt("PR title");
1110
- prBody = await inputPrompt("PR body (markdown)");
3162
+ }
3163
+ if (submitAction === "cancel") {
3164
+ warn("Submit cancelled.");
3165
+ return;
3166
+ }
3167
+ if (submitAction === "squash") {
3168
+ await performSquashMerge(origin, baseBranch, currentBranch, {
3169
+ defaultMsg: prTitle ?? undefined,
3170
+ model: args.model,
3171
+ convention: config.commitConvention
3172
+ });
3173
+ return;
3174
+ }
3175
+ info(`Pushing ${pc13.bold(currentBranch)} to ${origin}...`);
3176
+ const pushResult = await pushSetUpstream(origin, currentBranch);
3177
+ if (pushResult.exitCode !== 0) {
3178
+ error(`Failed to push: ${pushResult.stderr}`);
3179
+ process.exit(1);
3180
+ }
3181
+ if (!ghInstalled || !ghAuthed) {
3182
+ const repoInfo = await getRepoInfoFromRemote(origin);
3183
+ if (repoInfo) {
3184
+ const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${baseBranch}...${currentBranch}?expand=1`;
3185
+ console.log();
3186
+ info("Create your PR manually:");
3187
+ console.log(` ${pc13.cyan(prUrl)}`);
1111
3188
  } else {
1112
- const fillResult = await createPRFill(devBranch, args.draft);
1113
- if (fillResult.exitCode !== 0) {
1114
- error(`Failed to create PR: ${fillResult.stderr}`);
1115
- process.exit(1);
1116
- }
1117
- success(`✅ PR created: ${fillResult.stdout.trim()}`);
1118
- return;
3189
+ info("gh CLI not available. Create your PR manually on GitHub.");
1119
3190
  }
3191
+ return;
3192
+ }
3193
+ const existingPR = await getPRForBranch(currentBranch);
3194
+ if (existingPR) {
3195
+ success(`Pushed changes to existing PR #${existingPR.number}: ${pc13.bold(existingPR.title)}`);
3196
+ console.log(` ${pc13.cyan(existingPR.url)}`);
3197
+ return;
3198
+ }
3199
+ if (submitAction === "fill") {
3200
+ const fillResult = await createPRFill(baseBranch, args.draft);
3201
+ if (fillResult.exitCode !== 0) {
3202
+ error(`Failed to create PR: ${fillResult.stderr}`);
3203
+ process.exit(1);
3204
+ }
3205
+ success(`✅ PR created: ${fillResult.stdout.trim()}`);
3206
+ return;
1120
3207
  }
1121
3208
  if (!prTitle) {
1122
3209
  error("No PR title provided.");
1123
3210
  process.exit(1);
1124
3211
  }
1125
3212
  const prResult = await createPR({
1126
- base: devBranch,
3213
+ base: baseBranch,
1127
3214
  title: prTitle,
1128
3215
  body: prBody ?? "",
1129
3216
  draft: args.draft
@@ -1137,12 +3224,12 @@ ${pc8.dim("AI body preview:")}`);
1137
3224
  });
1138
3225
 
1139
3226
  // src/commands/sync.ts
1140
- import { defineCommand as defineCommand7 } from "citty";
1141
- import pc9 from "picocolors";
1142
- var sync_default = defineCommand7({
3227
+ import { defineCommand as defineCommand11 } from "citty";
3228
+ import pc14 from "picocolors";
3229
+ var sync_default = defineCommand11({
1143
3230
  meta: {
1144
3231
  name: "sync",
1145
- description: "Reset dev branch to match origin/main (maintainer) or upstream/dev (contributor)"
3232
+ description: "Sync your local branches with the remote"
1146
3233
  },
1147
3234
  args: {
1148
3235
  yes: {
@@ -1162,86 +3249,70 @@ var sync_default = defineCommand7({
1162
3249
  error("No .contributerc.json found. Run `contrib setup` first.");
1163
3250
  process.exit(1);
1164
3251
  }
1165
- const { role, mainBranch, devBranch, origin, upstream } = config;
3252
+ const { workflow, role, origin } = config;
1166
3253
  if (await hasUncommittedChanges()) {
1167
3254
  error("You have uncommitted changes. Please commit or stash them before syncing.");
1168
3255
  process.exit(1);
1169
3256
  }
1170
- heading(`\uD83D\uDD04 contrib sync (${role})`);
1171
- if (role === "maintainer") {
1172
- info(`Fetching ${origin}...`);
1173
- const fetchResult = await fetchRemote(origin);
1174
- if (fetchResult.exitCode !== 0) {
1175
- error(`Failed to fetch ${origin}: ${fetchResult.stderr}`);
1176
- process.exit(1);
1177
- }
1178
- const div = await getDivergence(devBranch, `${origin}/${mainBranch}`);
1179
- if (div.ahead > 0 || div.behind > 0) {
1180
- info(`${pc9.bold(devBranch)} is ${pc9.yellow(`${div.ahead} ahead`)} and ${pc9.red(`${div.behind} behind`)} ${origin}/${mainBranch}`);
1181
- } else {
1182
- info(`${pc9.bold(devBranch)} is already in sync with ${origin}/${mainBranch}`);
1183
- }
1184
- if (!args.yes) {
1185
- const ok = await confirmPrompt(`This will reset ${pc9.bold(devBranch)} to match ${pc9.bold(`${origin}/${mainBranch}`)}.`);
1186
- if (!ok)
1187
- process.exit(0);
1188
- }
1189
- const coResult = await checkoutBranch(devBranch);
1190
- if (coResult.exitCode !== 0) {
1191
- error(`Failed to checkout ${devBranch}: ${coResult.stderr}`);
1192
- process.exit(1);
1193
- }
1194
- const resetResult = await resetHard(`${origin}/${mainBranch}`);
1195
- if (resetResult.exitCode !== 0) {
1196
- error(`Failed to reset: ${resetResult.stderr}`);
1197
- process.exit(1);
1198
- }
1199
- const pushResult = await pushForceWithLease(origin, devBranch);
1200
- if (pushResult.exitCode !== 0) {
1201
- error(`Failed to push: ${pushResult.stderr}`);
1202
- process.exit(1);
1203
- }
1204
- success(`✅ ${devBranch} has been reset to match ${origin}/${mainBranch} and pushed.`);
3257
+ heading(`\uD83D\uDD04 contrib sync (${workflow}, ${role})`);
3258
+ const baseBranch = getBaseBranch(config);
3259
+ const syncSource = getSyncSource(config);
3260
+ info(`Fetching ${syncSource.remote}...`);
3261
+ const fetchResult = await fetchRemote(syncSource.remote);
3262
+ if (fetchResult.exitCode !== 0) {
3263
+ error(`Failed to fetch ${syncSource.remote}: ${fetchResult.stderr}`);
3264
+ process.exit(1);
3265
+ }
3266
+ if (role === "contributor" && syncSource.remote !== origin) {
3267
+ await fetchRemote(origin);
3268
+ }
3269
+ const div = await getDivergence(baseBranch, syncSource.ref);
3270
+ if (div.ahead > 0 || div.behind > 0) {
3271
+ info(`${pc14.bold(baseBranch)} is ${pc14.yellow(`${div.ahead} ahead`)} and ${pc14.red(`${div.behind} behind`)} ${syncSource.ref}`);
1205
3272
  } else {
1206
- info(`Fetching ${upstream}...`);
1207
- const fetchResult = await fetchRemote(upstream);
1208
- if (fetchResult.exitCode !== 0) {
1209
- error(`Failed to fetch ${upstream}: ${fetchResult.stderr}`);
1210
- process.exit(1);
1211
- }
1212
- if (!args.yes) {
1213
- const ok = await confirmPrompt(`This will reset local ${pc9.bold(devBranch)} to match ${pc9.bold(`${upstream}/${devBranch}`)}.`);
1214
- if (!ok)
1215
- process.exit(0);
1216
- }
1217
- const coResult = await checkoutBranch(devBranch);
1218
- if (coResult.exitCode !== 0) {
1219
- error(`Failed to checkout ${devBranch}: ${coResult.stderr}`);
1220
- process.exit(1);
1221
- }
1222
- const resetResult = await resetHard(`${upstream}/${devBranch}`);
1223
- if (resetResult.exitCode !== 0) {
1224
- error(`Failed to reset: ${resetResult.stderr}`);
1225
- process.exit(1);
1226
- }
1227
- const pushResult = await pushForceWithLease(origin, devBranch);
1228
- if (pushResult.exitCode !== 0) {
1229
- error(`Failed to push: ${pushResult.stderr}`);
1230
- process.exit(1);
3273
+ info(`${pc14.bold(baseBranch)} is already in sync with ${syncSource.ref}`);
3274
+ }
3275
+ if (!args.yes) {
3276
+ const ok = await confirmPrompt(`This will pull ${pc14.bold(syncSource.ref)} into local ${pc14.bold(baseBranch)}.`);
3277
+ if (!ok)
3278
+ process.exit(0);
3279
+ }
3280
+ const coResult = await checkoutBranch2(baseBranch);
3281
+ if (coResult.exitCode !== 0) {
3282
+ error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
3283
+ process.exit(1);
3284
+ }
3285
+ const pullResult = await pullBranch(syncSource.remote, baseBranch);
3286
+ if (pullResult.exitCode !== 0) {
3287
+ error(`Failed to pull: ${pullResult.stderr}`);
3288
+ process.exit(1);
3289
+ }
3290
+ success(`✅ ${baseBranch} is now in sync with ${syncSource.ref}`);
3291
+ if (hasDevBranch(workflow) && role === "maintainer") {
3292
+ const mainDiv = await getDivergence(config.mainBranch, `${origin}/${config.mainBranch}`);
3293
+ if (mainDiv.behind > 0) {
3294
+ info(`Also syncing ${pc14.bold(config.mainBranch)}...`);
3295
+ const mainCoResult = await checkoutBranch2(config.mainBranch);
3296
+ if (mainCoResult.exitCode === 0) {
3297
+ const mainPullResult = await pullBranch(origin, config.mainBranch);
3298
+ if (mainPullResult.exitCode === 0) {
3299
+ success(`✅ ${config.mainBranch} is now in sync with ${origin}/${config.mainBranch}`);
3300
+ }
3301
+ }
3302
+ await checkoutBranch2(baseBranch);
1231
3303
  }
1232
- success(`✅ ${devBranch} has been reset to match ${upstream}/${devBranch} and pushed.`);
1233
3304
  }
1234
3305
  }
1235
3306
  });
1236
3307
 
1237
3308
  // src/commands/update.ts
1238
- import { readFileSync as readFileSync2 } from "node:fs";
1239
- import { defineCommand as defineCommand8 } from "citty";
1240
- import pc10 from "picocolors";
1241
- var update_default = defineCommand8({
3309
+ import { readFileSync as readFileSync4 } from "node:fs";
3310
+ import { defineCommand as defineCommand12 } from "citty";
3311
+ import pc15 from "picocolors";
3312
+ var update_default = defineCommand12({
1242
3313
  meta: {
1243
3314
  name: "update",
1244
- description: "Rebase current branch onto latest dev"
3315
+ description: "Rebase current branch onto the latest base branch"
1245
3316
  },
1246
3317
  args: {
1247
3318
  model: {
@@ -1264,14 +3335,16 @@ var update_default = defineCommand8({
1264
3335
  error("No .contributerc.json found. Run `contrib setup` first.");
1265
3336
  process.exit(1);
1266
3337
  }
1267
- const { mainBranch, devBranch, origin, upstream, role } = config;
3338
+ const baseBranch = getBaseBranch(config);
3339
+ const protectedBranches = getProtectedBranches(config);
3340
+ const syncSource = getSyncSource(config);
1268
3341
  const currentBranch = await getCurrentBranch();
1269
3342
  if (!currentBranch) {
1270
3343
  error("Could not determine current branch.");
1271
3344
  process.exit(1);
1272
3345
  }
1273
- if (currentBranch === mainBranch || currentBranch === devBranch) {
1274
- error(`Use \`contrib sync\` to update ${pc10.bold(mainBranch)} or ${pc10.bold(devBranch)} branches.`);
3346
+ if (protectedBranches.includes(currentBranch)) {
3347
+ error(`Use \`contrib sync\` to update ${protectedBranches.map((b) => pc15.bold(b)).join(" or ")} branches.`);
1275
3348
  process.exit(1);
1276
3349
  }
1277
3350
  if (await hasUncommittedChanges()) {
@@ -1279,12 +3352,103 @@ var update_default = defineCommand8({
1279
3352
  process.exit(1);
1280
3353
  }
1281
3354
  heading("\uD83D\uDD03 contrib update");
1282
- info(`Updating ${pc10.bold(currentBranch)} with latest ${pc10.bold(devBranch)}...`);
1283
- const remote = role === "contributor" ? upstream : origin;
1284
- const remoteDevRef = role === "contributor" ? `${upstream}/${devBranch}` : `${origin}/${devBranch}`;
1285
- await fetchRemote(remote);
1286
- await resetHard(remoteDevRef);
1287
- const rebaseResult = await rebase(devBranch);
3355
+ const mergedPR = await getMergedPRForBranch(currentBranch);
3356
+ if (mergedPR) {
3357
+ warn(`PR #${mergedPR.number} (${pc15.bold(mergedPR.title)}) has already been merged.`);
3358
+ info(`Link: ${pc15.underline(mergedPR.url)}`);
3359
+ const localWork = await hasLocalWork(syncSource.remote, currentBranch);
3360
+ const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
3361
+ if (hasWork) {
3362
+ if (localWork.uncommitted) {
3363
+ info("You have uncommitted local changes.");
3364
+ }
3365
+ if (localWork.unpushedCommits > 0) {
3366
+ info(`You have ${localWork.unpushedCommits} unpushed commit(s).`);
3367
+ }
3368
+ const SAVE_NEW_BRANCH = "Save changes to a new branch";
3369
+ const DISCARD = "Discard all changes and clean up";
3370
+ const CANCEL = "Cancel";
3371
+ const action = await selectPrompt(`${pc15.bold(currentBranch)} is stale but has local work. What would you like to do?`, [SAVE_NEW_BRANCH, DISCARD, CANCEL]);
3372
+ if (action === CANCEL) {
3373
+ info("No changes made. You are still on your current branch.");
3374
+ return;
3375
+ }
3376
+ if (action === SAVE_NEW_BRANCH) {
3377
+ info(pc15.dim("Tip: Describe what you're working on in plain English and we'll generate a branch name."));
3378
+ const description = await inputPrompt("What are you working on?");
3379
+ let newBranchName = description;
3380
+ if (!args["no-ai"] && looksLikeNaturalLanguage(description)) {
3381
+ const spinner = createSpinner("Generating branch name suggestion...");
3382
+ const suggested = await suggestBranchName(description, args.model);
3383
+ if (suggested) {
3384
+ spinner.success("Branch name suggestion ready.");
3385
+ console.log(`
3386
+ ${pc15.dim("AI suggestion:")} ${pc15.bold(pc15.cyan(suggested))}`);
3387
+ const accepted = await confirmPrompt(`Use ${pc15.bold(suggested)} as your branch name?`);
3388
+ newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
3389
+ } else {
3390
+ spinner.fail("AI did not return a suggestion.");
3391
+ newBranchName = await inputPrompt("Enter branch name", description);
3392
+ }
3393
+ }
3394
+ if (!hasPrefix(newBranchName, config.branchPrefixes)) {
3395
+ const prefix = await selectPrompt(`Choose a branch type for ${pc15.bold(newBranchName)}:`, config.branchPrefixes);
3396
+ newBranchName = formatBranchName(prefix, newBranchName);
3397
+ }
3398
+ if (!isValidBranchName(newBranchName)) {
3399
+ error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
3400
+ process.exit(1);
3401
+ }
3402
+ const staleUpstream = await getUpstreamRef();
3403
+ const staleUpstreamHash = staleUpstream ? await getCommitHash(staleUpstream) : null;
3404
+ const renameResult = await renameBranch(currentBranch, newBranchName);
3405
+ if (renameResult.exitCode !== 0) {
3406
+ error(`Failed to rename branch: ${renameResult.stderr}`);
3407
+ process.exit(1);
3408
+ }
3409
+ success(`Renamed ${pc15.bold(currentBranch)} → ${pc15.bold(newBranchName)}`);
3410
+ await unsetUpstream();
3411
+ await fetchRemote(syncSource.remote);
3412
+ let rebaseResult2;
3413
+ if (staleUpstreamHash) {
3414
+ rebaseResult2 = await rebaseOnto(syncSource.ref, staleUpstreamHash);
3415
+ } else {
3416
+ const savedStrategy = await determineRebaseStrategy(newBranchName, syncSource.ref);
3417
+ rebaseResult2 = savedStrategy.strategy === "onto" && savedStrategy.ontoOldBase ? await rebaseOnto(syncSource.ref, savedStrategy.ontoOldBase) : await rebase(syncSource.ref);
3418
+ }
3419
+ if (rebaseResult2.exitCode !== 0) {
3420
+ warn("Rebase encountered conflicts. Resolve them manually, then run:");
3421
+ info(` ${pc15.bold("git rebase --continue")}`);
3422
+ } else {
3423
+ success(`Rebased ${pc15.bold(newBranchName)} onto ${pc15.bold(syncSource.ref)}.`);
3424
+ }
3425
+ info(`All your changes are preserved. Run ${pc15.bold("contrib submit")} when ready to create a new PR.`);
3426
+ return;
3427
+ }
3428
+ warn("Discarding local changes...");
3429
+ }
3430
+ await fetchRemote(syncSource.remote);
3431
+ const coResult = await checkoutBranch2(baseBranch);
3432
+ if (coResult.exitCode !== 0) {
3433
+ error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
3434
+ process.exit(1);
3435
+ }
3436
+ await updateLocalBranch(baseBranch, syncSource.ref);
3437
+ success(`Synced ${pc15.bold(baseBranch)} with ${pc15.bold(syncSource.ref)}.`);
3438
+ info(`Deleting stale branch ${pc15.bold(currentBranch)}...`);
3439
+ await forceDeleteBranch(currentBranch);
3440
+ success(`Deleted ${pc15.bold(currentBranch)}.`);
3441
+ info(`Run ${pc15.bold("contrib start")} to begin a new feature branch.`);
3442
+ return;
3443
+ }
3444
+ info(`Updating ${pc15.bold(currentBranch)} with latest ${pc15.bold(baseBranch)}...`);
3445
+ await fetchRemote(syncSource.remote);
3446
+ await updateLocalBranch(baseBranch, syncSource.ref);
3447
+ const rebaseStrategy = await determineRebaseStrategy(currentBranch, syncSource.ref);
3448
+ if (rebaseStrategy.strategy === "onto" && rebaseStrategy.ontoOldBase) {
3449
+ info(pc15.dim(`Using --onto rebase (branch was based on a different ref)`));
3450
+ }
3451
+ const rebaseResult = rebaseStrategy.strategy === "onto" && rebaseStrategy.ontoOldBase ? await rebaseOnto(syncSource.ref, rebaseStrategy.ontoOldBase) : await rebase(syncSource.ref);
1288
3452
  if (rebaseResult.exitCode !== 0) {
1289
3453
  warn("Rebase hit conflicts. Resolve them manually.");
1290
3454
  console.log();
@@ -1296,7 +3460,7 @@ var update_default = defineCommand8({
1296
3460
  let conflictDiff = "";
1297
3461
  for (const file of conflictFiles.slice(0, 3)) {
1298
3462
  try {
1299
- const content = readFileSync2(file, "utf-8");
3463
+ const content = readFileSync4(file, "utf-8");
1300
3464
  if (content.includes("<<<<<<<")) {
1301
3465
  conflictDiff += `
1302
3466
  --- ${file} ---
@@ -1306,101 +3470,88 @@ ${content.slice(0, 2000)}
1306
3470
  } catch {}
1307
3471
  }
1308
3472
  if (conflictDiff) {
3473
+ const spinner = createSpinner("Analyzing conflicts with AI...");
1309
3474
  const suggestion = await suggestConflictResolution(conflictDiff, args.model);
1310
3475
  if (suggestion) {
3476
+ spinner.success("AI conflict guidance ready.");
1311
3477
  console.log(`
1312
- ${pc10.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
1313
- console.log(pc10.dim("─".repeat(60)));
3478
+ ${pc15.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
3479
+ console.log(pc15.dim("─".repeat(60)));
1314
3480
  console.log(suggestion);
1315
- console.log(pc10.dim("─".repeat(60)));
3481
+ console.log(pc15.dim("─".repeat(60)));
1316
3482
  console.log();
3483
+ } else {
3484
+ spinner.fail("AI could not analyze the conflicts.");
1317
3485
  }
1318
3486
  }
1319
3487
  }
1320
3488
  }
1321
- console.log(pc10.bold("To resolve:"));
3489
+ console.log(pc15.bold("To resolve:"));
1322
3490
  console.log(` 1. Fix conflicts in the affected files`);
1323
- console.log(` 2. ${pc10.cyan("git add <resolved-files>")}`);
1324
- console.log(` 3. ${pc10.cyan("git rebase --continue")}`);
3491
+ console.log(` 2. ${pc15.cyan("git add <resolved-files>")}`);
3492
+ console.log(` 3. ${pc15.cyan("git rebase --continue")}`);
1325
3493
  console.log();
1326
- console.log(` Or abort: ${pc10.cyan("git rebase --abort")}`);
3494
+ console.log(` Or abort: ${pc15.cyan("git rebase --abort")}`);
1327
3495
  process.exit(1);
1328
3496
  }
1329
- success(`✅ ${pc10.bold(currentBranch)} has been rebased onto latest ${pc10.bold(devBranch)}`);
3497
+ success(`✅ ${pc15.bold(currentBranch)} has been rebased onto latest ${pc15.bold(baseBranch)}`);
1330
3498
  }
1331
3499
  });
1332
3500
 
1333
- // src/ui/banner.ts
1334
- import figlet from "figlet";
1335
- import pc11 from "picocolors";
1336
- // package.json
1337
- var package_default = {
1338
- name: "contribute-now",
1339
- version: "0.1.2",
1340
- description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
1341
- type: "module",
1342
- bin: {
1343
- contrib: "dist/index.js",
1344
- contribute: "dist/index.js"
1345
- },
1346
- files: [
1347
- "dist"
1348
- ],
1349
- scripts: {
1350
- build: "bun build src/index.ts --outfile dist/index.js --target node --packages external",
1351
- dev: "bun src/index.ts",
1352
- test: "bun test",
1353
- lint: "biome check .",
1354
- "lint:fix": "biome check --write .",
1355
- format: "biome format --write .",
1356
- prepare: "husky || true",
1357
- "www:dev": "bun run --cwd www dev",
1358
- "www:build": "bun run --cwd www build",
1359
- "www:preview": "bun run --cwd www preview"
1360
- },
1361
- engines: {
1362
- node: ">=18",
1363
- bun: ">=1.0"
1364
- },
1365
- keywords: [
1366
- "git",
1367
- "workflow",
1368
- "squash-merge",
1369
- "sync",
1370
- "cli",
1371
- "contribute",
1372
- "fork",
1373
- "dev-branch",
1374
- "clean-commit"
1375
- ],
1376
- author: "Waren Gonzaga",
1377
- license: "GPL-3.0",
1378
- repository: {
1379
- type: "git",
1380
- url: "git+https://github.com/warengonzaga/contribute-now.git"
3501
+ // src/commands/validate.ts
3502
+ import { defineCommand as defineCommand13 } from "citty";
3503
+ import pc16 from "picocolors";
3504
+ var validate_default = defineCommand13({
3505
+ meta: {
3506
+ name: "validate",
3507
+ description: "Validate a commit message against the configured convention"
1381
3508
  },
1382
- dependencies: {
1383
- "@github/copilot-sdk": "^0.1.25",
1384
- "@wgtechlabs/log-engine": "^2.3.1",
1385
- citty: "^0.1.6",
1386
- figlet: "^1.10.0",
1387
- picocolors: "^1.1.1"
3509
+ args: {
3510
+ message: {
3511
+ type: "positional",
3512
+ description: "The commit message to validate",
3513
+ required: true
3514
+ }
1388
3515
  },
1389
- devDependencies: {
1390
- "@biomejs/biome": "^2.4.4",
1391
- "@types/bun": "latest",
1392
- "@types/figlet": "^1.7.0",
1393
- husky: "^9.1.7",
1394
- typescript: "^5.7.0"
3516
+ async run({ args }) {
3517
+ const config = readConfig();
3518
+ if (!config) {
3519
+ error("No .contributerc.json found. Run `contrib setup` first.");
3520
+ process.exit(1);
3521
+ }
3522
+ const convention = config.commitConvention;
3523
+ if (convention === "none") {
3524
+ info('Commit convention is set to "none". All messages are accepted.');
3525
+ process.exit(0);
3526
+ }
3527
+ const message = args.message;
3528
+ if (validateCommitMessage(message, convention)) {
3529
+ success(`Valid ${CONVENTION_LABELS[convention]} message.`);
3530
+ process.exit(0);
3531
+ }
3532
+ const errors = getValidationError(convention);
3533
+ for (const line of errors) {
3534
+ console.error(pc16.red(` ✗ ${line}`));
3535
+ }
3536
+ process.exit(1);
1395
3537
  }
1396
- };
3538
+ });
1397
3539
 
1398
3540
  // src/ui/banner.ts
1399
- var LOGO;
3541
+ import figlet from "figlet";
3542
+ import pc17 from "picocolors";
3543
+ var LOGO_BIG;
3544
+ try {
3545
+ LOGO_BIG = figlet.textSync(`Contribute
3546
+ Now`, { font: "ANSI Shadow" });
3547
+ } catch {
3548
+ LOGO_BIG = "Contribute Now";
3549
+ }
3550
+ var LOGO_SMALL;
1400
3551
  try {
1401
- LOGO = figlet.textSync("contrib", { font: "ANSI Shadow" });
3552
+ LOGO_SMALL = figlet.textSync("Contribute Now", { font: "Slant" });
1402
3553
  } catch {
1403
- LOGO = "contribute-now";
3554
+ LOGO_SMALL = "Contribute Now";
1404
3555
  }
1405
3556
  function getVersion() {
1406
3557
  return package_default.version ?? "unknown";
@@ -1408,28 +3559,34 @@ function getVersion() {
1408
3559
  function getAuthor() {
1409
3560
  return typeof package_default.author === "string" ? package_default.author : "unknown";
1410
3561
  }
1411
- function showBanner(minimal = false) {
1412
- console.log(pc11.cyan(`
1413
- ${LOGO}`));
1414
- console.log(` ${pc11.dim(`v${getVersion()}`)} ${pc11.dim("—")} ${pc11.dim(`Built by ${getAuthor()}`)}`);
1415
- if (!minimal) {
1416
- console.log(` ${pc11.dim(package_default.description)}`);
3562
+ function showBanner(variant = "small") {
3563
+ const logo = variant === "big" ? LOGO_BIG : LOGO_SMALL;
3564
+ console.log(pc17.cyan(`
3565
+ ${logo}`));
3566
+ console.log(` ${pc17.dim(`v${getVersion()}`)} ${pc17.dim("—")} ${pc17.dim(`Built by ${getAuthor()}`)}`);
3567
+ if (variant === "big") {
1417
3568
  console.log();
1418
- console.log(` ${pc11.yellow("Star")} ${pc11.cyan("https://github.com/warengonzaga/contribute-now")}`);
1419
- console.log(` ${pc11.green("Contribute")} ${pc11.cyan("https://github.com/warengonzaga/contribute-now/blob/main/CONTRIBUTING.md")}`);
1420
- console.log(` ${pc11.magenta("Sponsor")} ${pc11.cyan("https://warengonzaga.com/sponsor")}`);
3569
+ console.log(` ${pc17.yellow("Star")} ${pc17.cyan("https://github.com/warengonzaga/contribute-now")}`);
3570
+ console.log(` ${pc17.green("Contribute")} ${pc17.cyan("https://github.com/warengonzaga/contribute-now/blob/main/CONTRIBUTING.md")}`);
3571
+ console.log(` ${pc17.magenta("Sponsor")} ${pc17.cyan("https://warengonzaga.com/sponsor")}`);
1421
3572
  }
1422
3573
  console.log();
1423
3574
  }
1424
3575
 
1425
3576
  // src/index.ts
1426
- var isHelp = process.argv.includes("--help") || process.argv.includes("-h");
1427
- showBanner(isHelp);
1428
- var main = defineCommand9({
3577
+ var isVersion = process.argv.includes("--version") || process.argv.includes("-v");
3578
+ if (!isVersion) {
3579
+ const subCommands = ["setup", "sync", "start", "commit", "update", "submit", "clean", "status", "log", "branch", "hook", "validate", "doctor"];
3580
+ const isHelp = process.argv.includes("--help") || process.argv.includes("-h");
3581
+ const hasSubCommand = subCommands.some((cmd) => process.argv.includes(cmd));
3582
+ const useBigBanner = isHelp || !hasSubCommand;
3583
+ showBanner(useBigBanner ? "big" : "small");
3584
+ }
3585
+ var main = defineCommand14({
1429
3586
  meta: {
1430
3587
  name: "contrib",
1431
3588
  version: getVersion(),
1432
- description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges."
3589
+ description: "Git workflow CLI that guides contributors through clean branching, commits, and PRs."
1433
3590
  },
1434
3591
  args: {
1435
3592
  version: {
@@ -1445,8 +3602,13 @@ var main = defineCommand9({
1445
3602
  commit: commit_default,
1446
3603
  update: update_default,
1447
3604
  submit: submit_default,
3605
+ branch: branch_default,
1448
3606
  clean: clean_default,
1449
- status: status_default
3607
+ status: status_default,
3608
+ log: log_default,
3609
+ hook: hook_default,
3610
+ validate: validate_default,
3611
+ doctor: doctor_default
1450
3612
  },
1451
3613
  run({ args }) {
1452
3614
  if (args.version) {
@@ -1454,4 +3616,6 @@ var main = defineCommand9({
1454
3616
  }
1455
3617
  }
1456
3618
  });
1457
- runMain(main);
3619
+ runMain(main).then(() => {
3620
+ process.exit(0);
3621
+ });