contribute-now 0.1.2-staging.c209cc7 → 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.
Files changed (2) hide show
  1. package/dist/index.js +2229 -807
  2. 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 defineCommand11, runMain } from "citty";
6
+ import { defineCommand as defineCommand12, runMain } from "citty";
5
7
 
6
8
  // src/commands/clean.ts
7
9
  import { defineCommand } from "citty";
8
- import pc3 from "picocolors";
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
- return JSON.parse(raw);
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
  }
@@ -56,187 +81,800 @@ function getDefaultConfig() {
56
81
  }
57
82
 
58
83
  // src/utils/confirm.ts
84
+ import * as clack from "@clack/prompts";
59
85
  import pc from "picocolors";
60
- async function confirmPrompt(message) {
61
- console.log(`
62
- ${message}`);
63
- process.stdout.write(`${pc.dim("Continue? [y/N] ")}`);
64
- const response = await new Promise((resolve) => {
65
- process.stdin.setEncoding("utf-8");
66
- process.stdin.once("data", (data) => {
67
- process.stdin.pause();
68
- resolve(data.toString().trim());
69
- });
70
- process.stdin.resume();
71
- });
72
- if (response.toLowerCase() !== "y") {
73
- console.log(pc.yellow("Aborted."));
74
- return false;
86
+ function handleCancel(value) {
87
+ if (clack.isCancel(value)) {
88
+ clack.cancel("Cancelled.");
89
+ process.exit(0);
75
90
  }
76
- return true;
91
+ }
92
+ async function confirmPrompt(message) {
93
+ const result = await clack.confirm({ message });
94
+ handleCancel(result);
95
+ return result;
77
96
  }
78
97
  async function selectPrompt(message, choices) {
79
- console.log(`
80
- ${message}`);
81
- choices.forEach((choice, i) => {
82
- console.log(` ${pc.dim(`${i + 1}.`)} ${choice}`);
83
- });
84
- process.stdout.write(pc.dim(`Enter number [1-${choices.length}]: `));
85
- const response = await new Promise((resolve) => {
86
- process.stdin.setEncoding("utf-8");
87
- process.stdin.once("data", (data) => {
88
- process.stdin.pause();
89
- resolve(data.toString().trim());
90
- });
91
- process.stdin.resume();
98
+ const result = await clack.select({
99
+ message,
100
+ options: choices.map((choice) => ({ value: choice, label: choice }))
92
101
  });
93
- const index = Number.parseInt(response, 10) - 1;
94
- if (index >= 0 && index < choices.length) {
95
- return choices[index];
96
- }
97
- return choices[0];
102
+ handleCancel(result);
103
+ return result;
98
104
  }
99
105
  async function inputPrompt(message, defaultValue) {
100
- const hint = defaultValue ? ` ${pc.dim(`[${defaultValue}]`)}` : "";
101
- process.stdout.write(`
102
- ${message}${hint}: `);
103
- const response = await new Promise((resolve) => {
104
- process.stdin.setEncoding("utf-8");
105
- process.stdin.once("data", (data) => {
106
- process.stdin.pause();
107
- resolve(data.toString().trim());
108
- });
109
- process.stdin.resume();
106
+ const result = await clack.text({
107
+ message,
108
+ placeholder: defaultValue,
109
+ defaultValue
110
+ });
111
+ handleCancel(result);
112
+ return result || defaultValue || "";
113
+ }
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
110
119
  });
111
- return response || defaultValue || "";
120
+ handleCancel(result);
121
+ return result;
112
122
  }
113
123
 
114
- // src/utils/git.ts
115
- import { execFile as execFileCb } from "node:child_process";
116
- function run(args) {
117
- return new Promise((resolve) => {
118
- execFileCb("git", args, (error, stdout, stderr) => {
119
- resolve({
120
- exitCode: error ? error.code === "ENOENT" ? 127 : error.code != null ? Number(error.code) : 1 : 0,
121
- stdout: stdout ?? "",
122
- stderr: stderr ?? ""
123
- });
124
+ // src/utils/copilot.ts
125
+ import { CopilotClient } from "@github/copilot-sdk";
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.
142
+
143
+ ${conventionBlock}
144
+
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
+ ]
152
+
153
+ Rules:
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`;
160
+ }
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);
124
200
  });
125
201
  });
126
202
  }
127
- async function isGitRepo() {
128
- const { exitCode } = await run(["rev-parse", "--is-inside-work-tree"]);
129
- return exitCode === 0;
130
- }
131
- async function getCurrentBranch() {
132
- const { exitCode, stdout } = await run(["rev-parse", "--abbrev-ref", "HEAD"]);
133
- if (exitCode !== 0)
203
+ var COPILOT_TIMEOUT_MS = 30000;
204
+ var COPILOT_LONG_TIMEOUT_MS = 90000;
205
+ async function checkCopilotAvailable() {
206
+ try {
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
+ }
134
220
  return null;
135
- return stdout.trim() || null;
221
+ } catch (err) {
222
+ const msg = err instanceof Error ? err.message : String(err);
223
+ if (msg.includes("ENOENT") || msg.includes("not found")) {
224
+ return "Copilot CLI binary not found. Ensure GitHub Copilot is installed and your gh CLI is up to date.";
225
+ }
226
+ return `Failed to start Copilot service: ${msg}`;
227
+ }
136
228
  }
137
- async function getRemotes() {
138
- const { exitCode, stdout } = await run(["remote"]);
139
- if (exitCode !== 0)
140
- return [];
141
- return stdout.trim().split(`
142
- `).map((r) => r.trim()).filter(Boolean);
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);
249
+ }
250
+ return _managedClient;
143
251
  }
144
- async function getRemoteUrl(remote) {
145
- const { exitCode, stdout } = await run(["remote", "get-url", remote]);
146
- if (exitCode !== 0)
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);
260
+ try {
261
+ const response = await withTimeout(session.sendAndWait({ prompt: userMessage }), timeoutMs);
262
+ if (!response?.data?.content)
263
+ return null;
264
+ return response.data.content;
265
+ } finally {
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);
300
+ }
301
+ return text2;
302
+ }
303
+ async function generateCommitMessage(diff, stagedFiles, model, convention = "clean-commit") {
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.` : "";
308
+ const userMessage = `Generate a commit message for these staged changes:
309
+
310
+ Files: ${stagedFiles.join(", ")}
311
+
312
+ Diff:
313
+ ${diff.slice(0, 4000)}${multiFileHint}`;
314
+ const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
315
+ return result?.trim() ?? null;
316
+ } catch {
147
317
  return null;
148
- return stdout.trim() || null;
318
+ }
149
319
  }
150
- async function hasUncommittedChanges() {
151
- const { exitCode, stdout } = await run(["status", "--porcelain"]);
152
- if (exitCode !== 0)
153
- return false;
154
- return stdout.trim().length > 0;
320
+ async function generatePRDescription(commits, diff, model, convention = "clean-commit") {
321
+ try {
322
+ const userMessage = `Generate a PR description for these changes:
323
+
324
+ Commits:
325
+ ${commits.join(`
326
+ `)}
327
+
328
+ Diff (truncated):
329
+ ${diff.slice(0, 4000)}`;
330
+ const result = await callCopilot(getPRDescriptionSystemPrompt(convention), userMessage, model);
331
+ if (!result)
332
+ return null;
333
+ const cleaned = extractJson(result);
334
+ return JSON.parse(cleaned);
335
+ } catch {
336
+ return null;
337
+ }
155
338
  }
156
- async function fetchRemote(remote) {
157
- return run(["fetch", remote]);
339
+ async function suggestBranchName(description, model) {
340
+ try {
341
+ const result = await callCopilot(BRANCH_NAME_SYSTEM_PROMPT, description, model);
342
+ const trimmed = result?.trim() ?? null;
343
+ if (trimmed && /^[a-z]+\/[a-z0-9-]+$/.test(trimmed)) {
344
+ return trimmed;
345
+ }
346
+ return null;
347
+ } catch {
348
+ return null;
349
+ }
158
350
  }
159
- async function fetchAll() {
160
- return run(["fetch", "--all", "--quiet"]);
351
+ async function suggestConflictResolution(conflictDiff, model) {
352
+ try {
353
+ const userMessage = `Help me resolve this merge conflict:
354
+
355
+ ${conflictDiff.slice(0, 4000)}`;
356
+ const result = await callCopilot(CONFLICT_RESOLUTION_SYSTEM_PROMPT, userMessage, model);
357
+ return result?.trim() ?? null;
358
+ } catch {
359
+ return null;
360
+ }
161
361
  }
162
- async function checkoutBranch(branch) {
163
- return run(["checkout", branch]);
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;
164
392
  }
165
- async function createBranch(branch, from) {
166
- const args = from ? ["checkout", "-b", branch, from] : ["checkout", "-b", branch];
167
- return run(args);
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
+ }
168
418
  }
169
- async function resetHard(ref) {
170
- return run(["reset", "--hard", ref]);
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
+ }
171
432
  }
172
- async function pushSetUpstream(remote, branch) {
173
- return run(["push", "-u", remote, branch]);
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
+ });
174
446
  }
175
- async function rebase(branch) {
176
- return run(["rebase", branch]);
447
+ async function checkGhInstalled() {
448
+ try {
449
+ const { exitCode } = await run(["--version"]);
450
+ return exitCode === 0;
451
+ } catch {
452
+ return false;
453
+ }
177
454
  }
178
- async function getStagedDiff() {
179
- const { stdout } = await run(["diff", "--cached"]);
180
- return stdout;
455
+ async function checkGhAuth() {
456
+ try {
457
+ const { exitCode } = await run(["auth", "status"]);
458
+ return exitCode === 0;
459
+ } catch {
460
+ return false;
461
+ }
181
462
  }
182
- async function getStagedFiles() {
183
- const { exitCode, stdout } = await run(["diff", "--cached", "--name-only"]);
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"]);
184
468
  if (exitCode !== 0)
185
- return [];
186
- return stdout.trim().split(`
187
- `).filter(Boolean);
469
+ return null;
470
+ try {
471
+ return JSON.parse(stdout.trim());
472
+ } catch {
473
+ return null;
474
+ }
188
475
  }
189
- async function getChangedFiles() {
190
- const { exitCode, stdout } = await run(["status", "--porcelain"]);
476
+ async function isRepoFork() {
477
+ const { exitCode, stdout } = await run(["repo", "view", "--json", "isFork", "-q", ".isFork"]);
191
478
  if (exitCode !== 0)
192
- return [];
193
- return stdout.trim().split(`
194
- `).filter(Boolean).map((l) => l.slice(3));
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;
195
486
  }
196
- async function getDivergence(branch, base) {
487
+ async function getCurrentRepoInfo() {
197
488
  const { exitCode, stdout } = await run([
198
- "rev-list",
199
- "--left-right",
200
- "--count",
201
- `${base}...${branch}`
489
+ "repo",
490
+ "view",
491
+ "--json",
492
+ "nameWithOwner",
493
+ "-q",
494
+ ".nameWithOwner"
202
495
  ]);
203
496
  if (exitCode !== 0)
204
- return { ahead: 0, behind: 0 };
205
- const parts = stdout.trim().split(/\s+/);
206
- return {
207
- behind: Number.parseInt(parts[0] ?? "0", 10),
208
- ahead: Number.parseInt(parts[1] ?? "0", 10)
209
- };
210
- }
211
- async function getMergedBranches(base) {
212
- const { exitCode, stdout } = await run(["branch", "--merged", base]);
213
- if (exitCode !== 0)
214
- return [];
215
- return stdout.trim().split(`
216
- `).map((b) => b.replace(/^\*?\s+/, "").trim()).filter(Boolean);
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);
217
754
  }
218
755
  async function deleteBranch(branch) {
219
- return run(["branch", "-d", 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]);
220
779
  }
221
780
  async function pruneRemote(remote) {
222
- return run(["remote", "prune", remote]);
781
+ return run2(["remote", "prune", remote]);
223
782
  }
224
783
  async function commitWithMessage(message) {
225
- return run(["commit", "-m", message]);
784
+ return run2(["commit", "-m", message]);
226
785
  }
227
786
  async function getLogDiff(base, head) {
228
- const { stdout } = await run(["diff", `${base}...${head}`]);
787
+ const { stdout } = await run2(["diff", `${base}...${head}`]);
229
788
  return stdout;
230
789
  }
231
790
  async function getLog(base, head) {
232
- const { exitCode, stdout } = await run(["log", `${base}..${head}`, "--oneline"]);
791
+ const { exitCode, stdout } = await run2(["log", `${base}..${head}`, "--oneline"]);
233
792
  if (exitCode !== 0)
234
793
  return [];
235
794
  return stdout.trim().split(`
236
795
  `).filter(Boolean);
237
796
  }
238
797
  async function pullBranch(remote, branch) {
239
- return run(["pull", 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;
240
878
  }
241
879
 
242
880
  // src/utils/logger.ts
@@ -267,6 +905,53 @@ function heading(msg) {
267
905
  ${pc2.bold(msg)}`);
268
906
  }
269
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
+
270
955
  // src/utils/workflow.ts
271
956
  var WORKFLOW_DESCRIPTIONS = {
272
957
  "clean-flow": "Clean Flow — main + dev, squash features into dev, merge dev into main",
@@ -315,6 +1000,91 @@ function getProtectedBranches(config) {
315
1000
  }
316
1001
 
317
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
+ }
318
1088
  var clean_default = defineCommand({
319
1089
  meta: {
320
1090
  name: "clean",
@@ -340,47 +1110,114 @@ var clean_default = defineCommand({
340
1110
  }
341
1111
  const { origin } = config;
342
1112
  const baseBranch = getBaseBranch(config);
343
- const currentBranch = await getCurrentBranch();
1113
+ let currentBranch = await getCurrentBranch();
344
1114
  heading("\uD83E\uDDF9 contrib clean");
345
- const mergedBranches = await getMergedBranches(baseBranch);
346
- const protectedBranches = new Set([...getProtectedBranches(config), currentBranch ?? ""]);
347
- const candidates = mergedBranches.filter((b) => !protectedBranches.has(b));
348
- if (candidates.length === 0) {
349
- info("No merged branches to clean up.");
1115
+ info(`Pruning ${origin} remote refs...`);
1116
+ const pruneResult = await pruneRemote(origin);
1117
+ if (pruneResult.exitCode === 0) {
1118
+ success(`Pruned ${origin} remote refs.`);
350
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) {
351
1140
  console.log(`
352
- ${pc3.bold("Branches to delete:")}`);
353
- for (const b of candidates) {
354
- console.log(` ${pc3.dim("•")} ${b}`);
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}`);
355
1145
  }
356
1146
  console.log();
357
- const ok = args.yes || await confirmPrompt(`Delete ${pc3.bold(String(candidates.length))} merged branch${candidates.length !== 1 ? "es" : ""}?`);
358
- if (!ok) {
359
- info("Skipped branch deletion.");
360
- } else {
361
- for (const branch of candidates) {
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
+ }
362
1162
  const result = await deleteBranch(branch);
363
1163
  if (result.exitCode === 0) {
364
- success(` Deleted ${pc3.bold(branch)}`);
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)}`);
365
1199
  } else {
366
1200
  warn(` Failed to delete ${branch}: ${result.stderr.trim()}`);
367
1201
  }
368
1202
  }
1203
+ } else {
1204
+ info("Skipped stale branch deletion.");
369
1205
  }
370
1206
  }
371
- info(`Pruning ${origin} remote refs...`);
372
- const pruneResult = await pruneRemote(origin);
373
- if (pruneResult.exitCode === 0) {
374
- success(`✅ Pruned ${origin} remote refs.`);
375
- } else {
376
- warn(`Could not prune remote: ${pruneResult.stderr.trim()}`);
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.`);
377
1214
  }
378
1215
  }
379
1216
  });
380
1217
 
381
1218
  // src/commands/commit.ts
382
1219
  import { defineCommand as defineCommand2 } from "citty";
383
- import pc4 from "picocolors";
1220
+ import pc5 from "picocolors";
384
1221
 
385
1222
  // src/utils/convention.ts
386
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;
@@ -425,339 +1262,734 @@ function getValidationError(convention) {
425
1262
  ];
426
1263
  }
427
1264
 
428
- // src/utils/copilot.ts
429
- import { CopilotClient } from "@github/copilot-sdk";
430
- var CONVENTIONAL_COMMIT_SYSTEM_PROMPT = `You are a git commit message generator. Generate a Conventional Commit message following this exact format:
431
- <type>[!][(<scope>)]: <description>
432
-
433
- Types:
434
- feat – a new feature
435
- fix – a bug fix
436
- docs – documentation only changes
437
- style – changes that do not affect code meaning (whitespace, formatting)
438
- refactor – code change that neither fixes a bug nor adds a feature
439
- perf – performance improvement
440
- test – adding or correcting tests
441
- build – changes to the build system or external dependencies
442
- ci – changes to CI configuration files and scripts
443
- chore – other changes that don't modify src or test files
444
- revert – reverts a previous commit
445
-
446
- Rules:
447
- - Breaking change (!) only for: feat, fix, refactor, perf
448
- - Description: concise, imperative mood, max 72 chars, lowercase start
449
- - Scope: optional, camelCase or kebab-case component name
450
- - Return ONLY the commit message line, nothing else
451
-
452
- Examples:
453
- feat: add user authentication system
454
- fix(auth): resolve token expiry issue
455
- docs: update contributing guidelines
456
- feat!: redesign authentication API`;
457
- var CLEAN_COMMIT_SYSTEM_PROMPT = `You are a git commit message generator. Generate a Clean Commit message following this exact format:
458
- <emoji> <type>[!][(<scope>)]: <description>
459
-
460
- Emoji and type table:
461
- \uD83D\uDCE6 new – new features, files, or capabilities
462
- \uD83D\uDD27 update – changes, refactoring, improvements
463
- \uD83D\uDDD1️ remove – removing code, files, or dependencies
464
- \uD83D\uDD12 security – security fixes or patches
465
- ⚙️ setup – configs, CI/CD, tooling, build systems
466
- ☕ chore – maintenance, dependency updates
467
- \uD83E\uDDEA test – adding or updating tests
468
- \uD83D\uDCD6 docs – documentation changes
469
- \uD83D\uDE80 release – version releases
470
-
471
- Rules:
472
- - Breaking change (!) only for: new, update, remove, security
473
- - Description: concise, imperative mood, max 72 chars
474
- - Scope: optional, camelCase or kebab-case component name
475
- - Return ONLY the commit message line, nothing else
476
-
477
- Examples:
478
- \uD83D\uDCE6 new: user authentication system
479
- \uD83D\uDD27 update (api): improve error handling
480
- ⚙️ setup (ci): configure github actions workflow
481
- \uD83D\uDCE6 new!: completely redesign authentication system`;
482
- var BRANCH_NAME_SYSTEM_PROMPT = `You are a git branch name generator. Convert natural language descriptions into proper git branch names.
483
-
484
- Format: <prefix>/<kebab-case-name>
485
- Prefixes: feature, fix, docs, chore, test, refactor
486
-
487
- Rules:
488
- - Use lowercase kebab-case for the name part
489
- - Keep it short and descriptive (2-5 words max)
490
- - Return ONLY the branch name, nothing else
491
-
492
- Examples:
493
- Input: "fix the login timeout bug" → fix/login-timeout
494
- Input: "add user profile page" → feature/user-profile-page
495
- Input: "update readme documentation" → docs/update-readme`;
496
- var PR_DESCRIPTION_SYSTEM_PROMPT = `You are a GitHub pull request description generator. Create a clear, structured PR description.
497
-
498
- Return a JSON object with this exact structure:
499
- {
500
- "title": "Brief PR title (50 chars max)",
501
- "body": "## Summary\\n...\\n\\n## Changes\\n...\\n\\n## Test Plan\\n..."
502
- }
503
-
504
- Rules:
505
- - title: concise, present tense, describes what the PR does
506
- - body: markdown with Summary, Changes (bullet list), and Test Plan sections
507
- - Return ONLY the JSON object, no markdown fences, no extra text`;
508
- var CONFLICT_RESOLUTION_SYSTEM_PROMPT = `You are a git merge conflict resolution advisor. Analyze the conflict markers and provide guidance.
509
-
510
- Rules:
511
- - Explain what each side of the conflict contains
512
- - Suggest the most likely correct resolution strategy
513
- - Never auto-resolve — provide guidance only
514
- - Be concise and actionable`;
515
- async function checkCopilotAvailable() {
516
- let client = null;
517
- try {
518
- client = new CopilotClient;
519
- await client.start();
520
- } catch (err) {
521
- const msg = err instanceof Error ? err.message : String(err);
522
- if (msg.includes("ENOENT") || msg.includes("not found")) {
523
- return "Copilot CLI binary not found. Ensure GitHub Copilot is installed and your gh CLI is up to date.";
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
524
1285
  }
525
- return `Failed to start Copilot service: ${msg}`;
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);
526
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;
527
1455
  try {
528
- await client.ping();
1456
+ groups = await generateCommitGroups(changedFiles, diffs, model, config.commitConvention);
1457
+ spinner.success(`AI generated ${groups.length} commit group(s).`);
529
1458
  } catch (err) {
530
- const msg = err instanceof Error ? err.message : String(err);
531
- if (msg.includes("auth") || msg.includes("token") || msg.includes("401") || msg.includes("403")) {
532
- return "Copilot authentication failed. Run `gh auth login` to refresh your token.";
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.`);
533
1472
  }
534
- if (msg.includes("ECONNREFUSED") || msg.includes("timeout") || msg.includes("network")) {
535
- return "Could not reach GitHub Copilot service. Check your internet connection.";
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();
536
1493
  }
537
- return `Copilot health check failed: ${msg}`;
538
- } finally {
539
- try {
540
- await client.stop();
541
- } catch {}
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";
542
1516
  }
543
- return null;
544
- }
545
- async function callCopilot(systemMessage, userMessage, model) {
546
- const client = new CopilotClient;
547
- await client.start();
548
- try {
549
- const sessionConfig = {
550
- systemMessage: { content: systemMessage }
551
- };
552
- if (model)
553
- sessionConfig.model = model;
554
- const session = await client.createSession(sessionConfig);
555
- try {
556
- const response = await session.sendAndWait({ content: userMessage });
557
- if (!response?.data?.content)
558
- return null;
559
- return response.data.content;
560
- } finally {
561
- await session.destroy();
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
+ }
562
1608
  }
563
- } finally {
564
- await client.stop();
565
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);
566
1617
  }
567
- function getCommitSystemPrompt(convention) {
568
- if (convention === "conventional")
569
- return CONVENTIONAL_COMMIT_SYSTEM_PROMPT;
570
- return CLEAN_COMMIT_SYSTEM_PROMPT;
571
- }
572
- async function generateCommitMessage(diff, stagedFiles, model, convention = "clean-commit") {
573
- try {
574
- const userMessage = `Generate a commit message for these staged changes:
575
1618
 
576
- Files: ${stagedFiles.join(", ")}
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
+ };
577
1684
 
578
- Diff:
579
- ${diff.slice(0, 4000)}`;
580
- const result = await callCopilot(getCommitSystemPrompt(convention), userMessage, model);
581
- return result?.trim() ?? null;
582
- } catch {
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)
583
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
+ }
584
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
+ });
585
1750
  }
586
- async function generatePRDescription(commits, diff, model) {
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
+ }
587
1788
  try {
588
- const userMessage = `Generate a PR description for these changes:
589
-
590
- Commits:
591
- ${commits.join(`
592
- `)}
593
-
594
- Diff (truncated):
595
- ${diff.slice(0, 4000)}`;
596
- const result = await callCopilot(PR_DESCRIPTION_SYSTEM_PROMPT, userMessage, model);
597
- if (!result)
598
- return null;
599
- const cleaned = result.trim().replace(/^```(?:json)?\s*/i, "").replace(/\s*```$/i, "").trim();
600
- return JSON.parse(cleaned);
1789
+ await import("@github/copilot-sdk");
1790
+ checks.push({ label: "Copilot SDK importable", ok: true });
601
1791
  } catch {
602
- return null;
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
+ }
603
1867
  }
604
- }
605
- async function suggestBranchName(description, model) {
606
- try {
607
- const result = await callCopilot(BRANCH_NAME_SYSTEM_PROMPT, description, model);
608
- return result?.trim() ?? null;
609
- } catch {
610
- return null;
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 });
611
1888
  }
1889
+ return { title: "Fork Detection", checks };
612
1890
  }
613
- async function suggestConflictResolution(conflictDiff, model) {
614
- try {
615
- const userMessage = `Help me resolve this merge conflict:
616
-
617
- ${conflictDiff.slice(0, 4000)}`;
618
- const result = await callCopilot(CONFLICT_RESOLUTION_SYSTEM_PROMPT, userMessage, model);
619
- return result?.trim() ?? null;
620
- } catch {
621
- return null;
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
+ }
622
1926
  }
1927
+ if (checks.length === 0) {
1928
+ checks.push({ label: "No relevant environment variables set", ok: true });
1929
+ }
1930
+ return { title: "Environment", checks };
623
1931
  }
624
-
625
- // src/commands/commit.ts
626
- var commit_default = defineCommand2({
1932
+ var doctor_default = defineCommand3({
627
1933
  meta: {
628
- name: "commit",
629
- description: "Stage changes and create a commit message (AI-powered)"
1934
+ name: "doctor",
1935
+ description: "Diagnose the contribute-now CLI environment and configuration"
630
1936
  },
631
1937
  args: {
632
- model: {
633
- type: "string",
634
- description: "AI model to use for commit message generation"
635
- },
636
- "no-ai": {
1938
+ json: {
637
1939
  type: "boolean",
638
- description: "Skip AI and write commit message manually",
1940
+ description: "Output report as JSON",
639
1941
  default: false
640
1942
  }
641
1943
  },
642
1944
  async run({ args }) {
643
- if (!await isGitRepo()) {
644
- error("Not inside a git repository.");
645
- process.exit(1);
646
- }
647
- const config = readConfig();
648
- if (!config) {
649
- error("No .contributerc.json found. Run `contrib setup` first.");
650
- process.exit(1);
651
- }
652
- heading("\uD83D\uDCBE contrib commit");
653
- const stagedFiles = await getStagedFiles();
654
- if (stagedFiles.length === 0) {
655
- const changedFiles = await getChangedFiles();
656
- if (changedFiles.length === 0) {
657
- error("No changes to commit.");
658
- process.exit(1);
659
- }
660
- console.log(`
661
- ${pc4.bold("Changed files:")}`);
662
- for (const f of changedFiles) {
663
- console.log(` ${pc4.dim("•")} ${f}`);
664
- }
665
- console.log();
666
- warn("No staged changes. Stage your files with `git add` and re-run.");
667
- process.exit(1);
668
- }
669
- info(`Staged files: ${stagedFiles.join(", ")}`);
670
- let commitMessage = null;
671
- const useAI = !args["no-ai"];
672
- if (useAI) {
673
- const copilotError = await checkCopilotAvailable();
674
- if (copilotError) {
675
- warn(`AI unavailable: ${copilotError}`);
676
- warn("Falling back to manual commit message entry.");
677
- } else {
678
- info("Generating commit message with AI...");
679
- const diff = await getStagedDiff();
680
- commitMessage = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
681
- if (commitMessage) {
682
- console.log(`
683
- ${pc4.dim("AI suggestion:")} ${pc4.bold(pc4.cyan(commitMessage))}`);
684
- } else {
685
- warn("AI did not return a commit message. Falling back to manual entry.");
686
- }
687
- }
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;
688
1961
  }
689
- let finalMessage = null;
690
- if (commitMessage) {
691
- const action = await selectPrompt("What would you like to do?", [
692
- "Accept this message",
693
- "Edit this message",
694
- "Regenerate",
695
- "Write manually"
696
- ]);
697
- if (action === "Accept this message") {
698
- finalMessage = commitMessage;
699
- } else if (action === "Edit this message") {
700
- finalMessage = await inputPrompt("Edit commit message", commitMessage);
701
- } else if (action === "Regenerate") {
702
- info("Regenerating...");
703
- const diff = await getStagedDiff();
704
- const regen = await generateCommitMessage(diff, stagedFiles, args.model, config.commitConvention);
705
- if (regen) {
706
- console.log(`
707
- ${pc4.dim("AI suggestion:")} ${pc4.bold(pc4.cyan(regen))}`);
708
- const ok = await confirmPrompt("Use this message?");
709
- finalMessage = ok ? regen : await inputPrompt("Enter commit message manually");
710
- } else {
711
- warn("Regeneration failed. Falling back to manual entry.");
712
- finalMessage = await inputPrompt("Enter commit message");
713
- }
714
- } else {
715
- finalMessage = await inputPrompt("Enter commit message");
716
- }
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
+ `);
717
1970
  } else {
718
- const convention2 = config.commitConvention;
719
- if (convention2 !== "none") {
720
- console.log();
721
- for (const hint of CONVENTION_FORMAT_HINTS[convention2]) {
722
- console.log(pc4.dim(hint));
723
- }
724
- console.log();
1971
+ if (failures.length > 0) {
1972
+ console.log(` ${pc6.red(`${failures.length} issue${failures.length !== 1 ? "s" : ""} found.`)}`);
725
1973
  }
726
- finalMessage = await inputPrompt("Enter commit message");
727
- }
728
- if (!finalMessage) {
729
- error("No commit message provided.");
730
- process.exit(1);
731
- }
732
- const convention = config.commitConvention;
733
- if (!validateCommitMessage(finalMessage, convention)) {
734
- for (const line of getValidationError(convention)) {
735
- warn(line);
1974
+ if (warnings.length > 0) {
1975
+ console.log(` ${pc6.yellow(`${warnings.length} warning${warnings.length !== 1 ? "s" : ""}.`)}`);
736
1976
  }
737
- const proceed = await confirmPrompt("Commit anyway?");
738
- if (!proceed)
739
- process.exit(1);
740
- }
741
- const result = await commitWithMessage(finalMessage);
742
- if (result.exitCode !== 0) {
743
- error(`Failed to commit: ${result.stderr}`);
744
- process.exit(1);
1977
+ console.log();
745
1978
  }
746
- success(`✅ Committed: ${pc4.bold(finalMessage)}`);
747
1979
  }
748
1980
  });
749
1981
 
750
1982
  // src/commands/hook.ts
751
- import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, rmSync, writeFileSync as writeFileSync2 } from "node:fs";
752
- import { join as join2 } from "node:path";
753
- import { defineCommand as defineCommand3 } from "citty";
754
- import pc5 from "picocolors";
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";
755
1987
  var HOOK_MARKER = "# managed by contribute-now";
