@gilbert_oliveira/commit-wizard 1.2.2 → 2.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.
@@ -0,0 +1,381 @@
1
+ import { log } from '@clack/prompts';
2
+ import { loadConfig, validateConfig } from '../config/index.ts';
3
+ import {
4
+ isGitRepository,
5
+ getGitStatus,
6
+ getDiffStats,
7
+ executeCommit,
8
+ executeFileCommit,
9
+ } from '../git/index.ts';
10
+ import { generateWithRetry } from './openai.ts';
11
+ import {
12
+ showCommitPreview,
13
+ editCommitMessage,
14
+ copyToClipboard,
15
+ showCommitResult,
16
+ showCancellation,
17
+ selectFilesForCommit,
18
+ askContinueCommits,
19
+ } from '../ui/index.ts';
20
+ import { chooseSplitMode } from '../ui/smart-split.ts';
21
+ import { handleSmartSplitMode } from './smart-split.ts';
22
+ import { initializeCache } from './cache.ts';
23
+ import type { CLIArgs } from '../utils/args.ts';
24
+ import type { Config } from '../config/index.ts';
25
+
26
+ export async function main(
27
+ args: CLIArgs = {
28
+ silent: false,
29
+ yes: false,
30
+ auto: false,
31
+ split: false,
32
+ smartSplit: false,
33
+ dryRun: false,
34
+ help: false,
35
+ version: false,
36
+ }
37
+ ) {
38
+ if (!args.silent) {
39
+ log.info('🚀 Commit Wizard iniciado!');
40
+ }
41
+
42
+ // Verificar se estamos em um repositório Git
43
+ if (!isGitRepository()) {
44
+ log.error('❌ Não foi encontrado um repositório Git neste diretório.');
45
+ if (!args.silent) {
46
+ log.info(
47
+ '💡 Execute o comando em um diretório com repositório Git inicializado.'
48
+ );
49
+ }
50
+ process.exit(1);
51
+ }
52
+
53
+ // Carregar e validar configuração
54
+ if (!args.silent) {
55
+ log.info('⚙️ Carregando configuração...');
56
+ }
57
+ const config = loadConfig();
58
+
59
+ // Inicializar cache global
60
+ initializeCache(config);
61
+
62
+ // Sobrescrever configuração com argumentos CLI
63
+ if (args.split) {
64
+ config.splitCommits = true;
65
+ }
66
+ if (args.dryRun) {
67
+ config.dryRun = true;
68
+ }
69
+
70
+ const configErrors = validateConfig(config);
71
+
72
+ if (configErrors.length > 0) {
73
+ log.error('❌ Erros na configuração:');
74
+ configErrors.forEach((error) => log.error(` • ${error}`));
75
+ process.exit(1);
76
+ }
77
+
78
+ if (!args.silent) {
79
+ log.success(
80
+ `✅ Configuração carregada (modelo: ${config.openai.model}, idioma: ${config.language})`
81
+ );
82
+ }
83
+
84
+ // Verificar arquivos staged
85
+ if (!args.silent) {
86
+ log.info('📋 Verificando arquivos staged...');
87
+ }
88
+ const gitStatus = getGitStatus();
89
+
90
+ if (!gitStatus.hasStaged) {
91
+ log.warn('⚠️ Nenhum arquivo foi encontrado no stage.');
92
+ if (!args.silent) {
93
+ log.info(
94
+ '💡 Use `git add <arquivo>` para adicionar arquivos ao stage antes de gerar o commit.'
95
+ );
96
+ }
97
+ process.exit(0);
98
+ }
99
+
100
+ const diffStats = getDiffStats();
101
+ if (!args.silent) {
102
+ log.success(
103
+ `✅ Encontrados ${gitStatus.stagedFiles.length} arquivo(s) staged:`
104
+ );
105
+ gitStatus.stagedFiles.forEach((file) => log.info(` 📄 ${file}`));
106
+ log.info(
107
+ `📊 Estatísticas: +${diffStats.added} -${diffStats.removed} linhas`
108
+ );
109
+ }
110
+
111
+ // Modo Split: escolher entre smart split e split manual
112
+ if (config.splitCommits || args.smartSplit) {
113
+ if (args.yes) {
114
+ // Modo automático: usar smart split
115
+ return await handleSmartSplitMode(gitStatus, config, args);
116
+ } else {
117
+ // Modo interativo: perguntar qual tipo de split
118
+ const splitAction = await chooseSplitMode();
119
+
120
+ switch (splitAction.action) {
121
+ case 'proceed':
122
+ return await handleSmartSplitMode(gitStatus, config, args);
123
+ case 'manual':
124
+ return await handleSplitMode(gitStatus, config, args);
125
+ case 'cancel':
126
+ showCancellation();
127
+ return;
128
+ }
129
+ }
130
+ }
131
+
132
+ // Gerar mensagem de commit com OpenAI
133
+ if (!args.silent) {
134
+ log.info('🤖 Gerando mensagem de commit com IA...');
135
+ }
136
+
137
+ const result = await generateWithRetry(
138
+ gitStatus.diff,
139
+ config,
140
+ gitStatus.stagedFiles
141
+ );
142
+
143
+ if (!result.success) {
144
+ log.error(`❌ Erro ao gerar commit: ${result.error}`);
145
+ process.exit(1);
146
+ }
147
+
148
+ if (!result.suggestion) {
149
+ log.error('❌ Nenhuma sugestão foi gerada');
150
+ process.exit(1);
151
+ }
152
+
153
+ if (!args.silent) {
154
+ log.success('✨ Mensagem de commit gerada!');
155
+ }
156
+
157
+ // Modo Dry Run: apenas mostrar mensagem
158
+ if (config.dryRun) {
159
+ log.info('🔍 Modo Dry Run - Mensagem gerada:');
160
+ log.info(`"${result.suggestion.message}"`);
161
+ log.info('💡 Execute sem --dry-run para fazer o commit');
162
+ return;
163
+ }
164
+
165
+ // Modo automático: commit direto
166
+ if (args.yes) {
167
+ const commitResult = executeCommit(result.suggestion.message);
168
+ showCommitResult(
169
+ commitResult.success,
170
+ commitResult.hash,
171
+ commitResult.error
172
+ );
173
+ return;
174
+ }
175
+
176
+ // Interface interativa
177
+ while (true) {
178
+ const uiAction = await showCommitPreview(result.suggestion);
179
+
180
+ switch (uiAction.action) {
181
+ case 'commit': {
182
+ // Commit direto com mensagem gerada
183
+ const commitResult = executeCommit(result.suggestion.message);
184
+ showCommitResult(
185
+ commitResult.success,
186
+ commitResult.hash,
187
+ commitResult.error
188
+ );
189
+ return;
190
+ }
191
+ case 'edit': {
192
+ // Editar mensagem
193
+ const editAction = await editCommitMessage(result.suggestion.message);
194
+ if (editAction.action === 'cancel') {
195
+ showCancellation();
196
+ return;
197
+ }
198
+ if (editAction.action === 'commit' && editAction.message) {
199
+ const editCommitResult = executeCommit(editAction.message);
200
+ showCommitResult(
201
+ editCommitResult.success,
202
+ editCommitResult.hash,
203
+ editCommitResult.error
204
+ );
205
+ return;
206
+ }
207
+ break;
208
+ }
209
+ case 'copy': {
210
+ // Copiar para clipboard
211
+ await copyToClipboard(result.suggestion.message);
212
+ if (!args.silent) {
213
+ log.info(
214
+ '🎯 Você pode usar a mensagem copiada com: git commit -m "mensagem"'
215
+ );
216
+ }
217
+ return;
218
+ }
219
+ case 'cancel': {
220
+ // Cancelar operação
221
+ showCancellation();
222
+ return;
223
+ }
224
+ }
225
+ }
226
+ }
227
+
228
+ async function handleSplitMode(gitStatus: any, config: any, args: CLIArgs) {
229
+ if (!args.silent) {
230
+ log.info('🔄 Modo Split ativado - Commits separados por arquivo');
231
+ }
232
+
233
+ let remainingFiles = [
234
+ ...(gitStatus as { stagedFiles: string[] }).stagedFiles,
235
+ ];
236
+
237
+ while (remainingFiles.length > 0) {
238
+ // Selecionar arquivos para este commit
239
+ const selectedFiles = args.yes
240
+ ? [remainingFiles[0]] // Modo automático: um arquivo por vez
241
+ : await selectFilesForCommit(remainingFiles);
242
+
243
+ if (selectedFiles.length === 0) {
244
+ if (!args.silent) {
245
+ log.info('❌ Nenhum arquivo selecionado');
246
+ }
247
+ break;
248
+ }
249
+
250
+ // Gerar diff apenas dos arquivos selecionados
251
+ const { getFileDiff } = await import('../git/index.ts');
252
+ const fileDiffs = selectedFiles
253
+ .filter((file): file is string => file !== undefined)
254
+ .map((file) => {
255
+ try {
256
+ return getFileDiff(file);
257
+ } catch (error) {
258
+ log.error(
259
+ `❌ Erro ao obter diff do arquivo ${file}: ${error instanceof Error ? error.message : 'Erro desconhecido'}`
260
+ );
261
+ return '';
262
+ }
263
+ })
264
+ .filter((diff) => diff.length > 0)
265
+ .join('\n');
266
+
267
+ if (!fileDiffs) {
268
+ if (!args.silent) {
269
+ log.warn('⚠️ Nenhum diff encontrado para os arquivos selecionados');
270
+ }
271
+ remainingFiles = remainingFiles.filter(
272
+ (file) => !selectedFiles.includes(file)
273
+ );
274
+ continue;
275
+ }
276
+
277
+ if (!args.silent) {
278
+ log.info(`🤖 Gerando commit para: ${selectedFiles.join(', ')}`);
279
+ }
280
+
281
+ const result = await generateWithRetry(fileDiffs, config, selectedFiles.filter((file): file is string => file !== undefined));
282
+
283
+ if (!result.success) {
284
+ log.error(`❌ Erro ao gerar commit: ${result.error}`);
285
+ remainingFiles = remainingFiles.filter(
286
+ (file) => !selectedFiles.includes(file)
287
+ );
288
+ continue;
289
+ }
290
+
291
+ if (!result.suggestion) {
292
+ log.error('❌ Nenhuma sugestão foi gerada');
293
+ remainingFiles = remainingFiles.filter(
294
+ (file) => !selectedFiles.includes(file)
295
+ );
296
+ continue;
297
+ }
298
+
299
+ // Modo Dry Run: apenas mostrar mensagem
300
+ if ((config as Config).dryRun) {
301
+ log.info(`🔍 Dry Run - Mensagem para ${selectedFiles.join(', ')}:`);
302
+ log.info(`"${result.suggestion.message}"`);
303
+ remainingFiles = remainingFiles.filter(
304
+ (file) => !selectedFiles.includes(file)
305
+ );
306
+ continue;
307
+ }
308
+
309
+ // Modo automático: commit direto
310
+ if (args.yes) {
311
+ // Para múltiplos arquivos, usar commit normal
312
+ // Para arquivo único, usar executeFileCommit
313
+ const commitResult =
314
+ selectedFiles.length === 1 && selectedFiles[0]
315
+ ? await executeFileCommit(selectedFiles[0], result.suggestion.message)
316
+ : await executeCommit(result.suggestion.message);
317
+
318
+ showCommitResult(
319
+ commitResult.success,
320
+ commitResult.hash,
321
+ commitResult.error
322
+ );
323
+ } else {
324
+ // Interface interativa para este commit
325
+ const uiAction = await showCommitPreview(result.suggestion);
326
+
327
+ if (uiAction.action === 'commit') {
328
+ const commitResult =
329
+ selectedFiles.length === 1 && selectedFiles[0]
330
+ ? await executeFileCommit(
331
+ selectedFiles[0],
332
+ result.suggestion.message
333
+ )
334
+ : await executeCommit(result.suggestion.message);
335
+ showCommitResult(
336
+ commitResult.success,
337
+ commitResult.hash,
338
+ commitResult.error
339
+ );
340
+ } else if (uiAction.action === 'edit') {
341
+ const editAction = await editCommitMessage(result.suggestion.message);
342
+ if (editAction.action === 'commit' && editAction.message) {
343
+ const commitResult =
344
+ selectedFiles.length === 1 && selectedFiles[0]
345
+ ? await executeFileCommit(selectedFiles[0], editAction.message)
346
+ : await executeCommit(editAction.message);
347
+ showCommitResult(
348
+ commitResult.success,
349
+ commitResult.hash,
350
+ commitResult.error
351
+ );
352
+ }
353
+ } else if (uiAction.action === 'copy') {
354
+ await copyToClipboard(result.suggestion.message);
355
+ if (!args.silent) {
356
+ log.info('🎯 Mensagem copiada para clipboard');
357
+ }
358
+ } else if (uiAction.action === 'cancel') {
359
+ showCancellation();
360
+ return;
361
+ }
362
+ }
363
+
364
+ // Remover arquivos processados
365
+ remainingFiles = remainingFiles.filter(
366
+ (file) => !selectedFiles.includes(file)
367
+ );
368
+
369
+ // Perguntar se quer continuar (exceto em modo automático)
370
+ if (remainingFiles.length > 0 && !args.yes) {
371
+ const continueCommits = await askContinueCommits(remainingFiles);
372
+ if (!continueCommits) {
373
+ break;
374
+ }
375
+ }
376
+ }
377
+
378
+ if (!args.silent) {
379
+ log.success('✅ Modo Split concluído!');
380
+ }
381
+ }
@@ -0,0 +1,336 @@
1
+ import type { Config } from '../config/index.ts';
2
+
3
+ export interface CommitSuggestion {
4
+ message: string;
5
+ type:
6
+ | 'feat'
7
+ | 'fix'
8
+ | 'docs'
9
+ | 'style'
10
+ | 'refactor'
11
+ | 'test'
12
+ | 'chore'
13
+ | 'build'
14
+ | 'ci';
15
+ confidence: number;
16
+ }
17
+
18
+ export interface OpenAIResponse {
19
+ success: boolean;
20
+ suggestion?: CommitSuggestion;
21
+ error?: string;
22
+ }
23
+
24
+ /**
25
+ * Constrói o prompt para a OpenAI baseado no diff e configurações (otimizado)
26
+ */
27
+ export function buildPrompt(
28
+ diff: string,
29
+ config: Config,
30
+ filenames: string[]
31
+ ): string {
32
+ const language = config.language === 'pt' ? 'português' : 'english';
33
+ const styleInstructions = getStyleInstructions(
34
+ config.commitStyle,
35
+ config.language
36
+ );
37
+
38
+ // Limitar tamanho do diff para economizar tokens
39
+ const maxDiffLength = 6000;
40
+ const truncatedDiff = diff.length > maxDiffLength
41
+ ? diff.substring(0, maxDiffLength) + '\n... (diff truncado)'
42
+ : diff;
43
+
44
+ // Simplificar lista de arquivos se houver muitos
45
+ const fileList = filenames.length > 10
46
+ ? `${filenames.length} arquivos: ${filenames.slice(0, 5).join(', ')}...`
47
+ : filenames.join(', ');
48
+
49
+ const prompt = `Gere mensagem de commit em ${language} (${config.commitStyle}).
50
+
51
+ Arquivos: ${fileList}
52
+
53
+ ${styleInstructions}
54
+
55
+ Diff:
56
+ \`\`\`
57
+ ${truncatedDiff}
58
+ \`\`\`
59
+
60
+ Mensagem:`;
61
+
62
+ return prompt;
63
+ }
64
+
65
+ /**
66
+ * Obtém instruções específicas baseadas no estilo de commit
67
+ */
68
+ function getStyleInstructions(style: string, language: string): string {
69
+ const instructions = {
70
+ pt: {
71
+ conventional: `- Use formato: tipo(escopo): descrição
72
+ - Tipos válidos: feat, fix, docs, style, refactor, test, chore, build, ci
73
+ - Exemplo: "feat(auth): adicionar validação de email"
74
+ - Mantenha a primeira linha com até 50 caracteres`,
75
+
76
+ simple: `- Use formato simples e direto
77
+ - Comece com verbo no infinitivo
78
+ - Exemplo: "corrigir validação de formulário"
79
+ - Máximo 50 caracteres`,
80
+
81
+ detailed: `- Primeira linha: resumo em até 50 caracteres
82
+ - Se necessário, adicione corpo explicativo
83
+ - Use presente do indicativo
84
+ - Seja descritivo mas conciso`,
85
+ },
86
+ en: {
87
+ conventional: `- Use format: type(scope): description
88
+ - Valid types: feat, fix, docs, style, refactor, test, chore, build, ci
89
+ - Example: "feat(auth): add email validation"
90
+ - Keep first line under 50 characters`,
91
+
92
+ simple: `- Use simple and direct format
93
+ - Start with imperative verb
94
+ - Example: "fix form validation"
95
+ - Maximum 50 characters`,
96
+
97
+ detailed: `- First line: summary under 50 characters
98
+ - Add explanatory body if needed
99
+ - Use imperative mood
100
+ - Be descriptive but concise`,
101
+ },
102
+ };
103
+
104
+ const lang = language === 'pt' ? 'pt' : 'en';
105
+ return (
106
+ instructions[lang][style as keyof typeof instructions.pt] ||
107
+ instructions[lang].conventional
108
+ );
109
+ }
110
+
111
+ /**
112
+ * Extrai o tipo de commit da mensagem gerada pela OpenAI
113
+ */
114
+ export function extractCommitTypeFromMessage(
115
+ message: string
116
+ ): CommitSuggestion['type'] | null {
117
+ // Padrões para detectar tipos de commit
118
+ const typePatterns = {
119
+ feat: /^(feat|feature)(\([^)]+\))?:/i,
120
+ fix: /^(fix|bugfix)(\([^)]+\))?:/i,
121
+ docs: /^(docs|documentation)(\([^)]+\))?:/i,
122
+ style: /^(style|format)(\([^)]+\))?:/i,
123
+ refactor: /^(refactor|refactoring)(\([^)]+\))?:/i,
124
+ test: /^(test|testing)(\([^)]+\))?:/i,
125
+ chore: /^(chore|maintenance)(\([^)]+\))?:/i,
126
+ build: /^(build|ci)(\([^)]+\))?:/i,
127
+ ci: /^(ci|continuous-integration)(\([^)]+\))?:/i,
128
+ };
129
+
130
+ for (const [type, pattern] of Object.entries(typePatterns)) {
131
+ if (pattern.test(message)) {
132
+ return type as CommitSuggestion['type'];
133
+ }
134
+ }
135
+
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * Detecta o tipo de commit baseado no diff
141
+ */
142
+ export function detectCommitType(
143
+ diff: string,
144
+ filenames: string[]
145
+ ): CommitSuggestion['type'] {
146
+ const diffLower = diff.toLowerCase();
147
+ const filesStr = filenames.join(' ').toLowerCase();
148
+
149
+ // Testes
150
+ if (
151
+ filesStr.includes('test') ||
152
+ filesStr.includes('spec') ||
153
+ diffLower.includes('test(')
154
+ ) {
155
+ return 'test';
156
+ }
157
+
158
+ // Documentação
159
+ if (
160
+ filesStr.includes('readme') ||
161
+ filesStr.includes('.md') ||
162
+ filesStr.includes('docs')
163
+ ) {
164
+ return 'docs';
165
+ }
166
+
167
+ // Build/CI
168
+ if (
169
+ filesStr.includes('package.json') ||
170
+ filesStr.includes('dockerfile') ||
171
+ filesStr.includes('.yml') ||
172
+ filesStr.includes('.yaml') ||
173
+ filesStr.includes('webpack') ||
174
+ filesStr.includes('tsconfig')
175
+ ) {
176
+ return 'build';
177
+ }
178
+
179
+ // Styles
180
+ if (
181
+ filesStr.includes('.css') ||
182
+ filesStr.includes('.scss') ||
183
+ diffLower.includes('style') ||
184
+ diffLower.includes('format')
185
+ ) {
186
+ return 'style';
187
+ }
188
+
189
+ // Fixes
190
+ if (
191
+ diffLower.includes('fix') ||
192
+ diffLower.includes('bug') ||
193
+ diffLower.includes('error') ||
194
+ diffLower.includes('issue')
195
+ ) {
196
+ return 'fix';
197
+ }
198
+
199
+ // Features (padrão para novas funcionalidades)
200
+ if (
201
+ diffLower.includes('add') ||
202
+ diffLower.includes('new') ||
203
+ diffLower.includes('create') ||
204
+ diffLower.includes('implement')
205
+ ) {
206
+ return 'feat';
207
+ }
208
+
209
+ // Refactor
210
+ if (
211
+ diffLower.includes('refactor') ||
212
+ diffLower.includes('restructure') ||
213
+ diffLower.includes('rename')
214
+ ) {
215
+ return 'refactor';
216
+ }
217
+
218
+ // Default
219
+ return 'chore';
220
+ }
221
+
222
+ /**
223
+ * Consome a API da OpenAI para gerar mensagem de commit
224
+ */
225
+ export async function generateCommitMessage(
226
+ diff: string,
227
+ config: Config,
228
+ filenames: string[]
229
+ ): Promise<OpenAIResponse> {
230
+ try {
231
+ if (!config.openai.apiKey) {
232
+ return {
233
+ success: false,
234
+ error:
235
+ 'Chave da OpenAI não encontrada. Configure OPENAI_API_KEY nas variáveis de ambiente.',
236
+ };
237
+ }
238
+
239
+ const prompt = buildPrompt(diff, config, filenames);
240
+
241
+ const response = await fetch('https://api.openai.com/v1/chat/completions', {
242
+ method: 'POST',
243
+ headers: {
244
+ Authorization: `Bearer ${config.openai.apiKey}`,
245
+ 'Content-Type': 'application/json',
246
+ },
247
+ body: JSON.stringify({
248
+ model: config.openai.model,
249
+ messages: [
250
+ {
251
+ role: 'user',
252
+ content: prompt,
253
+ },
254
+ ],
255
+ max_tokens: Math.min(config.openai.maxTokens, 150), // Limitar para economizar tokens
256
+ temperature: config.openai.temperature,
257
+ }),
258
+ });
259
+
260
+ if (!response.ok) {
261
+ const errorData = (await response.json().catch(() => ({}))) as any;
262
+ return {
263
+ success: false,
264
+ error: `Erro da OpenAI (${response.status}): ${errorData.error?.message || 'Erro desconhecido'}`,
265
+ };
266
+ }
267
+
268
+ const data = (await response.json()) as any;
269
+ let message = data.choices?.[0]?.message?.content?.trim();
270
+
271
+ if (!message) {
272
+ return {
273
+ success: false,
274
+ error: 'OpenAI retornou resposta vazia',
275
+ };
276
+ }
277
+
278
+ // Remover backticks se presentes
279
+ message = message.replace(/^```\s*/, '').replace(/\s*```$/, '');
280
+
281
+ // Remover quebras de linha extras
282
+ message = message.trim();
283
+
284
+ // Extrair tipo da mensagem gerada pela OpenAI
285
+ const extractedType = extractCommitTypeFromMessage(message);
286
+ const fallbackType = detectCommitType(diff, filenames);
287
+
288
+ return {
289
+ success: true,
290
+ suggestion: {
291
+ message,
292
+ type: extractedType || fallbackType,
293
+ confidence: 0.8, // Placeholder - pode ser melhorado
294
+ },
295
+ };
296
+ } catch (error) {
297
+ return {
298
+ success: false,
299
+ error: `Erro ao conectar com OpenAI: ${error instanceof Error ? error.message : 'Erro desconhecido'}`,
300
+ };
301
+ }
302
+ }
303
+
304
+ /**
305
+ * Gera mensagem com retry em caso de falha
306
+ */
307
+ export async function generateWithRetry(
308
+ diff: string,
309
+ config: Config,
310
+ filenames: string[],
311
+ maxRetries: number = 3
312
+ ): Promise<OpenAIResponse> {
313
+ let lastError = '';
314
+
315
+ for (let i = 0; i < maxRetries; i++) {
316
+ const result = await generateCommitMessage(diff, config, filenames);
317
+
318
+ if (result.success) {
319
+ return result;
320
+ }
321
+
322
+ lastError = result.error || 'Erro desconhecido';
323
+
324
+ // Aguardar antes de tentar novamente (exponential backoff)
325
+ if (i < maxRetries - 1) {
326
+ await new Promise((resolve) =>
327
+ setTimeout(resolve, Math.pow(2, i) * 1000)
328
+ );
329
+ }
330
+ }
331
+
332
+ return {
333
+ success: false,
334
+ error: `Falha após ${maxRetries} tentativas. Último erro: ${lastError}`,
335
+ };
336
+ }