contribute-now 0.4.0 → 0.4.1-dev.7cf05e9

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 +55 -2
  2. package/dist/index.js +279 -41
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -64,7 +64,7 @@ bun install -g contribute-now
64
64
  ## Prerequisites
65
65
 
66
66
  - **[Git](https://git-scm.com/)** — required
67
- - **[GitHub CLI](https://cli.github.com)** (`gh`) — optional; enables role auto-detection and PR creation
67
+ - **[GitHub CLI](https://cli.github.com)** (`gh`) — recommended; required for PR creation, role detection, and merge status checks
68
68
  - **[GitHub Copilot](https://github.com/features/copilot)** — optional; enables AI features
69
69
 
70
70
  ---
@@ -129,10 +129,13 @@ Stage your changes and create a validated, AI-generated commit message matching
129
129
  contrib commit # AI-generated message
130
130
  contrib commit --no-ai # manual entry, still validated
131
131
  contrib commit --model gpt-4.1 # specific AI model
132
+ contrib commit --group # AI groups changes into atomic commits
132
133
  ```
133
134
 
134
135
  After the AI generates a message, you can **accept**, **edit**, **regenerate**, or **write manually**. Messages are always validated against your convention — with a soft warning if they don't match (you can still commit).
135
136
 
137
+ **Group commit mode** (`--group`): AI analyzes all staged and unstaged changes, groups related files into logical atomic commits, and generates a commit message for each group. Great for splitting a large set of changes into clean, reviewable commits.
138
+
136
139
  ---
137
140
 
138
141
  ### `contrib update`
@@ -180,6 +183,55 @@ contrib status
180
183
 
181
184
  ---
182
185
 
186
+ ### `contrib doctor`
187
+
188
+ Diagnose the contribute-now CLI environment and configuration. Checks tools, dependencies, config, git state, fork setup, workflow, and environment.
189
+
190
+ ```bash
191
+ contrib doctor # pretty-printed report
192
+ contrib doctor --json # machine-readable JSON output
193
+ ```
194
+
195
+ Checks include:
196
+ - CLI version and runtime (Bun/Node)
197
+ - git and GitHub CLI availability and authentication
198
+ - `.contributerc.json` validity and `.gitignore` status
199
+ - Git repo state (uncommitted changes, lock files, shallow clone)
200
+ - Fork and remote configuration
201
+ - Workflow and branch setup
202
+
203
+ ---
204
+
205
+ ### `contrib log`
206
+
207
+ Show a colorized, workflow-aware commit log with graph visualization.
208
+
209
+ ```bash
210
+ contrib log # last 20 commits with graph
211
+ contrib log -n 50 # last 50 commits
212
+ contrib log --all # all branches
213
+ contrib log --no-graph # flat view without graph lines
214
+ contrib log -b feature/x # log for a specific branch
215
+ ```
216
+
217
+ Protected branches (main, dev) are highlighted, and the current branch is color-coded for quick orientation.
218
+
219
+ ---
220
+
221
+ ### `contrib branch`
222
+
223
+ List branches with workflow-aware labels and tracking status.
224
+
225
+ ```bash
226
+ contrib branch # local branches
227
+ contrib branch --all # local + remote branches
228
+ contrib branch --remote # remote branches only
229
+ ```
230
+
231
+ Branches are annotated with workflow labels (e.g., base, dev, feature) and tracking info (upstream, gone, no remote).
232
+
233
+ ---
234
+
183
235
  ### `contrib hook`
184
236
 
185
237
  Install or uninstall a `commit-msg` git hook that validates every commit against your configured convention — no Husky or lint-staged needed.
@@ -210,8 +262,9 @@ contrib validate "added stuff" # exit 1
210
262
  All AI features are powered by **GitHub Copilot** via `@github/copilot-sdk` and are entirely **optional** — every command has a manual fallback.
211
263
 
212
264
  | Command | AI Feature | Fallback |
213
- |---------|------------|---------|
265
+ |---------|------------|----------|
214
266
  | `commit` | Generate commit message from staged diff | Type manually |
267
+ | `commit --group` | Group related changes into atomic commits | Manual staging + commit |
215
268
  | `start` | Suggest branch name from natural language | Prefix picker + manual |
216
269
  | `update` | Conflict resolution guidance | Standard git instructions |
217
270
  | `submit` | Generate PR title and body | `gh pr create --fill` or manual |
package/dist/index.js CHANGED
@@ -10,7 +10,7 @@ import { defineCommand } from "citty";
10
10
  import pc2 from "picocolors";
11
11
 
12
12
  // src/utils/config.ts
13
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
13
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from "node:fs";
14
14
  import { join } from "node:path";
15
15
  var CONFIG_FILENAME = ".contributerc.json";
16
16
  function getConfigPath(cwd = process.cwd()) {
@@ -86,6 +86,23 @@ function isGitignored(cwd = process.cwd()) {
86
86
  return false;
87
87
  }
88
88
  }
89
+ function ensureGitignored(cwd = process.cwd()) {
90
+ if (isGitignored(cwd))
91
+ return false;
92
+ const gitignorePath = join(cwd, ".gitignore");
93
+ const line = `${CONFIG_FILENAME}
94
+ `;
95
+ if (!existsSync(gitignorePath)) {
96
+ writeFileSync(gitignorePath, line, "utf-8");
97
+ return true;
98
+ }
99
+ const content = readFileSync(gitignorePath, "utf-8");
100
+ const needsLeadingNewline = content.length > 0 && !content.endsWith(`
101
+ `);
102
+ appendFileSync(gitignorePath, `${needsLeadingNewline ? `
103
+ ` : ""}${line}`, "utf-8");
104
+ return true;
105
+ }
89
106
  function getDefaultConfig() {
90
107
  return {
91
108
  workflow: "clean-flow",
@@ -953,6 +970,79 @@ function withTimeout(promise, ms) {
953
970
  }
954
971
  var COPILOT_TIMEOUT_MS = 30000;
955
972
  var COPILOT_LONG_TIMEOUT_MS = 90000;
973
+ var BATCH_CONFIG = {
974
+ LARGE_CHANGESET_THRESHOLD: 15,
975
+ COMPACT_PER_FILE_CHARS: 300,
976
+ MAX_COMPACT_PAYLOAD: 1e4,
977
+ FALLBACK_BATCH_SIZE: 15
978
+ };
979
+ function parseDiffByFile(rawDiff) {
980
+ const sections = new Map;
981
+ const headerPattern = /^diff --git a\/(.+?) b\/(.+?)$/gm;
982
+ const positions = [];
983
+ for (let match = headerPattern.exec(rawDiff);match !== null; match = headerPattern.exec(rawDiff)) {
984
+ const aFile = match[1];
985
+ const bFile = match[2] ?? aFile;
986
+ positions.push({ aFile, bFile, start: match.index });
987
+ }
988
+ for (let i = 0;i < positions.length; i++) {
989
+ const { aFile, bFile, start } = positions[i];
990
+ const end = i + 1 < positions.length ? positions[i + 1].start : rawDiff.length;
991
+ const section = rawDiff.slice(start, end);
992
+ sections.set(aFile, section);
993
+ if (bFile && bFile !== aFile) {
994
+ sections.set(bFile, section);
995
+ }
996
+ }
997
+ return sections;
998
+ }
999
+ function extractDiffStats(diffSection) {
1000
+ let added = 0;
1001
+ let removed = 0;
1002
+ for (const line of diffSection.split(`
1003
+ `)) {
1004
+ if (line.startsWith("+") && !line.startsWith("+++"))
1005
+ added++;
1006
+ if (line.startsWith("-") && !line.startsWith("---"))
1007
+ removed++;
1008
+ }
1009
+ return { added, removed };
1010
+ }
1011
+ function createCompactDiff(files, rawDiff, maxTotalChars = BATCH_CONFIG.MAX_COMPACT_PAYLOAD) {
1012
+ if (files.length === 0)
1013
+ return "";
1014
+ const diffSections = parseDiffByFile(rawDiff);
1015
+ const perFileBudget = Math.min(BATCH_CONFIG.COMPACT_PER_FILE_CHARS, Math.floor(maxTotalChars / files.length));
1016
+ const parts = [];
1017
+ for (const file of files) {
1018
+ const section = diffSections.get(file);
1019
+ if (section) {
1020
+ const stats = extractDiffStats(section);
1021
+ const header = `[${file}] (+${stats.added}/-${stats.removed})`;
1022
+ if (section.length <= perFileBudget) {
1023
+ parts.push(`${header}
1024
+ ${section}`);
1025
+ } else {
1026
+ const availableForBody = perFileBudget - header.length - 20;
1027
+ if (availableForBody <= 0) {
1028
+ parts.push(header);
1029
+ } else {
1030
+ const truncated = section.slice(0, availableForBody);
1031
+ parts.push(`${header}
1032
+ ${truncated}
1033
+ ...(truncated)`);
1034
+ }
1035
+ }
1036
+ } else {
1037
+ parts.push(`[${file}] (new/binary file — no diff available)`);
1038
+ }
1039
+ }
1040
+ const result = parts.join(`
1041
+
1042
+ `);
1043
+ return result.length > maxTotalChars ? `${result.slice(0, maxTotalChars - 15)}
1044
+ ...(truncated)` : result;
1045
+ }
956
1046
  async function checkCopilotAvailable() {
957
1047
  try {
958
1048
  const client = await getManagedClient();
@@ -1053,16 +1143,18 @@ function extractJson(raw) {
1053
1143
  }
1054
1144
  async function generateCommitMessage(diff, stagedFiles, model, convention = "clean-commit") {
1055
1145
  try {
1146
+ const isLarge = stagedFiles.length >= BATCH_CONFIG.LARGE_CHANGESET_THRESHOLD;
1056
1147
  const multiFileHint = stagedFiles.length > 1 ? `
1057
1148
 
1058
1149
  IMPORTANT: Multiple files are staged. Generate ONE commit message that captures the high-level purpose of ALL changes together. Focus on the overall intent, not individual file changes. Be specific but concise — do not list every file.` : "";
1150
+ const diffContent = isLarge ? createCompactDiff(stagedFiles, diff) : diff.slice(0, 4000);
1059
1151
  const userMessage = `Generate a commit message for these staged changes:
1060
1152
 
1061
- Files: ${stagedFiles.join(", ")}
1153
+ Files (${stagedFiles.length}): ${stagedFiles.join(", ")}
1062
1154
 
1063
1155
  Diff:
1064
- ${diff.slice(0, 4000)}${multiFileHint}`;
1065
- const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
1156
+ ${diffContent}${multiFileHint}`;
1157
+ const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model, isLarge ? COPILOT_LONG_TIMEOUT_MS : COPILOT_TIMEOUT_MS);
1066
1158
  return result?.trim() ?? null;
1067
1159
  } catch {
1068
1160
  return null;
@@ -1111,16 +1203,23 @@ ${conflictDiff.slice(0, 4000)}`;
1111
1203
  }
1112
1204
  }
1113
1205
  async function generateCommitGroups(files, diffs, model, convention = "clean-commit") {
1206
+ const isLarge = files.length >= BATCH_CONFIG.LARGE_CHANGESET_THRESHOLD;
1207
+ const diffContent = isLarge ? createCompactDiff(files, diffs) : diffs.slice(0, 6000);
1208
+ const largeHint = isLarge ? `
1209
+
1210
+ NOTE: This is a large changeset (${files.length} files). Compact diffs are provided for every file. Focus on creating well-organized logical groups.` : "";
1114
1211
  const userMessage = `Group these changed files into logical atomic commits:
1115
1212
 
1116
1213
  Files:
1117
1214
  ${files.join(`
1118
1215
  `)}
1119
1216
 
1120
- Diffs (truncated):
1121
- ${diffs.slice(0, 6000)}`;
1217
+ Diffs:
1218
+ ${diffContent}${largeHint}`;
1122
1219
  const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
1123
1220
  if (!result) {
1221
+ if (isLarge)
1222
+ return generateCommitGroupsInBatches(files, diffs, model, convention);
1124
1223
  throw new Error("AI returned an empty response");
1125
1224
  }
1126
1225
  const cleaned = extractJson(result);
@@ -1128,10 +1227,14 @@ ${diffs.slice(0, 6000)}`;
1128
1227
  try {
1129
1228
  parsed = JSON.parse(cleaned);
1130
1229
  } catch {
1230
+ if (isLarge)
1231
+ return generateCommitGroupsInBatches(files, diffs, model, convention);
1131
1232
  throw new Error(`AI response is not valid JSON. Raw start: "${result.slice(0, 120)}..."`);
1132
1233
  }
1133
1234
  const groups = parsed;
1134
1235
  if (!Array.isArray(groups) || groups.length === 0) {
1236
+ if (isLarge)
1237
+ return generateCommitGroupsInBatches(files, diffs, model, convention);
1135
1238
  throw new Error("AI response was not a valid JSON array of commit groups");
1136
1239
  }
1137
1240
  for (const group of groups) {
@@ -1141,7 +1244,63 @@ ${diffs.slice(0, 6000)}`;
1141
1244
  }
1142
1245
  return groups;
1143
1246
  }
1247
+ async function generateCommitGroupsInBatches(files, diffs, model, convention = "clean-commit") {
1248
+ const batchSize = BATCH_CONFIG.FALLBACK_BATCH_SIZE;
1249
+ const allGroups = [];
1250
+ const diffSections = parseDiffByFile(diffs);
1251
+ for (let i = 0;i < files.length; i += batchSize) {
1252
+ const batchFiles = files.slice(i, i + batchSize);
1253
+ const batchDiff = batchFiles.map((f) => diffSections.get(f) ?? "").filter(Boolean).join(`
1254
+ `);
1255
+ const batchDiffContent = batchFiles.length >= BATCH_CONFIG.LARGE_CHANGESET_THRESHOLD ? createCompactDiff(batchFiles, batchDiff) : batchDiff.slice(0, 6000);
1256
+ const batchNum = Math.floor(i / batchSize) + 1;
1257
+ const totalBatches = Math.ceil(files.length / batchSize);
1258
+ const userMessage = `Group these changed files into logical atomic commits:
1259
+
1260
+ Files:
1261
+ ${batchFiles.join(`
1262
+ `)}
1263
+
1264
+ Diffs:
1265
+ ${batchDiffContent}
1266
+
1267
+ NOTE: Processing batch ${batchNum}/${totalBatches} of a large changeset. Group only the files listed above.`;
1268
+ try {
1269
+ const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
1270
+ if (!result)
1271
+ continue;
1272
+ const cleaned = extractJson(result);
1273
+ const parsed = JSON.parse(cleaned);
1274
+ if (Array.isArray(parsed)) {
1275
+ for (const group of parsed) {
1276
+ if (Array.isArray(group.files) && typeof group.message === "string") {
1277
+ const batchFileSet = new Set(batchFiles);
1278
+ const filteredFiles = group.files.filter((f) => batchFileSet.has(f));
1279
+ if (filteredFiles.length > 0) {
1280
+ allGroups.push({ ...group, files: filteredFiles });
1281
+ }
1282
+ }
1283
+ }
1284
+ }
1285
+ } catch {}
1286
+ }
1287
+ const groupedFiles = new Set(allGroups.flatMap((g) => g.files));
1288
+ const ungrouped = files.filter((f) => !groupedFiles.has(f));
1289
+ if (ungrouped.length > 0) {
1290
+ allGroups.push({
1291
+ files: ungrouped,
1292
+ message: `chore: update ${ungrouped.length} remaining file${ungrouped.length !== 1 ? "s" : ""}`
1293
+ });
1294
+ }
1295
+ if (allGroups.length === 0) {
1296
+ throw new Error("AI could not group any files even with batch processing");
1297
+ }
1298
+ return allGroups;
1299
+ }
1144
1300
  async function regenerateAllGroupMessages(groups, diffs, model, convention = "clean-commit") {
1301
+ const totalFiles = groups.reduce((sum, g) => sum + g.files.length, 0);
1302
+ const isLarge = totalFiles >= BATCH_CONFIG.LARGE_CHANGESET_THRESHOLD;
1303
+ const diffContent = isLarge ? createCompactDiff(groups.flatMap((g) => g.files), diffs) : diffs.slice(0, 6000);
1145
1304
  const groupSummary = groups.map((g, i) => `Group ${i + 1}: [${g.files.join(", ")}]`).join(`
1146
1305
  `);
1147
1306
  const userMessage = `Regenerate ONLY the commit messages for these pre-defined file groups. Do NOT change the file groupings.
@@ -1149,8 +1308,8 @@ async function regenerateAllGroupMessages(groups, diffs, model, convention = "cl
1149
1308
  Groups:
1150
1309
  ${groupSummary}
1151
1310
 
1152
- Diffs (truncated):
1153
- ${diffs.slice(0, 6000)}`;
1311
+ Diffs:
1312
+ ${diffContent}`;
1154
1313
  const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
1155
1314
  if (!result)
1156
1315
  return groups;
@@ -1169,12 +1328,14 @@ ${diffs.slice(0, 6000)}`;
1169
1328
  }
1170
1329
  async function regenerateGroupMessage(files, diffs, model, convention = "clean-commit") {
1171
1330
  try {
1331
+ const isLarge = files.length >= BATCH_CONFIG.LARGE_CHANGESET_THRESHOLD;
1332
+ const diffContent = isLarge ? createCompactDiff(files, diffs) : diffs.slice(0, 4000);
1172
1333
  const userMessage = `Generate a single commit message for these files:
1173
1334
 
1174
1335
  Files: ${files.join(", ")}
1175
1336
 
1176
1337
  Diff:
1177
- ${diffs.slice(0, 4000)}`;
1338
+ ${diffContent}`;
1178
1339
  const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
1179
1340
  return result?.trim() ?? null;
1180
1341
  } catch {
@@ -1732,7 +1893,8 @@ ${pc6.bold("Changed files:")}`);
1732
1893
  warn(`AI unavailable: ${copilotError}`);
1733
1894
  warn("Falling back to manual commit message entry.");
1734
1895
  } else {
1735
- const spinner = createSpinner("Generating commit message with AI...");
1896
+ const spinnerMsg = stagedFiles.length >= BATCH_CONFIG.LARGE_CHANGESET_THRESHOLD ? `Generating commit message with AI (${stagedFiles.length} files — using optimized batching)...` : "Generating commit message with AI...";
1897
+ const spinner = createSpinner(spinnerMsg);
1736
1898
  commitMessage = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
1737
1899
  if (commitMessage) {
1738
1900
  spinner.success("AI commit message generated.");
@@ -1823,7 +1985,7 @@ ${pc6.bold("Changed files:")}`);
1823
1985
  for (const f of changedFiles) {
1824
1986
  console.log(` ${pc6.dim("•")} ${f}`);
1825
1987
  }
1826
- const spinner = createSpinner(`Asking AI to group ${changedFiles.length} file(s) into logical commits...`);
1988
+ const spinner = createSpinner(changedFiles.length >= BATCH_CONFIG.LARGE_CHANGESET_THRESHOLD ? `Asking AI to group ${changedFiles.length} file(s) into logical commits (using optimized batching)...` : `Asking AI to group ${changedFiles.length} file(s) into logical commits...`);
1827
1989
  const diffs = await getFullDiffForFiles(changedFiles);
1828
1990
  if (!diffs.trim()) {
1829
1991
  spinner.stop();
@@ -2001,8 +2163,8 @@ import pc7 from "picocolors";
2001
2163
  // package.json
2002
2164
  var package_default = {
2003
2165
  name: "contribute-now",
2004
- version: "0.4.0",
2005
- description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
2166
+ version: "0.4.1-dev.7cf05e9",
2167
+ description: "Developer CLI that automates git workflows branching, syncing, committing, and PRs — with multi-workflow and commit convention support.",
2006
2168
  type: "module",
2007
2169
  bin: {
2008
2170
  contrib: "dist/index.js",
@@ -2660,6 +2822,43 @@ function colorizeSubject(subject) {
2660
2822
  // src/commands/setup.ts
2661
2823
  import { defineCommand as defineCommand7 } from "citty";
2662
2824
  import pc10 from "picocolors";
2825
+ async function shouldContinueSetupWithExistingConfig(options) {
2826
+ const {
2827
+ existingConfig,
2828
+ hasConfigFile,
2829
+ confirm: confirm2,
2830
+ ensureIgnored,
2831
+ onInfo,
2832
+ onWarn,
2833
+ onSuccess,
2834
+ summary
2835
+ } = options;
2836
+ if (existingConfig) {
2837
+ onInfo("Existing .contributerc.json detected:");
2838
+ summary(existingConfig);
2839
+ const shouldContinue = await confirm2("Continue setup and overwrite existing config?");
2840
+ if (!shouldContinue) {
2841
+ if (ensureIgnored()) {
2842
+ onInfo("Added .contributerc.json to .gitignore to avoid committing personal config.");
2843
+ }
2844
+ onSuccess("Keeping existing setup.");
2845
+ return false;
2846
+ }
2847
+ return true;
2848
+ }
2849
+ if (hasConfigFile) {
2850
+ onWarn("Found .contributerc.json but it appears invalid.");
2851
+ const shouldContinue = await confirm2("Continue setup and overwrite invalid config?");
2852
+ if (!shouldContinue) {
2853
+ if (ensureIgnored()) {
2854
+ onInfo("Added .contributerc.json to .gitignore to avoid committing personal config.");
2855
+ }
2856
+ onInfo("Keeping existing file. Run setup again when ready to repair it.");
2857
+ return false;
2858
+ }
2859
+ }
2860
+ return true;
2861
+ }
2663
2862
  var setup_default = defineCommand7({
2664
2863
  meta: {
2665
2864
  name: "setup",
@@ -2671,6 +2870,20 @@ var setup_default = defineCommand7({
2671
2870
  process.exit(1);
2672
2871
  }
2673
2872
  heading("\uD83D\uDD27 contribute-now setup");
2873
+ const existingConfig = readConfig();
2874
+ const shouldContinue = await shouldContinueSetupWithExistingConfig({
2875
+ existingConfig,
2876
+ hasConfigFile: configExists(),
2877
+ confirm: confirmPrompt,
2878
+ ensureIgnored: ensureGitignored,
2879
+ onInfo: info,
2880
+ onWarn: warn,
2881
+ onSuccess: success,
2882
+ summary: logConfigSummary
2883
+ });
2884
+ if (!shouldContinue) {
2885
+ return;
2886
+ }
2674
2887
  const workflowChoice = await selectPrompt("Which git workflow does this project use?", [
2675
2888
  "Clean Flow — main + dev, squash features into dev, merge dev into main (recommended)",
2676
2889
  "GitHub Flow — main + feature branches, squash/merge into main",
@@ -2700,31 +2913,42 @@ var setup_default = defineCommand7({
2700
2913
  info(`Found remotes: ${remotes.join(", ")}`);
2701
2914
  let detectedRole = null;
2702
2915
  let detectionSource = "";
2703
- const ghInstalled = await checkGhInstalled();
2704
- if (ghInstalled && await checkGhAuth()) {
2705
- const isFork = await isRepoFork();
2706
- if (isFork === true) {
2707
- detectedRole = "contributor";
2708
- detectionSource = "gh CLI (fork detected)";
2709
- } else if (isFork === false) {
2710
- const repoInfo = await getCurrentRepoInfo();
2711
- if (repoInfo) {
2712
- const perms = await checkRepoPermissions(repoInfo.owner, repoInfo.repo);
2713
- if (perms?.admin || perms?.push) {
2714
- detectedRole = "maintainer";
2715
- detectionSource = "gh CLI (admin/push permissions)";
2916
+ const roleSpinner = createSpinner("Detecting your role...");
2917
+ try {
2918
+ roleSpinner.update("Checking GitHub CLI and auth...");
2919
+ const ghInstalled = await checkGhInstalled();
2920
+ if (ghInstalled && await checkGhAuth()) {
2921
+ roleSpinner.update("Inspecting repository relationship (fork/permissions)...");
2922
+ const isFork = await isRepoFork();
2923
+ if (isFork === true) {
2924
+ detectedRole = "contributor";
2925
+ detectionSource = "gh CLI (fork detected)";
2926
+ } else if (isFork === false) {
2927
+ const repoInfo = await getCurrentRepoInfo();
2928
+ if (repoInfo) {
2929
+ const perms = await checkRepoPermissions(repoInfo.owner, repoInfo.repo);
2930
+ if (perms?.admin || perms?.push) {
2931
+ detectedRole = "maintainer";
2932
+ detectionSource = "gh CLI (admin/push permissions)";
2933
+ }
2716
2934
  }
2717
2935
  }
2718
2936
  }
2719
- }
2720
- if (detectedRole === null) {
2721
- if (remotes.includes("upstream")) {
2722
- detectedRole = "contributor";
2723
- detectionSource = "heuristic (upstream remote exists)";
2724
- } else if (remotes.includes("origin") && remotes.length === 1) {
2725
- detectedRole = "maintainer";
2726
- detectionSource = "heuristic (only origin remote)";
2937
+ if (detectedRole === null) {
2938
+ roleSpinner.update("Analyzing git remotes...");
2939
+ if (remotes.includes("upstream")) {
2940
+ detectedRole = "contributor";
2941
+ detectionSource = "heuristic (upstream remote exists)";
2942
+ } else if (remotes.includes("origin") && remotes.length === 1) {
2943
+ detectedRole = "maintainer";
2944
+ detectionSource = "heuristic (only origin remote)";
2945
+ }
2727
2946
  }
2947
+ roleSpinner.success("Role detection complete.");
2948
+ } catch {
2949
+ roleSpinner.fail("Role detection failed; falling back to manual selection.");
2950
+ detectedRole = null;
2951
+ detectionSource = "";
2728
2952
  }
2729
2953
  if (detectedRole === null) {
2730
2954
  const roleChoice = await selectPrompt("What is your role in this project?", [
@@ -2742,16 +2966,20 @@ var setup_default = defineCommand7({
2742
2966
  }
2743
2967
  }
2744
2968
  const defaultConfig = getDefaultConfig();
2745
- const mainBranch = await inputPrompt("Main branch name", defaultConfig.mainBranch);
2969
+ info(pc10.dim("Tip: press Enter to keep the default branch name shown in each prompt."));
2970
+ const mainBranchDefault = defaultConfig.mainBranch;
2971
+ const mainBranch = await inputPrompt(`Main branch name (default: ${mainBranchDefault} — press Enter to keep)`, mainBranchDefault);
2746
2972
  let devBranch;
2747
2973
  if (hasDevBranch(workflow)) {
2748
2974
  const defaultDev = workflow === "git-flow" ? "develop" : "dev";
2749
- devBranch = await inputPrompt("Dev/develop branch name", defaultDev);
2975
+ devBranch = await inputPrompt(`Dev/develop branch name (default: ${defaultDev} — press Enter to keep)`, defaultDev);
2750
2976
  }
2751
- const originRemote = await inputPrompt("Origin remote name", defaultConfig.origin);
2977
+ const originRemoteDefault = defaultConfig.origin;
2978
+ const originRemote = await inputPrompt(`Origin remote name (default: ${originRemoteDefault} — press Enter to keep)`, originRemoteDefault);
2752
2979
  let upstreamRemote = defaultConfig.upstream;
2753
2980
  if (detectedRole === "contributor") {
2754
- upstreamRemote = await inputPrompt("Upstream remote name", defaultConfig.upstream);
2981
+ const upstreamRemoteDefault = defaultConfig.upstream;
2982
+ upstreamRemote = await inputPrompt(`Upstream remote name (default: ${upstreamRemoteDefault} — press Enter to keep)`, upstreamRemoteDefault);
2755
2983
  if (!remotes.includes(upstreamRemote)) {
2756
2984
  warn(`Remote "${upstreamRemote}" not found.`);
2757
2985
  const originUrl = await getRemoteUrl(originRemote);
@@ -2799,9 +3027,8 @@ var setup_default = defineCommand7({
2799
3027
  warn("Config was saved — verify the branch name and re-run setup if needed.");
2800
3028
  }
2801
3029
  }
2802
- if (!isGitignored()) {
2803
- warn(".contributerc.json is not in .gitignore. Add it to avoid committing personal config.");
2804
- warn(' echo ".contributerc.json" >> .gitignore');
3030
+ if (ensureGitignored()) {
3031
+ info("Added .contributerc.json to .gitignore to avoid committing personal config.");
2805
3032
  }
2806
3033
  console.log();
2807
3034
  info(`Workflow: ${pc10.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
@@ -2815,6 +3042,17 @@ var setup_default = defineCommand7({
2815
3042
  info(`Origin: ${pc10.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc10.bold(config.upstream)}` : ""}`);
2816
3043
  }
2817
3044
  });
3045
+ function logConfigSummary(config) {
3046
+ info(`Workflow: ${pc10.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
3047
+ info(`Convention: ${pc10.bold(CONVENTION_DESCRIPTIONS[config.commitConvention])}`);
3048
+ info(`Role: ${pc10.bold(config.role)}`);
3049
+ if (config.devBranch) {
3050
+ info(`Main: ${pc10.bold(config.mainBranch)} | Dev: ${pc10.bold(config.devBranch)}`);
3051
+ } else {
3052
+ info(`Main: ${pc10.bold(config.mainBranch)}`);
3053
+ }
3054
+ info(`Origin: ${pc10.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc10.bold(config.upstream)}` : ""}`);
3055
+ }
2818
3056
 
2819
3057
  // src/commands/start.ts
2820
3058
  import { defineCommand as defineCommand8 } from "citty";
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "contribute-now",
3
- "version": "0.4.0",
4
- "description": "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
3
+ "version": "0.4.1-dev.7cf05e9",
4
+ "description": "Developer CLI that automates git workflows branching, syncing, committing, and PRs — with multi-workflow and commit convention support.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "contrib": "dist/index.js",