756
1988
  function getHooksDir(cwd = process.cwd()) {
757
- return join2(cwd, ".git", "hooks");
1989
+ return join3(cwd, ".git", "hooks");
758
1990
  }
759
1991
  function getHookPath(cwd = process.cwd()) {
760
- return join2(getHooksDir(cwd), "commit-msg");
1992
+ return join3(getHooksDir(cwd), "commit-msg");
761
1993
  }
762
1994
  function generateHookScript() {
763
1995
  return `#!/bin/sh
@@ -774,11 +2006,22 @@ case "$commit_msg" in
774
2006
  Merge\\ *|fixup!*|squash!*|amend!*) exit 0 ;;
775
2007
  esac
776
2008
 
777
- # Validate using contrib CLI
778
- npx contrib validate "$commit_msg"
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
779
2022
  `;
780
2023
  }
781
- var hook_default = defineCommand3({
2024
+ var hook_default = defineCommand4({
782
2025
  meta: {
783
2026
  name: "hook",
784
2027
  description: "Install or uninstall the commit-msg git hook"
@@ -822,155 +2065,43 @@ async function installHook() {
822
2065
  const hookPath = getHookPath();
823
2066
  const hooksDir = getHooksDir();
824
2067
  if (existsSync2(hookPath)) {
825
- const existing = readFileSync2(hookPath, "utf-8");
826
- if (!existing.includes(HOOK_MARKER)) {
827
- error("A commit-msg hook already exists and was not installed by contribute-now.");
828
- warn(`Path: ${hookPath}`);
829
- warn("Remove it manually or back it up before installing.");
830
- process.exit(1);
831
- }
832
- info("Updating existing contribute-now hook...");
833
- }
834
- if (!existsSync2(hooksDir)) {
835
- mkdirSync(hooksDir, { recursive: true });
836
- }
837
- writeFileSync2(hookPath, generateHookScript(), { mode: 493 });
838
- success(`commit-msg hook installed.`);
839
- info(`Convention: ${pc5.bold(CONVENTION_LABELS[config.commitConvention])}`);
840
- info(`Path: ${pc5.dim(hookPath)}`);
841
- }
842
- async function uninstallHook() {
843
- heading("\uD83E\uDE9D hook uninstall");
844
- const hookPath = getHookPath();
845
- if (!existsSync2(hookPath)) {
846
- info("No commit-msg hook found. Nothing to uninstall.");
847
- return;
848
- }
849
- const content = readFileSync2(hookPath, "utf-8");
850
- if (!content.includes(HOOK_MARKER)) {
851
- error("The commit-msg hook was not installed by contribute-now. Leaving it untouched.");
852
- process.exit(1);
853
- }
854
- rmSync(hookPath);
855
- success("commit-msg hook removed.");
856
- }
857
-
858
- // src/commands/setup.ts
859
- import { defineCommand as defineCommand4 } from "citty";
860
- import pc6 from "picocolors";
861
-
862
- // src/utils/gh.ts
863
- import { execFile as execFileCb2 } from "node:child_process";
864
- function run2(args) {
865
- return new Promise((resolve) => {
866
- execFileCb2("gh", args, (error2, stdout, stderr) => {
867
- resolve({
868
- exitCode: error2 ? error2.code != null ? Number(error2.code) : 1 : 0,
869
- stdout: stdout ?? "",
870
- stderr: stderr ?? ""
871
- });
872
- });
873
- });
874
- }
875
- async function checkGhInstalled() {
876
- try {
877
- const { exitCode } = await run2(["--version"]);
878
- return exitCode === 0;
879
- } catch {
880
- return false;
881
- }
882
- }
883
- async function checkGhAuth() {
884
- try {
885
- const { exitCode } = await run2(["auth", "status"]);
886
- return exitCode === 0;
887
- } catch {
888
- return false;
889
- }
890
- }
891
- async function checkRepoPermissions(owner, repo) {
892
- const { exitCode, stdout } = await run2(["api", `repos/${owner}/${repo}`, "--jq", ".permissions"]);
893
- if (exitCode !== 0)
894
- return null;
895
- try {
896
- return JSON.parse(stdout.trim());
897
- } catch {
898
- return null;
899
- }
900
- }
901
- async function isRepoFork() {
902
- const { exitCode, stdout } = await run2(["repo", "view", "--json", "isFork", "-q", ".isFork"]);
903
- if (exitCode !== 0)
904
- return null;
905
- const val = stdout.trim();
906
- if (val === "true")
907
- return true;
908
- if (val === "false")
909
- return false;
910
- return null;
911
- }
912
- async function getCurrentRepoInfo() {
913
- const { exitCode, stdout } = await run2([
914
- "repo",
915
- "view",
916
- "--json",
917
- "nameWithOwner",
918
- "-q",
919
- ".nameWithOwner"
920
- ]);
921
- if (exitCode !== 0)
922
- return null;
923
- const nameWithOwner = stdout.trim();
924
- if (!nameWithOwner)
925
- return null;
926
- const [owner, repo] = nameWithOwner.split("/");
927
- if (!owner || !repo)
928
- return null;
929
- return { owner, repo };
930
- }
931
- async function createPR(options) {
932
- const args = [
933
- "pr",
934
- "create",
935
- "--base",
936
- options.base,
937
- "--title",
938
- options.title,
939
- "--body",
940
- options.body
941
- ];
942
- if (options.draft)
943
- args.push("--draft");
944
- return run2(args);
945
- }
946
- async function createPRFill(base, draft) {
947
- const args = ["pr", "create", "--base", base, "--fill"];
948
- if (draft)
949
- args.push("--draft");
950
- return run2(args);
951
- }
952
-
953
- // src/utils/remote.ts
954
- function parseRepoFromUrl(url) {
955
- const httpsMatch = url.match(/https?:\/\/github\.com\/([^/]+)\/([^/.]+?)(?:\.git)?$/);
956
- if (httpsMatch) {
957
- return { owner: httpsMatch[1], repo: httpsMatch[2] };
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...");
958
2076
  }
959
- const sshMatch = url.match(/git@github\.com:([^/]+)\/([^/.]+?)(?:\.git)?$/);
960
- if (sshMatch) {
961
- return { owner: sshMatch[1], repo: sshMatch[2] };
2077
+ if (!existsSync2(hooksDir)) {
2078
+ mkdirSync(hooksDir, { recursive: true });
962
2079
  }
963
- return null;
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)}`);
964
2084
  }
965
- async function getRepoInfoFromRemote(remote = "origin") {
966
- const url = await getRemoteUrl(remote);
967
- if (!url)
968
- return null;
969
- return parseRepoFromUrl(url);
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;
2091
+ }
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);
2096
+ }
2097
+ rmSync(hookPath);
2098
+ success("commit-msg hook removed.");
970
2099
  }
971
2100
 
972
2101
  // src/commands/setup.ts
973
- var setup_default = defineCommand4({
2102
+ import { defineCommand as defineCommand5 } from "citty";
2103
+ import pc8 from "picocolors";
2104
+ var setup_default = defineCommand5({
974
2105
  meta: {
975
2106
  name: "setup",
976
2107
  description: "Initialize contribute-now config for this repo (.contributerc.json)"
@@ -991,7 +2122,7 @@ var setup_default = defineCommand4({
991
2122
  workflow = "github-flow";
992
2123
  else if (workflowChoice.startsWith("Git Flow"))
993
2124
  workflow = "git-flow";
994
- info(`Workflow: ${pc6.bold(WORKFLOW_DESCRIPTIONS[workflow])}`);
2125
+ info(`Workflow: ${pc8.bold(WORKFLOW_DESCRIPTIONS[workflow])}`);
995
2126
  const conventionChoice = await selectPrompt("Which commit convention should this project use?", [
996
2127
  `${CONVENTION_DESCRIPTIONS["clean-commit"]} (recommended)`,
997
2128
  CONVENTION_DESCRIPTIONS.conventional,
@@ -1044,8 +2175,8 @@ var setup_default = defineCommand4({
1044
2175
  detectedRole = roleChoice;
1045
2176
  detectionSource = "user selection";
1046
2177
  } else {
1047
- info(`Detected role: ${pc6.bold(detectedRole)} (via ${detectionSource})`);
1048
- const confirmed = await confirmPrompt(`Role detected as ${pc6.bold(detectedRole)}. Is this correct?`);
2178
+ info(`Detected role: ${pc8.bold(detectedRole)} (via ${detectionSource})`);
2179
+ const confirmed = await confirmPrompt(`Role detected as ${pc8.bold(detectedRole)}. Is this correct?`);
1049
2180
  if (!confirmed) {
1050
2181
  const roleChoice = await selectPrompt("Select your role:", ["maintainer", "contributor"]);
1051
2182
  detectedRole = roleChoice;
@@ -1090,37 +2221,22 @@ var setup_default = defineCommand4({
1090
2221
  warn(' echo ".contributerc.json" >> .gitignore');
1091
2222
  }
1092
2223
  console.log();
1093
- info(`Workflow: ${pc6.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
1094
- info(`Convention: ${pc6.bold(CONVENTION_DESCRIPTIONS[config.commitConvention])}`);
1095
- info(`Role: ${pc6.bold(config.role)}`);
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)}`);
1096
2227
  if (config.devBranch) {
1097
- info(`Main: ${pc6.bold(config.mainBranch)} | Dev: ${pc6.bold(config.devBranch)}`);
2228
+ info(`Main: ${pc8.bold(config.mainBranch)} | Dev: ${pc8.bold(config.devBranch)}`);
1098
2229
  } else {
1099
- info(`Main: ${pc6.bold(config.mainBranch)}`);
2230
+ info(`Main: ${pc8.bold(config.mainBranch)}`);
1100
2231
  }
1101
- info(`Origin: ${pc6.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc6.bold(config.upstream)}` : ""}`);
2232
+ info(`Origin: ${pc8.bold(config.origin)}${config.role === "contributor" ? ` | Upstream: ${pc8.bold(config.upstream)}` : ""}`);
1102
2233
  }
1103
2234
  });
