contribute-now 0.2.0-dev.7c81c96 → 0.2.0-dev.88d5119

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 +2097 -758
  2. package/package.json +1 -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,6 +16,12 @@ 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))
@@ -24,6 +32,38 @@ function readConfig(cwd = process.cwd()) {
24
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") {
25
33
  return null;
26
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
+ }
27
67
  return parsed;
28
68
  } catch {
29
69
  return null;
@@ -59,50 +99,9 @@ function getDefaultConfig() {
59
99
  };
60
100
  }
61
101
 
62
- // src/utils/confirm.ts
63
- import * as clack from "@clack/prompts";
64
- import pc from "picocolors";
65
- function handleCancel(value) {
66
- if (clack.isCancel(value)) {
67
- clack.cancel("Cancelled.");
68
- process.exit(0);
69
- }
70
- }
71
- async function confirmPrompt(message) {
72
- const result = await clack.confirm({ message });
73
- handleCancel(result);
74
- return result;
75
- }
76
- async function selectPrompt(message, choices) {
77
- const result = await clack.select({
78
- message,
79
- options: choices.map((choice) => ({ value: choice, label: choice }))
80
- });
81
- handleCancel(result);
82
- return result;
83
- }
84
- async function inputPrompt(message, defaultValue) {
85
- const result = await clack.text({
86
- message,
87
- placeholder: defaultValue,
88
- defaultValue
89
- });
90
- handleCancel(result);
91
- return result || defaultValue || "";
92
- }
93
- async function multiSelectPrompt(message, choices) {
94
- const result = await clack.multiselect({
95
- message: `${message} ${pc.dim("(space to toggle, enter to confirm)")}`,
96
- options: choices.map((choice) => ({ value: choice, label: choice })),
97
- required: false
98
- });
99
- handleCancel(result);
100
- return result;
101
- }
102
-
103
102
  // src/utils/git.ts
104
103
  import { execFile as execFileCb } from "node:child_process";
105
- import { readFileSync as readFileSync2 } from "node:fs";
104
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
106
105
  import { join as join2 } from "node:path";
