contribute-now 0.4.0 → 0.4.1-dev.23d6614

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 +88 -8
  2. package/dist/index.js +633 -77
  3. package/package.json +6 -5
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,57 @@ 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. By default it shows only **local unpushed commits** — the changes you've made since the last push (or since branching off the base branch). Use flags to switch between different views.
208
+
209
+ ```bash
210
+ contrib log # local unpushed commits (default)
211
+ contrib log --remote # commits on remote not yet pulled
212
+ contrib log --full # full history for the current branch
213
+ contrib log --all # commits across all branches
214
+ contrib log -n 50 # change the commit limit (default: 20)
215
+ contrib log -b feature/x # log for a specific branch
216
+ contrib log --no-graph # flat view without graph lines
217
+ ```
218
+
219
+ When no upstream tracking is set (branch hasn't been pushed yet), the command automatically compares against the base branch from your config (e.g., `origin/dev`). Protected branches are highlighted, and the current branch is color-coded for quick orientation.
220
+
221
+ ---
222
+
223
+ ### `contrib branch`
224
+
225
+ List branches with workflow-aware labels and tracking status.
226
+
227
+ ```bash
228
+ contrib branch # local branches
229
+ contrib branch --all # local + remote branches
230
+ contrib branch --remote # remote branches only
231
+ ```
232
+
233
+ Branches are annotated with workflow labels (e.g., base, dev, feature) and tracking info (upstream, gone, no remote).
234
+
235
+ ---
236
+
183
237
  ### `contrib hook`
184
238
 
185
239
  Install or uninstall a `commit-msg` git hook that validates every commit against your configured convention — no Husky or lint-staged needed.
@@ -210,8 +264,9 @@ contrib validate "added stuff" # exit 1
210
264
  All AI features are powered by **GitHub Copilot** via `@github/copilot-sdk` and are entirely **optional** — every command has a manual fallback.
211
265
 
212
266
  | Command | AI Feature | Fallback |
213
- |---------|------------|---------|
267
+ |---------|------------|----------|
214
268
  | `commit` | Generate commit message from staged diff | Type manually |
269
+ | `commit --group` | Group related changes into atomic commits | Manual staging + commit |
215
270
  | `start` | Suggest branch name from natural language | Prefix picker + manual |
216
271
  | `update` | Conflict resolution guidance | Standard git instructions |
217
272
  | `submit` | Generate PR title and body | `gh pr create --fill` or manual |
@@ -299,15 +354,40 @@ bun test # run tests
299
354
  bun run lint # check code quality
300
355
  ```
301
356
 
302
- ## Contributing
357
+ ## 🎯 Contributing
303
358
 
304
- Contributions are welcome! See [CONTRIBUTING.md](CONTRIBUTING.md) for the workflow, commit convention, and PR guidelines.
359
+ Contributions are welcome, create a pull request to this repo and I will review your code. Please consider to submit your pull request to the `dev` branch. Thank you!
305
360
 
306
- ## License
361
+ Read the project's [contributing guide](./CONTRIBUTING.md) for more info.
307
362
 
308
- [GPL-3.0](LICENSE) © [Waren Gonzaga](https://warengonzaga.com)
363
+ ## 🐛 Issues
309
364
 
310
- ---
365
+ Please report any issues and bugs by [creating a new issue here](https://github.com/warengonzaga/contribute-now/issues/new/choose), also make sure you're reporting an issue that doesn't exist. Any help to improve the project would be appreciated. Thanks! 🙏✨
366
+
367
+ ## 🙏 Sponsor
368
+
369
+ Like this project? **Leave a star**! ⭐⭐⭐⭐⭐
370
+
371
+ Want to support my work and get some perks? [Become a sponsor](https://github.com/sponsors/warengonzaga)! 💖
372
+
373
+ Or, you just love what I do? [Buy me a coffee](https://buymeacoffee.com/warengonzaga)! ☕
311
374
 
312
- 💻💖☕ Made with ❤️ by [Waren Gonzaga](https://github.com/warengonzaga)
375
+ Recognized my open-source contributions? [Nominate me](https://stars.github.com/nominate) as GitHub Star! 💫
376
+
377
+ ## 📋 Code of Conduct
378
+
379
+ Read the project's [code of conduct](./CODE_OF_CONDUCT.md).
380
+
381
+ ## 📃 License
382
+
383
+ This project is licensed under [GNU General Public License v3.0](https://opensource.org/licenses/GPL-3.0).
384
+
385
+ ## 📝 Author
386
+
387
+ This project is created by **[Waren Gonzaga](https://github.com/warengonzaga)**, with the help of awesome [contributors](https://github.com/warengonzaga/contribute-now/graphs/contributors).
388
+
389
+ [![contributors](https://contrib.rocks/image?repo=warengonzaga/contribute-now)](https://github.com/warengonzaga/contribute-now/graphs/contributors)
390
+
391
+ ---
313
392
 
393
+ 💻💖☕ by [Waren Gonzaga](https://warengonzaga.com) & [YHWH](https://www.youtube.com/watch?v=VOZbswniA-g) 🙏 — Without *Him*, none of this exists, *even me*.
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",
@@ -530,6 +547,76 @@ async function getLogEntries(options) {
530
547
  return { hash: hash.trim(), subject: subject.trim(), refs: refs.trim() };
531
548
  });
532
549
  }
550
+ async function getLocalCommitsGraph(options) {
551
+ const count = options?.count ?? 20;
552
+ const upstream = options?.upstream;
553
+ if (!upstream)
554
+ return [];
555
+ const args = [
556
+ "log",
557
+ "--oneline",
558
+ "--graph",
559
+ "--decorate",
560
+ `--max-count=${count}`,
561
+ "--color=never",
562
+ `${upstream}..HEAD`
563
+ ];
564
+ const { exitCode, stdout } = await run(args);
565
+ if (exitCode !== 0)
566
+ return [];
567
+ return stdout.trimEnd().split(`
568
+ `).filter(Boolean);
569
+ }
570
+ async function getLocalCommitsEntries(options) {
571
+ const count = options?.count ?? 20;
572
+ const upstream = options?.upstream;
573
+ if (!upstream)
574
+ return [];
575
+ const args = ["log", `--format=%h||%s||%D`, `--max-count=${count}`, `${upstream}..HEAD`];
576
+ const { exitCode, stdout } = await run(args);
577
+ if (exitCode !== 0)
578
+ return [];
579
+ return stdout.trimEnd().split(`
580
+ `).filter(Boolean).map((line) => {
581
+ const [hash = "", subject = "", refs = ""] = line.split("||");
582
+ return { hash: hash.trim(), subject: subject.trim(), refs: refs.trim() };
583
+ });
584
+ }
585
+ async function getRemoteOnlyCommitsGraph(options) {
586
+ const count = options?.count ?? 20;
587
+ const upstream = options?.upstream;
588
+ if (!upstream)
589
+ return [];
590
+ const args = [
591
+ "log",
592
+ "--oneline",
593
+ "--graph",
594
+ "--decorate",
595
+ `--max-count=${count}`,
596
+ "--color=never",
597
+ `HEAD..${upstream}`
598
+ ];
599
+ const { exitCode, stdout } = await run(args);
600
+ if (exitCode !== 0)
601
+ return [];
602
+ return stdout.trimEnd().split(`
603
+ `).filter(Boolean);
604
+ }
605
+ async function getRemoteOnlyCommitsEntries(options) {
606
+ const count = options?.count ?? 20;
607
+ const upstream = options?.upstream;
608
+ if (!upstream)
609
+ return [];
610
+ const args = ["log", `--format=%h||%s||%D`, `--max-count=${count}`, `HEAD..${upstream}`];
611
+ const { exitCode, stdout } = await run(args);
612
+ if (exitCode !== 0)
613
+ return [];
614
+ return stdout.trimEnd().split(`
615
+ `).filter(Boolean).map((line) => {
616
+ const [hash = "", subject = "", refs = ""] = line.split("||");
617
+ return { hash: hash.trim(), subject: subject.trim(), refs: refs.trim() };
618
+ });
619
+ }
533
620
  async function getLocalBranches() {
534
621
  const { exitCode, stdout } = await run(["branch", "-vv", "--no-color"]);
535
622
  if (exitCode !== 0)
@@ -558,6 +645,17 @@ async function getRemoteBranches() {
558
645
  return stdout.trimEnd().split(`
559
646
  `).map((line) => line.trim()).filter((line) => line.length > 0 && !line.includes(" -> "));
560
647
  }
648
+ async function isBranchMergedInto(branch, base) {
649
+ const { exitCode } = await run(["merge-base", "--is-ancestor", branch, base]);
650
+ return exitCode === 0;
651
+ }
652
+ async function getLastCommitDate(branch) {
653
+ const { exitCode, stdout } = await run(["log", "-1", "--format=%aI", branch]);
654
+ if (exitCode !== 0)
655
+ return null;
656
+ const date = stdout.trim();
657
+ return date || null;
658
+ }
561
659
 
562
660
  // src/utils/logger.ts
563
661
  import { LogEngine, LogMode } from "@wgtechlabs/log-engine";
@@ -953,6 +1051,79 @@ function withTimeout(promise, ms) {
953
1051
  }
954
1052
  var COPILOT_TIMEOUT_MS = 30000;
955
1053
  var COPILOT_LONG_TIMEOUT_MS = 90000;
1054
+ var BATCH_CONFIG = {
1055
+ LARGE_CHANGESET_THRESHOLD: 15,
1056
+ COMPACT_PER_FILE_CHARS: 300,
1057
+ MAX_COMPACT_PAYLOAD: 1e4,
1058
+ FALLBACK_BATCH_SIZE: 15
1059
+ };
1060
+ function parseDiffByFile(rawDiff) {
1061
+ const sections = new Map;
1062
+ const headerPattern = /^diff --git a\/(.+?) b\/(.+?)$/gm;
1063
+ const positions = [];
1064
+ for (let match = headerPattern.exec(rawDiff);match !== null; match = headerPattern.exec(rawDiff)) {
1065
+ const aFile = match[1];
1066
+ const bFile = match[2] ?? aFile;
1067
+ positions.push({ aFile, bFile, start: match.index });
1068
+ }
1069
+ for (let i = 0;i < positions.length; i++) {
1070
+ const { aFile, bFile, start } = positions[i];
1071
+ const end = i + 1 < positions.length ? positions[i + 1].start : rawDiff.length;
1072
+ const section = rawDiff.slice(start, end);
1073
+ sections.set(aFile, section);
1074
+ if (bFile && bFile !== aFile) {
1075
+ sections.set(bFile, section);
1076
+ }
1077
+ }
1078
+ return sections;
1079
+ }
1080
+ function extractDiffStats(diffSection) {
1081
+ let added = 0;
1082
+ let removed = 0;
1083
+ for (const line of diffSection.split(`
1084
+ `)) {
1085
+ if (line.startsWith("+") && !line.startsWith("+++"))
1086
+ added++;
1087
+ if (line.startsWith("-") && !line.startsWith("---"))
1088
+ removed++;
1089
+ }
1090
+ return { added, removed };
1091
+ }
1092
+ function createCompactDiff(files, rawDiff, maxTotalChars = BATCH_CONFIG.MAX_COMPACT_PAYLOAD) {
1093
+ if (files.length === 0)
1094
+ return "";
1095
+ const diffSections = parseDiffByFile(rawDiff);
1096
+ const perFileBudget = Math.min(BATCH_CONFIG.COMPACT_PER_FILE_CHARS, Math.floor(maxTotalChars / files.length));
1097
+ const parts = [];
1098
+ for (const file of files) {
1099
+ const section = diffSections.get(file);
1100
+ if (section) {
1101
+ const stats = extractDiffStats(section);
1102
+ const header = `[${file}] (+${stats.added}/-${stats.removed})`;
1103
+ if (section.length <= perFileBudget) {
1104
+ parts.push(`${header}
1105
+ ${section}`);
1106
+ } else {
1107
+ const availableForBody = perFileBudget - header.length - 20;
1108
+ if (availableForBody <= 0) {
1109
+ parts.push(header);
1110
+ } else {
1111
+ const truncated = section.slice(0, availableForBody);
1112
+ parts.push(`${header}
1113
+ ${truncated}
1114
+ ...(truncated)`);
1115
+ }
1116
+ }
1117
+ } else {
1118
+ parts.push(`[${file}] (new/binary file — no diff available)`);
1119
+ }
1120
+ }
1121
+ const result = parts.join(`
1122
+
1123
+ `);
1124
+ return result.length > maxTotalChars ? `${result.slice(0, maxTotalChars - 15)}
1125
+ ...(truncated)` : result;
1126
+ }
956
1127
  async function checkCopilotAvailable() {
957
1128
  try {
958
1129
  const client = await getManagedClient();
@@ -1053,16 +1224,18 @@ function extractJson(raw) {
1053
1224
  }
1054
1225
  async function generateCommitMessage(diff, stagedFiles, model, convention = "clean-commit") {
1055
1226
  try {
1227
+ const isLarge = stagedFiles.length >= BATCH_CONFIG.LARGE_CHANGESET_THRESHOLD;
1056
1228
  const multiFileHint = stagedFiles.length > 1 ? `
1057
1229
 
1058
1230
  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.` : "";
1231
+ const diffContent = isLarge ? createCompactDiff(stagedFiles, diff) : diff.slice(0, 4000);
1059
1232
  const userMessage = `Generate a commit message for these staged changes:
1060
1233
 
1061
- Files: ${stagedFiles.join(", ")}
1234
+ Files (${stagedFiles.length}): ${stagedFiles.join(", ")}
1062
1235
 
1063
1236
  Diff:
1064
- ${diff.slice(0, 4000)}${multiFileHint}`;
1065
- const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
1237
+ ${diffContent}${multiFileHint}`;
1238
+ const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model, isLarge ? COPILOT_LONG_TIMEOUT_MS : COPILOT_TIMEOUT_MS);
1066
1239
  return result?.trim() ?? null;
