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