107
106
  function run(args) {
108
107
  return new Promise((resolve) => {
@@ -119,11 +118,69 @@ async function isGitRepo() {
119
118
  const { exitCode } = await run(["rev-parse", "--is-inside-work-tree"]);
120
119
  return exitCode === 0;
121
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
+ }
122
176
  async function getCurrentBranch() {
123
177
  const { exitCode, stdout } = await run(["rev-parse", "--abbrev-ref", "HEAD"]);
124
178
  if (exitCode !== 0)
125
179
  return null;
126
- return stdout.trim() || null;
180
+ const branch = stdout.trim();
181
+ if (!branch || branch === "HEAD")
182
+ return null;
183
+ return branch;
127
184
  }
128
185
  async function getRemotes() {
129
186
  const { exitCode, stdout } = await run(["remote"]);
@@ -141,12 +198,19 @@ async function getRemoteUrl(remote) {
141
198
  async function hasUncommittedChanges() {
142
199
  const { exitCode, stdout } = await run(["status", "--porcelain"]);
143
200
  if (exitCode !== 0)
144
- return false;
201
+ return true;
145
202
  return stdout.trim().length > 0;
146
203
  }
204
+ async function branchExists(branch) {
205
+ const { exitCode } = await run(["rev-parse", "--verify", branch]);
206
+ return exitCode === 0;
207
+ }
147
208
  async function fetchRemote(remote) {
148
209
  return run(["fetch", remote]);
149
210
  }
211
+ async function addRemote(name, url) {
212
+ return run(["remote", "add", name, url]);
213
+ }
150
214
  async function fetchAll() {
151
215
  return run(["fetch", "--all", "--quiet"]);
152
216
  }
@@ -173,15 +237,64 @@ async function pushSetUpstream(remote, branch) {
173
237
  async function rebase(branch) {
174
238
  return run(["rebase", branch]);
175
239
  }
240
+ async function rebaseAbort() {
241
+ return run(["rebase", "--abort"]);
242
+ }
176
243
  async function getUpstreamRef() {
177
- const { exitCode, stdout } = await run(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
244
+ const { exitCode, stdout } = await run([
245
+ "rev-parse",
246
+ "--abbrev-ref",
247
+ "--symbolic-full-name",
248
+ "@{u}"
249
+ ]);
178
250
  if (exitCode !== 0)
179
251
  return null;
180
252
  return stdout.trim() || null;
181
253
  }
254
+ async function unsetUpstream() {
255
+ return run(["branch", "--unset-upstream"]);
256
+ }
182
257
  async function rebaseOnto(newBase, oldBase) {
183
258
  return run(["rebase", "--onto", newBase, oldBase]);
184
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;
@@ -204,7 +317,7 @@ async function getChangedFiles() {
204
317
  if (!match)
205
318
  return "";
206
319
  const file = match[1];
207
- const renameIdx = file.indexOf(" -> ");
320
+ const renameIdx = file.lastIndexOf(" -> ");
208
321
  return renameIdx !== -1 ? file.slice(renameIdx + 4) : file;
209
322
  }).filter(Boolean);
210
323
  }
@@ -235,7 +348,9 @@ async function getGoneBranches() {
235
348
  if (exitCode !== 0)
236
349
  return [];
237
350
  return stdout.trimEnd().split(`
238
- `).filter((line) => line.includes(": gone]")).map((line) => line.replace(/^\*?\s+/, "").split(/\s+/)[0]).filter(Boolean);
351
+ `).filter((line) => {
352
+ return /\[\S+: gone\]/.test(line);
353
+ }).map((line) => line.replace(/^\*?\s+/, "").split(/\s+/)[0]).filter(Boolean);
239
354
  }
240
355
  async function deleteBranch(branch) {
241
356
  return run(["branch", "-d", branch]);
@@ -282,6 +397,13 @@ async function getLog(base, head) {
282
397
  async function pullBranch(remote, branch) {
283
398
  return run(["pull", remote, branch]);
284
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
+ }
285
407
  async function stageFiles(files) {
286
408
  return run(["add", "--", ...files]);
287
409
  }
@@ -346,7 +468,7 @@ async function getFileStatus() {
346
468
  const indexStatus = line[0];
347
469
  const workTreeStatus = line[1];
348
470
  const pathPart = line.slice(3);
349
- const renameIdx = pathPart.indexOf(" -> ");
471
+ const renameIdx = pathPart.lastIndexOf(" -> ");
350
472
  const file = renameIdx !== -1 ? pathPart.slice(renameIdx + 4) : pathPart;
351
473
  if (indexStatus === "?" && workTreeStatus === "?") {
352
474
  result.untracked.push(file);
@@ -361,10 +483,78 @@ async function getFileStatus() {
361
483
  }
362
484
  return result;
363
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
+ }
364
554
 
365
555
  // src/utils/logger.ts
366
556
  import { LogEngine, LogMode } from "@wgtechlabs/log-engine";
367
- import pc2 from "picocolors";
557
+ import pc from "picocolors";
368
558
  LogEngine.configure({
369
559
  mode: LogMode.INFO,
370
560
  format: {
@@ -387,7 +577,7 @@ function info(msg) {
387
577
  }
388
578
  function heading(msg) {
389
579
  console.log(`
390
- ${pc2.bold(msg)}`);
580
+ ${pc.bold(msg)}`);
391
581
  }
392
582
 
393
583
  // src/utils/workflow.ts
@@ -436,78 +626,37 @@ function getProtectedBranches(config) {
436
626
  }
437
627
  return branches;
438
628
  }
439
-
440
- // src/commands/clean.ts
441
- async function handleCurrentBranchDeletion(currentBranch, baseBranch, config) {
442
- if (!config)
443
- return "skipped";
444
- const { origin } = config;
445
- const localWork = await hasLocalWork(origin, currentBranch);
446
- const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
447
- if (hasWork) {
448
- if (localWork.uncommitted) {
449
- warn("You have uncommitted changes in your working tree.");
450
- }
451
- if (localWork.unpushedCommits > 0) {
452
- warn(`You have ${pc3.bold(String(localWork.unpushedCommits))} local commit${localWork.unpushedCommits !== 1 ? "s" : ""} not pushed.`);
453
- }
454
- const SAVE_NEW_BRANCH = "Save changes to a new branch";
455
- const DISCARD = "Discard all changes and clean up";
456
- const CANCEL = "Skip this branch";
457
- const action = await selectPrompt(`${pc3.bold(currentBranch)} has local changes. What would you like to do?`, [SAVE_NEW_BRANCH, DISCARD, CANCEL]);
458
- if (action === CANCEL)
459
- return "skipped";
460
- if (action === SAVE_NEW_BRANCH) {
461
- const suggestedName = currentBranch.replace(/^(feature|fix|docs|chore|test|refactor)\//, "$1/new-");
462
- const newBranchName = await inputPrompt("New branch name", suggestedName !== currentBranch ? suggestedName : `${currentBranch}-v2`);
463
- const renameResult = await renameBranch(currentBranch, newBranchName);
464
- if (renameResult.exitCode !== 0) {
465
- error(`Failed to rename branch: ${renameResult.stderr}`);
466
- return "skipped";
467
- }
468
- success(`Renamed ${pc3.bold(currentBranch)} → ${pc3.bold(newBranchName)}`);
469
- const syncSource2 = getSyncSource(config);
470
- await fetchRemote(syncSource2.remote);
471
- const savedUpstreamRef = await getUpstreamRef();
472
- const rebaseResult = savedUpstreamRef && savedUpstreamRef !== syncSource2.ref ? await rebaseOnto(syncSource2.ref, savedUpstreamRef) : await rebase(syncSource2.ref);
473
- if (rebaseResult.exitCode !== 0) {
474
- warn("Rebase encountered conflicts. Resolve them after cleanup:");
475
- info(` ${pc3.bold(`git checkout ${newBranchName} && git rebase --continue`)}`);
476
- } else {
477
- success(`Rebased ${pc3.bold(newBranchName)} onto ${pc3.bold(syncSource2.ref)}.`);
478
- }
479
- const coResult2 = await checkoutBranch(baseBranch);
480
- if (coResult2.exitCode !== 0) {
481
- error(`Failed to checkout ${baseBranch}: ${coResult2.stderr}`);
482
- return "saved";
483
- }
484
- await updateLocalBranch(baseBranch, syncSource2.ref);
485
- success(`Synced ${pc3.bold(baseBranch)} with ${pc3.bold(syncSource2.ref)}.`);
486
- return "saved";
487
- }
488
- }
489
- const syncSource = getSyncSource(config);
490
- info(`Switching to ${pc3.bold(baseBranch)} and syncing...`);
491
- await fetchRemote(syncSource.remote);
492
- const coResult = await checkoutBranch(baseBranch);
493
- if (coResult.exitCode !== 0) {
494
- error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
495
- return "skipped";
629
+ function getProtectedPrefixes(config) {
630
+ if (config.workflow === "git-flow") {
631
+ return ["release/", "hotfix/"];
496
632
  }
497
- await updateLocalBranch(baseBranch, syncSource.ref);
498
- success(`Synced ${pc3.bold(baseBranch)} with ${pc3.bold(syncSource.ref)}.`);
499
- return "switched";
633
+ return [];
500
634
  }
501
- var clean_default = defineCommand({
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
+ }
642
+
643
+ // src/commands/branch.ts
644
+ var branch_default = defineCommand({
502
645
  meta: {
503
- name: "clean",
504
- description: "Delete merged branches and prune remote refs"
646
+ name: "branch",
647
+ description: "List branches with workflow-aware labels and status"
505
648
  },
506
649
  args: {
507
- yes: {
650
+ all: {
508
651
  type: "boolean",
509
- alias: "y",
510
- 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",
511
660
  default: false
512
661
  }
513
662
  },
@@ -517,150 +666,203 @@ var clean_default = defineCommand({
517
666
  process.exit(1);
518
667
  }
519
668
  const config = readConfig();
520
- if (!config) {
521
- error("No .contributerc.json found. Run `contrib setup` first.");
522
- process.exit(1);
523
- }
524
- const { origin } = config;
525
- const baseBranch = getBaseBranch(config);
526
- let currentBranch = await getCurrentBranch();
527
- heading("\uD83E\uDDF9 contrib clean");
528
- info(`Pruning ${origin} remote refs...`);
529
- const pruneResult = await pruneRemote(origin);
530
- if (pruneResult.exitCode === 0) {
531
- success(`Pruned ${origin} remote refs.`);
532
- } else {
533
- warn(`Could not prune remote: ${pruneResult.stderr.trim()}`);
534
- }
535
- const protectedBranches = new Set(getProtectedBranches(config));
536
- const mergedBranches = await getMergedBranches(baseBranch);
537
- const mergedCandidates = mergedBranches.filter((b) => !protectedBranches.has(b));
538
- const goneBranches = await getGoneBranches();
539
- const goneCandidates = goneBranches.filter((b) => !protectedBranches.has(b) && !mergedCandidates.includes(b));
540
- if (mergedCandidates.length > 0) {
541
- console.log(`
542
- ${pc3.bold("Merged branches to delete:")}`);
543
- for (const b of mergedCandidates) {
544
- const marker = b === currentBranch ? pc3.yellow(" (current)") : "";
545
- console.log(` ${pc3.dim("•")} ${b}${marker}`);
546
- }
547
- console.log();
548
- const ok = args.yes || await confirmPrompt(`Delete ${pc3.bold(String(mergedCandidates.length))} merged branch${mergedCandidates.length !== 1 ? "es" : ""}?`);
549
- if (ok) {
550
- for (const branch of mergedCandidates) {
551
- if (branch === currentBranch) {
552
- const result2 = await handleCurrentBranchDeletion(currentBranch, baseBranch, config);
553
- if (result2 === "skipped") {
554
- warn(` Skipped ${branch}.`);
555
- continue;
556
- }
557
- if (result2 === "saved") {
558
- currentBranch = baseBranch;
559
- continue;
560
- }
561
- currentBranch = baseBranch;
669
+ const protectedBranches = config ? getProtectedBranches(config) : ["main", "master"];
670
+ const currentBranch = await getCurrentBranch();
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."));
679
+ } else {
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("* "));
686
+ } else {
687
+ parts.push(" ");
562
688
  }
563
- const result = await deleteBranch(branch);
564
- if (result.exitCode === 0) {
565
- success(` Deleted ${pc3.bold(branch)}`);
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}`));
566
695
  } else {
567
- warn(` Failed to delete ${branch}: ${result.stderr.trim()}`);
696
+ parts.push(pc2.dim(" (no remote)"));
697
+ }
698
+ const labels = getBranchLabels(branch.name, protectedBranches, config);
699
+ if (labels.length > 0) {
700
+ parts.push(` ${labels.join(" ")}`);
568
701
  }
702
+ console.log(` ${parts.join("")}`);
569
703
  }
570
- } else {
571
- info("Skipped merged branch deletion.");
572
704
  }
573
705
  }
574
- if (goneCandidates.length > 0) {
575
- console.log(`
576
- ${pc3.bold("Stale branches (remote deleted, likely squash-merged):")}`);
577
- for (const b of goneCandidates) {
578
- const marker = b === currentBranch ? pc3.yellow(" (current)") : "";
579
- console.log(` ${pc3.dim("•")} ${b}${marker}`);
706
+ if (showRemoteOnly || showAll) {
707
+ const remoteBranches = await getRemoteBranches();
708
+ if (!showRemoteOnly) {
709
+ console.log();
580
710
  }
581
- console.log();
582
- const ok = args.yes || await confirmPrompt(`Delete ${pc3.bold(String(goneCandidates.length))} stale branch${goneCandidates.length !== 1 ? "es" : ""}?`);
583
- if (ok) {
584
- for (const branch of goneCandidates) {
585
- if (branch === currentBranch) {
586
- const result2 = await handleCurrentBranchDeletion(currentBranch, baseBranch, config);
587
- if (result2 === "skipped") {
588
- warn(` Skipped ${branch}.`);
589
- continue;
590
- }
591
- if (result2 === "saved") {
592
- currentBranch = baseBranch;
593
- continue;
594
- }
595
- currentBranch = baseBranch;
596
- }
597
- const result = await forceDeleteBranch(branch);
598
- if (result.exitCode === 0) {
599
- success(` Deleted ${pc3.bold(branch)}`);
600
- } else {
601
- warn(` Failed to delete ${branch}: ${result.stderr.trim()}`);
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}`);
602
723
  }
724
+ console.log();
603
725
  }
604
- } else {
605
- info("Skipped stale branch deletion.");
606
726
  }
607
727
  }
608
- if (mergedCandidates.length === 0 && goneCandidates.length === 0) {
609
- info("No branches to clean up. Everything is tidy! \uD83E\uDDF9");
728
+ const tips = [];
729
+ if (!showAll && !showRemoteOnly) {
730
+ tips.push(`Use ${pc2.bold("contrib branch -a")} to include remote branches`);
610
731
  }
611
- const finalBranch = await getCurrentBranch();
612
- if (finalBranch && protectedBranches.has(finalBranch)) {
613
- console.log();
614
- info(`You're on ${pc3.bold(finalBranch)}. Run ${pc3.bold("contrib start")} to begin a new feature.`);
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`);
615
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();
616
743
  }
617
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
+ }
618
781
 
619
- // src/commands/commit.ts
782
+ // src/commands/clean.ts
620
783
  import { defineCommand as defineCommand2 } from "citty";
621
784
  import pc5 from "picocolors";
622
785
 
623
- // src/utils/convention.ts
624
- var CLEAN_COMMIT_PATTERN = /^(📦|🔧|🗑\uFE0F?|🔒|⚙\uFE0F?|☕|🧪|📖|🚀) (new|update|remove|security|setup|chore|test|docs|release)(!?)( \([a-zA-Z0-9][a-zA-Z0-9-]*\))?: .{1,72}$/u;
625
- 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}$/;
626
- var CONVENTION_LABELS = {
627
- conventional: "Conventional Commits",
628
- "clean-commit": "Clean Commit (by WGTech Labs)",
629
- none: "No convention"
630
- };
631
- var CONVENTION_DESCRIPTIONS = {
632
- conventional: "Conventional Commits feat: | fix: | docs: | chore: etc. (conventionalcommits.org)",
633
- "clean-commit": "Clean Commit — \uD83D\uDCE6 new: | \uD83D\uDD27 update: | \uD83D\uDDD1️ remove: etc. (by WGTech Labs)",
634
- none: "No commit convention enforcement"
635
- };
636
- var CONVENTION_FORMAT_HINTS = {
637
- conventional: [
638
- "Format: <type>[!][(<scope>)]: <description>",
639
- "Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert",
640
- "Examples: feat: add login page | fix(auth): resolve token expiry | docs: update README"
641
- ],
642
- "clean-commit": [
643
- "Format: <emoji> <type>[!][(<scope>)]: <description>",
644
- "Types: \uD83D\uDCE6 new | \uD83D\uDD27 update | \uD83D\uDDD1️ remove | \uD83D\uDD12 security | ⚙️ setup | ☕ chore | \uD83E\uDDEA test | \uD83D\uDCD6 docs | \uD83D\uDE80 release",
645
- "Examples: \uD83D\uDCE6 new: user auth | \uD83D\uDD27 update (api): improve errors | ⚙️ setup (ci): add workflow"
646
- ]
647
- };
648
- function validateCommitMessage(message, convention) {
649
- if (convention === "none")
650
- return true;
651
- if (convention === "clean-commit")
652
- return CLEAN_COMMIT_PATTERN.test(message);
653
- if (convention === "conventional")
654
- return CONVENTIONAL_COMMIT_PATTERN.test(message);
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}/`));
790
+ }
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;
655
821
  return true;
656
822
  }
657
- function getValidationError(convention) {
658
- if (convention === "none")
659
- return [];
660
- return [
661
- `Commit message does not follow ${CONVENTION_LABELS[convention]} format.`,
662
- ...CONVENTION_FORMAT_HINTS[convention]
663
- ];
823
+ function looksLikeNaturalLanguage(input) {
824
+ return input.includes(" ") && !input.includes("/");
825
+ }
826
+
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;
664
866
  }
665
867
 
666
868
  // src/utils/copilot.ts
@@ -700,28 +902,31 @@ Rules:
700
902
  - Order groups so foundational changes come first (types, utils) and consumers come after
701
903
  - Return ONLY the JSON array, nothing else`;
702
904
  }
703
- var BRANCH_NAME_SYSTEM_PROMPT = `Git branch name generator. Format: <prefix>/<kebab-case-name>
704
- Prefixes: feature, fix, docs, chore, test, refactor
705
- Rules: lowercase kebab-case, 2-5 words max. Return ONLY the branch name.
706
- Examples: fix/login-timeout | feature/user-profile-page | docs/update-readme`;
707
- var PR_DESCRIPTION_SYSTEM_PROMPT_BASE = `GitHub PR description generator. Return JSON: {"title":"<72 chars>","body":"## Summary\\n...\\n\\n## Changes\\n- ...\\n\\n## Test Plan\\n..."}`;
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.`;
708
913
  function getPRDescriptionSystemPrompt(convention) {
709
914
  if (convention === "clean-commit") {
710
915
  return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
711
916
  CRITICAL: The PR title MUST follow the Clean Commit format exactly: <emoji> <type>: <description>
712
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
713
918
  Title examples: \uD83D\uDCE6 new: add user authentication | \uD83D\uDD27 update: improve error handling | \uD83D\uDDD1️ remove: drop legacy API
714
- Rules: title follows convention, present tense, max 72 chars; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
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.`;
715
920
  }
716
921
  if (convention === "conventional") {
717
922
  return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
718
923
  CRITICAL: The PR title MUST follow Conventional Commits format: <type>[(<scope>)]: <description>
719
924
  Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
720
925
  Title examples: feat: add user authentication | fix(auth): resolve token expiry | docs: update contributing guide
721
- Rules: title follows convention, present tense, max 72 chars; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
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.`;
722
927
  }
723
928
  return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
724
- Rules: title concise present tense; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
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.`;
725
930
  }
726
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.`;
727
932
  function suppressSubprocessWarnings() {
@@ -878,7 +1083,11 @@ ${diff.slice(0, 4000)}`;
878
1083
  async function suggestBranchName(description, model) {
879
1084
  try {
880
1085
  const result = await callCopilot(BRANCH_NAME_SYSTEM_PROMPT, description, model);
881
- return result?.trim() ?? null;
1086
+ const trimmed = result?.trim() ?? null;
1087
+ if (trimmed && /^[a-z]+\/[a-z0-9-]+$/.test(trimmed)) {
1088
+ return trimmed;
1089
+ }
1090
+ return null;
882
1091
  } catch {
883
1092
  return null;
884
1093
  }
@@ -966,6 +1175,144 @@ ${diffs.slice(0, 4000)}`;
966
1175
  }
967
1176
  }
968
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
+
969
1316
  // src/utils/spinner.ts
970
1317
  import pc4 from "picocolors";
971
1318
  var FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
@@ -1010,11 +1357,283 @@ function createSpinner(text2) {
1010
1357
  stop() {
1011
1358
  stop();
1012
1359
  }
1013
- };
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
+ ];
1014
1633
  }
1015
1634
 
1016
1635
  // src/commands/commit.ts
1017
- var commit_default = defineCommand2({
1636
+ var commit_default = defineCommand3({
1018
1637
  meta: {
1019
1638
  name: "commit",
1020
1639
  description: "Stage changes and create a commit message (AI-powered)"
@@ -1040,6 +1659,7 @@ var commit_default = defineCommand2({
1040
1659
  error("Not inside a git repository.");
1041
1660
  process.exit(1);
1042
1661
  }
1662
+ await assertCleanGitState("committing");
1043
1663
  const config = readConfig();
1044
1664
  if (!config) {
1045
1665
  error("No .contributerc.json found. Run `contrib setup` first.");
@@ -1058,9 +1678,9 @@ var commit_default = defineCommand2({
1058
1678
  process.exit(1);
1059
1679
  }
1060
1680
  console.log(`
1061
- ${pc5.bold("Changed files:")}`);
1681
+ ${pc6.bold("Changed files:")}`);
1062
1682
  for (const f of changedFiles) {
1063
- console.log(` ${pc5.dim("•")} ${f}`);
1683
+ console.log(` ${pc6.dim("•")} ${f}`);
1064
1684
  }
1065
1685
  const stageAction = await selectPrompt("No staged changes. How would you like to stage?", [
1066
1686
  "Stage all changes",
@@ -1110,7 +1730,7 @@ ${pc5.bold("Changed files:")}`);
1110
1730
  if (commitMessage) {
1111
1731
  spinner.success("AI commit message generated.");
1112
1732
  console.log(`
1113
- ${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(commitMessage))}`);
1733
+ ${pc6.dim("AI suggestion:")} ${pc6.bold(pc6.cyan(commitMessage))}`);
1114
1734
  } else {
1115
1735
  spinner.fail("AI did not return a commit message.");
1116
1736
  warn("Falling back to manual entry.");
@@ -1136,7 +1756,7 @@ ${pc5.bold("Changed files:")}`);
1136
1756
  if (regen) {
1137
1757
  spinner.success("Commit message regenerated.");
1138
1758
  console.log(`
1139
- ${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(regen))}`);
1759
+ ${pc6.dim("AI suggestion:")} ${pc6.bold(pc6.cyan(regen))}`);
1140
1760
  const ok = await confirmPrompt("Use this message?");
1141
1761
  finalMessage = ok ? regen : await inputPrompt("Enter commit message manually");
1142
1762
  } else {
@@ -1151,7 +1771,7 @@ ${pc5.bold("Changed files:")}`);
1151
1771
  if (convention2 !== "none") {
1152
1772
  console.log();
1153
1773
  for (const hint of CONVENTION_FORMAT_HINTS[convention2]) {
1154
- console.log(pc5.dim(hint));
1774
+ console.log(pc6.dim(hint));
1155
1775
  }
1156
1776
  console.log();
1157
1777
  }
@@ -1175,7 +1795,7 @@ ${pc5.bold("Changed files:")}`);
1175
1795
  error(`Failed to commit: ${result.stderr}`);
1176
1796
  process.exit(1);
1177
1797
  }
1178
- success(`✅ Committed: ${pc5.bold(finalMessage)}`);
1798
+ success(`✅ Committed: ${pc6.bold(finalMessage)}`);
1179
1799
  }
1180
1800
  });
1181
1801
  async function runGroupCommit(model, config) {
@@ -1192,9 +1812,9 @@ async function runGroupCommit(model, config) {
1192
1812
  process.exit(1);
1193
1813
  }
1194
1814
  console.log(`
1195
- ${pc5.bold("Changed files:")}`);
1815
+ ${pc6.bold("Changed files:")}`);
1196
1816
  for (const f of changedFiles) {
1197
- console.log(` ${pc5.dim("•")} ${f}`);
1817
+ console.log(` ${pc6.dim("•")} ${f}`);
1198
1818
  }
1199
1819
  const spinner = createSpinner(`Asking AI to group ${changedFiles.length} file(s) into logical commits...`);
1200
1820
  const diffs = await getFullDiffForFiles(changedFiles);
@@ -1232,13 +1852,13 @@ ${pc5.bold("Changed files:")}`);
1232
1852
  let commitAll = false;
1233
1853
  while (!proceedToCommit) {
1234
1854
  console.log(`
1235
- ${pc5.bold(`AI suggested ${validGroups.length} commit group(s):`)}
1855
+ ${pc6.bold(`AI suggested ${validGroups.length} commit group(s):`)}
1236
1856
  `);
1237
1857
  for (let i = 0;i < validGroups.length; i++) {
1238
1858
  const g = validGroups[i];
1239
- console.log(` ${pc5.cyan(`Group ${i + 1}:`)} ${pc5.bold(g.message)}`);
1859
+ console.log(` ${pc6.cyan(`Group ${i + 1}:`)} ${pc6.bold(g.message)}`);
1240
1860
  for (const f of g.files) {
1241
- console.log(` ${pc5.dim("•")} ${f}`);
1861
+ console.log(` ${pc6.dim("•")} ${f}`);
1242
1862
  }
1243
1863
  console.log();
1244
1864
  }
@@ -1282,16 +1902,16 @@ ${pc5.bold(`AI suggested ${validGroups.length} commit group(s):`)}
1282
1902
  continue;
1283
1903
  }
1284
1904
  committed++;
1285
- success(`✅ Committed group ${i + 1}: ${pc5.bold(group.message)}`);
1905
+ success(`✅ Committed group ${i + 1}: ${pc6.bold(group.message)}`);
1286
1906
  }
1287
1907
  } else {
1288
1908
  for (let i = 0;i < validGroups.length; i++) {
1289
1909
  const group = validGroups[i];
1290
- console.log(pc5.bold(`
1910
+ console.log(pc6.bold(`
1291
1911
  ── Group ${i + 1}/${validGroups.length} ──`));
1292
- console.log(` ${pc5.cyan(group.message)}`);
1912
+ console.log(` ${pc6.cyan(group.message)}`);
1293
1913
  for (const f of group.files) {
1294
- console.log(` ${pc5.dim("•")} ${f}`);
1914
+ console.log(` ${pc6.dim("•")} ${f}`);
1295
1915
  }
1296
1916
  let message = group.message;
1297
1917
  let actionDone = false;
@@ -1313,7 +1933,7 @@ ${pc5.bold(`AI suggested ${validGroups.length} commit group(s):`)}
1313
1933
  if (newMsg) {
1314
1934
  message = newMsg;
1315
1935
  group.message = newMsg;
1316
- regenSpinner.success(`New message: ${pc5.bold(message)}`);
1936
+ regenSpinner.success(`New message: ${pc6.bold(message)}`);
1317
1937
  } else {
1318
1938
  regenSpinner.fail("AI could not generate a new message. Keeping current one.");
1319
1939
  }
@@ -1353,25 +1973,413 @@ ${pc5.bold(`AI suggested ${validGroups.length} commit group(s):`)}
1353
1973
  continue;
1354
1974
  }
1355
1975
  committed++;
1356
- success(`✅ Committed group ${i + 1}: ${pc5.bold(message)}`);
1976
+ success(`✅ Committed group ${i + 1}: ${pc6.bold(message)}`);
1357
1977
  actionDone = true;
1358
1978
  }
1359
1979
  }
1360
1980
  }
1361
- if (committed === 0) {
1362
- warn("No groups were committed.");
1363
- } else {
1364
- success(`
1365
- \uD83C\uDF89 ${committed} of ${validGroups.length} group(s) committed successfully.`);
1366
- }
1367
- process.exit(0);
1368
- }
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.2.0-dev.88d5119",
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.`)}`);
2369
+ }
2370
+ if (warnings.length > 0) {
2371
+ console.log(` ${pc7.yellow(`${warnings.length} warning${warnings.length !== 1 ? "s" : ""}.`)}`);
2372
+ }
2373
+ console.log();
2374
+ }
2375
+ }
2376
+ });
1369
2377
 
