@misaelabanto/commita 0.1.2
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/.claude/settings.local.json +10 -0
- package/.commita.example +22 -0
- package/.cursor/rules/use-bun-instead-of-node-vite-npm-pnpm.mdc +111 -0
- package/.github/workflows/release.yml +103 -0
- package/README.md +388 -0
- package/index.ts +7 -0
- package/install.sh +113 -0
- package/package.json +40 -0
- package/src/ai/ai.service.ts +124 -0
- package/src/ai/commit-type-analyzer.ts +103 -0
- package/src/ai/emoji-mapper.ts +29 -0
- package/src/cli/commit-handler.ts +185 -0
- package/src/cli/index.ts +58 -0
- package/src/cli/set-handler.ts +134 -0
- package/src/config/config.loader.ts +151 -0
- package/src/config/config.types.ts +22 -0
- package/src/config/config.writer.ts +204 -0
- package/src/config/prompt-templates.ts +55 -0
- package/src/git/file-grouper.ts +142 -0
- package/src/git/git.service.ts +147 -0
- package/src/git/project-detector.ts +89 -0
- package/src/utils/pattern-matcher.ts +39 -0
- package/tsconfig.json +26 -0
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { ConfigWriter } from '@/config/config.writer.ts';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { createInterface } from 'readline';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
|
|
7
|
+
export interface SetOptions {
|
|
8
|
+
local?: boolean;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
const SENSITIVE_KEYS = ['OPENAI_API_KEY', 'GEMINI_API_KEY'];
|
|
12
|
+
|
|
13
|
+
export class SetHandler {
|
|
14
|
+
private configWriter: ConfigWriter;
|
|
15
|
+
|
|
16
|
+
constructor() {
|
|
17
|
+
this.configWriter = new ConfigWriter();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async execute(keyValue: string, options: SetOptions): Promise<void> {
|
|
21
|
+
const { key, value } = this.parseKeyValue(keyValue);
|
|
22
|
+
|
|
23
|
+
let finalValue = value;
|
|
24
|
+
if (!finalValue) {
|
|
25
|
+
finalValue = await this.promptForValue(key);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const configPath = this.getConfigPath(options.local || false);
|
|
29
|
+
await this.configWriter.set(key, finalValue, configPath);
|
|
30
|
+
|
|
31
|
+
console.log(chalk.green('\n✓ Configuration updated successfully'));
|
|
32
|
+
console.log(` ${chalk.cyan(key)}=${chalk.green(this.maskSensitiveValue(key, finalValue))} set in ${chalk.gray(configPath)}\n`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private parseKeyValue(keyValue: string): { key: string; value?: string } {
|
|
36
|
+
const eqIndex = keyValue.indexOf('=');
|
|
37
|
+
|
|
38
|
+
if (eqIndex === -1) {
|
|
39
|
+
// No value provided, will prompt interactively
|
|
40
|
+
return {
|
|
41
|
+
key: keyValue.trim(),
|
|
42
|
+
value: undefined,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const key = keyValue.substring(0, eqIndex).trim();
|
|
47
|
+
const value = keyValue.substring(eqIndex + 1).trim();
|
|
48
|
+
|
|
49
|
+
if (!key) {
|
|
50
|
+
throw new Error('Key cannot be empty');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { key, value };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private getConfigPath(isLocal: boolean): string {
|
|
57
|
+
if (isLocal) {
|
|
58
|
+
return join(process.cwd(), '.commita');
|
|
59
|
+
}
|
|
60
|
+
return join(homedir(), '.commita');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async promptForValue(key: string): Promise<string> {
|
|
64
|
+
if (!process.stdin.isTTY) {
|
|
65
|
+
throw new Error(`Value required for key '${key}'. Use format: commita set KEY=value`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const isSensitive = this.isSensitiveKey(key);
|
|
69
|
+
const prompt = `Enter value for ${chalk.cyan(key)}${isSensitive ? ' (hidden)' : ''}: `;
|
|
70
|
+
|
|
71
|
+
return new Promise((resolve, reject) => {
|
|
72
|
+
const readline = createInterface({
|
|
73
|
+
input: process.stdin,
|
|
74
|
+
output: isSensitive ? process.stderr : process.stdout,
|
|
75
|
+
terminal: true,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (isSensitive) {
|
|
79
|
+
// Hide input for sensitive keys
|
|
80
|
+
process.stdin.setRawMode?.(true);
|
|
81
|
+
process.stderr.write(prompt);
|
|
82
|
+
} else {
|
|
83
|
+
readline.question(prompt, () => {});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
let value = '';
|
|
87
|
+
|
|
88
|
+
process.stdin.on('data', (buffer) => {
|
|
89
|
+
const char = buffer.toString();
|
|
90
|
+
|
|
91
|
+
if (char === '\n' || char === '\r') {
|
|
92
|
+
if (isSensitive) {
|
|
93
|
+
process.stdin.setRawMode?.(false);
|
|
94
|
+
process.stderr.write('\n');
|
|
95
|
+
}
|
|
96
|
+
readline.close();
|
|
97
|
+
resolve(value);
|
|
98
|
+
} else if (char === '\u0003') {
|
|
99
|
+
// Handle Ctrl+C
|
|
100
|
+
if (isSensitive) {
|
|
101
|
+
process.stdin.setRawMode?.(false);
|
|
102
|
+
}
|
|
103
|
+
readline.close();
|
|
104
|
+
reject(new Error('Input cancelled'));
|
|
105
|
+
} else if (char === '\u007f') {
|
|
106
|
+
// Handle backspace
|
|
107
|
+
value = value.slice(0, -1);
|
|
108
|
+
} else {
|
|
109
|
+
value += char;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
readline.once('close', () => {
|
|
114
|
+
if (!value) {
|
|
115
|
+
reject(new Error('No value provided'));
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private isSensitiveKey(key: string): boolean {
|
|
122
|
+
return SENSITIVE_KEYS.includes(key.toUpperCase());
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private maskSensitiveValue(key: string, value: string): string {
|
|
126
|
+
if (!this.isSensitiveKey(key)) {
|
|
127
|
+
return value;
|
|
128
|
+
}
|
|
129
|
+
if (value.length <= 4) {
|
|
130
|
+
return '*'.repeat(value.length);
|
|
131
|
+
}
|
|
132
|
+
return value.substring(0, 2) + '*'.repeat(value.length - 4) + value.substring(value.length - 2);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type { CommitaConfig, CommitStyle, PromptStyle, Provider } from '@/config/config.types.ts';
|
|
2
|
+
import { DEFAULT_CONFIG } from '@/config/config.types.ts';
|
|
3
|
+
import { existsSync } from 'fs';
|
|
4
|
+
import { readFile } from 'fs/promises';
|
|
5
|
+
import { join } from 'path';
|
|
6
|
+
import { homedir } from 'os';
|
|
7
|
+
import simpleGit from 'simple-git';
|
|
8
|
+
|
|
9
|
+
export class ConfigLoader {
|
|
10
|
+
async load(configPath?: string): Promise<CommitaConfig> {
|
|
11
|
+
const globalConfig = await this.loadFromFile(this.getGlobalConfigPath());
|
|
12
|
+
const fileConfig = await this.loadFromFile(configPath);
|
|
13
|
+
const envConfig = this.loadFromEnv();
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
...DEFAULT_CONFIG,
|
|
17
|
+
...globalConfig,
|
|
18
|
+
...fileConfig,
|
|
19
|
+
...envConfig,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
private getGlobalConfigPath(): string {
|
|
24
|
+
return join(homedir(), '.commita');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
private async loadFromFile(configPath?: string): Promise<Partial<CommitaConfig>> {
|
|
28
|
+
let path = configPath;
|
|
29
|
+
|
|
30
|
+
if (!path) {
|
|
31
|
+
const cwdPath = join(process.cwd(), '.commita');
|
|
32
|
+
if (existsSync(cwdPath)) {
|
|
33
|
+
path = cwdPath;
|
|
34
|
+
} else {
|
|
35
|
+
const gitRoot = await this.findGitRoot();
|
|
36
|
+
if (gitRoot) {
|
|
37
|
+
const rootPath = join(gitRoot, '.commita');
|
|
38
|
+
if (existsSync(rootPath)) {
|
|
39
|
+
path = rootPath;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Fallback to default check if we still haven't found it or if it was explicitly provided
|
|
46
|
+
if (!path) {
|
|
47
|
+
path = join(process.cwd(), '.commita');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!existsSync(path)) {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const content = await readFile(path, 'utf-8');
|
|
56
|
+
return this.parseKeyValue(content);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.warn(`Warning: Could not read config file at ${path}`);
|
|
59
|
+
return {};
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
private async findGitRoot(): Promise<string | null> {
|
|
64
|
+
try {
|
|
65
|
+
const git = simpleGit();
|
|
66
|
+
const root = await git.revparse(['--show-toplevel']);
|
|
67
|
+
return root.trim();
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private parseKeyValue(content: string): Partial<CommitaConfig> {
|
|
74
|
+
const config: Partial<CommitaConfig> = {};
|
|
75
|
+
const lines = content.split('\n');
|
|
76
|
+
|
|
77
|
+
for (const line of lines) {
|
|
78
|
+
const trimmed = line.trim();
|
|
79
|
+
if (!trimmed || trimmed.startsWith('#')) continue;
|
|
80
|
+
|
|
81
|
+
const [key, ...valueParts] = trimmed.split('=');
|
|
82
|
+
if (!key || valueParts.length === 0) continue;
|
|
83
|
+
|
|
84
|
+
const value = valueParts.join('=').trim();
|
|
85
|
+
const normalizedKey = key.trim().toUpperCase();
|
|
86
|
+
|
|
87
|
+
switch (normalizedKey) {
|
|
88
|
+
case 'PROVIDER':
|
|
89
|
+
config.provider = value as Provider;
|
|
90
|
+
break;
|
|
91
|
+
case 'MODEL':
|
|
92
|
+
config.model = value;
|
|
93
|
+
break;
|
|
94
|
+
case 'PROMPT_STYLE':
|
|
95
|
+
config.promptStyle = value as PromptStyle;
|
|
96
|
+
break;
|
|
97
|
+
case 'PROMPT_TEMPLATE':
|
|
98
|
+
config.promptTemplate = value;
|
|
99
|
+
break;
|
|
100
|
+
case 'CUSTOM_PROMPT':
|
|
101
|
+
config.customPrompt = value;
|
|
102
|
+
break;
|
|
103
|
+
case 'COMMIT_STYLE':
|
|
104
|
+
config.commitStyle = value as CommitStyle;
|
|
105
|
+
break;
|
|
106
|
+
case 'OPENAI_API_KEY':
|
|
107
|
+
config.openaiApiKey = value;
|
|
108
|
+
break;
|
|
109
|
+
case 'GEMINI_API_KEY':
|
|
110
|
+
config.geminiApiKey = value;
|
|
111
|
+
break;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return config;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private loadFromEnv(): Partial<CommitaConfig> {
|
|
119
|
+
const config: Partial<CommitaConfig> = {};
|
|
120
|
+
|
|
121
|
+
if (process.env.COMMITA_PROVIDER) {
|
|
122
|
+
config.provider = process.env.COMMITA_PROVIDER as Provider;
|
|
123
|
+
}
|
|
124
|
+
if (process.env.COMMITA_MODEL) {
|
|
125
|
+
config.model = process.env.COMMITA_MODEL;
|
|
126
|
+
}
|
|
127
|
+
if (process.env.COMMITA_PROMPT_STYLE) {
|
|
128
|
+
config.promptStyle = process.env.COMMITA_PROMPT_STYLE as PromptStyle;
|
|
129
|
+
}
|
|
130
|
+
if (process.env.COMMITA_PROMPT_TEMPLATE) {
|
|
131
|
+
config.promptTemplate = process.env.COMMITA_PROMPT_TEMPLATE;
|
|
132
|
+
}
|
|
133
|
+
if (process.env.COMMITA_CUSTOM_PROMPT) {
|
|
134
|
+
config.customPrompt = process.env.COMMITA_CUSTOM_PROMPT;
|
|
135
|
+
}
|
|
136
|
+
if (process.env.COMMITA_COMMIT_STYLE) {
|
|
137
|
+
config.commitStyle = process.env.COMMITA_COMMIT_STYLE as CommitStyle;
|
|
138
|
+
}
|
|
139
|
+
if (process.env.OPENAI_API_KEY) {
|
|
140
|
+
config.openaiApiKey = process.env.OPENAI_API_KEY;
|
|
141
|
+
}
|
|
142
|
+
if (process.env.GEMINI_API_KEY) {
|
|
143
|
+
config.geminiApiKey = process.env.GEMINI_API_KEY;
|
|
144
|
+
}
|
|
145
|
+
if (process.env.GOOGLE_GENERATIVE_AI_API_KEY && !config.geminiApiKey) {
|
|
146
|
+
config.geminiApiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return config;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type CommitStyle = 'conventional' | 'emoji';
|
|
2
|
+
export type PromptStyle = 'default' | 'detailed' | 'minimal' | 'custom';
|
|
3
|
+
export type Provider = 'openai' | 'gemini';
|
|
4
|
+
|
|
5
|
+
export interface CommitaConfig {
|
|
6
|
+
provider: Provider;
|
|
7
|
+
model: string;
|
|
8
|
+
promptStyle: PromptStyle;
|
|
9
|
+
promptTemplate?: string;
|
|
10
|
+
customPrompt?: string;
|
|
11
|
+
commitStyle: CommitStyle;
|
|
12
|
+
openaiApiKey?: string;
|
|
13
|
+
geminiApiKey?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const DEFAULT_CONFIG: CommitaConfig = {
|
|
17
|
+
provider: 'openai',
|
|
18
|
+
model: 'gpt-4o-mini',
|
|
19
|
+
promptStyle: 'default',
|
|
20
|
+
commitStyle: 'conventional',
|
|
21
|
+
};
|
|
22
|
+
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import type { CommitStyle, PromptStyle, Provider } from '@/config/config.types.ts';
|
|
2
|
+
import { existsSync } from 'fs';
|
|
3
|
+
import { readFile, writeFile, chmod } from 'fs/promises';
|
|
4
|
+
import { join } from 'path';
|
|
5
|
+
|
|
6
|
+
interface ConfigLine {
|
|
7
|
+
type: 'comment' | 'blank' | 'config';
|
|
8
|
+
content: string;
|
|
9
|
+
key?: string;
|
|
10
|
+
value?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const VALID_KEYS = [
|
|
14
|
+
'PROVIDER',
|
|
15
|
+
'MODEL',
|
|
16
|
+
'OPENAI_API_KEY',
|
|
17
|
+
'GEMINI_API_KEY',
|
|
18
|
+
'COMMIT_STYLE',
|
|
19
|
+
'PROMPT_STYLE',
|
|
20
|
+
'PROMPT_TEMPLATE',
|
|
21
|
+
'CUSTOM_PROMPT',
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
const VALID_PROVIDERS: Provider[] = ['openai', 'gemini'];
|
|
25
|
+
const VALID_COMMIT_STYLES: CommitStyle[] = ['conventional', 'emoji'];
|
|
26
|
+
const VALID_PROMPT_STYLES: PromptStyle[] = ['default', 'detailed', 'minimal', 'custom'];
|
|
27
|
+
|
|
28
|
+
export class ConfigWriter {
|
|
29
|
+
async set(key: string, value: string, filePath: string): Promise<void> {
|
|
30
|
+
this.validateKey(key);
|
|
31
|
+
this.validateValue(key, value);
|
|
32
|
+
|
|
33
|
+
const lines = await this.readConfigFile(filePath);
|
|
34
|
+
const updatedLines = this.updateOrAddConfigLine(lines, key, value);
|
|
35
|
+
await this.writeConfigFile(filePath, updatedLines);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
private validateKey(key: string): void {
|
|
39
|
+
const normalizedKey = key.toUpperCase();
|
|
40
|
+
if (!VALID_KEYS.includes(normalizedKey)) {
|
|
41
|
+
const availableKeys = VALID_KEYS.join(', ');
|
|
42
|
+
throw new Error(`Unknown configuration key '${key}'\n\nAvailable keys: ${availableKeys}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private validateValue(key: string, value: string): void {
|
|
47
|
+
const normalizedKey = key.toUpperCase();
|
|
48
|
+
|
|
49
|
+
switch (normalizedKey) {
|
|
50
|
+
case 'PROVIDER':
|
|
51
|
+
if (!VALID_PROVIDERS.includes(value as Provider)) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Invalid value for PROVIDER\n Expected: ${VALID_PROVIDERS.join(' or ')}\n Received: ${value}`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
break;
|
|
57
|
+
case 'COMMIT_STYLE':
|
|
58
|
+
if (!VALID_COMMIT_STYLES.includes(value as CommitStyle)) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Invalid value for COMMIT_STYLE\n Expected: ${VALID_COMMIT_STYLES.join(' or ')}\n Received: ${value}`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
break;
|
|
64
|
+
case 'PROMPT_STYLE':
|
|
65
|
+
if (!VALID_PROMPT_STYLES.includes(value as PromptStyle)) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`Invalid value for PROMPT_STYLE\n Expected: ${VALID_PROMPT_STYLES.join(', ')}\n Received: ${value}`
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
break;
|
|
71
|
+
case 'OPENAI_API_KEY':
|
|
72
|
+
if (value.length < 10) {
|
|
73
|
+
throw new Error(
|
|
74
|
+
`API key for OPENAI_API_KEY appears to be invalid (too short: ${value.length} characters)`
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
break;
|
|
78
|
+
case 'GEMINI_API_KEY':
|
|
79
|
+
if (value.length < 10) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`API key for GEMINI_API_KEY appears to be invalid (too short: ${value.length} characters)`
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async readConfigFile(filePath: string): Promise<ConfigLine[]> {
|
|
89
|
+
if (!existsSync(filePath)) {
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const content = await readFile(filePath, 'utf-8');
|
|
95
|
+
return this.parseConfigLines(content);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
throw new Error(`Could not read config file at ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private parseConfigLines(content: string): ConfigLine[] {
|
|
102
|
+
const lines: ConfigLine[] = [];
|
|
103
|
+
const contentLines = content.split('\n');
|
|
104
|
+
|
|
105
|
+
for (const line of contentLines) {
|
|
106
|
+
if (line.trim().startsWith('#')) {
|
|
107
|
+
lines.push({
|
|
108
|
+
type: 'comment',
|
|
109
|
+
content: line,
|
|
110
|
+
});
|
|
111
|
+
} else if (line.trim() === '') {
|
|
112
|
+
lines.push({
|
|
113
|
+
type: 'blank',
|
|
114
|
+
content: line,
|
|
115
|
+
});
|
|
116
|
+
} else {
|
|
117
|
+
const trimmed = line.trim();
|
|
118
|
+
const eqIndex = trimmed.indexOf('=');
|
|
119
|
+
if (eqIndex > 0) {
|
|
120
|
+
const key = trimmed.substring(0, eqIndex).trim().toUpperCase();
|
|
121
|
+
const value = trimmed.substring(eqIndex + 1).trim();
|
|
122
|
+
lines.push({
|
|
123
|
+
type: 'config',
|
|
124
|
+
content: line,
|
|
125
|
+
key,
|
|
126
|
+
value,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return lines;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
private updateOrAddConfigLine(lines: ConfigLine[], key: string, value: string): ConfigLine[] {
|
|
136
|
+
const normalizedKey = key.toUpperCase();
|
|
137
|
+
const existingIndex = lines.findIndex(
|
|
138
|
+
(line) => line.type === 'config' && line.key === normalizedKey
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
if (existingIndex >= 0) {
|
|
142
|
+
lines[existingIndex] = {
|
|
143
|
+
type: 'config',
|
|
144
|
+
content: `${normalizedKey}=${value}`,
|
|
145
|
+
key: normalizedKey,
|
|
146
|
+
value,
|
|
147
|
+
};
|
|
148
|
+
} else {
|
|
149
|
+
lines.push({
|
|
150
|
+
type: 'config',
|
|
151
|
+
content: `${normalizedKey}=${value}`,
|
|
152
|
+
key: normalizedKey,
|
|
153
|
+
value,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return lines;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private async writeConfigFile(filePath: string, lines: ConfigLine[]): Promise<void> {
|
|
161
|
+
let content: string;
|
|
162
|
+
|
|
163
|
+
if (lines.length === 0) {
|
|
164
|
+
content = this.createDefaultConfig();
|
|
165
|
+
} else {
|
|
166
|
+
content = lines.map((line) => line.content).join('\n');
|
|
167
|
+
if (content && !content.endsWith('\n')) {
|
|
168
|
+
content += '\n';
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
try {
|
|
173
|
+
await writeFile(filePath, content, 'utf-8');
|
|
174
|
+
// Set file permissions to 0600 (owner read/write only)
|
|
175
|
+
await chmod(filePath, 0o600);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Could not write config file at ${filePath}: ${error instanceof Error ? error.message : String(error)}`
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private createDefaultConfig(): string {
|
|
184
|
+
return `# Commita Configuration
|
|
185
|
+
# https://github.com/misaelabanto/commita
|
|
186
|
+
|
|
187
|
+
# AI Provider: openai or gemini
|
|
188
|
+
# PROVIDER=openai
|
|
189
|
+
|
|
190
|
+
# Model name (provider-specific)
|
|
191
|
+
# MODEL=gpt-4o-mini
|
|
192
|
+
|
|
193
|
+
# Prompt style: default, detailed, minimal, or custom
|
|
194
|
+
# PROMPT_STYLE=default
|
|
195
|
+
|
|
196
|
+
# Commit message style: conventional or emoji
|
|
197
|
+
# COMMIT_STYLE=conventional
|
|
198
|
+
|
|
199
|
+
# API Keys - set the key for your chosen provider
|
|
200
|
+
# OPENAI_API_KEY=your-openai-api-key-here
|
|
201
|
+
# GEMINI_API_KEY=your-gemini-api-key-here
|
|
202
|
+
`;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const PROMPT_TEMPLATES = {
|
|
2
|
+
default: `You are a helpful assistant that generates git commit messages.
|
|
3
|
+
|
|
4
|
+
Analyze the following git diff and generate a commit message following this format:
|
|
5
|
+
<type>(<scope>): <short description>
|
|
6
|
+
|
|
7
|
+
- Change 1
|
|
8
|
+
- Change 2
|
|
9
|
+
- Change 3
|
|
10
|
+
|
|
11
|
+
Rules:
|
|
12
|
+
1. Determine the commit type from: feat, fix, refactor, chore, docs, style, test, perf
|
|
13
|
+
2. The scope should be extracted from the common path (e.g., "components", "utils", "services")
|
|
14
|
+
3. Keep the short description under 50 characters
|
|
15
|
+
4. List 2-5 key changes as bullet points
|
|
16
|
+
5. Be concise and clear
|
|
17
|
+
|
|
18
|
+
Git diff:
|
|
19
|
+
{diff}`,
|
|
20
|
+
|
|
21
|
+
detailed: `You are an expert developer analyzing code changes for commit message generation.
|
|
22
|
+
|
|
23
|
+
Your task is to deeply analyze the following git diff and create a comprehensive commit message.
|
|
24
|
+
|
|
25
|
+
Context Analysis:
|
|
26
|
+
- Identify what problem is being solved or feature being added
|
|
27
|
+
- Understand the broader context of changes
|
|
28
|
+
- Note any patterns, architectural decisions, or technical debt addressed
|
|
29
|
+
|
|
30
|
+
Format your commit message as:
|
|
31
|
+
<type>(<scope>): <short description>
|
|
32
|
+
|
|
33
|
+
- Detailed change 1 with context
|
|
34
|
+
- Detailed change 2 with context
|
|
35
|
+
- Detailed change 3 with context
|
|
36
|
+
|
|
37
|
+
Commit types: feat, fix, refactor, chore, docs, style, test, perf
|
|
38
|
+
The scope should reflect the affected module/area.
|
|
39
|
+
|
|
40
|
+
Git diff:
|
|
41
|
+
{diff}`,
|
|
42
|
+
|
|
43
|
+
minimal: `Generate a short commit message for this diff.
|
|
44
|
+
|
|
45
|
+
Format: <type>(<scope>): <description>
|
|
46
|
+
|
|
47
|
+
- Key change 1
|
|
48
|
+
- Key change 2
|
|
49
|
+
|
|
50
|
+
Types: feat, fix, refactor, chore, docs, style, test, perf
|
|
51
|
+
|
|
52
|
+
Diff:
|
|
53
|
+
{diff}`,
|
|
54
|
+
};
|
|
55
|
+
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import type { FileChange } from '@/git/git.service.ts';
|
|
2
|
+
import type { ProjectBoundary } from '@/git/project-detector.ts';
|
|
3
|
+
|
|
4
|
+
export interface FileGroup {
|
|
5
|
+
scope: string;
|
|
6
|
+
files: FileChange[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class FileGrouper {
|
|
10
|
+
private boundaries: ProjectBoundary[];
|
|
11
|
+
|
|
12
|
+
constructor(boundaries: ProjectBoundary[] = []) {
|
|
13
|
+
// Sort longest path first so the most specific project matches first
|
|
14
|
+
this.boundaries = [...boundaries].sort(
|
|
15
|
+
(a, b) => b.path.length - a.path.length,
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
groupByPath(files: FileChange[]): FileGroup[] {
|
|
20
|
+
if (files.length === 0) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const groups = new Map<string, FileChange[]>();
|
|
25
|
+
|
|
26
|
+
for (const file of files) {
|
|
27
|
+
const scope = this.extractScope(file.path);
|
|
28
|
+
const existing = groups.get(scope) || [];
|
|
29
|
+
existing.push(file);
|
|
30
|
+
groups.set(scope, existing);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return Array.from(groups.entries()).map(([scope, files]) => ({
|
|
34
|
+
scope,
|
|
35
|
+
files,
|
|
36
|
+
}));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private extractScope(filePath: string): string {
|
|
40
|
+
const parts = filePath.split('/');
|
|
41
|
+
|
|
42
|
+
if (parts.length === 1) {
|
|
43
|
+
return 'root';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const project = this.findProjectForFile(filePath);
|
|
47
|
+
|
|
48
|
+
if (project) {
|
|
49
|
+
const relativePath = filePath.slice(project.path.length + 1);
|
|
50
|
+
const relativeParts = relativePath.split('/');
|
|
51
|
+
|
|
52
|
+
if (relativeParts.length <= 1) {
|
|
53
|
+
return project.path;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const meaningfulParts = this.findMeaningfulPath(relativeParts);
|
|
57
|
+
return `${project.path}/${meaningfulParts.join('/')}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (this.boundaries.length > 0) {
|
|
61
|
+
// In a monorepo, root-level files that don't belong to any project
|
|
62
|
+
return 'root';
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const meaningfulParts = this.findMeaningfulPath(parts);
|
|
66
|
+
return meaningfulParts.join('/');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private findProjectForFile(filePath: string): ProjectBoundary | undefined {
|
|
70
|
+
return this.boundaries.find(
|
|
71
|
+
b => filePath === b.path || filePath.startsWith(`${b.path}/`),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private findMeaningfulPath(parts: string[]): string[] {
|
|
76
|
+
const commonDirs = ['src', 'lib', 'app', 'pages'];
|
|
77
|
+
const startIdx = parts.findIndex(p => commonDirs.includes(p));
|
|
78
|
+
|
|
79
|
+
if (startIdx === -1) {
|
|
80
|
+
return parts.slice(0, Math.min(2, parts.length - 1));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const relevantParts = parts.slice(startIdx);
|
|
84
|
+
|
|
85
|
+
if (relevantParts.length === 1) {
|
|
86
|
+
return relevantParts;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return relevantParts.slice(0, 2);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
optimizeGroups(groups: FileGroup[]): FileGroup[] {
|
|
93
|
+
const scopeMap = new Map<string, FileChange[]>();
|
|
94
|
+
|
|
95
|
+
for (const group of groups) {
|
|
96
|
+
const normalizedScope = this.normalizeScope(group.scope);
|
|
97
|
+
const existing = scopeMap.get(normalizedScope) || [];
|
|
98
|
+
existing.push(...group.files);
|
|
99
|
+
scopeMap.set(normalizedScope, existing);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const optimized: FileGroup[] = [];
|
|
103
|
+
for (const [scope, files] of scopeMap.entries()) {
|
|
104
|
+
optimized.push({ scope, files });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return optimized.sort((a, b) => {
|
|
108
|
+
if (a.scope === 'root') return 1;
|
|
109
|
+
if (b.scope === 'root') return -1;
|
|
110
|
+
return a.scope.localeCompare(b.scope);
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private normalizeScope(scope: string): string {
|
|
115
|
+
const project = this.findProjectForScope(scope);
|
|
116
|
+
|
|
117
|
+
if (project) {
|
|
118
|
+
const subScope = scope.slice(project.path.length + 1);
|
|
119
|
+
const parts = subScope.split('/');
|
|
120
|
+
|
|
121
|
+
if (parts.length > 2 && parts[0] === 'src') {
|
|
122
|
+
return `${project.path}/${parts.slice(0, 2).join('/')}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return scope;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const parts = scope.split('/');
|
|
129
|
+
|
|
130
|
+
if (parts.length > 2 && parts[0] === 'src') {
|
|
131
|
+
return parts.slice(0, 2).join('/');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return scope;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private findProjectForScope(scope: string): ProjectBoundary | undefined {
|
|
138
|
+
return this.boundaries.find(
|
|
139
|
+
b => scope === b.path || scope.startsWith(`${b.path}/`),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|