contribute-now 0.1.2 → 0.2.0-dev.70284d0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +162 -133
  2. package/dist/index.js +536 -210
  3. package/package.json +2 -3
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { defineCommand as defineCommand9, runMain } from "citty";
4
+ import { defineCommand as defineCommand11, runMain } from "citty";
5
5
 
6
6
  // src/commands/clean.ts
7
7
  import { defineCommand } from "citty";
@@ -44,12 +44,14 @@ function isGitignored(cwd = process.cwd()) {
44
44
  }
45
45
  function getDefaultConfig() {
46
46
  return {
47
+ workflow: "clean-flow",
47
48
  role: "contributor",
48
49
  mainBranch: "main",
49
50
  devBranch: "dev",
50
51
  upstream: "upstream",
51
52
  origin: "origin",
52
- branchPrefixes: ["feature", "fix", "docs", "chore", "test", "refactor"]
53
+ branchPrefixes: ["feature", "fix", "docs", "chore", "test", "refactor"],
54
+ commitConvention: "clean-commit"
53
55
  };
54
56
  }
55
57
 
@@ -167,8 +169,12 @@ async function createBranch(branch, from) {
167
169
  async function resetHard(ref) {
168
170
  return run(["reset", "--hard", ref]);
169
171
  }
170
- async function pushForceWithLease(remote, branch) {
171
- return run(["push", "--force-with-lease", remote, branch]);
172
+ async function updateLocalBranch(branch, target) {
173
+ const current = await getCurrentBranch();
174
+ if (current === branch) {
175
+ return resetHard(target);
176
+ }
177
+ return run(["branch", "-f", branch, target]);
172
178
  }
173
179
  async function pushSetUpstream(remote, branch) {
174
180
  return run(["push", "-u", remote, branch]);
@@ -236,6 +242,9 @@ async function getLog(base, head) {
236
242
  return stdout.trim().split(`
237
243
  `).filter(Boolean);
238
244
  }
245
+ async function pullBranch(remote, branch) {
246
+ return run(["pull", remote, branch]);
247
+ }
239
248
 
240
249
  // src/utils/logger.ts
241
250
  import { LogEngine, LogMode } from "@wgtechlabs/log-engine";
@@ -265,6 +274,53 @@ function heading(msg) {
265
274
  ${pc2.bold(msg)}`);
266
275
  }
267
276
 
277
+ // src/utils/workflow.ts
278
+ var WORKFLOW_DESCRIPTIONS = {
279
+ "clean-flow": "Clean Flow — main + dev, squash features into dev, merge dev into main",
280
+ "github-flow": "GitHub Flow — main + feature branches, squash/merge into main",
281
+ "git-flow": "Git Flow — main + develop + release + hotfix branches"
282
+ };
283
+ function getBaseBranch(config) {
284
+ switch (config.workflow) {
285
+ case "clean-flow":
286
+ case "git-flow":
287
+ return config.devBranch ?? "dev";
288
+ case "github-flow":
289
+ return config.mainBranch;
290
+ }
291
+ }
292
+ function hasDevBranch(workflow) {
293
+ return workflow === "clean-flow" || workflow === "git-flow";
294
+ }
295
+ function getSyncSource(config) {
296
+ const { workflow, role, mainBranch, origin, upstream } = config;
297
+ const devBranch = config.devBranch ?? "dev";
298
+ switch (workflow) {
299
+ case "clean-flow":
300
+ if (role === "contributor") {
301
+ return { remote: upstream, ref: `${upstream}/${devBranch}`, strategy: "pull" };
302
+ }
303
+ return { remote: origin, ref: `${origin}/${devBranch}`, strategy: "pull" };
304
+ case "github-flow":
305
+ if (role === "contributor") {
306
+ return { remote: upstream, ref: `${upstream}/${mainBranch}`, strategy: "pull" };
307
+ }
308
+ return { remote: origin, ref: `${origin}/${mainBranch}`, strategy: "pull" };
309
+ case "git-flow":
310
+ if (role === "contributor") {
311
+ return { remote: upstream, ref: `${upstream}/${devBranch}`, strategy: "pull" };
312
+ }
313
+ return { remote: origin, ref: `${origin}/${devBranch}`, strategy: "pull" };
314
+ }
315
+ }
316
+ function getProtectedBranches(config) {
317
+ const branches = [config.mainBranch];
318
+ if (hasDevBranch(config.workflow) && config.devBranch) {
319
+ branches.push(config.devBranch);
320
+ }
321
+ return branches;
322
+ }
323
+
268
324
  // src/commands/clean.ts
269
325
  var clean_default = defineCommand({
270
326
  meta: {
@@ -289,12 +345,13 @@ var clean_default = defineCommand({
289
345
  error("No .contributerc.json found. Run `contrib setup` first.");
290
346
  process.exit(1);
291
347
  }
292
- const { mainBranch, devBranch, origin } = config;
348
+ const { origin } = config;
349
+ const baseBranch = getBaseBranch(config);
293
350
  const currentBranch = await getCurrentBranch();
294
351
  heading("\uD83E\uDDF9 contrib clean");
295
- const mergedBranches = await getMergedBranches(devBranch);
296
- const protected_ = new Set([mainBranch, devBranch, currentBranch ?? ""]);
297
- const candidates = mergedBranches.filter((b) => !protected_.has(b));
352
+ const mergedBranches = await getMergedBranches(baseBranch);
353
+ const protectedBranches = new Set([...getProtectedBranches(config), currentBranch ?? ""]);
354
+ const candidates = mergedBranches.filter((b) => !protectedBranches.has(b));
298
355
  if (candidates.length === 0) {
299
356
  info("No merged branches to clean up.");
300
357
  } else {
@@ -332,10 +389,86 @@ ${pc3.bold("Branches to delete:")}`);
332
389
  import { defineCommand as defineCommand2 } from "citty";
333
390
  import pc4 from "picocolors";
334
391
 
392
+ // src/utils/convention.ts
393
+ var CLEAN_COMMIT_PATTERN = /^(📦|🔧|🗑\uFE0F?|🔒|⚙\uFE0F?|☕|🧪|📖|🚀) (new|update|remove|security|setup|chore|test|docs|release)(!?)( \([a-zA-Z0-9][a-zA-Z0-9-]*\))?: .{1,72}$/u;
394
+ var CONVENTIONAL_COMMIT_PATTERN = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(!?)(\([a-zA-Z0-9][a-zA-Z0-9._-]*\))?: .{1,72}$/;
395
+ var CONVENTION_LABELS = {
396
+ conventional: "Conventional Commits",
397
+ "clean-commit": "Clean Commit (by WGTech Labs)",
398
+ none: "No convention"
399
+ };
400
+ var CONVENTION_DESCRIPTIONS = {
401
+ conventional: "Conventional Commits — feat: | fix: | docs: | chore: etc. (conventionalcommits.org)",
402
+ "clean-commit": "Clean Commit — \uD83D\uDCE6 new: | \uD83D\uDD27 update: | \uD83D\uDDD1️ remove: etc. (by WGTech Labs)",
403
+ none: "No commit convention enforcement"
404
+ };
405
+ var CONVENTION_FORMAT_HINTS = {
406
+ conventional: [
407
+ "Format: <type>[!][(<scope>)]: <description>",
408
+ "Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert",
409
+ "Examples: feat: add login page | fix(auth): resolve token expiry | docs: update README"
410
+ ],
411
+ "clean-commit": [
412
+ "Format: <emoji> <type>[!][(<scope>)]: <description>",
413
+ "Types: \uD83D\uDCE6 new | \uD83D\uDD27 update | \uD83D\uDDD1️ remove | \uD83D\uDD12 security | ⚙️ setup | ☕ chore | \uD83E\uDDEA test | \uD83D\uDCD6 docs | \uD83D\uDE80 release",
414
+ "Examples: \uD83D\uDCE6 new: user auth | \uD83D\uDD27 update (api): improve errors | ⚙️ setup (ci): add workflow"
415
+ ]
416
+ };
417
+ function validateCommitMessage(message, convention) {
418
+ if (convention === "none")
419
+ return true;
420
+ if (convention === "clean-commit")
421
+ return CLEAN_COMMIT_PATTERN.test(message);
422
+ if (convention === "conventional")
423
+ return CONVENTIONAL_COMMIT_PATTERN.test(message);
424
+ return true;
425
+ }
426
+ function getValidationError(convention) {
427
+ if (convention === "none")
428
+ return [];
429
+ return [
430
+ `Commit message does not follow ${CONVENTION_LABELS[convention]} format.`,
431
+ ...CONVENTION_FORMAT_HINTS[convention]
432
+ ];
433
+ }
434
+
335
435
  // src/utils/copilot.ts
336
436
  import { CopilotClient } from "@github/copilot-sdk";
337
- var CLEAN_COMMIT_SYSTEM_PROMPT = `You are a git commit message generator. Generate a Clean Commit message following this exact format:
338
- <emoji> <type>[!][(<scope>)]: <description>
437
+ var CONVENTIONAL_COMMIT_SYSTEM_PROMPT = `You are a git commit message generator. Generate a Conventional Commit message following this exact format:
438
+ <type>[!][(<scope>)]: <description>
439
+
440
+ Types:
441
+ feat – a new feature
442
+ fix – a bug fix
443
+ docs – documentation only changes
444
+ style – changes that do not affect code meaning (whitespace, formatting)
445
+ refactor – code change that neither fixes a bug nor adds a feature
446
+ perf – performance improvement
447
+ test – adding or correcting tests
448
+ build – changes to the build system or external dependencies
449
+ ci – changes to CI configuration files and scripts
450
+ chore – other changes that don't modify src or test files
451
+ revert – reverts a previous commit
452
+
453
+ Rules:
454
+ - Breaking change (!) only for: feat, fix, refactor, perf
455
+ - Description: concise, imperative mood, max 72 chars, lowercase start
456
+ - Scope: optional, camelCase or kebab-case component name
457
+ - Return ONLY the commit message line, nothing else
458
+
459
+ Examples:
460
+ feat: add user authentication system
461
+ fix(auth): resolve token expiry issue
462
+ docs: update contributing guidelines
463
+ feat!: redesign authentication API`;
464
+ var CLEAN_COMMIT_SYSTEM_PROMPT = `You are a git commit message generator. Generate a Clean Commit message following this EXACT format:
465
+ <emoji> <type>[!][ (<scope>)]: <description>
466
+
467
+ CRITICAL spacing rules (must follow exactly):
468
+ - There MUST be a space between the emoji and the type
469
+ - If a scope is used, there MUST be a space before the opening parenthesis
470
+ - There MUST be a colon and a space after the type or scope before the description
471
+ - Pattern: EMOJI SPACE TYPE SPACE OPENPAREN SCOPE CLOSEPAREN COLON SPACE DESCRIPTION
339
472
 
340
473
  Emoji and type table:
341
474
  \uD83D\uDCE6 new – new features, files, or capabilities
@@ -350,15 +483,21 @@ Emoji and type table:
350
483
 
351
484
  Rules:
352
485
  - Breaking change (!) only for: new, update, remove, security
353
- - Description: concise, imperative mood, max 72 chars
486
+ - Description: concise, imperative mood, max 72 chars, lowercase start
354
487
  - Scope: optional, camelCase or kebab-case component name
355
488
  - Return ONLY the commit message line, nothing else
356
489
 
357
- Examples:
358
- \uD83D\uDCE6 new: user authentication system
490
+ Correct examples:
491
+ \uD83D\uDCE6 new: add user authentication system
359
492
  \uD83D\uDD27 update (api): improve error handling
360
493
  ⚙️ setup (ci): configure github actions workflow
361
- \uD83D\uDCE6 new!: completely redesign authentication system`;
494
+ \uD83D\uDCE6 new!: redesign authentication system
495
+ \uD83D\uDDD1️ remove (deps): drop unused lodash dependency
496
+
497
+ WRONG (never do this):
498
+ ⚙️setup(ci): ... ← missing spaces
499
+ \uD83D\uDCE6new: ... ← missing space after emoji
500
+ \uD83D\uDD27 update(api): ... ← missing space before scope`;
362
501
  var BRANCH_NAME_SYSTEM_PROMPT = `You are a git branch name generator. Convert natural language descriptions into proper git branch names.
363
502
 
364
503
  Format: <prefix>/<kebab-case-name>
@@ -392,8 +531,21 @@ Rules:
392
531
  - Suggest the most likely correct resolution strategy
393
532
  - Never auto-resolve — provide guidance only
394
533
  - Be concise and actionable`;
534
+ function suppressSubprocessWarnings() {
535
+ const prev = process.env.NODE_NO_WARNINGS;
536
+ process.env.NODE_NO_WARNINGS = "1";
537
+ return prev;
538
+ }
539
+ function restoreWarnings(prev) {
540
+ if (prev === undefined) {
541
+ delete process.env.NODE_NO_WARNINGS;
542
+ } else {
543
+ process.env.NODE_NO_WARNINGS = prev;
544
+ }
545
+ }
395
546
  async function checkCopilotAvailable() {
396
547
  let client = null;
548
+ const prev = suppressSubprocessWarnings();
397
549
  try {
398
550
  client = new CopilotClient;
399
551
  await client.start();
@@ -416,6 +568,7 @@ async function checkCopilotAvailable() {
416
568
  }
417
569
  return `Copilot health check failed: ${msg}`;
418
570
  } finally {
571
+ restoreWarnings(prev);
419
572
  try {
420
573
  await client.stop();
421
574
  } catch {}
@@ -423,17 +576,18 @@ async function checkCopilotAvailable() {
423
576
  return null;
424
577
  }
425
578
  async function callCopilot(systemMessage, userMessage, model) {
579
+ const prev = suppressSubprocessWarnings();
426
580
  const client = new CopilotClient;
427
581
  await client.start();
428
582
  try {
429
583
  const sessionConfig = {
430
- systemMessage: { content: systemMessage }
584
+ systemMessage: { mode: "replace", content: systemMessage }
431
585
  };
432
586
  if (model)
433
587
  sessionConfig.model = model;
434
588
  const session = await client.createSession(sessionConfig);
435
589
  try {
436
- const response = await session.sendAndWait({ content: userMessage });
590
+ const response = await session.sendAndWait({ prompt: userMessage });
437
591
  if (!response?.data?.content)
438
592
  return null;
439
593
  return response.data.content;
@@ -441,10 +595,16 @@ async function callCopilot(systemMessage, userMessage, model) {
441
595
  await session.destroy();
442
596
  }
443
597
  } finally {
598
+ restoreWarnings(prev);
444
599
  await client.stop();
445
600
  }
446
601
  }
447
- async function generateCommitMessage(diff, stagedFiles, model) {
602
+ function getCommitSystemPrompt(convention) {
603
+ if (convention === "conventional")
604
+ return CONVENTIONAL_COMMIT_SYSTEM_PROMPT;
605
+ return CLEAN_COMMIT_SYSTEM_PROMPT;
606
+ }
607
+ async function generateCommitMessage(diff, stagedFiles, model, convention = "clean-commit") {
448
608
  try {
449
609
  const userMessage = `Generate a commit message for these staged changes:
450
610
 
@@ -452,7 +612,7 @@ Files: ${stagedFiles.join(", ")}
452
612
 
453
613
  Diff:
454
614
  ${diff.slice(0, 4000)}`;
455
- const result = await callCopilot(CLEAN_COMMIT_SYSTEM_PROMPT, userMessage, model);
615
+ const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
456
616
  return result?.trim() ?? null;
457
617
  } catch {
458
618
  return null;
@@ -498,14 +658,10 @@ ${conflictDiff.slice(0, 4000)}`;
498
658
  }
499
659
 
500
660
  // src/commands/commit.ts
501
- var CLEAN_COMMIT_PATTERN = /^(📦|🔧|🗑\uFE0F?|🔒|⚙\uFE0F?|☕|🧪|📖|🚀) (new|update|remove|security|setup|chore|test|docs|release)(!?)( \([a-zA-Z0-9][a-zA-Z0-9-]*\))?: .{1,72}$/u;
502
- function validateCleanCommit(msg) {
503
- return CLEAN_COMMIT_PATTERN.test(msg);
504
- }
505
661
  var commit_default = defineCommand2({
506
662
  meta: {
507
663
  name: "commit",
508
- description: "Stage changes and create a Clean Commit message (AI-powered)"
664
+ description: "Stage changes and create a commit message (AI-powered)"
509
665
  },
510
666
  args: {
511
667
  model: {
@@ -556,7 +712,7 @@ ${pc4.bold("Changed files:")}`);
556
712
  } else {
557
713
  info("Generating commit message with AI...");
558
714
  const diff = await getStagedDiff();
559
- commitMessage = await generateCommitMessage(diff, stagedFiles, args.model);
715
+ commitMessage = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
560
716
  if (commitMessage) {
561
717
  console.log(`
562
718
  ${pc4.dim("AI suggestion:")} ${pc4.bold(pc4.cyan(commitMessage))}`);
@@ -580,7 +736,7 @@ ${pc4.bold("Changed files:")}`);
580
736
  } else if (action === "Regenerate") {
581
737
  info("Regenerating...");
582
738
  const diff = await getStagedDiff();
583
- const regen = await generateCommitMessage(diff, stagedFiles, args.model);
739
+ const regen = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
584
740
  if (regen) {
585
741
  console.log(`
586
742
  ${pc4.dim("AI suggestion:")} ${pc4.bold(pc4.cyan(regen))}`);
@@ -594,19 +750,25 @@ ${pc4.bold("Changed files:")}`);
594
750
  finalMessage = await inputPrompt("Enter commit message");
595
751
  }
596
752
  } else {
597
- console.log();
598
- console.log(pc4.dim("Clean Commit format: <emoji> <type>[!][(<scope>)]: <description>"));
599
- console.log(pc4.dim("Examples: \uD83D\uDCE6 new: user auth | \uD83D\uDD27 update (api): improve errors"));
600
- console.log();
753
+ const convention2 = config.commitConvention;
754
+ if (convention2 !== "none") {
755
+ console.log();
756
+ for (const hint of CONVENTION_FORMAT_HINTS[convention2]) {
757
+ console.log(pc4.dim(hint));
758
+ }
759
+ console.log();
760
+ }
601
761
  finalMessage = await inputPrompt("Enter commit message");
602
762
  }
603
763
  if (!finalMessage) {
604
764
  error("No commit message provided.");
605
765
  process.exit(1);
606
766
  }
607
- if (!validateCleanCommit(finalMessage)) {
608
- warn("Commit message does not follow Clean Commit format.");
609
- warn("Format: <emoji> <type>[!][(<scope>)]: <description>");
767
+ const convention = config.commitConvention;
768
+ if (!validateCommitMessage(finalMessage, convention)) {
769
+ for (const line of getValidationError(convention)) {
770
+ warn(line);
771
+ }
610
772
  const proceed = await confirmPrompt("Commit anyway?");
611
773
  if (!proceed)
612
774
  process.exit(1);
@@ -620,9 +782,117 @@ ${pc4.bold("Changed files:")}`);
620
782
  }
621
783
  });
622
784
 
623
- // src/commands/setup.ts
785
+ // src/commands/hook.ts
786
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
787
+ import { join as join2 } from "node:path";
624
788
  import { defineCommand as defineCommand3 } from "citty";
625
789
  import pc5 from "picocolors";
790
+ var HOOK_MARKER = "# managed by contribute-now";
791
+ function getHooksDir(cwd = process.cwd()) {
792
+ return join2(cwd, ".git", "hooks");
793
+ }
794
+ function getHookPath(cwd = process.cwd()) {
795
+ return join2(getHooksDir(cwd), "commit-msg");
796
+ }
797
+ function generateHookScript() {
798
+ return `#!/bin/sh
799
+ ${HOOK_MARKER}
800
+ # Validates commit messages against your configured convention.
801
+ # Install: contrib hook install
802
+ # Uninstall: contrib hook uninstall
803
+
804
+ commit_msg_file="$1"
805
+ commit_msg=$(head -1 "$commit_msg_file")
806
+
807
+ # Skip merge commits and fixup/squash commits
808
+ case "$commit_msg" in
809
+ Merge\\ *|fixup!*|squash!*|amend!*) exit 0 ;;
810
+ esac
811
+
812
+ # Validate using contrib CLI
813
+ npx contrib validate "$commit_msg"
814
+ `;
815
+ }
816
+ var hook_default = defineCommand3({
817
+ meta: {
818
+ name: "hook",
819
+ description: "Install or uninstall the commit-msg git hook"
820
+ },
821
+ args: {
822
+ action: {
823
+ type: "positional",
824
+ description: "Action to perform: install or uninstall",
825
+ required: true
826
+ }
827
+ },
828
+ async run({ args }) {
829
+ if (!await isGitRepo()) {
830
+ error("Not inside a git repository.");
831
+ process.exit(1);
832
+ }
833
+ const action = args.action;
834
+ if (action !== "install" && action !== "uninstall") {
835
+ error(`Unknown action "${action}". Use "install" or "uninstall".`);
836
+ process.exit(1);
837
+ }
838
+ if (action === "install") {
839
+ await installHook();
840
+ } else {
841
+ await uninstallHook();
842
+ }
843
+ }
844
+ });
845
+ async function installHook() {
846
+ heading("\uD83E\uDE9D hook install");
847
+ const config = readConfig();
848
+ if (!config) {
849
+ error("No .contributerc.json found. Run `contrib setup` first.");
850
+ process.exit(1);
851
+ }
852
+ if (config.commitConvention === "none") {
853
+ warn('Commit convention is set to "none". No hook to install.');
854
+ info("Change your convention with `contrib setup` first.");
855
+ process.exit(0);
856
+ }
857
+ const hookPath = getHookPath();
858
+ const hooksDir = getHooksDir();
859
+ if (existsSync2(hookPath)) {
860
+ const existing = readFileSync2(hookPath, "utf-8");
861
+ if (!existing.includes(HOOK_MARKER)) {
862
+ error("A commit-msg hook already exists and was not installed by contribute-now.");
863
+ warn(`Path: ${hookPath}`);
864
+ warn("Remove it manually or back it up before installing.");
865
+ process.exit(1);
866
+ }
867
+ info("Updating existing contribute-now hook...");
868
+ }
869
+ if (!existsSync2(hooksDir)) {
870
+ mkdirSync(hooksDir, { recursive: true });
871
+ }
872
+ writeFileSync2(hookPath, generateHookScript(), { mode: 493 });
873
+ success(`commit-msg hook installed.`);
874
+ info(`Convention: ${pc5.bold(CONVENTION_LABELS[config.commitConvention])}`);
875
+ info(`Path: ${pc5.dim(hookPath)}`);
876
+ }
877
+ async function uninstallHook() {
878
+ heading("\uD83E\uDE9D hook uninstall");
879
+ const hookPath = getHookPath();
880
+ if (!existsSync2(hookPath)) {
881
+ info("No commit-msg hook found. Nothing to uninstall.");
882
+ return;
883
+ }
884
+ const content = readFileSync2(hookPath, "utf-8");
885
+ if (!content.includes(HOOK_MARKER)) {
886
+ error("The commit-msg hook was not installed by contribute-now. Leaving it untouched.");
887
+ process.exit(1);
888
+ }
889
+ rmSync(hookPath);
890
+ success("commit-msg hook removed.");
891
+ }
892
+
893
+ // src/commands/setup.ts
894
+ import { defineCommand as defineCommand4 } from "citty";
895
+ import pc6 from "picocolors";
626
896
 
627
897
  // src/utils/gh.ts
628
898
  import { execFile as execFileCb2 } from "node:child_process";
@@ -735,7 +1005,7 @@ async function getRepoInfoFromRemote(remote = "origin") {
735
1005
  }
736
1006
 
737
1007
  // src/commands/setup.ts
738
- var setup_default = defineCommand3({
1008
+ var setup_default = defineCommand4({
739
1009
  meta: {
740
1010
  name: "setup",
741
1011
  description: "Initialize contribute-now config for this repo (.contributerc.json)"
@@ -746,6 +1016,27 @@ var setup_default = defineCommand3({
746
1016
  process.exit(1);
747
1017
  }
748
1018
  heading("\uD83D\uDD27 contribute-now setup");
1019
+ const workflowChoice = await selectPrompt("Which git workflow does this project use?", [
1020
+ "Clean Flow — main + dev, squash features into dev, merge dev into main (recommended)",
1021
+ "GitHub Flow — main + feature branches, squash/merge into main",
1022
+ "Git Flow — main + develop + release + hotfix branches"
1023
+ ]);
1024
+ let workflow = "clean-flow";
1025
+ if (workflowChoice.startsWith("GitHub"))
1026
+ workflow = "github-flow";
1027
+ else if (workflowChoice.startsWith("Git Flow"))
1028
+ workflow = "git-flow";
1029
+ info(`Workflow: ${pc6.bold(WORKFLOW_DESCRIPTIONS[workflow])}`);
1030
+ const conventionChoice = await selectPrompt("Which commit convention should this project use?", [
1031
+ `${CONVENTION_DESCRIPTIONS["clean-commit"]} (recommended)`,
1032
+ CONVENTION_DESCRIPTIONS.conventional,
1033
+ CONVENTION_DESCRIPTIONS.none
1034
+ ]);
1035
+ let commitConvention = "clean-commit";
1036
+ if (conventionChoice.includes("Conventional Commits"))
1037
+ commitConvention = "conventional";
1038
+ else if (conventionChoice.includes("No commit"))
1039
+ commitConvention = "none";
749
1040
  const remotes = await getRemotes();
750
1041
  if (remotes.length === 0) {
751
1042
  error("No git remotes found. Add a remote first (e.g., git remote add origin <url>).");
@@ -788,8 +1079,8 @@ var setup_default = defineCommand3({
788
1079
  detectedRole = roleChoice;
789
1080
  detectionSource = "user selection";
790
1081
  } else {
791
- info(`Detected role: ${pc5.bold(detectedRole)} (via ${detectionSource})`);
792
- const confirmed = await confirmPrompt(`Role detected as ${pc5.bold(detectedRole)}. Is this correct?`);
1082
+ info(`Detected role: ${pc6.bold(detectedRole)} (via ${detectionSource})`);
1083
+ const confirmed = await confirmPrompt(`Role detected as ${pc6.bold(detectedRole)}. Is this correct?`);
793
1084
  if (!confirmed) {
794
1085
  const roleChoice = await selectPrompt("Select your role:", ["maintainer", "contributor"]);
795
1086
  detectedRole = roleChoice;
@@ -797,7 +1088,11 @@ var setup_default = defineCommand3({
797
1088
  }
798
1089
  const defaultConfig = getDefaultConfig();
799
1090
  const mainBranch = await inputPrompt("Main branch name", defaultConfig.mainBranch);
800
- const devBranch = await inputPrompt("Dev branch name", defaultConfig.devBranch);
1091
+ let devBranch;
1092
+ if (hasDevBranch(workflow)) {
1093
+ const defaultDev = workflow === "git-flow" ? "develop" : "dev";
1094
+ devBranch = await inputPrompt("Dev/develop branch name", defaultDev);
1095
+ }
801
1096
  const originRemote = await inputPrompt("Origin remote name", defaultConfig.origin);
802
1097
  let upstreamRemote = defaultConfig.upstream;
803
1098
  if (detectedRole === "contributor") {
@@ -814,12 +1109,14 @@ var setup_default = defineCommand3({
814
1109
  }
815
1110
  }
816
1111
  const config = {
1112
+ workflow,
817
1113
  role: detectedRole,
818
1114
  mainBranch,
819
- devBranch,
1115
+ ...devBranch ? { devBranch } : {},
820
1116
  upstream: upstreamRemote,
821
1117
  origin: originRemote,
822
- branchPrefixes: defaultConfig.branchPrefixes
1118
+ branchPrefixes: defaultConfig.branchPrefixes,
1119
+ commitConvention
823
1120
  };
824
1121
  writeConfig(config);
825
1122
  success(`✅ Config written to .contributerc.json`);
@@ -828,15 +1125,21 @@ var setup_default = defineCommand3({
828
1125
  warn(' echo ".contributerc.json" >> .gitignore');
829
1126
  }
830
1127
  console.log();
831
- info(`Role: ${pc5.bold(config.role)}`);
832
- info(`Main: ${pc5.bold(config.mainBranch)} | Dev: ${pc5.bold(config.devBranch)}`);
833
- info(`Origin: ${pc5.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc5.bold(config.upstream)}` : ""}`);
1128
+ info(`Workflow: ${pc6.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
1129
+ info(`Convention: ${pc6.bold(CONVENTION_DESCRIPTIONS[config.commitConvention])}`);
1130
+ info(`Role: ${pc6.bold(config.role)}`);
1131
+ if (config.devBranch) {
1132
+ info(`Main: ${pc6.bold(config.mainBranch)} | Dev: ${pc6.bold(config.devBranch)}`);
1133
+ } else {
1134
+ info(`Main: ${pc6.bold(config.mainBranch)}`);
1135
+ }
1136
+ info(`Origin: ${pc6.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc6.bold(config.upstream)}` : ""}`);
834
1137
  }
835
1138
  });
836
1139
 
837
1140
  // src/commands/start.ts
838
- import { defineCommand as defineCommand4 } from "citty";
839
- import pc6 from "picocolors";
1141
+ import { defineCommand as defineCommand5 } from "citty";
1142
+ import pc7 from "picocolors";
840
1143
 
841
1144
  // src/utils/branch.ts
842
1145
  var DEFAULT_PREFIXES = ["feature", "fix", "docs", "chore", "test", "refactor"];
@@ -852,10 +1155,10 @@ function looksLikeNaturalLanguage(input) {
852
1155
  }
853
1156
 
854
1157
  // src/commands/start.ts
855
- var start_default = defineCommand4({
1158
+ var start_default = defineCommand5({
856
1159
  meta: {
857
1160
  name: "start",
858
- description: "Create a new feature branch from the latest dev"
1161
+ description: "Create a new feature branch from the latest base branch"
859
1162
  },
860
1163
  args: {
861
1164
  name: {
@@ -887,7 +1190,9 @@ var start_default = defineCommand4({
887
1190
  error("You have uncommitted changes. Please commit or stash them before creating a branch.");
888
1191
  process.exit(1);
889
1192
  }
890
- const { devBranch, origin, upstream, branchPrefixes, role } = config;
1193
+ const { branchPrefixes } = config;
1194
+ const baseBranch = getBaseBranch(config);
1195
+ const syncSource = getSyncSource(config);
891
1196
  let branchName = args.name;
892
1197
  heading("\uD83C\uDF3F contrib start");
893
1198
  const useAI = !args["no-ai"] && looksLikeNaturalLanguage(branchName);
@@ -896,8 +1201,8 @@ var start_default = defineCommand4({
896
1201
  const suggested = await suggestBranchName(branchName, args.model);
897
1202
  if (suggested) {
898
1203
  console.log(`
899
- ${pc6.dim("AI suggestion:")} ${pc6.bold(pc6.cyan(suggested))}`);
900
- const accepted = await confirmPrompt(`Use ${pc6.bold(suggested)} as your branch name?`);
1204
+ ${pc7.dim("AI suggestion:")} ${pc7.bold(pc7.cyan(suggested))}`);
1205
+ const accepted = await confirmPrompt(`Use ${pc7.bold(suggested)} as your branch name?`);
901
1206
  if (accepted) {
902
1207
  branchName = suggested;
903
1208
  } else {
@@ -906,31 +1211,29 @@ var start_default = defineCommand4({
906
1211
  }
907
1212
  }
908
1213
  if (!hasPrefix(branchName, branchPrefixes)) {
909
- const prefix = await selectPrompt(`Choose a branch type for ${pc6.bold(branchName)}:`, branchPrefixes);
1214
+ const prefix = await selectPrompt(`Choose a branch type for ${pc7.bold(branchName)}:`, branchPrefixes);
910
1215
  branchName = formatBranchName(prefix, branchName);
911
1216
  }
912
- info(`Creating branch: ${pc6.bold(branchName)}`);
913
- const remote = role === "contributor" ? upstream : origin;
914
- const remoteDevRef = role === "contributor" ? `${upstream}/${devBranch}` : `${origin}/${devBranch}`;
915
- await fetchRemote(remote);
916
- const resetResult = await resetHard(remoteDevRef);
917
- if (resetResult.exitCode !== 0) {}
918
- const result = await createBranch(branchName, devBranch);
1217
+ info(`Creating branch: ${pc7.bold(branchName)}`);
1218
+ await fetchRemote(syncSource.remote);
1219
+ const updateResult = await updateLocalBranch(baseBranch, syncSource.ref);
1220
+ if (updateResult.exitCode !== 0) {}
1221
+ const result = await createBranch(branchName, baseBranch);
919
1222
  if (result.exitCode !== 0) {
920
1223
  error(`Failed to create branch: ${result.stderr}`);
921
1224
  process.exit(1);
922
1225
  }
923
- success(`✅ Created ${pc6.bold(branchName)} from latest ${pc6.bold(devBranch)}`);
1226
+ success(`✅ Created ${pc7.bold(branchName)} from latest ${pc7.bold(baseBranch)}`);
924
1227
  }
925
1228
  });
926
1229
 
927
1230
  // src/commands/status.ts
928
- import { defineCommand as defineCommand5 } from "citty";
929
- import pc7 from "picocolors";
930
- var status_default = defineCommand5({
1231
+ import { defineCommand as defineCommand6 } from "citty";
1232
+ import pc8 from "picocolors";
1233
+ var status_default = defineCommand6({
931
1234
  meta: {
932
1235
  name: "status",
933
- description: "Show sync status of main, dev, and current branch"
1236
+ description: "Show sync status of branches"
934
1237
  },
935
1238
  async run() {
936
1239
  if (!await isGitRepo()) {
@@ -943,60 +1246,57 @@ var status_default = defineCommand5({
943
1246
  process.exit(1);
944
1247
  }
945
1248
  heading("\uD83D\uDCCA contribute-now status");
1249
+ console.log(` ${pc8.dim("Workflow:")} ${pc8.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
1250
+ console.log(` ${pc8.dim("Role:")} ${pc8.bold(config.role)}`);
1251
+ console.log();
946
1252
  await fetchAll();
947
1253
  const currentBranch = await getCurrentBranch();
948
- const { mainBranch, devBranch, origin, upstream } = config;
1254
+ const { mainBranch, origin, upstream, workflow } = config;
1255
+ const baseBranch = getBaseBranch(config);
949
1256
  const isContributor = config.role === "contributor";
950
1257
  const dirty = await hasUncommittedChanges();
951
1258
  if (dirty) {
952
- console.log(` ${pc7.yellow("⚠")} ${pc7.yellow("Uncommitted changes in working tree")}`);
1259
+ console.log(` ${pc8.yellow("⚠")} ${pc8.yellow("Uncommitted changes in working tree")}`);
953
1260
  console.log();
954
1261
  }
955
1262
  const mainRemote = `${origin}/${mainBranch}`;
956
1263
  const mainDiv = await getDivergence(mainBranch, mainRemote);
957
1264
  const mainStatus = formatStatus(mainBranch, mainRemote, mainDiv.ahead, mainDiv.behind);
958
1265
  console.log(mainStatus);
959
- const devRemoteRef = isContributor ? `${upstream}/${devBranch}` : `${origin}/${mainBranch}`;
960
- const devDiv = await getDivergence(devBranch, devRemoteRef);
961
- let devLine = formatStatus(devBranch, devRemoteRef, devDiv.ahead, devDiv.behind);
962
- if (!isContributor && devDiv.ahead > 0 && devDiv.behind > 0) {
963
- devLine += pc7.red(" (needs sync! squash-merge divergence detected)");
964
- } else if (devDiv.ahead > 0 && devDiv.behind === 0) {
965
- devLine += pc7.yellow(" (needs sync!)");
966
- }
967
- console.log(devLine);
968
- if (currentBranch && currentBranch !== mainBranch && currentBranch !== devBranch) {
969
- const branchDiv = await getDivergence(currentBranch, devBranch);
970
- const branchLine = formatStatus(currentBranch, devBranch, branchDiv.ahead, branchDiv.behind);
971
- console.log(branchLine + pc7.dim(` (current ${pc7.green("*")})`));
1266
+ if (hasDevBranch(workflow) && config.devBranch) {
1267
+ const devRemoteRef = isContributor ? `${upstream}/${config.devBranch}` : `${origin}/${config.devBranch}`;
1268
+ const devDiv = await getDivergence(config.devBranch, devRemoteRef);
1269
+ const devLine = formatStatus(config.devBranch, devRemoteRef, devDiv.ahead, devDiv.behind);
1270
+ console.log(devLine);
1271
+ }
1272
+ if (currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
1273
+ const branchDiv = await getDivergence(currentBranch, baseBranch);
1274
+ const branchLine = formatStatus(currentBranch, baseBranch, branchDiv.ahead, branchDiv.behind);
1275
+ console.log(branchLine + pc8.dim(` (current ${pc8.green("*")})`));
972
1276
  } else if (currentBranch) {
973
- if (currentBranch === mainBranch) {
974
- console.log(pc7.dim(` (on ${pc7.bold(mainBranch)} branch)`));
975
- } else if (currentBranch === devBranch) {
976
- console.log(pc7.dim(` (on ${pc7.bold(devBranch)} branch)`));
977
- }
1277
+ console.log(pc8.dim(` (on ${pc8.bold(currentBranch)} branch)`));
978
1278
  }
979
1279
  console.log();
980
1280
  }
981
1281
  });
982
1282
  function formatStatus(branch, base, ahead, behind) {
983
- const label = pc7.bold(branch.padEnd(20));
1283
+ const label = pc8.bold(branch.padEnd(20));
984
1284
  if (ahead === 0 && behind === 0) {
985
- return ` ${pc7.green("✓")} ${label} ${pc7.dim(`in sync with ${base}`)}`;
1285
+ return ` ${pc8.green("✓")} ${label} ${pc8.dim(`in sync with ${base}`)}`;
986
1286
  }
987
1287
  if (ahead > 0 && behind === 0) {
988
- return ` ${pc7.yellow("↑")} ${label} ${pc7.yellow(`${ahead} commit${ahead !== 1 ? "s" : ""} ahead of ${base}`)}`;
1288
+ return ` ${pc8.yellow("↑")} ${label} ${pc8.yellow(`${ahead} commit${ahead !== 1 ? "s" : ""} ahead of ${base}`)}`;
989
1289
  }
990
1290
  if (behind > 0 && ahead === 0) {
991
- return ` ${pc7.red("↓")} ${label} ${pc7.red(`${behind} commit${behind !== 1 ? "s" : ""} behind ${base}`)}`;
1291
+ return ` ${pc8.red("↓")} ${label} ${pc8.red(`${behind} commit${behind !== 1 ? "s" : ""} behind ${base}`)}`;
992
1292
  }
993
- return ` ${pc7.red("⚡")} ${label} ${pc7.yellow(`${ahead} ahead`)}${pc7.dim(", ")}${pc7.red(`${behind} behind`)} ${pc7.dim(base)}`;
1293
+ return ` ${pc8.red("⚡")} ${label} ${pc8.yellow(`${ahead} ahead`)}${pc8.dim(", ")}${pc8.red(`${behind} behind`)} ${pc8.dim(base)}`;
994
1294
  }
995
1295
 
996
1296
  // src/commands/submit.ts
997
- import { defineCommand as defineCommand6 } from "citty";
998
- import pc8 from "picocolors";
999
- var submit_default = defineCommand6({
1297
+ import { defineCommand as defineCommand7 } from "citty";
1298
+ import pc9 from "picocolors";
1299
+ var submit_default = defineCommand7({
1000
1300
  meta: {
1001
1301
  name: "submit",
1002
1302
  description: "Push current branch and create a pull request"
@@ -1027,18 +1327,20 @@ var submit_default = defineCommand6({
1027
1327
  error("No .contributerc.json found. Run `contrib setup` first.");
1028
1328
  process.exit(1);
1029
1329
  }
1030
- const { mainBranch, devBranch, origin } = config;
1330
+ const { origin } = config;
1331
+ const baseBranch = getBaseBranch(config);
1332
+ const protectedBranches = getProtectedBranches(config);
1031
1333
  const currentBranch = await getCurrentBranch();
1032
1334
  if (!currentBranch) {
1033
1335
  error("Could not determine current branch.");
1034
1336
  process.exit(1);
1035
1337
  }
1036
- if (currentBranch === mainBranch || currentBranch === devBranch) {
1037
- error(`Cannot submit ${pc8.bold(mainBranch)} or ${pc8.bold(devBranch)} as a PR. Switch to your feature branch.`);
1338
+ if (protectedBranches.includes(currentBranch)) {
1339
+ error(`Cannot submit ${protectedBranches.map((b) => pc9.bold(b)).join(" or ")} as a PR. Switch to your feature branch.`);
1038
1340
  process.exit(1);
1039
1341
  }
1040
1342
  heading("\uD83D\uDE80 contrib submit");
1041
- info(`Pushing ${pc8.bold(currentBranch)} to ${origin}...`);
1343
+ info(`Pushing ${pc9.bold(currentBranch)} to ${origin}...`);
1042
1344
  const pushResult = await pushSetUpstream(origin, currentBranch);
1043
1345
  if (pushResult.exitCode !== 0) {
1044
1346
  error(`Failed to push: ${pushResult.stderr}`);
@@ -1049,10 +1351,10 @@ var submit_default = defineCommand6({
1049
1351
  if (!ghInstalled || !ghAuthed) {
1050
1352
  const repoInfo = await getRepoInfoFromRemote(origin);
1051
1353
  if (repoInfo) {
1052
- const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${devBranch}...${currentBranch}?expand=1`;
1354
+ const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${baseBranch}...${currentBranch}?expand=1`;
1053
1355
  console.log();
1054
1356
  info("Create your PR manually:");
1055
- console.log(` ${pc8.cyan(prUrl)}`);
1357
+ console.log(` ${pc9.cyan(prUrl)}`);
1056
1358
  } else {
1057
1359
  info("gh CLI not available. Create your PR manually on GitHub.");
1058
1360
  }
@@ -1064,17 +1366,17 @@ var submit_default = defineCommand6({
1064
1366
  const copilotError = await checkCopilotAvailable();
1065
1367
  if (!copilotError) {
1066
1368
  info("Generating AI PR description...");
1067
- const commits = await getLog(devBranch, "HEAD");
1068
- const diff = await getLogDiff(devBranch, "HEAD");
1369
+ const commits = await getLog(baseBranch, "HEAD");
1370
+ const diff = await getLogDiff(baseBranch, "HEAD");
1069
1371
  const result = await generatePRDescription(commits, diff, args.model);
1070
1372
  if (result) {
1071
1373
  prTitle = result.title;
1072
1374
  prBody = result.body;
1073
1375
  console.log(`
1074
- ${pc8.dim("AI title:")} ${pc8.bold(pc8.cyan(prTitle))}`);
1376
+ ${pc9.dim("AI title:")} ${pc9.bold(pc9.cyan(prTitle))}`);
1075
1377
  console.log(`
1076
- ${pc8.dim("AI body preview:")}`);
1077
- console.log(pc8.dim(prBody.slice(0, 300) + (prBody.length > 300 ? "..." : "")));
1378
+ ${pc9.dim("AI body preview:")}`);
1379
+ console.log(pc9.dim(prBody.slice(0, 300) + (prBody.length > 300 ? "..." : "")));
1078
1380
  } else {
1079
1381
  warn("AI did not return a PR description.");
1080
1382
  }
@@ -1095,7 +1397,7 @@ ${pc8.dim("AI body preview:")}`);
1095
1397
  prTitle = await inputPrompt("PR title");
1096
1398
  prBody = await inputPrompt("PR body (markdown)");
1097
1399
  } else {
1098
- const fillResult = await createPRFill(devBranch, args.draft);
1400
+ const fillResult = await createPRFill(baseBranch, args.draft);
1099
1401
  if (fillResult.exitCode !== 0) {
1100
1402
  error(`Failed to create PR: ${fillResult.stderr}`);
1101
1403
  process.exit(1);
@@ -1109,7 +1411,7 @@ ${pc8.dim("AI body preview:")}`);
1109
1411
  prTitle = await inputPrompt("PR title");
1110
1412
  prBody = await inputPrompt("PR body (markdown)");
1111
1413
  } else {
1112
- const fillResult = await createPRFill(devBranch, args.draft);
1414
+ const fillResult = await createPRFill(baseBranch, args.draft);
1113
1415
  if (fillResult.exitCode !== 0) {
1114
1416
  error(`Failed to create PR: ${fillResult.stderr}`);
1115
1417
  process.exit(1);
@@ -1123,7 +1425,7 @@ ${pc8.dim("AI body preview:")}`);
1123
1425
  process.exit(1);
1124
1426
  }
1125
1427
  const prResult = await createPR({
1126
- base: devBranch,
1428
+ base: baseBranch,
1127
1429
  title: prTitle,
1128
1430
  body: prBody ?? "",
1129
1431
  draft: args.draft
@@ -1137,12 +1439,12 @@ ${pc8.dim("AI body preview:")}`);
1137
1439
  });
1138
1440
 
1139
1441
  // src/commands/sync.ts
1140
- import { defineCommand as defineCommand7 } from "citty";
1141
- import pc9 from "picocolors";
1142
- var sync_default = defineCommand7({
1442
+ import { defineCommand as defineCommand8 } from "citty";
1443
+ import pc10 from "picocolors";
1444
+ var sync_default = defineCommand8({
1143
1445
  meta: {
1144
1446
  name: "sync",
1145
- description: "Reset dev branch to match origin/main (maintainer) or upstream/dev (contributor)"
1447
+ description: "Sync your local branches with the remote"
1146
1448
  },
1147
1449
  args: {
1148
1450
  yes: {
@@ -1162,86 +1464,70 @@ var sync_default = defineCommand7({
1162
1464
  error("No .contributerc.json found. Run `contrib setup` first.");
1163
1465
  process.exit(1);
1164
1466
  }
1165
- const { role, mainBranch, devBranch, origin, upstream } = config;
1467
+ const { workflow, role, origin } = config;
1166
1468
  if (await hasUncommittedChanges()) {
1167
1469
  error("You have uncommitted changes. Please commit or stash them before syncing.");
1168
1470
  process.exit(1);
1169
1471
  }
1170
- heading(`\uD83D\uDD04 contrib sync (${role})`);
1171
- if (role === "maintainer") {
1172
- info(`Fetching ${origin}...`);
1173
- const fetchResult = await fetchRemote(origin);
1174
- if (fetchResult.exitCode !== 0) {
1175
- error(`Failed to fetch ${origin}: ${fetchResult.stderr}`);
1176
- process.exit(1);
1177
- }
1178
- const div = await getDivergence(devBranch, `${origin}/${mainBranch}`);
1179
- if (div.ahead > 0 || div.behind > 0) {
1180
- info(`${pc9.bold(devBranch)} is ${pc9.yellow(`${div.ahead} ahead`)} and ${pc9.red(`${div.behind} behind`)} ${origin}/${mainBranch}`);
1181
- } else {
1182
- info(`${pc9.bold(devBranch)} is already in sync with ${origin}/${mainBranch}`);
1183
- }
1184
- if (!args.yes) {
1185
- const ok = await confirmPrompt(`This will reset ${pc9.bold(devBranch)} to match ${pc9.bold(`${origin}/${mainBranch}`)}.`);
1186
- if (!ok)
1187
- process.exit(0);
1188
- }
1189
- const coResult = await checkoutBranch(devBranch);
1190
- if (coResult.exitCode !== 0) {
1191
- error(`Failed to checkout ${devBranch}: ${coResult.stderr}`);
1192
- process.exit(1);
1193
- }
1194
- const resetResult = await resetHard(`${origin}/${mainBranch}`);
1195
- if (resetResult.exitCode !== 0) {
1196
- error(`Failed to reset: ${resetResult.stderr}`);
1197
- process.exit(1);
1198
- }
1199
- const pushResult = await pushForceWithLease(origin, devBranch);
1200
- if (pushResult.exitCode !== 0) {
1201
- error(`Failed to push: ${pushResult.stderr}`);
1202
- process.exit(1);
1203
- }
1204
- success(`✅ ${devBranch} has been reset to match ${origin}/${mainBranch} and pushed.`);
1472
+ heading(`\uD83D\uDD04 contrib sync (${workflow}, ${role})`);
1473
+ const baseBranch = getBaseBranch(config);
1474
+ const syncSource = getSyncSource(config);
1475
+ info(`Fetching ${syncSource.remote}...`);
1476
+ const fetchResult = await fetchRemote(syncSource.remote);
1477
+ if (fetchResult.exitCode !== 0) {
1478
+ error(`Failed to fetch ${syncSource.remote}: ${fetchResult.stderr}`);
1479
+ process.exit(1);
1480
+ }
1481
+ if (role === "contributor" && syncSource.remote !== origin) {
1482
+ await fetchRemote(origin);
1483
+ }
1484
+ const div = await getDivergence(baseBranch, syncSource.ref);
1485
+ if (div.ahead > 0 || div.behind > 0) {
1486
+ info(`${pc10.bold(baseBranch)} is ${pc10.yellow(`${div.ahead} ahead`)} and ${pc10.red(`${div.behind} behind`)} ${syncSource.ref}`);
1205
1487
  } else {
1206
- info(`Fetching ${upstream}...`);
1207
- const fetchResult = await fetchRemote(upstream);
1208
- if (fetchResult.exitCode !== 0) {
1209
- error(`Failed to fetch ${upstream}: ${fetchResult.stderr}`);
1210
- process.exit(1);
1211
- }
1212
- if (!args.yes) {
1213
- const ok = await confirmPrompt(`This will reset local ${pc9.bold(devBranch)} to match ${pc9.bold(`${upstream}/${devBranch}`)}.`);
1214
- if (!ok)
1215
- process.exit(0);
1216
- }
1217
- const coResult = await checkoutBranch(devBranch);
1218
- if (coResult.exitCode !== 0) {
1219
- error(`Failed to checkout ${devBranch}: ${coResult.stderr}`);
1220
- process.exit(1);
1221
- }
1222
- const resetResult = await resetHard(`${upstream}/${devBranch}`);
1223
- if (resetResult.exitCode !== 0) {
1224
- error(`Failed to reset: ${resetResult.stderr}`);
1225
- process.exit(1);
1226
- }
1227
- const pushResult = await pushForceWithLease(origin, devBranch);
1228
- if (pushResult.exitCode !== 0) {
1229
- error(`Failed to push: ${pushResult.stderr}`);
1230
- process.exit(1);
1488
+ info(`${pc10.bold(baseBranch)} is already in sync with ${syncSource.ref}`);
1489
+ }
1490
+ if (!args.yes) {
1491
+ const ok = await confirmPrompt(`This will pull ${pc10.bold(syncSource.ref)} into local ${pc10.bold(baseBranch)}.`);
1492
+ if (!ok)
1493
+ process.exit(0);
1494
+ }
1495
+ const coResult = await checkoutBranch(baseBranch);
1496
+ if (coResult.exitCode !== 0) {
1497
+ error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
1498
+ process.exit(1);
1499
+ }
1500
+ const pullResult = await pullBranch(syncSource.remote, baseBranch);
1501
+ if (pullResult.exitCode !== 0) {
1502
+ error(`Failed to pull: ${pullResult.stderr}`);
1503
+ process.exit(1);
1504
+ }
1505
+ success(`✅ ${baseBranch} is now in sync with ${syncSource.ref}`);
1506
+ if (hasDevBranch(workflow) && role === "maintainer") {
1507
+ const mainDiv = await getDivergence(config.mainBranch, `${origin}/${config.mainBranch}`);
1508
+ if (mainDiv.behind > 0) {
1509
+ info(`Also syncing ${pc10.bold(config.mainBranch)}...`);
1510
+ const mainCoResult = await checkoutBranch(config.mainBranch);
1511
+ if (mainCoResult.exitCode === 0) {
1512
+ const mainPullResult = await pullBranch(origin, config.mainBranch);
1513
+ if (mainPullResult.exitCode === 0) {
1514
+ success(`✅ ${config.mainBranch} is now in sync with ${origin}/${config.mainBranch}`);
1515
+ }
1516
+ }
1517
+ await checkoutBranch(baseBranch);
1231
1518
  }
1232
- success(`✅ ${devBranch} has been reset to match ${upstream}/${devBranch} and pushed.`);
1233
1519
  }
1234
1520
  }
1235
1521
  });
1236
1522
 
1237
1523
  // src/commands/update.ts
1238
- import { readFileSync as readFileSync2 } from "node:fs";
1239
- import { defineCommand as defineCommand8 } from "citty";
1240
- import pc10 from "picocolors";
1241
- var update_default = defineCommand8({
1524
+ import { readFileSync as readFileSync3 } from "node:fs";
1525
+ import { defineCommand as defineCommand9 } from "citty";
1526
+ import pc11 from "picocolors";
1527
+ var update_default = defineCommand9({
1242
1528
  meta: {
1243
1529
  name: "update",
1244
- description: "Rebase current branch onto latest dev"
1530
+ description: "Rebase current branch onto the latest base branch"
1245
1531
  },
1246
1532
  args: {
1247
1533
  model: {
@@ -1264,14 +1550,16 @@ var update_default = defineCommand8({
1264
1550
  error("No .contributerc.json found. Run `contrib setup` first.");
1265
1551
  process.exit(1);
1266
1552
  }
1267
- const { mainBranch, devBranch, origin, upstream, role } = config;
1553
+ const baseBranch = getBaseBranch(config);
1554
+ const protectedBranches = getProtectedBranches(config);
1555
+ const syncSource = getSyncSource(config);
1268
1556
  const currentBranch = await getCurrentBranch();
1269
1557
  if (!currentBranch) {
1270
1558
  error("Could not determine current branch.");
1271
1559
  process.exit(1);
1272
1560
  }
1273
- if (currentBranch === mainBranch || currentBranch === devBranch) {
1274
- error(`Use \`contrib sync\` to update ${pc10.bold(mainBranch)} or ${pc10.bold(devBranch)} branches.`);
1561
+ if (protectedBranches.includes(currentBranch)) {
1562
+ error(`Use \`contrib sync\` to update ${protectedBranches.map((b) => pc11.bold(b)).join(" or ")} branches.`);
1275
1563
  process.exit(1);
1276
1564
  }
1277
1565
  if (await hasUncommittedChanges()) {
@@ -1279,12 +1567,10 @@ var update_default = defineCommand8({
1279
1567
  process.exit(1);
1280
1568
  }
1281
1569
  heading("\uD83D\uDD03 contrib update");
1282
- info(`Updating ${pc10.bold(currentBranch)} with latest ${pc10.bold(devBranch)}...`);
1283
- const remote = role === "contributor" ? upstream : origin;
1284
- const remoteDevRef = role === "contributor" ? `${upstream}/${devBranch}` : `${origin}/${devBranch}`;
1285
- await fetchRemote(remote);
1286
- await resetHard(remoteDevRef);
1287
- const rebaseResult = await rebase(devBranch);
1570
+ info(`Updating ${pc11.bold(currentBranch)} with latest ${pc11.bold(baseBranch)}...`);
1571
+ await fetchRemote(syncSource.remote);
1572
+ await updateLocalBranch(baseBranch, syncSource.ref);
1573
+ const rebaseResult = await rebase(baseBranch);
1288
1574
  if (rebaseResult.exitCode !== 0) {
1289
1575
  warn("Rebase hit conflicts. Resolve them manually.");
1290
1576
  console.log();
@@ -1296,7 +1582,7 @@ var update_default = defineCommand8({
1296
1582
  let conflictDiff = "";
1297
1583
  for (const file of conflictFiles.slice(0, 3)) {
1298
1584
  try {
1299
- const content = readFileSync2(file, "utf-8");
1585
+ const content = readFileSync3(file, "utf-8");
1300
1586
  if (content.includes("<<<<<<<")) {
1301
1587
  conflictDiff += `
1302
1588
  --- ${file} ---
@@ -1309,34 +1595,73 @@ ${content.slice(0, 2000)}
1309
1595
  const suggestion = await suggestConflictResolution(conflictDiff, args.model);
1310
1596
  if (suggestion) {
1311
1597
  console.log(`
1312
- ${pc10.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
1313
- console.log(pc10.dim("─".repeat(60)));
1598
+ ${pc11.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
1599
+ console.log(pc11.dim("─".repeat(60)));
1314
1600
  console.log(suggestion);
1315
- console.log(pc10.dim("─".repeat(60)));
1601
+ console.log(pc11.dim("─".repeat(60)));
1316
1602
  console.log();
1317
1603
  }
1318
1604
  }
1319
1605
  }
1320
1606
  }
1321
- console.log(pc10.bold("To resolve:"));
1607
+ console.log(pc11.bold("To resolve:"));
1322
1608
  console.log(` 1. Fix conflicts in the affected files`);
1323
- console.log(` 2. ${pc10.cyan("git add <resolved-files>")}`);
1324
- console.log(` 3. ${pc10.cyan("git rebase --continue")}`);
1609
+ console.log(` 2. ${pc11.cyan("git add <resolved-files>")}`);
1610
+ console.log(` 3. ${pc11.cyan("git rebase --continue")}`);
1325
1611
  console.log();
1326
- console.log(` Or abort: ${pc10.cyan("git rebase --abort")}`);
1612
+ console.log(` Or abort: ${pc11.cyan("git rebase --abort")}`);
1327
1613
  process.exit(1);
1328
1614
  }
1329
- success(`✅ ${pc10.bold(currentBranch)} has been rebased onto latest ${pc10.bold(devBranch)}`);
1615
+ success(`✅ ${pc11.bold(currentBranch)} has been rebased onto latest ${pc11.bold(baseBranch)}`);
1616
+ }
1617
+ });
1618
+
1619
+ // src/commands/validate.ts
1620
+ import { defineCommand as defineCommand10 } from "citty";
1621
+ import pc12 from "picocolors";
1622
+ var validate_default = defineCommand10({
1623
+ meta: {
1624
+ name: "validate",
1625
+ description: "Validate a commit message against the configured convention"
1626
+ },
1627
+ args: {
1628
+ message: {
1629
+ type: "positional",
1630
+ description: "The commit message to validate",
1631
+ required: true
1632
+ }
1633
+ },
1634
+ async run({ args }) {
1635
+ const config = readConfig();
1636
+ if (!config) {
1637
+ error("No .contributerc.json found. Run `contrib setup` first.");
1638
+ process.exit(1);
1639
+ }
1640
+ const convention = config.commitConvention;
1641
+ if (convention === "none") {
1642
+ info('Commit convention is set to "none". All messages are accepted.');
1643
+ process.exit(0);
1644
+ }
1645
+ const message = args.message;
1646
+ if (validateCommitMessage(message, convention)) {
1647
+ success(`Valid ${CONVENTION_LABELS[convention]} message.`);
1648
+ process.exit(0);
1649
+ }
1650
+ const errors = getValidationError(convention);
1651
+ for (const line of errors) {
1652
+ console.error(pc12.red(` ✗ ${line}`));
1653
+ }
1654
+ process.exit(1);
1330
1655
  }
1331
1656
  });
1332
1657
 
1333
1658
  // src/ui/banner.ts
1334
1659
  import figlet from "figlet";
1335
- import pc11 from "picocolors";
1660
+ import pc13 from "picocolors";
1336
1661
  // package.json
1337
1662
  var package_default = {
1338
1663
  name: "contribute-now",
1339
- version: "0.1.2",
1664
+ version: "0.2.0-dev.70284d0",
1340
1665
  description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
1341
1666
  type: "module",
1342
1667
  bin: {
@@ -1348,12 +1673,12 @@ var package_default = {
1348
1673
  ],
1349
1674
  scripts: {
1350
1675
  build: "bun build src/index.ts --outfile dist/index.js --target node --packages external",
1676
+ cli: "bun run src/index.ts --",
1351
1677
  dev: "bun src/index.ts",
1352
1678
  test: "bun test",
1353
1679
  lint: "biome check .",
1354
1680
  "lint:fix": "biome check --write .",
1355
1681
  format: "biome format --write .",
1356
- prepare: "husky || true",
1357
1682
  "www:dev": "bun run --cwd www dev",
1358
1683
  "www:build": "bun run --cwd www build",
1359
1684
  "www:preview": "bun run --cwd www preview"
@@ -1390,7 +1715,6 @@ var package_default = {
1390
1715
  "@biomejs/biome": "^2.4.4",
1391
1716
  "@types/bun": "latest",
1392
1717
  "@types/figlet": "^1.7.0",
1393
- husky: "^9.1.7",
1394
1718
  typescript: "^5.7.0"
1395
1719
  }
1396
1720
  };
@@ -1398,9 +1722,10 @@ var package_default = {
1398
1722
  // src/ui/banner.ts
1399
1723
  var LOGO;
1400
1724
  try {
1401
- LOGO = figlet.textSync("contrib", { font: "ANSI Shadow" });
1725
+ LOGO = figlet.textSync(`Contribute
1726
+ Now`, { font: "ANSI Shadow" });
1402
1727
  } catch {
1403
- LOGO = "contribute-now";
1728
+ LOGO = "Contribute Now";
1404
1729
  }
1405
1730
  function getVersion() {
1406
1731
  return package_default.version ?? "unknown";
@@ -1408,16 +1733,15 @@ function getVersion() {
1408
1733
  function getAuthor() {
1409
1734
  return typeof package_default.author === "string" ? package_default.author : "unknown";
1410
1735
  }
1411
- function showBanner(minimal = false) {
1412
- console.log(pc11.cyan(`
1736
+ function showBanner(showLinks = false) {
1737
+ console.log(pc13.cyan(`
1413
1738
  ${LOGO}`));
1414
- console.log(` ${pc11.dim(`v${getVersion()}`)} ${pc11.dim("—")} ${pc11.dim(`Built by ${getAuthor()}`)}`);
1415
- if (!minimal) {
1416
- console.log(` ${pc11.dim(package_default.description)}`);
1739
+ console.log(` ${pc13.dim(`v${getVersion()}`)} ${pc13.dim("—")} ${pc13.dim(`Built by ${getAuthor()}`)}`);
1740
+ if (showLinks) {
1417
1741
  console.log();
1418
- console.log(` ${pc11.yellow("Star")} ${pc11.cyan("https://github.com/warengonzaga/contribute-now")}`);
1419
- console.log(` ${pc11.green("Contribute")} ${pc11.cyan("https://github.com/warengonzaga/contribute-now/blob/main/CONTRIBUTING.md")}`);
1420
- console.log(` ${pc11.magenta("Sponsor")} ${pc11.cyan("https://warengonzaga.com/sponsor")}`);
1742
+ console.log(` ${pc13.yellow("Star")} ${pc13.cyan("https://github.com/warengonzaga/contribute-now")}`);
1743
+ console.log(` ${pc13.green("Contribute")} ${pc13.cyan("https://github.com/warengonzaga/contribute-now/blob/main/CONTRIBUTING.md")}`);
1744
+ console.log(` ${pc13.magenta("Sponsor")} ${pc13.cyan("https://warengonzaga.com/sponsor")}`);
1421
1745
  }
1422
1746
  console.log();
1423
1747
  }
@@ -1425,11 +1749,11 @@ ${LOGO}`));
1425
1749
  // src/index.ts
1426
1750
  var isHelp = process.argv.includes("--help") || process.argv.includes("-h");
1427
1751
  showBanner(isHelp);
1428
- var main = defineCommand9({
1752
+ var main = defineCommand11({
1429
1753
  meta: {
1430
1754
  name: "contrib",
1431
1755
  version: getVersion(),
1432
- description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges."
1756
+ description: "Git workflow CLI that guides contributors through clean branching, commits, and PRs."
1433
1757
  },
1434
1758
  args: {
1435
1759
  version: {
@@ -1446,7 +1770,9 @@ var main = defineCommand9({
1446
1770
  update: update_default,
1447
1771
  submit: submit_default,
1448
1772
  clean: clean_default,
1449
- status: status_default
1773
+ status: status_default,
1774
+ hook: hook_default,
1775
+ validate: validate_default
1450
1776
  },
1451
1777
  run({ args }) {
1452
1778
  if (args.version) {