contribute-now 0.1.2 → 0.2.0-dev.33be40f
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 +162 -133
- package/dist/index.js +1933 -584
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,11 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
|
-
import { defineCommand as
|
|
4
|
+
import { defineCommand as defineCommand11, runMain } from "citty";
|
|
5
5
|
|
|
6
6
|
// src/commands/clean.ts
|
|
7
7
|
import { defineCommand } from "citty";
|
|
8
|
-
import
|
|
8
|
+
import pc4 from "picocolors";
|
|
9
|
+
|
|
10
|
+
// src/utils/branch.ts
|
|
11
|
+
var DEFAULT_PREFIXES = ["feature", "fix", "docs", "chore", "test", "refactor"];
|
|
12
|
+
function hasPrefix(branchName, prefixes = DEFAULT_PREFIXES) {
|
|
13
|
+
return prefixes.some((p) => branchName.startsWith(`${p}/`));
|
|
14
|
+
}
|
|
15
|
+
function formatBranchName(prefix, name) {
|
|
16
|
+
const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
17
|
+
return `${prefix}/${sanitized}`;
|
|
18
|
+
}
|
|
19
|
+
function isValidBranchName(name) {
|
|
20
|
+
return /^[a-zA-Z0-9._/-]+$/.test(name) && !name.startsWith("/") && !name.endsWith("/");
|
|
21
|
+
}
|
|
22
|
+
function looksLikeNaturalLanguage(input) {
|
|
23
|
+
return input.includes(" ") && !input.includes("/");
|
|
24
|
+
}
|
|
9
25
|
|
|
10
26
|
// src/utils/config.ts
|
|
11
27
|
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
@@ -20,7 +36,11 @@ function readConfig(cwd = process.cwd()) {
|
|
|
20
36
|
return null;
|
|
21
37
|
try {
|
|
22
38
|
const raw = readFileSync(path, "utf-8");
|
|
23
|
-
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (typeof parsed !== "object" || parsed === null || typeof parsed.workflow !== "string" || typeof parsed.role !== "string" || typeof parsed.mainBranch !== "string" || typeof parsed.upstream !== "string" || typeof parsed.origin !== "string" || !Array.isArray(parsed.branchPrefixes) || typeof parsed.commitConvention !== "string") {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
return parsed;
|
|
24
44
|
} catch {
|
|
25
45
|
return null;
|
|
26
46
|
}
|
|
@@ -44,78 +64,515 @@ function isGitignored(cwd = process.cwd()) {
|
|
|
44
64
|
}
|
|
45
65
|
function getDefaultConfig() {
|
|
46
66
|
return {
|
|
67
|
+
workflow: "clean-flow",
|
|
47
68
|
role: "contributor",
|
|
48
69
|
mainBranch: "main",
|
|
49
70
|
devBranch: "dev",
|
|
50
71
|
upstream: "upstream",
|
|
51
72
|
origin: "origin",
|
|
52
|
-
branchPrefixes: ["feature", "fix", "docs", "chore", "test", "refactor"]
|
|
73
|
+
branchPrefixes: ["feature", "fix", "docs", "chore", "test", "refactor"],
|
|
74
|
+
commitConvention: "clean-commit"
|
|
53
75
|
};
|
|
54
76
|
}
|
|
55
77
|
|
|
56
78
|
// src/utils/confirm.ts
|
|
79
|
+
import * as clack from "@clack/prompts";
|
|
57
80
|
import pc from "picocolors";
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const response = await new Promise((resolve) => {
|
|
63
|
-
process.stdin.setEncoding("utf-8");
|
|
64
|
-
process.stdin.once("data", (data) => {
|
|
65
|
-
process.stdin.pause();
|
|
66
|
-
resolve(data.toString().trim());
|
|
67
|
-
});
|
|
68
|
-
process.stdin.resume();
|
|
69
|
-
});
|
|
70
|
-
if (response.toLowerCase() !== "y") {
|
|
71
|
-
console.log(pc.yellow("Aborted."));
|
|
72
|
-
return false;
|
|
81
|
+
function handleCancel(value) {
|
|
82
|
+
if (clack.isCancel(value)) {
|
|
83
|
+
clack.cancel("Cancelled.");
|
|
84
|
+
process.exit(0);
|
|
73
85
|
}
|
|
74
|
-
|
|
86
|
+
}
|
|
87
|
+
async function confirmPrompt(message) {
|
|
88
|
+
const result = await clack.confirm({ message });
|
|
89
|
+
handleCancel(result);
|
|
90
|
+
return result;
|
|
75
91
|
}
|
|
76
92
|
async function selectPrompt(message, choices) {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
console.log(` ${pc.dim(`${i + 1}.`)} ${choice}`);
|
|
93
|
+
const result = await clack.select({
|
|
94
|
+
message,
|
|
95
|
+
options: choices.map((choice) => ({ value: choice, label: choice }))
|
|
81
96
|
});
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
handleCancel(result);
|
|
98
|
+
return result;
|
|
99
|
+
}
|
|
100
|
+
async function inputPrompt(message, defaultValue) {
|
|
101
|
+
const result = await clack.text({
|
|
102
|
+
message,
|
|
103
|
+
placeholder: defaultValue,
|
|
104
|
+
defaultValue
|
|
105
|
+
});
|
|
106
|
+
handleCancel(result);
|
|
107
|
+
return result || defaultValue || "";
|
|
108
|
+
}
|
|
109
|
+
async function multiSelectPrompt(message, choices) {
|
|
110
|
+
const result = await clack.multiselect({
|
|
111
|
+
message: `${message} ${pc.dim("(space to toggle, enter to confirm)")}`,
|
|
112
|
+
options: choices.map((choice) => ({ value: choice, label: choice })),
|
|
113
|
+
required: false
|
|
90
114
|
});
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
115
|
+
handleCancel(result);
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// src/utils/copilot.ts
|
|
120
|
+
import { CopilotClient } from "@github/copilot-sdk";
|
|
121
|
+
var CONVENTIONAL_COMMIT_SYSTEM_PROMPT = `Git commit message generator. Format: <type>[!][(<scope>)]: <description>
|
|
122
|
+
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
|
123
|
+
Rules: breaking (!) only for feat/fix/refactor/perf; imperative mood; max 72 chars; lowercase start; scope optional camelCase/kebab-case. Return ONLY the message line.
|
|
124
|
+
Examples: feat: add user auth | fix(auth): resolve token expiry | feat!: redesign auth API`;
|
|
125
|
+
var CLEAN_COMMIT_SYSTEM_PROMPT = `Git commit message generator. EXACT format: <emoji> <type>[!][ (<scope>)]: <description>
|
|
126
|
+
Spacing: EMOJI SPACE TYPE [SPACE OPENPAREN SCOPE CLOSEPAREN] COLON SPACE DESCRIPTION
|
|
127
|
+
Types: \uD83D\uDCE6 new, \uD83D\uDD27 update, \uD83D\uDDD1️ remove, \uD83D\uDD12 security, ⚙️ setup, ☕ chore, \uD83E\uDDEA test, \uD83D\uDCD6 docs, \uD83D\uDE80 release
|
|
128
|
+
Rules: breaking (!) only for new/update/remove/security; imperative mood; max 72 chars; lowercase start; scope optional. Return ONLY the message line.
|
|
129
|
+
Correct: \uD83D\uDCE6 new: add user auth | \uD83D\uDD27 update (api): improve error handling | ⚙️ setup (ci): configure github actions
|
|
130
|
+
WRONG: ⚙️setup(ci): ... | \uD83D\uDD27 update(api): ... ← always space before scope parenthesis`;
|
|
131
|
+
function getGroupingSystemPrompt(convention) {
|
|
132
|
+
const conventionBlock = convention === "conventional" ? `Use Conventional Commit format: <type>[(<scope>)]: <description>
|
|
133
|
+
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert` : `Use Clean Commit format: <emoji> <type>[!][ (<scope>)]: <description>
|
|
134
|
+
Emoji/type table:
|
|
135
|
+
\uD83D\uDCE6 new, \uD83D\uDD27 update, \uD83D\uDDD1️ remove, \uD83D\uDD12 security, ⚙️ setup, ☕ chore, \uD83E\uDDEA test, \uD83D\uDCD6 docs, \uD83D\uDE80 release`;
|
|
136
|
+
return `You are a smart commit grouping assistant. Given a list of changed files and their diffs, group related changes into logical atomic commits.
|
|
137
|
+
|
|
138
|
+
${conventionBlock}
|
|
139
|
+
|
|
140
|
+
Return a JSON array of commit groups with this EXACT structure (no markdown fences, no explanation):
|
|
141
|
+
[
|
|
142
|
+
{
|
|
143
|
+
"files": ["path/to/file1.ts", "path/to/file2.ts"],
|
|
144
|
+
"message": "<commit message following the convention above>"
|
|
94
145
|
}
|
|
95
|
-
|
|
146
|
+
]
|
|
147
|
+
|
|
148
|
+
Rules:
|
|
149
|
+
- Group files that are logically related (e.g. a utility and its tests, a feature and its types)
|
|
150
|
+
- Each group should represent ONE logical change
|
|
151
|
+
- Every file must appear in exactly one group
|
|
152
|
+
- Commit messages must follow the convention, be concise, imperative, max 72 chars
|
|
153
|
+
- Order groups so foundational changes come first (types, utils) and consumers come after
|
|
154
|
+
- Return ONLY the JSON array, nothing else`;
|
|
96
155
|
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
156
|
+
var BRANCH_NAME_SYSTEM_PROMPT = `You are a git branch name generator. Your ONLY job is to output a single git branch name. NOTHING ELSE.
|
|
157
|
+
Output format: <prefix>/<kebab-case-name>
|
|
158
|
+
Valid prefixes: feature, fix, docs, chore, test, refactor
|
|
159
|
+
Rules: lowercase, kebab-case, 2-5 words after the prefix, no punctuation.
|
|
160
|
+
CRITICAL: Output ONLY the branch name on a single line. No explanation. No markdown. No questions. No other text.
|
|
161
|
+
Examples: fix/login-timeout | feature/user-profile-page | docs/update-readme | chore/update-pr-title`;
|
|
162
|
+
var PR_DESCRIPTION_SYSTEM_PROMPT_BASE = `GitHub PR description generator. Return JSON: {"title":"<72 chars>","body":"## Summary\\n...\\n\\n## Changes\\n- ...\\n\\n## Test Plan\\n..."}
|
|
163
|
+
IMPORTANT: The title must capture the overall theme or goal of the PR — NOT enumerate individual changes. Think: what problem does this PR solve or what capability does it add? Keep it focused and specific but high-level.`;
|
|
164
|
+
function getPRDescriptionSystemPrompt(convention) {
|
|
165
|
+
if (convention === "clean-commit") {
|
|
166
|
+
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
167
|
+
CRITICAL: The PR title MUST follow the Clean Commit format exactly: <emoji> <type>: <description>
|
|
168
|
+
Emoji/type table: \uD83D\uDCE6 new, \uD83D\uDD27 update, \uD83D\uDDD1️ remove, \uD83D\uDD12 security, ⚙️ setup, ☕ chore, \uD83E\uDDEA test, \uD83D\uDCD6 docs, \uD83D\uDE80 release
|
|
169
|
+
Title examples: \uD83D\uDCE6 new: add user authentication | \uD83D\uDD27 update: improve error handling | \uD83D\uDDD1️ remove: drop legacy API
|
|
170
|
+
Rules: title follows convention, present tense, max 72 chars, describes the PR theme not individual commits; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
|
|
171
|
+
}
|
|
172
|
+
if (convention === "conventional") {
|
|
173
|
+
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
174
|
+
CRITICAL: The PR title MUST follow Conventional Commits format: <type>[(<scope>)]: <description>
|
|
175
|
+
Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
|
176
|
+
Title examples: feat: add user authentication | fix(auth): resolve token expiry | docs: update contributing guide
|
|
177
|
+
Rules: title follows convention, present tense, max 72 chars, describes the PR theme not individual commits; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
|
|
178
|
+
}
|
|
179
|
+
return `${PR_DESCRIPTION_SYSTEM_PROMPT_BASE}
|
|
180
|
+
Rules: title concise present tense, describes the PR theme not individual commits; body has Summary, Changes (bullets), Test Plan sections. Return ONLY the JSON object, no fences.`;
|
|
181
|
+
}
|
|
182
|
+
var CONFLICT_RESOLUTION_SYSTEM_PROMPT = `Git merge conflict advisor. Explain each side, suggest resolution strategy. Never auto-resolve — guidance only. Be concise and actionable.`;
|
|
183
|
+
function suppressSubprocessWarnings() {
|
|
184
|
+
process.env.NODE_NO_WARNINGS = "1";
|
|
185
|
+
}
|
|
186
|
+
function withTimeout(promise, ms) {
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const timer = setTimeout(() => reject(new Error(`Copilot request timed out after ${ms / 1000}s`)), ms);
|
|
189
|
+
promise.then((val) => {
|
|
190
|
+
clearTimeout(timer);
|
|
191
|
+
resolve(val);
|
|
192
|
+
}, (err) => {
|
|
193
|
+
clearTimeout(timer);
|
|
194
|
+
reject(err);
|
|
106
195
|
});
|
|
107
|
-
process.stdin.resume();
|
|
108
196
|
});
|
|
109
|
-
return response || defaultValue || "";
|
|
110
197
|
}
|
|
198
|
+
var COPILOT_TIMEOUT_MS = 30000;
|
|
199
|
+
var COPILOT_LONG_TIMEOUT_MS = 90000;
|
|
200
|
+
async function checkCopilotAvailable() {
|
|
201
|
+
try {
|
|
202
|
+
const client = await getManagedClient();
|
|
203
|
+
try {
|
|
204
|
+
await client.ping();
|
|
205
|
+
} catch (err) {
|
|
206
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
207
|
+
if (msg.includes("auth") || msg.includes("token") || msg.includes("401") || msg.includes("403")) {
|
|
208
|
+
return "Copilot authentication failed. Run `gh auth login` to refresh your token.";
|
|
209
|
+
}
|
|
210
|
+
if (msg.includes("ECONNREFUSED") || msg.includes("timeout") || msg.includes("network")) {
|
|
211
|
+
return "Could not reach GitHub Copilot service. Check your internet connection.";
|
|
212
|
+
}
|
|
213
|
+
return `Copilot health check failed: ${msg}`;
|
|
214
|
+
}
|
|
215
|
+
return null;
|
|
216
|
+
} catch (err) {
|
|
217
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
218
|
+
if (msg.includes("ENOENT") || msg.includes("not found")) {
|
|
219
|
+
return "Copilot CLI binary not found. Ensure GitHub Copilot is installed and your gh CLI is up to date.";
|
|
220
|
+
}
|
|
221
|
+
return `Failed to start Copilot service: ${msg}`;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
var _managedClient = null;
|
|
225
|
+
var _clientStarted = false;
|
|
226
|
+
async function getManagedClient() {
|
|
227
|
+
if (!_managedClient || !_clientStarted) {
|
|
228
|
+
suppressSubprocessWarnings();
|
|
229
|
+
_managedClient = new CopilotClient;
|
|
230
|
+
await _managedClient.start();
|
|
231
|
+
_clientStarted = true;
|
|
232
|
+
const cleanup = () => {
|
|
233
|
+
if (_managedClient && _clientStarted) {
|
|
234
|
+
try {
|
|
235
|
+
_managedClient.stop();
|
|
236
|
+
} catch {}
|
|
237
|
+
_clientStarted = false;
|
|
238
|
+
_managedClient = null;
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
process.once("exit", cleanup);
|
|
242
|
+
process.once("SIGINT", cleanup);
|
|
243
|
+
process.once("SIGTERM", cleanup);
|
|
244
|
+
}
|
|
245
|
+
return _managedClient;
|
|
246
|
+
}
|
|
247
|
+
async function callCopilot(systemMessage, userMessage, model, timeoutMs = COPILOT_TIMEOUT_MS) {
|
|
248
|
+
const client = await getManagedClient();
|
|
249
|
+
const sessionConfig = {
|
|
250
|
+
systemMessage: { mode: "replace", content: systemMessage }
|
|
251
|
+
};
|
|
252
|
+
if (model)
|
|
253
|
+
sessionConfig.model = model;
|
|
254
|
+
const session = await client.createSession(sessionConfig);
|
|
255
|
+
try {
|
|
256
|
+
const response = await withTimeout(session.sendAndWait({ prompt: userMessage }), timeoutMs);
|
|
257
|
+
if (!response?.data?.content)
|
|
258
|
+
return null;
|
|
259
|
+
return response.data.content;
|
|
260
|
+
} finally {
|
|
261
|
+
await session.destroy();
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
function getCommitSystemPrompt(convention) {
|
|
265
|
+
if (convention === "conventional")
|
|
266
|
+
return CONVENTIONAL_COMMIT_SYSTEM_PROMPT;
|
|
267
|
+
return CLEAN_COMMIT_SYSTEM_PROMPT;
|
|
268
|
+
}
|
|
269
|
+
function extractJson(raw) {
|
|
270
|
+
let text2 = raw.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
|
|
271
|
+
if (text2.startsWith("[") || text2.startsWith("{"))
|
|
272
|
+
return text2;
|
|
273
|
+
const arrayStart = text2.indexOf("[");
|
|
274
|
+
const objStart = text2.indexOf("{");
|
|
275
|
+
let start;
|
|
276
|
+
let closeChar;
|
|
277
|
+
if (arrayStart === -1 && objStart === -1)
|
|
278
|
+
return text2;
|
|
279
|
+
if (arrayStart === -1) {
|
|
280
|
+
start = objStart;
|
|
281
|
+
closeChar = "}";
|
|
282
|
+
} else if (objStart === -1) {
|
|
283
|
+
start = arrayStart;
|
|
284
|
+
closeChar = "]";
|
|
285
|
+
} else if (arrayStart < objStart) {
|
|
286
|
+
start = arrayStart;
|
|
287
|
+
closeChar = "]";
|
|
288
|
+
} else {
|
|
289
|
+
start = objStart;
|
|
290
|
+
closeChar = "}";
|
|
291
|
+
}
|
|
292
|
+
const end = text2.lastIndexOf(closeChar);
|
|
293
|
+
if (end > start) {
|
|
294
|
+
text2 = text2.slice(start, end + 1);
|
|
295
|
+
}
|
|
296
|
+
return text2;
|
|
297
|
+
}
|
|
298
|
+
async function generateCommitMessage(diff, stagedFiles, model, convention = "clean-commit") {
|
|
299
|
+
try {
|
|
300
|
+
const multiFileHint = stagedFiles.length > 1 ? `
|
|
111
301
|
|
|
112
|
-
|
|
302
|
+
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.` : "";
|
|
303
|
+
const userMessage = `Generate a commit message for these staged changes:
|
|
304
|
+
|
|
305
|
+
Files: ${stagedFiles.join(", ")}
|
|
306
|
+
|
|
307
|
+
Diff:
|
|
308
|
+
${diff.slice(0, 4000)}${multiFileHint}`;
|
|
309
|
+
const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
|
|
310
|
+
return result?.trim() ?? null;
|
|
311
|
+
} catch {
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
async function generatePRDescription(commits, diff, model, convention = "clean-commit") {
|
|
316
|
+
try {
|
|
317
|
+
const userMessage = `Generate a PR description for these changes:
|
|
318
|
+
|
|
319
|
+
Commits:
|
|
320
|
+
${commits.join(`
|
|
321
|
+
`)}
|
|
322
|
+
|
|
323
|
+
Diff (truncated):
|
|
324
|
+
${diff.slice(0, 4000)}`;
|
|
325
|
+
const result = await callCopilot(getPRDescriptionSystemPrompt(convention), userMessage, model);
|
|
326
|
+
if (!result)
|
|
327
|
+
return null;
|
|
328
|
+
const cleaned = extractJson(result);
|
|
329
|
+
return JSON.parse(cleaned);
|
|
330
|
+
} catch {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
async function suggestBranchName(description, model) {
|
|
335
|
+
try {
|
|
336
|
+
const result = await callCopilot(BRANCH_NAME_SYSTEM_PROMPT, description, model);
|
|
337
|
+
const trimmed = result?.trim() ?? null;
|
|
338
|
+
if (trimmed && /^[a-z]+\/[a-z0-9-]+$/.test(trimmed)) {
|
|
339
|
+
return trimmed;
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
} catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async function suggestConflictResolution(conflictDiff, model) {
|
|
347
|
+
try {
|
|
348
|
+
const userMessage = `Help me resolve this merge conflict:
|
|
349
|
+
|
|
350
|
+
${conflictDiff.slice(0, 4000)}`;
|
|
351
|
+
const result = await callCopilot(CONFLICT_RESOLUTION_SYSTEM_PROMPT, userMessage, model);
|
|
352
|
+
return result?.trim() ?? null;
|
|
353
|
+
} catch {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
async function generateCommitGroups(files, diffs, model, convention = "clean-commit") {
|
|
358
|
+
const userMessage = `Group these changed files into logical atomic commits:
|
|
359
|
+
|
|
360
|
+
Files:
|
|
361
|
+
${files.join(`
|
|
362
|
+
`)}
|
|
363
|
+
|
|
364
|
+
Diffs (truncated):
|
|
365
|
+
${diffs.slice(0, 6000)}`;
|
|
366
|
+
const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
|
|
367
|
+
if (!result) {
|
|
368
|
+
throw new Error("AI returned an empty response");
|
|
369
|
+
}
|
|
370
|
+
const cleaned = extractJson(result);
|
|
371
|
+
let parsed;
|
|
372
|
+
try {
|
|
373
|
+
parsed = JSON.parse(cleaned);
|
|
374
|
+
} catch {
|
|
375
|
+
throw new Error(`AI response is not valid JSON. Raw start: "${result.slice(0, 120)}..."`);
|
|
376
|
+
}
|
|
377
|
+
const groups = parsed;
|
|
378
|
+
if (!Array.isArray(groups) || groups.length === 0) {
|
|
379
|
+
throw new Error("AI response was not a valid JSON array of commit groups");
|
|
380
|
+
}
|
|
381
|
+
for (const group of groups) {
|
|
382
|
+
if (!Array.isArray(group.files) || typeof group.message !== "string") {
|
|
383
|
+
throw new Error("AI returned groups with invalid structure (missing files or message)");
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return groups;
|
|
387
|
+
}
|
|
388
|
+
async function regenerateAllGroupMessages(groups, diffs, model, convention = "clean-commit") {
|
|
389
|
+
const groupSummary = groups.map((g, i) => `Group ${i + 1}: [${g.files.join(", ")}]`).join(`
|
|
390
|
+
`);
|
|
391
|
+
const userMessage = `Regenerate ONLY the commit messages for these pre-defined file groups. Do NOT change the file groupings.
|
|
392
|
+
|
|
393
|
+
Groups:
|
|
394
|
+
${groupSummary}
|
|
395
|
+
|
|
396
|
+
Diffs (truncated):
|
|
397
|
+
${diffs.slice(0, 6000)}`;
|
|
398
|
+
const result = await callCopilot(getGroupingSystemPrompt(convention), userMessage, model, COPILOT_LONG_TIMEOUT_MS);
|
|
399
|
+
if (!result)
|
|
400
|
+
return groups;
|
|
401
|
+
try {
|
|
402
|
+
const cleaned = extractJson(result);
|
|
403
|
+
const parsed = JSON.parse(cleaned);
|
|
404
|
+
if (!Array.isArray(parsed) || parsed.length !== groups.length)
|
|
405
|
+
return groups;
|
|
406
|
+
return groups.map((g, i) => ({
|
|
407
|
+
files: g.files,
|
|
408
|
+
message: typeof parsed[i]?.message === "string" ? parsed[i].message : g.message
|
|
409
|
+
}));
|
|
410
|
+
} catch {
|
|
411
|
+
return groups;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
async function regenerateGroupMessage(files, diffs, model, convention = "clean-commit") {
|
|
415
|
+
try {
|
|
416
|
+
const userMessage = `Generate a single commit message for these files:
|
|
417
|
+
|
|
418
|
+
Files: ${files.join(", ")}
|
|
419
|
+
|
|
420
|
+
Diff:
|
|
421
|
+
${diffs.slice(0, 4000)}`;
|
|
422
|
+
const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
|
|
423
|
+
return result?.trim() ?? null;
|
|
424
|
+
} catch {
|
|
425
|
+
return null;
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// src/utils/gh.ts
|
|
113
430
|
import { execFile as execFileCb } from "node:child_process";
|
|
114
431
|
function run(args) {
|
|
115
432
|
return new Promise((resolve) => {
|
|
116
|
-
execFileCb("
|
|
433
|
+
execFileCb("gh", args, (error, stdout, stderr) => {
|
|
117
434
|
resolve({
|
|
118
|
-
exitCode: error ? error.code === "ENOENT" ? 127 : error.
|
|
435
|
+
exitCode: error ? error.code === "ENOENT" ? 127 : error.status ?? 1 : 0,
|
|
436
|
+
stdout: stdout ?? "",
|
|
437
|
+
stderr: stderr ?? ""
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
async function checkGhInstalled() {
|
|
443
|
+
try {
|
|
444
|
+
const { exitCode } = await run(["--version"]);
|
|
445
|
+
return exitCode === 0;
|
|
446
|
+
} catch {
|
|
447
|
+
return false;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
async function checkGhAuth() {
|
|
451
|
+
try {
|
|
452
|
+
const { exitCode } = await run(["auth", "status"]);
|
|
453
|
+
return exitCode === 0;
|
|
454
|
+
} catch {
|
|
455
|
+
return false;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
var SAFE_SLUG = /^[\w.-]+$/;
|
|
459
|
+
async function checkRepoPermissions(owner, repo) {
|
|
460
|
+
if (!SAFE_SLUG.test(owner) || !SAFE_SLUG.test(repo))
|
|
461
|
+
return null;
|
|
462
|
+
const { exitCode, stdout } = await run(["api", `repos/${owner}/${repo}`, "--jq", ".permissions"]);
|
|
463
|
+
if (exitCode !== 0)
|
|
464
|
+
return null;
|
|
465
|
+
try {
|
|
466
|
+
return JSON.parse(stdout.trim());
|
|
467
|
+
} catch {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
async function isRepoFork() {
|
|
472
|
+
const { exitCode, stdout } = await run(["repo", "view", "--json", "isFork", "-q", ".isFork"]);
|
|
473
|
+
if (exitCode !== 0)
|
|
474
|
+
return null;
|
|
475
|
+
const val = stdout.trim();
|
|
476
|
+
if (val === "true")
|
|
477
|
+
return true;
|
|
478
|
+
if (val === "false")
|
|
479
|
+
return false;
|
|
480
|
+
return null;
|
|
481
|
+
}
|
|
482
|
+
async function getCurrentRepoInfo() {
|
|
483
|
+
const { exitCode, stdout } = await run([
|
|
484
|
+
"repo",
|
|
485
|
+
"view",
|
|
486
|
+
"--json",
|
|
487
|
+
"nameWithOwner",
|
|
488
|
+
"-q",
|
|
489
|
+
".nameWithOwner"
|
|
490
|
+
]);
|
|
491
|
+
if (exitCode !== 0)
|
|
492
|
+
return null;
|
|
493
|
+
const nameWithOwner = stdout.trim();
|
|
494
|
+
if (!nameWithOwner)
|
|
495
|
+
return null;
|
|
496
|
+
const [owner, repo] = nameWithOwner.split("/");
|
|
497
|
+
if (!owner || !repo)
|
|
498
|
+
return null;
|
|
499
|
+
return { owner, repo };
|
|
500
|
+
}
|
|
501
|
+
async function createPR(options) {
|
|
502
|
+
const args = [
|
|
503
|
+
"pr",
|
|
504
|
+
"create",
|
|
505
|
+
"--base",
|
|
506
|
+
options.base,
|
|
507
|
+
"--title",
|
|
508
|
+
options.title,
|
|
509
|
+
"--body",
|
|
510
|
+
options.body
|
|
511
|
+
];
|
|
512
|
+
if (options.draft)
|
|
513
|
+
args.push("--draft");
|
|
514
|
+
return run(args);
|
|
515
|
+
}
|
|
516
|
+
async function createPRFill(base, draft) {
|
|
517
|
+
const args = ["pr", "create", "--base", base, "--fill"];
|
|
518
|
+
if (draft)
|
|
519
|
+
args.push("--draft");
|
|
520
|
+
return run(args);
|
|
521
|
+
}
|
|
522
|
+
async function getPRForBranch(headBranch) {
|
|
523
|
+
const { exitCode, stdout } = await run([
|
|
524
|
+
"pr",
|
|
525
|
+
"list",
|
|
526
|
+
"--head",
|
|
527
|
+
headBranch,
|
|
528
|
+
"--state",
|
|
529
|
+
"open",
|
|
530
|
+
"--json",
|
|
531
|
+
"number,url,title,state",
|
|
532
|
+
"--limit",
|
|
533
|
+
"1"
|
|
534
|
+
]);
|
|
535
|
+
if (exitCode !== 0)
|
|
536
|
+
return null;
|
|
537
|
+
try {
|
|
538
|
+
const prs = JSON.parse(stdout.trim());
|
|
539
|
+
return prs.length > 0 ? prs[0] : null;
|
|
540
|
+
} catch {
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
async function getMergedPRForBranch(headBranch) {
|
|
545
|
+
const { exitCode, stdout } = await run([
|
|
546
|
+
"pr",
|
|
547
|
+
"list",
|
|
548
|
+
"--head",
|
|
549
|
+
headBranch,
|
|
550
|
+
"--state",
|
|
551
|
+
"merged",
|
|
552
|
+
"--json",
|
|
553
|
+
"number,url,title,state",
|
|
554
|
+
"--limit",
|
|
555
|
+
"1"
|
|
556
|
+
]);
|
|
557
|
+
if (exitCode !== 0)
|
|
558
|
+
return null;
|
|
559
|
+
try {
|
|
560
|
+
const prs = JSON.parse(stdout.trim());
|
|
561
|
+
return prs.length > 0 ? prs[0] : null;
|
|
562
|
+
} catch {
|
|
563
|
+
return null;
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// src/utils/git.ts
|
|
568
|
+
import { execFile as execFileCb2 } from "node:child_process";
|
|
569
|
+
import { readFileSync as readFileSync2 } from "node:fs";
|
|
570
|
+
import { join as join2 } from "node:path";
|
|
571
|
+
function run2(args) {
|
|
572
|
+
return new Promise((resolve) => {
|
|
573
|
+
execFileCb2("git", args, (error, stdout, stderr) => {
|
|
574
|
+
resolve({
|
|
575
|
+
exitCode: error ? error.code === "ENOENT" ? 127 : error.status ?? 1 : 0,
|
|
119
576
|
stdout: stdout ?? "",
|
|
120
577
|
stderr: stderr ?? ""
|
|
121
578
|
});
|
|
@@ -123,79 +580,100 @@ function run(args) {
|
|
|
123
580
|
});
|
|
124
581
|
}
|
|
125
582
|
async function isGitRepo() {
|
|
126
|
-
const { exitCode } = await
|
|
583
|
+
const { exitCode } = await run2(["rev-parse", "--is-inside-work-tree"]);
|
|
127
584
|
return exitCode === 0;
|
|
128
585
|
}
|
|
129
586
|
async function getCurrentBranch() {
|
|
130
|
-
const { exitCode, stdout } = await
|
|
587
|
+
const { exitCode, stdout } = await run2(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
131
588
|
if (exitCode !== 0)
|
|
132
589
|
return null;
|
|
133
590
|
return stdout.trim() || null;
|
|
134
591
|
}
|
|
135
592
|
async function getRemotes() {
|
|
136
|
-
const { exitCode, stdout } = await
|
|
593
|
+
const { exitCode, stdout } = await run2(["remote"]);
|
|
137
594
|
if (exitCode !== 0)
|
|
138
595
|
return [];
|
|
139
596
|
return stdout.trim().split(`
|
|
140
597
|
`).map((r) => r.trim()).filter(Boolean);
|
|
141
598
|
}
|
|
142
599
|
async function getRemoteUrl(remote) {
|
|
143
|
-
const { exitCode, stdout } = await
|
|
600
|
+
const { exitCode, stdout } = await run2(["remote", "get-url", remote]);
|
|
144
601
|
if (exitCode !== 0)
|
|
145
602
|
return null;
|
|
146
603
|
return stdout.trim() || null;
|
|
147
604
|
}
|
|
148
605
|
async function hasUncommittedChanges() {
|
|
149
|
-
const { exitCode, stdout } = await
|
|
606
|
+
const { exitCode, stdout } = await run2(["status", "--porcelain"]);
|
|
150
607
|
if (exitCode !== 0)
|
|
151
608
|
return false;
|
|
152
609
|
return stdout.trim().length > 0;
|
|
153
610
|
}
|
|
154
611
|
async function fetchRemote(remote) {
|
|
155
|
-
return
|
|
612
|
+
return run2(["fetch", remote]);
|
|
156
613
|
}
|
|
157
614
|
async function fetchAll() {
|
|
158
|
-
return
|
|
615
|
+
return run2(["fetch", "--all", "--quiet"]);
|
|
159
616
|
}
|
|
160
|
-
async function
|
|
161
|
-
return
|
|
617
|
+
async function checkoutBranch2(branch) {
|
|
618
|
+
return run2(["checkout", branch]);
|
|
162
619
|
}
|
|
163
620
|
async function createBranch(branch, from) {
|
|
164
621
|
const args = from ? ["checkout", "-b", branch, from] : ["checkout", "-b", branch];
|
|
165
|
-
return
|
|
622
|
+
return run2(args);
|
|
166
623
|
}
|
|
167
624
|
async function resetHard(ref) {
|
|
168
|
-
return
|
|
625
|
+
return run2(["reset", "--hard", ref]);
|
|
169
626
|
}
|
|
170
|
-
async function
|
|
171
|
-
|
|
627
|
+
async function updateLocalBranch(branch, target) {
|
|
628
|
+
const current = await getCurrentBranch();
|
|
629
|
+
if (current === branch) {
|
|
630
|
+
return resetHard(target);
|
|
631
|
+
}
|
|
632
|
+
return run2(["branch", "-f", branch, target]);
|
|
172
633
|
}
|
|
173
634
|
async function pushSetUpstream(remote, branch) {
|
|
174
|
-
return
|
|
635
|
+
return run2(["push", "-u", remote, branch]);
|
|
175
636
|
}
|
|
176
637
|
async function rebase(branch) {
|
|
177
|
-
return
|
|
638
|
+
return run2(["rebase", branch]);
|
|
639
|
+
}
|
|
640
|
+
async function getUpstreamRef() {
|
|
641
|
+
const { exitCode, stdout } = await run2(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
642
|
+
if (exitCode !== 0)
|
|
643
|
+
return null;
|
|
644
|
+
return stdout.trim() || null;
|
|
645
|
+
}
|
|
646
|
+
async function rebaseOnto(newBase, oldBase) {
|
|
647
|
+
return run2(["rebase", "--onto", newBase, oldBase]);
|
|
178
648
|
}
|
|
179
649
|
async function getStagedDiff() {
|
|
180
|
-
const { stdout } = await
|
|
650
|
+
const { stdout } = await run2(["diff", "--cached"]);
|
|
181
651
|
return stdout;
|
|
182
652
|
}
|
|
183
653
|
async function getStagedFiles() {
|
|
184
|
-
const { exitCode, stdout } = await
|
|
654
|
+
const { exitCode, stdout } = await run2(["diff", "--cached", "--name-only"]);
|
|
185
655
|
if (exitCode !== 0)
|
|
186
656
|
return [];
|
|
187
657
|
return stdout.trim().split(`
|
|
188
658
|
`).filter(Boolean);
|
|
189
659
|
}
|
|
190
660
|
async function getChangedFiles() {
|
|
191
|
-
const { exitCode, stdout } = await
|
|
661
|
+
const { exitCode, stdout } = await run2(["status", "--porcelain"]);
|
|
192
662
|
if (exitCode !== 0)
|
|
193
663
|
return [];
|
|
194
|
-
return stdout.
|
|
195
|
-
`).filter(Boolean).map((l) =>
|
|
664
|
+
return stdout.trimEnd().split(`
|
|
665
|
+
`).filter(Boolean).map((l) => {
|
|
666
|
+
const line = l.replace(/\r$/, "");
|
|
667
|
+
const match = line.match(/^..\s+(.*)/);
|
|
668
|
+
if (!match)
|
|
669
|
+
return "";
|
|
670
|
+
const file = match[1];
|
|
671
|
+
const renameIdx = file.indexOf(" -> ");
|
|
672
|
+
return renameIdx !== -1 ? file.slice(renameIdx + 4) : file;
|
|
673
|
+
}).filter(Boolean);
|
|
196
674
|
}
|
|
197
675
|
async function getDivergence(branch, base) {
|
|
198
|
-
const { exitCode, stdout } = await
|
|
676
|
+
const { exitCode, stdout } = await run2([
|
|
199
677
|
"rev-list",
|
|
200
678
|
"--left-right",
|
|
201
679
|
"--count",
|
|
@@ -210,32 +688,143 @@ async function getDivergence(branch, base) {
|
|
|
210
688
|
};
|
|
211
689
|
}
|
|
212
690
|
async function getMergedBranches(base) {
|
|
213
|
-
const { exitCode, stdout } = await
|
|
691
|
+
const { exitCode, stdout } = await run2(["branch", "--merged", base]);
|
|
214
692
|
if (exitCode !== 0)
|
|
215
693
|
return [];
|
|
216
694
|
return stdout.trim().split(`
|
|
217
695
|
`).map((b) => b.replace(/^\*?\s+/, "").trim()).filter(Boolean);
|
|
218
696
|
}
|
|
697
|
+
async function getGoneBranches() {
|
|
698
|
+
const { exitCode, stdout } = await run2(["branch", "-vv"]);
|
|
699
|
+
if (exitCode !== 0)
|
|
700
|
+
return [];
|
|
701
|
+
return stdout.trimEnd().split(`
|
|
702
|
+
`).filter((line) => line.includes(": gone]")).map((line) => line.replace(/^\*?\s+/, "").split(/\s+/)[0]).filter(Boolean);
|
|
703
|
+
}
|
|
219
704
|
async function deleteBranch(branch) {
|
|
220
|
-
return
|
|
705
|
+
return run2(["branch", "-d", branch]);
|
|
706
|
+
}
|
|
707
|
+
async function forceDeleteBranch(branch) {
|
|
708
|
+
return run2(["branch", "-D", branch]);
|
|
709
|
+
}
|
|
710
|
+
async function renameBranch(oldName, newName) {
|
|
711
|
+
return run2(["branch", "-m", oldName, newName]);
|
|
712
|
+
}
|
|
713
|
+
async function hasLocalWork(remote, branch) {
|
|
714
|
+
const uncommitted = await hasUncommittedChanges();
|
|
715
|
+
const trackingRef = `${remote}/${branch}`;
|
|
716
|
+
const { exitCode, stdout } = await run2(["rev-list", "--count", `${trackingRef}..${branch}`]);
|
|
717
|
+
const unpushedCommits = exitCode === 0 ? Number.parseInt(stdout.trim(), 10) || 0 : 0;
|
|
718
|
+
return { uncommitted, unpushedCommits };
|
|
719
|
+
}
|
|
720
|
+
async function deleteRemoteBranch(remote, branch) {
|
|
721
|
+
return run2(["push", remote, "--delete", branch]);
|
|
722
|
+
}
|
|
723
|
+
async function mergeSquash(branch) {
|
|
724
|
+
return run2(["merge", "--squash", branch]);
|
|
725
|
+
}
|
|
726
|
+
async function pushBranch(remote, branch) {
|
|
727
|
+
return run2(["push", remote, branch]);
|
|
221
728
|
}
|
|
222
729
|
async function pruneRemote(remote) {
|
|
223
|
-
return
|
|
730
|
+
return run2(["remote", "prune", remote]);
|
|
224
731
|
}
|
|
225
732
|
async function commitWithMessage(message) {
|
|
226
|
-
return
|
|
733
|
+
return run2(["commit", "-m", message]);
|
|
227
734
|
}
|
|
228
735
|
async function getLogDiff(base, head) {
|
|
229
|
-
const { stdout } = await
|
|
736
|
+
const { stdout } = await run2(["diff", `${base}...${head}`]);
|
|
230
737
|
return stdout;
|
|
231
738
|
}
|
|
232
739
|
async function getLog(base, head) {
|
|
233
|
-
const { exitCode, stdout } = await
|
|
740
|
+
const { exitCode, stdout } = await run2(["log", `${base}..${head}`, "--oneline"]);
|
|
234
741
|
if (exitCode !== 0)
|
|
235
742
|
return [];
|
|
236
743
|
return stdout.trim().split(`
|
|
237
744
|
`).filter(Boolean);
|
|
238
745
|
}
|
|
746
|
+
async function pullBranch(remote, branch) {
|
|
747
|
+
return run2(["pull", remote, branch]);
|
|
748
|
+
}
|
|
749
|
+
async function stageFiles(files) {
|
|
750
|
+
return run2(["add", "--", ...files]);
|
|
751
|
+
}
|
|
752
|
+
async function unstageFiles(files) {
|
|
753
|
+
return run2(["reset", "HEAD", "--", ...files]);
|
|
754
|
+
}
|
|
755
|
+
async function stageAll() {
|
|
756
|
+
return run2(["add", "-A"]);
|
|
757
|
+
}
|
|
758
|
+
async function getFullDiffForFiles(files) {
|
|
759
|
+
const [unstaged, staged, untracked] = await Promise.all([
|
|
760
|
+
run2(["diff", "--", ...files]),
|
|
761
|
+
run2(["diff", "--cached", "--", ...files]),
|
|
762
|
+
getUntrackedFiles()
|
|
763
|
+
]);
|
|
764
|
+
const parts = [staged.stdout, unstaged.stdout].filter(Boolean);
|
|
765
|
+
const untrackedSet = new Set(untracked);
|
|
766
|
+
const MAX_FILE_CONTENT = 2000;
|
|
767
|
+
for (const file of files) {
|
|
768
|
+
if (untrackedSet.has(file)) {
|
|
769
|
+
try {
|
|
770
|
+
const content = readFileSync2(join2(process.cwd(), file), "utf-8");
|
|
771
|
+
const truncated = content.length > MAX_FILE_CONTENT ? `${content.slice(0, MAX_FILE_CONTENT)}
|
|
772
|
+
... (truncated)` : content;
|
|
773
|
+
const lines = truncated.split(`
|
|
774
|
+
`).map((l) => `+${l}`);
|
|
775
|
+
parts.push(`diff --git a/${file} b/${file}
|
|
776
|
+
new file
|
|
777
|
+
--- /dev/null
|
|
778
|
+
+++ b/${file}
|
|
779
|
+
${lines.join(`
|
|
780
|
+
`)}`);
|
|
781
|
+
} catch {}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return parts.join(`
|
|
785
|
+
`);
|
|
786
|
+
}
|
|
787
|
+
async function getUntrackedFiles() {
|
|
788
|
+
const { exitCode, stdout } = await run2(["ls-files", "--others", "--exclude-standard"]);
|
|
789
|
+
if (exitCode !== 0)
|
|
790
|
+
return [];
|
|
791
|
+
return stdout.trim().split(`
|
|
792
|
+
`).filter(Boolean);
|
|
793
|
+
}
|
|
794
|
+
async function getFileStatus() {
|
|
795
|
+
const { exitCode, stdout } = await run2(["status", "--porcelain"]);
|
|
796
|
+
if (exitCode !== 0)
|
|
797
|
+
return { staged: [], modified: [], untracked: [] };
|
|
798
|
+
const result = { staged: [], modified: [], untracked: [] };
|
|
799
|
+
const STATUS_LABELS = {
|
|
800
|
+
A: "new file",
|
|
801
|
+
M: "modified",
|
|
802
|
+
D: "deleted",
|
|
803
|
+
R: "renamed",
|
|
804
|
+
C: "copied",
|
|
805
|
+
T: "type changed"
|
|
806
|
+
};
|
|
807
|
+
for (const raw of stdout.trimEnd().split(`
|
|
808
|
+
`).filter(Boolean)) {
|
|
809
|
+
const line = raw.replace(/\r$/, "");
|
|
810
|
+
const indexStatus = line[0];
|
|
811
|
+
const workTreeStatus = line[1];
|
|
812
|
+
const pathPart = line.slice(3);
|
|
813
|
+
const renameIdx = pathPart.indexOf(" -> ");
|
|
814
|
+
const file = renameIdx !== -1 ? pathPart.slice(renameIdx + 4) : pathPart;
|
|
815
|
+
if (indexStatus === "?" && workTreeStatus === "?") {
|
|
816
|
+
result.untracked.push(file);
|
|
817
|
+
continue;
|
|
818
|
+
}
|
|
819
|
+
if (indexStatus && indexStatus !== " " && indexStatus !== "?") {
|
|
820
|
+
result.staged.push({ file, status: STATUS_LABELS[indexStatus] ?? indexStatus });
|
|
821
|
+
}
|
|
822
|
+
if (workTreeStatus && workTreeStatus !== " " && workTreeStatus !== "?") {
|
|
823
|
+
result.modified.push({ file, status: STATUS_LABELS[workTreeStatus] ?? workTreeStatus });
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
return result;
|
|
827
|
+
}
|
|
239
828
|
|
|
240
829
|
// src/utils/logger.ts
|
|
241
830
|
import { LogEngine, LogMode } from "@wgtechlabs/log-engine";
|
|
@@ -260,12 +849,191 @@ function warn(msg) {
|
|
|
260
849
|
function info(msg) {
|
|
261
850
|
LogEngine.info(msg);
|
|
262
851
|
}
|
|
263
|
-
function heading(msg) {
|
|
264
|
-
console.log(`
|
|
265
|
-
${pc2.bold(msg)}`);
|
|
852
|
+
function heading(msg) {
|
|
853
|
+
console.log(`
|
|
854
|
+
${pc2.bold(msg)}`);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// src/utils/spinner.ts
|
|
858
|
+
import pc3 from "picocolors";
|
|
859
|
+
var FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
860
|
+
function createSpinner(text2) {
|
|
861
|
+
let frameIdx = 0;
|
|
862
|
+
let currentText = text2;
|
|
863
|
+
let stopped = false;
|
|
864
|
+
const clearLine = () => {
|
|
865
|
+
process.stderr.write("\r\x1B[K");
|
|
866
|
+
};
|
|
867
|
+
const render = () => {
|
|
868
|
+
if (stopped)
|
|
869
|
+
return;
|
|
870
|
+
const frame = pc3.cyan(FRAMES[frameIdx % FRAMES.length]);
|
|
871
|
+
clearLine();
|
|
872
|
+
process.stderr.write(`${frame} ${currentText}`);
|
|
873
|
+
frameIdx++;
|
|
874
|
+
};
|
|
875
|
+
const timer = setInterval(render, 80);
|
|
876
|
+
render();
|
|
877
|
+
const stop = () => {
|
|
878
|
+
if (stopped)
|
|
879
|
+
return;
|
|
880
|
+
stopped = true;
|
|
881
|
+
clearInterval(timer);
|
|
882
|
+
clearLine();
|
|
883
|
+
};
|
|
884
|
+
return {
|
|
885
|
+
update(newText) {
|
|
886
|
+
currentText = newText;
|
|
887
|
+
},
|
|
888
|
+
success(msg) {
|
|
889
|
+
stop();
|
|
890
|
+
process.stderr.write(`${pc3.green("✔")} ${msg}
|
|
891
|
+
`);
|
|
892
|
+
},
|
|
893
|
+
fail(msg) {
|
|
894
|
+
stop();
|
|
895
|
+
process.stderr.write(`${pc3.red("✖")} ${msg}
|
|
896
|
+
`);
|
|
897
|
+
},
|
|
898
|
+
stop() {
|
|
899
|
+
stop();
|
|
900
|
+
}
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// src/utils/workflow.ts
|
|
905
|
+
var WORKFLOW_DESCRIPTIONS = {
|
|
906
|
+
"clean-flow": "Clean Flow — main + dev, squash features into dev, merge dev into main",
|
|
907
|
+
"github-flow": "GitHub Flow — main + feature branches, squash/merge into main",
|
|
908
|
+
"git-flow": "Git Flow — main + develop + release + hotfix branches"
|
|
909
|
+
};
|
|
910
|
+
function getBaseBranch(config) {
|
|
911
|
+
switch (config.workflow) {
|
|
912
|
+
case "clean-flow":
|
|
913
|
+
case "git-flow":
|
|
914
|
+
return config.devBranch ?? "dev";
|
|
915
|
+
case "github-flow":
|
|
916
|
+
return config.mainBranch;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
function hasDevBranch(workflow) {
|
|
920
|
+
return workflow === "clean-flow" || workflow === "git-flow";
|
|
921
|
+
}
|
|
922
|
+
function getSyncSource(config) {
|
|
923
|
+
const { workflow, role, mainBranch, origin, upstream } = config;
|
|
924
|
+
const devBranch = config.devBranch ?? "dev";
|
|
925
|
+
switch (workflow) {
|
|
926
|
+
case "clean-flow":
|
|
927
|
+
if (role === "contributor") {
|
|
928
|
+
return { remote: upstream, ref: `${upstream}/${devBranch}`, strategy: "pull" };
|
|
929
|
+
}
|
|
930
|
+
return { remote: origin, ref: `${origin}/${devBranch}`, strategy: "pull" };
|
|
931
|
+
case "github-flow":
|
|
932
|
+
if (role === "contributor") {
|
|
933
|
+
return { remote: upstream, ref: `${upstream}/${mainBranch}`, strategy: "pull" };
|
|
934
|
+
}
|
|
935
|
+
return { remote: origin, ref: `${origin}/${mainBranch}`, strategy: "pull" };
|
|
936
|
+
case "git-flow":
|
|
937
|
+
if (role === "contributor") {
|
|
938
|
+
return { remote: upstream, ref: `${upstream}/${devBranch}`, strategy: "pull" };
|
|
939
|
+
}
|
|
940
|
+
return { remote: origin, ref: `${origin}/${devBranch}`, strategy: "pull" };
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
function getProtectedBranches(config) {
|
|
944
|
+
const branches = [config.mainBranch];
|
|
945
|
+
if (hasDevBranch(config.workflow) && config.devBranch) {
|
|
946
|
+
branches.push(config.devBranch);
|
|
947
|
+
}
|
|
948
|
+
return branches;
|
|
266
949
|
}
|
|
267
950
|
|
|
268
951
|
// src/commands/clean.ts
|
|
952
|
+
async function handleCurrentBranchDeletion(currentBranch, baseBranch, config) {
|
|
953
|
+
if (!config)
|
|
954
|
+
return "skipped";
|
|
955
|
+
const { origin } = config;
|
|
956
|
+
const localWork = await hasLocalWork(origin, currentBranch);
|
|
957
|
+
const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
|
|
958
|
+
if (hasWork) {
|
|
959
|
+
if (localWork.uncommitted) {
|
|
960
|
+
warn("You have uncommitted changes in your working tree.");
|
|
961
|
+
}
|
|
962
|
+
if (localWork.unpushedCommits > 0) {
|
|
963
|
+
warn(`You have ${pc4.bold(String(localWork.unpushedCommits))} local commit${localWork.unpushedCommits !== 1 ? "s" : ""} not pushed.`);
|
|
964
|
+
}
|
|
965
|
+
const SAVE_NEW_BRANCH = "Save changes to a new branch";
|
|
966
|
+
const DISCARD = "Discard all changes and clean up";
|
|
967
|
+
const CANCEL = "Skip this branch";
|
|
968
|
+
const action = await selectPrompt(`${pc4.bold(currentBranch)} has local changes. What would you like to do?`, [SAVE_NEW_BRANCH, DISCARD, CANCEL]);
|
|
969
|
+
if (action === CANCEL)
|
|
970
|
+
return "skipped";
|
|
971
|
+
if (action === SAVE_NEW_BRANCH) {
|
|
972
|
+
if (!config)
|
|
973
|
+
return "skipped";
|
|
974
|
+
info(pc4.dim("Tip: Describe what you're working on in plain English and we'll generate a branch name."));
|
|
975
|
+
const description = await inputPrompt("What are you working on?");
|
|
976
|
+
let newBranchName = description;
|
|
977
|
+
if (looksLikeNaturalLanguage(description)) {
|
|
978
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
979
|
+
const suggested = await suggestBranchName(description);
|
|
980
|
+
if (suggested) {
|
|
981
|
+
spinner.success("Branch name suggestion ready.");
|
|
982
|
+
console.log(`
|
|
983
|
+
${pc4.dim("AI suggestion:")} ${pc4.bold(pc4.cyan(suggested))}`);
|
|
984
|
+
const accepted = await confirmPrompt(`Use ${pc4.bold(suggested)} as your branch name?`);
|
|
985
|
+
newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
|
|
986
|
+
} else {
|
|
987
|
+
spinner.fail("AI did not return a suggestion.");
|
|
988
|
+
newBranchName = await inputPrompt("Enter branch name", description);
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
if (!hasPrefix(newBranchName, config.branchPrefixes)) {
|
|
992
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc4.bold(newBranchName)}:`, config.branchPrefixes);
|
|
993
|
+
newBranchName = formatBranchName(prefix, newBranchName);
|
|
994
|
+
}
|
|
995
|
+
if (!isValidBranchName(newBranchName)) {
|
|
996
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
997
|
+
return "skipped";
|
|
998
|
+
}
|
|
999
|
+
const renameResult = await renameBranch(currentBranch, newBranchName);
|
|
1000
|
+
if (renameResult.exitCode !== 0) {
|
|
1001
|
+
error(`Failed to rename branch: ${renameResult.stderr}`);
|
|
1002
|
+
return "skipped";
|
|
1003
|
+
}
|
|
1004
|
+
success(`Renamed ${pc4.bold(currentBranch)} → ${pc4.bold(newBranchName)}`);
|
|
1005
|
+
const syncSource2 = getSyncSource(config);
|
|
1006
|
+
await fetchRemote(syncSource2.remote);
|
|
1007
|
+
const savedUpstreamRef = await getUpstreamRef();
|
|
1008
|
+
const rebaseResult = savedUpstreamRef && savedUpstreamRef !== syncSource2.ref ? await rebaseOnto(syncSource2.ref, savedUpstreamRef) : await rebase(syncSource2.ref);
|
|
1009
|
+
if (rebaseResult.exitCode !== 0) {
|
|
1010
|
+
warn("Rebase encountered conflicts. Resolve them after cleanup:");
|
|
1011
|
+
info(` ${pc4.bold(`git checkout ${newBranchName} && git rebase --continue`)}`);
|
|
1012
|
+
} else {
|
|
1013
|
+
success(`Rebased ${pc4.bold(newBranchName)} onto ${pc4.bold(syncSource2.ref)}.`);
|
|
1014
|
+
}
|
|
1015
|
+
const coResult2 = await checkoutBranch(baseBranch);
|
|
1016
|
+
if (coResult2.exitCode !== 0) {
|
|
1017
|
+
error(`Failed to checkout ${baseBranch}: ${coResult2.stderr}`);
|
|
1018
|
+
return "saved";
|
|
1019
|
+
}
|
|
1020
|
+
await updateLocalBranch(baseBranch, syncSource2.ref);
|
|
1021
|
+
success(`Synced ${pc4.bold(baseBranch)} with ${pc4.bold(syncSource2.ref)}.`);
|
|
1022
|
+
return "saved";
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
const syncSource = getSyncSource(config);
|
|
1026
|
+
info(`Switching to ${pc4.bold(baseBranch)} and syncing...`);
|
|
1027
|
+
await fetchRemote(syncSource.remote);
|
|
1028
|
+
const coResult = await checkoutBranch(baseBranch);
|
|
1029
|
+
if (coResult.exitCode !== 0) {
|
|
1030
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
1031
|
+
return "skipped";
|
|
1032
|
+
}
|
|
1033
|
+
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
1034
|
+
success(`Synced ${pc4.bold(baseBranch)} with ${pc4.bold(syncSource.ref)}.`);
|
|
1035
|
+
return "switched";
|
|
1036
|
+
}
|
|
269
1037
|
var clean_default = defineCommand({
|
|
270
1038
|
meta: {
|
|
271
1039
|
name: "clean",
|
|
@@ -289,223 +1057,165 @@ var clean_default = defineCommand({
|
|
|
289
1057
|
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
290
1058
|
process.exit(1);
|
|
291
1059
|
}
|
|
292
|
-
const {
|
|
293
|
-
const
|
|
1060
|
+
const { origin } = config;
|
|
1061
|
+
const baseBranch = getBaseBranch(config);
|
|
1062
|
+
let currentBranch = await getCurrentBranch();
|
|
294
1063
|
heading("\uD83E\uDDF9 contrib clean");
|
|
295
|
-
|
|
296
|
-
const
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
info("No merged branches to clean up.");
|
|
1064
|
+
info(`Pruning ${origin} remote refs...`);
|
|
1065
|
+
const pruneResult = await pruneRemote(origin);
|
|
1066
|
+
if (pruneResult.exitCode === 0) {
|
|
1067
|
+
success(`Pruned ${origin} remote refs.`);
|
|
300
1068
|
} else {
|
|
1069
|
+
warn(`Could not prune remote: ${pruneResult.stderr.trim()}`);
|
|
1070
|
+
}
|
|
1071
|
+
const protectedBranches = new Set(getProtectedBranches(config));
|
|
1072
|
+
const mergedBranches = await getMergedBranches(baseBranch);
|
|
1073
|
+
const mergedCandidates = mergedBranches.filter((b) => !protectedBranches.has(b));
|
|
1074
|
+
const goneBranches = await getGoneBranches();
|
|
1075
|
+
const goneCandidates = goneBranches.filter((b) => !protectedBranches.has(b) && !mergedCandidates.includes(b));
|
|
1076
|
+
if (currentBranch && !protectedBranches.has(currentBranch) && !mergedCandidates.includes(currentBranch) && !goneCandidates.includes(currentBranch)) {
|
|
1077
|
+
const ghInstalled = await checkGhInstalled();
|
|
1078
|
+
const ghAuthed = ghInstalled && await checkGhAuth();
|
|
1079
|
+
if (ghInstalled && ghAuthed) {
|
|
1080
|
+
const mergedPR = await getMergedPRForBranch(currentBranch);
|
|
1081
|
+
if (mergedPR) {
|
|
1082
|
+
warn(`PR #${mergedPR.number} (${pc4.bold(mergedPR.title)}) has already been merged.`);
|
|
1083
|
+
info(`Link: ${pc4.underline(mergedPR.url)}`);
|
|
1084
|
+
goneCandidates.push(currentBranch);
|
|
1085
|
+
}
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
if (mergedCandidates.length > 0) {
|
|
301
1089
|
console.log(`
|
|
302
|
-
${
|
|
303
|
-
for (const b of
|
|
304
|
-
|
|
1090
|
+
${pc4.bold("Merged branches to delete:")}`);
|
|
1091
|
+
for (const b of mergedCandidates) {
|
|
1092
|
+
const marker = b === currentBranch ? pc4.yellow(" (current)") : "";
|
|
1093
|
+
console.log(` ${pc4.dim("•")} ${b}${marker}`);
|
|
305
1094
|
}
|
|
306
1095
|
console.log();
|
|
307
|
-
const ok = args.yes || await confirmPrompt(`Delete ${
|
|
308
|
-
if (
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
1096
|
+
const ok = args.yes || await confirmPrompt(`Delete ${pc4.bold(String(mergedCandidates.length))} merged branch${mergedCandidates.length !== 1 ? "es" : ""}?`);
|
|
1097
|
+
if (ok) {
|
|
1098
|
+
for (const branch of mergedCandidates) {
|
|
1099
|
+
if (branch === currentBranch) {
|
|
1100
|
+
const result2 = await handleCurrentBranchDeletion(currentBranch, baseBranch, config);
|
|
1101
|
+
if (result2 === "skipped") {
|
|
1102
|
+
warn(` Skipped ${branch}.`);
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
if (result2 === "saved") {
|
|
1106
|
+
currentBranch = baseBranch;
|
|
1107
|
+
continue;
|
|
1108
|
+
}
|
|
1109
|
+
currentBranch = baseBranch;
|
|
1110
|
+
}
|
|
312
1111
|
const result = await deleteBranch(branch);
|
|
313
1112
|
if (result.exitCode === 0) {
|
|
314
|
-
success(` Deleted ${
|
|
1113
|
+
success(` Deleted ${pc4.bold(branch)}`);
|
|
315
1114
|
} else {
|
|
316
1115
|
warn(` Failed to delete ${branch}: ${result.stderr.trim()}`);
|
|
317
1116
|
}
|
|
318
1117
|
}
|
|
1118
|
+
} else {
|
|
1119
|
+
info("Skipped merged branch deletion.");
|
|
319
1120
|
}
|
|
320
1121
|
}
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
1122
|
+
if (goneCandidates.length > 0) {
|
|
1123
|
+
console.log(`
|
|
1124
|
+
${pc4.bold("Stale branches (remote deleted, likely squash-merged):")}`);
|
|
1125
|
+
for (const b of goneCandidates) {
|
|
1126
|
+
const marker = b === currentBranch ? pc4.yellow(" (current)") : "";
|
|
1127
|
+
console.log(` ${pc4.dim("•")} ${b}${marker}`);
|
|
1128
|
+
}
|
|
1129
|
+
console.log();
|
|
1130
|
+
const ok = args.yes || await confirmPrompt(`Delete ${pc4.bold(String(goneCandidates.length))} stale branch${goneCandidates.length !== 1 ? "es" : ""}?`);
|
|
1131
|
+
if (ok) {
|
|
1132
|
+
for (const branch of goneCandidates) {
|
|
1133
|
+
if (branch === currentBranch) {
|
|
1134
|
+
const result2 = await handleCurrentBranchDeletion(currentBranch, baseBranch, config);
|
|
1135
|
+
if (result2 === "skipped") {
|
|
1136
|
+
warn(` Skipped ${branch}.`);
|
|
1137
|
+
continue;
|
|
1138
|
+
}
|
|
1139
|
+
if (result2 === "saved") {
|
|
1140
|
+
currentBranch = baseBranch;
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
currentBranch = baseBranch;
|
|
1144
|
+
}
|
|
1145
|
+
const result = await forceDeleteBranch(branch);
|
|
1146
|
+
if (result.exitCode === 0) {
|
|
1147
|
+
success(` Deleted ${pc4.bold(branch)}`);
|
|
1148
|
+
} else {
|
|
1149
|
+
warn(` Failed to delete ${branch}: ${result.stderr.trim()}`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
} else {
|
|
1153
|
+
info("Skipped stale branch deletion.");
|
|
1154
|
+
}
|
|
1155
|
+
}
|
|
1156
|
+
if (mergedCandidates.length === 0 && goneCandidates.length === 0) {
|
|
1157
|
+
info("No branches to clean up. Everything is tidy! \uD83E\uDDF9");
|
|
1158
|
+
}
|
|
1159
|
+
const finalBranch = await getCurrentBranch();
|
|
1160
|
+
if (finalBranch && protectedBranches.has(finalBranch)) {
|
|
1161
|
+
console.log();
|
|
1162
|
+
info(`You're on ${pc4.bold(finalBranch)}. Run ${pc4.bold("contrib start")} to begin a new feature.`);
|
|
327
1163
|
}
|
|
328
1164
|
}
|
|
329
1165
|
});
|
|
330
1166
|
|
|
331
1167
|
// src/commands/commit.ts
|
|
332
1168
|
import { defineCommand as defineCommand2 } from "citty";
|
|
333
|
-
import
|
|
334
|
-
|
|
335
|
-
// src/utils/copilot.ts
|
|
336
|
-
import { CopilotClient } from "@github/copilot-sdk";
|
|
337
|
-
var CLEAN_COMMIT_SYSTEM_PROMPT = `You are a git commit message generator. Generate a Clean Commit message following this exact format:
|
|
338
|
-
<emoji> <type>[!][(<scope>)]: <description>
|
|
339
|
-
|
|
340
|
-
Emoji and type table:
|
|
341
|
-
\uD83D\uDCE6 new – new features, files, or capabilities
|
|
342
|
-
\uD83D\uDD27 update – changes, refactoring, improvements
|
|
343
|
-
\uD83D\uDDD1️ remove – removing code, files, or dependencies
|
|
344
|
-
\uD83D\uDD12 security – security fixes or patches
|
|
345
|
-
⚙️ setup – configs, CI/CD, tooling, build systems
|
|
346
|
-
☕ chore – maintenance, dependency updates
|
|
347
|
-
\uD83E\uDDEA test – adding or updating tests
|
|
348
|
-
\uD83D\uDCD6 docs – documentation changes
|
|
349
|
-
\uD83D\uDE80 release – version releases
|
|
350
|
-
|
|
351
|
-
Rules:
|
|
352
|
-
- Breaking change (!) only for: new, update, remove, security
|
|
353
|
-
- Description: concise, imperative mood, max 72 chars
|
|
354
|
-
- Scope: optional, camelCase or kebab-case component name
|
|
355
|
-
- Return ONLY the commit message line, nothing else
|
|
356
|
-
|
|
357
|
-
Examples:
|
|
358
|
-
\uD83D\uDCE6 new: user authentication system
|
|
359
|
-
\uD83D\uDD27 update (api): improve error handling
|
|
360
|
-
⚙️ setup (ci): configure github actions workflow
|
|
361
|
-
\uD83D\uDCE6 new!: completely redesign authentication system`;
|
|
362
|
-
var BRANCH_NAME_SYSTEM_PROMPT = `You are a git branch name generator. Convert natural language descriptions into proper git branch names.
|
|
363
|
-
|
|
364
|
-
Format: <prefix>/<kebab-case-name>
|
|
365
|
-
Prefixes: feature, fix, docs, chore, test, refactor
|
|
366
|
-
|
|
367
|
-
Rules:
|
|
368
|
-
- Use lowercase kebab-case for the name part
|
|
369
|
-
- Keep it short and descriptive (2-5 words max)
|
|
370
|
-
- Return ONLY the branch name, nothing else
|
|
371
|
-
|
|
372
|
-
Examples:
|
|
373
|
-
Input: "fix the login timeout bug" → fix/login-timeout
|
|
374
|
-
Input: "add user profile page" → feature/user-profile-page
|
|
375
|
-
Input: "update readme documentation" → docs/update-readme`;
|
|
376
|
-
var PR_DESCRIPTION_SYSTEM_PROMPT = `You are a GitHub pull request description generator. Create a clear, structured PR description.
|
|
377
|
-
|
|
378
|
-
Return a JSON object with this exact structure:
|
|
379
|
-
{
|
|
380
|
-
"title": "Brief PR title (50 chars max)",
|
|
381
|
-
"body": "## Summary\\n...\\n\\n## Changes\\n...\\n\\n## Test Plan\\n..."
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
Rules:
|
|
385
|
-
- title: concise, present tense, describes what the PR does
|
|
386
|
-
- body: markdown with Summary, Changes (bullet list), and Test Plan sections
|
|
387
|
-
- Return ONLY the JSON object, no markdown fences, no extra text`;
|
|
388
|
-
var CONFLICT_RESOLUTION_SYSTEM_PROMPT = `You are a git merge conflict resolution advisor. Analyze the conflict markers and provide guidance.
|
|
389
|
-
|
|
390
|
-
Rules:
|
|
391
|
-
- Explain what each side of the conflict contains
|
|
392
|
-
- Suggest the most likely correct resolution strategy
|
|
393
|
-
- Never auto-resolve — provide guidance only
|
|
394
|
-
- Be concise and actionable`;
|
|
395
|
-
async function checkCopilotAvailable() {
|
|
396
|
-
let client = null;
|
|
397
|
-
try {
|
|
398
|
-
client = new CopilotClient;
|
|
399
|
-
await client.start();
|
|
400
|
-
} catch (err) {
|
|
401
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
402
|
-
if (msg.includes("ENOENT") || msg.includes("not found")) {
|
|
403
|
-
return "Copilot CLI binary not found. Ensure GitHub Copilot is installed and your gh CLI is up to date.";
|
|
404
|
-
}
|
|
405
|
-
return `Failed to start Copilot service: ${msg}`;
|
|
406
|
-
}
|
|
407
|
-
try {
|
|
408
|
-
await client.ping();
|
|
409
|
-
} catch (err) {
|
|
410
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
411
|
-
if (msg.includes("auth") || msg.includes("token") || msg.includes("401") || msg.includes("403")) {
|
|
412
|
-
return "Copilot authentication failed. Run `gh auth login` to refresh your token.";
|
|
413
|
-
}
|
|
414
|
-
if (msg.includes("ECONNREFUSED") || msg.includes("timeout") || msg.includes("network")) {
|
|
415
|
-
return "Could not reach GitHub Copilot service. Check your internet connection.";
|
|
416
|
-
}
|
|
417
|
-
return `Copilot health check failed: ${msg}`;
|
|
418
|
-
} finally {
|
|
419
|
-
try {
|
|
420
|
-
await client.stop();
|
|
421
|
-
} catch {}
|
|
422
|
-
}
|
|
423
|
-
return null;
|
|
424
|
-
}
|
|
425
|
-
async function callCopilot(systemMessage, userMessage, model) {
|
|
426
|
-
const client = new CopilotClient;
|
|
427
|
-
await client.start();
|
|
428
|
-
try {
|
|
429
|
-
const sessionConfig = {
|
|
430
|
-
systemMessage: { content: systemMessage }
|
|
431
|
-
};
|
|
432
|
-
if (model)
|
|
433
|
-
sessionConfig.model = model;
|
|
434
|
-
const session = await client.createSession(sessionConfig);
|
|
435
|
-
try {
|
|
436
|
-
const response = await session.sendAndWait({ content: userMessage });
|
|
437
|
-
if (!response?.data?.content)
|
|
438
|
-
return null;
|
|
439
|
-
return response.data.content;
|
|
440
|
-
} finally {
|
|
441
|
-
await session.destroy();
|
|
442
|
-
}
|
|
443
|
-
} finally {
|
|
444
|
-
await client.stop();
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
async function generateCommitMessage(diff, stagedFiles, model) {
|
|
448
|
-
try {
|
|
449
|
-
const userMessage = `Generate a commit message for these staged changes:
|
|
450
|
-
|
|
451
|
-
Files: ${stagedFiles.join(", ")}
|
|
452
|
-
|
|
453
|
-
Diff:
|
|
454
|
-
${diff.slice(0, 4000)}`;
|
|
455
|
-
const result = await callCopilot(CLEAN_COMMIT_SYSTEM_PROMPT, userMessage, model);
|
|
456
|
-
return result?.trim() ?? null;
|
|
457
|
-
} catch {
|
|
458
|
-
return null;
|
|
459
|
-
}
|
|
460
|
-
}
|
|
461
|
-
async function generatePRDescription(commits, diff, model) {
|
|
462
|
-
try {
|
|
463
|
-
const userMessage = `Generate a PR description for these changes:
|
|
464
|
-
|
|
465
|
-
Commits:
|
|
466
|
-
${commits.join(`
|
|
467
|
-
`)}
|
|
1169
|
+
import pc5 from "picocolors";
|
|
468
1170
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
1171
|
+
// src/utils/convention.ts
|
|
1172
|
+
var CLEAN_COMMIT_PATTERN = /^(📦|🔧|🗑\uFE0F?|🔒|⚙\uFE0F?|☕|🧪|📖|🚀) (new|update|remove|security|setup|chore|test|docs|release)(!?)( \([a-zA-Z0-9][a-zA-Z0-9-]*\))?: .{1,72}$/u;
|
|
1173
|
+
var CONVENTIONAL_COMMIT_PATTERN = /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(!?)(\([a-zA-Z0-9][a-zA-Z0-9._-]*\))?: .{1,72}$/;
|
|
1174
|
+
var CONVENTION_LABELS = {
|
|
1175
|
+
conventional: "Conventional Commits",
|
|
1176
|
+
"clean-commit": "Clean Commit (by WGTech Labs)",
|
|
1177
|
+
none: "No convention"
|
|
1178
|
+
};
|
|
1179
|
+
var CONVENTION_DESCRIPTIONS = {
|
|
1180
|
+
conventional: "Conventional Commits — feat: | fix: | docs: | chore: etc. (conventionalcommits.org)",
|
|
1181
|
+
"clean-commit": "Clean Commit — \uD83D\uDCE6 new: | \uD83D\uDD27 update: | \uD83D\uDDD1️ remove: etc. (by WGTech Labs)",
|
|
1182
|
+
none: "No commit convention enforcement"
|
|
1183
|
+
};
|
|
1184
|
+
var CONVENTION_FORMAT_HINTS = {
|
|
1185
|
+
conventional: [
|
|
1186
|
+
"Format: <type>[!][(<scope>)]: <description>",
|
|
1187
|
+
"Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert",
|
|
1188
|
+
"Examples: feat: add login page | fix(auth): resolve token expiry | docs: update README"
|
|
1189
|
+
],
|
|
1190
|
+
"clean-commit": [
|
|
1191
|
+
"Format: <emoji> <type>[!][(<scope>)]: <description>",
|
|
1192
|
+
"Types: \uD83D\uDCE6 new | \uD83D\uDD27 update | \uD83D\uDDD1️ remove | \uD83D\uDD12 security | ⚙️ setup | ☕ chore | \uD83E\uDDEA test | \uD83D\uDCD6 docs | \uD83D\uDE80 release",
|
|
1193
|
+
"Examples: \uD83D\uDCE6 new: user auth | \uD83D\uDD27 update (api): improve errors | ⚙️ setup (ci): add workflow"
|
|
1194
|
+
]
|
|
1195
|
+
};
|
|
1196
|
+
function validateCommitMessage(message, convention) {
|
|
1197
|
+
if (convention === "none")
|
|
1198
|
+
return true;
|
|
1199
|
+
if (convention === "clean-commit")
|
|
1200
|
+
return CLEAN_COMMIT_PATTERN.test(message);
|
|
1201
|
+
if (convention === "conventional")
|
|
1202
|
+
return CONVENTIONAL_COMMIT_PATTERN.test(message);
|
|
1203
|
+
return true;
|
|
487
1204
|
}
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
${
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
} catch {
|
|
496
|
-
return null;
|
|
497
|
-
}
|
|
1205
|
+
function getValidationError(convention) {
|
|
1206
|
+
if (convention === "none")
|
|
1207
|
+
return [];
|
|
1208
|
+
return [
|
|
1209
|
+
`Commit message does not follow ${CONVENTION_LABELS[convention]} format.`,
|
|
1210
|
+
...CONVENTION_FORMAT_HINTS[convention]
|
|
1211
|
+
];
|
|
498
1212
|
}
|
|
499
1213
|
|
|
500
1214
|
// src/commands/commit.ts
|
|
501
|
-
var CLEAN_COMMIT_PATTERN = /^(📦|🔧|🗑\uFE0F?|🔒|⚙\uFE0F?|☕|🧪|📖|🚀) (new|update|remove|security|setup|chore|test|docs|release)(!?)( \([a-zA-Z0-9][a-zA-Z0-9-]*\))?: .{1,72}$/u;
|
|
502
|
-
function validateCleanCommit(msg) {
|
|
503
|
-
return CLEAN_COMMIT_PATTERN.test(msg);
|
|
504
|
-
}
|
|
505
1215
|
var commit_default = defineCommand2({
|
|
506
1216
|
meta: {
|
|
507
1217
|
name: "commit",
|
|
508
|
-
description: "Stage changes and create a
|
|
1218
|
+
description: "Stage changes and create a commit message (AI-powered)"
|
|
509
1219
|
},
|
|
510
1220
|
args: {
|
|
511
1221
|
model: {
|
|
@@ -516,6 +1226,11 @@ var commit_default = defineCommand2({
|
|
|
516
1226
|
type: "boolean",
|
|
517
1227
|
description: "Skip AI and write commit message manually",
|
|
518
1228
|
default: false
|
|
1229
|
+
},
|
|
1230
|
+
group: {
|
|
1231
|
+
type: "boolean",
|
|
1232
|
+
description: "AI groups related changes into separate atomic commits",
|
|
1233
|
+
default: false
|
|
519
1234
|
}
|
|
520
1235
|
},
|
|
521
1236
|
async run({ args }) {
|
|
@@ -529,7 +1244,11 @@ var commit_default = defineCommand2({
|
|
|
529
1244
|
process.exit(1);
|
|
530
1245
|
}
|
|
531
1246
|
heading("\uD83D\uDCBE contrib commit");
|
|
532
|
-
|
|
1247
|
+
if (args.group) {
|
|
1248
|
+
await runGroupCommit(args.model, config);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
let stagedFiles = await getStagedFiles();
|
|
533
1252
|
if (stagedFiles.length === 0) {
|
|
534
1253
|
const changedFiles = await getChangedFiles();
|
|
535
1254
|
if (changedFiles.length === 0) {
|
|
@@ -537,31 +1256,62 @@ var commit_default = defineCommand2({
|
|
|
537
1256
|
process.exit(1);
|
|
538
1257
|
}
|
|
539
1258
|
console.log(`
|
|
540
|
-
${
|
|
1259
|
+
${pc5.bold("Changed files:")}`);
|
|
541
1260
|
for (const f of changedFiles) {
|
|
542
|
-
console.log(` ${
|
|
1261
|
+
console.log(` ${pc5.dim("•")} ${f}`);
|
|
1262
|
+
}
|
|
1263
|
+
const stageAction = await selectPrompt("No staged changes. How would you like to stage?", [
|
|
1264
|
+
"Stage all changes",
|
|
1265
|
+
"Select files to stage",
|
|
1266
|
+
"Cancel"
|
|
1267
|
+
]);
|
|
1268
|
+
if (stageAction === "Cancel") {
|
|
1269
|
+
process.exit(0);
|
|
1270
|
+
}
|
|
1271
|
+
if (stageAction === "Stage all changes") {
|
|
1272
|
+
const result2 = await stageAll();
|
|
1273
|
+
if (result2.exitCode !== 0) {
|
|
1274
|
+
error(`Failed to stage files: ${result2.stderr}`);
|
|
1275
|
+
process.exit(1);
|
|
1276
|
+
}
|
|
1277
|
+
success("Staged all changes.");
|
|
1278
|
+
} else {
|
|
1279
|
+
const selected = await multiSelectPrompt("Select files to stage:", changedFiles);
|
|
1280
|
+
if (selected.length === 0) {
|
|
1281
|
+
error("No files selected.");
|
|
1282
|
+
process.exit(1);
|
|
1283
|
+
}
|
|
1284
|
+
const result2 = await stageFiles(selected);
|
|
1285
|
+
if (result2.exitCode !== 0) {
|
|
1286
|
+
error(`Failed to stage files: ${result2.stderr}`);
|
|
1287
|
+
process.exit(1);
|
|
1288
|
+
}
|
|
1289
|
+
success(`Staged ${selected.length} file(s).`);
|
|
1290
|
+
}
|
|
1291
|
+
stagedFiles = await getStagedFiles();
|
|
1292
|
+
if (stagedFiles.length === 0) {
|
|
1293
|
+
error("No staged changes after staging attempt.");
|
|
1294
|
+
process.exit(1);
|
|
543
1295
|
}
|
|
544
|
-
console.log();
|
|
545
|
-
warn("No staged changes. Stage your files with `git add` and re-run.");
|
|
546
|
-
process.exit(1);
|
|
547
1296
|
}
|
|
548
1297
|
info(`Staged files: ${stagedFiles.join(", ")}`);
|
|
549
1298
|
let commitMessage = null;
|
|
550
1299
|
const useAI = !args["no-ai"];
|
|
551
1300
|
if (useAI) {
|
|
552
|
-
const copilotError = await checkCopilotAvailable();
|
|
1301
|
+
const [copilotError, diff] = await Promise.all([checkCopilotAvailable(), getStagedDiff()]);
|
|
553
1302
|
if (copilotError) {
|
|
554
1303
|
warn(`AI unavailable: ${copilotError}`);
|
|
555
1304
|
warn("Falling back to manual commit message entry.");
|
|
556
1305
|
} else {
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
commitMessage = await generateCommitMessage(diff, stagedFiles, args.model);
|
|
1306
|
+
const spinner = createSpinner("Generating commit message with AI...");
|
|
1307
|
+
commitMessage = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
|
|
560
1308
|
if (commitMessage) {
|
|
1309
|
+
spinner.success("AI commit message generated.");
|
|
561
1310
|
console.log(`
|
|
562
|
-
${
|
|
1311
|
+
${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(commitMessage))}`);
|
|
563
1312
|
} else {
|
|
564
|
-
|
|
1313
|
+
spinner.fail("AI did not return a commit message.");
|
|
1314
|
+
warn("Falling back to manual entry.");
|
|
565
1315
|
}
|
|
566
1316
|
}
|
|
567
1317
|
}
|
|
@@ -578,35 +1328,42 @@ ${pc4.bold("Changed files:")}`);
|
|
|
578
1328
|
} else if (action === "Edit this message") {
|
|
579
1329
|
finalMessage = await inputPrompt("Edit commit message", commitMessage);
|
|
580
1330
|
} else if (action === "Regenerate") {
|
|
581
|
-
|
|
1331
|
+
const spinner = createSpinner("Regenerating commit message...");
|
|
582
1332
|
const diff = await getStagedDiff();
|
|
583
|
-
const regen = await generateCommitMessage(diff, stagedFiles, args.model);
|
|
1333
|
+
const regen = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
|
|
584
1334
|
if (regen) {
|
|
1335
|
+
spinner.success("Commit message regenerated.");
|
|
585
1336
|
console.log(`
|
|
586
|
-
${
|
|
1337
|
+
${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(regen))}`);
|
|
587
1338
|
const ok = await confirmPrompt("Use this message?");
|
|
588
1339
|
finalMessage = ok ? regen : await inputPrompt("Enter commit message manually");
|
|
589
1340
|
} else {
|
|
590
|
-
|
|
1341
|
+
spinner.fail("Regeneration failed.");
|
|
591
1342
|
finalMessage = await inputPrompt("Enter commit message");
|
|
592
1343
|
}
|
|
593
1344
|
} else {
|
|
594
1345
|
finalMessage = await inputPrompt("Enter commit message");
|
|
595
1346
|
}
|
|
596
1347
|
} else {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
1348
|
+
const convention2 = config.commitConvention;
|
|
1349
|
+
if (convention2 !== "none") {
|
|
1350
|
+
console.log();
|
|
1351
|
+
for (const hint of CONVENTION_FORMAT_HINTS[convention2]) {
|
|
1352
|
+
console.log(pc5.dim(hint));
|
|
1353
|
+
}
|
|
1354
|
+
console.log();
|
|
1355
|
+
}
|
|
601
1356
|
finalMessage = await inputPrompt("Enter commit message");
|
|
602
1357
|
}
|
|
603
1358
|
if (!finalMessage) {
|
|
604
1359
|
error("No commit message provided.");
|
|
605
1360
|
process.exit(1);
|
|
606
1361
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
1362
|
+
const convention = config.commitConvention;
|
|
1363
|
+
if (!validateCommitMessage(finalMessage, convention)) {
|
|
1364
|
+
for (const line of getValidationError(convention)) {
|
|
1365
|
+
warn(line);
|
|
1366
|
+
}
|
|
610
1367
|
const proceed = await confirmPrompt("Commit anyway?");
|
|
611
1368
|
if (!proceed)
|
|
612
1369
|
process.exit(1);
|
|
@@ -616,105 +1373,321 @@ ${pc4.bold("Changed files:")}`);
|
|
|
616
1373
|
error(`Failed to commit: ${result.stderr}`);
|
|
617
1374
|
process.exit(1);
|
|
618
1375
|
}
|
|
619
|
-
success(`✅ Committed: ${
|
|
1376
|
+
success(`✅ Committed: ${pc5.bold(finalMessage)}`);
|
|
620
1377
|
}
|
|
621
1378
|
});
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
return new Promise((resolve) => {
|
|
631
|
-
execFileCb2("gh", args, (error2, stdout, stderr) => {
|
|
632
|
-
resolve({
|
|
633
|
-
exitCode: error2 ? error2.code != null ? Number(error2.code) : 1 : 0,
|
|
634
|
-
stdout: stdout ?? "",
|
|
635
|
-
stderr: stderr ?? ""
|
|
636
|
-
});
|
|
637
|
-
});
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
async function checkGhInstalled() {
|
|
641
|
-
try {
|
|
642
|
-
const { exitCode } = await run2(["--version"]);
|
|
643
|
-
return exitCode === 0;
|
|
644
|
-
} catch {
|
|
645
|
-
return false;
|
|
1379
|
+
async function runGroupCommit(model, config) {
|
|
1380
|
+
const [copilotError, changedFiles] = await Promise.all([
|
|
1381
|
+
checkCopilotAvailable(),
|
|
1382
|
+
getChangedFiles()
|
|
1383
|
+
]);
|
|
1384
|
+
if (copilotError) {
|
|
1385
|
+
error(`AI is required for --group mode but unavailable: ${copilotError}`);
|
|
1386
|
+
process.exit(1);
|
|
646
1387
|
}
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
const { exitCode } = await run2(["auth", "status"]);
|
|
651
|
-
return exitCode === 0;
|
|
652
|
-
} catch {
|
|
653
|
-
return false;
|
|
1388
|
+
if (changedFiles.length === 0) {
|
|
1389
|
+
error("No changes to group-commit.");
|
|
1390
|
+
process.exit(1);
|
|
654
1391
|
}
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
const
|
|
658
|
-
|
|
659
|
-
|
|
1392
|
+
console.log(`
|
|
1393
|
+
${pc5.bold("Changed files:")}`);
|
|
1394
|
+
for (const f of changedFiles) {
|
|
1395
|
+
console.log(` ${pc5.dim("•")} ${f}`);
|
|
1396
|
+
}
|
|
1397
|
+
const spinner = createSpinner(`Asking AI to group ${changedFiles.length} file(s) into logical commits...`);
|
|
1398
|
+
const diffs = await getFullDiffForFiles(changedFiles);
|
|
1399
|
+
if (!diffs.trim()) {
|
|
1400
|
+
spinner.stop();
|
|
1401
|
+
warn("Could not retrieve diff context for any files. AI needs diffs to produce groups.");
|
|
1402
|
+
}
|
|
1403
|
+
let groups;
|
|
660
1404
|
try {
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
1405
|
+
groups = await generateCommitGroups(changedFiles, diffs, model, config.commitConvention);
|
|
1406
|
+
spinner.success(`AI generated ${groups.length} commit group(s).`);
|
|
1407
|
+
} catch (err) {
|
|
1408
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
1409
|
+
spinner.fail(`AI grouping failed: ${reason}`);
|
|
1410
|
+
process.exit(1);
|
|
1411
|
+
}
|
|
1412
|
+
if (groups.length === 0) {
|
|
1413
|
+
error("AI could not produce commit groups. Try committing files manually.");
|
|
1414
|
+
process.exit(1);
|
|
664
1415
|
}
|
|
1416
|
+
const changedSet = new Set(changedFiles);
|
|
1417
|
+
for (const group of groups) {
|
|
1418
|
+
const invalid = group.files.filter((f) => !changedSet.has(f));
|
|
1419
|
+
if (invalid.length > 0) {
|
|
1420
|
+
warn(`AI suggested unknown file(s): ${invalid.join(", ")} — removed from group.`);
|
|
1421
|
+
}
|
|
1422
|
+
group.files = group.files.filter((f) => changedSet.has(f));
|
|
1423
|
+
}
|
|
1424
|
+
let validGroups = groups.filter((g) => g.files.length > 0);
|
|
1425
|
+
if (validGroups.length === 0) {
|
|
1426
|
+
error("No valid groups remain after validation. Try committing files manually.");
|
|
1427
|
+
process.exit(1);
|
|
1428
|
+
}
|
|
1429
|
+
let proceedToCommit = false;
|
|
1430
|
+
let commitAll = false;
|
|
1431
|
+
while (!proceedToCommit) {
|
|
1432
|
+
console.log(`
|
|
1433
|
+
${pc5.bold(`AI suggested ${validGroups.length} commit group(s):`)}
|
|
1434
|
+
`);
|
|
1435
|
+
for (let i = 0;i < validGroups.length; i++) {
|
|
1436
|
+
const g = validGroups[i];
|
|
1437
|
+
console.log(` ${pc5.cyan(`Group ${i + 1}:`)} ${pc5.bold(g.message)}`);
|
|
1438
|
+
for (const f of g.files) {
|
|
1439
|
+
console.log(` ${pc5.dim("•")} ${f}`);
|
|
1440
|
+
}
|
|
1441
|
+
console.log();
|
|
1442
|
+
}
|
|
1443
|
+
const summaryAction = await selectPrompt("What would you like to do?", [
|
|
1444
|
+
"Commit all",
|
|
1445
|
+
"Review each group",
|
|
1446
|
+
"Regenerate all messages",
|
|
1447
|
+
"Cancel"
|
|
1448
|
+
]);
|
|
1449
|
+
if (summaryAction === "Cancel") {
|
|
1450
|
+
warn("Group commit cancelled.");
|
|
1451
|
+
process.exit(0);
|
|
1452
|
+
}
|
|
1453
|
+
if (summaryAction === "Regenerate all messages") {
|
|
1454
|
+
const regenSpinner = createSpinner("Regenerating all commit messages...");
|
|
1455
|
+
try {
|
|
1456
|
+
validGroups = await regenerateAllGroupMessages(validGroups, diffs, model, config.commitConvention);
|
|
1457
|
+
regenSpinner.success("All commit messages regenerated.");
|
|
1458
|
+
} catch {
|
|
1459
|
+
regenSpinner.fail("Failed to regenerate messages. Keeping current ones.");
|
|
1460
|
+
}
|
|
1461
|
+
continue;
|
|
1462
|
+
}
|
|
1463
|
+
proceedToCommit = true;
|
|
1464
|
+
commitAll = summaryAction === "Commit all";
|
|
1465
|
+
}
|
|
1466
|
+
let committed = 0;
|
|
1467
|
+
if (commitAll) {
|
|
1468
|
+
for (let i = 0;i < validGroups.length; i++) {
|
|
1469
|
+
const group = validGroups[i];
|
|
1470
|
+
const stageResult = await stageFiles(group.files);
|
|
1471
|
+
if (stageResult.exitCode !== 0) {
|
|
1472
|
+
error(`Failed to stage group ${i + 1}: ${stageResult.stderr}`);
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1475
|
+
const commitResult = await commitWithMessage(group.message);
|
|
1476
|
+
if (commitResult.exitCode !== 0) {
|
|
1477
|
+
const detail = (commitResult.stderr || commitResult.stdout).trim();
|
|
1478
|
+
error(`Failed to commit group ${i + 1}: ${detail}`);
|
|
1479
|
+
await unstageFiles(group.files);
|
|
1480
|
+
continue;
|
|
1481
|
+
}
|
|
1482
|
+
committed++;
|
|
1483
|
+
success(`✅ Committed group ${i + 1}: ${pc5.bold(group.message)}`);
|
|
1484
|
+
}
|
|
1485
|
+
} else {
|
|
1486
|
+
for (let i = 0;i < validGroups.length; i++) {
|
|
1487
|
+
const group = validGroups[i];
|
|
1488
|
+
console.log(pc5.bold(`
|
|
1489
|
+
── Group ${i + 1}/${validGroups.length} ──`));
|
|
1490
|
+
console.log(` ${pc5.cyan(group.message)}`);
|
|
1491
|
+
for (const f of group.files) {
|
|
1492
|
+
console.log(` ${pc5.dim("•")} ${f}`);
|
|
1493
|
+
}
|
|
1494
|
+
let message = group.message;
|
|
1495
|
+
let actionDone = false;
|
|
1496
|
+
while (!actionDone) {
|
|
1497
|
+
const action = await selectPrompt("Action for this group:", [
|
|
1498
|
+
"Commit as-is",
|
|
1499
|
+
"Edit message and commit",
|
|
1500
|
+
"Regenerate message",
|
|
1501
|
+
"Skip this group"
|
|
1502
|
+
]);
|
|
1503
|
+
if (action === "Skip this group") {
|
|
1504
|
+
warn(`Skipped group ${i + 1}.`);
|
|
1505
|
+
actionDone = true;
|
|
1506
|
+
continue;
|
|
1507
|
+
}
|
|
1508
|
+
if (action === "Regenerate message") {
|
|
1509
|
+
const regenSpinner = createSpinner("Regenerating commit message for this group...");
|
|
1510
|
+
const newMsg = await regenerateGroupMessage(group.files, diffs, model, config.commitConvention);
|
|
1511
|
+
if (newMsg) {
|
|
1512
|
+
message = newMsg;
|
|
1513
|
+
group.message = newMsg;
|
|
1514
|
+
regenSpinner.success(`New message: ${pc5.bold(message)}`);
|
|
1515
|
+
} else {
|
|
1516
|
+
regenSpinner.fail("AI could not generate a new message. Keeping current one.");
|
|
1517
|
+
}
|
|
1518
|
+
continue;
|
|
1519
|
+
}
|
|
1520
|
+
if (action === "Edit message and commit") {
|
|
1521
|
+
message = await inputPrompt("Edit commit message", message);
|
|
1522
|
+
if (!message) {
|
|
1523
|
+
warn(`Skipped group ${i + 1} (empty message).`);
|
|
1524
|
+
actionDone = true;
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
if (!validateCommitMessage(message, config.commitConvention)) {
|
|
1529
|
+
for (const line of getValidationError(config.commitConvention)) {
|
|
1530
|
+
warn(line);
|
|
1531
|
+
}
|
|
1532
|
+
const proceed = await confirmPrompt("Commit anyway?");
|
|
1533
|
+
if (!proceed) {
|
|
1534
|
+
warn(`Skipped group ${i + 1}.`);
|
|
1535
|
+
actionDone = true;
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
}
|
|
1539
|
+
const stageResult = await stageFiles(group.files);
|
|
1540
|
+
if (stageResult.exitCode !== 0) {
|
|
1541
|
+
error(`Failed to stage group ${i + 1}: ${stageResult.stderr}`);
|
|
1542
|
+
actionDone = true;
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
const commitResult = await commitWithMessage(message);
|
|
1546
|
+
if (commitResult.exitCode !== 0) {
|
|
1547
|
+
const detail = (commitResult.stderr || commitResult.stdout).trim();
|
|
1548
|
+
error(`Failed to commit group ${i + 1}: ${detail}`);
|
|
1549
|
+
await unstageFiles(group.files);
|
|
1550
|
+
actionDone = true;
|
|
1551
|
+
continue;
|
|
1552
|
+
}
|
|
1553
|
+
committed++;
|
|
1554
|
+
success(`✅ Committed group ${i + 1}: ${pc5.bold(message)}`);
|
|
1555
|
+
actionDone = true;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
if (committed === 0) {
|
|
1560
|
+
warn("No groups were committed.");
|
|
1561
|
+
} else {
|
|
1562
|
+
success(`
|
|
1563
|
+
\uD83C\uDF89 ${committed} of ${validGroups.length} group(s) committed successfully.`);
|
|
1564
|
+
}
|
|
1565
|
+
process.exit(0);
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// src/commands/hook.ts
|
|
1569
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1570
|
+
import { join as join3 } from "node:path";
|
|
1571
|
+
import { defineCommand as defineCommand3 } from "citty";
|
|
1572
|
+
import pc6 from "picocolors";
|
|
1573
|
+
var HOOK_MARKER = "# managed by contribute-now";
|
|
1574
|
+
function getHooksDir(cwd = process.cwd()) {
|
|
1575
|
+
return join3(cwd, ".git", "hooks");
|
|
665
1576
|
}
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
if (exitCode !== 0)
|
|
669
|
-
return null;
|
|
670
|
-
const val = stdout.trim();
|
|
671
|
-
if (val === "true")
|
|
672
|
-
return true;
|
|
673
|
-
if (val === "false")
|
|
674
|
-
return false;
|
|
675
|
-
return null;
|
|
1577
|
+
function getHookPath(cwd = process.cwd()) {
|
|
1578
|
+
return join3(getHooksDir(cwd), "commit-msg");
|
|
676
1579
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
1580
|
+
function generateHookScript() {
|
|
1581
|
+
return `#!/bin/sh
|
|
1582
|
+
${HOOK_MARKER}
|
|
1583
|
+
# Validates commit messages against your configured convention.
|
|
1584
|
+
# Install: contrib hook install
|
|
1585
|
+
# Uninstall: contrib hook uninstall
|
|
1586
|
+
|
|
1587
|
+
commit_msg_file="$1"
|
|
1588
|
+
commit_msg=$(head -1 "$commit_msg_file")
|
|
1589
|
+
|
|
1590
|
+
# Skip merge commits and fixup/squash commits
|
|
1591
|
+
case "$commit_msg" in
|
|
1592
|
+
Merge\\ *|fixup!*|squash!*|amend!*) exit 0 ;;
|
|
1593
|
+
esac
|
|
1594
|
+
|
|
1595
|
+
# Detect available package runner
|
|
1596
|
+
if command -v contrib >/dev/null 2>&1; then
|
|
1597
|
+
contrib validate "$commit_msg"
|
|
1598
|
+
elif command -v bunx >/dev/null 2>&1; then
|
|
1599
|
+
bunx contrib validate "$commit_msg"
|
|
1600
|
+
elif command -v pnpx >/dev/null 2>&1; then
|
|
1601
|
+
pnpx contrib validate "$commit_msg"
|
|
1602
|
+
elif command -v npx >/dev/null 2>&1; then
|
|
1603
|
+
npx contrib validate "$commit_msg"
|
|
1604
|
+
else
|
|
1605
|
+
echo "Warning: No package runner found. Skipping commit message validation."
|
|
1606
|
+
exit 0
|
|
1607
|
+
fi
|
|
1608
|
+
`;
|
|
695
1609
|
}
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
"
|
|
699
|
-
"
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
1610
|
+
var hook_default = defineCommand3({
|
|
1611
|
+
meta: {
|
|
1612
|
+
name: "hook",
|
|
1613
|
+
description: "Install or uninstall the commit-msg git hook"
|
|
1614
|
+
},
|
|
1615
|
+
args: {
|
|
1616
|
+
action: {
|
|
1617
|
+
type: "positional",
|
|
1618
|
+
description: "Action to perform: install or uninstall",
|
|
1619
|
+
required: true
|
|
1620
|
+
}
|
|
1621
|
+
},
|
|
1622
|
+
async run({ args }) {
|
|
1623
|
+
if (!await isGitRepo()) {
|
|
1624
|
+
error("Not inside a git repository.");
|
|
1625
|
+
process.exit(1);
|
|
1626
|
+
}
|
|
1627
|
+
const action = args.action;
|
|
1628
|
+
if (action !== "install" && action !== "uninstall") {
|
|
1629
|
+
error(`Unknown action "${action}". Use "install" or "uninstall".`);
|
|
1630
|
+
process.exit(1);
|
|
1631
|
+
}
|
|
1632
|
+
if (action === "install") {
|
|
1633
|
+
await installHook();
|
|
1634
|
+
} else {
|
|
1635
|
+
await uninstallHook();
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
});
|
|
1639
|
+
async function installHook() {
|
|
1640
|
+
heading("\uD83E\uDE9D hook install");
|
|
1641
|
+
const config = readConfig();
|
|
1642
|
+
if (!config) {
|
|
1643
|
+
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
1644
|
+
process.exit(1);
|
|
1645
|
+
}
|
|
1646
|
+
if (config.commitConvention === "none") {
|
|
1647
|
+
warn('Commit convention is set to "none". No hook to install.');
|
|
1648
|
+
info("Change your convention with `contrib setup` first.");
|
|
1649
|
+
process.exit(0);
|
|
1650
|
+
}
|
|
1651
|
+
const hookPath = getHookPath();
|
|
1652
|
+
const hooksDir = getHooksDir();
|
|
1653
|
+
if (existsSync2(hookPath)) {
|
|
1654
|
+
const existing = readFileSync3(hookPath, "utf-8");
|
|
1655
|
+
if (!existing.includes(HOOK_MARKER)) {
|
|
1656
|
+
error("A commit-msg hook already exists and was not installed by contribute-now.");
|
|
1657
|
+
warn(`Path: ${hookPath}`);
|
|
1658
|
+
warn("Remove it manually or back it up before installing.");
|
|
1659
|
+
process.exit(1);
|
|
1660
|
+
}
|
|
1661
|
+
info("Updating existing contribute-now hook...");
|
|
1662
|
+
}
|
|
1663
|
+
if (!existsSync2(hooksDir)) {
|
|
1664
|
+
mkdirSync(hooksDir, { recursive: true });
|
|
1665
|
+
}
|
|
1666
|
+
writeFileSync2(hookPath, generateHookScript(), { mode: 493 });
|
|
1667
|
+
success(`commit-msg hook installed.`);
|
|
1668
|
+
info(`Convention: ${pc6.bold(CONVENTION_LABELS[config.commitConvention])}`);
|
|
1669
|
+
info(`Path: ${pc6.dim(hookPath)}`);
|
|
710
1670
|
}
|
|
711
|
-
async function
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
1671
|
+
async function uninstallHook() {
|
|
1672
|
+
heading("\uD83E\uDE9D hook uninstall");
|
|
1673
|
+
const hookPath = getHookPath();
|
|
1674
|
+
if (!existsSync2(hookPath)) {
|
|
1675
|
+
info("No commit-msg hook found. Nothing to uninstall.");
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
const content = readFileSync3(hookPath, "utf-8");
|
|
1679
|
+
if (!content.includes(HOOK_MARKER)) {
|
|
1680
|
+
error("The commit-msg hook was not installed by contribute-now. Leaving it untouched.");
|
|
1681
|
+
process.exit(1);
|
|
1682
|
+
}
|
|
1683
|
+
rmSync(hookPath);
|
|
1684
|
+
success("commit-msg hook removed.");
|
|
716
1685
|
}
|
|
717
1686
|
|
|
1687
|
+
// src/commands/setup.ts
|
|
1688
|
+
import { defineCommand as defineCommand4 } from "citty";
|
|
1689
|
+
import pc7 from "picocolors";
|
|
1690
|
+
|
|
718
1691
|
// src/utils/remote.ts
|
|
719
1692
|
function parseRepoFromUrl(url) {
|
|
720
1693
|
const httpsMatch = url.match(/https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
|
|
@@ -735,7 +1708,7 @@ async function getRepoInfoFromRemote(remote = "origin") {
|
|
|
735
1708
|
}
|
|
736
1709
|
|
|
737
1710
|
// src/commands/setup.ts
|
|
738
|
-
var setup_default =
|
|
1711
|
+
var setup_default = defineCommand4({
|
|
739
1712
|
meta: {
|
|
740
1713
|
name: "setup",
|
|
741
1714
|
description: "Initialize contribute-now config for this repo (.contributerc.json)"
|
|
@@ -746,6 +1719,27 @@ var setup_default = defineCommand3({
|
|
|
746
1719
|
process.exit(1);
|
|
747
1720
|
}
|
|
748
1721
|
heading("\uD83D\uDD27 contribute-now setup");
|
|
1722
|
+
const workflowChoice = await selectPrompt("Which git workflow does this project use?", [
|
|
1723
|
+
"Clean Flow — main + dev, squash features into dev, merge dev into main (recommended)",
|
|
1724
|
+
"GitHub Flow — main + feature branches, squash/merge into main",
|
|
1725
|
+
"Git Flow — main + develop + release + hotfix branches"
|
|
1726
|
+
]);
|
|
1727
|
+
let workflow = "clean-flow";
|
|
1728
|
+
if (workflowChoice.startsWith("GitHub"))
|
|
1729
|
+
workflow = "github-flow";
|
|
1730
|
+
else if (workflowChoice.startsWith("Git Flow"))
|
|
1731
|
+
workflow = "git-flow";
|
|
1732
|
+
info(`Workflow: ${pc7.bold(WORKFLOW_DESCRIPTIONS[workflow])}`);
|
|
1733
|
+
const conventionChoice = await selectPrompt("Which commit convention should this project use?", [
|
|
1734
|
+
`${CONVENTION_DESCRIPTIONS["clean-commit"]} (recommended)`,
|
|
1735
|
+
CONVENTION_DESCRIPTIONS.conventional,
|
|
1736
|
+
CONVENTION_DESCRIPTIONS.none
|
|
1737
|
+
]);
|
|
1738
|
+
let commitConvention = "clean-commit";
|
|
1739
|
+
if (conventionChoice.includes("Conventional Commits"))
|
|
1740
|
+
commitConvention = "conventional";
|
|
1741
|
+
else if (conventionChoice.includes("No commit"))
|
|
1742
|
+
commitConvention = "none";
|
|
749
1743
|
const remotes = await getRemotes();
|
|
750
1744
|
if (remotes.length === 0) {
|
|
751
1745
|
error("No git remotes found. Add a remote first (e.g., git remote add origin <url>).");
|
|
@@ -788,8 +1782,8 @@ var setup_default = defineCommand3({
|
|
|
788
1782
|
detectedRole = roleChoice;
|
|
789
1783
|
detectionSource = "user selection";
|
|
790
1784
|
} else {
|
|
791
|
-
info(`Detected role: ${
|
|
792
|
-
const confirmed = await confirmPrompt(`Role detected as ${
|
|
1785
|
+
info(`Detected role: ${pc7.bold(detectedRole)} (via ${detectionSource})`);
|
|
1786
|
+
const confirmed = await confirmPrompt(`Role detected as ${pc7.bold(detectedRole)}. Is this correct?`);
|
|
793
1787
|
if (!confirmed) {
|
|
794
1788
|
const roleChoice = await selectPrompt("Select your role:", ["maintainer", "contributor"]);
|
|
795
1789
|
detectedRole = roleChoice;
|
|
@@ -797,7 +1791,11 @@ var setup_default = defineCommand3({
|
|
|
797
1791
|
}
|
|
798
1792
|
const defaultConfig = getDefaultConfig();
|
|
799
1793
|
const mainBranch = await inputPrompt("Main branch name", defaultConfig.mainBranch);
|
|
800
|
-
|
|
1794
|
+
let devBranch;
|
|
1795
|
+
if (hasDevBranch(workflow)) {
|
|
1796
|
+
const defaultDev = workflow === "git-flow" ? "develop" : "dev";
|
|
1797
|
+
devBranch = await inputPrompt("Dev/develop branch name", defaultDev);
|
|
1798
|
+
}
|
|
801
1799
|
const originRemote = await inputPrompt("Origin remote name", defaultConfig.origin);
|
|
802
1800
|
let upstreamRemote = defaultConfig.upstream;
|
|
803
1801
|
if (detectedRole === "contributor") {
|
|
@@ -814,12 +1812,14 @@ var setup_default = defineCommand3({
|
|
|
814
1812
|
}
|
|
815
1813
|
}
|
|
816
1814
|
const config = {
|
|
1815
|
+
workflow,
|
|
817
1816
|
role: detectedRole,
|
|
818
1817
|
mainBranch,
|
|
819
|
-
devBranch,
|
|
1818
|
+
...devBranch ? { devBranch } : {},
|
|
820
1819
|
upstream: upstreamRemote,
|
|
821
1820
|
origin: originRemote,
|
|
822
|
-
branchPrefixes: defaultConfig.branchPrefixes
|
|
1821
|
+
branchPrefixes: defaultConfig.branchPrefixes,
|
|
1822
|
+
commitConvention
|
|
823
1823
|
};
|
|
824
1824
|
writeConfig(config);
|
|
825
1825
|
success(`✅ Config written to .contributerc.json`);
|
|
@@ -828,34 +1828,25 @@ var setup_default = defineCommand3({
|
|
|
828
1828
|
warn(' echo ".contributerc.json" >> .gitignore');
|
|
829
1829
|
}
|
|
830
1830
|
console.log();
|
|
831
|
-
info(`
|
|
832
|
-
info(`
|
|
833
|
-
info(`
|
|
1831
|
+
info(`Workflow: ${pc7.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
|
|
1832
|
+
info(`Convention: ${pc7.bold(CONVENTION_DESCRIPTIONS[config.commitConvention])}`);
|
|
1833
|
+
info(`Role: ${pc7.bold(config.role)}`);
|
|
1834
|
+
if (config.devBranch) {
|
|
1835
|
+
info(`Main: ${pc7.bold(config.mainBranch)} | Dev: ${pc7.bold(config.devBranch)}`);
|
|
1836
|
+
} else {
|
|
1837
|
+
info(`Main: ${pc7.bold(config.mainBranch)}`);
|
|
1838
|
+
}
|
|
1839
|
+
info(`Origin: ${pc7.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc7.bold(config.upstream)}` : ""}`);
|
|
834
1840
|
}
|
|
835
1841
|
});
|
|
836
1842
|
|
|
837
1843
|
// src/commands/start.ts
|
|
838
|
-
import { defineCommand as
|
|
839
|
-
import
|
|
840
|
-
|
|
841
|
-
// src/utils/branch.ts
|
|
842
|
-
var DEFAULT_PREFIXES = ["feature", "fix", "docs", "chore", "test", "refactor"];
|
|
843
|
-
function hasPrefix(branchName, prefixes = DEFAULT_PREFIXES) {
|
|
844
|
-
return prefixes.some((p) => branchName.startsWith(`${p}/`));
|
|
845
|
-
}
|
|
846
|
-
function formatBranchName(prefix, name) {
|
|
847
|
-
const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
848
|
-
return `${prefix}/${sanitized}`;
|
|
849
|
-
}
|
|
850
|
-
function looksLikeNaturalLanguage(input) {
|
|
851
|
-
return input.includes(" ") && !input.includes("/");
|
|
852
|
-
}
|
|
853
|
-
|
|
854
|
-
// src/commands/start.ts
|
|
855
|
-
var start_default = defineCommand4({
|
|
1844
|
+
import { defineCommand as defineCommand5 } from "citty";
|
|
1845
|
+
import pc8 from "picocolors";
|
|
1846
|
+
var start_default = defineCommand5({
|
|
856
1847
|
meta: {
|
|
857
1848
|
name: "start",
|
|
858
|
-
description: "Create a new feature branch from the latest
|
|
1849
|
+
description: "Create a new feature branch from the latest base branch"
|
|
859
1850
|
},
|
|
860
1851
|
args: {
|
|
861
1852
|
name: {
|
|
@@ -887,50 +1878,57 @@ var start_default = defineCommand4({
|
|
|
887
1878
|
error("You have uncommitted changes. Please commit or stash them before creating a branch.");
|
|
888
1879
|
process.exit(1);
|
|
889
1880
|
}
|
|
890
|
-
const {
|
|
1881
|
+
const { branchPrefixes } = config;
|
|
1882
|
+
const baseBranch = getBaseBranch(config);
|
|
1883
|
+
const syncSource = getSyncSource(config);
|
|
891
1884
|
let branchName = args.name;
|
|
892
1885
|
heading("\uD83C\uDF3F contrib start");
|
|
893
1886
|
const useAI = !args["no-ai"] && looksLikeNaturalLanguage(branchName);
|
|
894
1887
|
if (useAI) {
|
|
895
|
-
|
|
1888
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
896
1889
|
const suggested = await suggestBranchName(branchName, args.model);
|
|
897
1890
|
if (suggested) {
|
|
1891
|
+
spinner.success("Branch name suggestion ready.");
|
|
898
1892
|
console.log(`
|
|
899
|
-
${
|
|
900
|
-
const accepted = await confirmPrompt(`Use ${
|
|
1893
|
+
${pc8.dim("AI suggestion:")} ${pc8.bold(pc8.cyan(suggested))}`);
|
|
1894
|
+
const accepted = await confirmPrompt(`Use ${pc8.bold(suggested)} as your branch name?`);
|
|
901
1895
|
if (accepted) {
|
|
902
1896
|
branchName = suggested;
|
|
903
1897
|
} else {
|
|
904
1898
|
branchName = await inputPrompt("Enter branch name", branchName);
|
|
905
1899
|
}
|
|
1900
|
+
} else {
|
|
1901
|
+
spinner.fail("AI did not return a branch name suggestion.");
|
|
906
1902
|
}
|
|
907
1903
|
}
|
|
908
1904
|
if (!hasPrefix(branchName, branchPrefixes)) {
|
|
909
|
-
const prefix = await selectPrompt(`Choose a branch type for ${
|
|
1905
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc8.bold(branchName)}:`, branchPrefixes);
|
|
910
1906
|
branchName = formatBranchName(prefix, branchName);
|
|
911
1907
|
}
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
const
|
|
1908
|
+
if (!isValidBranchName(branchName)) {
|
|
1909
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
1910
|
+
process.exit(1);
|
|
1911
|
+
}
|
|
1912
|
+
info(`Creating branch: ${pc8.bold(branchName)}`);
|
|
1913
|
+
await fetchRemote(syncSource.remote);
|
|
1914
|
+
const updateResult = await updateLocalBranch(baseBranch, syncSource.ref);
|
|
1915
|
+
if (updateResult.exitCode !== 0) {}
|
|
1916
|
+
const result = await createBranch(branchName, baseBranch);
|
|
919
1917
|
if (result.exitCode !== 0) {
|
|
920
1918
|
error(`Failed to create branch: ${result.stderr}`);
|
|
921
1919
|
process.exit(1);
|
|
922
1920
|
}
|
|
923
|
-
success(`✅ Created ${
|
|
1921
|
+
success(`✅ Created ${pc8.bold(branchName)} from latest ${pc8.bold(baseBranch)}`);
|
|
924
1922
|
}
|
|
925
1923
|
});
|
|
926
1924
|
|
|
927
1925
|
// src/commands/status.ts
|
|
928
|
-
import { defineCommand as
|
|
929
|
-
import
|
|
930
|
-
var status_default =
|
|
1926
|
+
import { defineCommand as defineCommand6 } from "citty";
|
|
1927
|
+
import pc9 from "picocolors";
|
|
1928
|
+
var status_default = defineCommand6({
|
|
931
1929
|
meta: {
|
|
932
1930
|
name: "status",
|
|
933
|
-
description: "Show sync status of
|
|
1931
|
+
description: "Show sync status of branches"
|
|
934
1932
|
},
|
|
935
1933
|
async run() {
|
|
936
1934
|
if (!await isGitRepo()) {
|
|
@@ -943,60 +1941,160 @@ var status_default = defineCommand5({
|
|
|
943
1941
|
process.exit(1);
|
|
944
1942
|
}
|
|
945
1943
|
heading("\uD83D\uDCCA contribute-now status");
|
|
1944
|
+
console.log(` ${pc9.dim("Workflow:")} ${pc9.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
|
|
1945
|
+
console.log(` ${pc9.dim("Role:")} ${pc9.bold(config.role)}`);
|
|
1946
|
+
console.log();
|
|
946
1947
|
await fetchAll();
|
|
947
1948
|
const currentBranch = await getCurrentBranch();
|
|
948
|
-
const { mainBranch,
|
|
1949
|
+
const { mainBranch, origin, upstream, workflow } = config;
|
|
1950
|
+
const baseBranch = getBaseBranch(config);
|
|
949
1951
|
const isContributor = config.role === "contributor";
|
|
950
|
-
const dirty = await
|
|
1952
|
+
const [dirty, fileStatus] = await Promise.all([
|
|
1953
|
+
hasUncommittedChanges(),
|
|
1954
|
+
getFileStatus()
|
|
1955
|
+
]);
|
|
951
1956
|
if (dirty) {
|
|
952
|
-
console.log(` ${
|
|
1957
|
+
console.log(` ${pc9.yellow("⚠")} ${pc9.yellow("Uncommitted changes in working tree")}`);
|
|
953
1958
|
console.log();
|
|
954
1959
|
}
|
|
955
1960
|
const mainRemote = `${origin}/${mainBranch}`;
|
|
956
1961
|
const mainDiv = await getDivergence(mainBranch, mainRemote);
|
|
957
1962
|
const mainStatus = formatStatus(mainBranch, mainRemote, mainDiv.ahead, mainDiv.behind);
|
|
958
1963
|
console.log(mainStatus);
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
const branchDiv = await getDivergence(currentBranch, devBranch);
|
|
970
|
-
const branchLine = formatStatus(currentBranch, devBranch, branchDiv.ahead, branchDiv.behind);
|
|
971
|
-
console.log(branchLine + pc7.dim(` (current ${pc7.green("*")})`));
|
|
1964
|
+
if (hasDevBranch(workflow) && config.devBranch) {
|
|
1965
|
+
const devRemoteRef = isContributor ? `${upstream}/${config.devBranch}` : `${origin}/${config.devBranch}`;
|
|
1966
|
+
const devDiv = await getDivergence(config.devBranch, devRemoteRef);
|
|
1967
|
+
const devLine = formatStatus(config.devBranch, devRemoteRef, devDiv.ahead, devDiv.behind);
|
|
1968
|
+
console.log(devLine);
|
|
1969
|
+
}
|
|
1970
|
+
if (currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
|
|
1971
|
+
const branchDiv = await getDivergence(currentBranch, baseBranch);
|
|
1972
|
+
const branchLine = formatStatus(currentBranch, baseBranch, branchDiv.ahead, branchDiv.behind);
|
|
1973
|
+
console.log(branchLine + pc9.dim(` (current ${pc9.green("*")})`));
|
|
972
1974
|
} else if (currentBranch) {
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1975
|
+
console.log(pc9.dim(` (on ${pc9.bold(currentBranch)} branch)`));
|
|
1976
|
+
}
|
|
1977
|
+
const hasFiles = fileStatus.staged.length > 0 || fileStatus.modified.length > 0 || fileStatus.untracked.length > 0;
|
|
1978
|
+
if (hasFiles) {
|
|
1979
|
+
console.log();
|
|
1980
|
+
if (fileStatus.staged.length > 0) {
|
|
1981
|
+
console.log(` ${pc9.green("Staged for commit:")}`);
|
|
1982
|
+
for (const { file, status } of fileStatus.staged) {
|
|
1983
|
+
console.log(` ${pc9.green("+")} ${pc9.dim(`${status}:`)} ${file}`);
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
if (fileStatus.modified.length > 0) {
|
|
1987
|
+
console.log(` ${pc9.yellow("Unstaged changes:")}`);
|
|
1988
|
+
for (const { file, status } of fileStatus.modified) {
|
|
1989
|
+
console.log(` ${pc9.yellow("~")} ${pc9.dim(`${status}:`)} ${file}`);
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
if (fileStatus.untracked.length > 0) {
|
|
1993
|
+
console.log(` ${pc9.red("Untracked files:")}`);
|
|
1994
|
+
for (const file of fileStatus.untracked) {
|
|
1995
|
+
console.log(` ${pc9.red("?")} ${file}`);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
} else if (!dirty) {
|
|
1999
|
+
console.log(` ${pc9.green("✓")} ${pc9.dim("Working tree clean")}`);
|
|
2000
|
+
}
|
|
2001
|
+
const tips = [];
|
|
2002
|
+
if (fileStatus.staged.length > 0) {
|
|
2003
|
+
tips.push(`Run ${pc9.bold("contrib commit")} to commit staged changes`);
|
|
2004
|
+
}
|
|
2005
|
+
if (fileStatus.modified.length > 0 || fileStatus.untracked.length > 0) {
|
|
2006
|
+
tips.push(`Run ${pc9.bold("contrib commit")} to stage and commit changes`);
|
|
2007
|
+
}
|
|
2008
|
+
if (fileStatus.staged.length === 0 && fileStatus.modified.length === 0 && fileStatus.untracked.length === 0 && currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
|
|
2009
|
+
const branchDiv = await getDivergence(currentBranch, `${origin}/${currentBranch}`);
|
|
2010
|
+
if (branchDiv.ahead > 0) {
|
|
2011
|
+
tips.push(`Run ${pc9.bold("contrib submit")} to push and create/update your PR`);
|
|
2012
|
+
}
|
|
2013
|
+
}
|
|
2014
|
+
if (tips.length > 0) {
|
|
2015
|
+
console.log();
|
|
2016
|
+
console.log(` ${pc9.dim("\uD83D\uDCA1 Tip:")}`);
|
|
2017
|
+
for (const tip of tips) {
|
|
2018
|
+
console.log(` ${pc9.dim(tip)}`);
|
|
977
2019
|
}
|
|
978
2020
|
}
|
|
979
2021
|
console.log();
|
|
980
2022
|
}
|
|
981
2023
|
});
|
|
982
2024
|
function formatStatus(branch, base, ahead, behind) {
|
|
983
|
-
const label =
|
|
2025
|
+
const label = pc9.bold(branch.padEnd(20));
|
|
984
2026
|
if (ahead === 0 && behind === 0) {
|
|
985
|
-
return ` ${
|
|
2027
|
+
return ` ${pc9.green("✓")} ${label} ${pc9.dim(`in sync with ${base}`)}`;
|
|
986
2028
|
}
|
|
987
2029
|
if (ahead > 0 && behind === 0) {
|
|
988
|
-
return ` ${
|
|
2030
|
+
return ` ${pc9.yellow("↑")} ${label} ${pc9.yellow(`${ahead} commit${ahead !== 1 ? "s" : ""} ahead of ${base}`)}`;
|
|
989
2031
|
}
|
|
990
2032
|
if (behind > 0 && ahead === 0) {
|
|
991
|
-
return ` ${
|
|
2033
|
+
return ` ${pc9.red("↓")} ${label} ${pc9.red(`${behind} commit${behind !== 1 ? "s" : ""} behind ${base}`)}`;
|
|
992
2034
|
}
|
|
993
|
-
return ` ${
|
|
2035
|
+
return ` ${pc9.red("⚡")} ${label} ${pc9.yellow(`${ahead} ahead`)}${pc9.dim(", ")}${pc9.red(`${behind} behind`)} ${pc9.dim(base)}`;
|
|
994
2036
|
}
|
|
995
2037
|
|
|
996
2038
|
// src/commands/submit.ts
|
|
997
|
-
import { defineCommand as
|
|
998
|
-
import
|
|
999
|
-
|
|
2039
|
+
import { defineCommand as defineCommand7 } from "citty";
|
|
2040
|
+
import pc10 from "picocolors";
|
|
2041
|
+
async function performSquashMerge(origin, baseBranch, featureBranch, options) {
|
|
2042
|
+
info(`Checking out ${pc10.bold(baseBranch)}...`);
|
|
2043
|
+
const coResult = await checkoutBranch2(baseBranch);
|
|
2044
|
+
if (coResult.exitCode !== 0) {
|
|
2045
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
2046
|
+
process.exit(1);
|
|
2047
|
+
}
|
|
2048
|
+
info(`Squash merging ${pc10.bold(featureBranch)} into ${pc10.bold(baseBranch)}...`);
|
|
2049
|
+
const mergeResult = await mergeSquash(featureBranch);
|
|
2050
|
+
if (mergeResult.exitCode !== 0) {
|
|
2051
|
+
error(`Squash merge failed: ${mergeResult.stderr}`);
|
|
2052
|
+
process.exit(1);
|
|
2053
|
+
}
|
|
2054
|
+
let message = options?.defaultMsg;
|
|
2055
|
+
if (!message) {
|
|
2056
|
+
const copilotError = await checkCopilotAvailable();
|
|
2057
|
+
if (!copilotError) {
|
|
2058
|
+
const spinner = createSpinner("Generating AI commit message for squash merge...");
|
|
2059
|
+
const [stagedDiff, stagedFiles] = await Promise.all([getStagedDiff(), getStagedFiles()]);
|
|
2060
|
+
const aiMsg = await generateCommitMessage(stagedDiff, stagedFiles, options?.model, options?.convention ?? "clean-commit");
|
|
2061
|
+
if (aiMsg) {
|
|
2062
|
+
message = aiMsg;
|
|
2063
|
+
spinner.success("AI commit message generated.");
|
|
2064
|
+
} else {
|
|
2065
|
+
spinner.fail("AI did not return a commit message.");
|
|
2066
|
+
}
|
|
2067
|
+
} else {
|
|
2068
|
+
warn(`AI unavailable: ${copilotError}`);
|
|
2069
|
+
}
|
|
2070
|
+
}
|
|
2071
|
+
const fallback = message || `squash merge ${featureBranch}`;
|
|
2072
|
+
const finalMsg = await inputPrompt("Commit message", fallback);
|
|
2073
|
+
const commitResult = await commitWithMessage(finalMsg);
|
|
2074
|
+
if (commitResult.exitCode !== 0) {
|
|
2075
|
+
error(`Commit failed: ${commitResult.stderr}`);
|
|
2076
|
+
process.exit(1);
|
|
2077
|
+
}
|
|
2078
|
+
info(`Pushing ${pc10.bold(baseBranch)} to ${origin}...`);
|
|
2079
|
+
const pushResult = await pushBranch(origin, baseBranch);
|
|
2080
|
+
if (pushResult.exitCode !== 0) {
|
|
2081
|
+
error(`Failed to push ${baseBranch}: ${pushResult.stderr}`);
|
|
2082
|
+
process.exit(1);
|
|
2083
|
+
}
|
|
2084
|
+
info(`Deleting local branch ${pc10.bold(featureBranch)}...`);
|
|
2085
|
+
const delLocal = await forceDeleteBranch(featureBranch);
|
|
2086
|
+
if (delLocal.exitCode !== 0) {
|
|
2087
|
+
warn(`Could not delete local branch: ${delLocal.stderr.trim()}`);
|
|
2088
|
+
}
|
|
2089
|
+
info(`Deleting remote branch ${pc10.bold(featureBranch)}...`);
|
|
2090
|
+
const delRemote = await deleteRemoteBranch(origin, featureBranch);
|
|
2091
|
+
if (delRemote.exitCode !== 0) {
|
|
2092
|
+
warn(`Could not delete remote branch: ${delRemote.stderr.trim()}`);
|
|
2093
|
+
}
|
|
2094
|
+
success(`✅ Squash merged ${pc10.bold(featureBranch)} into ${pc10.bold(baseBranch)} and pushed.`);
|
|
2095
|
+
info(`Run ${pc10.bold("contrib start")} to begin a new feature.`);
|
|
2096
|
+
}
|
|
2097
|
+
var submit_default = defineCommand7({
|
|
1000
2098
|
meta: {
|
|
1001
2099
|
name: "submit",
|
|
1002
2100
|
description: "Push current branch and create a pull request"
|
|
@@ -1027,75 +2125,195 @@ var submit_default = defineCommand6({
|
|
|
1027
2125
|
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
1028
2126
|
process.exit(1);
|
|
1029
2127
|
}
|
|
1030
|
-
const {
|
|
2128
|
+
const { origin } = config;
|
|
2129
|
+
const baseBranch = getBaseBranch(config);
|
|
2130
|
+
const protectedBranches = getProtectedBranches(config);
|
|
1031
2131
|
const currentBranch = await getCurrentBranch();
|
|
1032
2132
|
if (!currentBranch) {
|
|
1033
2133
|
error("Could not determine current branch.");
|
|
1034
2134
|
process.exit(1);
|
|
1035
2135
|
}
|
|
1036
|
-
if (currentBranch
|
|
1037
|
-
error(`Cannot submit ${
|
|
2136
|
+
if (protectedBranches.includes(currentBranch)) {
|
|
2137
|
+
error(`Cannot submit ${protectedBranches.map((b) => pc10.bold(b)).join(" or ")} as a PR. Switch to your feature branch.`);
|
|
1038
2138
|
process.exit(1);
|
|
1039
2139
|
}
|
|
1040
2140
|
heading("\uD83D\uDE80 contrib submit");
|
|
1041
|
-
|
|
2141
|
+
const ghInstalled = await checkGhInstalled();
|
|
2142
|
+
const ghAuthed = ghInstalled && await checkGhAuth();
|
|
2143
|
+
if (ghInstalled && ghAuthed) {
|
|
2144
|
+
const mergedPR = await getMergedPRForBranch(currentBranch);
|
|
2145
|
+
if (mergedPR) {
|
|
2146
|
+
warn(`PR #${mergedPR.number} (${pc10.bold(mergedPR.title)}) was already merged.`);
|
|
2147
|
+
const localWork = await hasLocalWork(origin, currentBranch);
|
|
2148
|
+
const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
|
|
2149
|
+
if (hasWork) {
|
|
2150
|
+
if (localWork.uncommitted) {
|
|
2151
|
+
warn("You have uncommitted changes in your working tree.");
|
|
2152
|
+
}
|
|
2153
|
+
if (localWork.unpushedCommits > 0) {
|
|
2154
|
+
warn(`You have ${pc10.bold(String(localWork.unpushedCommits))} local commit${localWork.unpushedCommits !== 1 ? "s" : ""} not in the merged PR.`);
|
|
2155
|
+
}
|
|
2156
|
+
const SAVE_NEW_BRANCH = "Save changes to a new branch";
|
|
2157
|
+
const DISCARD = "Discard all changes and clean up";
|
|
2158
|
+
const CANCEL2 = "Cancel";
|
|
2159
|
+
const action = await selectPrompt("This branch was merged but you have local changes. What would you like to do?", [SAVE_NEW_BRANCH, DISCARD, CANCEL2]);
|
|
2160
|
+
if (action === CANCEL2) {
|
|
2161
|
+
info("No changes made. You are still on your current branch.");
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
if (action === SAVE_NEW_BRANCH) {
|
|
2165
|
+
info(pc10.dim("Tip: Describe what you're working on in plain English and we'll generate a branch name."));
|
|
2166
|
+
const description = await inputPrompt("What are you working on?");
|
|
2167
|
+
let newBranchName = description;
|
|
2168
|
+
if (!args["no-ai"] && looksLikeNaturalLanguage(description)) {
|
|
2169
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
2170
|
+
const suggested = await suggestBranchName(description, args.model);
|
|
2171
|
+
if (suggested) {
|
|
2172
|
+
spinner.success("Branch name suggestion ready.");
|
|
2173
|
+
console.log(`
|
|
2174
|
+
${pc10.dim("AI suggestion:")} ${pc10.bold(pc10.cyan(suggested))}`);
|
|
2175
|
+
const accepted = await confirmPrompt(`Use ${pc10.bold(suggested)} as your branch name?`);
|
|
2176
|
+
newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
|
|
2177
|
+
} else {
|
|
2178
|
+
spinner.fail("AI did not return a suggestion.");
|
|
2179
|
+
newBranchName = await inputPrompt("Enter branch name", description);
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
if (!hasPrefix(newBranchName, config.branchPrefixes)) {
|
|
2183
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc10.bold(newBranchName)}:`, config.branchPrefixes);
|
|
2184
|
+
newBranchName = formatBranchName(prefix, newBranchName);
|
|
2185
|
+
}
|
|
2186
|
+
if (!isValidBranchName(newBranchName)) {
|
|
2187
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
2188
|
+
process.exit(1);
|
|
2189
|
+
}
|
|
2190
|
+
const renameResult = await renameBranch(currentBranch, newBranchName);
|
|
2191
|
+
if (renameResult.exitCode !== 0) {
|
|
2192
|
+
error(`Failed to rename branch: ${renameResult.stderr}`);
|
|
2193
|
+
process.exit(1);
|
|
2194
|
+
}
|
|
2195
|
+
success(`Renamed ${pc10.bold(currentBranch)} → ${pc10.bold(newBranchName)}`);
|
|
2196
|
+
const syncSource2 = getSyncSource(config);
|
|
2197
|
+
info(`Syncing ${pc10.bold(newBranchName)} with latest ${pc10.bold(baseBranch)}...`);
|
|
2198
|
+
await fetchRemote(syncSource2.remote);
|
|
2199
|
+
const savedUpstreamRef = await getUpstreamRef();
|
|
2200
|
+
const rebaseResult = savedUpstreamRef && savedUpstreamRef !== syncSource2.ref ? await rebaseOnto(syncSource2.ref, savedUpstreamRef) : await rebase(syncSource2.ref);
|
|
2201
|
+
if (rebaseResult.exitCode !== 0) {
|
|
2202
|
+
warn("Rebase encountered conflicts. Resolve them manually, then run:");
|
|
2203
|
+
info(` ${pc10.bold("git rebase --continue")}`);
|
|
2204
|
+
} else {
|
|
2205
|
+
success(`Rebased ${pc10.bold(newBranchName)} onto ${pc10.bold(syncSource2.ref)}.`);
|
|
2206
|
+
}
|
|
2207
|
+
info(`All your changes are preserved. Run ${pc10.bold("contrib submit")} when ready to create a new PR.`);
|
|
2208
|
+
return;
|
|
2209
|
+
}
|
|
2210
|
+
warn("Discarding local changes...");
|
|
2211
|
+
}
|
|
2212
|
+
const syncSource = getSyncSource(config);
|
|
2213
|
+
info(`Switching to ${pc10.bold(baseBranch)} and syncing...`);
|
|
2214
|
+
await fetchRemote(syncSource.remote);
|
|
2215
|
+
const coResult = await checkoutBranch2(baseBranch);
|
|
2216
|
+
if (coResult.exitCode !== 0) {
|
|
2217
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
2218
|
+
process.exit(1);
|
|
2219
|
+
}
|
|
2220
|
+
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
2221
|
+
success(`Synced ${pc10.bold(baseBranch)} with ${pc10.bold(syncSource.ref)}.`);
|
|
2222
|
+
info(`Deleting stale branch ${pc10.bold(currentBranch)}...`);
|
|
2223
|
+
const delResult = await forceDeleteBranch(currentBranch);
|
|
2224
|
+
if (delResult.exitCode === 0) {
|
|
2225
|
+
success(`Deleted ${pc10.bold(currentBranch)}.`);
|
|
2226
|
+
} else {
|
|
2227
|
+
warn(`Could not delete branch: ${delResult.stderr.trim()}`);
|
|
2228
|
+
}
|
|
2229
|
+
console.log();
|
|
2230
|
+
info(`You're now on ${pc10.bold(baseBranch)}. Run ${pc10.bold("contrib start")} to begin a new feature.`);
|
|
2231
|
+
return;
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
info(`Pushing ${pc10.bold(currentBranch)} to ${origin}...`);
|
|
1042
2235
|
const pushResult = await pushSetUpstream(origin, currentBranch);
|
|
1043
2236
|
if (pushResult.exitCode !== 0) {
|
|
1044
2237
|
error(`Failed to push: ${pushResult.stderr}`);
|
|
1045
2238
|
process.exit(1);
|
|
1046
2239
|
}
|
|
1047
|
-
const ghInstalled = await checkGhInstalled();
|
|
1048
|
-
const ghAuthed = ghInstalled && await checkGhAuth();
|
|
1049
2240
|
if (!ghInstalled || !ghAuthed) {
|
|
1050
2241
|
const repoInfo = await getRepoInfoFromRemote(origin);
|
|
1051
2242
|
if (repoInfo) {
|
|
1052
|
-
const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${
|
|
2243
|
+
const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${baseBranch}...${currentBranch}?expand=1`;
|
|
1053
2244
|
console.log();
|
|
1054
2245
|
info("Create your PR manually:");
|
|
1055
|
-
console.log(` ${
|
|
2246
|
+
console.log(` ${pc10.cyan(prUrl)}`);
|
|
1056
2247
|
} else {
|
|
1057
2248
|
info("gh CLI not available. Create your PR manually on GitHub.");
|
|
1058
2249
|
}
|
|
1059
2250
|
return;
|
|
1060
2251
|
}
|
|
2252
|
+
const existingPR = await getPRForBranch(currentBranch);
|
|
2253
|
+
if (existingPR) {
|
|
2254
|
+
success(`Pushed changes to existing PR #${existingPR.number}: ${pc10.bold(existingPR.title)}`);
|
|
2255
|
+
console.log(` ${pc10.cyan(existingPR.url)}`);
|
|
2256
|
+
return;
|
|
2257
|
+
}
|
|
1061
2258
|
let prTitle = null;
|
|
1062
2259
|
let prBody = null;
|
|
1063
2260
|
if (!args["no-ai"]) {
|
|
1064
|
-
const copilotError = await
|
|
2261
|
+
const [copilotError, commits, diff] = await Promise.all([
|
|
2262
|
+
checkCopilotAvailable(),
|
|
2263
|
+
getLog(baseBranch, "HEAD"),
|
|
2264
|
+
getLogDiff(baseBranch, "HEAD")
|
|
2265
|
+
]);
|
|
1065
2266
|
if (!copilotError) {
|
|
1066
|
-
|
|
1067
|
-
const
|
|
1068
|
-
const diff = await getLogDiff(devBranch, "HEAD");
|
|
1069
|
-
const result = await generatePRDescription(commits, diff, args.model);
|
|
2267
|
+
const spinner = createSpinner("Generating AI PR description...");
|
|
2268
|
+
const result = await generatePRDescription(commits, diff, args.model, config.commitConvention);
|
|
1070
2269
|
if (result) {
|
|
1071
2270
|
prTitle = result.title;
|
|
1072
2271
|
prBody = result.body;
|
|
2272
|
+
spinner.success("PR description generated.");
|
|
1073
2273
|
console.log(`
|
|
1074
|
-
${
|
|
2274
|
+
${pc10.dim("AI title:")} ${pc10.bold(pc10.cyan(prTitle))}`);
|
|
1075
2275
|
console.log(`
|
|
1076
|
-
${
|
|
1077
|
-
console.log(
|
|
2276
|
+
${pc10.dim("AI body preview:")}`);
|
|
2277
|
+
console.log(pc10.dim(prBody.slice(0, 300) + (prBody.length > 300 ? "..." : "")));
|
|
1078
2278
|
} else {
|
|
1079
|
-
|
|
2279
|
+
spinner.fail("AI did not return a PR description.");
|
|
1080
2280
|
}
|
|
1081
2281
|
} else {
|
|
1082
2282
|
warn(`AI unavailable: ${copilotError}`);
|
|
1083
2283
|
}
|
|
1084
2284
|
}
|
|
2285
|
+
const CANCEL = "Cancel";
|
|
2286
|
+
const SQUASH_LOCAL = `Squash merge to ${baseBranch} locally (no PR)`;
|
|
1085
2287
|
if (prTitle && prBody) {
|
|
1086
|
-
const
|
|
2288
|
+
const choices = [
|
|
1087
2289
|
"Use AI description",
|
|
1088
2290
|
"Edit title",
|
|
1089
2291
|
"Write manually",
|
|
1090
2292
|
"Use gh --fill (auto-fill from commits)"
|
|
1091
|
-
]
|
|
2293
|
+
];
|
|
2294
|
+
if (config.role === "maintainer")
|
|
2295
|
+
choices.push(SQUASH_LOCAL);
|
|
2296
|
+
choices.push(CANCEL);
|
|
2297
|
+
const action = await selectPrompt("What would you like to do with the PR description?", choices);
|
|
2298
|
+
if (action === CANCEL) {
|
|
2299
|
+
warn("Submit cancelled.");
|
|
2300
|
+
return;
|
|
2301
|
+
}
|
|
2302
|
+
if (action === SQUASH_LOCAL) {
|
|
2303
|
+
await performSquashMerge(origin, baseBranch, currentBranch, {
|
|
2304
|
+
defaultMsg: prTitle ?? undefined,
|
|
2305
|
+
model: args.model,
|
|
2306
|
+
convention: config.commitConvention
|
|
2307
|
+
});
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
1092
2310
|
if (action === "Use AI description") {} else if (action === "Edit title") {
|
|
1093
2311
|
prTitle = await inputPrompt("PR title", prTitle);
|
|
1094
2312
|
} else if (action === "Write manually") {
|
|
1095
2313
|
prTitle = await inputPrompt("PR title");
|
|
1096
2314
|
prBody = await inputPrompt("PR body (markdown)");
|
|
1097
2315
|
} else {
|
|
1098
|
-
const fillResult = await createPRFill(
|
|
2316
|
+
const fillResult = await createPRFill(baseBranch, args.draft);
|
|
1099
2317
|
if (fillResult.exitCode !== 0) {
|
|
1100
2318
|
error(`Failed to create PR: ${fillResult.stderr}`);
|
|
1101
2319
|
process.exit(1);
|
|
@@ -1104,12 +2322,30 @@ ${pc8.dim("AI body preview:")}`);
|
|
|
1104
2322
|
return;
|
|
1105
2323
|
}
|
|
1106
2324
|
} else {
|
|
1107
|
-
const
|
|
1108
|
-
|
|
2325
|
+
const choices = [
|
|
2326
|
+
"Write title & body manually",
|
|
2327
|
+
"Use gh --fill (auto-fill from commits)"
|
|
2328
|
+
];
|
|
2329
|
+
if (config.role === "maintainer")
|
|
2330
|
+
choices.push(SQUASH_LOCAL);
|
|
2331
|
+
choices.push(CANCEL);
|
|
2332
|
+
const action = await selectPrompt("How would you like to create the PR?", choices);
|
|
2333
|
+
if (action === CANCEL) {
|
|
2334
|
+
warn("Submit cancelled.");
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2337
|
+
if (action === SQUASH_LOCAL) {
|
|
2338
|
+
await performSquashMerge(origin, baseBranch, currentBranch, {
|
|
2339
|
+
model: args.model,
|
|
2340
|
+
convention: config.commitConvention
|
|
2341
|
+
});
|
|
2342
|
+
return;
|
|
2343
|
+
}
|
|
2344
|
+
if (action === "Write title & body manually") {
|
|
1109
2345
|
prTitle = await inputPrompt("PR title");
|
|
1110
2346
|
prBody = await inputPrompt("PR body (markdown)");
|
|
1111
2347
|
} else {
|
|
1112
|
-
const fillResult = await createPRFill(
|
|
2348
|
+
const fillResult = await createPRFill(baseBranch, args.draft);
|
|
1113
2349
|
if (fillResult.exitCode !== 0) {
|
|
1114
2350
|
error(`Failed to create PR: ${fillResult.stderr}`);
|
|
1115
2351
|
process.exit(1);
|
|
@@ -1123,7 +2359,7 @@ ${pc8.dim("AI body preview:")}`);
|
|
|
1123
2359
|
process.exit(1);
|
|
1124
2360
|
}
|
|
1125
2361
|
const prResult = await createPR({
|
|
1126
|
-
base:
|
|
2362
|
+
base: baseBranch,
|
|
1127
2363
|
title: prTitle,
|
|
1128
2364
|
body: prBody ?? "",
|
|
1129
2365
|
draft: args.draft
|
|
@@ -1137,12 +2373,12 @@ ${pc8.dim("AI body preview:")}`);
|
|
|
1137
2373
|
});
|
|
1138
2374
|
|
|
1139
2375
|
// src/commands/sync.ts
|
|
1140
|
-
import { defineCommand as
|
|
1141
|
-
import
|
|
1142
|
-
var sync_default =
|
|
2376
|
+
import { defineCommand as defineCommand8 } from "citty";
|
|
2377
|
+
import pc11 from "picocolors";
|
|
2378
|
+
var sync_default = defineCommand8({
|
|
1143
2379
|
meta: {
|
|
1144
2380
|
name: "sync",
|
|
1145
|
-
description: "
|
|
2381
|
+
description: "Sync your local branches with the remote"
|
|
1146
2382
|
},
|
|
1147
2383
|
args: {
|
|
1148
2384
|
yes: {
|
|
@@ -1162,86 +2398,70 @@ var sync_default = defineCommand7({
|
|
|
1162
2398
|
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
1163
2399
|
process.exit(1);
|
|
1164
2400
|
}
|
|
1165
|
-
const {
|
|
2401
|
+
const { workflow, role, origin } = config;
|
|
1166
2402
|
if (await hasUncommittedChanges()) {
|
|
1167
2403
|
error("You have uncommitted changes. Please commit or stash them before syncing.");
|
|
1168
2404
|
process.exit(1);
|
|
1169
2405
|
}
|
|
1170
|
-
heading(`\uD83D\uDD04 contrib sync (${role})`);
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
const ok = await confirmPrompt(`This will reset ${pc9.bold(devBranch)} to match ${pc9.bold(`${origin}/${mainBranch}`)}.`);
|
|
1186
|
-
if (!ok)
|
|
1187
|
-
process.exit(0);
|
|
1188
|
-
}
|
|
1189
|
-
const coResult = await checkoutBranch(devBranch);
|
|
1190
|
-
if (coResult.exitCode !== 0) {
|
|
1191
|
-
error(`Failed to checkout ${devBranch}: ${coResult.stderr}`);
|
|
1192
|
-
process.exit(1);
|
|
1193
|
-
}
|
|
1194
|
-
const resetResult = await resetHard(`${origin}/${mainBranch}`);
|
|
1195
|
-
if (resetResult.exitCode !== 0) {
|
|
1196
|
-
error(`Failed to reset: ${resetResult.stderr}`);
|
|
1197
|
-
process.exit(1);
|
|
1198
|
-
}
|
|
1199
|
-
const pushResult = await pushForceWithLease(origin, devBranch);
|
|
1200
|
-
if (pushResult.exitCode !== 0) {
|
|
1201
|
-
error(`Failed to push: ${pushResult.stderr}`);
|
|
1202
|
-
process.exit(1);
|
|
1203
|
-
}
|
|
1204
|
-
success(`✅ ${devBranch} has been reset to match ${origin}/${mainBranch} and pushed.`);
|
|
2406
|
+
heading(`\uD83D\uDD04 contrib sync (${workflow}, ${role})`);
|
|
2407
|
+
const baseBranch = getBaseBranch(config);
|
|
2408
|
+
const syncSource = getSyncSource(config);
|
|
2409
|
+
info(`Fetching ${syncSource.remote}...`);
|
|
2410
|
+
const fetchResult = await fetchRemote(syncSource.remote);
|
|
2411
|
+
if (fetchResult.exitCode !== 0) {
|
|
2412
|
+
error(`Failed to fetch ${syncSource.remote}: ${fetchResult.stderr}`);
|
|
2413
|
+
process.exit(1);
|
|
2414
|
+
}
|
|
2415
|
+
if (role === "contributor" && syncSource.remote !== origin) {
|
|
2416
|
+
await fetchRemote(origin);
|
|
2417
|
+
}
|
|
2418
|
+
const div = await getDivergence(baseBranch, syncSource.ref);
|
|
2419
|
+
if (div.ahead > 0 || div.behind > 0) {
|
|
2420
|
+
info(`${pc11.bold(baseBranch)} is ${pc11.yellow(`${div.ahead} ahead`)} and ${pc11.red(`${div.behind} behind`)} ${syncSource.ref}`);
|
|
1205
2421
|
} else {
|
|
1206
|
-
info(
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
2422
|
+
info(`${pc11.bold(baseBranch)} is already in sync with ${syncSource.ref}`);
|
|
2423
|
+
}
|
|
2424
|
+
if (!args.yes) {
|
|
2425
|
+
const ok = await confirmPrompt(`This will pull ${pc11.bold(syncSource.ref)} into local ${pc11.bold(baseBranch)}.`);
|
|
2426
|
+
if (!ok)
|
|
2427
|
+
process.exit(0);
|
|
2428
|
+
}
|
|
2429
|
+
const coResult = await checkoutBranch2(baseBranch);
|
|
2430
|
+
if (coResult.exitCode !== 0) {
|
|
2431
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
2432
|
+
process.exit(1);
|
|
2433
|
+
}
|
|
2434
|
+
const pullResult = await pullBranch(syncSource.remote, baseBranch);
|
|
2435
|
+
if (pullResult.exitCode !== 0) {
|
|
2436
|
+
error(`Failed to pull: ${pullResult.stderr}`);
|
|
2437
|
+
process.exit(1);
|
|
2438
|
+
}
|
|
2439
|
+
success(`✅ ${baseBranch} is now in sync with ${syncSource.ref}`);
|
|
2440
|
+
if (hasDevBranch(workflow) && role === "maintainer") {
|
|
2441
|
+
const mainDiv = await getDivergence(config.mainBranch, `${origin}/${config.mainBranch}`);
|
|
2442
|
+
if (mainDiv.behind > 0) {
|
|
2443
|
+
info(`Also syncing ${pc11.bold(config.mainBranch)}...`);
|
|
2444
|
+
const mainCoResult = await checkoutBranch2(config.mainBranch);
|
|
2445
|
+
if (mainCoResult.exitCode === 0) {
|
|
2446
|
+
const mainPullResult = await pullBranch(origin, config.mainBranch);
|
|
2447
|
+
if (mainPullResult.exitCode === 0) {
|
|
2448
|
+
success(`✅ ${config.mainBranch} is now in sync with ${origin}/${config.mainBranch}`);
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
await checkoutBranch2(baseBranch);
|
|
1231
2452
|
}
|
|
1232
|
-
success(`✅ ${devBranch} has been reset to match ${upstream}/${devBranch} and pushed.`);
|
|
1233
2453
|
}
|
|
1234
2454
|
}
|
|
1235
2455
|
});
|
|
1236
2456
|
|
|
1237
2457
|
// src/commands/update.ts
|
|
1238
|
-
import { readFileSync as
|
|
1239
|
-
import { defineCommand as
|
|
1240
|
-
import
|
|
1241
|
-
var update_default =
|
|
2458
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
2459
|
+
import { defineCommand as defineCommand9 } from "citty";
|
|
2460
|
+
import pc12 from "picocolors";
|
|
2461
|
+
var update_default = defineCommand9({
|
|
1242
2462
|
meta: {
|
|
1243
2463
|
name: "update",
|
|
1244
|
-
description: "Rebase current branch onto latest
|
|
2464
|
+
description: "Rebase current branch onto the latest base branch"
|
|
1245
2465
|
},
|
|
1246
2466
|
args: {
|
|
1247
2467
|
model: {
|
|
@@ -1264,14 +2484,16 @@ var update_default = defineCommand8({
|
|
|
1264
2484
|
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
1265
2485
|
process.exit(1);
|
|
1266
2486
|
}
|
|
1267
|
-
const
|
|
2487
|
+
const baseBranch = getBaseBranch(config);
|
|
2488
|
+
const protectedBranches = getProtectedBranches(config);
|
|
2489
|
+
const syncSource = getSyncSource(config);
|
|
1268
2490
|
const currentBranch = await getCurrentBranch();
|
|
1269
2491
|
if (!currentBranch) {
|
|
1270
2492
|
error("Could not determine current branch.");
|
|
1271
2493
|
process.exit(1);
|
|
1272
2494
|
}
|
|
1273
|
-
if (currentBranch
|
|
1274
|
-
error(`Use \`contrib sync\` to update ${
|
|
2495
|
+
if (protectedBranches.includes(currentBranch)) {
|
|
2496
|
+
error(`Use \`contrib sync\` to update ${protectedBranches.map((b) => pc12.bold(b)).join(" or ")} branches.`);
|
|
1275
2497
|
process.exit(1);
|
|
1276
2498
|
}
|
|
1277
2499
|
if (await hasUncommittedChanges()) {
|
|
@@ -1279,12 +2501,92 @@ var update_default = defineCommand8({
|
|
|
1279
2501
|
process.exit(1);
|
|
1280
2502
|
}
|
|
1281
2503
|
heading("\uD83D\uDD03 contrib update");
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
2504
|
+
const mergedPR = await getMergedPRForBranch(currentBranch);
|
|
2505
|
+
if (mergedPR) {
|
|
2506
|
+
warn(`PR #${mergedPR.number} (${pc12.bold(mergedPR.title)}) has already been merged.`);
|
|
2507
|
+
info(`Link: ${pc12.underline(mergedPR.url)}`);
|
|
2508
|
+
const localWork = await hasLocalWork(syncSource.remote, currentBranch);
|
|
2509
|
+
const hasWork = localWork.uncommitted || localWork.unpushedCommits > 0;
|
|
2510
|
+
if (hasWork) {
|
|
2511
|
+
if (localWork.uncommitted) {
|
|
2512
|
+
info("You have uncommitted local changes.");
|
|
2513
|
+
}
|
|
2514
|
+
if (localWork.unpushedCommits > 0) {
|
|
2515
|
+
info(`You have ${localWork.unpushedCommits} unpushed commit(s).`);
|
|
2516
|
+
}
|
|
2517
|
+
const SAVE_NEW_BRANCH = "Save changes to a new branch";
|
|
2518
|
+
const DISCARD = "Discard all changes and clean up";
|
|
2519
|
+
const CANCEL = "Cancel";
|
|
2520
|
+
const action = await selectPrompt(`${pc12.bold(currentBranch)} is stale but has local work. What would you like to do?`, [SAVE_NEW_BRANCH, DISCARD, CANCEL]);
|
|
2521
|
+
if (action === CANCEL) {
|
|
2522
|
+
info("No changes made. You are still on your current branch.");
|
|
2523
|
+
return;
|
|
2524
|
+
}
|
|
2525
|
+
if (action === SAVE_NEW_BRANCH) {
|
|
2526
|
+
info(pc12.dim("Tip: Describe what you're working on in plain English and we'll generate a branch name."));
|
|
2527
|
+
const description = await inputPrompt("What are you working on?");
|
|
2528
|
+
let newBranchName = description;
|
|
2529
|
+
if (!args["no-ai"] && looksLikeNaturalLanguage(description)) {
|
|
2530
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
2531
|
+
const suggested = await suggestBranchName(description, args.model);
|
|
2532
|
+
if (suggested) {
|
|
2533
|
+
spinner.success("Branch name suggestion ready.");
|
|
2534
|
+
console.log(`
|
|
2535
|
+
${pc12.dim("AI suggestion:")} ${pc12.bold(pc12.cyan(suggested))}`);
|
|
2536
|
+
const accepted = await confirmPrompt(`Use ${pc12.bold(suggested)} as your branch name?`);
|
|
2537
|
+
newBranchName = accepted ? suggested : await inputPrompt("Enter branch name", description);
|
|
2538
|
+
} else {
|
|
2539
|
+
spinner.fail("AI did not return a suggestion.");
|
|
2540
|
+
newBranchName = await inputPrompt("Enter branch name", description);
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
if (!hasPrefix(newBranchName, config.branchPrefixes)) {
|
|
2544
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc12.bold(newBranchName)}:`, config.branchPrefixes);
|
|
2545
|
+
newBranchName = formatBranchName(prefix, newBranchName);
|
|
2546
|
+
}
|
|
2547
|
+
if (!isValidBranchName(newBranchName)) {
|
|
2548
|
+
error("Invalid branch name. Use only alphanumeric characters, dots, hyphens, underscores, and slashes.");
|
|
2549
|
+
process.exit(1);
|
|
2550
|
+
}
|
|
2551
|
+
const renameResult = await renameBranch(currentBranch, newBranchName);
|
|
2552
|
+
if (renameResult.exitCode !== 0) {
|
|
2553
|
+
error(`Failed to rename branch: ${renameResult.stderr}`);
|
|
2554
|
+
process.exit(1);
|
|
2555
|
+
}
|
|
2556
|
+
success(`Renamed ${pc12.bold(currentBranch)} → ${pc12.bold(newBranchName)}`);
|
|
2557
|
+
await fetchRemote(syncSource.remote);
|
|
2558
|
+
const savedUpstreamRef = await getUpstreamRef();
|
|
2559
|
+
const rebaseResult2 = savedUpstreamRef && savedUpstreamRef !== syncSource.ref ? await rebaseOnto(syncSource.ref, savedUpstreamRef) : await rebase(syncSource.ref);
|
|
2560
|
+
if (rebaseResult2.exitCode !== 0) {
|
|
2561
|
+
warn("Rebase encountered conflicts. Resolve them manually, then run:");
|
|
2562
|
+
info(` ${pc12.bold("git rebase --continue")}`);
|
|
2563
|
+
} else {
|
|
2564
|
+
success(`Rebased ${pc12.bold(newBranchName)} onto ${pc12.bold(syncSource.ref)}.`);
|
|
2565
|
+
}
|
|
2566
|
+
info(`All your changes are preserved. Run ${pc12.bold("contrib submit")} when ready to create a new PR.`);
|
|
2567
|
+
return;
|
|
2568
|
+
}
|
|
2569
|
+
warn("Discarding local changes...");
|
|
2570
|
+
}
|
|
2571
|
+
await fetchRemote(syncSource.remote);
|
|
2572
|
+
const coResult = await checkoutBranch2(baseBranch);
|
|
2573
|
+
if (coResult.exitCode !== 0) {
|
|
2574
|
+
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
2575
|
+
process.exit(1);
|
|
2576
|
+
}
|
|
2577
|
+
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
2578
|
+
success(`Synced ${pc12.bold(baseBranch)} with ${pc12.bold(syncSource.ref)}.`);
|
|
2579
|
+
info(`Deleting stale branch ${pc12.bold(currentBranch)}...`);
|
|
2580
|
+
await forceDeleteBranch(currentBranch);
|
|
2581
|
+
success(`Deleted ${pc12.bold(currentBranch)}.`);
|
|
2582
|
+
info(`Run ${pc12.bold("contrib start")} to begin a new feature branch.`);
|
|
2583
|
+
return;
|
|
2584
|
+
}
|
|
2585
|
+
info(`Updating ${pc12.bold(currentBranch)} with latest ${pc12.bold(baseBranch)}...`);
|
|
2586
|
+
await fetchRemote(syncSource.remote);
|
|
2587
|
+
await updateLocalBranch(baseBranch, syncSource.ref);
|
|
2588
|
+
const upstreamRef = await getUpstreamRef();
|
|
2589
|
+
const rebaseResult = upstreamRef && upstreamRef !== syncSource.ref ? await rebaseOnto(syncSource.ref, upstreamRef) : await rebase(syncSource.ref);
|
|
1288
2590
|
if (rebaseResult.exitCode !== 0) {
|
|
1289
2591
|
warn("Rebase hit conflicts. Resolve them manually.");
|
|
1290
2592
|
console.log();
|
|
@@ -1296,7 +2598,7 @@ var update_default = defineCommand8({
|
|
|
1296
2598
|
let conflictDiff = "";
|
|
1297
2599
|
for (const file of conflictFiles.slice(0, 3)) {
|
|
1298
2600
|
try {
|
|
1299
|
-
const content =
|
|
2601
|
+
const content = readFileSync4(file, "utf-8");
|
|
1300
2602
|
if (content.includes("<<<<<<<")) {
|
|
1301
2603
|
conflictDiff += `
|
|
1302
2604
|
--- ${file} ---
|
|
@@ -1306,37 +2608,80 @@ ${content.slice(0, 2000)}
|
|
|
1306
2608
|
} catch {}
|
|
1307
2609
|
}
|
|
1308
2610
|
if (conflictDiff) {
|
|
2611
|
+
const spinner = createSpinner("Analyzing conflicts with AI...");
|
|
1309
2612
|
const suggestion = await suggestConflictResolution(conflictDiff, args.model);
|
|
1310
2613
|
if (suggestion) {
|
|
2614
|
+
spinner.success("AI conflict guidance ready.");
|
|
1311
2615
|
console.log(`
|
|
1312
|
-
${
|
|
1313
|
-
console.log(
|
|
2616
|
+
${pc12.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
|
|
2617
|
+
console.log(pc12.dim("─".repeat(60)));
|
|
1314
2618
|
console.log(suggestion);
|
|
1315
|
-
console.log(
|
|
2619
|
+
console.log(pc12.dim("─".repeat(60)));
|
|
1316
2620
|
console.log();
|
|
2621
|
+
} else {
|
|
2622
|
+
spinner.fail("AI could not analyze the conflicts.");
|
|
1317
2623
|
}
|
|
1318
2624
|
}
|
|
1319
2625
|
}
|
|
1320
2626
|
}
|
|
1321
|
-
console.log(
|
|
2627
|
+
console.log(pc12.bold("To resolve:"));
|
|
1322
2628
|
console.log(` 1. Fix conflicts in the affected files`);
|
|
1323
|
-
console.log(` 2. ${
|
|
1324
|
-
console.log(` 3. ${
|
|
2629
|
+
console.log(` 2. ${pc12.cyan("git add <resolved-files>")}`);
|
|
2630
|
+
console.log(` 3. ${pc12.cyan("git rebase --continue")}`);
|
|
1325
2631
|
console.log();
|
|
1326
|
-
console.log(` Or abort: ${
|
|
2632
|
+
console.log(` Or abort: ${pc12.cyan("git rebase --abort")}`);
|
|
2633
|
+
process.exit(1);
|
|
2634
|
+
}
|
|
2635
|
+
success(`✅ ${pc12.bold(currentBranch)} has been rebased onto latest ${pc12.bold(baseBranch)}`);
|
|
2636
|
+
}
|
|
2637
|
+
});
|
|
2638
|
+
|
|
2639
|
+
// src/commands/validate.ts
|
|
2640
|
+
import { defineCommand as defineCommand10 } from "citty";
|
|
2641
|
+
import pc13 from "picocolors";
|
|
2642
|
+
var validate_default = defineCommand10({
|
|
2643
|
+
meta: {
|
|
2644
|
+
name: "validate",
|
|
2645
|
+
description: "Validate a commit message against the configured convention"
|
|
2646
|
+
},
|
|
2647
|
+
args: {
|
|
2648
|
+
message: {
|
|
2649
|
+
type: "positional",
|
|
2650
|
+
description: "The commit message to validate",
|
|
2651
|
+
required: true
|
|
2652
|
+
}
|
|
2653
|
+
},
|
|
2654
|
+
async run({ args }) {
|
|
2655
|
+
const config = readConfig();
|
|
2656
|
+
if (!config) {
|
|
2657
|
+
error("No .contributerc.json found. Run `contrib setup` first.");
|
|
1327
2658
|
process.exit(1);
|
|
1328
2659
|
}
|
|
1329
|
-
|
|
2660
|
+
const convention = config.commitConvention;
|
|
2661
|
+
if (convention === "none") {
|
|
2662
|
+
info('Commit convention is set to "none". All messages are accepted.');
|
|
2663
|
+
process.exit(0);
|
|
2664
|
+
}
|
|
2665
|
+
const message = args.message;
|
|
2666
|
+
if (validateCommitMessage(message, convention)) {
|
|
2667
|
+
success(`Valid ${CONVENTION_LABELS[convention]} message.`);
|
|
2668
|
+
process.exit(0);
|
|
2669
|
+
}
|
|
2670
|
+
const errors = getValidationError(convention);
|
|
2671
|
+
for (const line of errors) {
|
|
2672
|
+
console.error(pc13.red(` ✗ ${line}`));
|
|
2673
|
+
}
|
|
2674
|
+
process.exit(1);
|
|
1330
2675
|
}
|
|
1331
2676
|
});
|
|
1332
2677
|
|
|
1333
2678
|
// src/ui/banner.ts
|
|
1334
2679
|
import figlet from "figlet";
|
|
1335
|
-
import
|
|
2680
|
+
import pc14 from "picocolors";
|
|
1336
2681
|
// package.json
|
|
1337
2682
|
var package_default = {
|
|
1338
2683
|
name: "contribute-now",
|
|
1339
|
-
version: "0.
|
|
2684
|
+
version: "0.2.0-dev.33be40f",
|
|
1340
2685
|
description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
|
|
1341
2686
|
type: "module",
|
|
1342
2687
|
bin: {
|
|
@@ -1348,12 +2693,12 @@ var package_default = {
|
|
|
1348
2693
|
],
|
|
1349
2694
|
scripts: {
|
|
1350
2695
|
build: "bun build src/index.ts --outfile dist/index.js --target node --packages external",
|
|
2696
|
+
cli: "bun run src/index.ts --",
|
|
1351
2697
|
dev: "bun src/index.ts",
|
|
1352
2698
|
test: "bun test",
|
|
1353
2699
|
lint: "biome check .",
|
|
1354
2700
|
"lint:fix": "biome check --write .",
|
|
1355
2701
|
format: "biome format --write .",
|
|
1356
|
-
prepare: "husky || true",
|
|
1357
2702
|
"www:dev": "bun run --cwd www dev",
|
|
1358
2703
|
"www:build": "bun run --cwd www build",
|
|
1359
2704
|
"www:preview": "bun run --cwd www preview"
|
|
@@ -1380,6 +2725,7 @@ var package_default = {
|
|
|
1380
2725
|
url: "git+https://github.com/warengonzaga/contribute-now.git"
|
|
1381
2726
|
},
|
|
1382
2727
|
dependencies: {
|
|
2728
|
+
"@clack/prompts": "^1.0.1",
|
|
1383
2729
|
"@github/copilot-sdk": "^0.1.25",
|
|
1384
2730
|
"@wgtechlabs/log-engine": "^2.3.1",
|
|
1385
2731
|
citty: "^0.1.6",
|
|
@@ -1390,7 +2736,6 @@ var package_default = {
|
|
|
1390
2736
|
"@biomejs/biome": "^2.4.4",
|
|
1391
2737
|
"@types/bun": "latest",
|
|
1392
2738
|
"@types/figlet": "^1.7.0",
|
|
1393
|
-
husky: "^9.1.7",
|
|
1394
2739
|
typescript: "^5.7.0"
|
|
1395
2740
|
}
|
|
1396
2741
|
};
|
|
@@ -1398,9 +2743,10 @@ var package_default = {
|
|
|
1398
2743
|
// src/ui/banner.ts
|
|
1399
2744
|
var LOGO;
|
|
1400
2745
|
try {
|
|
1401
|
-
LOGO = figlet.textSync(
|
|
2746
|
+
LOGO = figlet.textSync(`Contribute
|
|
2747
|
+
Now`, { font: "ANSI Shadow" });
|
|
1402
2748
|
} catch {
|
|
1403
|
-
LOGO = "
|
|
2749
|
+
LOGO = "Contribute Now";
|
|
1404
2750
|
}
|
|
1405
2751
|
function getVersion() {
|
|
1406
2752
|
return package_default.version ?? "unknown";
|
|
@@ -1408,16 +2754,15 @@ function getVersion() {
|
|
|
1408
2754
|
function getAuthor() {
|
|
1409
2755
|
return typeof package_default.author === "string" ? package_default.author : "unknown";
|
|
1410
2756
|
}
|
|
1411
|
-
function showBanner(
|
|
1412
|
-
console.log(
|
|
2757
|
+
function showBanner(showLinks = false) {
|
|
2758
|
+
console.log(pc14.cyan(`
|
|
1413
2759
|
${LOGO}`));
|
|
1414
|
-
console.log(` ${
|
|
1415
|
-
if (
|
|
1416
|
-
console.log(` ${pc11.dim(package_default.description)}`);
|
|
2760
|
+
console.log(` ${pc14.dim(`v${getVersion()}`)} ${pc14.dim("—")} ${pc14.dim(`Built by ${getAuthor()}`)}`);
|
|
2761
|
+
if (showLinks) {
|
|
1417
2762
|
console.log();
|
|
1418
|
-
console.log(` ${
|
|
1419
|
-
console.log(` ${
|
|
1420
|
-
console.log(` ${
|
|
2763
|
+
console.log(` ${pc14.yellow("Star")} ${pc14.cyan("https://github.com/warengonzaga/contribute-now")}`);
|
|
2764
|
+
console.log(` ${pc14.green("Contribute")} ${pc14.cyan("https://github.com/warengonzaga/contribute-now/blob/main/CONTRIBUTING.md")}`);
|
|
2765
|
+
console.log(` ${pc14.magenta("Sponsor")} ${pc14.cyan("https://warengonzaga.com/sponsor")}`);
|
|
1421
2766
|
}
|
|
1422
2767
|
console.log();
|
|
1423
2768
|
}
|
|
@@ -1425,11 +2770,11 @@ ${LOGO}`));
|
|
|
1425
2770
|
// src/index.ts
|
|
1426
2771
|
var isHelp = process.argv.includes("--help") || process.argv.includes("-h");
|
|
1427
2772
|
showBanner(isHelp);
|
|
1428
|
-
var main =
|
|
2773
|
+
var main = defineCommand11({
|
|
1429
2774
|
meta: {
|
|
1430
2775
|
name: "contrib",
|
|
1431
2776
|
version: getVersion(),
|
|
1432
|
-
description: "Git workflow CLI
|
|
2777
|
+
description: "Git workflow CLI that guides contributors through clean branching, commits, and PRs."
|
|
1433
2778
|
},
|
|
1434
2779
|
args: {
|
|
1435
2780
|
version: {
|
|
@@ -1446,7 +2791,9 @@ var main = defineCommand9({
|
|
|
1446
2791
|
update: update_default,
|
|
1447
2792
|
submit: submit_default,
|
|
1448
2793
|
clean: clean_default,
|
|
1449
|
-
status: status_default
|
|
2794
|
+
status: status_default,
|
|
2795
|
+
hook: hook_default,
|
|
2796
|
+
validate: validate_default
|
|
1450
2797
|
},
|
|
1451
2798
|
run({ args }) {
|
|
1452
2799
|
if (args.version) {
|
|
@@ -1454,4 +2801,6 @@ var main = defineCommand9({
|
|
|
1454
2801
|
}
|
|
1455
2802
|
}
|
|
1456
2803
|
});
|
|
1457
|
-
runMain(main)
|
|
2804
|
+
runMain(main).then(() => {
|
|
2805
|
+
process.exit(0);
|
|
2806
|
+
});
|