1104
2235
 
1105
2236
  // src/commands/start.ts
1106
- import { defineCommand as defineCommand5 } from "citty";
1107
- import pc7 from "picocolors";
1108
-
1109
- // src/utils/branch.ts
1110
- var DEFAULT_PREFIXES = ["feature", "fix", "docs", "chore", "test", "refactor"];
1111
- function hasPrefix(branchName, prefixes = DEFAULT_PREFIXES) {
1112
- return prefixes.some((p) => branchName.startsWith(`${p}/`));
1113
- }
1114
- function formatBranchName(prefix, name) {
1115
- const sanitized = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1116
- return `${prefix}/${sanitized}`;
1117
- }
1118
- function looksLikeNaturalLanguage(input) {
1119
- return input.includes(" ") && !input.includes("/");
1120
- }
1121
-
1122
- // src/commands/start.ts
1123
- var start_default = defineCommand5({
2237
+ import { defineCommand as defineCommand6 } from "citty";
2238
+ import pc9 from "picocolors";
2239
+ var start_default = defineCommand6({
1124
2240
  meta: {
1125
2241
  name: "start",
1126
2242
  description: "Create a new feature branch from the latest base branch"
@@ -1162,40 +2278,47 @@ var start_default = defineCommand5({
1162
2278
  heading("\uD83C\uDF3F contrib start");
1163
2279
  const useAI = !args["no-ai"] && looksLikeNaturalLanguage(branchName);
1164
2280
  if (useAI) {
1165
- info("Generating branch name suggestion from description...");
2281
+ const spinner = createSpinner("Generating branch name suggestion...");
1166
2282
  const suggested = await suggestBranchName(branchName, args.model);
1167
2283
  if (suggested) {
2284
+ spinner.success("Branch name suggestion ready.");
1168
2285
  console.log(`
1169
- ${pc7.dim("AI suggestion:")} ${pc7.bold(pc7.cyan(suggested))}`);
1170
- const accepted = await confirmPrompt(`Use ${pc7.bold(suggested)} as your branch name?`);
2286
+ ${pc9.dim("AI suggestion:")} ${pc9.bold(pc9.cyan(suggested))}`);
2287
+ const accepted = await confirmPrompt(`Use ${pc9.bold(suggested)} as your branch name?`);
1171
2288
  if (accepted) {
1172
2289
  branchName = suggested;
1173
2290
  } else {
1174
2291
  branchName = await inputPrompt("Enter branch name", branchName);
1175
2292
  }
2293
+ } else {
2294
+ spinner.fail("AI did not return a branch name suggestion.");
1176
2295
  }
1177
2296
  }
1178
2297
  if (!hasPrefix(branchName, branchPrefixes)) {
1179
- const prefix = await selectPrompt(`Choose a branch type for ${pc7.bold(branchName)}:`, branchPrefixes);
2298
+ const prefix = await selectPrompt(`Choose a branch type for ${pc9.bold(branchName)}:`, branchPrefixes);
1180
2299
  branchName = formatBranchName(prefix, branchName);
1181
2300
  }
1182
- info(`Creating branch: ${pc7.bold(branchName)}`);
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)}`);
1183
2306
  await fetchRemote(syncSource.remote);
1184
- const resetResult = await resetHard(syncSource.ref);
1185
- if (resetResult.exitCode !== 0) {}
2307
+ const updateResult = await updateLocalBranch(baseBranch, syncSource.ref);
2308
+ if (updateResult.exitCode !== 0) {}
1186
2309
  const result = await createBranch(branchName, baseBranch);
1187
2310
  if (result.exitCode !== 0) {
1188
2311
  error(`Failed to create branch: ${result.stderr}`);
1189
2312
  process.exit(1);
1190
2313
  }
1191
- success(`✅ Created ${pc7.bold(branchName)} from latest ${pc7.bold(baseBranch)}`);
2314
+ success(`✅ Created ${pc9.bold(branchName)} from latest ${pc9.bold(baseBranch)}`);
1192
2315
  }
1193
2316
  });
1194
2317
 
1195
2318
  // src/commands/status.ts
1196
- import { defineCommand as defineCommand6 } from "citty";
1197
- import pc8 from "picocolors";
1198
- var status_default = defineCommand6({
2319
+ import { defineCommand as defineCommand7 } from "citty";
2320
+ import pc10 from "picocolors";
2321
+ var status_default = defineCommand7({
1199
2322
  meta: {
1200
2323
  name: "status",
1201
2324
  description: "Show sync status of branches"
@@ -1211,17 +2334,17 @@ var status_default = defineCommand6({
1211
2334
  process.exit(1);
1212
2335
  }
1213
2336
  heading("\uD83D\uDCCA contribute-now status");
1214
- console.log(` ${pc8.dim("Workflow:")} ${pc8.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
1215
- console.log(` ${pc8.dim("Role:")} ${pc8.bold(config.role)}`);
2337
+ console.log(` ${pc10.dim("Workflow:")} ${pc10.bold(WORKFLOW_DESCRIPTIONS[config.workflow])}`);
2338
+ console.log(` ${pc10.dim("Role:")} ${pc10.bold(config.role)}`);
1216
2339
  console.log();
1217
2340
  await fetchAll();
1218
2341
  const currentBranch = await getCurrentBranch();
1219
2342
  const { mainBranch, origin, upstream, workflow } = config;
1220
2343
  const baseBranch = getBaseBranch(config);
1221
2344
  const isContributor = config.role === "contributor";
1222
- const dirty = await hasUncommittedChanges();
2345
+ const [dirty, fileStatus] = await Promise.all([hasUncommittedChanges(), getFileStatus()]);
1223
2346
  if (dirty) {
1224
- console.log(` ${pc8.yellow("⚠")} ${pc8.yellow("Uncommitted changes in working tree")}`);
2347
+ console.log(` ${pc10.yellow("⚠")} ${pc10.yellow("Uncommitted changes in working tree")}`);
1225
2348
  console.log();
1226
2349
  }
1227
2350
  const mainRemote = `${origin}/${mainBranch}`;
@@ -1237,31 +2360,131 @@ var status_default = defineCommand6({
1237
2360
  if (currentBranch && currentBranch !== mainBranch && currentBranch !== config.devBranch) {
1238
2361
  const branchDiv = await getDivergence(currentBranch, baseBranch);
1239
2362
  const branchLine = formatStatus(currentBranch, baseBranch, branchDiv.ahead, branchDiv.behind);
1240
- console.log(branchLine + pc8.dim(` (current ${pc8.green("*")})`));
2363
+ console.log(branchLine + pc10.dim(` (current ${pc10.green("*")})`));
1241
2364
  } else if (currentBranch) {
1242
- console.log(pc8.dim(` (on ${pc8.bold(currentBranch)} branch)`));
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)}`);
2409
+ }
1243
2410
  }
1244
2411
  console.log();
1245
2412
  }
1246
2413
  });
1247
2414
  function formatStatus(branch, base, ahead, behind) {
1248
- const label = pc8.bold(branch.padEnd(20));
2415
+ const label = pc10.bold(branch.padEnd(20));
1249
2416
  if (ahead === 0 && behind === 0) {
1250
- return ` ${pc8.green("✓")} ${label} ${pc8.dim(`in sync with ${base}`)}`;
2417
+ return ` ${pc10.green("✓")} ${label} ${pc10.dim(`in sync with ${base}`)}`;
1251
2418
  }
1252
2419
  if (ahead > 0 && behind === 0) {
1253
- return ` ${pc8.yellow("↑")} ${label} ${pc8.yellow(`${ahead} commit${ahead !== 1 ? "s" : ""} ahead of ${base}`)}`;
2420
+ return ` ${pc10.yellow("↑")} ${label} ${pc10.yellow(`${ahead} commit${ahead !== 1 ? "s" : ""} ahead of ${base}`)}`;
1254
2421
  }
1255
2422
  if (behind > 0 && ahead === 0) {
1256
- return ` ${pc8.red("↓")} ${label} ${pc8.red(`${behind} commit${behind !== 1 ? "s" : ""} behind ${base}`)}`;
2423
+ return ` ${pc10.red("↓")} ${label} ${pc10.red(`${behind} commit${behind !== 1 ? "s" : ""} behind ${base}`)}`;
1257
2424
  }
1258
- return ` ${pc8.red("⚡")} ${label} ${pc8.yellow(`${ahead} ahead`)}${pc8.dim(", ")}${pc8.red(`${behind} behind`)} ${pc8.dim(base)}`;
2425
+ return ` ${pc10.red("⚡")} ${label} ${pc10.yellow(`${ahead} ahead`)}${pc10.dim(", ")}${pc10.red(`${behind} behind`)} ${pc10.dim(base)}`;
1259
2426
  }
1260
2427
 
1261
2428
  // src/commands/submit.ts
1262
- import { defineCommand as defineCommand7 } from "citty";
1263
- import pc9 from "picocolors";
1264
- var submit_default = defineCommand7({
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({
1265
2488
  meta: {
1266
2489
  name: "submit",
1267
2490
  description: "Push current branch and create a pull request"
@@ -1301,89 +2524,251 @@ var submit_default = defineCommand7({
1301
2524
  process.exit(1);
1302
2525
  }
1303
2526
  if (protectedBranches.includes(currentBranch)) {
1304
- error(`Cannot submit ${protectedBranches.map((b) => pc9.bold(b)).join(" or ")} as a PR. Switch to your feature branch.`);
2527
+ error(`Cannot submit ${protectedBranches.map((b) => pc11.bold(b)).join(" or ")} as a PR. Switch to your feature branch.`);
1305
2528
  process.exit(1);
1306
2529
  }
1307
2530
  heading("\uD83D\uDE80 contrib submit");
1308
- info(`Pushing ${pc9.bold(currentBranch)} to ${origin}...`);
1309
- const pushResult = await pushSetUpstream(origin, currentBranch);
1310
- if (pushResult.exitCode !== 0) {
1311
- error(`Failed to push: ${pushResult.stderr}`);
1312
- process.exit(1);
1313
- }
1314
2531
  const ghInstalled = await checkGhInstalled();
1315
2532
  const ghAuthed = ghInstalled && await checkGhAuth();
1316
- if (!ghInstalled || !ghAuthed) {
1317
- const repoInfo = await getRepoInfoFromRemote(origin);
1318
- if (repoInfo) {
1319
- const prUrl = `https://github.com/${repoInfo.owner}/${repoInfo.repo}/compare/${baseBranch}...${currentBranch}?expand=1`;
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
+ }
1320
2627
  console.log();
1321
- info("Create your PR manually:");
1322
- console.log(` ${pc9.cyan(prUrl)}`);
1323
- } else {
1324
- 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;
1325
2630
  }
1326
- return;
1327
2631
  }
1328
2632
  let prTitle = null;
1329
2633
  let prBody = null;
1330
- if (!args["no-ai"]) {
1331
- const copilotError = await checkCopilotAvailable();
2634
+ async function tryGenerateAI() {
2635
+ const [copilotError, commits, diff] = await Promise.all([
2636
+ checkCopilotAvailable(),
2637
+ getLog(baseBranch, "HEAD"),
2638
+ getLogDiff(baseBranch, "HEAD")
2639
+ ]);
1332
2640
  if (!copilotError) {
1333
- info("Generating AI PR description...");
1334
- const commits = await getLog(baseBranch, "HEAD");
1335
- const diff = await getLogDiff(baseBranch, "HEAD");
1336
- 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);
1337
2643
  if (result) {
1338
2644
  prTitle = result.title;
1339
2645
  prBody = result.body;
2646
+ spinner.success("PR description generated.");
1340
2647
  console.log(`
1341
- ${pc9.dim("AI title:")} ${pc9.bold(pc9.cyan(prTitle))}`);
2648
+ ${pc11.dim("AI title:")} ${pc11.bold(pc11.cyan(prTitle))}`);
1342
2649
  console.log(`
1343
- ${pc9.dim("AI body preview:")}`);
1344
- console.log(pc9.dim(prBody.slice(0, 300) + (prBody.length > 300 ? "..." : "")));
2650
+ ${pc11.dim("AI body preview:")}`);
2651
+ console.log(pc11.dim(prBody.slice(0, 300) + (prBody.length > 300 ? "..." : "")));
1345
2652
  } else {
1346
- warn("AI did not return a PR description.");
2653
+ spinner.fail("AI did not return a PR description.");
1347
2654
  }
1348
2655
  } else {
1349
2656
  warn(`AI unavailable: ${copilotError}`);
1350
2657
  }
1351
2658
  }
1352
- if (prTitle && prBody) {
1353
- const action = await selectPrompt("What would you like to do with the PR description?", [
1354
- "Use AI description",
1355
- "Edit title",
1356
- "Write manually",
1357
- "Use gh --fill (auto-fill from commits)"
1358
- ]);
1359
- if (action === "Use AI description") {} else if (action === "Edit title") {
1360
- prTitle = await inputPrompt("PR title", prTitle);
1361
- } else if (action === "Write manually") {
1362
- prTitle = await inputPrompt("PR title");
1363
- prBody = await inputPrompt("PR body (markdown)");
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
+ }
1364
2701
  } else {
1365
- const fillResult = await createPRFill(baseBranch, args.draft);
1366
- if (fillResult.exitCode !== 0) {
1367
- error(`Failed to create PR: ${fillResult.stderr}`);
1368
- process.exit(1);
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;
1369
2725
  }
1370
- success(`✅ PR created: ${fillResult.stdout.trim()}`);
1371
- return;
1372
2726
  }
1373
- } else {
1374
- const useManual = await confirmPrompt("Create PR with manual title/body?");
1375
- if (useManual) {
1376
- prTitle = await inputPrompt("PR title");
1377
- prBody = await inputPrompt("PR body (markdown)");
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)}`);
1378
2753
  } else {
1379
- const fillResult = await createPRFill(baseBranch, args.draft);
1380
- if (fillResult.exitCode !== 0) {
1381
- error(`Failed to create PR: ${fillResult.stderr}`);
1382
- process.exit(1);
1383
- }
1384
- success(`✅ PR created: ${fillResult.stdout.trim()}`);
1385
- return;
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);
1386
2769
  }
2770
+ success(`✅ PR created: ${fillResult.stdout.trim()}`);
2771
+ return;
1387
2772
  }
1388
2773
  if (!prTitle) {
1389
2774
  error("No PR title provided.");
@@ -1404,9 +2789,9 @@ ${pc9.dim("AI body preview:")}`);
1404
2789
  });
1405
2790
 
1406
2791
  // src/commands/sync.ts
1407
- import { defineCommand as defineCommand8 } from "citty";
1408
- import pc10 from "picocolors";
1409
- var sync_default = defineCommand8({
2792
+ import { defineCommand as defineCommand9 } from "citty";
2793
+ import pc12 from "picocolors";
2794
+ var sync_default = defineCommand9({
1410
2795
  meta: {
1411
2796
  name: "sync",
1412
2797
  description: "Sync your local branches with the remote"
@@ -1448,16 +2833,16 @@ var sync_default = defineCommand8({
1448
2833
  }
1449
2834
  const div = await getDivergence(baseBranch, syncSource.ref);
1450
2835
  if (div.ahead > 0 || div.behind > 0) {
1451
- info(`${pc10.bold(baseBranch)} is ${pc10.yellow(`${div.ahead} ahead`)} and ${pc10.red(`${div.behind} behind`)} ${syncSource.ref}`);
2836
+ info(`${pc12.bold(baseBranch)} is ${pc12.yellow(`${div.ahead} ahead`)} and ${pc12.red(`${div.behind} behind`)} ${syncSource.ref}`);
1452
2837
  } else {
1453
- info(`${pc10.bold(baseBranch)} is already in sync with ${syncSource.ref}`);
2838
+ info(`${pc12.bold(baseBranch)} is already in sync with ${syncSource.ref}`);
1454
2839
  }
1455
2840
  if (!args.yes) {
1456
- const ok = await confirmPrompt(`This will pull ${pc10.bold(syncSource.ref)} into local ${pc10.bold(baseBranch)}.`);
2841
+ const ok = await confirmPrompt(`This will pull ${pc12.bold(syncSource.ref)} into local ${pc12.bold(baseBranch)}.`);
1457
2842
  if (!ok)
1458
2843
  process.exit(0);
1459
2844
  }
1460
- const coResult = await checkoutBranch(baseBranch);
2845
+ const coResult = await checkoutBranch2(baseBranch);
1461
2846
  if (coResult.exitCode !== 0) {
1462
2847
  error(`Failed to checkout ${baseBranch}: ${coResult.stderr}`);
1463
2848
  process.exit(1);
@@ -1471,25 +2856,25 @@ var sync_default = defineCommand8({
1471
2856
  if (hasDevBranch(workflow) && role === "maintainer") {
1472
2857
  const mainDiv = await getDivergence(config.mainBranch, `${origin}/${config.mainBranch}`);
1473
2858
  if (mainDiv.behind > 0) {
1474
- info(`Also syncing ${pc10.bold(config.mainBranch)}...`);
1475
- const mainCoResult = await checkoutBranch(config.mainBranch);
2859
+ info(`Also syncing ${pc12.bold(config.mainBranch)}...`);
2860
+ const mainCoResult = await checkoutBranch2(config.mainBranch);
1476
2861
  if (mainCoResult.exitCode === 0) {
1477
2862
  const mainPullResult = await pullBranch(origin, config.mainBranch);
1478
2863
  if (mainPullResult.exitCode === 0) {
1479
2864
  success(`✅ ${config.mainBranch} is now in sync with ${origin}/${config.mainBranch}`);
1480
2865
  }
1481
2866
  }
1482
- await checkoutBranch(baseBranch);
2867
+ await checkoutBranch2(baseBranch);
1483
2868
  }
1484
2869
  }
1485
2870
  }
1486
2871
  });
1487
2872
 
1488
2873
  // src/commands/update.ts
1489
- import { readFileSync as readFileSync3 } from "node:fs";
1490
- import { defineCommand as defineCommand9 } from "citty";
1491
- import pc11 from "picocolors";
1492
- var update_default = defineCommand9({
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({
1493
2878
  meta: {
1494
2879
  name: "update",
1495
2880
  description: "Rebase current branch onto the latest base branch"
@@ -1524,7 +2909,7 @@ var update_default = defineCommand9({
1524
2909
  process.exit(1);
1525
2910
  }
1526
2911
  if (protectedBranches.includes(currentBranch)) {
1527
- error(`Use \`contrib sync\` to update ${protectedBranches.map((b) => pc11.bold(b)).join(" or ")} branches.`);
2912
+ error(`Use \`contrib sync\` to update ${protectedBranches.map((b) => pc13.bold(b)).join(" or ")} branches.`);
1528
2913
  process.exit(1);
1529
2914
  }
1530
2915
  if (await hasUncommittedChanges()) {
@@ -1532,10 +2917,103 @@ var update_default = defineCommand9({
1532
2917
  process.exit(1);
1533
2918
  }
1534
2919
  heading("\uD83D\uDD03 contrib update");
1535
- info(`Updating ${pc11.bold(currentBranch)} with latest ${pc11.bold(baseBranch)}...`);
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)}...`);
1536
3010
  await fetchRemote(syncSource.remote);
1537
- await resetHard(syncSource.ref);
1538
- const rebaseResult = await rebase(baseBranch);
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);
1539
3017
  if (rebaseResult.exitCode !== 0) {
1540
3018
  warn("Rebase hit conflicts. Resolve them manually.");
1541
3019
  console.log();
@@ -1547,7 +3025,7 @@ var update_default = defineCommand9({
1547
3025
  let conflictDiff = "";
1548
3026
  for (const file of conflictFiles.slice(0, 3)) {
1549
3027
  try {
1550
- const content = readFileSync3(file, "utf-8");
3028
+ const content = readFileSync4(file, "utf-8");
1551
3029
  if (content.includes("<<<<<<<")) {
1552
3030
  conflictDiff += `
1553
3031
  --- ${file} ---
@@ -1557,34 +3035,38 @@ ${content.slice(0, 2000)}
1557
3035
  } catch {}
1558
3036
  }
1559
3037
  if (conflictDiff) {
3038
+ const spinner = createSpinner("Analyzing conflicts with AI...");
1560
3039
  const suggestion = await suggestConflictResolution(conflictDiff, args.model);
1561
3040
  if (suggestion) {
3041
+ spinner.success("AI conflict guidance ready.");
1562
3042
  console.log(`
1563
- ${pc11.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
1564
- console.log(pc11.dim("─".repeat(60)));
3043
+ ${pc13.bold("\uD83D\uDCA1 AI Conflict Resolution Guidance:")}`);
3044
+ console.log(pc13.dim("─".repeat(60)));
1565
3045
  console.log(suggestion);
1566
- console.log(pc11.dim("─".repeat(60)));
3046
+ console.log(pc13.dim("─".repeat(60)));
1567
3047
  console.log();
3048
+ } else {
3049
+ spinner.fail("AI could not analyze the conflicts.");
1568
3050
  }
1569
3051
  }
1570
3052
  }
1571
3053
  }
1572
- console.log(pc11.bold("To resolve:"));
3054
+ console.log(pc13.bold("To resolve:"));
1573
3055
  console.log(` 1. Fix conflicts in the affected files`);
1574
- console.log(` 2. ${pc11.cyan("git add <resolved-files>")}`);
1575
- console.log(` 3. ${pc11.cyan("git rebase --continue")}`);
3056
+ console.log(` 2. ${pc13.cyan("git add <resolved-files>")}`);
3057
+ console.log(` 3. ${pc13.cyan("git rebase --continue")}`);
1576
3058
  console.log();
1577
- console.log(` Or abort: ${pc11.cyan("git rebase --abort")}`);
3059
+ console.log(` Or abort: ${pc13.cyan("git rebase --abort")}`);
1578
3060
  process.exit(1);
1579
3061
  }
1580
- success(`✅ ${pc11.bold(currentBranch)} has been rebased onto latest ${pc11.bold(baseBranch)}`);
3062
+ success(`✅ ${pc13.bold(currentBranch)} has been rebased onto latest ${pc13.bold(baseBranch)}`);
1581
3063
  }
1582
3064
  });
1583
3065
 
1584
3066
  // src/commands/validate.ts
1585
- import { defineCommand as defineCommand10 } from "citty";
1586
- import pc12 from "picocolors";
1587
- var validate_default = defineCommand10({
3067
+ import { defineCommand as defineCommand11 } from "citty";
3068
+ import pc14 from "picocolors";
3069
+ var validate_default = defineCommand11({
1588
3070
  meta: {
1589
3071
  name: "validate",
1590
3072
  description: "Validate a commit message against the configured convention"
@@ -1614,7 +3096,7 @@ var validate_default = defineCommand10({
1614
3096
  }
1615
3097
  const errors = getValidationError(convention);
1616
3098
  for (const line of errors) {
1617
- console.error(pc12.red(` ✗ ${line}`));
3099
+ console.error(pc14.red(` ✗ ${line}`));
1618
3100
  }
1619
3101
  process.exit(1);
1620
3102
  }
@@ -1622,75 +3104,13 @@ var validate_default = defineCommand10({
1622
3104
 
1623
3105
  // src/ui/banner.ts
1624
3106
  import figlet from "figlet";
1625
- import pc13 from "picocolors";
1626
- // package.json
1627
- var package_default = {
1628
- name: "contribute-now",
1629
- version: "0.1.2-staging.c209cc7",
1630
- description: "Git workflow CLI for squash-merge two-branch models. Keeps dev in sync with main after squash merges.",
1631
- type: "module",
1632
- bin: {
1633
- contrib: "dist/index.js",
1634
- contribute: "dist/index.js"
1635
- },
1636
- files: [
1637
- "dist"
1638
- ],
1639
- scripts: {
1640
- build: "bun build src/index.ts --outfile dist/index.js --target node --packages external",
1641
- dev: "bun src/index.ts",
1642
- test: "bun test",
1643
- lint: "biome check .",
1644
- "lint:fix": "biome check --write .",
1645
- format: "biome format --write .",
1646
- prepare: "husky || true",
1647
- "www:dev": "bun run --cwd www dev",
1648
- "www:build": "bun run --cwd www build",
1649
- "www:preview": "bun run --cwd www preview"
1650
- },
1651
- engines: {
1652
- node: ">=18",
1653
- bun: ">=1.0"
1654
- },
1655
- keywords: [
1656
- "git",
1657
- "workflow",
1658
- "squash-merge",
1659
- "sync",
1660
- "cli",
1661
- "contribute",
1662
- "fork",
1663
- "dev-branch",
1664
- "clean-commit"
1665
- ],
1666
- author: "Waren Gonzaga",
1667
- license: "GPL-3.0",
1668
- repository: {
1669
- type: "git",
1670
- url: "git+https://github.com/warengonzaga/contribute-now.git"
1671
- },
1672
- dependencies: {
1673
- "@github/copilot-sdk": "^0.1.25",
1674
- "@wgtechlabs/log-engine": "^2.3.1",
1675
- citty: "^0.1.6",
1676
- figlet: "^1.10.0",
1677
- picocolors: "^1.1.1"
1678
- },
1679
- devDependencies: {
1680
- "@biomejs/biome": "^2.4.4",
1681
- "@types/bun": "latest",
1682
- "@types/figlet": "^1.7.0",
1683
- husky: "^9.1.7",
1684
- typescript: "^5.7.0"
1685
- }
1686
- };
1687
-
1688
- // src/ui/banner.ts
3107
+ import pc15 from "picocolors";
1689
3108
  var LOGO;
1690
3109
  try {
1691
- LOGO = figlet.textSync("contrib", { font: "ANSI Shadow" });
3110
+ LOGO = figlet.textSync(`Contribute
3111
+ Now`, { font: "ANSI Shadow" });
1692
3112
  } catch {
1693
- LOGO = "contribute-now";
3113
+ LOGO = "Contribute Now";
1694
3114
  }
1695
3115
  function getVersion() {
1696
3116
  return package_default.version ?? "unknown";
@@ -1698,16 +3118,15 @@ function getVersion() {
1698
3118
  function getAuthor() {
1699
3119
  return typeof package_default.author === "string" ? package_default.author : "unknown";
1700
3120
  }
1701
- function showBanner(minimal = false) {
1702
- console.log(pc13.cyan(`
3121
+ function showBanner(showLinks = false) {
3122
+ console.log(pc15.cyan(`
1703
3123
  ${LOGO}`));
1704
- console.log(` ${pc13.dim(`v${getVersion()}`)} ${pc13.dim("—")} ${pc13.dim(`Built by ${getAuthor()}`)}`);
1705
- if (!minimal) {
1706
- console.log(` ${pc13.dim(package_default.description)}`);
3124
+ console.log(` ${pc15.dim(`v${getVersion()}`)} ${pc15.dim("—")} ${pc15.dim(`Built by ${getAuthor()}`)}`);
3125
+ if (showLinks) {
1707
3126
  console.log();
1708
- console.log(` ${pc13.yellow("Star")} ${pc13.cyan("https://github.com/warengonzaga/contribute-now")}`);
1709
- console.log(` ${pc13.green("Contribute")} ${pc13.cyan("https://github.com/warengonzaga/contribute-now/blob/main/CONTRIBUTING.md")}`);
1710
- console.log(` ${pc13.magenta("Sponsor")} ${pc13.cyan("https://warengonzaga.com/sponsor")}`);
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")}`);
1711
3130
  }
1712
3131
  console.log();
1713
3132
  }
@@ -1715,7 +3134,7 @@ ${LOGO}`));
1715
3134
  // src/index.ts
1716
3135
  var isHelp = process.argv.includes("--help") || process.argv.includes("-h");
1717
3136
  showBanner(isHelp);
1718
- var main = defineCommand11({
3137
+ var main = defineCommand12({
1719
3138
  meta: {
1720
3139
  name: "contrib",
1721
3140
  version: getVersion(),
@@ -1738,7 +3157,8 @@ var main = defineCommand11({
1738
3157
  clean: clean_default,
1739
3158
  status: status_default,
1740
3159
  hook: hook_default,
1741
- validate: validate_default
3160
+ validate: validate_default,
3161
+ doctor: doctor_default
1742
3162
  },
1743
3163
  run({ args }) {
1744
3164
  if (args.version) {
@@ -1746,4 +3166,6 @@ var main = defineCommand11({
1746
3166
  }
1747
3167
  }
1748
3168
  });
1749
- runMain(main);
3169
+ runMain(main).then(() => {
3170
+ process.exit(0);
3171
+ });