1067
1240
  } catch {
1068
1241
  return null;
@@ -1111,16 +1284,23 @@ ${conflictDiff.slice(0, 4000)}`;
1111
1284
  }
1112
1285
  }
1113
1286
  async function generateCommitGroups(files, diffs, model, convention = "clean-commit") {
1287
+ const isLarge = files.length >= BATCH_CONFIG.LARGE_CHANGESET_THRESHOLD;
1288
+ const diffContent = isLarge ? createCompactDiff(files, diffs) : diffs.slice(0, 6000);
1289
+ const largeHint = isLarge ? `
1290
+
1291
+ NOTE: This is a large changeset (${files.length} files). Compact diffs are provided for every file. Focus on creating well-organized logical groups.` : "";
1114
1292
  const userMessage = `Group these changed files into logical atomic commits:
1115
1293
 
1116
1294
  Files:
1117
1295
  ${files.join(`
1118
1296
  `)}
1119
1297
 
1120
- Diffs (truncated):
1121
- ${diffs.slice(0, 6000)}`;
1298
+ Diffs:
1299
+ ${diffContent}${largeHint}`;
1122
1300
  const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
1123
1301
  if (!result) {
1302
+ if (isLarge)
1303
+ return generateCommitGroupsInBatches(files, diffs, model, convention);
1124
1304
  throw new Error("AI returned an empty response");
1125
1305
  }
1126
1306
  const cleaned = extractJson(result);
@@ -1128,10 +1308,14 @@ ${diffs.slice(0, 6000)}`;
1128
1308
  try {
1129
1309
  parsed = JSON.parse(cleaned);
1130
1310
  } catch {
1311
+ if (isLarge)
1312
+ return generateCommitGroupsInBatches(files, diffs, model, convention);
1131
1313
  throw new Error(`AI response is not valid JSON. Raw start: "${result.slice(0, 120)}..."`);
1132
1314
  }
1133
1315
  const groups = parsed;
1134
1316
  if (!Array.isArray(groups) || groups.length === 0) {
1317
+ if (isLarge)
1318
+ return generateCommitGroupsInBatches(files, diffs, model, convention);
1135
1319
  throw new Error("AI response was not a valid JSON array of commit groups");
1136
1320
  }
1137
1321
  for (const group of groups) {
@@ -1141,7 +1325,63 @@ ${diffs.slice(0, 6000)}`;
1141
1325
  }
