contribute-now 0.1.2-staging.c209cc7 → 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/dist/index.js +1573 -516
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -5,7 +5,23 @@ 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
|
}
|
|
@@ -56,68 +76,503 @@ function getDefaultConfig() {
|
|
|
56
76
|
}
|
|
57
77
|
|
|
58
78
|
// src/utils/confirm.ts
|
|
79
|
+
import * as clack from "@clack/prompts";
|
|
59
80
|
import pc from "picocolors";
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const response = await new Promise((resolve) => {
|
|
65
|
-
process.stdin.setEncoding("utf-8");
|
|
66
|
-
process.stdin.once("data", (data) => {
|
|
67
|
-
process.stdin.pause();
|
|
68
|
-
resolve(data.toString().trim());
|
|
69
|
-
});
|
|
70
|
-
process.stdin.resume();
|
|
71
|
-
});
|
|
72
|
-
if (response.toLowerCase() !== "y") {
|
|
73
|
-
console.log(pc.yellow("Aborted."));
|
|
74
|
-
return false;
|
|
81
|
+
function handleCancel(value) {
|
|
82
|
+
if (clack.isCancel(value)) {
|
|
83
|
+
clack.cancel("Cancelled.");
|
|
84
|
+
process.exit(0);
|
|
75
85
|
}
|
|
76
|
-
|
|
86
|
+
}
|
|
87
|
+
async function confirmPrompt(message) {
|
|
88
|
+
const result = await clack.confirm({ message });
|
|
89
|
+
handleCancel(result);
|
|
90
|
+
return result;
|
|
77
91
|
}
|
|
78
92
|
async function selectPrompt(message, choices) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
93
|
+
const result = await clack.select({
|
|
94
|
+
message,
|
|
95
|
+
options: choices.map((choice) => ({ value: choice, label: choice }))
|
|
96
|
+
});
|
|
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
|
|
83
105
|
});
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
114
|
+
});
|
|
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>"
|
|
145
|
+
}
|
|
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`;
|
|
155
|
+
}
|
|
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);
|
|
90
195
|
});
|
|
91
|
-
process.stdin.resume();
|
|
92
196
|
});
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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}`;
|
|
96
222
|
}
|
|
97
|
-
return choices[0];
|
|
98
223
|
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 ? `
|
|
301
|
+
|
|
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
|
|
430
|
+
import { execFile as execFileCb } from "node:child_process";
|
|
431
|
+
function run(args) {
|
|
432
|
+
return new Promise((resolve) => {
|
|
433
|
+
execFileCb("gh", args, (error, stdout, stderr) => {
|
|
434
|
+
resolve({
|
|
435
|
+
exitCode: error ? error.code === "ENOENT" ? 127 : error.status ?? 1 : 0,
|
|
436
|
+
stdout: stdout ?? "",
|
|
437
|
+
stderr: stderr ?? ""
|
|
438
|
+
});
|
|
108
439
|
});
|
|
109
|
-
process.stdin.resume();
|
|
110
440
|
});
|
|
111
|
-
|
|
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
|
+
}
|
|
112
565
|
}
|
|
113
566
|
|
|
114
567
|
// src/utils/git.ts
|
|
115
|
-
import { execFile as
|
|
116
|
-
|
|
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) {
|
|
117
572
|
return new Promise((resolve) => {
|
|
118
|
-
|
|
573
|
+
execFileCb2("git", args, (error, stdout, stderr) => {
|
|
119
574
|
resolve({
|
|
120
|
-
exitCode: error ? error.code === "ENOENT" ? 127 : error.
|
|
575
|
+
exitCode: error ? error.code === "ENOENT" ? 127 : error.status ?? 1 : 0,
|
|
121
576
|
stdout: stdout ?? "",
|
|
122
577
|
stderr: stderr ?? ""
|
|
123
578
|
});
|
|
@@ -125,76 +580,100 @@ function run(args) {
|
|
|
125
580
|
});
|
|
126
581
|
}
|
|
127
582
|
async function isGitRepo() {
|
|
128
|
-
const { exitCode } = await
|
|
583
|
+
const { exitCode } = await run2(["rev-parse", "--is-inside-work-tree"]);
|
|
129
584
|
return exitCode === 0;
|
|
130
585
|
}
|
|
131
586
|
async function getCurrentBranch() {
|
|
132
|
-
const { exitCode, stdout } = await
|
|
587
|
+
const { exitCode, stdout } = await run2(["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
133
588
|
if (exitCode !== 0)
|
|
134
589
|
return null;
|
|
135
590
|
return stdout.trim() || null;
|
|
136
591
|
}
|
|
137
592
|
async function getRemotes() {
|
|
138
|
-
const { exitCode, stdout } = await
|
|
593
|
+
const { exitCode, stdout } = await run2(["remote"]);
|
|
139
594
|
if (exitCode !== 0)
|
|
140
595
|
return [];
|
|
141
596
|
return stdout.trim().split(`
|
|
142
597
|
`).map((r) => r.trim()).filter(Boolean);
|
|
143
598
|
}
|
|
144
599
|
async function getRemoteUrl(remote) {
|
|
145
|
-
const { exitCode, stdout } = await
|
|
600
|
+
const { exitCode, stdout } = await run2(["remote", "get-url", remote]);
|
|
146
601
|
if (exitCode !== 0)
|
|
147
602
|
return null;
|
|
148
603
|
return stdout.trim() || null;
|
|
149
604
|
}
|
|
150
605
|
async function hasUncommittedChanges() {
|
|
151
|
-
const { exitCode, stdout } = await
|
|
606
|
+
const { exitCode, stdout } = await run2(["status", "--porcelain"]);
|
|
152
607
|
if (exitCode !== 0)
|
|
153
608
|
return false;
|
|
154
609
|
return stdout.trim().length > 0;
|
|
155
610
|
}
|
|
156
611
|
async function fetchRemote(remote) {
|
|
157
|
-
return
|
|
612
|
+
return run2(["fetch", remote]);
|
|
158
613
|
}
|
|
159
614
|
async function fetchAll() {
|
|
160
|
-
return
|
|
615
|
+
return run2(["fetch", "--all", "--quiet"]);
|
|
161
616
|
}
|
|
162
|
-
async function
|
|
163
|
-
return
|
|
617
|
+
async function checkoutBranch2(branch) {
|
|
618
|
+
return run2(["checkout", branch]);
|
|
164
619
|
}
|
|
165
620
|
async function createBranch(branch, from) {
|
|
166
621
|
const args = from ? ["checkout", "-b", branch, from] : ["checkout", "-b", branch];
|
|
167
|
-
return
|
|
622
|
+
return run2(args);
|
|
168
623
|
}
|
|
169
624
|
async function resetHard(ref) {
|
|
170
|
-
return
|
|
625
|
+
return run2(["reset", "--hard", ref]);
|
|
626
|
+
}
|
|
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]);
|
|
171
633
|
}
|
|
172
634
|
async function pushSetUpstream(remote, branch) {
|
|
173
|
-
return
|
|
635
|
+
return run2(["push", "-u", remote, branch]);
|
|
174
636
|
}
|
|
175
637
|
async function rebase(branch) {
|
|
176
|
-
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]);
|
|
177
648
|
}
|
|
178
649
|
async function getStagedDiff() {
|
|
179
|
-
const { stdout } = await
|
|
650
|
+
const { stdout } = await run2(["diff", "--cached"]);
|
|
180
651
|
return stdout;
|
|
181
652
|
}
|
|
182
653
|
async function getStagedFiles() {
|
|
183
|
-
const { exitCode, stdout } = await
|
|
654
|
+
const { exitCode, stdout } = await run2(["diff", "--cached", "--name-only"]);
|
|
184
655
|
if (exitCode !== 0)
|
|
185
656
|
return [];
|
|
186
657
|
return stdout.trim().split(`
|
|
187
658
|
`).filter(Boolean);
|
|
188
659
|
}
|
|
189
660
|
async function getChangedFiles() {
|
|
190
|
-
const { exitCode, stdout } = await
|
|
661
|
+
const { exitCode, stdout } = await run2(["status", "--porcelain"]);
|
|
191
662
|
if (exitCode !== 0)
|
|
192
663
|
return [];
|
|
193
|
-
return stdout.
|
|
194
|
-
`).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);
|
|
195
674
|
}
|
|
196
675
|
async function getDivergence(branch, base) {
|
|
197
|
-
const { exitCode, stdout } = await
|
|
676
|
+
const { exitCode, stdout } = await run2([
|
|
198
677
|
"rev-list",
|
|
199
678
|
"--left-right",
|
|
200
679
|
"--count",
|
|
@@ -209,34 +688,142 @@ async function getDivergence(branch, base) {
|
|
|
209
688
|
};
|
|
210
689
|
}
|
|
211
690
|
async function getMergedBranches(base) {
|
|
212
|
-
const { exitCode, stdout } = await
|
|
691
|
+
const { exitCode, stdout } = await run2(["branch", "--merged", base]);
|
|
213
692
|
if (exitCode !== 0)
|
|
214
693
|
return [];
|
|
215
694
|
return stdout.trim().split(`
|
|
216
695
|
`).map((b) => b.replace(/^\*?\s+/, "").trim()).filter(Boolean);
|
|
217
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
|
+
}
|
|
218
704
|
async function deleteBranch(branch) {
|
|
219
|
-
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]);
|
|
220
728
|
}
|
|
221
729
|
async function pruneRemote(remote) {
|
|
222
|
-
return
|
|
730
|
+
return run2(["remote", "prune", remote]);
|
|
223
731
|
}
|
|
224
732
|
async function commitWithMessage(message) {
|
|
225
|
-
return
|
|
733
|
+
return run2(["commit", "-m", message]);
|
|
226
734
|
}
|
|
227
735
|
async function getLogDiff(base, head) {
|
|
228
|
-
const { stdout } = await
|
|
736
|
+
const { stdout } = await run2(["diff", `${base}...${head}`]);
|
|
229
737
|
return stdout;
|
|
230
738
|
}
|
|
231
739
|
async function getLog(base, head) {
|
|
232
|
-
const { exitCode, stdout } = await
|
|
740
|
+
const { exitCode, stdout } = await run2(["log", `${base}..${head}`, "--oneline"]);
|
|
233
741
|
if (exitCode !== 0)
|
|
234
742
|
return [];
|
|
235
743
|
return stdout.trim().split(`
|
|
236
744
|
`).filter(Boolean);
|
|
237
745
|
}
|
|
238
746
|
async function pullBranch(remote, branch) {
|
|
239
|
-
return
|
|
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;
|
|
240
827
|
}
|
|
241
828
|
|
|
242
829
|
// src/utils/logger.ts
|
|
@@ -267,6 +854,53 @@ function heading(msg) {
|
|
|
267
854
|
${pc2.bold(msg)}`);
|
|
268
855
|
}
|
|
269
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
|
+
|
|
270
904
|
// src/utils/workflow.ts
|
|
271
905
|
var WORKFLOW_DESCRIPTIONS = {
|
|
272
906
|
"clean-flow": "Clean Flow — main + dev, squash features into dev, merge dev into main",
|
|
@@ -315,6 +949,91 @@ function getProtectedBranches(config) {
|
|
|
315
949
|
}
|
|
316
950
|
|
|
317
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
|
+
}
|
|
318
1037
|
var clean_default = defineCommand({
|
|
319
1038
|
meta: {
|
|
320
1039
|
name: "clean",
|
|
@@ -340,47 +1059,114 @@ var clean_default = defineCommand({
|
|
|
340
1059
|
}
|
|
341
1060
|
const { origin } = config;
|
|
342
1061
|
const baseBranch = getBaseBranch(config);
|
|
343
|
-
|
|
1062
|
+
let currentBranch = await getCurrentBranch();
|
|
344
1063
|
heading("\uD83E\uDDF9 contrib clean");
|
|
345
|
-
|
|
346
|
-
const
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
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.`);
|
|
350
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) {
|
|
351
1089
|
console.log(`
|
|
352
|
-
${
|
|
353
|
-
for (const b of
|
|
354
|
-
|
|
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}`);
|
|
355
1094
|
}
|
|
356
1095
|
console.log();
|
|
357
|
-
const ok = args.yes || await confirmPrompt(`Delete ${
|
|
358
|
-
if (
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
+
}
|
|
362
1111
|
const result = await deleteBranch(branch);
|
|
363
1112
|
if (result.exitCode === 0) {
|
|
364
|
-
success(` Deleted ${
|
|
1113
|
+
success(` Deleted ${pc4.bold(branch)}`);
|
|
365
1114
|
} else {
|
|
366
1115
|
warn(` Failed to delete ${branch}: ${result.stderr.trim()}`);
|
|
367
1116
|
}
|
|
368
1117
|
}
|
|
1118
|
+
} else {
|
|
1119
|
+
info("Skipped merged branch deletion.");
|
|
369
1120
|
}
|
|
370
1121
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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.`);
|
|
377
1163
|
}
|
|
378
1164
|
}
|
|
379
1165
|
});
|
|
380
1166
|
|
|
381
1167
|
// src/commands/commit.ts
|
|
382
1168
|
import { defineCommand as defineCommand2 } from "citty";
|
|
383
|
-
import
|
|
1169
|
+
import pc5 from "picocolors";
|
|
384
1170
|
|
|
385
1171
|
// src/utils/convention.ts
|
|
386
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;
|
|
@@ -425,203 +1211,6 @@ function getValidationError(convention) {
|
|
|
425
1211
|
];
|
|
426
1212
|
}
|
|
427
1213
|
|
|
428
|
-
// src/utils/copilot.ts
|
|
429
|
-
import { CopilotClient } from "@github/copilot-sdk";
|
|
430
|
-
var CONVENTIONAL_COMMIT_SYSTEM_PROMPT = `You are a git commit message generator. Generate a Conventional Commit message following this exact format:
|
|
431
|
-
<type>[!][(<scope>)]: <description>
|
|
432
|
-
|
|
433
|
-
Types:
|
|
434
|
-
feat – a new feature
|
|
435
|
-
fix – a bug fix
|
|
436
|
-
docs – documentation only changes
|
|
437
|
-
style – changes that do not affect code meaning (whitespace, formatting)
|
|
438
|
-
refactor – code change that neither fixes a bug nor adds a feature
|
|
439
|
-
perf – performance improvement
|
|
440
|
-
test – adding or correcting tests
|
|
441
|
-
build – changes to the build system or external dependencies
|
|
442
|
-
ci – changes to CI configuration files and scripts
|
|
443
|
-
chore – other changes that don't modify src or test files
|
|
444
|
-
revert – reverts a previous commit
|
|
445
|
-
|
|
446
|
-
Rules:
|
|
447
|
-
- Breaking change (!) only for: feat, fix, refactor, perf
|
|
448
|
-
- Description: concise, imperative mood, max 72 chars, lowercase start
|
|
449
|
-
- Scope: optional, camelCase or kebab-case component name
|
|
450
|
-
- Return ONLY the commit message line, nothing else
|
|
451
|
-
|
|
452
|
-
Examples:
|
|
453
|
-
feat: add user authentication system
|
|
454
|
-
fix(auth): resolve token expiry issue
|
|
455
|
-
docs: update contributing guidelines
|
|
456
|
-
feat!: redesign authentication API`;
|
|
457
|
-
var CLEAN_COMMIT_SYSTEM_PROMPT = `You are a git commit message generator. Generate a Clean Commit message following this exact format:
|
|
458
|
-
<emoji> <type>[!][(<scope>)]: <description>
|
|
459
|
-
|
|
460
|
-
Emoji and type table:
|
|
461
|
-
\uD83D\uDCE6 new – new features, files, or capabilities
|
|
462
|
-
\uD83D\uDD27 update – changes, refactoring, improvements
|
|
463
|
-
\uD83D\uDDD1️ remove – removing code, files, or dependencies
|
|
464
|
-
\uD83D\uDD12 security – security fixes or patches
|
|
465
|
-
⚙️ setup – configs, CI/CD, tooling, build systems
|
|
466
|
-
☕ chore – maintenance, dependency updates
|
|
467
|
-
\uD83E\uDDEA test – adding or updating tests
|
|
468
|
-
\uD83D\uDCD6 docs – documentation changes
|
|
469
|
-
\uD83D\uDE80 release – version releases
|
|
470
|
-
|
|
471
|
-
Rules:
|
|
472
|
-
- Breaking change (!) only for: new, update, remove, security
|
|
473
|
-
- Description: concise, imperative mood, max 72 chars
|
|
474
|
-
- Scope: optional, camelCase or kebab-case component name
|
|
475
|
-
- Return ONLY the commit message line, nothing else
|
|
476
|
-
|
|
477
|
-
Examples:
|
|
478
|
-
\uD83D\uDCE6 new: user authentication system
|
|
479
|
-
\uD83D\uDD27 update (api): improve error handling
|
|
480
|
-
⚙️ setup (ci): configure github actions workflow
|
|
481
|
-
\uD83D\uDCE6 new!: completely redesign authentication system`;
|
|
482
|
-
var BRANCH_NAME_SYSTEM_PROMPT = `You are a git branch name generator. Convert natural language descriptions into proper git branch names.
|
|
483
|
-
|
|
484
|
-
Format: <prefix>/<kebab-case-name>
|
|
485
|
-
Prefixes: feature, fix, docs, chore, test, refactor
|
|
486
|
-
|
|
487
|
-
Rules:
|
|
488
|
-
- Use lowercase kebab-case for the name part
|
|
489
|
-
- Keep it short and descriptive (2-5 words max)
|
|
490
|
-
- Return ONLY the branch name, nothing else
|
|
491
|
-
|
|
492
|
-
Examples:
|
|
493
|
-
Input: "fix the login timeout bug" → fix/login-timeout
|
|
494
|
-
Input: "add user profile page" → feature/user-profile-page
|
|
495
|
-
Input: "update readme documentation" → docs/update-readme`;
|
|
496
|
-
var PR_DESCRIPTION_SYSTEM_PROMPT = `You are a GitHub pull request description generator. Create a clear, structured PR description.
|
|
497
|
-
|
|
498
|
-
Return a JSON object with this exact structure:
|
|
499
|
-
{
|
|
500
|
-
"title": "Brief PR title (50 chars max)",
|
|
501
|
-
"body": "## Summary\\n...\\n\\n## Changes\\n...\\n\\n## Test Plan\\n..."
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
Rules:
|
|
505
|
-
- title: concise, present tense, describes what the PR does
|
|
506
|
-
- body: markdown with Summary, Changes (bullet list), and Test Plan sections
|
|
507
|
-
- Return ONLY the JSON object, no markdown fences, no extra text`;
|
|
508
|
-
var CONFLICT_RESOLUTION_SYSTEM_PROMPT = `You are a git merge conflict resolution advisor. Analyze the conflict markers and provide guidance.
|
|
509
|
-
|
|
510
|
-
Rules:
|
|
511
|
-
- Explain what each side of the conflict contains
|
|
512
|
-
- Suggest the most likely correct resolution strategy
|
|
513
|
-
- Never auto-resolve — provide guidance only
|
|
514
|
-
- Be concise and actionable`;
|
|
515
|
-
async function checkCopilotAvailable() {
|
|
516
|
-
let client = null;
|
|
517
|
-
try {
|
|
518
|
-
client = new CopilotClient;
|
|
519
|
-
await client.start();
|
|
520
|
-
} catch (err) {
|
|
521
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
522
|
-
if (msg.includes("ENOENT") || msg.includes("not found")) {
|
|
523
|
-
return "Copilot CLI binary not found. Ensure GitHub Copilot is installed and your gh CLI is up to date.";
|
|
524
|
-
}
|
|
525
|
-
return `Failed to start Copilot service: ${msg}`;
|
|
526
|
-
}
|
|
527
|
-
try {
|
|
528
|
-
await client.ping();
|
|
529
|
-
} catch (err) {
|
|
530
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
531
|
-
if (msg.includes("auth") || msg.includes("token") || msg.includes("401") || msg.includes("403")) {
|
|
532
|
-
return "Copilot authentication failed. Run `gh auth login` to refresh your token.";
|
|
533
|
-
}
|
|
534
|
-
if (msg.includes("ECONNREFUSED") || msg.includes("timeout") || msg.includes("network")) {
|
|
535
|
-
return "Could not reach GitHub Copilot service. Check your internet connection.";
|
|
536
|
-
}
|
|
537
|
-
return `Copilot health check failed: ${msg}`;
|
|
538
|
-
} finally {
|
|
539
|
-
try {
|
|
540
|
-
await client.stop();
|
|
541
|
-
} catch {}
|
|
542
|
-
}
|
|
543
|
-
return null;
|
|
544
|
-
}
|
|
545
|
-
async function callCopilot(systemMessage, userMessage, model) {
|
|
546
|
-
const client = new CopilotClient;
|
|
547
|
-
await client.start();
|
|
548
|
-
try {
|
|
549
|
-
const sessionConfig = {
|
|
550
|
-
systemMessage: { content: systemMessage }
|
|
551
|
-
};
|
|
552
|
-
if (model)
|
|
553
|
-
sessionConfig.model = model;
|
|
554
|
-
const session = await client.createSession(sessionConfig);
|
|
555
|
-
try {
|
|
556
|
-
const response = await session.sendAndWait({ content: userMessage });
|
|
557
|
-
if (!response?.data?.content)
|
|
558
|
-
return null;
|
|
559
|
-
return response.data.content;
|
|
560
|
-
} finally {
|
|
561
|
-
await session.destroy();
|
|
562
|
-
}
|
|
563
|
-
} finally {
|
|
564
|
-
await client.stop();
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
function getCommitSystemPrompt(convention) {
|
|
568
|
-
if (convention === "conventional")
|
|
569
|
-
return CONVENTIONAL_COMMIT_SYSTEM_PROMPT;
|
|
570
|
-
return CLEAN_COMMIT_SYSTEM_PROMPT;
|
|
571
|
-
}
|
|
572
|
-
async function generateCommitMessage(diff, stagedFiles, model, convention = "clean-commit") {
|
|
573
|
-
try {
|
|
574
|
-
const userMessage = `Generate a commit message for these staged changes:
|
|
575
|
-
|
|
576
|
-
Files: ${stagedFiles.join(", ")}
|
|
577
|
-
|
|
578
|
-
Diff:
|
|
579
|
-
${diff.slice(0, 4000)}`;
|
|
580
|
-
const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
|
|
581
|
-
return result?.trim() ?? null;
|
|
582
|
-
} catch {
|
|
583
|
-
return null;
|
|
584
|
-
}
|
|
585
|
-
}
|
|
586
|
-
async function generatePRDescription(commits, diff, model) {
|
|
587
|
-
try {
|
|
588
|
-
const userMessage = `Generate a PR description for these changes:
|
|
589
|
-
|
|
590
|
-
Commits:
|
|
591
|
-
${commits.join(`
|
|
592
|
-
`)}
|
|
593
|
-
|
|
594
|
-
Diff (truncated):
|
|
595
|
-
${diff.slice(0, 4000)}`;
|
|
596
|
-
const result = await callCopilot(PR_DESCRIPTION_SYSTEM_PROMPT, userMessage, model);
|
|
597
|
-
if (!result)
|
|
598
|
-
return null;
|
|
599
|
-
const cleaned = result.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
|
|
600
|
-
return JSON.parse(cleaned);
|
|
601
|
-
} catch {
|
|
602
|
-
return null;
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
async function suggestBranchName(description, model) {
|
|
606
|
-
try {
|
|
607
|
-
const result = await callCopilot(BRANCH_NAME_SYSTEM_PROMPT, description, model);
|
|
608
|
-
return result?.trim() ?? null;
|
|
609
|
-
} catch {
|
|
610
|
-
return null;
|
|
611
|
-
}
|
|
612
|
-
}
|
|
613
|
-
async function suggestConflictResolution(conflictDiff, model) {
|
|
614
|
-
try {
|
|
615
|
-
const userMessage = `Help me resolve this merge conflict:
|
|
616
|
-
|
|
617
|
-
${conflictDiff.slice(0, 4000)}`;
|
|
618
|
-
const result = await callCopilot(CONFLICT_RESOLUTION_SYSTEM_PROMPT, userMessage, model);
|
|
619
|
-
return result?.trim() ?? null;
|
|
620
|
-
} catch {
|
|
621
|
-
return null;
|
|
622
|
-
}
|
|
623
|
-
}
|
|
624
|
-
|
|
625
1214
|
// src/commands/commit.ts
|
|
626
1215
|
var commit_default = defineCommand2({
|
|
627
1216
|
meta: {
|
|
@@ -637,6 +1226,11 @@ var commit_default = defineCommand2({
|
|
|
637
1226
|
type: "boolean",
|
|
638
1227
|
description: "Skip AI and write commit message manually",
|
|
639
1228
|
default: false
|
|
1229
|
+
},
|
|
1230
|
+
group: {
|
|
1231
|
+
type: "boolean",
|
|
1232
|
+
description: "AI groups related changes into separate atomic commits",
|
|
1233
|
+
default: false
|
|
640
1234
|
}
|
|
641
1235
|
},
|
|
642
1236
|
async run({ args }) {
|
|
@@ -650,7 +1244,11 @@ var commit_default = defineCommand2({
|
|
|
650
1244
|
process.exit(1);
|
|
651
1245
|
}
|
|
652
1246
|
heading("\uD83D\uDCBE contrib commit");
|
|
653
|
-
|
|
1247
|
+
if (args.group) {
|
|
1248
|
+
await runGroupCommit(args.model, config);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
let stagedFiles = await getStagedFiles();
|
|
654
1252
|
if (stagedFiles.length === 0) {
|
|
655
1253
|
const changedFiles = await getChangedFiles();
|
|
656
1254
|
if (changedFiles.length === 0) {
|
|
@@ -658,31 +1256,62 @@ var commit_default = defineCommand2({
|
|
|
658
1256
|
process.exit(1);
|
|
659
1257
|
}
|
|
660
1258
|
console.log(`
|
|
661
|
-
${
|
|
1259
|
+
${pc5.bold("Changed files:")}`);
|
|
662
1260
|
for (const f of changedFiles) {
|
|
663
|
-
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);
|
|
664
1295
|
}
|
|
665
|
-
console.log();
|
|
666
|
-
warn("No staged changes. Stage your files with `git add` and re-run.");
|
|
667
|
-
process.exit(1);
|
|
668
1296
|
}
|
|
669
1297
|
info(`Staged files: ${stagedFiles.join(", ")}`);
|
|
670
1298
|
let commitMessage = null;
|
|
671
1299
|
const useAI = !args["no-ai"];
|
|
672
1300
|
if (useAI) {
|
|
673
|
-
const copilotError = await checkCopilotAvailable();
|
|
1301
|
+
const [copilotError, diff] = await Promise.all([checkCopilotAvailable(), getStagedDiff()]);
|
|
674
1302
|
if (copilotError) {
|
|
675
1303
|
warn(`AI unavailable: ${copilotError}`);
|
|
676
1304
|
warn("Falling back to manual commit message entry.");
|
|
677
1305
|
} else {
|
|
678
|
-
|
|
679
|
-
const diff = await getStagedDiff();
|
|
1306
|
+
const spinner = createSpinner("Generating commit message with AI...");
|
|
680
1307
|
commitMessage = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
|
|
681
1308
|
if (commitMessage) {
|
|
1309
|
+
spinner.success("AI commit message generated.");
|
|
682
1310
|
console.log(`
|
|
683
|
-
${
|
|
1311
|
+
${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(commitMessage))}`);
|
|
684
1312
|
} else {
|
|
685
|
-
|
|
1313
|
+
spinner.fail("AI did not return a commit message.");
|
|
1314
|
+
warn("Falling back to manual entry.");
|
|
686
1315
|
}
|
|
687
1316
|
}
|
|
688
1317
|
}
|
|
@@ -699,16 +1328,17 @@ ${pc4.bold("Changed files:")}`);
|
|
|
699
1328
|
} else if (action === "Edit this message") {
|
|
700
1329
|
finalMessage = await inputPrompt("Edit commit message", commitMessage);
|
|
701
1330
|
} else if (action === "Regenerate") {
|
|
702
|
-
|
|
1331
|
+
const spinner = createSpinner("Regenerating commit message...");
|
|
703
1332
|
const diff = await getStagedDiff();
|
|
704
1333
|
const regen = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
|
|
705
1334
|
if (regen) {
|
|
1335
|
+
spinner.success("Commit message regenerated.");
|
|
706
1336
|
console.log(`
|
|
707
|
-
${
|
|
1337
|
+
${pc5.dim("AI suggestion:")} ${pc5.bold(pc5.cyan(regen))}`);
|
|
708
1338
|
const ok = await confirmPrompt("Use this message?");
|
|
709
1339
|
finalMessage = ok ? regen : await inputPrompt("Enter commit message manually");
|
|
710
1340
|
} else {
|
|
711
|
-
|
|
1341
|
+
spinner.fail("Regeneration failed.");
|
|
712
1342
|
finalMessage = await inputPrompt("Enter commit message");
|
|
713
1343
|
}
|
|
714
1344
|
} else {
|
|
@@ -719,7 +1349,7 @@ ${pc4.bold("Changed files:")}`);
|
|
|
719
1349
|
if (convention2 !== "none") {
|
|
720
1350
|
console.log();
|
|
721
1351
|
for (const hint of CONVENTION_FORMAT_HINTS[convention2]) {
|
|
722
|
-
console.log(
|
|
1352
|
+
console.log(pc5.dim(hint));
|
|
723
1353
|
}
|
|
724
1354
|
console.log();
|
|
725
1355
|
}
|
|
@@ -743,21 +1373,209 @@ ${pc4.bold("Changed files:")}`);
|
|
|
743
1373
|
error(`Failed to commit: ${result.stderr}`);
|
|
744
1374
|
process.exit(1);
|
|
745
1375
|
}
|
|
746
|
-
success(`✅ Committed: ${
|
|
1376
|
+
success(`✅ Committed: ${pc5.bold(finalMessage)}`);
|
|
747
1377
|
}
|
|
748
1378
|
});
|
|
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);
|
|
1387
|
+
}
|
|
1388
|
+
if (changedFiles.length === 0) {
|
|
1389
|
+
error("No changes to group-commit.");
|
|
1390
|
+
process.exit(1);
|
|
1391
|
+
}
|
|
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;
|
|
1404
|
+
try {
|
|
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);
|
|
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
|
+
}
|
|
749
1567
|
|
|
750
1568
|
// src/commands/hook.ts
|
|
751
|
-
import { existsSync as existsSync2, mkdirSync, readFileSync as
|
|
752
|
-
import { join as
|
|
1569
|
+
import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync3, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1570
|
+
import { join as join3 } from "node:path";
|
|
753
1571
|
import { defineCommand as defineCommand3 } from "citty";
|
|
754
|
-
import
|
|
1572
|
+
import pc6 from "picocolors";
|
|
755
1573
|
var HOOK_MARKER = "# managed by contribute-now";
|
|
756
1574
|
function getHooksDir(cwd = process.cwd()) {
|
|
757
|
-
return
|
|
1575
|
+
return join3(cwd, ".git", "hooks");
|
|
758
1576
|
}
|
|
759
1577
|
function getHookPath(cwd = process.cwd()) {
|
|
760
|
-
return
|
|
1578
|
+
return join3(getHooksDir(cwd), "commit-msg");
|
|
761
1579
|
}
|
|
762
1580
|
function generateHookScript() {
|
|
763
1581
|
return `#!/bin/sh
|
|
@@ -774,8 +1592,19 @@ case "$commit_msg" in
|
|
|
774
1592
|
Merge\\ *|fixup!*|squash!*|amend!*) exit 0 ;;
|
|
775
1593
|
esac
|
|
776
1594
|
|
|
777
|
-
#
|
|
778
|
-
|
|
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
|
|
779
1608
|
`;
|
|
780
1609
|
}
|
|
781
1610
|
var hook_default = defineCommand3({
|
|
@@ -822,7 +1651,7 @@ async function installHook() {
|
|
|
822
1651
|
const hookPath = getHookPath();
|
|
823
1652
|
const hooksDir = getHooksDir();
|
|
824
1653
|
if (existsSync2(hookPath)) {
|
|
825
|
-
const existing =
|
|
1654
|
+
const existing = readFileSync3(hookPath, "utf-8");
|
|
826
1655
|
if (!existing.includes(HOOK_MARKER)) {
|
|
827
1656
|
error("A commit-msg hook already exists and was not installed by contribute-now.");
|
|
828
1657
|
warn(`Path: ${hookPath}`);
|
|
@@ -836,8 +1665,8 @@ async function installHook() {
|
|
|
836
1665
|
}
|
|
837
1666
|
writeFileSync2(hookPath, generateHookScript(), { mode: 493 });
|
|
838
1667
|
success(`commit-msg hook installed.`);
|
|
839
|
-
info(`Convention: ${
|
|
840
|
-
info(`Path: ${
|
|
1668
|
+
info(`Convention: ${pc6.bold(CONVENTION_LABELS[config.commitConvention])}`);
|
|
1669
|
+
info(`Path: ${pc6.dim(hookPath)}`);
|
|
841
1670
|
}
|
|
842
1671
|
async function uninstallHook() {
|
|
843
1672
|
heading("\uD83E\uDE9D hook uninstall");
|
|
@@ -846,7 +1675,7 @@ async function uninstallHook() {
|
|
|
846
1675
|
info("No commit-msg hook found. Nothing to uninstall.");
|
|
847
1676
|
return;
|
|
848
1677
|
}
|
|
849
|
-
const content =
|
|
1678
|
+
const content = readFileSync3(hookPath, "utf-8");
|
|
850
1679
|
if (!content.includes(HOOK_MARKER)) {
|
|
851
1680
|
error("The commit-msg hook was not installed by contribute-now. Leaving it untouched.");
|
|
852
1681
|
process.exit(1);
|
|
@@ -857,98 +1686,7 @@ async function uninstallHook() {
|
|
|
857
1686
|
|
|
858
1687
|
// src/commands/setup.ts
|
|
859
1688
|
import { defineCommand as defineCommand4 } from "citty";
|
|
860
|
-
import
|
|
861
|
-
|
|
862
|
-
// src/utils/gh.ts
|
|
863
|
-
import { execFile as execFileCb2 } from "node:child_process";
|
|
864
|
-
function run2(args) {
|
|
865
|
-
return new Promise((resolve) => {
|
|
866
|
-
execFileCb2("gh", args, (error2, stdout, stderr) => {
|
|
867
|
-
resolve({
|
|
868
|
-
exitCode: error2 ? error2.code != null ? Number(error2.code) : 1 : 0,
|
|
869
|
-
stdout: stdout ?? "",
|
|
870
|
-
stderr: stderr ?? ""
|
|
871
|
-
});
|
|
872
|
-
});
|
|
873
|
-
});
|
|
874
|
-
}
|
|
875
|
-
async function checkGhInstalled() {
|
|
876
|
-
try {
|
|
877
|
-
const { exitCode } = await run2(["--version"]);
|
|
878
|
-
return exitCode === 0;
|
|
879
|
-
} catch {
|
|
880
|
-
return false;
|
|
881
|
-
}
|
|
882
|
-
}
|
|
883
|
-
async function checkGhAuth() {
|
|
884
|
-
try {
|
|
885
|
-
const { exitCode } = await run2(["auth", "status"]);
|
|
886
|
-
return exitCode === 0;
|
|
887
|
-
} catch {
|
|
888
|
-
return false;
|
|
889
|
-
}
|
|
890
|
-
}
|
|
891
|
-
async function checkRepoPermissions(owner, repo) {
|
|
892
|
-
const { exitCode, stdout } = await run2(["api", `repos/${owner}/${repo}`, "--jq", ".permissions"]);
|
|
893
|
-
if (exitCode !== 0)
|
|
894
|
-
return null;
|
|
895
|
-
try {
|
|
896
|
-
return JSON.parse(stdout.trim());
|
|
897
|
-
} catch {
|
|
898
|
-
return null;
|
|
899
|
-
}
|
|
900
|
-
}
|
|
901
|
-
async function isRepoFork() {
|
|
902
|
-
const { exitCode, stdout } = await run2(["repo", "view", "--json", "isFork", "-q", ".isFork"]);
|
|
903
|
-
if (exitCode !== 0)
|
|
904
|
-
return null;
|
|
905
|
-
const val = stdout.trim();
|
|
906
|
-
if (val === "true")
|
|
907
|
-
return true;
|
|
908
|
-
if (val === "false")
|
|
909
|
-
return false;
|
|
910
|
-
return null;
|
|
911
|
-
}
|
|
912
|
-
async function getCurrentRepoInfo() {
|
|
913
|
-
const { exitCode, stdout } = await run2([
|
|
914
|
-
"repo",
|
|
915
|
-
"view",
|
|
916
|
-
"--json",
|
|
917
|
-
"nameWithOwner",
|
|
918
|
-
"-q",
|
|
919
|
-
".nameWithOwner"
|
|
920
|
-
]);
|
|
921
|
-
if (exitCode !== 0)
|
|
922
|
-
return null;
|
|
923
|
-
const nameWithOwner = stdout.trim();
|
|
924
|
-
if (!nameWithOwner)
|
|
925
|
-
return null;
|
|
926
|
-
const [owner, repo] = nameWithOwner.split("/");
|
|
927
|
-
if (!owner || !repo)
|
|
928
|
-
return null;
|
|
929
|
-
return { owner, repo };
|
|
930
|
-
}
|
|
931
|
-
async function createPR(options) {
|
|
932
|
-
const args = [
|
|
933
|
-
"pr",
|
|
934
|
-
"create",
|
|
935
|
-
"--base",
|
|
936
|
-
options.base,
|
|
937
|
-
"--title",
|
|
938
|
-
options.title,
|
|
939
|
-
"--body",
|
|
940
|
-
options.body
|
|
941
|
-
];
|
|
942
|
-
if (options.draft)
|
|
943
|
-
args.push("--draft");
|
|
944
|
-
return run2(args);
|
|
945
|
-
}
|
|
946
|
-
async function createPRFill(base, draft) {
|
|
947
|
-
const args = ["pr", "create", "--base", base, "--fill"];
|
|
948
|
-
if (draft)
|
|
949
|
-
args.push("--draft");
|
|
950
|
-
return run2(args);
|
|
951
|
-
}
|
|
1689
|
+
import pc7 from "picocolors";
|
|
952
1690
|
|
|
953
1691
|
// src/utils/remote.ts
|
|
954
1692
|
function parseRepoFromUrl(url) {
|
|
@@ -991,7 +1729,7 @@ var setup_default = defineCommand4({
|
|
|
991
1729
|
workflow = "github-flow";
|
|
992
1730
|
else if (workflowChoice.startsWith("Git Flow"))
|
|
993
1731
|
workflow = "git-flow";
|
|
994
|
-
info(`Workflow: ${
|
|
1732
|
+
info(`Workflow: ${pc7.bold(WORKFLOW_DESCRIPTIONS[workflow])}`);
|
|
995
1733
|
const conventionChoice = await selectPrompt("Which commit convention should this project use?", [
|
|
996
1734
|
`${CONVENTION_DESCRIPTIONS["clean-commit"]} (recommended)`,
|
|
997
1735
|
CONVENTION_DESCRIPTIONS.conventional,
|
|
@@ -1044,8 +1782,8 @@ var setup_default = defineCommand4({
|
|
|
1044
1782
|
detectedRole = roleChoice;
|
|
1045
1783
|
detectionSource = "user selection";
|
|
1046
1784
|
} else {
|
|
1047
|
-
info(`Detected role: ${
|
|
1048
|
-
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?`);
|
|
1049
1787
|
if (!confirmed) {
|
|
1050
1788
|
const roleChoice = await selectPrompt("Select your role:", ["maintainer", "contributor"]);
|
|
1051
1789
|
detectedRole = roleChoice;
|
|
@@ -1090,36 +1828,21 @@ var setup_default = defineCommand4({
|
|
|
1090
1828
|
warn(' echo ".contributerc.json" >> .gitignore');
|
|
1091
1829
|
}
|
|
1092
1830
|
console.log();
|
|
1093
|
-
info(`Workflow: ${
|
|
1094
|
-
info(`Convention: ${
|
|
1095
|
-
info(`Role: ${
|
|
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)}`);
|
|
1096
1834
|
if (config.devBranch) {
|
|
1097
|
-
info(`Main: ${
|
|
1835
|
+
info(`Main: ${pc7.bold(config.mainBranch)} | Dev: ${pc7.bold(config.devBranch)}`);
|
|
1098
1836
|
} else {
|
|
1099
|
-
info(`Main: ${
|
|
1837
|
+
info(`Main: ${pc7.bold(config.mainBranch)}`);
|
|
1100
1838
|
}
|
|
1101
|
-
info(`Origin: ${
|
|
1839
|
+
info(`Origin: ${pc7.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc7.bold(config.upstream)}` : ""}`);
|
|
1102
1840
|
}
|
|
1103
1841
|
});
|
|
1104
1842
|
|
|
1105
1843
|
// src/commands/start.ts
|
|
1106
1844
|
import { defineCommand as defineCommand5 } from "citty";
|
|
1107
|
-
import
|
|
1108
|
-
|
|
1109
|
-
// src/utils/branch.ts
|
|
1110
|
-
var DEFAULT_PREFIXES = ["feature", "fix", "docs", "chore", "test", "refactor"];
|
|
1111
|
-
function hasPrefix(branchName, prefixes = DEFAULT_PREFIXES) {
|
|
1112
|
-
return prefixes.some((p) => branchName.startsWith(`${p}/`));
|
|
1113
|
-
}
|
|
1114
|
-
function formatBranchName(prefix, name) {
|
|
1115
|
-
const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
1116
|
-
return `${prefix}/${sanitized}`;
|
|
1117
|
-
}
|
|
1118
|
-
function looksLikeNaturalLanguage(input) {
|
|
1119
|
-
return input.includes(" ") && !input.includes("/");
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
// src/commands/start.ts
|
|
1845
|
+
import pc8 from "picocolors";
|
|
1123
1846
|
var start_default = defineCommand5({
|
|
1124
1847
|
meta: {
|
|
1125
1848
|
name: "start",
|
|
@@ -1162,39 +1885,46 @@ var start_default = defineCommand5({
|
|
|
1162
1885
|
heading("\uD83C\uDF3F contrib start");
|
|
1163
1886
|
const useAI = !args["no-ai"] && looksLikeNaturalLanguage(branchName);
|
|
1164
1887
|
if (useAI) {
|
|
1165
|
-
|
|
1888
|
+
const spinner = createSpinner("Generating branch name suggestion...");
|
|
1166
1889
|
const suggested = await suggestBranchName(branchName, args.model);
|
|
1167
1890
|
if (suggested) {
|
|
1891
|
+
spinner.success("Branch name suggestion ready.");
|
|
1168
1892
|
console.log(`
|
|
1169
|
-
${
|
|
1170
|
-
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?`);
|
|
1171
1895
|
if (accepted) {
|
|
1172
1896
|
branchName = suggested;
|
|
1173
1897
|
} else {
|
|
1174
1898
|
branchName = await inputPrompt("Enter branch name", branchName);
|
|
1175
1899
|
}
|
|
1900
|
+
} else {
|
|
1901
|
+
spinner.fail("AI did not return a branch name suggestion.");
|
|
1176
1902
|
}
|
|
1177
1903
|
}
|
|
1178
1904
|
if (!hasPrefix(branchName, branchPrefixes)) {
|
|
1179
|
-
const prefix = await selectPrompt(`Choose a branch type for ${
|
|
1905
|
+
const prefix = await selectPrompt(`Choose a branch type for ${pc8.bold(branchName)}:`, branchPrefixes);
|
|
1180
1906
|
branchName = formatBranchName(prefix, branchName);
|
|
1181
1907
|
}
|
|
1182
|
-
|
|
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)}`);
|
|
1183
1913
|
await fetchRemote(syncSource.remote);
|
|
1184
|
-
const
|
|
1185
|
-
if (
|
|
1914
|
+
const updateResult = await updateLocalBranch(baseBranch, syncSource.ref);
|
|
1915
|
+
if (updateResult.exitCode !== 0) {}
|
|
1186
1916
|
const result = await createBranch(branchName, baseBranch);
|
|
1187
1917
|
if (result.exitCode !== 0) {
|
|
1188
1918
|
error(`Failed to create branch: ${result.stderr}`);
|
|
1189
1919
|
process.exit(1);
|
|
1190
1920
|
}
|
|
1191
|
-
success(`✅ Created ${
|
|
1921
|
+
success(`✅ Created ${pc8.bold(branchName)} from latest ${pc8.bold(baseBranch)}`);
|
|
1192
1922
|
}
|
|
1193
1923
|
});
|
|
1194
1924
|
|
|
1195
1925
|
// src/commands/status.ts
|
|
1196
1926
|
import { defineCommand as defineCommand6 } from "citty";
|
|
1197
|
-
import
|
|
1927
|
+
import pc9 from "picocolors";
|
|
1198
1928
|
var status_default = defineCommand6({
|
|
1199
1929
|
meta: {
|
|
1200
1930
|
name: "status",
|
|
@@ -1211,17 +1941,20 @@ var status_default = defineCommand6({
|
|
|
1211
1941
|
process.exit(1);
|
|
1212
1942
|
}
|
|
1213
1943
|
heading("\uD83D\uDCCA contribute-now status");
|
|
1214
|
-
console.log(` ${
|
|
1215
|
-
console.log(` ${
|
|
1944
|
+
console.log(` ${pc9.dim("Workflow:")} ${pc9.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
|
|
1945
|
+
console.log(` ${pc9.dim("Role:")} ${pc9.bold(config.role)}`);
|
|
1216
1946
|
console.log();
|
|
1217
1947
|
await fetchAll();
|
|
1218
1948
|
const currentBranch = await getCurrentBranch();
|
|
1219
1949
|
const { mainBranch, origin, upstream, workflow } = config;
|
|
1220
1950
|
const baseBranch = getBaseBranch(config);
|
|
1221
1951
|
const isContributor = config.role === "contributor";
|
|
1222
|
-
const dirty = await
|
|
1952
|
+
const [dirty, fileStatus] = await Promise.all([
|
|
1953
|
+
hasUncommittedChanges(),
|
|
1954
|
+
getFileStatus()
|
|
1955
|
+
]);
|
|
1223
1956
|
if (dirty) {
|
|
1224
|
-
console.log(` ${
|
|
1957
|
+
console.log(` ${pc9.yellow("⚠")} ${pc9.yellow("Uncommitted changes in working tree")}`);
|
|
1225
1958
|
console.log();
|
|
1226
1959
|
}
|
|
1227
1960
|
const mainRemote = `${origin}/${mainBranch}`;
|
|
@@ -1237,30 +1970,130 @@ var status_default = defineCommand6({
|
|
|
1237
1970
|
if (currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
|
|
1238
1971
|
const branchDiv = await getDivergence(currentBranch, baseBranch);
|
|
1239
1972
|
const branchLine = formatStatus(currentBranch, baseBranch, branchDiv.ahead, branchDiv.behind);
|
|
1240
|
-
console.log(branchLine +
|
|
1973
|
+
console.log(branchLine + pc9.dim(` (current ${pc9.green("*")})`));
|
|
1241
1974
|
} else if (currentBranch) {
|
|
1242
|
-
console.log(
|
|
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)}`);
|
|
2019
|
+
}
|
|
1243
2020
|
}
|
|
1244
2021
|
console.log();
|
|
1245
2022
|
}
|
|
1246
2023
|
});
|
|
1247
2024
|
function formatStatus(branch, base, ahead, behind) {
|
|
1248
|
-
const label =
|
|
2025
|
+
const label = pc9.bold(branch.padEnd(20));
|
|
1249
2026
|
if (ahead === 0 && behind === 0) {
|
|
1250
|
-
return ` ${
|
|
2027
|
+
return ` ${pc9.green("✓")} ${label} ${pc9.dim(`in sync with ${base}`)}`;
|
|
1251
2028
|
}
|
|
1252
2029
|
if (ahead > 0 && behind === 0) {
|
|
1253
|
-
return ` ${
|
|
2030
|
+
return ` ${pc9.yellow("↑")} ${label} ${pc9.yellow(`${ahead} commit${ahead !== 1 ? "s" : ""} ahead of ${base}`)}`;
|
|
1254
2031
|
}
|
|
1255
2032
|
if (behind > 0 && ahead === 0) {
|
|
1256
|
-
return ` ${
|
|
2033
|
+
return ` ${pc9.red("↓")} ${label} ${pc9.red(`${behind} commit${behind !== 1 ? "s" : ""} behind ${base}`)}`;
|
|
1257
2034
|
}
|
|
1258
|
-
return ` ${
|
|
2035
|
+
return ` ${pc9.red("⚡")} ${label} ${pc9.yellow(`${ahead} ahead`)}${pc9.dim(", ")}${pc9.red(`${behind} behind`)} ${pc9.dim(base)}`;
|
|
1259
2036
|
}
|
|
1260
2037
|
|
|
1261
2038
|
// src/commands/submit.ts
|
|
1262
2039
|
import { defineCommand as defineCommand7 } from "citty";
|
|
1263
|
-
import
|
|
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
|
+
}
|
|
1264
2097
|
var submit_default = defineCommand7({
|
|
1265
2098
|
meta: {
|
|
1266
2099
|
name: "submit",
|
|
@@ -1301,61 +2134,179 @@ var submit_default = defineCommand7({
|
|
|
1301
2134
|
process.exit(1);
|
|
1302
2135
|
}
|
|
1303
2136
|
if (protectedBranches.includes(currentBranch)) {
|
|
1304
|
-
error(`Cannot submit ${protectedBranches.map((b) =>
|
|
2137
|
+
error(`Cannot submit ${protectedBranches.map((b) => pc10.bold(b)).join(" or ")} as a PR. Switch to your feature branch.`);
|
|
1305
2138
|
process.exit(1);
|
|
1306
2139
|
}
|
|
1307
2140
|
heading("\uD83D\uDE80 contrib submit");
|
|
1308
|
-
|
|
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}...`);
|
|
1309
2235
|
const pushResult = await pushSetUpstream(origin, currentBranch);
|
|
1310
2236
|
if (pushResult.exitCode !== 0) {
|
|
1311
2237
|
error(`Failed to push: ${pushResult.stderr}`);
|
|
1312
2238
|
process.exit(1);
|
|
1313
2239
|
}
|
|
1314
|
-
const ghInstalled = await checkGhInstalled();
|
|
1315
|
-
const ghAuthed = ghInstalled && await checkGhAuth();
|
|
1316
2240
|
if (!ghInstalled || !ghAuthed) {
|
|
1317
2241
|
const repoInfo = await getRepoInfoFromRemote(origin);
|
|
1318
2242
|
if (repoInfo) {
|
|
1319
2243
|
const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${baseBranch}...${currentBranch}?expand=1`;
|
|
1320
2244
|
console.log();
|
|
1321
2245
|
info("Create your PR manually:");
|
|
1322
|
-
console.log(` ${
|
|
2246
|
+
console.log(` ${pc10.cyan(prUrl)}`);
|
|
1323
2247
|
} else {
|
|
1324
2248
|
info("gh CLI not available. Create your PR manually on GitHub.");
|
|
1325
2249
|
}
|
|
1326
2250
|
return;
|
|
1327
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
|
+
}
|
|
1328
2258
|
let prTitle = null;
|
|
1329
2259
|
let prBody = null;
|
|
1330
2260
|
if (!args["no-ai"]) {
|
|
1331
|
-
const copilotError = await
|
|
2261
|
+
const [copilotError, commits, diff] = await Promise.all([
|
|
2262
|
+
checkCopilotAvailable(),
|
|
2263
|
+
getLog(baseBranch, "HEAD"),
|
|
2264
|
+
getLogDiff(baseBranch, "HEAD")
|
|
2265
|
+
]);
|
|
1332
2266
|
if (!copilotError) {
|
|
1333
|
-
|
|
1334
|
-
const
|
|
1335
|
-
const diff = await getLogDiff(baseBranch, "HEAD");
|
|
1336
|
-
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);
|
|
1337
2269
|
if (result) {
|
|
1338
2270
|
prTitle = result.title;
|
|
1339
2271
|
prBody = result.body;
|
|
2272
|
+
spinner.success("PR description generated.");
|
|
1340
2273
|
console.log(`
|
|
1341
|
-
${
|
|
2274
|
+
${pc10.dim("AI title:")} ${pc10.bold(pc10.cyan(prTitle))}`);
|
|
1342
2275
|
console.log(`
|
|
1343
|
-
${
|
|
1344
|
-
console.log(
|
|
2276
|
+
${pc10.dim("AI body preview:")}`);
|
|
2277
|
+
console.log(pc10.dim(prBody.slice(0, 300) + (prBody.length > 300 ? "..." : "")));
|
|
1345
2278
|
} else {
|
|
1346
|
-
|
|
2279
|
+
spinner.fail("AI did not return a PR description.");
|
|
1347
2280
|
}
|
|
1348
2281
|
} else {
|
|
1349
2282
|
warn(`AI unavailable: ${copilotError}`);
|
|
1350
2283
|
}
|
|
1351
2284
|
}
|
|
2285
|
+
const CANCEL = "Cancel";
|
|
2286
|
+
const SQUASH_LOCAL = `Squash merge to ${baseBranch} locally (no PR)`;
|
|
1352
2287
|
if (prTitle && prBody) {
|
|
1353
|
-
const
|
|
2288
|
+
const choices = [
|
|
1354
2289
|
"Use AI description",
|
|
1355
2290
|
"Edit title",
|
|
1356
2291
|
"Write manually",
|
|
1357
2292
|
"Use gh --fill (auto-fill from commits)"
|
|
1358
|
-
]
|
|
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
|
+
}
|
|
1359
2310
|
if (action === "Use AI description") {} else if (action === "Edit title") {
|
|
1360
2311
|
prTitle = await inputPrompt("PR title", prTitle);
|
|
1361
2312
|
} else if (action === "Write manually") {
|
|
@@ -1371,8 +2322,26 @@ ${pc9.dim("AI body preview:")}`);
|
|
|
1371
2322
|
return;
|
|
1372
2323
|
}
|
|
1373
2324
|
} else {
|
|
1374
|
-
const
|
|
1375
|
-
|
|
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") {
|
|
1376
2345
|
prTitle = await inputPrompt("PR title");
|
|
1377
2346
|
prBody = await inputPrompt("PR body (markdown)");
|
|
1378
2347
|
} else {
|
|
@@ -1405,7 +2374,7 @@ ${pc9.dim("AI body preview:")}`);
|
|
|
1405
2374
|
|
|
1406
2375
|
// src/commands/sync.ts
|
|
1407
2376
|
import { defineCommand as defineCommand8 } from "citty";
|
|
1408
|
-
import
|
|
2377
|
+
import pc11 from "picocolors";
|
|
1409
2378
|
var sync_default = defineCommand8({
|
|
1410
2379
|
meta: {
|
|
1411
2380
|
name: "sync",
|
|
@@ -1448,16 +2417,16 @@ var sync_default = defineCommand8({
|
|
|
1448
2417
|
}
|
|
1449
2418
|
const div = await getDivergence(baseBranch, syncSource.ref);
|
|
1450
2419
|
if (div.ahead > 0 || div.behind > 0) {
|
|
1451
|
-
info(`${
|
|
2420
|
+
info(`${pc11.bold(baseBranch)} is ${pc11.yellow(`${div.ahead} ahead`)} and ${pc11.red(`${div.behind} behind`)} ${syncSource.ref}`);
|
|
1452
2421
|
} else {
|
|
1453
|
-
info(`${
|
|
2422
|
+
info(`${pc11.bold(baseBranch)} is already in sync with ${syncSource.ref}`);
|
|
1454
2423
|
}
|
|
1455
2424
|
if (!args.yes) {
|
|
1456
|
-
const ok = await confirmPrompt(`This will pull ${
|
|
2425
|
+
const ok = await confirmPrompt(`This will pull ${pc11.bold(syncSource.ref)} into local ${pc11.bold(baseBranch)}.`);
|
|
1457
2426
|
if (!ok)
|
|
1458
2427
|
process.exit(0);
|
|
1459
2428
|
}
|
|
1460
|
-
const coResult = await
|
|
2429
|
+
const coResult = await checkoutBranch2(baseBranch);
|
|
1461
2430
|
if (coResult.exitCode !== 0) {
|
|
1462
2431
|
error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
|
|
1463
2432
|
process.exit(1);
|
|
@@ -1471,24 +2440,24 @@ var sync_default = defineCommand8({
|
|
|
1471
2440
|
if (hasDevBranch(workflow) && role === "maintainer") {
|
|
1472
2441
|
const mainDiv = await getDivergence(config.mainBranch, `${origin}/${config.mainBranch}`);
|
|
1473
2442
|
if (mainDiv.behind > 0) {
|
|
1474
|
-
info(`Also syncing ${
|
|
1475
|
-
const mainCoResult = await
|
|
2443
|
+
info(`Also syncing ${pc11.bold(config.mainBranch)}...`);
|
|
2444
|
+
const mainCoResult = await checkoutBranch2(config.mainBranch);
|
|
1476
2445
|
if (mainCoResult.exitCode === 0) {
|
|
1477
2446
|
const mainPullResult = await pullBranch(origin, config.mainBranch);
|
|
1478
2447
|
if (mainPullResult.exitCode === 0) {
|
|
1479
2448
|
success(`✅ ${config.mainBranch} is now in sync with ${origin}/${config.mainBranch}`);
|
|
1480
2449
|
}
|
|
1481
2450
|
}
|
|
1482
|
-
await
|
|
2451
|
+
await checkoutBranch2(baseBranch);
|
|
1483
2452
|
}
|
|
1484
2453
|
}
|
|
1485
2454
|
}
|
|
1486
2455
|
});
|
|
1487
2456
|
|
|
1488
2457
|
// src/commands/update.ts
|
|
1489
|
-
import { readFileSync as
|
|
2458
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
1490
2459
|
import { defineCommand as defineCommand9 } from "citty";
|
|
1491
|
-
import
|
|
2460
|
+
import pc12 from "picocolors";
|
|
1492
2461
|
var update_default = defineCommand9({
|
|
1493
2462
|
meta: {
|
|
1494
2463
|
name: "update",
|
|
@@ -1524,7 +2493,7 @@ var update_default = defineCommand9({
|
|
|
1524
2493
|
process.exit(1);
|
|
1525
2494
|
}
|
|
1526
2495
|
if (protectedBranches.includes(currentBranch)) {
|
|
1527
|
-
error(`Use \`contrib sync\` to update ${protectedBranches.map((b) =>
|
|
2496
|
+
error(`Use \`contrib sync\` to update ${protectedBranches.map((b) => pc12.bold(b)).join(" or ")} branches.`);
|
|
1528
2497
|
process.exit(1);
|
|
1529
2498
|
}
|
|
1530
2499
|
if (await hasUncommittedChanges()) {
|
|
@@ -1532,10 +2501,92 @@ var update_default = defineCommand9({
|
|
|
1532
2501
|
process.exit(1);
|
|
1533
2502
|
}
|
|
1534
2503
|
heading("\uD83D\uDD03 contrib update");
|
|
1535
|
-
|
|
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)}...`);
|
|
1536
2586
|
await fetchRemote(syncSource.remote);
|
|
1537
|
-
await
|
|
1538
|
-
const
|
|
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);
|
|
1539
2590
|
if (rebaseResult.exitCode !== 0) {
|
|
1540
2591
|
warn("Rebase hit conflicts. Resolve them manually.");
|
|
1541
2592
|
console.log();
|
|
@@ -1547,7 +2598,7 @@ var update_default = defineCommand9({
|
|
|
1547
2598
|
let conflictDiff = "";
|
|
1548
2599
|
for (const file of conflictFiles.slice(0, 3)) {
|
|
1549
2600
|
try {
|
|
1550
|
-
const content =
|
|
2601
|
+
const content = readFileSync4(file, "utf-8");
|
|
1551
2602
|
if (content.includes("<<<<<<<")) {
|
|
1552
2603
|
conflictDiff += `
|
|
1553
2604
|
--- ${file} ---
|
|
@@ -1557,33 +2608,37 @@ ${content.slice(0, 2000)}
|
|
|
1557
2608
|
} catch {}
|
|
1558
2609
|
}
|
|
1559
2610
|
if (conflictDiff) {
|
|
2611
|
+
const spinner = createSpinner("Analyzing conflicts with AI...");
|
|
1560
2612
|
const suggestion = await suggestConflictResolution(conflictDiff, args.model);
|
|
1561
2613
|
if (suggestion) {
|
|
2614
|
+
spinner.success("AI conflict guidance ready.");
|
|
1562
2615
|
console.log(`
|
|
1563
|
-
${
|
|
1564
|
-
console.log(
|
|
2616
|
+
${pc12.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
|
|
2617
|
+
console.log(pc12.dim("─".repeat(60)));
|
|
1565
2618
|
console.log(suggestion);
|
|
1566
|
-
console.log(
|
|
2619
|
+
console.log(pc12.dim("─".repeat(60)));
|
|
1567
2620
|
console.log();
|
|
2621
|
+
} else {
|
|
2622
|
+
spinner.fail("AI could not analyze the conflicts.");
|
|
1568
2623
|
}
|
|
1569
2624
|
}
|
|
1570
2625
|
}
|
|
1571
2626
|
}
|
|
1572
|
-
console.log(
|
|
2627
|
+
console.log(pc12.bold("To resolve:"));
|
|
1573
2628
|
console.log(` 1. Fix conflicts in the affected files`);
|
|
1574
|
-
console.log(` 2. ${
|
|
1575
|
-
console.log(` 3. ${
|
|
2629
|
+
console.log(` 2. ${pc12.cyan("git add <resolved-files>")}`);
|
|
2630
|
+
console.log(` 3. ${pc12.cyan("git rebase --continue")}`);
|
|
1576
2631
|
console.log();
|
|
1577
|
-
console.log(` Or abort: ${
|
|
2632
|
+
console.log(` Or abort: ${pc12.cyan("git rebase --abort")}`);
|
|
1578
2633
|
process.exit(1);
|
|
1579
2634
|
}
|
|
1580
|
-
success(`✅ ${
|
|
2635
|
+
success(`✅ ${pc12.bold(currentBranch)} has been rebased onto latest ${pc12.bold(baseBranch)}`);
|
|
1581
2636
|
}
|
|
1582
2637
|
});
|
|
1583
2638
|
|
|
1584
2639
|
// src/commands/validate.ts
|
|
1585
2640
|
import { defineCommand as defineCommand10 } from "citty";
|
|
1586
|
-
import
|
|
2641
|
+
import pc13 from "picocolors";
|
|
1587
2642
|
var validate_default = defineCommand10({
|
|
1588
2643
|
meta: {
|
|
1589
2644
|
name: "validate",
|
|
@@ -1614,7 +2669,7 @@ var validate_default = defineCommand10({
|
|
|
1614
2669
|
}
|
|
1615
2670
|
const errors = getValidationError(convention);
|
|
1616
2671
|
for (const line of errors) {
|
|
1617
|
-
console.error(
|
|
2672
|
+
console.error(pc13.red(` ✗ ${line}`));
|
|
1618
2673
|
}
|
|
1619
2674
|
process.exit(1);
|
|
1620
2675
|
}
|
|
@@ -1622,11 +2677,11 @@ var validate_default = defineCommand10({
|
|
|
1622
2677
|
|
|
1623
2678
|
// src/ui/banner.ts
|
|
1624
2679
|
import figlet from "figlet";
|
|
1625
|
-
import
|
|
2680
|
+
import pc14 from "picocolors";
|
|
1626
2681
|
// package.json
|
|
1627
2682
|
var package_default = {
|
|
1628
2683
|
name: "contribute-now",
|
|
1629
|
-
version: "0.
|
|
2684
|
+
version: "0.2.0-dev.33be40f",
|
|
1630
2685
|
description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
|
|
1631
2686
|
type: "module",
|
|
1632
2687
|
bin: {
|
|
@@ -1638,12 +2693,12 @@ var package_default = {
|
|
|
1638
2693
|
],
|
|
1639
2694
|
scripts: {
|
|
1640
2695
|
build: "bun build src/index.ts --outfile dist/index.js --target node --packages external",
|
|
2696
|
+
cli: "bun run src/index.ts --",
|
|
1641
2697
|
dev: "bun src/index.ts",
|
|
1642
2698
|
test: "bun test",
|
|
1643
2699
|
lint: "biome check .",
|
|
1644
2700
|
"lint:fix": "biome check --write .",
|
|
1645
2701
|
format: "biome format --write .",
|
|
1646
|
-
prepare: "husky || true",
|
|
1647
2702
|
"www:dev": "bun run --cwd www dev",
|
|
1648
2703
|
"www:build": "bun run --cwd www build",
|
|
1649
2704
|
"www:preview": "bun run --cwd www preview"
|
|
@@ -1670,6 +2725,7 @@ var package_default = {
|
|
|
1670
2725
|
url: "git+https://github.com/warengonzaga/contribute-now.git"
|
|
1671
2726
|
},
|
|
1672
2727
|
dependencies: {
|
|
2728
|
+
"@clack/prompts": "^1.0.1",
|
|
1673
2729
|
"@github/copilot-sdk": "^0.1.25",
|
|
1674
2730
|
"@wgtechlabs/log-engine": "^2.3.1",
|
|
1675
2731
|
citty: "^0.1.6",
|
|
@@ -1680,7 +2736,6 @@ var package_default = {
|
|
|
1680
2736
|
"@biomejs/biome": "^2.4.4",
|
|
1681
2737
|
"@types/bun": "latest",
|
|
1682
2738
|
"@types/figlet": "^1.7.0",
|
|
1683
|
-
husky: "^9.1.7",
|
|
1684
2739
|
typescript: "^5.7.0"
|
|
1685
2740
|
}
|
|
1686
2741
|
};
|
|
@@ -1688,9 +2743,10 @@ var package_default = {
|
|
|
1688
2743
|
// src/ui/banner.ts
|
|
1689
2744
|
var LOGO;
|
|
1690
2745
|
try {
|
|
1691
|
-
LOGO = figlet.textSync(
|
|
2746
|
+
LOGO = figlet.textSync(`Contribute
|
|
2747
|
+
Now`, { font: "ANSI Shadow" });
|
|
1692
2748
|
} catch {
|
|
1693
|
-
LOGO = "
|
|
2749
|
+
LOGO = "Contribute Now";
|
|
1694
2750
|
}
|
|
1695
2751
|
function getVersion() {
|
|
1696
2752
|
return package_default.version ?? "unknown";
|
|
@@ -1698,16 +2754,15 @@ function getVersion() {
|
|
|
1698
2754
|
function getAuthor() {
|
|
1699
2755
|
return typeof package_default.author === "string" ? package_default.author : "unknown";
|
|
1700
2756
|
}
|
|
1701
|
-
function showBanner(
|
|
1702
|
-
console.log(
|
|
2757
|
+
function showBanner(showLinks = false) {
|
|
2758
|
+
console.log(pc14.cyan(`
|
|
1703
2759
|
${LOGO}`));
|
|
1704
|
-
console.log(` ${
|
|
1705
|
-
if (
|
|
1706
|
-
console.log(` ${pc13.dim(package_default.description)}`);
|
|
2760
|
+
console.log(` ${pc14.dim(`v${getVersion()}`)} ${pc14.dim("—")} ${pc14.dim(`Built by ${getAuthor()}`)}`);
|
|
2761
|
+
if (showLinks) {
|
|
1707
2762
|
console.log();
|
|
1708
|
-
console.log(` ${
|
|
1709
|
-
console.log(` ${
|
|
1710
|
-
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")}`);
|
|
1711
2766
|
}
|
|
1712
2767
|
console.log();
|
|
1713
2768
|
}
|
|
@@ -1746,4 +2801,6 @@ var main = defineCommand11({
|
|
|
1746
2801
|
}
|
|
1747
2802
|
}
|
|
1748
2803
|
});
|
|
1749
|
-
runMain(main)
|
|
2804
|
+
runMain(main).then(() => {
|
|
2805
|
+
process.exit(0);
|
|
2806
|
+
});
|