contribute-now 0.3.0 → 0.4.0-dev.89d5552
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.
- package/README.md +55 -2
- package/dist/index.js +299 -41
- 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`) —
|
|
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",
|
|
@@ -205,6 +222,13 @@ async function branchExists(branch) {
|
|
|
205
222
|
const { exitCode } = await run(["rev-parse", "--verify", branch]);
|
|
206
223
|
return exitCode === 0;
|
|
207
224
|
}
|
|
225
|
+
async function countCommitsAhead(branch, upstream) {
|
|
226
|
+
const { exitCode, stdout } = await run(["rev-list", "--count", `${upstream}..${branch}`]);
|
|
227
|
+
if (exitCode !== 0)
|
|
228
|
+
return 0;
|
|
229
|
+
const count = Number.parseInt(stdout.trim(), 10);
|
|
230
|
+
return Number.isNaN(count) ? 0 : count;
|
|
231
|
+
}
|
|
208
232
|
async function fetchRemote(remote) {
|
|
209
233
|
return run(["fetch", remote]);
|
|
210
234
|
}
|
|
@@ -946,6 +970,79 @@ function withTimeout(promise, ms) {
|
|
|
946
970
|
}
|
|
947
971
|
var COPILOT_TIMEOUT_MS = 30000;
|
|
948
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
|
+
}
|
|
949
1046
|
async function checkCopilotAvailable() {
|
|
950
1047
|
try {
|
|
951
1048
|
const client = await getManagedClient();
|
|
@@ -1046,16 +1143,18 @@ function extractJson(raw) {
|
|
|
1046
1143
|
}
|
|
1047
1144
|
async function generateCommitMessage(diff, stagedFiles, model, convention = "clean-commit") {
|
|
1048
1145
|
try {
|
|
1146
|
+
const isLarge = stagedFiles.length >= BATCH_CONFIG.LARGE_CHANGESET_THRESHOLD;
|
|
1049
1147
|
const multiFileHint = stagedFiles.length > 1 ? `
|
|
1050
1148
|
|
|
1051
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);
|
|
1052
1151
|
const userMessage = `Generate a commit message for these staged changes:
|
|
1053
1152
|
|
|
1054
|
-
Files: ${stagedFiles.join(", ")}
|
|
1153
|
+
Files (${stagedFiles.length}): ${stagedFiles.join(", ")}
|
|
1055
1154
|
|
|
1056
1155
|
Diff:
|
|
1057
|
-
${
|
|
1058
|
-
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);
|
|
1059
1158
|
return result?.trim() ?? null;
|
|
1060
1159
|
} catch {
|
|
1061
1160
|
return null;
|
|
@@ -1104,16 +1203,23 @@ ${conflictDiff.slice(0, 4000)}`;
|
|
|
1104
1203
|
}
|
|
1105
1204
|
}
|
|
1106
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.` : "";
|
|
1107
1211
|
const userMessage = `Group these changed files into logical atomic commits:
|
|
1108
1212
|
|
|
1109
1213
|
Files:
|
|
1110
1214
|
${files.join(`
|
|
1111
1215
|
`)}
|
|
1112
1216
|
|
|
1113
|
-
Diffs
|
|
1114
|
-
${
|
|
1217
|
+
Diffs:
|
|
1218
|
+
${diffContent}${largeHint}`;
|
|
1115
1219
|
const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
|
|
1116
1220
|
if (!result) {
|
|
1221
|
+
if (isLarge)
|
|
1222
|
+
return generateCommitGroupsInBatches(files, diffs, model, convention);
|
|
1117
1223
|
throw new Error("AI returned an empty response");
|
|
1118
1224
|
}
|
|
1119
1225
|
const cleaned = extractJson(result);
|
|
@@ -1121,10 +1227,14 @@ ${diffs.slice(0, 6000)}`;
|
|
|
1121
1227
|
try {
|
|
1122
1228
|
parsed = JSON.parse(cleaned);
|
|
1123
1229
|
} catch {
|
|
1230
|
+
if (isLarge)
|
|
1231
|
+
return generateCommitGroupsInBatches(files, diffs, model, convention);
|
|
1124
1232
|
throw new Error(`AI response is not valid JSON. Raw start: "${result.slice(0, 120)}..."`);
|
|
1125
1233
|
}
|
|
1126
1234
|
const groups = parsed;
|
|
1127
1235
|
if (!Array.isArray(groups) || groups.length === 0) {
|
|
1236
|
+
if (isLarge)
|
|
1237
|
+
return generateCommitGroupsInBatches(files, diffs, model, convention);
|
|
1128
1238
|
throw new Error("AI response was not a valid JSON array of commit groups");
|
|
1129
1239
|
}
|
|
1130
1240
|
for (const group of groups) {
|
|
@@ -1134,7 +1244,63 @@ ${diffs.slice(0, 6000)}`;
|
|
|
1134
1244
|
}
|
|
1135
1245
|
return groups;
|
|
1136
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
|
+
}
|
|
1137
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);
|
|
1138
1304
|
const groupSummary = groups.map((g, i) => `Group ${i + 1}: [${g.files.join(", ")}]`).join(`
|
|
1139
1305
|
`);
|
|
1140
1306
|
const userMessage = `Regenerate ONLY the commit messages for these pre-defined file groups. Do NOT change the file groupings.
|
|
@@ -1142,8 +1308,8 @@ async function regenerateAllGroupMessages(groups, diffs, model, convention = "cl
|
|
|
1142
1308
|
Groups:
|
|
1143
1309
|
${groupSummary}
|
|
1144
1310
|
|
|
1145
|
-
Diffs
|
|
1146
|
-
${
|
|
1311
|
+
Diffs:
|
|
1312
|
+
${diffContent}`;
|
|
1147
1313
|
const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
|
|
1148
1314
|
if (!result)
|
|
1149
1315
|
return groups;
|
|
@@ -1162,12 +1328,14 @@ ${diffs.slice(0, 6000)}`;
|
|
|
1162
1328
|
}
|
|
1163
1329
|
async function regenerateGroupMessage(files, diffs, model, convention = "clean-commit") {
|
|
1164
1330
|
try {
|
|
1331
|
+
const isLarge = files.length >= BATCH_CONFIG.LARGE_CHANGESET_THRESHOLD;
|
|
1332
|
+
const diffContent = isLarge ? createCompactDiff(files, diffs) : diffs.slice(0, 4000);
|
|
1165
1333
|
const userMessage = `Generate a single commit message for these files:
|
|
1166
1334
|
|
|
1167
1335
|
Files: ${files.join(", ")}
|
|
1168
1336
|
|
|
1169
1337
|
Diff:
|
|
1170
|
-
${
|
|
1338
|
+
${diffContent}`;
|
|
1171
1339
|
const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
|
|
1172
1340
|
return result?.trim() ?? null;
|
|
1173
1341
|
} catch {
|
|
@@ -1725,7 +1893,8 @@ ${pc6.bold("Changed files:")}`);
|
|
|
1725
1893
|
warn(`AI unavailable: ${copilotError}`);
|
|
1726
1894
|
warn("Falling back to manual commit message entry.");
|
|
1727
1895
|
} else {
|
|
1728
|
-
const
|
|
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);
|
|
1729
1898
|
commitMessage = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
|
|
1730
1899
|
if (commitMessage) {
|
|
1731
1900
|
spinner.success("AI commit message generated.");
|
|
@@ -1816,7 +1985,7 @@ ${pc6.bold("Changed files:")}`);
|
|
|
1816
1985
|
for (const f of changedFiles) {
|
|
1817
1986
|
console.log(` ${pc6.dim("•")} ${f}`);
|
|
1818
1987
|
}
|
|
1819
|
-
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...`);
|
|
1820
1989
|
const diffs = await getFullDiffForFiles(changedFiles);
|
|
1821
1990
|
if (!diffs.trim()) {
|
|
1822
1991
|
spinner.stop();
|
|
@@ -1994,8 +2163,8 @@ import pc7 from "picocolors";
|
|
|
1994
2163
|
// package.json
|
|
1995
2164
|
var package_default = {
|
|
1996
2165
|
name: "contribute-now",
|
|
1997
|
-
version: "0.
|
|
1998
|
-
description: "
|
|
2166
|
+
version: "0.4.0-dev.89d5552",
|
|
2167
|
+
description: "Developer CLI that automates git workflows — branching, syncing, committing, and PRs — with multi-workflow and commit convention support.",
|
|
1999
2168
|
type: "module",
|
|
2000
2169
|
bin: {
|
|
2001
2170
|
contrib: "dist/index.js",
|
|
@@ -2653,6 +2822,43 @@ function colorizeSubject(subject) {
|
|
|
2653
2822
|
// src/commands/setup.ts
|
|
2654
2823
|
import { defineCommand as defineCommand7 } from "citty";
|
|
2655
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
|
+
}
|
|
2656
2862
|
var setup_default = defineCommand7({
|
|
2657
2863
|
meta: {
|
|
2658
2864
|
name: "setup",
|
|
@@ -2664,6 +2870,20 @@ var setup_default = defineCommand7({
|
|
|
2664
2870
|
process.exit(1);
|
|
2665
2871
|
}
|
|
2666
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
|
+
}
|
|
2667
2887
|
const workflowChoice = await selectPrompt("Which git workflow does this project use?", [
|
|
2668
2888
|
"Clean Flow — main + dev, squash features into dev, merge dev into main (recommended)",
|
|
2669
2889
|
"GitHub Flow — main + feature branches, squash/merge into main",
|
|
@@ -2693,31 +2913,42 @@ var setup_default = defineCommand7({
|
|
|
2693
2913
|
info(`Found remotes: ${remotes.join(", ")}`);
|
|
2694
2914
|
let detectedRole = null;
|
|
2695
2915
|
let detectionSource = "";
|
|
2696
|
-
const
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2705
|
-
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
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
|
+
}
|
|
2709
2934
|
}
|
|
2710
2935
|
}
|
|
2711
2936
|
}
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
|
|
2715
|
-
|
|
2716
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
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
|
+
}
|
|
2720
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 = "";
|
|
2721
2952
|
}
|
|
2722
2953
|
if (detectedRole === null) {
|
|
2723
2954
|
const roleChoice = await selectPrompt("What is your role in this project?", [
|
|
@@ -2735,16 +2966,20 @@ var setup_default = defineCommand7({
|
|
|
2735
2966
|
}
|
|
2736
2967
|
}
|
|
2737
2968
|
const defaultConfig = getDefaultConfig();
|
|
2738
|
-
|
|
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);
|
|
2739
2972
|
let devBranch;
|
|
2740
2973
|
if (hasDevBranch(workflow)) {
|
|
2741
2974
|
const defaultDev = workflow === "git-flow" ? "develop" : "dev";
|
|
2742
|
-
devBranch = await inputPrompt(
|
|
2975
|
+
devBranch = await inputPrompt(`Dev/develop branch name (default: ${defaultDev} — press Enter to keep)`, defaultDev);
|
|
2743
2976
|
}
|
|
2744
|
-
const
|
|
2977
|
+
const originRemoteDefault = defaultConfig.origin;
|
|
2978
|
+
const originRemote = await inputPrompt(`Origin remote name (default: ${originRemoteDefault} — press Enter to keep)`, originRemoteDefault);
|
|
2745
2979
|
let upstreamRemote = defaultConfig.upstream;
|
|
2746
2980
|
if (detectedRole === "contributor") {
|
|
2747
|
-
|
|
2981
|
+
const upstreamRemoteDefault = defaultConfig.upstream;
|
|
2982
|
+
upstreamRemote = await inputPrompt(`Upstream remote name (default: ${upstreamRemoteDefault} — press Enter to keep)`, upstreamRemoteDefault);
|
|
2748
2983
|
if (!remotes.includes(upstreamRemote)) {
|
|
2749
2984
|
warn(`Remote "${upstreamRemote}" not found.`);
|
|
2750
2985
|
const originUrl = await getRemoteUrl(originRemote);
|
|
@@ -2792,9 +3027,8 @@ var setup_default = defineCommand7({
|
|
|
2792
3027
|
warn("Config was saved — verify the branch name and re-run setup if needed.");
|
|
2793
3028
|
}
|
|
2794
3029
|
}
|
|
2795
|
-
if (
|
|
2796
|
-
|
|
2797
|
-
warn(' echo ".contributerc.json" >> .gitignore');
|
|
3030
|
+
if (ensureGitignored()) {
|
|
3031
|
+
info("Added .contributerc.json to .gitignore to avoid committing personal config.");
|
|
2798
3032
|
}
|
|
2799
3033
|
console.log();
|
|
2800
3034
|
info(`Workflow: ${pc10.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
|
|
@@ -2808,6 +3042,17 @@ var setup_default = defineCommand7({
|
|
|
2808
3042
|
info(`Origin: ${pc10.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc10.bold(config.upstream)}` : ""}`);
|
|
2809
3043
|
}
|
|
2810
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
|
+
}
|
|
2811
3056
|
|
|
2812
3057
|
// src/commands/start.ts
|
|
2813
3058
|
import { defineCommand as defineCommand8 } from "citty";
|
|
@@ -2897,6 +3142,19 @@ var start_default = defineCommand8({
|
|
|
2897
3142
|
if (!await refExists(syncSource.ref)) {
|
|
2898
3143
|
warn(`Remote ref ${pc11.bold(syncSource.ref)} not found. Creating branch from local ${pc11.bold(baseBranch)}.`);
|
|
2899
3144
|
}
|
|
3145
|
+
const currentBranch = await getCurrentBranch();
|
|
3146
|
+
if (currentBranch === baseBranch && await refExists(syncSource.ref)) {
|
|
3147
|
+
const ahead = await countCommitsAhead(baseBranch, syncSource.ref);
|
|
3148
|
+
if (ahead > 0) {
|
|
3149
|
+
warn(`You are on ${pc11.bold(baseBranch)} with ${pc11.bold(String(ahead))} local commit${ahead > 1 ? "s" : ""} not in ${pc11.bold(syncSource.ref)}.`);
|
|
3150
|
+
info(" Syncing will discard those commits. Consider backing them up first (e.g. create a branch).");
|
|
3151
|
+
const proceed = await confirmPrompt("Discard local commits and sync to remote?");
|
|
3152
|
+
if (!proceed) {
|
|
3153
|
+
info("Aborted. Your local commits are untouched.");
|
|
3154
|
+
process.exit(0);
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
2900
3158
|
const updateResult = await updateLocalBranch(baseBranch, syncSource.ref);
|
|
2901
3159
|
if (updateResult.exitCode !== 0) {
|
|
2902
3160
|
if (await refExists(syncSource.ref)) {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "contribute-now",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0-dev.89d5552",
|
|
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",
|