1142
1326
  return groups;
1143
1327
  }
1328
+ async function generateCommitGroupsInBatches(files, diffs, model, convention = "clean-commit") {
1329
+ const batchSize = BATCH_CONFIG.FALLBACK_BATCH_SIZE;
1330
+ const allGroups = [];
1331
+ const diffSections = parseDiffByFile(diffs);
1332
+ for (let i = 0;i < files.length; i += batchSize) {
1333
+ const batchFiles = files.slice(i, i + batchSize);
1334
+ const batchDiff = batchFiles.map((f) => diffSections.get(f) ?? "").filter(Boolean).join(`
1335
+ `);
1336
+ const batchDiffContent = batchFiles.length >= BATCH_CONFIG.LARGE_CHANGESET_THRESHOLD ? createCompactDiff(batchFiles, batchDiff) : batchDiff.slice(0, 6000);
1337
+ const batchNum = Math.floor(i / batchSize) + 1;
1338
+ const totalBatches = Math.ceil(files.length / batchSize);
1339
+ const userMessage = `Group these changed files into logical atomic commits:
1340
+
1341
+ Files:
1342
+ ${batchFiles.join(`
1343
+ `)}
1344
+
1345
+ Diffs:
1346
+ ${batchDiffContent}
1347
+
1348
+ NOTE: Processing batch ${batchNum}/${totalBatches} of a large changeset. Group only the files listed above.`;
1349
+ try {
1350
+ const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
1351
+ if (!result)
1352
+ continue;
1353
+ const cleaned = extractJson(result);
1354
+ const parsed = JSON.parse(cleaned);
1355
+ if (Array.isArray(parsed)) {
1356
+ for (const group of parsed) {
1357
+ if (Array.isArray(group.files) && typeof group.message === "string") {
1358
+ const batchFileSet = new Set(batchFiles);
1359
+ const filteredFiles = group.files.filter((f) => batchFileSet.has(f));
1360
+ if (filteredFiles.length > 0) {
1361
+ allGroups.push({ ...group, files: filteredFiles });
1362
+ }
1363
+ }
1364
+ }
1365
+ }
1366
+ } catch {}
1367
+ }
1368
+ const groupedFiles = new Set(allGroups.flatMap((g) => g.files));
1369
+ const ungrouped = files.filter((f) => !groupedFiles.has(f));
1370
+ if (ungrouped.length > 0) {
1371
+ allGroups.push({
1372
+ files: ungrouped,
1373
+ message: `chore: update ${ungrouped.length} remaining file${ungrouped.length !== 1 ? "s" : ""}`
1374
+ });
1375
+ }
1376
+ if (allGroups.length === 0) {
1377
+ throw new Error("AI could not group any files even with batch processing");
1378
+ }
1379
+ return allGroups;
1380
+ }
1144
1381
  async function regenerateAllGroupMessages(groups, diffs, model, convention = "clean-commit") {
1382
+ const totalFiles = groups.reduce((sum, g) => sum + g.files.length, 0);
1383
+ const isLarge = totalFiles >= BATCH_CONFIG.LARGE_CHANGESET_THRESHOLD;
1384
+ const diffContent = isLarge ? createCompactDiff(groups.flatMap((g) => g.files), diffs) : diffs.slice(0, 6000);
1145
1385
  const groupSummary = groups.map((g, i) => `Group ${i + 1}: [${g.files.join(", ")}]`).join(`
1146
1386
  `);
1147
1387
  const userMessage = `Regenerate ONLY the commit messages for these pre-defined file groups. Do NOT change the file groupings.
@@ -1149,8 +1389,8 @@ async function regenerateAllGroupMessages(groups, diffs, model, convention = "cl
1149
1389
  Groups:
1150
1390
  ${groupSummary}
1151
1391
 
1152
- Diffs (truncated):
1153
- ${diffs.slice(0, 6000)}`;
1392
+ Diffs:
1393
+ ${diffContent}`;
1154
1394
  const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
1155
1395
  if (!result)
1156
1396
  return groups;
@@ -1169,12 +1409,14 @@ ${diffs.slice(0, 6000)}`;
1169
1409
  }
