contribute-now 0.2.1 → 0.3.0-dev.dbe86c3

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