1370
2378
  // src/commands/hook.ts
1371
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
2379
+ import { existsSync as existsSync3, mkdirSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
1372
2380
  import { join as join3 } from "node:path";
1373
- import { defineCommand as defineCommand3 } from "citty";
1374
- import pc6 from "picocolors";
2381
+ import { defineCommand as defineCommand5 } from "citty";
2382
+ import pc8 from "picocolors";
1375
2383
  var HOOK_MARKER = "# managed by contribute-now";
1376
2384
  function getHooksDir(cwd = process.cwd()) {
1377
2385
  return join3(cwd, ".git", "hooks");
@@ -1409,7 +2417,7 @@ else
1409
2417
  fi
1410
2418
  `;
1411
2419
  }
1412
- var hook_default = defineCommand3({
2420
+ var hook_default = defineCommand5({
1413
2421
  meta: {
1414
2422
  name: "hook",
1415
2423
  description: "Install or uninstall the commit-msg git hook"
@@ -1452,7 +2460,7 @@ async function installHook() {
1452
2460
  }
1453
2461
  const hookPath = getHookPath();
1454
2462
  const hooksDir = getHooksDir();
1455
- if (existsSync2(hookPath)) {
2463
+ if (existsSync3(hookPath)) {
1456
2464
  const existing = readFileSync3(hookPath, "utf-8");
1457
2465
  if (!existing.includes(HOOK_MARKER)) {
1458
2466
  error("A commit-msg hook already exists and was not installed by contribute-now.");
@@ -1462,18 +2470,19 @@ async function installHook() {
1462
2470
  }
1463
2471
  info("Updating existing contribute-now hook...");
1464
2472
  }
1465
- if (!existsSync2(hooksDir)) {
2473
+ if (!existsSync3(hooksDir)) {
1466
2474
  mkdirSync(hooksDir, { recursive: true });
1467
2475
  }
1468
2476
  writeFileSync2(hookPath, generateHookScript(), { mode: 493 });
1469
2477
  success(`commit-msg hook installed.`);
1470
- info(`Convention: ${pc6.bold(CONVENTION_LABELS[config.commitConvention])}`);
1471
- info(`Path: ${pc6.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`.");
1472
2481
  }
1473
2482
  async function uninstallHook() {
1474
2483
  heading("\uD83E\uDE9D hook uninstall");
1475
2484
  const hookPath = getHookPath();
1476
- if (!existsSync2(hookPath)) {
2485
+ if (!existsSync3(hookPath)) {
1477
2486
  info("No commit-msg hook found. Nothing to uninstall.");
1478
2487
  return;
1479
2488
  }
@@ -1486,169 +2495,165 @@ async function uninstallHook() {
1486
2495
  success("commit-msg hook removed.");
1487
2496
  }
1488
2497
 
1489
- // src/commands/setup.ts
1490
- import { defineCommand as defineCommand4 } from "citty";
1491
- import pc7 from "picocolors";
1492
-
1493
- // src/utils/gh.ts
1494
- import { execFile as execFileCb2 } from "node:child_process";
1495
- function run2(args) {
1496
- return new Promise((resolve) => {
1497
- execFileCb2("gh", args, (error2, stdout, stderr) => {
1498
- resolve({
1499
- exitCode: error2 ? error2.code === "ENOENT" ? 127 : error2.status ?? 1 : 0,
1500
- stdout: stdout ?? "",
1501
- stderr: stderr ?? ""
1502
- });
1503
- });
1504
- });
1505
- }
1506
- async function checkGhInstalled() {
1507
- try {
1508
- const { exitCode } = await run2(["--version"]);
1509
- return exitCode === 0;
1510
- } catch {
1511
- return false;
1512
- }
1513
- }
1514
- async function checkGhAuth() {
1515
- try {
1516
- const { exitCode } = await run2(["auth", "status"]);
1517
- return exitCode === 0;
1518
- } catch {
1519
- 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();
1520
2573
  }
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);
2579
+ }
2580
+ const [, graphPart = "", hash, , refs, subject = ""] = match;
2581
+ const parts = [];
2582
+ if (graphPart) {
2583
+ parts.push(colorizeGraphChars(graphPart));
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(", "));
1521
2625
  }
1522
- var SAFE_SLUG = /^[\w.-]+$/;
1523
- async function checkRepoPermissions(owner, repo) {
1524
- if (!SAFE_SLUG.test(owner) || !SAFE_SLUG.test(repo))
1525
- return null;
1526
- const { exitCode, stdout } = await run2(["api", `repos/${owner}/${repo}`, "--jq", ".permissions"]);
1527
- if (exitCode !== 0)
1528
- return null;
1529
- try {
1530
- return JSON.parse(stdout.trim());
1531
- } catch {
1532
- return null;
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));
1533
2631
  }
1534
- }
1535
- async function isRepoFork() {
1536
- const { exitCode, stdout } = await run2(["repo", "view", "--json", "isFork", "-q", ".isFork"]);
1537
- if (exitCode !== 0)
1538
- return null;
1539
- const val = stdout.trim();
1540
- if (val === "true")
1541
- return true;
1542
- if (val === "false")
1543
- return false;
1544
- return null;
1545
- }
1546
- async function getCurrentRepoInfo() {
1547
- const { exitCode, stdout } = await run2([
1548
- "repo",
1549
- "view",
1550
- "--json",
1551
- "nameWithOwner",
1552
- "-q",
1553
- ".nameWithOwner"
1554
- ]);
1555
- if (exitCode !== 0)
1556
- return null;
1557
- const nameWithOwner = stdout.trim();
1558
- if (!nameWithOwner)
1559
- return null;
1560
- const [owner, repo] = nameWithOwner.split("/");
1561
- if (!owner || !repo)
1562
- return null;
1563
- return { owner, repo };
1564
- }
1565
- async function createPR(options) {
1566
- const args = [
1567
- "pr",
1568
- "create",
1569
- "--base",
1570
- options.base,
1571
- "--title",
1572
- options.title,
1573
- "--body",
1574
- options.body
1575
- ];
1576
- if (options.draft)
1577
- args.push("--draft");
1578
- return run2(args);
1579
- }
1580
- async function createPRFill(base, draft) {
1581
- const args = ["pr", "create", "--base", base, "--fill"];
1582
- if (draft)
1583
- args.push("--draft");
1584
- return run2(args);
1585
- }
1586
- async function getPRForBranch(headBranch) {
1587
- const { exitCode, stdout } = await run2([
1588
- "pr",
1589
- "list",
1590
- "--head",
1591
- headBranch,
1592
- "--state",
1593
- "open",
1594
- "--json",
1595
- "number,url,title,state",
1596
- "--limit",
1597
- "1"
1598
- ]);
1599
- if (exitCode !== 0)
1600
- return null;
1601
- try {
1602
- const prs = JSON.parse(stdout.trim());
1603
- return prs.length > 0 ? prs[0] : null;
1604
- } catch {
1605
- return null;
2632
+ if (localName === currentBranch) {
2633
+ return pc9.bold(pc9.green(name));
1606
2634
  }
1607
- }
1608
- async function getMergedPRForBranch(headBranch) {
1609
- const { exitCode, stdout } = await run2([
1610
- "pr",
1611
- "list",
1612
- "--head",
1613
- headBranch,
1614
- "--state",
1615
- "merged",
1616
- "--json",
1617
- "number,url,title,state",
1618
- "--limit",
1619
- "1"
1620
- ]);
1621
- if (exitCode !== 0)
1622
- return null;
1623
- try {
1624
- const prs = JSON.parse(stdout.trim());
1625
- return prs.length > 0 ? prs[0] : null;
1626
- } catch {
1627
- return null;
2635
+ if (isRemote) {
2636
+ return pc9.blue(name);
1628
2637
  }
2638
+ return pc9.green(name);
1629
2639
  }
1630
-
1631
- // src/utils/remote.ts
1632
- function parseRepoFromUrl(url) {
1633
- const httpsMatch = url.match(/https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
1634
- if (httpsMatch) {
1635
- return { owner: httpsMatch[1], repo: httpsMatch[2] };
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)}`;
1636
2646
  }
1637
- const sshMatch = url.match(/git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
1638
- if (sshMatch) {
1639
- return { owner: sshMatch[1], repo: sshMatch[2] };
2647
+ if (subject.startsWith("Merge ")) {
2648
+ return pc9.dim(subject);
1640
2649
  }
1641
- return null;
1642
- }
1643
- async function getRepoInfoFromRemote(remote = "origin") {
1644
- const url = await getRemoteUrl(remote);
1645
- if (!url)
1646
- return null;
1647
- return parseRepoFromUrl(url);
2650
+ return pc9.white(subject);
1648
2651
  }
1649
2652
 
1650
2653
  // src/commands/setup.ts
1651
- var setup_default = defineCommand4({
2654
+ import { defineCommand as defineCommand7 } from "citty";
2655
+ import pc10 from "picocolors";
2656
+ var setup_default = defineCommand7({
1652
2657
  meta: {
1653
2658
  name: "setup",
1654
2659
  description: "Initialize contribute-now config for this repo (.contributerc.json)"
@@ -1669,7 +2674,7 @@ var setup_default = defineCommand4({
1669
2674
  workflow = "github-flow";
1670
2675
  else if (workflowChoice.startsWith("Git Flow"))
1671
2676
  workflow = "git-flow";
1672
- info(`Workflow: ${pc7.bold(WORKFLOW_DESCRIPTIONS[workflow])}`);
2677
+ info(`Workflow: ${pc10.bold(WORKFLOW_DESCRIPTIONS[workflow])}`);
1673
2678
  const conventionChoice = await selectPrompt("Which commit convention should this project use?", [
1674
2679
  `${CONVENTION_DESCRIPTIONS["clean-commit"]} (recommended)`,
1675
2680
  CONVENTION_DESCRIPTIONS.conventional,
@@ -1722,8 +2727,8 @@ var setup_default = defineCommand4({
1722
2727
  detectedRole = roleChoice;
1723
2728
  detectionSource = "user selection";
1724
2729
  } else {
1725
- info(`Detected role: ${pc7.bold(detectedRole)} (via ${detectionSource})`);
1726
- const confirmed = await confirmPrompt(`Role detected as ${pc7.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?`);
1727
2732
  if (!confirmed) {
1728
2733
  const roleChoice = await selectPrompt("Select your role:", ["maintainer", "contributor"]);
1729
2734
  detectedRole = roleChoice;
@@ -1746,8 +2751,17 @@ var setup_default = defineCommand4({
1746
2751
  const repoInfo = originUrl ? parseRepoFromUrl(originUrl) : null;
1747
2752
  const upstreamUrl = await inputPrompt("Enter upstream repository URL to add", repoInfo ? `https://github.com/${repoInfo.owner}/${repoInfo.repo}` : undefined);
1748
2753
  if (upstreamUrl) {
1749
- info(`Run: git remote add ${upstreamRemote} ${upstreamUrl}`);
1750
- 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);
1751
2765
  }
1752
2766
  }
1753
2767
  }
@@ -1763,45 +2777,42 @@ var setup_default = defineCommand4({
1763
2777
  };
1764
2778
  writeConfig(config);
1765
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
+ }
1766
2795
  if (!isGitignored()) {
1767
2796
  warn(".contributerc.json is not in .gitignore. Add it to avoid committing personal config.");
1768
2797
  warn(' echo ".contributerc.json" >> .gitignore');
1769
2798
  }
1770
2799
  console.log();
1771
- info(`Workflow: ${pc7.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
1772
- info(`Convention: ${pc7.bold(CONVENTION_DESCRIPTIONS[config.commitConvention])}`);
1773
- info(`Role: ${pc7.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)}`);
1774
2803
  if (config.devBranch) {
1775
- info(`Main: ${pc7.bold(config.mainBranch)} | Dev: ${pc7.bold(config.devBranch)}`);
2804
+ info(`Main: ${pc10.bold(config.mainBranch)} | Dev: ${pc10.bold(config.devBranch)}`);
1776
2805
  } else {
1777
- info(`Main: ${pc7.bold(config.mainBranch)}`);
2806
+ info(`Main: ${pc10.bold(config.mainBranch)}`);
1778
2807
  }
1779
- info(`Origin: ${pc7.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc7.bold(config.upstream)}` : ""}`);
2808
+ info(`Origin: ${pc10.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc10.bold(config.upstream)}` : ""}`);
1780
2809
  }
1781
2810
  });
1782
2811
 
1783
2812
  // src/commands/start.ts
1784
- import { defineCommand as defineCommand5 } from "citty";
1785
- import pc8 from "picocolors";
1786
-
1787
- // src/utils/branch.ts
1788
- var DEFAULT_PREFIXES = ["feature", "fix", "docs", "chore", "test", "refactor"];
1789
- function hasPrefix(branchName, prefixes = DEFAULT_PREFIXES) {
1790
- return prefixes.some((p) => branchName.startsWith(`${p}/`));
1791
- }
1792
- function formatBranchName(prefix, name) {
1793
- const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1794
- return `${prefix}/${sanitized}`;
1795
- }
1796
- function isValidBranchName(name) {
1797
- return /^[a-zA-Z0-9._/-]+$/.test(name) && !name.startsWith("/") && !name.endsWith("/");
1798
- }
1799
- function looksLikeNaturalLanguage(input) {
1800
- return input.includes(" ") && !input.includes("/");
1801
- }
1802
-
1803
- // src/commands/start.ts
1804
- var start_default = defineCommand5({
2813
+ import { defineCommand as defineCommand8 } from "citty";
2814
+ import pc11 from "picocolors";
2815
+ var start_default = defineCommand8({
1805
2816
  meta: {
1806
2817
  name: "start",
1807
2818
  description: "Create a new feature branch from the latest base branch"
@@ -1809,8 +2820,8 @@ var start_default = defineCommand5({
1809
2820
  args: {
1810
2821
  name: {
1811
2822
  type: "positional",
1812
- description: "Branch name or description",
1813
- required: true
2823
+ description: "Branch name or description (prompted if omitted)",
2824
+ required: false
1814
2825
  },
1815
2826
  model: {
1816
2827
  type: "string",
@@ -1827,6 +2838,7 @@ var start_default = defineCommand5({
1827
2838
  error("Not inside a git repository.");
1828
2839
  process.exit(1);
1829
2840
  }
2841
+ await assertCleanGitState("starting a new branch");
1830
2842
  const config = readConfig();
1831
2843
  if (!config) {
1832
2844
  error("No .contributerc.json found. Run `contrib setup` first.");
@@ -1841,6 +2853,14 @@ var start_default = defineCommand5({
1841
2853
  const syncSource = getSyncSource(config);
1842
2854
  let branchName = args.name;
1843
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
+ }
1844
2864
  const useAI = !args["no-ai"] && looksLikeNaturalLanguage(branchName);
1845
2865
  if (useAI) {
1846
2866
  const spinner = createSpinner("Generating branch name suggestion...");
@@ -1848,8 +2868,8 @@ var start_default = defineCommand5({
1848
2868
  if (suggested) {
1849
2869
  spinner.success("Branch name suggestion ready.");
1850
2870
  console.log(`
1851
- ${pc8.dim("AI suggestion:")} ${pc8.bold(pc8.cyan(suggested))}`);
1852
- const accepted = await confirmPrompt(`Use ${pc8.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?`);
1853
2873
  if (accepted) {
1854
2874
  branchName = suggested;
1855
2875
  } else {
@@ -1860,30 +2880,51 @@ var start_default = defineCommand5({
1860
2880
  }
1861
2881
  }
1862
2882
  if (!hasPrefix(branchName, branchPrefixes)) {
1863
- const prefix = await selectPrompt(`Choose a branch type for ${pc8.bold(branchName)}:`, branchPrefixes);
2883
+ const prefix = await selectPrompt(`Choose a branch type for ${pc11.bold(branchName)}:`, branchPrefixes);
1864
2884
  branchName = formatBranchName(prefix, branchName);
1865
2885
  }
1866
2886
  if (!isValidBranchName(branchName)) {
1867
2887
  error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
1868
2888
  process.exit(1);
1869
2889
  }
1870
- info(`Creating branch: ${pc8.bold(branchName)}`);
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
+ }
1871
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
+ }
1872
2900
  const updateResult = await updateLocalBranch(baseBranch, syncSource.ref);
1873
- 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
+ }
1874
2915
  const result = await createBranch(branchName, baseBranch);
1875
2916
  if (result.exitCode !== 0) {
1876
2917
  error(`Failed to create branch: ${result.stderr}`);
1877
2918
  process.exit(1);
1878
2919
  }
1879
- success(`✅ Created ${pc8.bold(branchName)} from latest ${pc8.bold(baseBranch)}`);
2920
+ success(`✅ Created ${pc11.bold(branchName)} from latest ${pc11.bold(baseBranch)}`);
1880
2921
  }
1881
2922
  });
1882
2923
 
1883
2924
  // src/commands/status.ts
1884
- import { defineCommand as defineCommand6 } from "citty";
1885
- import pc9 from "picocolors";
1886
- var status_default = defineCommand6({
2925
+ import { defineCommand as defineCommand9 } from "citty";
2926
+ import pc12 from "picocolors";
2927
+ var status_default = defineCommand9({
1887
2928
  meta: {
1888
2929
  name: "status",
1889
2930
  description: "Show sync status of branches"
@@ -1899,20 +2940,17 @@ var status_default = defineCommand6({
1899
2940
  process.exit(1);
1900
2941
  }
1901
2942
  heading("\uD83D\uDCCA contribute-now status");
1902
- console.log(` ${pc9.dim("Workflow:")} ${pc9.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
1903
- console.log(` ${pc9.dim("Role:")} ${pc9.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)}`);
1904
2945
  console.log();
1905
2946
  await fetchAll();
1906
2947
  const currentBranch = await getCurrentBranch();
1907
2948
  const { mainBranch, origin, upstream, workflow } = config;
1908
2949
  const baseBranch = getBaseBranch(config);
1909
2950
  const isContributor = config.role === "contributor";
1910
- const [dirty, fileStatus] = await Promise.all([
1911
- hasUncommittedChanges(),
1912
- getFileStatus()
1913
- ]);
2951
+ const [dirty, fileStatus] = await Promise.all([hasUncommittedChanges(), getFileStatus()]);
1914
2952
  if (dirty) {
1915
- console.log(` ${pc9.yellow("⚠")} ${pc9.yellow("Uncommitted changes in working tree")}`);
2953
+ console.log(` ${pc12.yellow("⚠")} ${pc12.yellow("Uncommitted changes in working tree")}`);
1916
2954
  console.log();
1917
2955
  }
1918
2956
  const mainRemote = `${origin}/${mainBranch}`;
@@ -1928,82 +2966,82 @@ var status_default = defineCommand6({
1928
2966
  if (currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
1929
2967
  const branchDiv = await getDivergence(currentBranch, baseBranch);
1930
2968
  const branchLine = formatStatus(currentBranch, baseBranch, branchDiv.ahead, branchDiv.behind);
1931
- console.log(branchLine + pc9.dim(` (current ${pc9.green("*")})`));
2969
+ console.log(branchLine + pc12.dim(` (current ${pc12.green("*")})`));
1932
2970
  } else if (currentBranch) {
1933
- console.log(pc9.dim(` (on ${pc9.bold(currentBranch)} branch)`));
2971
+ console.log(pc12.dim(` (on ${pc12.bold(currentBranch)} branch)`));
1934
2972
  }
1935
2973
  const hasFiles = fileStatus.staged.length > 0 || fileStatus.modified.length > 0 || fileStatus.untracked.length > 0;
1936
2974
  if (hasFiles) {
1937
2975
  console.log();
1938
2976
  if (fileStatus.staged.length > 0) {
1939
- console.log(` ${pc9.green("Staged for commit:")}`);
2977
+ console.log(` ${pc12.green("Staged for commit:")}`);
1940
2978
  for (const { file, status } of fileStatus.staged) {
1941
- console.log(` ${pc9.green("+")} ${pc9.dim(`${status}:`)} ${file}`);
2979
+ console.log(` ${pc12.green("+")} ${pc12.dim(`${status}:`)} ${file}`);
1942
2980
  }
1943
2981
  }
1944
2982
  if (fileStatus.modified.length > 0) {
1945
- console.log(` ${pc9.yellow("Unstaged changes:")}`);
2983
+ console.log(` ${pc12.yellow("Unstaged changes:")}`);
1946
2984
  for (const { file, status } of fileStatus.modified) {
1947
- console.log(` ${pc9.yellow("~")} ${pc9.dim(`${status}:`)} ${file}`);
2985
+ console.log(` ${pc12.yellow("~")} ${pc12.dim(`${status}:`)} ${file}`);
1948
2986
  }
1949
2987
  }
1950
2988
  if (fileStatus.untracked.length > 0) {
1951
- console.log(` ${pc9.red("Untracked files:")}`);
2989
+ console.log(` ${pc12.red("Untracked files:")}`);
1952
2990
  for (const file of fileStatus.untracked) {
1953
- console.log(` ${pc9.red("?")} ${file}`);
2991
+ console.log(` ${pc12.red("?")} ${file}`);
1954
2992
  }
1955
2993
  }
1956
2994
  } else if (!dirty) {
1957
- console.log(` ${pc9.green("✓")} ${pc9.dim("Working tree clean")}`);
2995
+ console.log(` ${pc12.green("✓")} ${pc12.dim("Working tree clean")}`);
1958
2996
  }
1959
2997
  const tips = [];
1960
2998
  if (fileStatus.staged.length > 0) {
1961
- tips.push(`Run ${pc9.bold("contrib commit")} to commit staged changes`);
2999
+ tips.push(`Run ${pc12.bold("contrib commit")} to commit staged changes`);
1962
3000
  }
1963
3001
  if (fileStatus.modified.length > 0 || fileStatus.untracked.length > 0) {
1964
- tips.push(`Run ${pc9.bold("contrib commit")} to stage and commit changes`);
3002
+ tips.push(`Run ${pc12.bold("contrib commit")} to stage and commit changes`);
1965
3003
  }
1966
3004
  if (fileStatus.staged.length === 0 && fileStatus.modified.length === 0 && fileStatus.untracked.length === 0 && currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
1967
3005
  const branchDiv = await getDivergence(currentBranch, `${origin}/${currentBranch}`);
1968
3006
  if (branchDiv.ahead > 0) {
1969
- tips.push(`Run ${pc9.bold("contrib submit")} to push and create/update your PR`);
3007
+ tips.push(`Run ${pc12.bold("contrib submit")} to push and create/update your PR`);
1970
3008
  }
1971
3009
  }
1972
3010
  if (tips.length > 0) {
1973
3011
  console.log();
1974
- console.log(` ${pc9.dim("\uD83D\uDCA1 Tip:")}`);
3012
+ console.log(` ${pc12.dim("\uD83D\uDCA1 Tip:")}`);
1975
3013
  for (const tip of tips) {
1976
- console.log(` ${pc9.dim(tip)}`);
3014
+ console.log(` ${pc12.dim(tip)}`);
1977
3015
  }
1978
3016
  }
1979
3017
  console.log();
1980
3018
  }
1981
3019
  });
1982
3020
  function formatStatus(branch, base, ahead, behind) {
1983
- const label = pc9.bold(branch.padEnd(20));
3021
+ const label = pc12.bold(branch.padEnd(20));
1984
3022
  if (ahead === 0 && behind === 0) {
1985
- return ` ${pc9.green("✓")} ${label} ${pc9.dim(`in sync with ${base}`)}`;
3023
+ return ` ${pc12.green("✓")} ${label} ${pc12.dim(`in sync with ${base}`)}`;
1986
3024
  }
1987
3025
  if (ahead > 0 && behind === 0) {
1988
- return ` ${pc9.yellow("↑")} ${label} ${pc9.yellow(`${ahead} commit${ahead !== 1 ? "s" : ""} ahead of ${base}`)}`;
3026
+ return ` ${pc12.yellow("↑")} ${label} ${pc12.yellow(`${ahead} commit${ahead !== 1 ? "s" : ""} ahead of ${base}`)}`;
1989
3027
  }
1990
3028
  if (behind > 0 && ahead === 0) {
1991
- return ` ${pc9.red("↓")} ${label} ${pc9.red(`${behind} commit${behind !== 1 ? "s" : ""} behind ${base}`)}`;
3029
+ return ` ${pc12.red("↓")} ${label} ${pc12.red(`${behind} commit${behind !== 1 ? "s" : ""} behind ${base}`)}`;
1992
3030
  }
1993
- return ` ${pc9.red("⚡")} ${label} ${pc9.yellow(`${ahead} ahead`)}${pc9.dim(", ")}${pc9.red(`${behind} behind`)} ${pc9.dim(base)}`;
3031
+ return ` ${pc12.red("⚡")} ${label} ${pc12.yellow(`${ahead} ahead`)}${pc12.dim(", ")}${pc12.red(`${behind} behind`)} ${pc12.dim(base)}`;
1994
3032
  }
1995
3033
 
1996
3034
  // src/commands/submit.ts
1997
- import { defineCommand as defineCommand7 } from "citty";
1998
- import pc10 from "picocolors";
3035
+ import { defineCommand as defineCommand10 } from "citty";
3036
+ import pc13 from "picocolors";
1999
3037
  async function performSquashMerge(origin, baseBranch, featureBranch, options) {
2000
- info(`Checking out ${pc10.bold(baseBranch)}...`);
3038
+ info(`Checking out ${pc13.bold(baseBranch)}...`);
2001
3039
  const coResult = await checkoutBranch(baseBranch);
2002
3040
  if (coResult.exitCode !== 0) {
2003
3041
  error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
2004
3042
  process.exit(1);
2005
3043
  }
2006
- info(`Squash merging ${pc10.bold(featureBranch)} into ${pc10.bold(baseBranch)}...`);
3044
+ info(`Squash merging ${pc13.bold(featureBranch)} into ${pc13.bold(baseBranch)}...`);
2007
3045
  const mergeResult = await mergeSquash(featureBranch);
2008
3046
  if (mergeResult.exitCode !== 0) {
2009
3047
  error(`Squash merge failed: ${mergeResult.stderr}`);
@@ -2027,32 +3065,42 @@ async function performSquashMerge(origin, baseBranch, featureBranch, options) {
2027
3065
  }
2028
3066
  }
2029
3067
  const fallback = message || `squash merge ${featureBranch}`;
2030
- const finalMsg = await inputPrompt("Commit message", fallback);
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);
3074
+ }
2031
3075
  const commitResult = await commitWithMessage(finalMsg);
2032
3076
  if (commitResult.exitCode !== 0) {
2033
3077
  error(`Commit failed: ${commitResult.stderr}`);
2034
3078
  process.exit(1);
2035
3079
  }
2036
- info(`Pushing ${pc10.bold(baseBranch)} to ${origin}...`);
3080
+ info(`Pushing ${pc13.bold(baseBranch)} to ${origin}...`);
2037
3081
  const pushResult = await pushBranch(origin, baseBranch);
2038
3082
  if (pushResult.exitCode !== 0) {
2039
3083
  error(`Failed to push ${baseBranch}: ${pushResult.stderr}`);
2040
3084
  process.exit(1);
2041
3085
  }
2042
- info(`Deleting local branch ${pc10.bold(featureBranch)}...`);
3086
+ info(`Deleting local branch ${pc13.bold(featureBranch)}...`);
2043
3087
  const delLocal = await forceDeleteBranch(featureBranch);
2044
3088
  if (delLocal.exitCode !== 0) {
2045
3089
  warn(`Could not delete local branch: ${delLocal.stderr.trim()}`);
2046
3090
  }
2047
- info(`Deleting remote branch ${pc10.bold(featureBranch)}...`);
2048
- const delRemote = await deleteRemoteBranch(origin, featureBranch);
2049
- if (delRemote.exitCode !== 0) {
2050
- warn(`Could not delete remote branch: ${delRemote.stderr.trim()}`);
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
+ }
2051
3099
  }
2052
- success(`✅ Squash merged ${pc10.bold(featureBranch)} into ${pc10.bold(baseBranch)} and pushed.`);
2053
- info(`Run ${pc10.bold("contrib start")} to begin a new feature.`);
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.`);
2054
3102
  }
2055
- var submit_default = defineCommand7({
3103
+ var submit_default = defineCommand10({
2056
3104
  meta: {
2057
3105
  name: "submit",
2058
3106
  description: "Push current branch and create a pull request"
@@ -2078,6 +3126,7 @@ var submit_default = defineCommand7({
2078
3126
  error("Not inside a git repository.");
2079
3127
  process.exit(1);
2080
3128
  }
3129
+ await assertCleanGitState("submitting");
2081
3130
  const config = readConfig();
2082
3131
  if (!config) {
2083
3132
  error("No .contributerc.json found. Run `contrib setup` first.");
@@ -2092,8 +3141,80 @@ var submit_default = defineCommand7({
2092
3141
  process.exit(1);
2093
3142
  }
2094
3143
  if (protectedBranches.includes(currentBranch)) {
2095
- error(`Cannot submit ${protectedBranches.map((b) => pc10.bold(b)).join(" or ")} as a PR. Switch to your feature branch.`);
2096
- 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;
2097
3218
  }
2098
3219
  heading("\uD83D\uDE80 contrib submit");
2099
3220
  const ghInstalled = await checkGhInstalled();
@@ -2101,7 +3222,7 @@ var submit_default = defineCommand7({
2101
3222
  if (ghInstalled && ghAuthed) {
2102
3223
  const mergedPR = await getMergedPRForBranch(currentBranch);
2103
3224
  if (mergedPR) {
2104
- warn(`PR #${mergedPR.number} (${pc10.bold(mergedPR.title)}) was already merged.`);
3225
+ warn(`PR #${mergedPR.number} (${pc13.bold(mergedPR.title)}) was already merged.`);
2105
3226
  const localWork = await hasLocalWork(origin, currentBranch);
2106
3227
  const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
2107
3228
  if (hasWork) {
@@ -2109,7 +3230,7 @@ var submit_default = defineCommand7({
2109
3230
  warn("You have uncommitted changes in your working tree.");
2110
3231
  }
2111
3232
  if (localWork.unpushedCommits > 0) {
2112
- warn(`You have ${pc10.bold(String(localWork.unpushedCommits))} local commit${localWork.unpushedCommits !== 1 ? "s" : ""} not in the merged PR.`);
3233
+ warn(`You have ${pc13.bold(String(localWork.unpushedCommits))} local commit${localWork.unpushedCommits !== 1 ? "s" : ""} not in the merged PR.`);
2113
3234
  }
2114
3235
  const SAVE_NEW_BRANCH = "Save changes to a new branch";
2115
3236
  const DISCARD = "Discard all changes and clean up";
@@ -2120,79 +3241,91 @@ var submit_default = defineCommand7({
2120
3241
  return;
2121
3242
  }
2122
3243
  if (action === SAVE_NEW_BRANCH) {
2123
- const suggestedName = currentBranch.replace(/^(feature|fix|docs|chore|test|refactor)\//, "$1/new-");
2124
- const newBranchName = await inputPrompt("New branch name", suggestedName !== currentBranch ? suggestedName : `${currentBranch}-v2`);
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
+ }
2125
3275
  const renameResult = await renameBranch(currentBranch, newBranchName);
2126
3276
  if (renameResult.exitCode !== 0) {
2127
3277
  error(`Failed to rename branch: ${renameResult.stderr}`);
2128
3278
  process.exit(1);
2129
3279
  }
2130
- success(`Renamed ${pc10.bold(currentBranch)} → ${pc10.bold(newBranchName)}`);
3280
+ success(`Renamed ${pc13.bold(currentBranch)} → ${pc13.bold(newBranchName)}`);
3281
+ await unsetUpstream();
2131
3282
  const syncSource2 = getSyncSource(config);
2132
- info(`Syncing ${pc10.bold(newBranchName)} with latest ${pc10.bold(baseBranch)}...`);
3283
+ info(`Syncing ${pc13.bold(newBranchName)} with latest ${pc13.bold(baseBranch)}...`);
2133
3284
  await fetchRemote(syncSource2.remote);
2134
- const savedUpstreamRef = await getUpstreamRef();
2135
- const rebaseResult = savedUpstreamRef && savedUpstreamRef !== syncSource2.ref ? await rebaseOnto(syncSource2.ref, savedUpstreamRef) : await rebase(syncSource2.ref);
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
+ }
2136
3292
  if (rebaseResult.exitCode !== 0) {
2137
3293
  warn("Rebase encountered conflicts. Resolve them manually, then run:");
2138
- info(` ${pc10.bold("git rebase --continue")}`);
3294
+ info(` ${pc13.bold("git rebase --continue")}`);
2139
3295
  } else {
2140
- success(`Rebased ${pc10.bold(newBranchName)} onto ${pc10.bold(syncSource2.ref)}.`);
3296
+ success(`Rebased ${pc13.bold(newBranchName)} onto ${pc13.bold(syncSource2.ref)}.`);
2141
3297
  }
2142
- info(`All your changes are preserved. Run ${pc10.bold("contrib submit")} when ready to create a new PR.`);
3298
+ info(`All your changes are preserved. Run ${pc13.bold("contrib submit")} when ready to create a new PR.`);
2143
3299
  return;
2144
3300
  }
2145
3301
  warn("Discarding local changes...");
2146
3302
  }
2147
3303
  const syncSource = getSyncSource(config);
2148
- info(`Switching to ${pc10.bold(baseBranch)} and syncing...`);
3304
+ info(`Switching to ${pc13.bold(baseBranch)} and syncing...`);
2149
3305
  await fetchRemote(syncSource.remote);
3306
+ await resetHard("HEAD");
2150
3307
  const coResult = await checkoutBranch(baseBranch);
2151
3308
  if (coResult.exitCode !== 0) {
2152
3309
  error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
2153
3310
  process.exit(1);
2154
3311
  }
2155
3312
  await updateLocalBranch(baseBranch, syncSource.ref);
2156
- success(`Synced ${pc10.bold(baseBranch)} with ${pc10.bold(syncSource.ref)}.`);
2157
- info(`Deleting stale branch ${pc10.bold(currentBranch)}...`);
3313
+ success(`Synced ${pc13.bold(baseBranch)} with ${pc13.bold(syncSource.ref)}.`);
3314
+ info(`Deleting stale branch ${pc13.bold(currentBranch)}...`);
2158
3315
  const delResult = await forceDeleteBranch(currentBranch);
2159
3316
  if (delResult.exitCode === 0) {
2160
- success(`Deleted ${pc10.bold(currentBranch)}.`);
3317
+ success(`Deleted ${pc13.bold(currentBranch)}.`);
2161
3318
  } else {
2162
3319
  warn(`Could not delete branch: ${delResult.stderr.trim()}`);
2163
3320
  }
2164
3321
  console.log();
2165
- info(`You're now on ${pc10.bold(baseBranch)}. Run ${pc10.bold("contrib start")} to begin a new feature.`);
3322
+ info(`You're now on ${pc13.bold(baseBranch)}. Run ${pc13.bold("contrib start")} to begin a new feature.`);
2166
3323
  return;
2167
3324
  }
2168
3325
  }
2169
- info(`Pushing ${pc10.bold(currentBranch)} to ${origin}...`);
2170
- const pushResult = await pushSetUpstream(origin, currentBranch);
2171
- if (pushResult.exitCode !== 0) {
2172
- error(`Failed to push: ${pushResult.stderr}`);
2173
- process.exit(1);
2174
- }
2175
- if (!ghInstalled || !ghAuthed) {
2176
- const repoInfo = await getRepoInfoFromRemote(origin);
2177
- if (repoInfo) {
2178
- const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${baseBranch}...${currentBranch}?expand=1`;
2179
- console.log();
2180
- info("Create your PR manually:");
2181
- console.log(` ${pc10.cyan(prUrl)}`);
2182
- } else {
2183
- info("gh CLI not available. Create your PR manually on GitHub.");
2184
- }
2185
- return;
2186
- }
2187
- const existingPR = await getPRForBranch(currentBranch);
2188
- if (existingPR) {
2189
- success(`Pushed changes to existing PR #${existingPR.number}: ${pc10.bold(existingPR.title)}`);
2190
- console.log(` ${pc10.cyan(existingPR.url)}`);
2191
- return;
2192
- }
2193
3326
  let prTitle = null;
2194
3327
  let prBody = null;
2195
- if (!args["no-ai"]) {
3328
+ async function tryGenerateAI() {
2196
3329
  const [copilotError, commits, diff] = await Promise.all([
2197
3330
  checkCopilotAvailable(),
2198
3331
  getLog(baseBranch, "HEAD"),
@@ -2206,10 +3339,10 @@ var submit_default = defineCommand7({
2206
3339
  prBody = result.body;
2207
3340
  spinner.success("PR description generated.");
2208
3341
  console.log(`
2209
- ${pc10.dim("AI title:")} ${pc10.bold(pc10.cyan(prTitle))}`);
3342
+ ${pc13.dim("AI title:")} ${pc13.bold(pc13.cyan(prTitle))}`);
2210
3343
  console.log(`
2211
- ${pc10.dim("AI body preview:")}`);
2212
- console.log(pc10.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 ? "..." : "")));
2213
3346
  } else {
2214
3347
  spinner.fail("AI did not return a PR description.");
2215
3348
  }
@@ -2217,77 +3350,126 @@ ${pc10.dim("AI body preview:")}`);
2217
3350
  warn(`AI unavailable: ${copilotError}`);
2218
3351
  }
2219
3352
  }
3353
+ if (!args["no-ai"]) {
3354
+ await tryGenerateAI();
3355
+ }
2220
3356
  const CANCEL = "Cancel";
2221
3357
  const SQUASH_LOCAL = `Squash merge to ${baseBranch} locally (no PR)`;
2222
- if (prTitle && prBody) {
2223
- const choices = [
2224
- "Use AI description",
2225
- "Edit title",
2226
- "Write manually",
2227
- "Use gh --fill (auto-fill from commits)"
2228
- ];
2229
- if (config.role === "maintainer")
2230
- choices.push(SQUASH_LOCAL);
2231
- choices.push(CANCEL);
2232
- const action = await selectPrompt("What would you like to do with the PR description?", choices);
2233
- if (action === CANCEL) {
2234
- warn("Submit cancelled.");
2235
- return;
2236
- }
2237
- if (action === SQUASH_LOCAL) {
2238
- await performSquashMerge(origin, baseBranch, currentBranch, {
2239
- defaultMsg: prTitle ?? undefined,
2240
- model: args.model,
2241
- convention: config.commitConvention
2242
- });
2243
- return;
2244
- }
2245
- if (action === "Use AI description") {} else if (action === "Edit title") {
2246
- prTitle = await inputPrompt("PR title", prTitle);
2247
- } else if (action === "Write manually") {
2248
- prTitle = await inputPrompt("PR title");
2249
- prBody = await inputPrompt("PR body (markdown)");
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
+ }
2250
3395
  } else {
2251
- const fillResult = await createPRFill(baseBranch, args.draft);
2252
- if (fillResult.exitCode !== 0) {
2253
- error(`Failed to create PR: ${fillResult.stderr}`);
2254
- 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;
2255
3419
  }
2256
- success(`✅ PR created: ${fillResult.stdout.trim()}`);
2257
- return;
2258
- }
2259
- } else {
2260
- const choices = [
2261
- "Write title & body manually",
2262
- "Use gh --fill (auto-fill from commits)"
2263
- ];
2264
- if (config.role === "maintainer")
2265
- choices.push(SQUASH_LOCAL);
2266
- choices.push(CANCEL);
2267
- const action = await selectPrompt("How would you like to create the PR?", choices);
2268
- if (action === CANCEL) {
2269
- warn("Submit cancelled.");
2270
- return;
2271
3420
  }
2272
- if (action === SQUASH_LOCAL) {
2273
- await performSquashMerge(origin, baseBranch, currentBranch, {
2274
- model: args.model,
2275
- convention: config.commitConvention
2276
- });
2277
- return;
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}`);
2278
3444
  }
2279
- if (action === "Write title & body manually") {
2280
- prTitle = await inputPrompt("PR title");
2281
- prBody = await inputPrompt("PR body (markdown)");
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)}`);
2282
3454
  } else {
2283
- const fillResult = await createPRFill(baseBranch, args.draft);
2284
- if (fillResult.exitCode !== 0) {
2285
- error(`Failed to create PR: ${fillResult.stderr}`);
2286
- process.exit(1);
2287
- }
2288
- success(`✅ PR created: ${fillResult.stdout.trim()}`);
2289
- return;
3455
+ info("gh CLI not available. Create your PR manually on GitHub.");
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);
2290
3470
  }
3471
+ success(`✅ PR created: ${fillResult.stdout.trim()}`);
3472
+ return;
2291
3473
  }
2292
3474
  if (!prTitle) {
2293
3475
  error("No PR title provided.");
@@ -2308,9 +3490,9 @@ ${pc10.dim("AI body preview:")}`);
2308
3490
  });
2309
3491
 
2310
3492
  // src/commands/sync.ts
2311
- import { defineCommand as defineCommand8 } from "citty";
2312
- import pc11 from "picocolors";
2313
- var sync_default = defineCommand8({
3493
+ import { defineCommand as defineCommand11 } from "citty";
3494
+ import pc14 from "picocolors";
3495
+ var sync_default = defineCommand11({
2314
3496
  meta: {
2315
3497
  name: "sync",
2316
3498
  description: "Sync your local branches with the remote"
@@ -2321,6 +3503,15 @@ var sync_default = defineCommand8({
2321
3503
  alias: "y",
2322
3504
  description: "Skip confirmation prompt",
2323
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
2324
3515
  }
2325
3516
  },
2326
3517
  async run({ args }) {
@@ -2328,6 +3519,7 @@ var sync_default = defineCommand8({
2328
3519
  error("Not inside a git repository.");
2329
3520
  process.exit(1);
2330
3521
  }
3522
+ await assertCleanGitState("syncing");
2331
3523
  const config = readConfig();
2332
3524
  if (!config) {
2333
3525
  error("No .contributerc.json found. Run `contrib setup` first.");
@@ -2350,14 +3542,98 @@ var sync_default = defineCommand8({
2350
3542
  if (role === "contributor" && syncSource.remote !== origin) {
2351
3543
  await fetchRemote(origin);
2352
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;
2353
3552
  const div = await getDivergence(baseBranch, syncSource.ref);
2354
3553
  if (div.ahead > 0 || div.behind > 0) {
2355
- info(`${pc11.bold(baseBranch)} is ${pc11.yellow(`${div.ahead} ahead`)} and ${pc11.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}`);
2356
3555
  } else {
2357
- info(`${pc11.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
+ }
2358
3634
  }
2359
3635
  if (!args.yes) {
2360
- const ok = await confirmPrompt(`This will pull ${pc11.bold(syncSource.ref)} into local ${pc11.bold(baseBranch)}.`);
3636
+ const ok = await confirmPrompt(`This will pull ${pc14.bold(syncSource.ref)} into local ${pc14.bold(baseBranch)}.`);
2361
3637
  if (!ok)
2362
3638
  process.exit(0);
2363
3639
  }
@@ -2366,19 +3642,24 @@ var sync_default = defineCommand8({
2366
3642
  error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
2367
3643
  process.exit(1);
2368
3644
  }
2369
- const pullResult = await pullBranch(syncSource.remote, baseBranch);
3645
+ const pullResult = allowMergeCommit ? await pullBranch(syncSource.remote, baseBranch) : await pullFastForwardOnly(syncSource.remote, baseBranch);
2370
3646
  if (pullResult.exitCode !== 0) {
2371
- 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
+ }
2372
3653
  process.exit(1);
2373
3654
  }
2374
3655
  success(`✅ ${baseBranch} is now in sync with ${syncSource.ref}`);
2375
3656
  if (hasDevBranch(workflow) && role === "maintainer") {
2376
3657
  const mainDiv = await getDivergence(config.mainBranch, `${origin}/${config.mainBranch}`);
2377
3658
  if (mainDiv.behind > 0) {
2378
- info(`Also syncing ${pc11.bold(config.mainBranch)}...`);
3659
+ info(`Also syncing ${pc14.bold(config.mainBranch)}...`);
2379
3660
  const mainCoResult = await checkoutBranch(config.mainBranch);
2380
3661
  if (mainCoResult.exitCode === 0) {
2381
- const mainPullResult = await pullBranch(origin, config.mainBranch);
3662
+ const mainPullResult = await pullFastForwardOnly(origin, config.mainBranch);
2382
3663
  if (mainPullResult.exitCode === 0) {
2383
3664
  success(`✅ ${config.mainBranch} is now in sync with ${origin}/${config.mainBranch}`);
2384
3665
  }
@@ -2391,9 +3672,9 @@ var sync_default = defineCommand8({
2391
3672
 
2392
3673
  // src/commands/update.ts
2393
3674
  import { readFileSync as readFileSync4 } from "node:fs";
2394
- import { defineCommand as defineCommand9 } from "citty";
2395
- import pc12 from "picocolors";
2396
- var update_default = defineCommand9({
3675
+ import { defineCommand as defineCommand12 } from "citty";
3676
+ import pc15 from "picocolors";
3677
+ var update_default = defineCommand12({
2397
3678
  meta: {
2398
3679
  name: "update",
2399
3680
  description: "Rebase current branch onto the latest base branch"
@@ -2414,6 +3695,7 @@ var update_default = defineCommand9({
2414
3695
  error("Not inside a git repository.");
2415
3696
  process.exit(1);
2416
3697
  }
3698
+ await assertCleanGitState("updating");
2417
3699
  const config = readConfig();
2418
3700
  if (!config) {
2419
3701
  error("No .contributerc.json found. Run `contrib setup` first.");
@@ -2428,8 +3710,77 @@ var update_default = defineCommand9({
2428
3710
  process.exit(1);
2429
3711
  }
2430
3712
  if (protectedBranches.includes(currentBranch)) {
2431
- error(`Use \`contrib sync\` to update ${protectedBranches.map((b) => pc12.bold(b)).join(" or ")} branches.`);
2432
- 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;
2433
3784
  }
2434
3785
  if (await hasUncommittedChanges()) {
2435
3786
  error("You have uncommitted changes. Please commit or stash them first.");
@@ -2438,8 +3789,8 @@ var update_default = defineCommand9({
2438
3789
  heading("\uD83D\uDD03 contrib update");
2439
3790
  const mergedPR = await getMergedPRForBranch(currentBranch);
2440
3791
  if (mergedPR) {
2441
- warn(`PR #${mergedPR.number} (${pc12.bold(mergedPR.title)}) has already been merged.`);
2442
- info(`Link: ${pc12.underline(mergedPR.url)}`);
3792
+ warn(`PR #${mergedPR.number} (${pc15.bold(mergedPR.title)}) has already been merged.`);
3793
+ info(`Link: ${pc15.underline(mergedPR.url)}`);
2443
3794
  const localWork = await hasLocalWork(syncSource.remote, currentBranch);
2444
3795
  const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
2445
3796
  if (hasWork) {
@@ -2452,14 +3803,14 @@ var update_default = defineCommand9({
2452
3803
  const SAVE_NEW_BRANCH = "Save changes to a new branch";
2453
3804
  const DISCARD = "Discard all changes and clean up";
2454
3805
  const CANCEL = "Cancel";
2455
- const action = await selectPrompt(`${pc12.bold(currentBranch)} is stale but has local work. What would you like to do?`, [SAVE_NEW_BRANCH, DISCARD, 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]);
2456
3807
  if (action === CANCEL) {
2457
3808
  info("No changes made. You are still on your current branch.");
2458
3809
  return;
2459
3810
  }
2460
3811
  if (action === SAVE_NEW_BRANCH) {
2461
- info(pc12.dim("Tip: Describe what you're working on in plain English and we'll generate a branch name."));
2462
- const description = await inputPrompt("What are you working on?");
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?");
2463
3814
  let newBranchName = description;
2464
3815
  if (!args["no-ai"] && looksLikeNaturalLanguage(description)) {
2465
3816
  const spinner = createSpinner("Generating branch name suggestion...");
@@ -2467,8 +3818,8 @@ var update_default = defineCommand9({
2467
3818
  if (suggested) {
2468
3819
  spinner.success("Branch name suggestion ready.");
2469
3820
  console.log(`
2470
- ${pc12.dim("AI suggestion:")} ${pc12.bold(pc12.cyan(suggested))}`);
2471
- const accepted = await confirmPrompt(`Use ${pc12.bold(suggested)} as your branch name?`);
3821
+ ${pc15.dim("AI suggestion:")} ${pc15.bold(pc15.cyan(suggested))}`);
3822
+ const accepted = await confirmPrompt(`Use ${pc15.bold(suggested)} as your branch name?`);
2472
3823
  newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
2473
3824
  } else {
2474
3825
  spinner.fail("AI did not return a suggestion.");
@@ -2476,52 +3827,73 @@ var update_default = defineCommand9({
2476
3827
  }
2477
3828
  }
2478
3829
  if (!hasPrefix(newBranchName, config.branchPrefixes)) {
2479
- const prefix = await selectPrompt(`Choose a branch type for ${pc12.bold(newBranchName)}:`, config.branchPrefixes);
3830
+ const prefix = await selectPrompt(`Choose a branch type for ${pc15.bold(newBranchName)}:`, config.branchPrefixes);
2480
3831
  newBranchName = formatBranchName(prefix, newBranchName);
2481
3832
  }
2482
3833
  if (!isValidBranchName(newBranchName)) {
2483
3834
  error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
2484
3835
  process.exit(1);
2485
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
+ }
2486
3843
  const renameResult = await renameBranch(currentBranch, newBranchName);
2487
3844
  if (renameResult.exitCode !== 0) {
2488
3845
  error(`Failed to rename branch: ${renameResult.stderr}`);
2489
3846
  process.exit(1);
2490
3847
  }
2491
- success(`Renamed ${pc12.bold(currentBranch)} → ${pc12.bold(newBranchName)}`);
3848
+ success(`Renamed ${pc15.bold(currentBranch)} → ${pc15.bold(newBranchName)}`);
3849
+ await unsetUpstream();
2492
3850
  await fetchRemote(syncSource.remote);
2493
- const savedUpstreamRef = await getUpstreamRef();
2494
- const rebaseResult2 = savedUpstreamRef && savedUpstreamRef !== syncSource.ref ? await rebaseOnto(syncSource.ref, savedUpstreamRef) : await rebase(syncSource.ref);
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
+ }
2495
3858
  if (rebaseResult2.exitCode !== 0) {
2496
3859
  warn("Rebase encountered conflicts. Resolve them manually, then run:");
2497
- info(` ${pc12.bold("git rebase --continue")}`);
3860
+ info(` ${pc15.bold("git rebase --continue")}`);
2498
3861
  } else {
2499
- success(`Rebased ${pc12.bold(newBranchName)} onto ${pc12.bold(syncSource.ref)}.`);
3862
+ success(`Rebased ${pc15.bold(newBranchName)} onto ${pc15.bold(syncSource.ref)}.`);
2500
3863
  }
2501
- info(`All your changes are preserved. Run ${pc12.bold("contrib submit")} when ready to create a new PR.`);
3864
+ info(`All your changes are preserved. Run ${pc15.bold("contrib submit")} when ready to create a new PR.`);
2502
3865
  return;
2503
3866
  }
2504
3867
  warn("Discarding local changes...");
2505
3868
  }
2506
3869
  await fetchRemote(syncSource.remote);
3870
+ await resetHard("HEAD");
2507
3871
  const coResult = await checkoutBranch(baseBranch);
2508
3872
  if (coResult.exitCode !== 0) {
2509
3873
  error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
2510
3874
  process.exit(1);
2511
3875
  }
2512
3876
  await updateLocalBranch(baseBranch, syncSource.ref);
2513
- success(`Synced ${pc12.bold(baseBranch)} with ${pc12.bold(syncSource.ref)}.`);
2514
- info(`Deleting stale branch ${pc12.bold(currentBranch)}...`);
3877
+ success(`Synced ${pc15.bold(baseBranch)} with ${pc15.bold(syncSource.ref)}.`);
3878
+ info(`Deleting stale branch ${pc15.bold(currentBranch)}...`);
2515
3879
  await forceDeleteBranch(currentBranch);
2516
- success(`Deleted ${pc12.bold(currentBranch)}.`);
2517
- info(`Run ${pc12.bold("contrib start")} to begin a new feature branch.`);
3880
+ success(`Deleted ${pc15.bold(currentBranch)}.`);
3881
+ info(`Run ${pc15.bold("contrib start")} to begin a new feature branch.`);
2518
3882
  return;
2519
3883
  }
2520
- info(`Updating ${pc12.bold(currentBranch)} with latest ${pc12.bold(baseBranch)}...`);
3884
+ info(`Updating ${pc15.bold(currentBranch)} with latest ${pc15.bold(baseBranch)}...`);
2521
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
+ }
2522
3891
  await updateLocalBranch(baseBranch, syncSource.ref);
2523
- const upstreamRef = await getUpstreamRef();
2524
- const rebaseResult = upstreamRef && upstreamRef !== syncSource.ref ? await rebaseOnto(syncSource.ref, upstreamRef) : await rebase(syncSource.ref);
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);
2525
3897
  if (rebaseResult.exitCode !== 0) {
2526
3898
  warn("Rebase hit conflicts. Resolve them manually.");
2527
3899
  console.log();
@@ -2548,10 +3920,10 @@ ${content.slice(0, 2000)}
2548
3920
  if (suggestion) {
2549
3921
  spinner.success("AI conflict guidance ready.");
2550
3922
  console.log(`
2551
- ${pc12.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
2552
- console.log(pc12.dim("─".repeat(60)));
3923
+ ${pc15.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
3924
+ console.log(pc15.dim("─".repeat(60)));
2553
3925
  console.log(suggestion);
2554
- console.log(pc12.dim("─".repeat(60)));
3926
+ console.log(pc15.dim("─".repeat(60)));
2555
3927
  console.log();
2556
3928
  } else {
2557
3929
  spinner.fail("AI could not analyze the conflicts.");
@@ -2559,22 +3931,22 @@ ${pc12.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
2559
3931
  }
2560
3932
  }
2561
3933
  }
2562
- console.log(pc12.bold("To resolve:"));
3934
+ console.log(pc15.bold("To resolve:"));
2563
3935
  console.log(` 1. Fix conflicts in the affected files`);
2564
- console.log(` 2. ${pc12.cyan("git add <resolved-files>")}`);
2565
- console.log(` 3. ${pc12.cyan("git rebase --continue")}`);
3936
+ console.log(` 2. ${pc15.cyan("git add <resolved-files>")}`);
3937
+ console.log(` 3. ${pc15.cyan("git rebase --continue")}`);
2566
3938
  console.log();
2567
- console.log(` Or abort: ${pc12.cyan("git rebase --abort")}`);
3939
+ console.log(` Or abort: ${pc15.cyan("git rebase --abort")}`);
2568
3940
  process.exit(1);
2569
3941
  }
2570
- success(`✅ ${pc12.bold(currentBranch)} has been rebased onto latest ${pc12.bold(baseBranch)}`);
3942
+ success(`✅ ${pc15.bold(currentBranch)} has been rebased onto latest ${pc15.bold(baseBranch)}`);
2571
3943
  }
2572
3944
  });
2573
3945
 
2574
3946
  // src/commands/validate.ts
2575
- import { defineCommand as defineCommand10 } from "citty";
2576
- import pc13 from "picocolors";
2577
- var validate_default = defineCommand10({
3947
+ import { defineCommand as defineCommand13 } from "citty";
3948
+ import pc16 from "picocolors";
3949
+ var validate_default = defineCommand13({
2578
3950
  meta: {
2579
3951
  name: "validate",
2580
3952
  description: "Validate a commit message against the configured convention"
@@ -2604,7 +3976,7 @@ var validate_default = defineCommand10({
2604
3976
  }
2605
3977
  const errors = getValidationError(convention);
2606
3978
  for (const line of errors) {
2607
- console.error(pc13.red(` ✗ ${line}`));
3979
+ console.error(pc16.red(` ✗ ${line}`));
2608
3980
  }
2609
3981
  process.exit(1);
2610
3982
  }
@@ -2612,76 +3984,19 @@ var validate_default = defineCommand10({
2612
3984
 
2613
3985
  // src/ui/banner.ts
2614
3986
  import figlet from "figlet";
2615
- import pc14 from "picocolors";
2616
- // package.json
2617
- var package_default = {
2618
- name: "contribute-now",
2619
- version: "0.2.0-dev.7c81c96",
2620
- description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
2621
- type: "module",
2622
- bin: {
2623
- contrib: "dist/index.js",
2624
- contribute: "dist/index.js"
2625
- },
2626
- files: [
2627
- "dist"
2628
- ],
2629
- scripts: {
2630
- build: "bun build src/index.ts --outfile dist/index.js --target node --packages external",
2631
- cli: "bun run src/index.ts --",
2632
- dev: "bun src/index.ts",
2633
- test: "bun test",
2634
- lint: "biome check .",
2635
- "lint:fix": "biome check --write .",
2636
- format: "biome format --write .",
2637
- "www:dev": "bun run --cwd www dev",
2638
- "www:build": "bun run --cwd www build",
2639
- "www:preview": "bun run --cwd www preview"
2640
- },
2641
- engines: {
2642
- node: ">=18",
2643
- bun: ">=1.0"
2644
- },
2645
- keywords: [
2646
- "git",
2647
- "workflow",
2648
- "squash-merge",
2649
- "sync",
2650
- "cli",
2651
- "contribute",
2652
- "fork",
2653
- "dev-branch",
2654
- "clean-commit"
2655
- ],
2656
- author: "Waren Gonzaga",
2657
- license: "GPL-3.0",
2658
- repository: {
2659
- type: "git",
2660
- url: "git+https://github.com/warengonzaga/contribute-now.git"
2661
- },
2662
- dependencies: {
2663
- "@clack/prompts": "^1.0.1",
2664
- "@github/copilot-sdk": "^0.1.25",
2665
- "@wgtechlabs/log-engine": "^2.3.1",
2666
- citty: "^0.1.6",
2667
- figlet: "^1.10.0",
2668
- picocolors: "^1.1.1"
2669
- },
2670
- devDependencies: {
2671
- "@biomejs/biome": "^2.4.4",
2672
- "@types/bun": "latest",
2673
- "@types/figlet": "^1.7.0",
2674
- typescript: "^5.7.0"
2675
- }
2676
- };
2677
-
2678
- // src/ui/banner.ts
2679
- var LOGO;
3987
+ import pc17 from "picocolors";
3988
+ var LOGO_BIG;
2680
3989
  try {
2681
- LOGO = figlet.textSync(`Contribute
3990
+ LOGO_BIG = figlet.textSync(`Contribute
2682
3991
  Now`, { font: "ANSI Shadow" });
2683
3992
  } catch {
2684
- 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";
2685
4000
  }
2686
4001
  function getVersion() {
2687
4002
  return package_default.version ?? "unknown";
@@ -2689,23 +4004,44 @@ function getVersion() {
2689
4004
  function getAuthor() {
2690
4005
  return typeof package_default.author === "string" ? package_default.author : "unknown";
2691
4006
  }
2692
- function showBanner(showLinks = false) {
2693
- console.log(pc14.cyan(`
2694
- ${LOGO}`));
2695
- console.log(` ${pc14.dim(`v${getVersion()}`)} ${pc14.dim("—")} ${pc14.dim(`Built by ${getAuthor()}`)}`);
2696
- 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") {
2697
4013
  console.log();
2698
- console.log(` ${pc14.yellow("Star")} ${pc14.cyan("https://github.com/warengonzaga/contribute-now")}`);
2699
- console.log(` ${pc14.green("Contribute")} ${pc14.cyan("https://github.com/warengonzaga/contribute-now/blob/main/CONTRIBUTING.md")}`);
2700
- console.log(` ${pc14.magenta("Sponsor")} ${pc14.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")}`);
2701
4017
  }
2702
4018
  console.log();
2703
4019
  }
2704
4020
 
2705
4021
  // src/index.ts
2706
- var isHelp = process.argv.includes("--help") || process.argv.includes("-h");
2707
- showBanner(isHelp);
2708
- 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({
2709
4045
  meta: {
2710
4046
  name: "contrib",
2711
4047
  version: getVersion(),
@@ -2725,10 +4061,13 @@ var main = defineCommand11({
2725
4061
  commit: commit_default,
2726
4062
  update: update_default,
2727
4063
  submit: submit_default,
4064
+ branch: branch_default,
2728
4065
  clean: clean_default,
2729
4066
  status: status_default,
4067
+ log: log_default,
2730
4068
  hook: hook_default,
2731
- validate: validate_default
4069
+ validate: validate_default,
4070
+ doctor: doctor_default
2732
4071
  },
2733
4072
  run({ args }) {
2734
4073
  if (args.version) {