sncommit 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/src/index.tsx ADDED
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+
3
+ import "./suppress-warnings";
4
+
5
+ import { Command } from "commander";
6
+ import { render } from "ink";
7
+ import { App as BetterCommitApp } from "./components/App";
8
+ import { ConfigApp } from "./components/ConfigApp";
9
+
10
+ // Handle raw mode errors gracefully
11
+ const handleRawModeError = (error: Error, fallbackMessage: string) => {
12
+ if (error.message.includes("Raw mode is not supported")) {
13
+ console.log(fallbackMessage);
14
+ process.exit(0);
15
+ }
16
+ throw error;
17
+ };
18
+
19
+ const program = new Command();
20
+
21
+ program
22
+ .name("better-commit")
23
+ .description("AI-powered git commit message generator with beautiful TUI")
24
+ .version("1.0.0");
25
+
26
+ // Add options
27
+ program.option("-a, --all", "stage all files before committing");
28
+
29
+ // Subcommand for config
30
+ program
31
+ .command("config")
32
+ .description("Configure better-commit settings")
33
+ .action(async () => {
34
+ // Check TTY support first before doing anything
35
+ if (!process.stdin.isTTY) {
36
+ console.log("Configuration interface not supported in this terminal.");
37
+ console.log("Please try running in a compatible terminal like:");
38
+ console.log("- Windows Terminal");
39
+ console.log("- PowerShell with proper TTY support");
40
+ console.log("- Git Bash");
41
+ console.log("- WSL terminal");
42
+ process.exit(0);
43
+ return;
44
+ }
45
+
46
+ try {
47
+ let exitMessage = "";
48
+ const { waitUntilExit } = render(
49
+ <ConfigApp
50
+ onExit={(message) => {
51
+ if (message) exitMessage = message;
52
+ }}
53
+ />,
54
+ {
55
+ stdout: process.stdout,
56
+ stdin: process.stdin,
57
+ patchConsole: true,
58
+ exitOnCtrlC: false,
59
+ },
60
+ );
61
+ await waitUntilExit();
62
+ if (exitMessage) console.log(exitMessage);
63
+ process.exit(0);
64
+ } catch (error) {
65
+ handleRawModeError(
66
+ error as Error,
67
+ "Configuration interface not supported in this terminal.",
68
+ );
69
+ }
70
+ });
71
+
72
+ // Default action - commit
73
+ program.action(async () => {
74
+ const options = program.opts();
75
+
76
+ // Check TTY support first before doing any git operations
77
+ if (!process.stdin.isTTY) {
78
+ console.log("Interactive interface not supported in this terminal.");
79
+ console.log("Please try running in a compatible terminal like:");
80
+ console.log("- Windows Terminal");
81
+ console.log("- PowerShell with proper TTY support");
82
+ console.log("- Git Bash");
83
+ console.log("- WSL terminal");
84
+ process.exit(0);
85
+ return;
86
+ }
87
+
88
+ try {
89
+ // Check git status first to provide fallback behavior
90
+ const gitService = await import("./services/git").then(
91
+ (m) => new m.GitService(),
92
+ );
93
+ const isGitRepo = await gitService.isGitRepository();
94
+ if (!isGitRepo) {
95
+ console.log(
96
+ "Error: Not a git repository. Please run this command from inside a git repository.",
97
+ );
98
+ process.exit(0);
99
+ return;
100
+ }
101
+
102
+ if (options.all) {
103
+ const hasUnstaged = await gitService.hasUnstagedChanges();
104
+ if (hasUnstaged) {
105
+ await gitService.stageAll();
106
+ }
107
+ }
108
+
109
+ const hasStaged = await gitService.hasStagedChanges();
110
+ if (!hasStaged) {
111
+ const hasUnstaged = await gitService.hasUnstagedChanges();
112
+ if (hasUnstaged) {
113
+ console.log(
114
+ 'No staged files. Use "git add ." or run "better-commit -a" to stage all files.',
115
+ );
116
+ } else {
117
+ console.log("No changes to commit. Working tree is clean.");
118
+ }
119
+ process.exit(0);
120
+ return;
121
+ }
122
+
123
+ // Only try to render if we have staged files
124
+ let exitMessage = "";
125
+ const { waitUntilExit } = render(
126
+ <BetterCommitApp
127
+ addAll={options.all || false}
128
+ onExit={(message) => {
129
+ if (message) exitMessage = message;
130
+ }}
131
+ />,
132
+ {
133
+ stdout: process.stdout,
134
+ stdin: process.stdin,
135
+ patchConsole: true,
136
+ exitOnCtrlC: false,
137
+ },
138
+ );
139
+ await waitUntilExit();
140
+ if (exitMessage) console.log(exitMessage);
141
+ process.exit(0);
142
+ } catch (error) {
143
+ handleRawModeError(
144
+ error as Error,
145
+ "Interactive interface not supported in this terminal. Please try running in a compatible terminal.",
146
+ );
147
+ }
148
+ });
149
+
150
+ program.parse();
@@ -0,0 +1,128 @@
1
+ import simpleGit, { SimpleGit } from "simple-git";
2
+ import { GitFile, GitCommit } from "../types";
3
+
4
+ export class GitService {
5
+ private git: SimpleGit;
6
+
7
+ constructor() {
8
+ this.git = simpleGit();
9
+ }
10
+
11
+ async isGitRepository(): Promise<boolean> {
12
+ try {
13
+ return await this.git.checkIsRepo();
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
18
+
19
+ async getStagedFiles(): Promise<GitFile[]> {
20
+ try {
21
+ const status = await this.git.status();
22
+ const stagedFiles: GitFile[] = [];
23
+
24
+ // Add staged files
25
+ status.staged.forEach((file) => {
26
+ stagedFiles.push({
27
+ path: file,
28
+ status: "staged",
29
+ isStaged: true,
30
+ });
31
+ });
32
+
33
+ return stagedFiles;
34
+ } catch (error) {
35
+ throw new Error(`Failed to get staged files: ${error}`);
36
+ }
37
+ }
38
+
39
+ async getRecentCommits(limit: number = 50): Promise<GitCommit[]> {
40
+ try {
41
+ const log = await this.git.log({ maxCount: limit });
42
+
43
+ return log.all.map((commit) => ({
44
+ hash: commit.hash,
45
+ message: commit.message,
46
+ author: commit.author_name,
47
+ date: commit.date,
48
+ }));
49
+ } catch {
50
+ // Silently handle git history errors - don't show them to user
51
+ // This is common in new repositories with no commits
52
+ return [];
53
+ }
54
+ }
55
+
56
+ async commit(message: string): Promise<void> {
57
+ try {
58
+ await this.git.commit(message);
59
+ } catch (error) {
60
+ throw new Error(`Failed to commit: ${error}`);
61
+ }
62
+ }
63
+
64
+ async getDiff(): Promise<string> {
65
+ try {
66
+ const diff = await this.git.diff(["--cached"]);
67
+ return diff;
68
+ } catch {
69
+ // Silently fail - don't interfere with TUI
70
+ return "";
71
+ }
72
+ }
73
+
74
+ async getDiffStats(): Promise<{
75
+ added: number;
76
+ deleted: number;
77
+ modified: number;
78
+ renamed: number;
79
+ files: string[];
80
+ }> {
81
+ try {
82
+ const status = await this.git.status();
83
+ const diffStats = await this.git.diffSummary(["--cached"]);
84
+
85
+ return {
86
+ added: diffStats.insertions,
87
+ deleted: diffStats.deletions,
88
+ modified: status.modified.length,
89
+ renamed: status.renamed.length,
90
+ files: status.staged,
91
+ };
92
+ } catch {
93
+ // Silently fail - don't interfere with TUI
94
+ return { added: 0, deleted: 0, modified: 0, renamed: 0, files: [] };
95
+ }
96
+ }
97
+
98
+ async hasStagedChanges(): Promise<boolean> {
99
+ try {
100
+ const status = await this.git.status();
101
+ return status.staged.length > 0;
102
+ } catch {
103
+ return false;
104
+ }
105
+ }
106
+
107
+ async stageAll(): Promise<void> {
108
+ try {
109
+ await this.git.add(".");
110
+ } catch {
111
+ throw new Error(`Failed to stage all files`);
112
+ }
113
+ }
114
+
115
+ async hasUnstagedChanges(): Promise<boolean> {
116
+ try {
117
+ const status = await this.git.status();
118
+ return (
119
+ status.not_added.length > 0 ||
120
+ status.modified.length > 0 ||
121
+ status.created.length > 0 ||
122
+ status.deleted.length > 0
123
+ );
124
+ } catch {
125
+ return false;
126
+ }
127
+ }
128
+ }
@@ -0,0 +1,360 @@
1
+ import Groq from "groq-sdk";
2
+ import { CommitSuggestion, GitFile, GitCommit, Config } from "../types";
3
+
4
+ export class GroqService {
5
+ private client: Groq;
6
+ private config: Config;
7
+
8
+ constructor(apiKey: string, config: Config) {
9
+ this.client = new Groq({ apiKey });
10
+ this.config = config;
11
+ }
12
+
13
+ async generateCommitSuggestions(
14
+ stagedFiles: GitFile[],
15
+ diff: string,
16
+ recentCommits: GitCommit[] = [],
17
+ _diffStats?: {
18
+ added: number;
19
+ deleted: number;
20
+ modified: number;
21
+ renamed: number;
22
+ files: string[];
23
+ },
24
+ ): Promise<CommitSuggestion[]> {
25
+ const prompt = this.buildPrompt(stagedFiles, diff, recentCommits);
26
+
27
+ try {
28
+ const response = await this.client.chat.completions.create({
29
+ model: this.config.model || "llama-3.1-8b-instant",
30
+ messages: [
31
+ {
32
+ role: "system",
33
+ content:
34
+ "You are an expert at writing clear, concise, and meaningful git commit messages.\n" +
35
+ "Generate commit messages that follow best practices and are helpful for future developers.\n" +
36
+ "IMPORTANT: Output your response in strict JSON format with the following schema:\n" +
37
+ '{\n "suggestions": [\n {\n "message": "commit message here",\n "type": "feat/fix/etc",\n "description": "brief explanation"\n }\n ]\n}',
38
+ },
39
+ {
40
+ role: "user",
41
+ content: prompt,
42
+ },
43
+ ],
44
+ // response_format: { type: "json_object" }, // Not all models support this, so we rely on system prompt + parsing
45
+ max_tokens: 2048, // Increased for reasoning models
46
+ temperature: 0.7,
47
+ });
48
+
49
+ const content = response.choices[0]?.message?.content;
50
+
51
+ if (!content) {
52
+ throw new Error("No response from Groq API");
53
+ }
54
+
55
+ return this.parseSuggestions(content);
56
+ } catch (e) {
57
+ console.error("Error generating suggestions:", e);
58
+ // Fallback to mock suggestions when API fails (silently handle)
59
+ const fallbackSuggestions = this.getFallbackSuggestions(stagedFiles);
60
+ return fallbackSuggestions.map((s) => ({ ...s, isFallback: true }));
61
+ }
62
+ }
63
+
64
+ async generateCommitSuggestionsFromCustomInput(
65
+ stagedFiles: GitFile[],
66
+ diff: string,
67
+ userInput: string,
68
+ recentCommits: GitCommit[] = [],
69
+ diffStats?: {
70
+ added: number;
71
+ deleted: number;
72
+ modified: number;
73
+ renamed: number;
74
+ files: string[];
75
+ },
76
+ ): Promise<CommitSuggestion[]> {
77
+ const prompt = this.buildPromptFromCustomInput(
78
+ stagedFiles,
79
+ diff,
80
+ userInput,
81
+ recentCommits,
82
+ diffStats,
83
+ );
84
+
85
+ try {
86
+ const response = await this.client.chat.completions.create({
87
+ model: this.config.model || "llama-3.1-8b-instant",
88
+ messages: [
89
+ {
90
+ role: "system",
91
+ content:
92
+ "You are an expert at writing clear, concise, and meaningful git commit messages.\n" +
93
+ "Generate commit messages that follow best practices and are helpful for future developers.\n" +
94
+ "IMPORTANT: Output your response in strict JSON format with the following schema:\n" +
95
+ '{\n "suggestions": [\n {\n "message": "commit message here",\n "type": "feat/fix/etc",\n "description": "brief explanation"\n }\n ]\n}',
96
+ },
97
+ {
98
+ role: "user",
99
+ content: prompt,
100
+ },
101
+ ],
102
+ // response_format: { type: "json_object" },
103
+ max_tokens: 2048,
104
+ temperature: 0.7,
105
+ });
106
+
107
+ const content = response.choices[0]?.message?.content;
108
+ if (!content) {
109
+ throw new Error("No response from Groq API");
110
+ }
111
+
112
+ return this.parseSuggestions(content);
113
+ } catch (e) {
114
+ console.error("Error generating suggestions:", e);
115
+ // Fallback to mock suggestions when API fails (silently handle)
116
+ const fallbackSuggestions = this.getFallbackSuggestions(stagedFiles);
117
+ return fallbackSuggestions.map((s) => ({ ...s, isFallback: true }));
118
+ }
119
+ }
120
+
121
+ private buildPromptFromCustomInput(
122
+ stagedFiles: GitFile[],
123
+ diff: string,
124
+ userInput: string,
125
+ recentCommits: GitCommit[],
126
+ diffStats?: {
127
+ added: number;
128
+ deleted: number;
129
+ modified: number;
130
+ renamed: number;
131
+ files: string[];
132
+ },
133
+ ): string {
134
+ const filesText = stagedFiles.map((f) => `- ${f.path}`).join("\n");
135
+ const recentCommitsText = recentCommits
136
+ .slice(0, 5)
137
+ .map((c) => `- ${c.message}`)
138
+ .join("\n");
139
+
140
+ let styleInstruction = "";
141
+ switch (this.config.commitStyle) {
142
+ case "conventional":
143
+ styleInstruction =
144
+ "Use conventional commit format (feat:, fix:, docs:, etc.)";
145
+ break;
146
+ case "simple":
147
+ styleInstruction = "Keep messages simple and concise";
148
+ break;
149
+ case "detailed":
150
+ styleInstruction = "Include detailed descriptions of changes";
151
+ break;
152
+ }
153
+
154
+ const customPrompt = this.config.customPrompt
155
+ ? `\nAdditional instructions: ${this.config.customPrompt}`
156
+ : "";
157
+
158
+ const statsText = diffStats
159
+ ? `
160
+ Change statistics:
161
+ - ${diffStats.added} lines added
162
+ - ${diffStats.deleted} lines deleted
163
+ - ${diffStats.modified} files modified
164
+ - ${diffStats.renamed} files renamed
165
+ - ${diffStats.files.length} total files changed`
166
+ : "";
167
+
168
+ return `The user wants commit messages based on their description: "${userInput}"
169
+
170
+ Analyze the following git changes and generate 4 different commit message suggestions that match the user's intent.
171
+
172
+ IMPORTANT: Carefully analyze the git diff below to understand WHAT changes were actually made:
173
+ - Look for added/removed/modified lines (lines starting with +, -, or @@)
174
+ - Identify if files were added, deleted, or modified
175
+ - Understand the nature of the changes (new features, bug fixes, refactoring, etc.)
176
+ - Match the user's description: "${userInput}"
177
+
178
+ Files staged for commit:
179
+ ${filesText}${statsText}
180
+
181
+ ${diff
182
+ ? `Complete git diff (analyze this carefully to understand the actual changes):\n${diff.slice(
183
+ 0,
184
+ 2000,
185
+ )}\n${diff.length > 2000 ? "...(truncated)" : ""}`
186
+ : "No diff available"
187
+ }
188
+
189
+ ${recentCommitsText
190
+ ? `Recent commit history for reference:\n${recentCommitsText}`
191
+ : ""
192
+ }
193
+
194
+ Requirements:
195
+ - Generate exactly 4 different commit messages
196
+ - Each message should be 50-72 characters
197
+ - ${styleInstruction}
198
+ - Make them specific to the actual changes shown AND match the user's intent: "${userInput}"
199
+ - Focus on what changed, not how it changed
200
+ ${customPrompt}
201
+
202
+ Output ONLY valid JSON.`;
203
+ }
204
+
205
+ private getFallbackSuggestions(stagedFiles: GitFile[]): CommitSuggestion[] {
206
+ const fileNames =
207
+ stagedFiles.length > 0
208
+ ? stagedFiles
209
+ .map((f) => f.path)
210
+ .slice(0, 3)
211
+ .join(", ") + (stagedFiles.length > 3 ? "..." : "")
212
+ : "files";
213
+
214
+ const suggestions = [
215
+ {
216
+ message: `feat: add ${fileNames}`,
217
+ type: "feat",
218
+ description: `feat: add ${fileNames}`,
219
+ },
220
+ {
221
+ message: `fix: update ${fileNames}`,
222
+ type: "fix",
223
+ description: `fix: update ${fileNames}`,
224
+ },
225
+ {
226
+ message: `refactor: improve ${fileNames}`,
227
+ type: "refactor",
228
+ description: `refactor: improve ${fileNames}`,
229
+ },
230
+ {
231
+ message: `docs: update ${fileNames}`,
232
+ type: "docs",
233
+ description: `docs: update ${fileNames}`,
234
+ },
235
+ ];
236
+
237
+ return suggestions;
238
+ }
239
+
240
+ private buildPrompt(
241
+ stagedFiles: GitFile[],
242
+ diff: string,
243
+ recentCommits: GitCommit[],
244
+ diffStats?: {
245
+ added: number;
246
+ deleted: number;
247
+ modified: number;
248
+ renamed: number;
249
+ files: string[];
250
+ },
251
+ ): string {
252
+ const filesText = stagedFiles.map((f) => `- ${f.path}`).join("\n");
253
+ const recentCommitsText = recentCommits
254
+ .slice(0, 5)
255
+ .map((c) => `- ${c.message}`)
256
+ .join("\n");
257
+
258
+ let styleInstruction = "";
259
+ switch (this.config.commitStyle) {
260
+ case "conventional":
261
+ styleInstruction =
262
+ "Use conventional commit format (feat:, fix:, docs:, etc.)";
263
+ break;
264
+ case "simple":
265
+ styleInstruction = "Keep messages simple and concise";
266
+ break;
267
+ case "detailed":
268
+ styleInstruction = "Include detailed descriptions of changes";
269
+ break;
270
+ }
271
+
272
+ const customPrompt = this.config.customPrompt
273
+ ? `\nAdditional instructions: ${this.config.customPrompt}`
274
+ : "";
275
+
276
+ const statsText = diffStats
277
+ ? `
278
+ Change statistics:
279
+ - ${diffStats.added} lines added
280
+ - ${diffStats.deleted} lines deleted
281
+ - ${diffStats.modified} files modified
282
+ - ${diffStats.renamed} files renamed
283
+ - ${diffStats.files.length} total files changed`
284
+ : "";
285
+
286
+ return `Analyze the following git changes and generate 4 different commit message suggestions.
287
+
288
+ IMPORTANT: Carefully analyze the git diff below to understand WHAT changes were actually made:
289
+ - Look for added/removed/modified lines (lines starting with +, -, or @@)
290
+ - Identify if files were added, deleted, or modified
291
+ - Understand the nature of the changes (new features, bug fixes, refactoring, etc.)
292
+ - DO NOT assume all changes are "add" operations - check the diff carefully
293
+
294
+ Files staged for commit:
295
+ ${filesText}${statsText}
296
+
297
+ ${diff
298
+ ? `Complete git diff (analyze this carefully to understand the actual changes):\n${diff.slice(
299
+ 0,
300
+ 2000,
301
+ )}\n${diff.length > 2000 ? "...(truncated)" : ""}`
302
+ : "No diff available"
303
+ }
304
+
305
+ ${recentCommitsText
306
+ ? `Recent commit history for reference:\n${recentCommitsText}`
307
+ : ""
308
+ }
309
+
310
+ Requirements:
311
+ - Generate exactly 4 different commit messages
312
+ - Each message should be 50-72 characters
313
+ - ${styleInstruction}
314
+ - Make them specific to the actual changes shown
315
+ - Focus on what changed, not how it changed
316
+ ${customPrompt}
317
+
318
+ Output ONLY valid JSON.`;
319
+ }
320
+
321
+ private parseSuggestions(content: string): CommitSuggestion[] {
322
+ try {
323
+ // Find the JSON object in the content (it might be wrapped in markdown or have extra text)
324
+ const jsonMatch = content.match(/\{[\s\S]*\}/);
325
+ if (jsonMatch) {
326
+ const parsed = JSON.parse(jsonMatch[0]);
327
+ if (parsed.suggestions && Array.isArray(parsed.suggestions)) {
328
+ return parsed.suggestions.map((s: any) => ({
329
+ message: s.message || "",
330
+ type: s.type || this.extractType(s.message || ""),
331
+ description: s.description || s.message || "",
332
+ })).slice(0, 4);
333
+ }
334
+ }
335
+ throw new Error("Invalid JSON structure");
336
+ } catch (e) {
337
+ console.warn("Failed to parse JSON suggestions, falling back to basic parsing:", e);
338
+ // Fallback to basic line parsing if JSON fails
339
+ const lines = content.split("\n").filter((line) => line.trim());
340
+ const suggestions: CommitSuggestion[] = [];
341
+
342
+ for (const line of lines) {
343
+ const match = line.match(/^\d+\.\s*(.+)$/);
344
+ if (match && suggestions.length < 4) {
345
+ suggestions.push({
346
+ message: match[1].trim(),
347
+ type: this.extractType(match[1]),
348
+ description: match[1].trim(),
349
+ });
350
+ }
351
+ }
352
+ return suggestions;
353
+ }
354
+ }
355
+
356
+ private extractType(message: string): string {
357
+ const match = message.match(/^(\w+):/);
358
+ return match ? match[1] : "feat";
359
+ }
360
+ }
@@ -0,0 +1,18 @@
1
+ // Suppress punycode and url.parse deprecation warnings
2
+ const originalEmit = process.emit;
3
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
4
+ process.emit = function (name: any, data: any, ...args: any[]): boolean {
5
+ if (
6
+ name === "warning" &&
7
+ typeof data === "object" &&
8
+ data &&
9
+ data.name === "DeprecationWarning" &&
10
+ data.message &&
11
+ (data.message.includes("punycode") || data.message.includes("url.parse"))
12
+ ) {
13
+ return false;
14
+ }
15
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type
16
+ return (originalEmit as Function).apply(process, [name, data, ...args]);
17
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
18
+ } as any;
@@ -0,0 +1,36 @@
1
+ export interface Config {
2
+ groqApiKey?: string;
3
+ model?: string;
4
+ maxHistoryCommits?: number;
5
+ commitStyle?: "conventional" | "simple" | "detailed";
6
+ language?: string;
7
+ customPrompt?: string;
8
+ }
9
+
10
+ export interface GitFile {
11
+ path: string;
12
+ status: string;
13
+ isStaged: boolean;
14
+ }
15
+
16
+ export interface CommitSuggestion {
17
+ message: string;
18
+ type?: string;
19
+ description?: string;
20
+ isFallback?: boolean;
21
+ }
22
+
23
+ export interface GitCommit {
24
+ hash: string;
25
+ message: string;
26
+ author: string;
27
+ date: string;
28
+ }
29
+
30
+ export interface AppState {
31
+ stagedFiles: GitFile[];
32
+ suggestions: CommitSuggestion[];
33
+ selectedIndex: number;
34
+ isLoading: boolean;
35
+ error?: string;
36
+ }