ghostcommit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,2580 @@
1
+ // src/cli.ts
2
+ import { readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
3
+ import { dirname, join as join5 } from "path";
4
+ import { fileURLToPath } from "url";
5
+ import chalk7 from "chalk";
6
+ import { Command } from "commander";
7
+
8
+ // src/commands/amend.ts
9
+ import chalk2 from "chalk";
10
+
11
+ // src/providers/anthropic.ts
12
+ var DEFAULT_MODEL = "claude-haiku-4-5-20251001";
13
+ var AnthropicProvider = class {
14
+ name = "anthropic";
15
+ model;
16
+ apiKey;
17
+ constructor(model) {
18
+ this.model = model || DEFAULT_MODEL;
19
+ this.apiKey = process.env.ANTHROPIC_API_KEY || "";
20
+ }
21
+ async isAvailable() {
22
+ return !!this.apiKey;
23
+ }
24
+ getTokenBudget() {
25
+ return 18e4;
26
+ }
27
+ async generate(prompt, systemPrompt) {
28
+ const { default: Anthropic } = await import("@anthropic-ai/sdk");
29
+ const client = new Anthropic({ apiKey: this.apiKey });
30
+ const response = await client.messages.create({
31
+ model: this.model,
32
+ max_tokens: 1024,
33
+ system: systemPrompt,
34
+ messages: [{ role: "user", content: prompt }]
35
+ });
36
+ const textBlock = response.content.find((block) => block.type === "text");
37
+ return textBlock && "text" in textBlock ? textBlock.text.trim() : "";
38
+ }
39
+ async *generateStream(prompt, systemPrompt) {
40
+ const { default: Anthropic } = await import("@anthropic-ai/sdk");
41
+ const client = new Anthropic({ apiKey: this.apiKey });
42
+ const stream = client.messages.stream({
43
+ model: this.model,
44
+ max_tokens: 1024,
45
+ system: systemPrompt,
46
+ messages: [{ role: "user", content: prompt }]
47
+ });
48
+ for await (const event of stream) {
49
+ if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
50
+ yield event.delta.text;
51
+ }
52
+ }
53
+ }
54
+ };
55
+
56
+ // src/providers/gemini.ts
57
+ var DEFAULT_MODEL2 = "gemini-2.0-flash";
58
+ var GeminiProvider = class {
59
+ name = "gemini";
60
+ model;
61
+ apiKey;
62
+ constructor(model) {
63
+ this.model = model || DEFAULT_MODEL2;
64
+ this.apiKey = process.env.GEMINI_API_KEY || "";
65
+ }
66
+ async isAvailable() {
67
+ return !!this.apiKey;
68
+ }
69
+ getTokenBudget() {
70
+ return 9e5;
71
+ }
72
+ async generate(prompt, systemPrompt) {
73
+ const { GoogleGenerativeAI } = await import("@google/generative-ai");
74
+ const client = new GoogleGenerativeAI(this.apiKey);
75
+ const model = client.getGenerativeModel({
76
+ model: this.model,
77
+ systemInstruction: systemPrompt
78
+ });
79
+ const result = await model.generateContent(prompt);
80
+ return result.response.text().trim();
81
+ }
82
+ async *generateStream(prompt, systemPrompt) {
83
+ const { GoogleGenerativeAI } = await import("@google/generative-ai");
84
+ const client = new GoogleGenerativeAI(this.apiKey);
85
+ const model = client.getGenerativeModel({
86
+ model: this.model,
87
+ systemInstruction: systemPrompt
88
+ });
89
+ const result = await model.generateContentStream(prompt);
90
+ for await (const chunk of result.stream) {
91
+ const text = chunk.text();
92
+ if (text) yield text;
93
+ }
94
+ }
95
+ };
96
+
97
+ // src/providers/groq.ts
98
+ var DEFAULT_MODEL3 = "llama-3.3-70b-versatile";
99
+ var GROQ_TOKEN_BUDGETS = {
100
+ "llama-3.3-70b-versatile": 1e4,
101
+ // 12k TPM, leave headroom
102
+ "llama-3.1-8b-instant": 5e3
103
+ // 6k TPM limit
104
+ };
105
+ var GROQ_DEFAULT_BUDGET = 6e3;
106
+ var GroqProvider = class {
107
+ name = "groq";
108
+ model;
109
+ apiKey;
110
+ constructor(model) {
111
+ this.model = model || DEFAULT_MODEL3;
112
+ this.apiKey = process.env.GROQ_API_KEY || "";
113
+ }
114
+ async isAvailable() {
115
+ return !!this.apiKey;
116
+ }
117
+ getTokenBudget() {
118
+ return GROQ_TOKEN_BUDGETS[this.model] ?? GROQ_DEFAULT_BUDGET;
119
+ }
120
+ async generate(prompt, systemPrompt) {
121
+ const { default: Groq } = await import("groq-sdk");
122
+ const client = new Groq({ apiKey: this.apiKey });
123
+ const response = await client.chat.completions.create({
124
+ model: this.model,
125
+ messages: [
126
+ { role: "system", content: systemPrompt },
127
+ { role: "user", content: prompt }
128
+ ]
129
+ });
130
+ return response.choices[0]?.message?.content?.trim() || "";
131
+ }
132
+ async *generateStream(prompt, systemPrompt) {
133
+ const { default: Groq } = await import("groq-sdk");
134
+ const client = new Groq({ apiKey: this.apiKey });
135
+ const stream = await client.chat.completions.create({
136
+ model: this.model,
137
+ messages: [
138
+ { role: "system", content: systemPrompt },
139
+ { role: "user", content: prompt }
140
+ ],
141
+ stream: true
142
+ });
143
+ for await (const chunk of stream) {
144
+ const content = chunk.choices[0]?.delta?.content;
145
+ if (content) yield content;
146
+ }
147
+ }
148
+ };
149
+
150
+ // src/providers/ollama.ts
151
+ var DEFAULT_MODEL4 = "qwen2.5-coder:0.5b";
152
+ var DEFAULT_HOST = "http://localhost:11434";
153
+ var OllamaProvider = class {
154
+ name = "ollama";
155
+ model;
156
+ host;
157
+ ensuredModel = false;
158
+ constructor(model, host) {
159
+ this.model = model || DEFAULT_MODEL4;
160
+ this.host = host || process.env.OLLAMA_HOST || DEFAULT_HOST;
161
+ }
162
+ async isAvailable() {
163
+ try {
164
+ const controller = new AbortController();
165
+ const timeout = setTimeout(() => controller.abort(), 2e3);
166
+ const response = await fetch(`${this.host}/api/tags`, {
167
+ signal: controller.signal
168
+ });
169
+ clearTimeout(timeout);
170
+ return response.ok;
171
+ } catch {
172
+ return false;
173
+ }
174
+ }
175
+ getTokenBudget() {
176
+ return 4e3;
177
+ }
178
+ /** Check if a specific model is already pulled locally. */
179
+ async hasModel(model) {
180
+ try {
181
+ const response = await fetch(`${this.host}/api/tags`);
182
+ if (!response.ok) return false;
183
+ const data = await response.json();
184
+ const target = model || this.model;
185
+ return data.models.some(
186
+ (m) => m.name === target || m.name === `${target}:latest`
187
+ );
188
+ } catch {
189
+ return false;
190
+ }
191
+ }
192
+ /** Auto-pull the model if not present. Shows progress to the user. */
193
+ async ensureModel() {
194
+ if (this.ensuredModel) return;
195
+ this.ensuredModel = true;
196
+ if (await this.hasModel()) return;
197
+ process.stderr.write(
198
+ `Downloading model "${this.model}" (first run only)...
199
+ `
200
+ );
201
+ const response = await fetch(`${this.host}/api/pull`, {
202
+ method: "POST",
203
+ headers: { "Content-Type": "application/json" },
204
+ body: JSON.stringify({ name: this.model, stream: true })
205
+ });
206
+ if (!response.ok) {
207
+ const text = await response.text();
208
+ throw new Error(
209
+ `Failed to pull model "${this.model}": ${text}
210
+ Run manually: ollama pull ${this.model}`
211
+ );
212
+ }
213
+ if (!response.body) {
214
+ throw new Error("No response body from Ollama pull");
215
+ }
216
+ const reader = response.body.getReader();
217
+ const decoder = new TextDecoder();
218
+ let buffer = "";
219
+ let lastPercent = -1;
220
+ try {
221
+ while (true) {
222
+ const { done, value } = await reader.read();
223
+ if (done) break;
224
+ buffer += decoder.decode(value, { stream: true });
225
+ const lines = buffer.split("\n");
226
+ buffer = lines.pop() || "";
227
+ for (const line of lines) {
228
+ if (!line.trim()) continue;
229
+ try {
230
+ const data = JSON.parse(line);
231
+ if (data.total && data.completed) {
232
+ const percent = Math.round(data.completed / data.total * 100);
233
+ if (percent !== lastPercent && percent % 10 === 0) {
234
+ process.stderr.write(` ${percent}%
235
+ `);
236
+ lastPercent = percent;
237
+ }
238
+ }
239
+ } catch {
240
+ }
241
+ }
242
+ }
243
+ } finally {
244
+ reader.releaseLock();
245
+ }
246
+ process.stderr.write(`Model "${this.model}" ready.
247
+ `);
248
+ }
249
+ async generate(prompt, systemPrompt) {
250
+ await this.ensureModel();
251
+ const messages = [
252
+ { role: "system", content: systemPrompt },
253
+ { role: "user", content: prompt }
254
+ ];
255
+ const controller = new AbortController();
256
+ const timeout = setTimeout(() => controller.abort(), 9e4);
257
+ const response = await fetch(`${this.host}/api/chat`, {
258
+ method: "POST",
259
+ headers: { "Content-Type": "application/json" },
260
+ body: JSON.stringify({
261
+ model: this.model,
262
+ messages,
263
+ stream: false
264
+ }),
265
+ signal: controller.signal
266
+ });
267
+ clearTimeout(timeout);
268
+ if (!response.ok) {
269
+ const text = await response.text();
270
+ throw new Error(`Ollama error (${response.status}): ${text}`);
271
+ }
272
+ const data = await response.json();
273
+ return data.message.content.trim();
274
+ }
275
+ async *generateStream(prompt, systemPrompt) {
276
+ await this.ensureModel();
277
+ const messages = [
278
+ { role: "system", content: systemPrompt },
279
+ { role: "user", content: prompt }
280
+ ];
281
+ const controller = new AbortController();
282
+ const timeout = setTimeout(() => controller.abort(), 9e4);
283
+ const response = await fetch(`${this.host}/api/chat`, {
284
+ method: "POST",
285
+ headers: { "Content-Type": "application/json" },
286
+ body: JSON.stringify({
287
+ model: this.model,
288
+ messages,
289
+ stream: true
290
+ }),
291
+ signal: controller.signal
292
+ });
293
+ clearTimeout(timeout);
294
+ if (!response.ok) {
295
+ const text = await response.text();
296
+ throw new Error(`Ollama error (${response.status}): ${text}`);
297
+ }
298
+ if (!response.body) {
299
+ throw new Error("No response body from Ollama");
300
+ }
301
+ const reader = response.body.getReader();
302
+ const decoder = new TextDecoder();
303
+ let buffer = "";
304
+ try {
305
+ while (true) {
306
+ const { done, value } = await reader.read();
307
+ if (done) break;
308
+ buffer += decoder.decode(value, { stream: true });
309
+ const lines = buffer.split("\n");
310
+ buffer = lines.pop() || "";
311
+ for (const line of lines) {
312
+ if (!line.trim()) continue;
313
+ try {
314
+ const data = JSON.parse(line);
315
+ if (data.message?.content) {
316
+ yield data.message.content;
317
+ }
318
+ } catch {
319
+ }
320
+ }
321
+ }
322
+ if (buffer.trim()) {
323
+ try {
324
+ const data = JSON.parse(buffer);
325
+ if (data.message?.content) {
326
+ yield data.message.content;
327
+ }
328
+ } catch {
329
+ }
330
+ }
331
+ } finally {
332
+ reader.releaseLock();
333
+ }
334
+ }
335
+ };
336
+
337
+ // src/providers/openai.ts
338
+ var DEFAULT_MODEL5 = "gpt-4o-mini";
339
+ var OpenAIProvider = class {
340
+ name = "openai";
341
+ model;
342
+ apiKey;
343
+ constructor(model) {
344
+ this.model = model || DEFAULT_MODEL5;
345
+ this.apiKey = process.env.OPENAI_API_KEY || "";
346
+ }
347
+ async isAvailable() {
348
+ return !!this.apiKey;
349
+ }
350
+ getTokenBudget() {
351
+ return 12e4;
352
+ }
353
+ async generate(prompt, systemPrompt) {
354
+ const { default: OpenAI } = await import("openai");
355
+ const client = new OpenAI({ apiKey: this.apiKey });
356
+ const response = await client.chat.completions.create({
357
+ model: this.model,
358
+ messages: [
359
+ { role: "system", content: systemPrompt },
360
+ { role: "user", content: prompt }
361
+ ]
362
+ });
363
+ return response.choices[0]?.message?.content?.trim() || "";
364
+ }
365
+ async *generateStream(prompt, systemPrompt) {
366
+ const { default: OpenAI } = await import("openai");
367
+ const client = new OpenAI({ apiKey: this.apiKey });
368
+ const stream = await client.chat.completions.create({
369
+ model: this.model,
370
+ messages: [
371
+ { role: "system", content: systemPrompt },
372
+ { role: "user", content: prompt }
373
+ ],
374
+ stream: true
375
+ });
376
+ for await (const chunk of stream) {
377
+ const content = chunk.choices[0]?.delta?.content;
378
+ if (content) yield content;
379
+ }
380
+ }
381
+ };
382
+
383
+ // src/ai.ts
384
+ function createProvider(providerName, model) {
385
+ switch (providerName) {
386
+ case "ollama":
387
+ return new OllamaProvider(model);
388
+ case "groq":
389
+ return new GroqProvider(model);
390
+ case "openai":
391
+ return new OpenAIProvider(model);
392
+ case "anthropic":
393
+ return new AnthropicProvider(model);
394
+ case "gemini":
395
+ return new GeminiProvider(model);
396
+ default:
397
+ throw new Error(
398
+ `Unknown provider "${providerName}". Available: ollama, groq, openai, anthropic, gemini`
399
+ );
400
+ }
401
+ }
402
+ async function resolveProvider(configuredProvider, model) {
403
+ if (configuredProvider) {
404
+ const provider = createProvider(configuredProvider, model);
405
+ const available = await provider.isAvailable();
406
+ if (!available) {
407
+ throw new Error(
408
+ `Provider "${configuredProvider}" is not available. ` + getProviderHelp(configuredProvider)
409
+ );
410
+ }
411
+ return provider;
412
+ }
413
+ const groq = new GroqProvider(model);
414
+ if (await groq.isAvailable()) {
415
+ return groq;
416
+ }
417
+ const ollama = new OllamaProvider(model);
418
+ if (await ollama.isAvailable()) {
419
+ return ollama;
420
+ }
421
+ throw new Error(
422
+ "No AI provider available.\n\nghostcommit needs an AI provider to generate commit messages.\n\nOptions (in order of recommendation):\n 1. Install Ollama (free, local, private): https://ollama.ai\n The model downloads automatically on first run.\n\n 2. Set GROQ_API_KEY for free cloud inference:\n https://console.groq.com/keys\n\n 3. Set GEMINI_API_KEY for free Google Gemini:\n https://aistudio.google.com/apikey\n\n 4. Set OPENAI_API_KEY or ANTHROPIC_API_KEY for paid providers"
423
+ );
424
+ }
425
+ function getProviderHelp(provider) {
426
+ switch (provider) {
427
+ case "ollama":
428
+ return "Make sure Ollama is running (ollama serve). The model downloads automatically.";
429
+ case "groq":
430
+ return "Set GROQ_API_KEY environment variable. Get a free key at https://console.groq.com/keys";
431
+ case "openai":
432
+ return "Set OPENAI_API_KEY environment variable.";
433
+ case "anthropic":
434
+ return "Set ANTHROPIC_API_KEY environment variable.";
435
+ case "gemini":
436
+ return "Set GEMINI_API_KEY environment variable. Get a free key at https://aistudio.google.com/apikey";
437
+ default:
438
+ return "";
439
+ }
440
+ }
441
+ var TOKEN_LIMIT_PATTERNS = [
442
+ /413/,
443
+ /rate.?limit/i,
444
+ /context.?length.?exceeded/i,
445
+ /too.?large/i,
446
+ /too.?many.?tokens/i,
447
+ /token.?limit/i,
448
+ /maximum.?context/i,
449
+ /request.?too.?large/i
450
+ ];
451
+ function isTokenLimitError(error) {
452
+ const message = error instanceof Error ? error.message : String(error ?? "");
453
+ return TOKEN_LIMIT_PATTERNS.some((pattern) => pattern.test(message));
454
+ }
455
+ async function generateCommitMessage(provider, userPrompt, systemPrompt, stream = true) {
456
+ if (stream && process.stdout.isTTY) {
457
+ let result = "";
458
+ for await (const chunk of provider.generateStream(
459
+ userPrompt,
460
+ systemPrompt
461
+ )) {
462
+ result += chunk;
463
+ process.stdout.write(chunk);
464
+ }
465
+ process.stdout.write("\n");
466
+ return result.trim();
467
+ }
468
+ return provider.generate(userPrompt, systemPrompt);
469
+ }
470
+
471
+ // src/config.ts
472
+ import { readFile } from "fs/promises";
473
+ import { homedir } from "os";
474
+ import { join } from "path";
475
+ import { parse as parseYaml } from "yaml";
476
+
477
+ // src/changelog/categorizer.ts
478
+ var ALL_CATEGORIES = [
479
+ "Features",
480
+ "Bug Fixes",
481
+ "Performance",
482
+ "Breaking Changes",
483
+ "Documentation",
484
+ "Refactoring",
485
+ "Tests",
486
+ "CI/CD",
487
+ "Chore"
488
+ ];
489
+ var TYPE_TO_CATEGORY = {
490
+ feat: "Features",
491
+ fix: "Bug Fixes",
492
+ perf: "Performance",
493
+ docs: "Documentation",
494
+ refactor: "Refactoring",
495
+ test: "Tests",
496
+ build: "CI/CD",
497
+ ci: "CI/CD",
498
+ chore: "Chore",
499
+ style: "Chore",
500
+ revert: "Chore"
501
+ };
502
+ var CATEGORIZER_SYSTEM_PROMPT = `You are a changelog categorizer. Given a commit message, categorize it into exactly ONE of these categories:
503
+ - Features (new functionality)
504
+ - Bug Fixes (bug fixes)
505
+ - Performance (performance improvements)
506
+ - Breaking Changes (backward-incompatible changes)
507
+ - Documentation (docs changes)
508
+ - Refactoring (code restructuring without behavior change)
509
+ - Tests (test additions or changes)
510
+ - CI/CD (CI/CD and build changes)
511
+ - Chore (maintenance, deps, etc.)
512
+
513
+ Respond with ONLY a JSON object (no markdown, no code fences):
514
+ {"category": "...", "summary": "..."}
515
+
516
+ The summary should be a concise, human-readable description of the change (imperative mood, no period).`;
517
+ function categorizeByType(commit) {
518
+ if (!commit.type) return null;
519
+ if (commit.breaking) {
520
+ return {
521
+ commit,
522
+ category: "Breaking Changes",
523
+ summary: commit.description
524
+ };
525
+ }
526
+ const category = TYPE_TO_CATEGORY[commit.type];
527
+ return category ? { commit, category, summary: commit.description } : null;
528
+ }
529
+ async function categorizeWithAI(commit, provider) {
530
+ try {
531
+ const response = await provider.generate(
532
+ `Commit message: "${commit.message}"`,
533
+ CATEGORIZER_SYSTEM_PROMPT
534
+ );
535
+ const cleaned = response.replace(/```json?\n?/g, "").replace(/```/g, "").trim();
536
+ const parsed = JSON.parse(cleaned);
537
+ const validCategory = ALL_CATEGORIES.find(
538
+ (c) => c.toLowerCase() === parsed.category.toLowerCase()
539
+ );
540
+ return {
541
+ commit,
542
+ category: validCategory ?? "Chore",
543
+ summary: parsed.summary || commit.description
544
+ };
545
+ } catch {
546
+ return { commit, category: "Chore", summary: commit.description };
547
+ }
548
+ }
549
+ function categorizeFallback(commit) {
550
+ return {
551
+ commit,
552
+ category: commit.breaking ? "Breaking Changes" : "Chore",
553
+ summary: commit.description
554
+ };
555
+ }
556
+ function matchesAnyPattern(message, patterns) {
557
+ return patterns.some((pattern) => new RegExp(pattern).test(message));
558
+ }
559
+ async function categorizeCommits(commits, options = {}) {
560
+ const { provider, excludePatterns = [] } = options;
561
+ const filtered = excludePatterns.length > 0 ? commits.filter((c) => !matchesAnyPattern(c.message, excludePatterns)) : commits;
562
+ const regexResults = filtered.map(categorizeByType);
563
+ const needsAI = filtered.filter((_, i) => regexResults[i] === null);
564
+ const aiResults = /* @__PURE__ */ new Map();
565
+ for (const commit of needsAI) {
566
+ aiResults.set(
567
+ commit,
568
+ provider ? await categorizeWithAI(commit, provider) : categorizeFallback(commit)
569
+ );
570
+ }
571
+ return filtered.map(
572
+ (commit, i) => regexResults[i] ?? aiResults.get(commit) ?? categorizeFallback(commit)
573
+ );
574
+ }
575
+ function groupByCategory(categorized) {
576
+ return categorized.reduce((grouped, item) => {
577
+ const existing = grouped.get(item.category) ?? [];
578
+ grouped.set(item.category, [...existing, item]);
579
+ return grouped;
580
+ }, /* @__PURE__ */ new Map());
581
+ }
582
+
583
+ // src/config.ts
584
+ function createDefaults() {
585
+ return {
586
+ provider: void 0,
587
+ model: void 0,
588
+ language: "en",
589
+ learnStyle: true,
590
+ learnStyleCommits: 50,
591
+ ignorePaths: [],
592
+ branchPrefix: true,
593
+ branchPattern: "[A-Z]+-\\d+",
594
+ tokenBudget: void 0,
595
+ changelog: {
596
+ format: "markdown",
597
+ output: "CHANGELOG.md",
598
+ categories: [...ALL_CATEGORIES],
599
+ exclude: []
600
+ },
601
+ release: {
602
+ draft: true
603
+ }
604
+ };
605
+ }
606
+ async function readYamlFile(filePath) {
607
+ try {
608
+ const content = await readFile(filePath, "utf-8");
609
+ const parsed = parseYaml(content);
610
+ if (parsed && typeof parsed === "object") {
611
+ return parsed;
612
+ }
613
+ return null;
614
+ } catch {
615
+ return null;
616
+ }
617
+ }
618
+ async function loadConfig(projectRoot, cliFlags) {
619
+ let config = createDefaults();
620
+ const globalConfig = await readYamlFile(join(homedir(), ".ghostcommit.yml"));
621
+ if (globalConfig) {
622
+ config = mergeConfig(config, globalConfig);
623
+ }
624
+ if (projectRoot) {
625
+ const projectConfig = await readYamlFile(
626
+ join(projectRoot, ".ghostcommit.yml")
627
+ );
628
+ if (projectConfig) {
629
+ config = mergeConfig(config, projectConfig);
630
+ }
631
+ }
632
+ if (cliFlags) {
633
+ config = applyCLIFlags(config, cliFlags);
634
+ }
635
+ return config;
636
+ }
637
+ function applyCLIFlags(base, flags) {
638
+ return {
639
+ ...base,
640
+ ...flags.provider !== void 0 && { provider: flags.provider },
641
+ ...flags.model !== void 0 && { model: flags.model },
642
+ ...flags.noStyle && { learnStyle: false }
643
+ };
644
+ }
645
+ function mergeChangelog(base, overrides) {
646
+ return {
647
+ format: overrides.format ?? base.format,
648
+ output: overrides.output ?? base.output,
649
+ categories: overrides.categories ?? base.categories,
650
+ exclude: overrides.exclude !== void 0 ? [...base.exclude, ...overrides.exclude] : [...base.exclude]
651
+ };
652
+ }
653
+ function mergeRelease(base, overrides) {
654
+ return {
655
+ draft: overrides.draft ?? base.draft
656
+ };
657
+ }
658
+ function mergeConfig(base, overrides) {
659
+ return {
660
+ provider: overrides.provider ?? base.provider,
661
+ model: overrides.model ?? base.model,
662
+ language: overrides.language ?? base.language,
663
+ learnStyle: overrides.learnStyle ?? base.learnStyle,
664
+ learnStyleCommits: overrides.learnStyleCommits ?? base.learnStyleCommits,
665
+ ignorePaths: overrides.ignorePaths !== void 0 ? [...base.ignorePaths, ...overrides.ignorePaths] : [...base.ignorePaths],
666
+ branchPrefix: overrides.branchPrefix ?? base.branchPrefix,
667
+ branchPattern: overrides.branchPattern ?? base.branchPattern,
668
+ tokenBudget: overrides.tokenBudget ?? base.tokenBudget,
669
+ changelog: overrides.changelog ? mergeChangelog(
670
+ base.changelog,
671
+ overrides.changelog
672
+ ) : { ...base.changelog, exclude: [...base.changelog.exclude] },
673
+ release: overrides.release ? mergeRelease(base.release, overrides.release) : { ...base.release }
674
+ };
675
+ }
676
+
677
+ // src/utils.ts
678
+ import { execFile } from "child_process";
679
+ import { promisify } from "util";
680
+ var execFileAsync = promisify(execFile);
681
+ async function exec(command, args, cwd) {
682
+ try {
683
+ const result = await execFileAsync(command, args, {
684
+ cwd,
685
+ maxBuffer: 10 * 1024 * 1024
686
+ // 10MB for large diffs
687
+ });
688
+ return { stdout: result.stdout, stderr: result.stderr };
689
+ } catch (error) {
690
+ const err = error;
691
+ throw new Error(err.stderr?.trim() || err.message);
692
+ }
693
+ }
694
+ function estimateTokens(text) {
695
+ return Math.ceil(text.length / 4);
696
+ }
697
+ function truncateLines(text, maxLines) {
698
+ const lines = text.split("\n");
699
+ if (lines.length <= maxLines) return text;
700
+ return lines.slice(0, maxLines).join("\n") + `
701
+ ... (truncated ${lines.length - maxLines} more lines)`;
702
+ }
703
+ function extractTicketFromBranch(branchName, pattern) {
704
+ const regex = new RegExp(pattern || "[A-Z]+-\\d+");
705
+ const match = branchName.match(regex);
706
+ return match ? match[0] : null;
707
+ }
708
+
709
+ // src/diff-processor.ts
710
+ var DEFAULT_IGNORE_PATTERNS = [
711
+ "package-lock.json",
712
+ "yarn.lock",
713
+ "pnpm-lock.yaml",
714
+ "bun.lockb",
715
+ "Gemfile.lock",
716
+ "Cargo.lock",
717
+ "poetry.lock",
718
+ "composer.lock",
719
+ "go.sum"
720
+ ];
721
+ var DEFAULT_IGNORE_GLOBS = [
722
+ "*.generated.*",
723
+ "*.min.js",
724
+ "*.min.css",
725
+ "*.map"
726
+ ];
727
+ var DEFAULT_IGNORE_DIRS = [
728
+ "dist/",
729
+ "build/",
730
+ ".next/",
731
+ "__pycache__/"
732
+ ];
733
+ var SOURCE_EXTENSIONS = [
734
+ ".ts",
735
+ ".tsx",
736
+ ".js",
737
+ ".jsx",
738
+ ".py",
739
+ ".java",
740
+ ".go",
741
+ ".rs",
742
+ ".rb",
743
+ ".c",
744
+ ".cpp",
745
+ ".h",
746
+ ".cs",
747
+ ".swift",
748
+ ".kt",
749
+ ".scala",
750
+ ".vue",
751
+ ".svelte"
752
+ ];
753
+ var DEFAULT_TOKEN_LIMIT = 2e3;
754
+ var DEFAULT_MAX_LINES_PER_FILE = 60;
755
+ function getMaxLinesPerFile(tokenBudget) {
756
+ if (tokenBudget > 1e4) return 200;
757
+ if (tokenBudget > 4e3) return 100;
758
+ return DEFAULT_MAX_LINES_PER_FILE;
759
+ }
760
+ function shouldIgnoreFile(filePath, extraIgnorePaths = []) {
761
+ const fileName = filePath.split("/").pop() || "";
762
+ if (DEFAULT_IGNORE_PATTERNS.includes(fileName)) return true;
763
+ for (const dir of DEFAULT_IGNORE_DIRS) {
764
+ if (filePath.startsWith(dir) || filePath.includes(`/${dir}`)) return true;
765
+ }
766
+ for (const pattern of DEFAULT_IGNORE_GLOBS) {
767
+ if (matchSimpleGlob(fileName, pattern)) return true;
768
+ }
769
+ for (const pattern of extraIgnorePaths) {
770
+ if (pattern.endsWith("/")) {
771
+ if (filePath.startsWith(pattern) || filePath.includes(`/${pattern}`))
772
+ return true;
773
+ } else if (pattern.includes("*")) {
774
+ if (matchSimpleGlob(fileName, pattern)) return true;
775
+ } else if (filePath === pattern || fileName === pattern) {
776
+ return true;
777
+ }
778
+ }
779
+ return false;
780
+ }
781
+ var globCache = /* @__PURE__ */ new Map();
782
+ function matchSimpleGlob(fileName, pattern) {
783
+ let regex = globCache.get(pattern);
784
+ if (!regex) {
785
+ regex = new RegExp(
786
+ `^${pattern.replace(/\./g, "\\.").replace(/\*/g, ".*").replace(/\?/g, ".")}$`
787
+ );
788
+ globCache.set(pattern, regex);
789
+ }
790
+ return regex.test(fileName);
791
+ }
792
+ function isSourceFile(filePath) {
793
+ return SOURCE_EXTENSIONS.some((ext) => filePath.endsWith(ext));
794
+ }
795
+ function countDiffChanges(diff) {
796
+ let additions = 0;
797
+ let deletions = 0;
798
+ for (const line of diff.split("\n")) {
799
+ if (line.startsWith("+") && !line.startsWith("+++")) additions++;
800
+ else if (line.startsWith("-") && !line.startsWith("---")) deletions++;
801
+ }
802
+ return { additions, deletions };
803
+ }
804
+ function parseDiffIntoChunks(rawDiff, stagedFiles) {
805
+ const chunks = [];
806
+ const fileDiffs = rawDiff.split(/^diff --git /m).filter(Boolean);
807
+ for (const fileDiff of fileDiffs) {
808
+ const headerMatch = fileDiff.match(/^a\/(.+?) b\/(.+)/m);
809
+ if (!headerMatch) continue;
810
+ const oldPath = headerMatch[1];
811
+ const newPath = headerMatch[2];
812
+ const stagedFile = stagedFiles.find(
813
+ (f) => f.path === newPath || f.oldPath === oldPath
814
+ );
815
+ const { additions, deletions } = countDiffChanges(fileDiff);
816
+ chunks.push({
817
+ path: newPath,
818
+ status: stagedFile?.status || "M",
819
+ oldPath: stagedFile?.oldPath,
820
+ diff: `diff --git ${fileDiff}`,
821
+ additions,
822
+ deletions
823
+ });
824
+ }
825
+ return chunks;
826
+ }
827
+ function processDiff(rawDiff, stagedFiles, extraIgnorePaths = [], tokenBudget = DEFAULT_TOKEN_LIMIT) {
828
+ if (!rawDiff.trim()) {
829
+ return {
830
+ chunks: [],
831
+ summary: "No changes",
832
+ totalAdditions: 0,
833
+ totalDeletions: 0,
834
+ wasFiltered: false,
835
+ wasTruncated: false
836
+ };
837
+ }
838
+ let chunks = parseDiffIntoChunks(rawDiff, stagedFiles);
839
+ const totalChunksBefore = chunks.length;
840
+ chunks = chunks.filter(
841
+ (chunk) => !shouldIgnoreFile(chunk.path, extraIgnorePaths)
842
+ );
843
+ const wasFiltered = chunks.length < totalChunksBefore;
844
+ const totalAdditions = chunks.reduce((sum, c) => sum + c.additions, 0);
845
+ const totalDeletions = chunks.reduce((sum, c) => sum + c.deletions, 0);
846
+ const fullDiff = chunks.map((c) => c.diff).join("\n");
847
+ const totalTokens = estimateTokens(fullDiff);
848
+ const maxLines = getMaxLinesPerFile(tokenBudget);
849
+ let wasTruncated = false;
850
+ if (totalTokens > tokenBudget) {
851
+ wasTruncated = true;
852
+ chunks.sort((a, b) => {
853
+ const aIsSource = isSourceFile(a.path) ? 0 : 1;
854
+ const bIsSource = isSourceFile(b.path) ? 0 : 1;
855
+ if (aIsSource !== bIsSource) return aIsSource - bIsSource;
856
+ return a.diff.length - b.diff.length;
857
+ });
858
+ for (const chunk of chunks) {
859
+ const lines = chunk.diff.split("\n");
860
+ if (lines.length > maxLines) {
861
+ chunk.diff = truncateLines(chunk.diff, maxLines);
862
+ }
863
+ }
864
+ const truncatedDiff = chunks.map((c) => c.diff).join("\n");
865
+ if (estimateTokens(truncatedDiff) > tokenBudget) {
866
+ const sourceChunks = chunks.filter((c) => isSourceFile(c.path));
867
+ const otherChunks = chunks.filter((c) => !isSourceFile(c.path));
868
+ if (sourceChunks.length > 0) {
869
+ chunks = sourceChunks;
870
+ for (const chunk of chunks) {
871
+ chunk.diff = truncateLines(chunk.diff, maxLines);
872
+ }
873
+ }
874
+ if (otherChunks.length > 0) {
875
+ const otherSummary = otherChunks.map(
876
+ (c) => ` ${c.status === "R" ? `${c.oldPath} \u2192 ` : ""}${c.path} (+${c.additions} -${c.deletions})`
877
+ ).join("\n");
878
+ chunks.push({
879
+ path: "(other files summary)",
880
+ status: "S",
881
+ diff: `Other files:
882
+ ${otherSummary}`,
883
+ additions: otherChunks.reduce((s, c) => s + c.additions, 0),
884
+ deletions: otherChunks.reduce((s, c) => s + c.deletions, 0)
885
+ });
886
+ }
887
+ }
888
+ }
889
+ const summaryParts = chunks.filter((c) => c.status !== "S").map((c) => {
890
+ const prefix = c.status === "A" ? "new: " : c.status === "D" ? "deleted: " : c.status === "R" ? `renamed: ${c.oldPath} \u2192 ` : "";
891
+ return `${prefix}${c.path} (+${c.additions} -${c.deletions})`;
892
+ });
893
+ const summary = `${chunks.filter((c) => c.status !== "S").length} files changed, +${totalAdditions} -${totalDeletions}
894
+ ${summaryParts.join("\n")}`;
895
+ return {
896
+ chunks,
897
+ summary,
898
+ totalAdditions,
899
+ totalDeletions,
900
+ wasFiltered,
901
+ wasTruncated
902
+ };
903
+ }
904
+ function isInitialCommit(chunks) {
905
+ const realChunks = chunks.filter((c) => c.status !== "S");
906
+ return realChunks.length > 5 && realChunks.every((c) => c.status === "A");
907
+ }
908
+ function formatDiffForPrompt(processed) {
909
+ if (processed.chunks.length === 0) return "No changes staged.";
910
+ const parts = [];
911
+ parts.push(`Files: ${processed.summary}`);
912
+ if (processed.wasFiltered) {
913
+ parts.push("(Some auto-generated/lock files were excluded)");
914
+ }
915
+ if (processed.wasTruncated) {
916
+ parts.push("(Large diff was truncated to fit context window)");
917
+ }
918
+ if (isInitialCommit(processed.chunks)) {
919
+ parts.push("(Initial commit \u2014 full diff omitted, use file list above)");
920
+ return parts.join("\n\n");
921
+ }
922
+ parts.push("---");
923
+ for (const chunk of processed.chunks) {
924
+ parts.push(chunk.diff);
925
+ }
926
+ return parts.join("\n\n");
927
+ }
928
+
929
+ // src/git.ts
930
+ async function isGitRepo() {
931
+ try {
932
+ await exec("git", ["rev-parse", "--is-inside-work-tree"]);
933
+ return true;
934
+ } catch {
935
+ return false;
936
+ }
937
+ }
938
+ async function getStagedDiff(excludePaths = []) {
939
+ const args = ["diff", "--staged"];
940
+ for (const p of excludePaths) {
941
+ args.push(`:(exclude)${p}`);
942
+ }
943
+ const { stdout } = await exec("git", args);
944
+ return stdout;
945
+ }
946
+ async function getStagedFiles() {
947
+ const { stdout } = await exec("git", ["diff", "--staged", "--name-status"]);
948
+ if (!stdout.trim()) return [];
949
+ return stdout.trim().split("\n").map((line) => {
950
+ const parts = line.split(" ");
951
+ const statusCode = parts[0];
952
+ if (statusCode.startsWith("R")) {
953
+ return {
954
+ status: "R",
955
+ path: parts[2],
956
+ oldPath: parts[1]
957
+ };
958
+ }
959
+ return {
960
+ status: statusCode,
961
+ path: parts[1]
962
+ };
963
+ });
964
+ }
965
+ async function getRecentCommits(n = 50) {
966
+ try {
967
+ const { stdout } = await exec("git", [
968
+ "log",
969
+ `-${n}`,
970
+ "--format=%H%x00%s%x00%an%x00%aI%x00%b%x1e"
971
+ ]);
972
+ if (!stdout.trim()) return [];
973
+ return stdout.split("").map((record) => record.trim()).filter(Boolean).map((record) => {
974
+ const [hash, message, author, date, ...bodyParts] = record.split("\0");
975
+ const body = bodyParts.join("\0").trim();
976
+ return { hash, message, author, date, body: body || void 0 };
977
+ });
978
+ } catch {
979
+ return [];
980
+ }
981
+ }
982
+ async function getBranchName() {
983
+ try {
984
+ const { stdout } = await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"]);
985
+ return stdout.trim();
986
+ } catch {
987
+ return "HEAD";
988
+ }
989
+ }
990
+ async function createCommit(message) {
991
+ await exec("git", ["commit", "-m", message]);
992
+ }
993
+ async function getDiffStats() {
994
+ const { stdout } = await exec("git", ["diff", "--staged", "--shortstat"]);
995
+ const text = stdout.trim();
996
+ if (!text) {
997
+ return { filesChanged: 0, insertions: 0, deletions: 0 };
998
+ }
999
+ const filesMatch = text.match(/(\d+) file/);
1000
+ const insertionsMatch = text.match(/(\d+) insertion/);
1001
+ const deletionsMatch = text.match(/(\d+) deletion/);
1002
+ return {
1003
+ filesChanged: filesMatch ? parseInt(filesMatch[1], 10) : 0,
1004
+ insertions: insertionsMatch ? parseInt(insertionsMatch[1], 10) : 0,
1005
+ deletions: deletionsMatch ? parseInt(deletionsMatch[1], 10) : 0
1006
+ };
1007
+ }
1008
+ async function getGitRootDir() {
1009
+ const { stdout } = await exec("git", ["rev-parse", "--show-toplevel"]);
1010
+ return stdout.trim();
1011
+ }
1012
+ async function getCommitsBetween(from, to = "HEAD") {
1013
+ try {
1014
+ const { stdout } = await exec("git", [
1015
+ "log",
1016
+ `${from}..${to}`,
1017
+ "--format=%H%x00%s%x00%an%x00%aI"
1018
+ ]);
1019
+ if (!stdout.trim()) return [];
1020
+ return stdout.trim().split("\n").map((line) => {
1021
+ const [hash, message, author, date] = line.split("\0");
1022
+ return { hash, message, author, date };
1023
+ });
1024
+ } catch {
1025
+ throw new Error(
1026
+ `Could not get commits between "${from}" and "${to}".
1027
+ Make sure both refs exist (tags, branches, or commit SHAs).`
1028
+ );
1029
+ }
1030
+ }
1031
+ async function getTags() {
1032
+ try {
1033
+ const { stdout } = await exec("git", [
1034
+ "tag",
1035
+ "--sort=-creatordate",
1036
+ "--format=%(refname:short)%00%(objectname:short)%00%(creatordate:iso-strict)"
1037
+ ]);
1038
+ if (!stdout.trim()) return [];
1039
+ return stdout.trim().split("\n").map((line) => {
1040
+ const [name, hash, date] = line.split("\0");
1041
+ return { name, hash, date };
1042
+ });
1043
+ } catch {
1044
+ return [];
1045
+ }
1046
+ }
1047
+ async function getLatestTag() {
1048
+ try {
1049
+ const { stdout } = await exec("git", ["describe", "--tags", "--abbrev=0"]);
1050
+ return stdout.trim() || null;
1051
+ } catch {
1052
+ return null;
1053
+ }
1054
+ }
1055
+ async function getGitHooksDir() {
1056
+ const { stdout } = await exec("git", ["rev-parse", "--git-path", "hooks"]);
1057
+ return stdout.trim();
1058
+ }
1059
+ async function getLastCommitMessage() {
1060
+ const { stdout } = await exec("git", ["log", "-1", "--format=%B"]);
1061
+ return stdout.trim();
1062
+ }
1063
+ async function getLastCommitDiff(excludePaths = []) {
1064
+ const args = ["diff", "HEAD~1", "HEAD"];
1065
+ for (const p of excludePaths) {
1066
+ args.push(`:(exclude)${p}`);
1067
+ }
1068
+ const { stdout } = await exec("git", args);
1069
+ return stdout;
1070
+ }
1071
+ async function getLastCommitDiffStats() {
1072
+ const { stdout } = await exec("git", [
1073
+ "diff",
1074
+ "HEAD~1",
1075
+ "HEAD",
1076
+ "--shortstat"
1077
+ ]);
1078
+ const text = stdout.trim();
1079
+ if (!text) {
1080
+ return { filesChanged: 0, insertions: 0, deletions: 0 };
1081
+ }
1082
+ const filesMatch = text.match(/(\d+) file/);
1083
+ const insertionsMatch = text.match(/(\d+) insertion/);
1084
+ const deletionsMatch = text.match(/(\d+) deletion/);
1085
+ return {
1086
+ filesChanged: filesMatch ? parseInt(filesMatch[1], 10) : 0,
1087
+ insertions: insertionsMatch ? parseInt(insertionsMatch[1], 10) : 0,
1088
+ deletions: deletionsMatch ? parseInt(deletionsMatch[1], 10) : 0
1089
+ };
1090
+ }
1091
+ async function getLastCommitFiles() {
1092
+ const { stdout } = await exec("git", [
1093
+ "diff",
1094
+ "HEAD~1",
1095
+ "HEAD",
1096
+ "--name-status"
1097
+ ]);
1098
+ if (!stdout.trim()) return [];
1099
+ return stdout.trim().split("\n").map((line) => {
1100
+ const parts = line.split(" ");
1101
+ const statusCode = parts[0];
1102
+ if (statusCode.startsWith("R")) {
1103
+ return {
1104
+ status: "R",
1105
+ path: parts[2],
1106
+ oldPath: parts[1]
1107
+ };
1108
+ }
1109
+ return {
1110
+ status: statusCode,
1111
+ path: parts[1]
1112
+ };
1113
+ });
1114
+ }
1115
+ async function amendCommit(message) {
1116
+ await exec("git", ["commit", "--amend", "-m", message]);
1117
+ }
1118
+
1119
+ // src/interactive.ts
1120
+ import { spawn } from "child_process";
1121
+ import { readFile as readFile2, unlink, writeFile } from "fs/promises";
1122
+ import { tmpdir } from "os";
1123
+ import { join as join2 } from "path";
1124
+ import chalk from "chalk";
1125
+ function promptSingleKey(options) {
1126
+ return new Promise((resolve) => {
1127
+ const line = chalk.dim("\u2500".repeat(40));
1128
+ const labels = options.map((o) => o.color(o.label)).join(" ");
1129
+ process.stdout.write(`
1130
+ ${line}
1131
+ ${labels}? `);
1132
+ const keyMap = new Map(options.map((o) => [o.key.toLowerCase(), o.value]));
1133
+ const stdin = process.stdin;
1134
+ const wasRaw = stdin.isRaw;
1135
+ if (stdin.isTTY) {
1136
+ stdin.setRawMode(true);
1137
+ }
1138
+ stdin.resume();
1139
+ const onData = (data) => {
1140
+ const key = data.toString().toLowerCase();
1141
+ if (key === "c" || key === "" || key === "\x1B") {
1142
+ cleanup();
1143
+ process.stdout.write("\n");
1144
+ resolve("cancel");
1145
+ return;
1146
+ }
1147
+ const value = keyMap.get(key);
1148
+ if (value !== void 0) {
1149
+ cleanup();
1150
+ process.stdout.write(`${key}
1151
+ `);
1152
+ resolve(value);
1153
+ }
1154
+ };
1155
+ const cleanup = () => {
1156
+ stdin.removeListener("data", onData);
1157
+ if (stdin.isTTY) {
1158
+ stdin.setRawMode(wasRaw ?? false);
1159
+ }
1160
+ stdin.pause();
1161
+ };
1162
+ stdin.on("data", onData);
1163
+ });
1164
+ }
1165
+ async function promptAction() {
1166
+ return promptSingleKey([
1167
+ {
1168
+ key: "a",
1169
+ label: "[A]ccept",
1170
+ color: chalk.green,
1171
+ value: "accept"
1172
+ },
1173
+ { key: "e", label: "[E]dit", color: chalk.blue, value: "edit" },
1174
+ {
1175
+ key: "r",
1176
+ label: "[R]egenerate",
1177
+ color: chalk.yellow,
1178
+ value: "regenerate"
1179
+ },
1180
+ { key: "c", label: "[C]ancel", color: chalk.red, value: "cancel" }
1181
+ ]);
1182
+ }
1183
+ async function editMessage(message) {
1184
+ const editorCmd = process.env.VISUAL || process.env.EDITOR || "vi";
1185
+ const tmpFile = join2(tmpdir(), `ghostcommit-${Date.now()}.txt`);
1186
+ try {
1187
+ await writeFile(tmpFile, message, "utf-8");
1188
+ const parts = editorCmd.split(/\s+/);
1189
+ const bin = parts[0];
1190
+ const args = [...parts.slice(1), tmpFile];
1191
+ await new Promise((resolve, reject) => {
1192
+ const child = spawn(bin, args, { stdio: "inherit" });
1193
+ child.on("close", (code) => {
1194
+ if (code === 0) resolve();
1195
+ else reject(new Error(`Editor exited with code ${code}`));
1196
+ });
1197
+ child.on("error", reject);
1198
+ });
1199
+ const edited = await readFile2(tmpFile, "utf-8");
1200
+ const trimmed = edited.trim();
1201
+ if (!trimmed || trimmed === message.trim()) {
1202
+ return null;
1203
+ }
1204
+ return trimmed;
1205
+ } catch {
1206
+ return null;
1207
+ } finally {
1208
+ try {
1209
+ await unlink(tmpFile);
1210
+ } catch {
1211
+ }
1212
+ }
1213
+ }
1214
+ function displayCommitMessage(message) {
1215
+ console.log("");
1216
+ const lines = message.split("\n");
1217
+ console.log(chalk.bold.white(lines[0]));
1218
+ if (lines.length > 1) {
1219
+ for (const line of lines.slice(1)) {
1220
+ console.log(chalk.gray(line));
1221
+ }
1222
+ }
1223
+ }
1224
+ function displayHeader(filesChanged, insertions, deletions) {
1225
+ console.log(chalk.bold("\n\u{1F47B} ghostcommit\n"));
1226
+ console.log(
1227
+ chalk.dim(
1228
+ `Analyzing ${filesChanged} file${filesChanged !== 1 ? "s" : ""} (+${insertions} -${deletions})...
1229
+ `
1230
+ )
1231
+ );
1232
+ }
1233
+
1234
+ // src/prompt.ts
1235
+ var BASE_SYSTEM_PROMPT = `You are ghostcommit, an AI that writes git commit messages.
1236
+
1237
+ RULES:
1238
+ - Follow Conventional Commits: <type>(<scope>): <description>
1239
+ - Types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
1240
+ - Imperative mood, lowercase subject, no period at end, max 72 chars for subject line
1241
+ - Focus on WHY, not just WHAT changed
1242
+ - If changes are significant, add a body after a blank line explaining the reasoning
1243
+ - Output ONLY the commit message, nothing else (no markdown, no quotes, no explanation)`;
1244
+ var LANGUAGE_NAMES = {
1245
+ en: "English",
1246
+ it: "Italian",
1247
+ es: "Spanish",
1248
+ fr: "French",
1249
+ de: "German",
1250
+ pt: "Portuguese",
1251
+ ja: "Japanese",
1252
+ ko: "Korean",
1253
+ zh: "Chinese",
1254
+ ru: "Russian"
1255
+ };
1256
+ function buildSystemPrompt(options) {
1257
+ const parts = [BASE_SYSTEM_PROMPT];
1258
+ if (options.language && options.language !== "en") {
1259
+ const name = LANGUAGE_NAMES[options.language] || options.language;
1260
+ parts.push("");
1261
+ parts.push(`LANGUAGE: Write the commit message in ${name}.`);
1262
+ }
1263
+ if (options.styleContext) {
1264
+ parts.push("");
1265
+ parts.push(options.styleContext);
1266
+ }
1267
+ return parts.join("\n");
1268
+ }
1269
+ function buildUserPrompt(options) {
1270
+ const parts = [];
1271
+ if (options.branchName && options.branchName !== "HEAD") {
1272
+ const ticket = extractTicketFromBranch(
1273
+ options.branchName,
1274
+ options.branchPattern
1275
+ );
1276
+ parts.push(`BRANCH: ${options.branchName}`);
1277
+ if (ticket) {
1278
+ parts.push(`\u2192 Include "${ticket}" reference if appropriate.`);
1279
+ }
1280
+ parts.push("");
1281
+ }
1282
+ if (options.userContext) {
1283
+ parts.push("DEVELOPER CONTEXT (from --context flag):");
1284
+ parts.push(options.userContext);
1285
+ parts.push(
1286
+ "\u2192 The developer provided this extra context about their changes. Use it to understand intent."
1287
+ );
1288
+ parts.push("");
1289
+ }
1290
+ parts.push("DIFF:");
1291
+ parts.push(options.diff);
1292
+ return parts.join("\n");
1293
+ }
1294
+ function estimatePromptOverhead(options) {
1295
+ const systemPrompt = buildSystemPrompt({
1296
+ styleContext: options.styleContext,
1297
+ language: options.language
1298
+ });
1299
+ const userPrompt = buildUserPrompt({
1300
+ diff: "",
1301
+ branchName: options.branchName,
1302
+ branchPattern: options.branchPattern,
1303
+ userContext: options.userContext
1304
+ });
1305
+ return estimateTokens(systemPrompt) + estimateTokens(userPrompt);
1306
+ }
1307
+
1308
+ // src/style-learner.ts
1309
+ import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
1310
+ import { join as join3 } from "path";
1311
+ var EMOJI_REGEX = /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|:\w+:/u;
1312
+ var COMMON_ENGLISH_WORDS = [
1313
+ "add",
1314
+ "fix",
1315
+ "update",
1316
+ "remove",
1317
+ "change",
1318
+ "implement",
1319
+ "create",
1320
+ "delete",
1321
+ "move",
1322
+ "rename",
1323
+ "refactor",
1324
+ "improve",
1325
+ "support",
1326
+ "handle",
1327
+ "use",
1328
+ "set",
1329
+ "merge",
1330
+ "release",
1331
+ "bump",
1332
+ "init"
1333
+ ];
1334
+ var COMMON_ITALIAN_WORDS = [
1335
+ "aggiungi",
1336
+ "aggiorna",
1337
+ "correggi",
1338
+ "rimuovi",
1339
+ "modifica",
1340
+ "implementa",
1341
+ "crea",
1342
+ "elimina",
1343
+ "sposta",
1344
+ "rinomina",
1345
+ "migliora",
1346
+ "gestisci",
1347
+ "usa",
1348
+ "imposta"
1349
+ ];
1350
+ function analyzeCommits(commits) {
1351
+ if (commits.length === 0) {
1352
+ return {
1353
+ usesConventionalCommits: false,
1354
+ conventionalCommitRatio: 0,
1355
+ usesScope: false,
1356
+ commonScopes: [],
1357
+ language: "unknown",
1358
+ averageSubjectLength: 0,
1359
+ usesBody: false,
1360
+ bodyRatio: 0,
1361
+ usesEmoji: false,
1362
+ emojiRatio: 0,
1363
+ usesLowercase: true,
1364
+ ticketPattern: null,
1365
+ commitCount: 0
1366
+ };
1367
+ }
1368
+ let conventionalCount = 0;
1369
+ let scopeCount = 0;
1370
+ let emojiCount = 0;
1371
+ let lowercaseCount = 0;
1372
+ let bodyCount = 0;
1373
+ let totalLength = 0;
1374
+ const scopes = {};
1375
+ let englishScore = 0;
1376
+ let italianScore = 0;
1377
+ const ticketPatterns = {};
1378
+ for (const commit of commits) {
1379
+ const msg = commit.message;
1380
+ totalLength += msg.length;
1381
+ if (commit.body) {
1382
+ bodyCount++;
1383
+ }
1384
+ const ccMatch = msg.match(
1385
+ /^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(([^)]+)\))?(!)?:/
1386
+ );
1387
+ if (ccMatch) {
1388
+ conventionalCount++;
1389
+ if (ccMatch[3]) {
1390
+ scopeCount++;
1391
+ const scope = ccMatch[3];
1392
+ scopes[scope] = (scopes[scope] || 0) + 1;
1393
+ }
1394
+ }
1395
+ if (EMOJI_REGEX.test(msg)) {
1396
+ emojiCount++;
1397
+ }
1398
+ const subjectMatch = msg.match(/^(?:\w+(?:\([^)]*\))?:\s*)(.*)/);
1399
+ const subject = subjectMatch ? subjectMatch[1] : msg;
1400
+ if (subject && subject[0] === subject[0].toLowerCase()) {
1401
+ lowercaseCount++;
1402
+ }
1403
+ const words = msg.toLowerCase().split(/\s+/);
1404
+ for (const word of words) {
1405
+ if (COMMON_ENGLISH_WORDS.includes(word)) englishScore++;
1406
+ if (COMMON_ITALIAN_WORDS.includes(word)) italianScore++;
1407
+ }
1408
+ const ticketMatch = msg.match(/([A-Z]+-\d+)|#(\d+)|(?:refs?\s+#?\d+)/i);
1409
+ if (ticketMatch) {
1410
+ const pattern = ticketMatch[0].replace(/\d+/g, "N");
1411
+ ticketPatterns[pattern] = (ticketPatterns[pattern] || 0) + 1;
1412
+ }
1413
+ }
1414
+ const n = commits.length;
1415
+ const conventionalRatio = conventionalCount / n;
1416
+ const scopeRatio = scopeCount / n;
1417
+ const commonScopes = Object.entries(scopes).filter(([, count]) => count >= 2).sort((a, b) => b[1] - a[1]).slice(0, 8).map(([scope]) => scope);
1418
+ let language = "unknown";
1419
+ if (englishScore > 0 || italianScore > 0) {
1420
+ if (englishScore > italianScore * 2) language = "english";
1421
+ else if (italianScore > englishScore * 2) language = "italian";
1422
+ else if (englishScore > 0 && italianScore > 0) language = "mixed";
1423
+ else language = englishScore > 0 ? "english" : "italian";
1424
+ }
1425
+ const topTicketPattern = Object.entries(ticketPatterns).sort((a, b) => b[1] - a[1]).find(([, count]) => count >= 3);
1426
+ return {
1427
+ usesConventionalCommits: conventionalRatio > 0.5,
1428
+ conventionalCommitRatio: conventionalRatio,
1429
+ usesScope: scopeRatio > 0.3,
1430
+ commonScopes,
1431
+ language,
1432
+ averageSubjectLength: Math.round(totalLength / n),
1433
+ usesBody: bodyCount / n > 0.2,
1434
+ bodyRatio: bodyCount / n,
1435
+ usesEmoji: emojiCount / n > 0.2,
1436
+ emojiRatio: emojiCount / n,
1437
+ usesLowercase: lowercaseCount / n > 0.7,
1438
+ ticketPattern: topTicketPattern ? topTicketPattern[0] : null,
1439
+ commitCount: n
1440
+ };
1441
+ }
1442
+ function buildStyleContext(analysis) {
1443
+ if (analysis.commitCount === 0) return "";
1444
+ const lines = ["STYLE GUIDE (from repo history):"];
1445
+ if (analysis.usesConventionalCommits) {
1446
+ if (analysis.usesScope) {
1447
+ lines.push("- Format: conventional commits with scope");
1448
+ } else {
1449
+ lines.push("- Format: conventional commits (no scope)");
1450
+ }
1451
+ } else {
1452
+ lines.push("- Format: freeform (no conventional commits pattern)");
1453
+ }
1454
+ if (analysis.commonScopes.length > 0) {
1455
+ lines.push(`- Common scopes: ${analysis.commonScopes.join(", ")}`);
1456
+ }
1457
+ if (analysis.language !== "unknown") {
1458
+ const languageMap = {
1459
+ english: "English",
1460
+ italian: "Italian",
1461
+ mixed: "Mixed (English/Italian)"
1462
+ };
1463
+ lines.push(`- Language: ${languageMap[analysis.language]}`);
1464
+ }
1465
+ lines.push(
1466
+ `- Average subject length: ${analysis.averageSubjectLength} chars`
1467
+ );
1468
+ if (analysis.bodyRatio > 0) {
1469
+ lines.push(
1470
+ `- Body: used in ${Math.round(analysis.bodyRatio * 100)}% of commits`
1471
+ );
1472
+ }
1473
+ if (analysis.usesEmoji) {
1474
+ lines.push("- Uses emoji/gitmoji in commit messages");
1475
+ }
1476
+ if (analysis.usesLowercase) {
1477
+ lines.push("- Subject starts with lowercase");
1478
+ }
1479
+ if (analysis.ticketPattern) {
1480
+ lines.push(`- Pattern: ticket reference "${analysis.ticketPattern}"`);
1481
+ }
1482
+ return lines.join("\n");
1483
+ }
1484
+ async function loadCache(cacheFile) {
1485
+ try {
1486
+ const content = await readFile3(cacheFile, "utf-8");
1487
+ return JSON.parse(content);
1488
+ } catch {
1489
+ return null;
1490
+ }
1491
+ }
1492
+ async function saveCache(cacheFile, data) {
1493
+ await writeFile2(cacheFile, JSON.stringify(data, null, 2));
1494
+ }
1495
+ async function learnStyle(n = 50) {
1496
+ let gitRoot;
1497
+ try {
1498
+ gitRoot = await getGitRootDir();
1499
+ } catch {
1500
+ return { styleContext: "", analysis: analyzeCommits([]) };
1501
+ }
1502
+ const cacheFile = join3(gitRoot, ".ghostcommit-cache.json");
1503
+ const commits = await getRecentCommits(n);
1504
+ if (commits.length === 0) {
1505
+ return { styleContext: "", analysis: analyzeCommits([]) };
1506
+ }
1507
+ const cache = await loadCache(cacheFile);
1508
+ if (cache && cache.lastCommitHash === commits[0].hash && cache.commitCount === commits.length) {
1509
+ return { styleContext: cache.styleContext, analysis: cache.analysis };
1510
+ }
1511
+ const analysis = analyzeCommits(commits);
1512
+ const styleContext = buildStyleContext(analysis);
1513
+ try {
1514
+ await saveCache(cacheFile, {
1515
+ styleContext,
1516
+ analysis,
1517
+ lastCommitHash: commits[0].hash,
1518
+ commitCount: commits.length,
1519
+ timestamp: Date.now()
1520
+ });
1521
+ } catch {
1522
+ }
1523
+ return { styleContext, analysis };
1524
+ }
1525
+
1526
+ // src/commands/amend.ts
1527
+ async function runAmend(options) {
1528
+ if (!await isGitRepo()) {
1529
+ throw new Error(
1530
+ "Not a git repository. Run this command from inside a git repo."
1531
+ );
1532
+ }
1533
+ let currentMessage;
1534
+ try {
1535
+ currentMessage = await getLastCommitMessage();
1536
+ } catch {
1537
+ throw new Error(
1538
+ "No commits found. Make a commit first before using amend."
1539
+ );
1540
+ }
1541
+ if (!currentMessage) {
1542
+ throw new Error(
1543
+ "No commits found. Make a commit first before using amend."
1544
+ );
1545
+ }
1546
+ let projectRoot;
1547
+ try {
1548
+ projectRoot = await getGitRootDir();
1549
+ } catch {
1550
+ projectRoot = process.cwd();
1551
+ }
1552
+ const cliFlags = {
1553
+ provider: options.provider,
1554
+ model: options.model,
1555
+ context: options.context,
1556
+ yes: options.yes,
1557
+ dryRun: options.dryRun,
1558
+ noStyle: options.style === false
1559
+ };
1560
+ const config = await loadConfig(projectRoot, cliFlags);
1561
+ console.log(chalk2.bold("\n\u{1F47B} ghostcommit amend\n"));
1562
+ console.log(chalk2.dim("Current message:"));
1563
+ console.log(chalk2.yellow(currentMessage));
1564
+ console.log("");
1565
+ const stats = await getLastCommitDiffStats();
1566
+ console.log(
1567
+ chalk2.dim(
1568
+ `Analyzing ${stats.filesChanged} file${stats.filesChanged !== 1 ? "s" : ""} (+${stats.insertions} -${stats.deletions})...
1569
+ `
1570
+ )
1571
+ );
1572
+ const gitExcludes = [
1573
+ ...DEFAULT_IGNORE_PATTERNS,
1574
+ ...DEFAULT_IGNORE_DIRS,
1575
+ ...config.ignorePaths
1576
+ ];
1577
+ const rawDiff = await getLastCommitDiff(gitExcludes);
1578
+ const lastCommitFiles = await getLastCommitFiles();
1579
+ let styleContext = "";
1580
+ if (config.learnStyle) {
1581
+ const style = await learnStyle(config.learnStyleCommits);
1582
+ styleContext = style.styleContext;
1583
+ }
1584
+ const branchName = config.branchPrefix ? await getBranchName() : void 0;
1585
+ const provider = await resolveProvider(config.provider, config.model);
1586
+ console.log(chalk2.dim(`Using ${provider.name}...
1587
+ `));
1588
+ const providerBudget = config.tokenBudget ?? provider.getTokenBudget();
1589
+ const promptOverhead = estimatePromptOverhead({
1590
+ styleContext,
1591
+ language: config.language,
1592
+ branchName,
1593
+ branchPattern: config.branchPattern,
1594
+ userContext: options.context
1595
+ });
1596
+ const RESPONSE_RESERVE = 500;
1597
+ let done = false;
1598
+ while (!done) {
1599
+ let message;
1600
+ const MAX_RETRIES = 3;
1601
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
1602
+ const diffBudget = Math.max(
1603
+ 500,
1604
+ Math.floor(
1605
+ (providerBudget - promptOverhead - RESPONSE_RESERVE) / 2 ** attempt
1606
+ )
1607
+ );
1608
+ if (attempt > 0) {
1609
+ console.log(
1610
+ chalk2.yellow(
1611
+ `Retrying with compressed diff (budget: ${diffBudget} tokens)...
1612
+ `
1613
+ )
1614
+ );
1615
+ }
1616
+ const processed = processDiff(
1617
+ rawDiff,
1618
+ lastCommitFiles,
1619
+ config.ignorePaths,
1620
+ diffBudget
1621
+ );
1622
+ const formattedDiff = formatDiffForPrompt(processed);
1623
+ const systemPrompt = buildSystemPrompt({
1624
+ styleContext,
1625
+ language: config.language
1626
+ });
1627
+ const userPrompt = buildUserPrompt({
1628
+ diff: formattedDiff,
1629
+ styleContext,
1630
+ branchName,
1631
+ branchPattern: config.branchPattern,
1632
+ userContext: options.context
1633
+ });
1634
+ try {
1635
+ message = await generateCommitMessage(
1636
+ provider,
1637
+ userPrompt,
1638
+ systemPrompt,
1639
+ !options.yes
1640
+ );
1641
+ break;
1642
+ } catch (error) {
1643
+ if (isTokenLimitError(error) && attempt < MAX_RETRIES - 1) {
1644
+ continue;
1645
+ }
1646
+ throw error;
1647
+ }
1648
+ }
1649
+ if (!message) {
1650
+ throw new Error("AI returned an empty commit message. Try again.");
1651
+ }
1652
+ if (options.dryRun) {
1653
+ if (!process.stdout.isTTY || options.yes) {
1654
+ displayCommitMessage(message);
1655
+ }
1656
+ done = true;
1657
+ continue;
1658
+ }
1659
+ if (options.yes) {
1660
+ console.log(chalk2.dim(message));
1661
+ await amendCommit(message);
1662
+ console.log(chalk2.green("\nCommit amended."));
1663
+ done = true;
1664
+ continue;
1665
+ }
1666
+ if (!process.stdout.isTTY) {
1667
+ await amendCommit(message);
1668
+ done = true;
1669
+ continue;
1670
+ }
1671
+ const action = await promptAction();
1672
+ switch (action) {
1673
+ case "accept":
1674
+ await amendCommit(message);
1675
+ console.log(chalk2.green("\nCommit amended."));
1676
+ done = true;
1677
+ break;
1678
+ case "edit": {
1679
+ const edited = await editMessage(message);
1680
+ if (edited) {
1681
+ await amendCommit(edited);
1682
+ console.log(chalk2.green("\nCommit amended."));
1683
+ } else {
1684
+ console.log(chalk2.yellow("No changes made. Amend cancelled."));
1685
+ process.exit(2);
1686
+ }
1687
+ done = true;
1688
+ break;
1689
+ }
1690
+ case "regenerate":
1691
+ console.log(chalk2.dim("\nRegenerating...\n"));
1692
+ break;
1693
+ case "cancel":
1694
+ console.log(chalk2.yellow("\nAmend cancelled."));
1695
+ process.exit(2);
1696
+ }
1697
+ }
1698
+ }
1699
+
1700
+ // src/commands/commit.ts
1701
+ import chalk3 from "chalk";
1702
+ async function runCommit(options) {
1703
+ if (!await isGitRepo()) {
1704
+ throw new Error(
1705
+ "Not a git repository. Run this command from inside a git repo."
1706
+ );
1707
+ }
1708
+ const stagedFiles = await getStagedFiles();
1709
+ if (stagedFiles.length === 0) {
1710
+ throw new Error(
1711
+ "No staged changes. Stage your changes first:\n git add <files>"
1712
+ );
1713
+ }
1714
+ let projectRoot;
1715
+ try {
1716
+ projectRoot = await getGitRootDir();
1717
+ } catch {
1718
+ projectRoot = process.cwd();
1719
+ }
1720
+ const cliFlags = {
1721
+ provider: options.provider,
1722
+ model: options.model,
1723
+ context: options.context,
1724
+ yes: options.yes,
1725
+ dryRun: options.dryRun,
1726
+ noStyle: options.style === false
1727
+ };
1728
+ const config = await loadConfig(projectRoot, cliFlags);
1729
+ const stats = await getDiffStats();
1730
+ displayHeader(stats.filesChanged, stats.insertions, stats.deletions);
1731
+ const gitExcludes = [
1732
+ ...DEFAULT_IGNORE_PATTERNS,
1733
+ ...DEFAULT_IGNORE_DIRS,
1734
+ ...config.ignorePaths
1735
+ ];
1736
+ const rawDiff = await getStagedDiff(gitExcludes);
1737
+ let styleContext = "";
1738
+ if (config.learnStyle) {
1739
+ const style = await learnStyle(config.learnStyleCommits);
1740
+ styleContext = style.styleContext;
1741
+ }
1742
+ const branchName = config.branchPrefix ? await getBranchName() : void 0;
1743
+ const provider = await resolveProvider(config.provider, config.model);
1744
+ console.log(chalk3.dim(`Using ${provider.name}...
1745
+ `));
1746
+ const providerBudget = config.tokenBudget ?? provider.getTokenBudget();
1747
+ const promptOverhead = estimatePromptOverhead({
1748
+ styleContext,
1749
+ language: config.language,
1750
+ branchName,
1751
+ branchPattern: config.branchPattern,
1752
+ userContext: options.context
1753
+ });
1754
+ const RESPONSE_RESERVE = 500;
1755
+ let done = false;
1756
+ while (!done) {
1757
+ let message;
1758
+ const MAX_RETRIES = 3;
1759
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
1760
+ const diffBudget = Math.max(
1761
+ 500,
1762
+ Math.floor(
1763
+ (providerBudget - promptOverhead - RESPONSE_RESERVE) / 2 ** attempt
1764
+ )
1765
+ );
1766
+ if (attempt > 0) {
1767
+ console.log(
1768
+ chalk3.yellow(
1769
+ `Retrying with compressed diff (budget: ${diffBudget} tokens)...
1770
+ `
1771
+ )
1772
+ );
1773
+ }
1774
+ const processed = processDiff(
1775
+ rawDiff,
1776
+ stagedFiles,
1777
+ config.ignorePaths,
1778
+ diffBudget
1779
+ );
1780
+ const formattedDiff = formatDiffForPrompt(processed);
1781
+ const systemPrompt = buildSystemPrompt({
1782
+ styleContext,
1783
+ language: config.language
1784
+ });
1785
+ const userPrompt = buildUserPrompt({
1786
+ diff: formattedDiff,
1787
+ styleContext,
1788
+ branchName,
1789
+ branchPattern: config.branchPattern,
1790
+ userContext: options.context
1791
+ });
1792
+ try {
1793
+ message = await generateCommitMessage(
1794
+ provider,
1795
+ userPrompt,
1796
+ systemPrompt,
1797
+ !options.yes
1798
+ // stream only in interactive mode
1799
+ );
1800
+ break;
1801
+ } catch (error) {
1802
+ if (isTokenLimitError(error) && attempt < MAX_RETRIES - 1) {
1803
+ continue;
1804
+ }
1805
+ throw error;
1806
+ }
1807
+ }
1808
+ if (!message) {
1809
+ throw new Error("AI returned an empty commit message. Try again.");
1810
+ }
1811
+ if (options.dryRun) {
1812
+ if (!process.stdout.isTTY || options.yes) {
1813
+ displayCommitMessage(message);
1814
+ }
1815
+ done = true;
1816
+ continue;
1817
+ }
1818
+ if (options.yes) {
1819
+ console.log(chalk3.dim(message));
1820
+ await createCommit(message);
1821
+ console.log(chalk3.green("\nCommit created."));
1822
+ done = true;
1823
+ continue;
1824
+ }
1825
+ if (!process.stdout.isTTY) {
1826
+ await createCommit(message);
1827
+ done = true;
1828
+ continue;
1829
+ }
1830
+ const action = await promptAction();
1831
+ switch (action) {
1832
+ case "accept":
1833
+ await createCommit(message);
1834
+ console.log(chalk3.green("\nCommit created."));
1835
+ done = true;
1836
+ break;
1837
+ case "edit": {
1838
+ const edited = await editMessage(message);
1839
+ if (edited) {
1840
+ await createCommit(edited);
1841
+ console.log(chalk3.green("\nCommit created."));
1842
+ } else {
1843
+ console.log(chalk3.yellow("No changes made. Commit cancelled."));
1844
+ process.exit(2);
1845
+ }
1846
+ done = true;
1847
+ break;
1848
+ }
1849
+ case "regenerate":
1850
+ console.log(chalk3.dim("\nRegenerating...\n"));
1851
+ break;
1852
+ case "cancel":
1853
+ console.log(chalk3.yellow("\nCommit cancelled."));
1854
+ process.exit(2);
1855
+ }
1856
+ }
1857
+ }
1858
+
1859
+ // src/commands/hook.ts
1860
+ import { chmod, readFile as readFile4, unlink as unlink2, writeFile as writeFile3 } from "fs/promises";
1861
+ import { join as join4 } from "path";
1862
+ import chalk4 from "chalk";
1863
+ var HOOK_MARKER = "# ghostcommit-hook";
1864
+ var HOOK_SCRIPT = `#!/bin/sh
1865
+ ${HOOK_MARKER} \u2014 auto-generated, do not edit
1866
+ ghostcommit hook run "$1" "$2" 2>/dev/null || true
1867
+ `;
1868
+ async function runHookInstall() {
1869
+ if (!await isGitRepo()) {
1870
+ throw new Error(
1871
+ "Not a git repository. Run this command from inside a git repo."
1872
+ );
1873
+ }
1874
+ const hooksDir = await getGitHooksDir();
1875
+ const hookPath = join4(hooksDir, "prepare-commit-msg");
1876
+ await writeFile3(hookPath, HOOK_SCRIPT, "utf-8");
1877
+ await chmod(hookPath, 493);
1878
+ console.log(chalk4.green("Installed prepare-commit-msg hook."));
1879
+ console.log(
1880
+ chalk4.dim(
1881
+ "ghostcommit will auto-generate messages when you run git commit."
1882
+ )
1883
+ );
1884
+ }
1885
+ async function runHookUninstall() {
1886
+ if (!await isGitRepo()) {
1887
+ throw new Error(
1888
+ "Not a git repository. Run this command from inside a git repo."
1889
+ );
1890
+ }
1891
+ const hooksDir = await getGitHooksDir();
1892
+ const hookPath = join4(hooksDir, "prepare-commit-msg");
1893
+ let content;
1894
+ try {
1895
+ content = await readFile4(hookPath, "utf-8");
1896
+ } catch {
1897
+ throw new Error("No prepare-commit-msg hook found.");
1898
+ }
1899
+ if (!content.includes(HOOK_MARKER)) {
1900
+ throw new Error(
1901
+ "The prepare-commit-msg hook was not created by ghostcommit. Refusing to remove it."
1902
+ );
1903
+ }
1904
+ await unlink2(hookPath);
1905
+ console.log(chalk4.green("Removed prepare-commit-msg hook."));
1906
+ }
1907
+ async function runHookRun(msgFile, source) {
1908
+ if (source === "message" || source === "merge" || source === "squash") {
1909
+ return;
1910
+ }
1911
+ const stagedFiles = await getStagedFiles();
1912
+ if (stagedFiles.length === 0) {
1913
+ return;
1914
+ }
1915
+ let projectRoot;
1916
+ try {
1917
+ projectRoot = await getGitRootDir();
1918
+ } catch {
1919
+ projectRoot = process.cwd();
1920
+ }
1921
+ const cliFlags = {};
1922
+ const config = await loadConfig(projectRoot, cliFlags);
1923
+ const gitExcludes = [
1924
+ ...DEFAULT_IGNORE_PATTERNS,
1925
+ ...DEFAULT_IGNORE_DIRS,
1926
+ ...config.ignorePaths
1927
+ ];
1928
+ const rawDiff = await getStagedDiff(gitExcludes);
1929
+ let styleContext = "";
1930
+ if (config.learnStyle) {
1931
+ const style = await learnStyle(config.learnStyleCommits);
1932
+ styleContext = style.styleContext;
1933
+ }
1934
+ const branchName = config.branchPrefix ? await getBranchName() : void 0;
1935
+ const provider = await resolveProvider(config.provider, config.model);
1936
+ const providerBudget = config.tokenBudget ?? provider.getTokenBudget();
1937
+ const promptOverhead = estimatePromptOverhead({
1938
+ styleContext,
1939
+ language: config.language,
1940
+ branchName,
1941
+ branchPattern: config.branchPattern
1942
+ });
1943
+ const RESPONSE_RESERVE = 500;
1944
+ const MAX_RETRIES = 3;
1945
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
1946
+ const diffBudget = Math.max(
1947
+ 500,
1948
+ Math.floor(
1949
+ (providerBudget - promptOverhead - RESPONSE_RESERVE) / 2 ** attempt
1950
+ )
1951
+ );
1952
+ const processed = processDiff(
1953
+ rawDiff,
1954
+ stagedFiles,
1955
+ config.ignorePaths,
1956
+ diffBudget
1957
+ );
1958
+ const formattedDiff = formatDiffForPrompt(processed);
1959
+ const systemPrompt = buildSystemPrompt({
1960
+ styleContext,
1961
+ language: config.language
1962
+ });
1963
+ const userPrompt = buildUserPrompt({
1964
+ diff: formattedDiff,
1965
+ styleContext,
1966
+ branchName,
1967
+ branchPattern: config.branchPattern
1968
+ });
1969
+ try {
1970
+ const message = await generateCommitMessage(
1971
+ provider,
1972
+ userPrompt,
1973
+ systemPrompt,
1974
+ false
1975
+ // no streaming in hook mode
1976
+ );
1977
+ if (message) {
1978
+ await writeFile3(msgFile, message, "utf-8");
1979
+ }
1980
+ return;
1981
+ } catch (error) {
1982
+ if (isTokenLimitError(error) && attempt < MAX_RETRIES - 1) {
1983
+ continue;
1984
+ }
1985
+ return;
1986
+ }
1987
+ }
1988
+ }
1989
+
1990
+ // src/commands/log.ts
1991
+ import { writeFile as writeFile4 } from "fs/promises";
1992
+ import chalk5 from "chalk";
1993
+
1994
+ // src/changelog/formatter.ts
1995
+ function formatPRRef(prNumber) {
1996
+ if (prNumber == null) return "";
1997
+ return ` (#${prNumber})`;
1998
+ }
1999
+ function formatCommitLine(item) {
2000
+ return `- ${item.summary}${formatPRRef(item.commit.prNumber)}`;
2001
+ }
2002
+ function formatMarkdown(categorized, options) {
2003
+ const version = options.version || "Unreleased";
2004
+ const date = options.date || (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2005
+ const categoryOrder = options.categories || ALL_CATEGORIES;
2006
+ const grouped = groupByCategory(categorized);
2007
+ const lines = [];
2008
+ lines.push(`## [${version}] - ${date}`);
2009
+ lines.push("");
2010
+ for (const category of categoryOrder) {
2011
+ const items = grouped.get(category);
2012
+ if (!items || items.length === 0) continue;
2013
+ lines.push(`### ${category}`);
2014
+ for (const item of items) {
2015
+ lines.push(formatCommitLine(item));
2016
+ }
2017
+ lines.push("");
2018
+ }
2019
+ return `${lines.join("\n").trimEnd()}
2020
+ `;
2021
+ }
2022
+ function formatJSON(categorized, options) {
2023
+ const version = options.version || "Unreleased";
2024
+ const date = options.date || (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2025
+ const grouped = groupByCategory(categorized);
2026
+ const categories = {};
2027
+ for (const [category, items] of grouped) {
2028
+ categories[category] = items.map((item) => ({
2029
+ summary: item.summary,
2030
+ hash: item.commit.hash,
2031
+ author: item.commit.author,
2032
+ prNumber: item.commit.prNumber,
2033
+ breaking: item.commit.breaking
2034
+ }));
2035
+ }
2036
+ const output = { version, date, categories };
2037
+ return `${JSON.stringify(output, null, 2)}
2038
+ `;
2039
+ }
2040
+ function formatPlain(categorized, options) {
2041
+ const version = options.version || "Unreleased";
2042
+ const date = options.date || (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
2043
+ const categoryOrder = options.categories || ALL_CATEGORIES;
2044
+ const grouped = groupByCategory(categorized);
2045
+ const lines = [];
2046
+ lines.push(`${version} (${date})`);
2047
+ lines.push("=".repeat(lines[0].length));
2048
+ lines.push("");
2049
+ for (const category of categoryOrder) {
2050
+ const items = grouped.get(category);
2051
+ if (!items || items.length === 0) continue;
2052
+ lines.push(`${category}:`);
2053
+ for (const item of items) {
2054
+ const prRef = item.commit.prNumber ? ` (#${item.commit.prNumber})` : "";
2055
+ lines.push(` * ${item.summary}${prRef}`);
2056
+ }
2057
+ lines.push("");
2058
+ }
2059
+ return `${lines.join("\n").trimEnd()}
2060
+ `;
2061
+ }
2062
+ function formatChangelog(categorized, options) {
2063
+ switch (options.format) {
2064
+ case "markdown":
2065
+ return formatMarkdown(categorized, options);
2066
+ case "json":
2067
+ return formatJSON(categorized, options);
2068
+ case "plain":
2069
+ return formatPlain(categorized, options);
2070
+ default:
2071
+ return formatMarkdown(categorized, options);
2072
+ }
2073
+ }
2074
+
2075
+ // src/changelog/parser.ts
2076
+ var CONVENTIONAL_COMMIT_RE = /^(?<type>feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(?:\((?<scope>[^)]+)\))?(?<breaking>!)?:\s+(?<description>.+)$/;
2077
+ var PR_NUMBER_RE = /\(#(\d+)\)\s*$/;
2078
+ function parseCommit(commit) {
2079
+ const { hash, message, author, date } = commit;
2080
+ const prMatch = message.match(PR_NUMBER_RE);
2081
+ const prNumber = prMatch ? parseInt(prMatch[1], 10) : void 0;
2082
+ const ccMatch = message.match(CONVENTIONAL_COMMIT_RE);
2083
+ if (ccMatch?.groups) {
2084
+ const { type, scope, breaking, description } = ccMatch.groups;
2085
+ return {
2086
+ hash,
2087
+ date,
2088
+ author,
2089
+ message,
2090
+ type,
2091
+ scope,
2092
+ description,
2093
+ breaking: !!breaking,
2094
+ prNumber
2095
+ };
2096
+ }
2097
+ const isBreaking = message.toUpperCase().includes("BREAKING CHANGE") || message.toUpperCase().includes("BREAKING:");
2098
+ return {
2099
+ hash,
2100
+ date,
2101
+ author,
2102
+ message,
2103
+ description: message,
2104
+ breaking: isBreaking,
2105
+ prNumber
2106
+ };
2107
+ }
2108
+ function parseCommits(commits) {
2109
+ return commits.map(parseCommit);
2110
+ }
2111
+
2112
+ // src/commands/log.ts
2113
+ async function promptLogAction(outputFile) {
2114
+ return promptSingleKey([
2115
+ {
2116
+ key: "a",
2117
+ label: "[A]ccept",
2118
+ color: chalk5.green,
2119
+ value: "accept"
2120
+ },
2121
+ {
2122
+ key: "w",
2123
+ label: `[W]rite to ${outputFile}`,
2124
+ color: chalk5.blue,
2125
+ value: "write"
2126
+ },
2127
+ { key: "e", label: "[E]dit", color: chalk5.yellow, value: "edit" },
2128
+ { key: "c", label: "[C]ancel", color: chalk5.red, value: "cancel" }
2129
+ ]);
2130
+ }
2131
+ async function runLog(options) {
2132
+ if (!await isGitRepo()) {
2133
+ throw new Error(
2134
+ "Not a git repository. Run this command from inside a git repo."
2135
+ );
2136
+ }
2137
+ const fromRef = options.from || await getLatestTag();
2138
+ if (!fromRef) {
2139
+ throw new Error(
2140
+ "No tags found and no --from specified.\nCreate a tag first (git tag v0.1.0) or specify --from <ref>."
2141
+ );
2142
+ }
2143
+ const toRef = options.to || "HEAD";
2144
+ console.log(chalk5.bold("\n\u{1F47B} ghostcommit log\n"));
2145
+ console.log(chalk5.dim(`Generating changelog for ${fromRef}...${toRef}`));
2146
+ const commits = await getCommitsBetween(fromRef, toRef);
2147
+ if (commits.length === 0) {
2148
+ console.log(chalk5.yellow("No commits found in the specified range."));
2149
+ return;
2150
+ }
2151
+ console.log(chalk5.dim(`Analyzing ${commits.length} commits...
2152
+ `));
2153
+ const parsed = parseCommits(commits);
2154
+ let projectRoot;
2155
+ try {
2156
+ projectRoot = await getGitRootDir();
2157
+ } catch {
2158
+ projectRoot = process.cwd();
2159
+ }
2160
+ const config = await loadConfig(projectRoot, {
2161
+ provider: options.provider,
2162
+ model: options.model
2163
+ });
2164
+ const needsAI = parsed.some((c) => !c.type);
2165
+ let provider;
2166
+ if (needsAI) {
2167
+ try {
2168
+ provider = await resolveProvider(config.provider, config.model);
2169
+ console.log(
2170
+ chalk5.dim(
2171
+ `Some commits need AI categorization, using ${provider.name}...`
2172
+ )
2173
+ );
2174
+ } catch {
2175
+ console.log(
2176
+ chalk5.dim(
2177
+ "No AI provider available. Non-conventional commits will be categorized as Chore."
2178
+ )
2179
+ );
2180
+ }
2181
+ }
2182
+ const categorized = await categorizeCommits(parsed, {
2183
+ provider,
2184
+ excludePatterns: config.changelog.exclude
2185
+ });
2186
+ const format = options.format || config.changelog.format;
2187
+ const outputFile = options.output || config.changelog.output;
2188
+ const changelog = formatChangelog(categorized, {
2189
+ format,
2190
+ version: toRef === "HEAD" ? void 0 : toRef,
2191
+ categories: config.changelog.categories
2192
+ });
2193
+ console.log(changelog);
2194
+ if (options.dryRun) {
2195
+ return;
2196
+ }
2197
+ if (!process.stdout.isTTY) {
2198
+ return;
2199
+ }
2200
+ const action = await promptLogAction(outputFile);
2201
+ switch (action) {
2202
+ case "accept":
2203
+ console.log(chalk5.green("Changelog generated."));
2204
+ break;
2205
+ case "write":
2206
+ await writeFile4(outputFile, changelog, "utf-8");
2207
+ console.log(chalk5.green(`
2208
+ Changelog written to ${outputFile}`));
2209
+ break;
2210
+ case "edit": {
2211
+ const edited = await editMessage(changelog);
2212
+ if (edited) {
2213
+ await writeFile4(outputFile, edited, "utf-8");
2214
+ console.log(chalk5.green(`
2215
+ Edited changelog written to ${outputFile}`));
2216
+ } else {
2217
+ console.log(chalk5.yellow("No changes made."));
2218
+ }
2219
+ break;
2220
+ }
2221
+ case "cancel":
2222
+ console.log(chalk5.yellow("\nCancelled."));
2223
+ process.exit(2);
2224
+ }
2225
+ }
2226
+
2227
+ // src/commands/release.ts
2228
+ import chalk6 from "chalk";
2229
+
2230
+ // src/github.ts
2231
+ import { Octokit } from "@octokit/rest";
2232
+ function getOctokit() {
2233
+ const token = process.env.GITHUB_TOKEN;
2234
+ if (!token) {
2235
+ throw new Error(
2236
+ "GITHUB_TOKEN environment variable is required for GitHub operations.\nCreate a token at https://github.com/settings/tokens"
2237
+ );
2238
+ }
2239
+ return new Octokit({ auth: token });
2240
+ }
2241
+ async function getRepoInfo() {
2242
+ const { stdout } = await exec("git", ["remote", "get-url", "origin"]);
2243
+ const url = stdout.trim();
2244
+ const sshMatch = url.match(/git@github\.com:([^/]+)\/(.+?)(?:\.git)?$/);
2245
+ if (sshMatch) {
2246
+ return { owner: sshMatch[1], repo: sshMatch[2] };
2247
+ }
2248
+ const httpsMatch = url.match(
2249
+ /https?:\/\/github\.com\/([^/]+)\/(.+?)(?:\.git)?$/
2250
+ );
2251
+ if (httpsMatch) {
2252
+ return { owner: httpsMatch[1], repo: httpsMatch[2] };
2253
+ }
2254
+ throw new Error(
2255
+ `Could not parse GitHub remote URL: ${url}
2256
+ Expected a github.com remote.`
2257
+ );
2258
+ }
2259
+ async function createRelease(options) {
2260
+ const octokit = getOctokit();
2261
+ const response = await octokit.repos.createRelease({
2262
+ owner: options.owner,
2263
+ repo: options.repo,
2264
+ tag_name: options.tag,
2265
+ name: options.title,
2266
+ body: options.body,
2267
+ draft: options.draft ?? false
2268
+ });
2269
+ return response.data.html_url;
2270
+ }
2271
+ function isGitHubTokenAvailable() {
2272
+ return !!process.env.GITHUB_TOKEN;
2273
+ }
2274
+
2275
+ // src/commands/release.ts
2276
+ async function promptReleaseAction() {
2277
+ return promptSingleKey([
2278
+ {
2279
+ key: "p",
2280
+ label: "[P]ublish release",
2281
+ color: chalk6.green,
2282
+ value: "publish"
2283
+ },
2284
+ { key: "c", label: "[C]ancel", color: chalk6.red, value: "cancel" }
2285
+ ]);
2286
+ }
2287
+ async function runRelease(options) {
2288
+ if (!await isGitRepo()) {
2289
+ throw new Error(
2290
+ "Not a git repository. Run this command from inside a git repo."
2291
+ );
2292
+ }
2293
+ if (!isGitHubTokenAvailable()) {
2294
+ throw new Error(
2295
+ "GITHUB_TOKEN environment variable is required for creating releases.\nCreate a token at https://github.com/settings/tokens"
2296
+ );
2297
+ }
2298
+ const targetTag = options.tag || await getLatestTag();
2299
+ if (!targetTag) {
2300
+ throw new Error(
2301
+ "No tags found and no --tag specified.\nCreate a tag first (git tag v1.0.0) or specify --tag <tag>."
2302
+ );
2303
+ }
2304
+ const tags = await getTags();
2305
+ const tagIndex = tags.findIndex((t) => t.name === targetTag);
2306
+ const previousTag = tagIndex >= 0 && tags.length > tagIndex + 1 ? tags[tagIndex + 1].name : null;
2307
+ if (!previousTag) {
2308
+ throw new Error(
2309
+ `No previous tag found before ${targetTag}. Need at least two tags for a release.
2310
+ Specify the range manually with: ghostcommit log --from <ref> --to <ref>`
2311
+ );
2312
+ }
2313
+ console.log(chalk6.bold("\n\u{1F47B} ghostcommit release\n"));
2314
+ console.log(
2315
+ chalk6.dim(
2316
+ `Creating release for ${targetTag} (${previousTag}...${targetTag})`
2317
+ )
2318
+ );
2319
+ const commits = await getCommitsBetween(previousTag, targetTag);
2320
+ if (commits.length === 0) {
2321
+ console.log(chalk6.yellow("No commits found in the specified range."));
2322
+ return;
2323
+ }
2324
+ console.log(chalk6.dim(`Analyzing ${commits.length} commits...
2325
+ `));
2326
+ const parsed = parseCommits(commits);
2327
+ let projectRoot;
2328
+ try {
2329
+ projectRoot = await getGitRootDir();
2330
+ } catch {
2331
+ projectRoot = process.cwd();
2332
+ }
2333
+ const config = await loadConfig(projectRoot, {
2334
+ provider: options.provider,
2335
+ model: options.model
2336
+ });
2337
+ const needsAI = parsed.some((c) => !c.type);
2338
+ let provider;
2339
+ if (needsAI) {
2340
+ try {
2341
+ provider = await resolveProvider(config.provider, config.model);
2342
+ } catch {
2343
+ }
2344
+ }
2345
+ const categorized = await categorizeCommits(parsed, {
2346
+ provider,
2347
+ excludePatterns: config.changelog.exclude
2348
+ });
2349
+ const body = formatChangelog(categorized, {
2350
+ format: "markdown",
2351
+ version: targetTag,
2352
+ categories: config.changelog.categories
2353
+ });
2354
+ console.log(body);
2355
+ const isDraft = options.draft ?? config.release.draft;
2356
+ const draftLabel = isDraft ? " (draft)" : "";
2357
+ console.log(chalk6.dim(`Release: ${targetTag}${draftLabel}`));
2358
+ if (process.stdout.isTTY) {
2359
+ const action = await promptReleaseAction();
2360
+ if (action === "cancel") {
2361
+ console.log(chalk6.yellow("\nRelease cancelled."));
2362
+ process.exit(2);
2363
+ }
2364
+ }
2365
+ const { owner, repo } = await getRepoInfo();
2366
+ console.log(chalk6.dim("\nCreating GitHub release..."));
2367
+ const releaseUrl = await createRelease({
2368
+ owner,
2369
+ repo,
2370
+ tag: targetTag,
2371
+ title: targetTag,
2372
+ body,
2373
+ draft: isDraft
2374
+ });
2375
+ console.log(chalk6.green(`
2376
+ Release created: ${releaseUrl}`));
2377
+ }
2378
+
2379
+ // src/cli.ts
2380
+ async function getVersion() {
2381
+ try {
2382
+ const __dirname = dirname(fileURLToPath(import.meta.url));
2383
+ for (const base of [__dirname, join5(__dirname, "..")]) {
2384
+ try {
2385
+ const pkg = JSON.parse(
2386
+ await readFile5(join5(base, "package.json"), "utf-8")
2387
+ );
2388
+ return pkg.version || "0.0.0";
2389
+ } catch {
2390
+ }
2391
+ }
2392
+ return "0.0.0";
2393
+ } catch {
2394
+ return "0.0.0";
2395
+ }
2396
+ }
2397
+ function wrapAction(fn) {
2398
+ return async (opts) => {
2399
+ try {
2400
+ await fn(opts);
2401
+ } catch (error) {
2402
+ const msg = error instanceof Error ? error.message : String(error);
2403
+ console.error(chalk7.red(`Error: ${msg}`));
2404
+ process.exit(1);
2405
+ }
2406
+ };
2407
+ }
2408
+ function createCLI() {
2409
+ const program = new Command();
2410
+ program.name("ghostcommit").description("Your commits, ghostwritten by AI").option("-c, --context <text>", "extra context to guide the AI").option(
2411
+ "-p, --provider <name>",
2412
+ "AI provider (groq, ollama, gemini, openai, anthropic)"
2413
+ ).option("-m, --model <name>", "model to use").option("-y, --yes", "auto-accept without interactive prompt").option("--dry-run", "show message without committing").option("--no-style", "disable style learning from repo history").action(
2414
+ wrapAction(async (options) => {
2415
+ await runCommit(options);
2416
+ })
2417
+ );
2418
+ program.command("log").description("generate a changelog from commit history").option("--from <ref>", "start ref (default: latest tag)").option("--to <ref>", "end ref (default: HEAD)").option("-o, --output <file>", "output file path").option("-f, --format <format>", "output format: markdown, json, plain").option("--dry-run", "preview changelog without writing").option(
2419
+ "-p, --provider <name>",
2420
+ "AI provider for categorizing non-conventional commits"
2421
+ ).option("-m, --model <name>", "model to use").action(
2422
+ wrapAction(async (options) => {
2423
+ await runLog(options);
2424
+ })
2425
+ );
2426
+ program.command("release").description("create a GitHub Release with generated changelog").option(
2427
+ "-t, --tag <tag>",
2428
+ "tag to create release for (default: latest tag)"
2429
+ ).option("--draft", "create as draft release").option(
2430
+ "-p, --provider <name>",
2431
+ "AI provider for categorizing non-conventional commits"
2432
+ ).option("-m, --model <name>", "model to use").action(
2433
+ wrapAction(async (options) => {
2434
+ await runRelease(options);
2435
+ })
2436
+ );
2437
+ program.command("amend").description("regenerate the last commit message with AI").option("-c, --context <text>", "extra context to guide the AI").option(
2438
+ "-p, --provider <name>",
2439
+ "AI provider (groq, ollama, gemini, openai, anthropic)"
2440
+ ).option("-m, --model <name>", "model to use").option("-y, --yes", "auto-accept without interactive prompt").option("--dry-run", "show message without amending").option("--no-style", "disable style learning from repo history").action(
2441
+ wrapAction(async (options) => {
2442
+ await runAmend(options);
2443
+ })
2444
+ );
2445
+ const hookCmd = program.command("hook").description("manage git hook for auto-generating commit messages");
2446
+ hookCmd.command("install").description("install the prepare-commit-msg git hook").action(
2447
+ wrapAction(async () => {
2448
+ await runHookInstall();
2449
+ })
2450
+ );
2451
+ hookCmd.command("uninstall").description("remove the prepare-commit-msg git hook").action(
2452
+ wrapAction(async () => {
2453
+ await runHookUninstall();
2454
+ })
2455
+ );
2456
+ hookCmd.command("run").description("(internal) called by the git hook").argument("<msgFile>", "commit message file path").argument("[source]", "commit source (message, merge, squash, etc.)").action(async (msgFile, source) => {
2457
+ try {
2458
+ await runHookRun(msgFile, source);
2459
+ } catch (error) {
2460
+ const msg = error instanceof Error ? error.message : String(error);
2461
+ console.error(chalk7.red(`Error: ${msg}`));
2462
+ process.exit(1);
2463
+ }
2464
+ });
2465
+ program.command("init").description("create a .ghostcommit.yml config file").action(
2466
+ wrapAction(async () => {
2467
+ await initConfig();
2468
+ })
2469
+ );
2470
+ return program;
2471
+ }
2472
+ async function initConfig() {
2473
+ const template = `# ghostcommit configuration
2474
+ # See https://github.com/Alessandro-Mac7/ghostcommit for docs
2475
+
2476
+ # AI provider (auto-detects: groq \u2192 ollama if not set)
2477
+ # Available: groq, ollama, gemini, openai, anthropic
2478
+ # provider: groq
2479
+
2480
+ # Model override (uses provider default if not set)
2481
+ # model: llama-3.3-70b-versatile
2482
+
2483
+ # Language for commit messages (en, it, etc.)
2484
+ # language: en
2485
+
2486
+ # Learn commit style from repo history
2487
+ learnStyle: true
2488
+ learnStyleCommits: 50
2489
+
2490
+ # Additional paths to ignore in diff analysis
2491
+ ignorePaths: []
2492
+ # - "*.generated.ts"
2493
+ # - "migrations/"
2494
+
2495
+ # Extract ticket references from branch names
2496
+ branchPrefix: true
2497
+ branchPattern: "[A-Z]+-\\\\d+"
2498
+
2499
+ # === Changelog settings ===
2500
+ changelog:
2501
+ format: markdown # markdown, json, plain
2502
+ output: CHANGELOG.md # default output file
2503
+ categories:
2504
+ - Features
2505
+ - Bug Fixes
2506
+ - Performance
2507
+ - Breaking Changes
2508
+ - Documentation
2509
+ exclude: # patterns to exclude
2510
+ - "^chore:"
2511
+ - "^ci:"
2512
+ - "^Merge"
2513
+
2514
+ # === Release settings ===
2515
+ release:
2516
+ draft: true # create as draft by default
2517
+ `;
2518
+ await writeFile5(".ghostcommit.yml", template, "utf-8");
2519
+ console.log(chalk7.green("Created .ghostcommit.yml"));
2520
+ console.log(
2521
+ chalk7.dim("Edit this file to customize ghostcommit for your project.")
2522
+ );
2523
+ }
2524
+ async function main() {
2525
+ const program = createCLI();
2526
+ const version = await getVersion();
2527
+ program.version(version, "-v, --version");
2528
+ await program.parseAsync();
2529
+ }
2530
+
2531
+ // src/env.ts
2532
+ import { readFileSync } from "fs";
2533
+ import { join as join6 } from "path";
2534
+ async function loadEnv() {
2535
+ const dirs = [];
2536
+ try {
2537
+ dirs.push(await getGitRootDir());
2538
+ } catch {
2539
+ }
2540
+ const cwd = process.cwd();
2541
+ if (!dirs.includes(cwd)) {
2542
+ dirs.push(cwd);
2543
+ }
2544
+ const fileNames = [".env.local", ".env"];
2545
+ for (const dir of dirs) {
2546
+ for (const fileName of fileNames) {
2547
+ tryLoadFile(join6(dir, fileName));
2548
+ }
2549
+ }
2550
+ }
2551
+ function tryLoadFile(filePath) {
2552
+ let content;
2553
+ try {
2554
+ content = readFileSync(filePath, "utf-8");
2555
+ } catch {
2556
+ return;
2557
+ }
2558
+ for (const line of content.split("\n")) {
2559
+ const trimmed = line.trim();
2560
+ if (!trimmed || trimmed.startsWith("#")) continue;
2561
+ const eqIndex = trimmed.indexOf("=");
2562
+ if (eqIndex === -1) continue;
2563
+ const key = trimmed.slice(0, eqIndex).trim();
2564
+ let value = trimmed.slice(eqIndex + 1).trim();
2565
+ if (value.startsWith('"') && value.endsWith('"') || value.startsWith("'") && value.endsWith("'")) {
2566
+ value = value.slice(1, -1);
2567
+ }
2568
+ if (process.env[key] === void 0) {
2569
+ process.env[key] = value;
2570
+ }
2571
+ }
2572
+ }
2573
+
2574
+ // src/index.ts
2575
+ loadEnv().then(() => main()).catch((error) => {
2576
+ const msg = error instanceof Error ? error.message : String(error);
2577
+ console.error(`ghostcommit: ${msg}`);
2578
+ process.exit(1);
2579
+ });
2580
+ //# sourceMappingURL=index.js.map