1170
1410
  async function regenerateGroupMessage(files, diffs, model, convention = "clean-commit") {
1171
1411
  try {
1412
+ const isLarge = files.length >= BATCH_CONFIG.LARGE_CHANGESET_THRESHOLD;
1413
+ const diffContent = isLarge ? createCompactDiff(files, diffs) : diffs.slice(0, 4000);
1172
1414
  const userMessage = `Generate a single commit message for these files:
1173
1415
 
1174
1416
  Files: ${files.join(", ")}
1175
1417
 
1176
1418
  Diff:
1177
- ${diffs.slice(0, 4000)}`;
1419
+ ${diffContent}`;
1178
1420
  const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
1179
1421
  return result?.trim() ?? null;
1180
1422
  } catch {
@@ -1732,7 +1974,8 @@ ${pc6.bold("Changed files:")}`);
1732
1974
  warn(`AI unavailable: ${copilotError}`);
1733
1975
  warn("Falling back to manual commit message entry.");
1734
1976
  } else {
1735
- const spinner = createSpinner("Generating commit message with AI...");
1977
+ 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...";
1978
+ const spinner = createSpinner(spinnerMsg);
1736
1979
  commitMessage = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
1737
1980
  if (commitMessage) {
1738
1981
  spinner.success("AI commit message generated.");
@@ -1823,7 +2066,7 @@ ${pc6.bold("Changed files:")}`);
1823
2066
  for (const f of changedFiles) {
1824
2067
  console.log(` ${pc6.dim("•")} ${f}`);
1825
2068
  }
1826
- const spinner = createSpinner(`Asking AI to group ${changedFiles.length} file(s) into logical commits...`);
2069
+ 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
2070
  const diffs = await getFullDiffForFiles(changedFiles);
1828
2071
  if (!diffs.trim()) {
1829
2072
  spinner.stop();
@@ -2001,8 +2244,8 @@ import pc7 from "picocolors";
2001
2244
  // package.json
2002
2245
  var package_default = {
2003
2246
  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.",
2247
+ version: "0.4.1-dev.23d6614",
2248
+ description: "Developer CLI that automates git workflows branching, syncing, committing, and PRs — with multi-workflow and commit convention support.",
2006
2249
  type: "module",
2007
2250
  bin: {
2008
2251
  contrib: "dist/index.js",
@@ -2019,9 +2262,10 @@ var package_default = {
2019
2262
  lint: "biome check .",
2020
2263
  "lint:fix": "biome check --write .",
2021
2264
  format: "biome format --write .",
2022
- "www:dev": "bun run --cwd www dev",
2023
- "www:build": "bun run --cwd www build",
2024
- "www:preview": "bun run --cwd www preview"
2265
+ "landing:install": "bun install --cwd landing",
2266
+ "landing:dev": "bun run --cwd landing dev",
2267
+ "landing:build": "bun run --cwd landing build",
2268
+ "landing:preview": "bun run --cwd landing preview"
2025
2269
  },
2026
2270
  engines: {
2027
2271
  node: ">=18",
@@ -2519,7 +2763,19 @@ var log_default = defineCommand6({
2519
2763
  all: {
2520
2764
  type: "boolean",
2521
2765
  alias: "a",
2522
- description: "Show all branches, not just current",
2766
+ description: "Show commits from all branches",
2767
+ default: false
2768
+ },
2769
+ remote: {
2770
+ type: "boolean",
2771
+ alias: "r",
2772
+ description: "Show only remote commits not yet pulled locally",
2773
+ default: false
2774
+ },
2775
+ full: {
2776
+ type: "boolean",
2777
+ alias: "f",
2778
+ description: "Show full commit history for the current branch",
2523
2779
  default: false
2524
2780
  },
2525
2781
  graph: {
@@ -2541,44 +2797,197 @@ var log_default = defineCommand6({
2541
2797
  }
2542
2798
  const config = readConfig();
2543
2799
  const count = args.count ? Number.parseInt(args.count, 10) : 20;
2544
- const showAll = args.all;
2545
2800
  const showGraph = args.graph;
2546
2801
  const targetBranch = args.branch;
2802
+ let mode = "local";
2803
+ if (args.all)
2804
+ mode = "all";
2805
+ else if (args.remote)
2806
+ mode = "remote";
2807
+ else if (args.full || targetBranch)
2808
+ mode = "full";
2547
2809
  const protectedBranches = config ? getProtectedBranches(config) : ["main", "master"];
2548
2810
  const currentBranch = await getCurrentBranch();
2811
+ const upstream = await getUpstreamRef();
2812
+ let compareRef = upstream;
2813
+ let usingFallback = false;
2814
+ if (!compareRef) {
2815
+ const fallback = await resolveBaseBranchRef(config);
2816
+ if (fallback) {
2817
+ compareRef = fallback;
2818
+ usingFallback = true;
2819
+ }
2820
+ }
2549
2821
  heading("\uD83D\uDCDC commit log");
2550
- if (showGraph) {
2551
- const lines = await getLogGraph({ count, all: showAll, branch: targetBranch });
2552
- if (lines.length === 0) {
2553
- console.log(pc9.dim(" No commits found."));
2822
+ printModeHeader(mode, currentBranch, compareRef, usingFallback);
2823
+ if (mode === "local" || mode === "remote") {
2824
+ if (!compareRef) {
2554
2825
  console.log();
2826
+ console.log(pc9.yellow(" ⚠ Could not determine a comparison branch."));
2827
+ console.log(pc9.dim(" No upstream tracking set and no remote base branch found."));
2828
+ console.log(pc9.dim(` Use ${pc9.bold("contrib log --full")} to see the full commit history instead.`));
2829
+ console.log();
2830
+ printGuidance();
2555
2831
  return;
2556
2832
  }
2557
- console.log();
2558
- for (const line of lines) {
2559
- console.log(` ${colorizeGraphLine(line, protectedBranches, currentBranch)}`);
2833
+ const hasCommits = await renderScopedLog({ mode, count, upstream: compareRef, showGraph, protectedBranches, currentBranch });
2834
+ if (!hasCommits) {
2835
+ printGuidance();
2836
+ return;
2560
2837
  }
2561
2838
  } else {
2562
- const entries = await getLogEntries({ count, all: showAll, branch: targetBranch });
2563
- if (entries.length === 0) {
2564
- console.log(pc9.dim(" No commits found."));
2565
- console.log();
2839
+ const hasCommits = await renderFullLog({ count, all: mode === "all", showGraph, targetBranch, protectedBranches, currentBranch });
2840
+ if (!hasCommits) {
2841
+ printGuidance();
2566
2842
  return;
2567
2843
  }
2568
- console.log();
2569
- for (const entry of entries) {
2570
- const hashStr = pc9.yellow(entry.hash);
2571
- const refsStr = entry.refs ? ` ${colorizeRefs(entry.refs, protectedBranches, currentBranch)}` : "";
2572
- const subjectStr = colorizeSubject(entry.subject);
2573
- console.log(` ${hashStr}${refsStr} ${subjectStr}`);
2844
+ }
2845
+ printFooter(mode, count, targetBranch);
2846
+ printGuidance();
2847
+ }
2848
+ });
2849
+ async function resolveBaseBranchRef(config) {
2850
+ if (!config) {
2851
+ for (const candidate2 of ["origin/main", "origin/master"]) {
2852
+ if (await branchExists(candidate2))
2853
+ return candidate2;
2854
+ }
2855
+ return null;
2856
+ }
2857
+ const baseBranch = getBaseBranch(config);
2858
+ const remote = config.origin ?? "origin";
2859
+ const candidate = `${remote}/${baseBranch}`;
2860
+ if (await branchExists(candidate))
2861
+ return candidate;
2862
+ for (const fallback of ["origin/main", "origin/master"]) {
2863
+ if (fallback !== candidate && await branchExists(fallback))
2864
+ return fallback;
2865
+ }
2866
+ return null;
2867
+ }
2868
+ function printModeHeader(mode, currentBranch, compareRef, usingFallback = false) {
2869
+ const branch = currentBranch ?? "HEAD";
2870
+ const fallbackNote = usingFallback ? pc9.yellow(" (no upstream — comparing against base branch)") : "";
2871
+ console.log();
2872
+ switch (mode) {
2873
+ case "local":
2874
+ console.log(pc9.dim(` mode: ${pc9.bold("local")} — unpushed commits on ${pc9.bold(branch)}`) + fallbackNote);
2875
+ if (compareRef) {
2876
+ console.log(pc9.dim(` comparing: ${pc9.bold(compareRef)} ➜ ${pc9.bold("HEAD")}`));
2574
2877
  }
2878
+ break;
2879
+ case "remote":
2880
+ console.log(pc9.dim(` mode: ${pc9.bold("remote")} — commits on remote not yet pulled into ${pc9.bold(branch)}`) + fallbackNote);
2881
+ if (compareRef) {
2882
+ console.log(pc9.dim(` comparing: ${pc9.bold("HEAD")} ➜ ${pc9.bold(compareRef)}`));
2883
+ }
2884
+ break;
2885
+ case "full":
2886
+ console.log(pc9.dim(` mode: ${pc9.bold("full")} — complete commit history for ${pc9.bold(branch)}`));
2887
+ break;
2888
+ case "all":
2889
+ console.log(pc9.dim(` mode: ${pc9.bold("all")} — commits across all branches`));
2890
+ break;
2891
+ }
2892
+ }
2893
+ async function renderScopedLog(options) {
2894
+ const { mode, count, upstream, showGraph, protectedBranches, currentBranch } = options;
2895
+ if (showGraph) {
2896
+ const graphFn = mode === "local" ? getLocalCommitsGraph : getRemoteOnlyCommitsGraph;
2897
+ const lines = await graphFn({ count, upstream });
2898
+ if (lines.length === 0) {
2899
+ printEmptyState(mode);
2900
+ return false;
2575
2901
  }
2576
2902
  console.log();
2577
- console.log(pc9.dim(` Showing ${count} most recent commits${showAll ? " (all branches)" : targetBranch ? ` (${targetBranch})` : ""}`));
2578
- console.log(pc9.dim(` Use ${pc9.bold("contrib log -n 50")} for more, or ${pc9.bold("contrib log --all")} for all branches`));
2903
+ for (const line of lines) {
2904
+ console.log(` ${colorizeGraphLine(line, protectedBranches, currentBranch)}`);
2905
+ }
2906
+ } else {
2907
+ const entryFn = mode === "local" ? getLocalCommitsEntries : getRemoteOnlyCommitsEntries;
2908
+ const entries = await entryFn({ count, upstream });
2909
+ if (entries.length === 0) {
2910
+ printEmptyState(mode);
2911
+ return false;
2912
+ }
2579
2913
  console.log();
2914
+ for (const entry of entries) {
2915
+ const hashStr = pc9.yellow(entry.hash);
2916
+ const refsStr = entry.refs ? ` ${colorizeRefs(entry.refs, protectedBranches, currentBranch)}` : "";
2917
+ const subjectStr = colorizeSubject(entry.subject);
2918
+ console.log(` ${hashStr}${refsStr} ${subjectStr}`);
2919
+ }
2580
2920
  }
2581
- });
2921
+ return true;
2922
+ }
2923
+ function printEmptyState(mode) {
2924
+ console.log();
2925
+ if (mode === "local") {
2926
+ console.log(pc9.dim(" No local unpushed commits — you're up to date with remote!"));
2927
+ } else {
2928
+ console.log(pc9.dim(" No remote-only commits — your local branch is up to date!"));
2929
+ }
2930
+ console.log();
2931
+ }
2932
+ async function renderFullLog(options) {
2933
+ const { count, all, showGraph, targetBranch, protectedBranches, currentBranch } = options;
2934
+ if (showGraph) {
2935
+ const lines = await getLogGraph({ count, all, branch: targetBranch });
2936
+ if (lines.length === 0) {
2937
+ console.log(pc9.dim(" No commits found."));
2938
+ console.log();
2939
+ return false;
2940
+ }
2941
+ console.log();
2942
+ for (const line of lines) {
2943
+ console.log(` ${colorizeGraphLine(line, protectedBranches, currentBranch)}`);
2944
+ }
2945
+ } else {
2946
+ const entries = await getLogEntries({ count, all, branch: targetBranch });
2947
+ if (entries.length === 0) {
2948
+ console.log(pc9.dim(" No commits found."));
2949
+ console.log();
2950
+ return false;
2951
+ }
2952
+ console.log();
2953
+ for (const entry of entries) {
2954
+ const hashStr = pc9.yellow(entry.hash);
2955
+ const refsStr = entry.refs ? ` ${colorizeRefs(entry.refs, protectedBranches, currentBranch)}` : "";
2956
+ const subjectStr = colorizeSubject(entry.subject);
2957
+ console.log(` ${hashStr}${refsStr} ${subjectStr}`);
2958
+ }
2959
+ }
2960
+ return true;
2961
+ }
2962
+ function printFooter(mode, count, targetBranch) {
2963
+ console.log();
2964
+ switch (mode) {
2965
+ case "local":
2966
+ console.log(pc9.dim(` Showing up to ${count} unpushed commits`));
2967
+ break;
2968
+ case "remote":
2969
+ console.log(pc9.dim(` Showing up to ${count} remote-only commits`));
2970
+ break;
2971
+ case "full":
2972
+ console.log(pc9.dim(` Showing ${count} most recent commits${targetBranch ? ` (${targetBranch})` : ""}`));
2973
+ break;
2974
+ case "all":
2975
+ console.log(pc9.dim(` Showing ${count} most recent commits (all branches)`));
2976
+ break;
2977
+ }
2978
+ }
2979
+ function printGuidance() {
2980
+ console.log();
2981
+ console.log(pc9.dim(" ─── quick guide ───"));
2982
+ console.log(pc9.dim(` ${pc9.bold("contrib log")} local unpushed commits (default)`));
2983
+ console.log(pc9.dim(` ${pc9.bold("contrib log --remote")} commits on remote not yet pulled`));
2984
+ console.log(pc9.dim(` ${pc9.bold("contrib log --full")} full history for the current branch`));
2985
+ console.log(pc9.dim(` ${pc9.bold("contrib log --all")} commits across all branches`));
2986
+ console.log(pc9.dim(` ${pc9.bold("contrib log -n 50")} change the commit limit (default: 20)`));
2987
+ console.log(pc9.dim(` ${pc9.bold("contrib log -b dev")} view log for a specific branch`));
2988
+ console.log(pc9.dim(` ${pc9.bold("contrib log --no-graph")} flat list without graph lines`));
2989
+ console.log();
2990
+ }
2582
2991
  function colorizeGraphLine(line, protectedBranches, currentBranch) {
2583
2992
  const match = line.match(/^([|/\\*\s_.-]*)([a-f0-9]{7,12})(\s+\(([^)]+)\))?\s*(.*)/);
2584
2993
  if (!match) {
@@ -2660,6 +3069,43 @@ function colorizeSubject(subject) {
2660
3069
  // src/commands/setup.ts
2661
3070
  import { defineCommand as defineCommand7 } from "citty";
2662
3071
  import pc10 from "picocolors";
3072
+ async function shouldContinueSetupWithExistingConfig(options) {
3073
+ const {
3074
+ existingConfig,
3075
+ hasConfigFile,
3076
+ confirm: confirm2,
3077
+ ensureIgnored,
3078
+ onInfo,
3079
+ onWarn,
3080
+ onSuccess,
3081
+ summary
3082
+ } = options;
3083
+ if (existingConfig) {
3084
+ onInfo("Existing .contributerc.json detected:");
3085
+ summary(existingConfig);
3086
+ const shouldContinue = await confirm2("Continue setup and overwrite existing config?");
3087
+ if (!shouldContinue) {
3088
+ if (ensureIgnored()) {
3089
+ onInfo("Added .contributerc.json to .gitignore to avoid committing personal config.");
3090
+ }
3091
+ onSuccess("Keeping existing setup.");
3092
+ return false;
3093
+ }
3094
+ return true;
3095
+ }
3096
+ if (hasConfigFile) {
3097
+ onWarn("Found .contributerc.json but it appears invalid.");
3098
+ const shouldContinue = await confirm2("Continue setup and overwrite invalid config?");
3099
+ if (!shouldContinue) {
3100
+ if (ensureIgnored()) {
3101
+ onInfo("Added .contributerc.json to .gitignore to avoid committing personal config.");
3102
+ }
3103
+ onInfo("Keeping existing file. Run setup again when ready to repair it.");
3104
+ return false;
3105
+ }
3106
+ }
3107
+ return true;
3108
+ }
2663
3109
  var setup_default = defineCommand7({
2664
3110
  meta: {
2665
3111
  name: "setup",
@@ -2671,6 +3117,20 @@ var setup_default = defineCommand7({
2671
3117
  process.exit(1);
2672
3118
  }
2673
3119
  heading("\uD83D\uDD27 contribute-now setup");
3120
+ const existingConfig = readConfig();
3121
+ const shouldContinue = await shouldContinueSetupWithExistingConfig({
3122
+ existingConfig,
3123
+ hasConfigFile: configExists(),
3124
+ confirm: confirmPrompt,
3125
+ ensureIgnored: ensureGitignored,
3126
+ onInfo: info,
3127
+ onWarn: warn,
3128
+ onSuccess: success,
3129
+ summary: logConfigSummary
3130
+ });
3131
+ if (!shouldContinue) {
3132
+ return;
3133
+ }
2674
3134
  const workflowChoice = await selectPrompt("Which git workflow does this project use?", [
2675
3135
  "Clean Flow — main + dev, squash features into dev, merge dev into main (recommended)",
2676
3136
  "GitHub Flow — main + feature branches, squash/merge into main",
@@ -2700,31 +3160,42 @@ var setup_default = defineCommand7({
2700
3160
  info(`Found remotes: ${remotes.join(", ")}`);
2701
3161
  let detectedRole = null;
2702
3162
  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)";
3163
+ const roleSpinner = createSpinner("Detecting your role...");
3164
+ try {
3165
+ roleSpinner.update("Checking GitHub CLI and auth...");
3166
+ const ghInstalled = await checkGhInstalled();
3167
+ if (ghInstalled && await checkGhAuth()) {
3168
+ roleSpinner.update("Inspecting repository relationship (fork/permissions)...");
3169
+ const isFork = await isRepoFork();
3170
+ if (isFork === true) {
3171
+ detectedRole = "contributor";
3172
+ detectionSource = "gh CLI (fork detected)";
3173
+ } else if (isFork === false) {
3174
+ const repoInfo = await getCurrentRepoInfo();
3175
+ if (repoInfo) {
3176
+ const perms = await checkRepoPermissions(repoInfo.owner, repoInfo.repo);
3177
+ if (perms?.admin || perms?.push) {
3178
+ detectedRole = "maintainer";
3179
+ detectionSource = "gh CLI (admin/push permissions)";
3180
+ }
2716
3181
  }
2717
3182
  }
2718
3183
  }
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)";
3184
+ if (detectedRole === null) {
3185
+ roleSpinner.update("Analyzing git remotes...");
3186
+ if (remotes.includes("upstream")) {
3187
+ detectedRole = "contributor";
3188
+ detectionSource = "heuristic (upstream remote exists)";
3189
+ } else if (remotes.includes("origin") && remotes.length === 1) {
3190
+ detectedRole = "maintainer";
3191
+ detectionSource = "heuristic (only origin remote)";
3192
+ }
2727
3193
  }
3194
+ roleSpinner.success("Role detection complete.");
3195
+ } catch {
3196
+ roleSpinner.fail("Role detection failed; falling back to manual selection.");
3197
+ detectedRole = null;
3198
+ detectionSource = "";
2728
3199
  }
2729
3200
  if (detectedRole === null) {
2730
3201
  const roleChoice = await selectPrompt("What is your role in this project?", [
@@ -2742,16 +3213,20 @@ var setup_default = defineCommand7({
2742
3213
  }
2743
3214
  }
2744
3215
  const defaultConfig = getDefaultConfig();
2745
- const mainBranch = await inputPrompt("Main branch name", defaultConfig.mainBranch);
3216
+ info(pc10.dim("Tip: press Enter to keep the default branch name shown in each prompt."));
3217
+ const mainBranchDefault = defaultConfig.mainBranch;
3218
+ const mainBranch = await inputPrompt(`Main branch name (default: ${mainBranchDefault} — press Enter to keep)`, mainBranchDefault);
2746
3219
  let devBranch;
2747
3220
  if (hasDevBranch(workflow)) {
2748
3221
  const defaultDev = workflow === "git-flow" ? "develop" : "dev";
2749
- devBranch = await inputPrompt("Dev/develop branch name", defaultDev);
3222
+ devBranch = await inputPrompt(`Dev/develop branch name (default: ${defaultDev} — press Enter to keep)`, defaultDev);
2750
3223
  }
2751
- const originRemote = await inputPrompt("Origin remote name", defaultConfig.origin);
3224
+ const originRemoteDefault = defaultConfig.origin;
3225
+ const originRemote = await inputPrompt(`Origin remote name (default: ${originRemoteDefault} — press Enter to keep)`, originRemoteDefault);
2752
3226
  let upstreamRemote = defaultConfig.upstream;
2753
3227
  if (detectedRole === "contributor") {
2754
- upstreamRemote = await inputPrompt("Upstream remote name", defaultConfig.upstream);
3228
+ const upstreamRemoteDefault = defaultConfig.upstream;
3229
+ upstreamRemote = await inputPrompt(`Upstream remote name (default: ${upstreamRemoteDefault} — press Enter to keep)`, upstreamRemoteDefault);
2755
3230
  if (!remotes.includes(upstreamRemote)) {
2756
3231
  warn(`Remote "${upstreamRemote}" not found.`);
2757
3232
  const originUrl = await getRemoteUrl(originRemote);
@@ -2799,9 +3274,8 @@ var setup_default = defineCommand7({
2799
3274
  warn("Config was saved — verify the branch name and re-run setup if needed.");
2800
3275
  }
2801
3276
  }
2802
- if (!isGitignored()) {
2803
- warn(".contributerc.json is not in .gitignore. Add it to avoid committing personal config.");
2804
- warn(' echo ".contributerc.json" >> .gitignore');
3277
+ if (ensureGitignored()) {
3278
+ info("Added .contributerc.json to .gitignore to avoid committing personal config.");
2805
3279
  }
2806
3280
  console.log();
2807
3281
  info(`Workflow: ${pc10.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
@@ -2815,6 +3289,17 @@ var setup_default = defineCommand7({
2815
3289
  info(`Origin: ${pc10.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc10.bold(config.upstream)}` : ""}`);
2816
3290
  }
2817
3291
  });
3292
+ function logConfigSummary(config) {
3293
+ info(`Workflow: ${pc10.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
3294
+ info(`Convention: ${pc10.bold(CONVENTION_DESCRIPTIONS[config.commitConvention])}`);
3295
+ info(`Role: ${pc10.bold(config.role)}`);
3296
+ if (config.devBranch) {
3297
+ info(`Main: ${pc10.bold(config.mainBranch)} | Dev: ${pc10.bold(config.devBranch)}`);
3298
+ } else {
3299
+ info(`Main: ${pc10.bold(config.mainBranch)}`);
3300
+ }
3301
+ info(`Origin: ${pc10.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc10.bold(config.upstream)}` : ""}`);
3302
+ }
2818
3303
 
2819
3304
  // src/commands/start.ts
2820
3305
  import { defineCommand as defineCommand8 } from "citty";
@@ -2983,10 +3468,20 @@ var status_default = defineCommand9({
2983
3468
  const devLine = formatStatus(config.devBranch, devRemoteRef, devDiv.ahead, devDiv.behind);
2984
3469
  console.log(devLine);
2985
3470
  }
2986
- if (currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
3471
+ const protectedBranches = getProtectedBranches(config);
3472
+ const isFeatureBranch = currentBranch && !protectedBranches.includes(currentBranch);
3473
+ let branchStatus = null;
3474
+ if (isFeatureBranch) {
2987
3475
  const branchDiv = await getDivergence(currentBranch, baseBranch);
2988
3476
  const branchLine = formatStatus(currentBranch, baseBranch, branchDiv.ahead, branchDiv.behind);
2989
3477
  console.log(branchLine + pc12.dim(` (current ${pc12.green("*")})`));
3478
+ branchStatus = await detectBranchStatus(currentBranch, baseBranch, config);
3479
+ if (branchStatus.merged) {
3480
+ console.log(` ${pc12.green("✓")} ${pc12.green("Branch merged")} — ${pc12.dim(branchStatus.mergedReason ?? "all commits reachable from base")}`);
3481
+ }
3482
+ if (branchStatus.stale) {
3483
+ console.log(` ${pc12.yellow("⏳")} ${pc12.yellow("Branch is stale")} — ${pc12.dim(`last commit ${branchStatus.staleDaysAgo} days ago`)}`);
3484
+ }
2990
3485
  } else if (currentBranch) {
2991
3486
  console.log(pc12.dim(` (on ${pc12.bold(currentBranch)} branch)`));
2992
3487
  }
@@ -3021,10 +3516,16 @@ var status_default = defineCommand9({
3021
3516
  if (fileStatus.modified.length > 0 || fileStatus.untracked.length > 0) {
3022
3517
  tips.push(`Run ${pc12.bold("contrib commit")} to stage and commit changes`);
3023
3518
  }
3024
- if (fileStatus.staged.length === 0 && fileStatus.modified.length === 0 && fileStatus.untracked.length === 0 && currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
3025
- const branchDiv = await getDivergence(currentBranch, `${origin}/${currentBranch}`);
3026
- if (branchDiv.ahead > 0) {
3027
- tips.push(`Run ${pc12.bold("contrib submit")} to push and create/update your PR`);
3519
+ if (isFeatureBranch && branchStatus) {
3520
+ if (branchStatus.merged) {
3521
+ tips.push(`Run ${pc12.bold("contrib clean")} to delete this merged branch`);
3522
+ } else if (branchStatus.stale) {
3523
+ tips.push(`Run ${pc12.bold("contrib sync")} to rebase on latest changes, or ${pc12.bold("contrib clean")} if no longer needed`);
3524
+ } else if (fileStatus.staged.length === 0 && fileStatus.modified.length === 0 && fileStatus.untracked.length === 0) {
3525
+ const branchDiv = await getDivergence(currentBranch, `${origin}/${currentBranch}`);
3526
+ if (branchDiv.ahead > 0) {
3527
+ tips.push(`Run ${pc12.bold("contrib submit")} to push and create/update your PR`);
3528
+ }
3028
3529
  }
3029
3530
  }
3030
3531
  if (tips.length > 0) {
@@ -3050,6 +3551,48 @@ function formatStatus(branch, base, ahead, behind) {
3050
3551
  }
3051
3552
  return ` ${pc12.red("⚡")} ${label} ${pc12.yellow(`${ahead} ahead`)}${pc12.dim(", ")}${pc12.red(`${behind} behind`)} ${pc12.dim(base)}`;
3052
3553
  }
3554
+ var STALE_THRESHOLD_DAYS = 14;
3555
+ async function detectBranchStatus(branch, baseBranch, config) {
3556
+ const result = { merged: false, mergedReason: null, stale: false, staleDaysAgo: null };
3557
+ const div = await getDivergence(branch, baseBranch);
3558
+ const hasWork = div.ahead > 0;
3559
+ if (hasWork) {
3560
+ if (await isBranchMergedInto(branch, baseBranch)) {
3561
+ result.merged = true;
3562
+ result.mergedReason = `all commits reachable from ${baseBranch}`;
3563
+ return result;
3564
+ }
3565
+ const mergedBranches = await getMergedBranches(baseBranch);
3566
+ if (mergedBranches.includes(branch)) {
3567
+ result.merged = true;
3568
+ result.mergedReason = `listed in merged branches of ${baseBranch}`;
3569
+ return result;
3570
+ }
3571
+ }
3572
+ const goneBranches = await getGoneBranches();
3573
+ if (goneBranches.includes(branch)) {
3574
+ result.merged = true;
3575
+ result.mergedReason = "remote branch deleted (likely squash-merged)";
3576
+ return result;
3577
+ }
3578
+ if (await checkGhInstalled()) {
3579
+ const mergedPR = await getMergedPRForBranch(branch);
3580
+ if (mergedPR) {
3581
+ result.merged = true;
3582
+ result.mergedReason = `PR #${mergedPR.number} was merged`;
3583
+ return result;
3584
+ }
3585
+ }
3586
+ const lastDate = await getLastCommitDate(branch);
3587
+ if (lastDate) {
3588
+ const daysAgo = Math.floor((Date.now() - new Date(lastDate).getTime()) / (1000 * 60 * 60 * 24));
3589
+ if (daysAgo >= STALE_THRESHOLD_DAYS) {
3590
+ result.stale = true;
3591
+ result.staleDaysAgo = daysAgo;
3592
+ }
3593
+ }
3594
+ return result;
3595
+ }
3053
3596
 
3054
3597
  // src/commands/submit.ts
3055
3598
  import { defineCommand as defineCommand10 } from "citty";
@@ -3343,6 +3886,25 @@ var submit_default = defineCommand10({
3343
3886
  return;
3344
3887
  }
3345
3888
  }
3889
+ if (ghInstalled && ghAuthed) {
3890
+ const existingPR = await getPRForBranch(currentBranch);
3891
+ if (existingPR) {
3892
+ info(`Pushing ${pc13.bold(currentBranch)} to ${origin}...`);
3893
+ const pushResult2 = await pushSetUpstream(origin, currentBranch);
3894
+ if (pushResult2.exitCode !== 0) {
3895
+ error(`Failed to push: ${pushResult2.stderr}`);
3896
+ if (pushResult2.stderr.includes("rejected") || pushResult2.stderr.includes("non-fast-forward")) {
3897
+ warn("The remote branch has diverged. Try:");
3898
+ info(` git pull --rebase ${origin} ${currentBranch}`);
3899
+ info(" Then run `contrib submit` again.");
3900
+ }
3901
+ process.exit(1);
3902
+ }
3903
+ success(`Pushed changes to existing PR #${existingPR.number}: ${pc13.bold(existingPR.title)}`);
3904
+ console.log(` ${pc13.cyan(existingPR.url)}`);
3905
+ return;
3906
+ }
3907
+ }
3346
3908
  let prTitle = null;
3347
3909
  let prBody = null;
3348
3910
  async function tryGenerateAI() {
@@ -3476,12 +4038,6 @@ ${pc13.dim("AI body preview:")}`);
3476
4038
  }
3477
4039
  return;
3478
4040
  }
3479
- const existingPR = await getPRForBranch(currentBranch);
3480
- if (existingPR) {
3481
- success(`Pushed changes to existing PR #${existingPR.number}: ${pc13.bold(existingPR.title)}`);
3482
- console.log(` ${pc13.cyan(existingPR.url)}`);
3483
- return;
3484
- }
3485
4041
  if (submitAction === "fill") {
3486
4042
  const fillResult = await createPRFill(baseBranch, args.draft);
3487
4043
  if (fillResult.exitCode !== 0) {
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.23d6614",
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",
@@ -18,9 +18,10 @@
18
18
  "lint": "biome check .",
19
19
  "lint:fix": "biome check --write .",
20
20
  "format": "biome format --write .",
21
- "www:dev": "bun run --cwd www dev",
22
- "www:build": "bun run --cwd www build",
23
- "www:preview": "bun run --cwd www preview"
21
+ "landing:install": "bun install --cwd landing",
22
+ "landing:dev": "bun run --cwd landing dev",
23
+ "landing:build": "bun run --cwd landing build",
24
+ "landing:preview": "bun run --cwd landing preview"
24
25
  },
25
26
  "engines": {
26
27
  "node": ">=18",