@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
package/install.sh
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
REPO="misaelabanto/commita"
|
|
6
|
+
BINARY_NAME="commita"
|
|
7
|
+
INSTALL_DIR="/usr/local/bin"
|
|
8
|
+
|
|
9
|
+
# Colors
|
|
10
|
+
RED='\033[0;31m'
|
|
11
|
+
GREEN='\033[0;32m'
|
|
12
|
+
YELLOW='\033[1;33m'
|
|
13
|
+
CYAN='\033[0;36m'
|
|
14
|
+
NC='\033[0m' # No Color
|
|
15
|
+
|
|
16
|
+
info() { echo -e "${CYAN}[commita]${NC} $*"; }
|
|
17
|
+
success() { echo -e "${GREEN}[commita]${NC} $*"; }
|
|
18
|
+
warn() { echo -e "${YELLOW}[commita]${NC} $*"; }
|
|
19
|
+
error() { echo -e "${RED}[commita]${NC} $*" >&2; exit 1; }
|
|
20
|
+
|
|
21
|
+
# Detect OS
|
|
22
|
+
detect_os() {
|
|
23
|
+
local os
|
|
24
|
+
os=$(uname -s | tr '[:upper:]' '[:lower:]')
|
|
25
|
+
case "$os" in
|
|
26
|
+
darwin) echo "darwin" ;;
|
|
27
|
+
linux) echo "linux" ;;
|
|
28
|
+
msys*|mingw*|cygwin*) echo "windows" ;;
|
|
29
|
+
*) error "Unsupported OS: $os" ;;
|
|
30
|
+
esac
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
# Detect architecture
|
|
34
|
+
detect_arch() {
|
|
35
|
+
local arch
|
|
36
|
+
arch=$(uname -m)
|
|
37
|
+
case "$arch" in
|
|
38
|
+
x86_64|amd64) echo "amd64" ;;
|
|
39
|
+
arm64|aarch64) echo "arm64" ;;
|
|
40
|
+
*) error "Unsupported architecture: $arch" ;;
|
|
41
|
+
esac
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
# Check for required tools
|
|
45
|
+
check_deps() {
|
|
46
|
+
for cmd in curl; do
|
|
47
|
+
if ! command -v "$cmd" &>/dev/null; then
|
|
48
|
+
error "'$cmd' is required but not installed."
|
|
49
|
+
fi
|
|
50
|
+
done
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Get latest release tag from GitHub API
|
|
54
|
+
get_latest_tag() {
|
|
55
|
+
local tag
|
|
56
|
+
tag=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" \
|
|
57
|
+
| grep '"tag_name"' \
|
|
58
|
+
| sed -E 's/.*"([^"]+)".*/\1/')
|
|
59
|
+
|
|
60
|
+
if [ -z "$tag" ]; then
|
|
61
|
+
error "Failed to fetch the latest release tag. Check your internet connection or visit https://github.com/${REPO}/releases."
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
echo "$tag"
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
main() {
|
|
68
|
+
check_deps
|
|
69
|
+
|
|
70
|
+
local os arch
|
|
71
|
+
os=$(detect_os)
|
|
72
|
+
arch=$(detect_arch)
|
|
73
|
+
|
|
74
|
+
# Windows is not supported via this script — direct users to releases
|
|
75
|
+
if [ "$os" = "windows" ]; then
|
|
76
|
+
error "Windows is not supported by this install script. Please download the binary manually from: https://github.com/${REPO}/releases"
|
|
77
|
+
fi
|
|
78
|
+
|
|
79
|
+
local tag
|
|
80
|
+
tag=$(get_latest_tag)
|
|
81
|
+
|
|
82
|
+
local binary_file="${BINARY_NAME}-${os}-${arch}"
|
|
83
|
+
local download_url="https://github.com/${REPO}/releases/download/${tag}/${binary_file}"
|
|
84
|
+
local tmp_file
|
|
85
|
+
tmp_file=$(mktemp)
|
|
86
|
+
|
|
87
|
+
info "Detected platform: ${os}/${arch}"
|
|
88
|
+
info "Latest version: ${tag}"
|
|
89
|
+
info "Downloading ${binary_file}..."
|
|
90
|
+
|
|
91
|
+
if ! curl -fsSL --progress-bar "$download_url" -o "$tmp_file"; then
|
|
92
|
+
rm -f "$tmp_file"
|
|
93
|
+
error "Download failed. Please check: ${download_url}"
|
|
94
|
+
fi
|
|
95
|
+
|
|
96
|
+
chmod +x "$tmp_file"
|
|
97
|
+
|
|
98
|
+
# Install to INSTALL_DIR, using sudo if needed
|
|
99
|
+
if [ -w "$INSTALL_DIR" ]; then
|
|
100
|
+
mv "$tmp_file" "${INSTALL_DIR}/${BINARY_NAME}"
|
|
101
|
+
else
|
|
102
|
+
warn "Installing to ${INSTALL_DIR} requires elevated privileges."
|
|
103
|
+
sudo mv "$tmp_file" "${INSTALL_DIR}/${BINARY_NAME}"
|
|
104
|
+
fi
|
|
105
|
+
|
|
106
|
+
success "${BINARY_NAME} ${tag} installed to ${INSTALL_DIR}/${BINARY_NAME}"
|
|
107
|
+
echo ""
|
|
108
|
+
echo " Get started: commita --help"
|
|
109
|
+
echo " Docs: https://github.com/${REPO}#readme"
|
|
110
|
+
echo ""
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
main "$@"
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@misaelabanto/commita",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "AI-powered git auto-commit tool that intelligently groups your changes and generates meaningful commit messages",
|
|
5
|
+
"module": "index.ts",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"private": false,
|
|
8
|
+
"bin": {
|
|
9
|
+
"commita": "./index.ts"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"dev": "bun run index.ts",
|
|
13
|
+
"build": "bun build index.ts --outdir dist --target bun",
|
|
14
|
+
"commita": "bun run index.ts"
|
|
15
|
+
},
|
|
16
|
+
"author": "Misael Abanto",
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/misaelabanto/commita.git"
|
|
21
|
+
},
|
|
22
|
+
"homepage": "https://github.com/misaelabanto/commita#readme",
|
|
23
|
+
"dependencies": {
|
|
24
|
+
"@ai-sdk/google": "^2.0.28",
|
|
25
|
+
"@ai-sdk/openai": "^2.0.62",
|
|
26
|
+
"ai": "^5.0.87",
|
|
27
|
+
"chalk": "^5.3.0",
|
|
28
|
+
"commander": "^12.1.0",
|
|
29
|
+
"dotenv": "^16.4.7",
|
|
30
|
+
"minimatch": "^10.0.1",
|
|
31
|
+
"simple-git": "^3.27.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/bun": "latest",
|
|
35
|
+
"@types/minimatch": "^5.1.2"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"typescript": "^5"
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { CommitTypeAnalyzer } from '@/ai/commit-type-analyzer.ts';
|
|
2
|
+
import { EmojiMapper } from '@/ai/emoji-mapper.ts';
|
|
3
|
+
import type { CommitaConfig } from '@/config/config.types.ts';
|
|
4
|
+
import { PROMPT_TEMPLATES } from '@/config/prompt-templates.ts';
|
|
5
|
+
import { openai, createOpenAI } from '@ai-sdk/openai';
|
|
6
|
+
import { google, createGoogleGenerativeAI } from '@ai-sdk/google';
|
|
7
|
+
import { generateText } from 'ai';
|
|
8
|
+
|
|
9
|
+
export class AIService {
|
|
10
|
+
private config: CommitaConfig;
|
|
11
|
+
private typeAnalyzer: CommitTypeAnalyzer;
|
|
12
|
+
private emojiMapper: EmojiMapper;
|
|
13
|
+
|
|
14
|
+
constructor(config: CommitaConfig) {
|
|
15
|
+
this.config = config;
|
|
16
|
+
this.typeAnalyzer = new CommitTypeAnalyzer();
|
|
17
|
+
this.emojiMapper = new EmojiMapper();
|
|
18
|
+
|
|
19
|
+
this.validateConfig();
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
private validateConfig(): void {
|
|
23
|
+
if (this.config.provider === 'openai' && !this.config.openaiApiKey) {
|
|
24
|
+
throw new Error('OpenAI API key is required. Set it in .commita file or OPENAI_API_KEY env var.');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (this.config.provider === 'gemini' && !this.config.geminiApiKey) {
|
|
28
|
+
throw new Error('Gemini API key is required. Set it in .commita file or GEMINI_API_KEY env var.');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async generateCommitMessage(diff: string, files: string[], scope: string): Promise<string> {
|
|
33
|
+
const prompt = this.buildPrompt(diff);
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const provider = this.getProvider();
|
|
37
|
+
const model = provider(this.config.model);
|
|
38
|
+
|
|
39
|
+
const { text } = await generateText({
|
|
40
|
+
model,
|
|
41
|
+
system: 'You are a helpful assistant that generates concise git commit messages. The response should be plain text without any markdown formatting.',
|
|
42
|
+
prompt,
|
|
43
|
+
temperature: 0.7,
|
|
44
|
+
maxTokens: 500,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
let message = text.replace(/^```\n*((.*\n*)+)```$/, '$1').trim() || '';
|
|
48
|
+
|
|
49
|
+
if (!message) {
|
|
50
|
+
const commitType = this.typeAnalyzer.analyzeFromDiff(diff, files);
|
|
51
|
+
message = `${commitType}(${scope}): update files`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (this.config.commitStyle === 'emoji') {
|
|
55
|
+
message = this.emojiMapper.replaceTypeWithEmoji(message);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return message;
|
|
59
|
+
} catch (error) {
|
|
60
|
+
console.error('Error generating commit message:', error);
|
|
61
|
+
const commitType = this.typeAnalyzer.analyzeFromDiff(diff, files);
|
|
62
|
+
let fallbackMessage = `${commitType}(${scope}): update files`;
|
|
63
|
+
|
|
64
|
+
if (this.config.commitStyle === 'emoji') {
|
|
65
|
+
fallbackMessage = this.emojiMapper.replaceTypeWithEmoji(fallbackMessage);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return fallbackMessage;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private getProvider() {
|
|
73
|
+
if (this.config.provider === 'gemini') {
|
|
74
|
+
if (this.config.geminiApiKey) {
|
|
75
|
+
return createGoogleGenerativeAI({
|
|
76
|
+
apiKey: this.config.geminiApiKey,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return google;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (this.config.openaiApiKey) {
|
|
83
|
+
return createOpenAI({
|
|
84
|
+
apiKey: this.config.openaiApiKey,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return openai;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private buildPrompt(diff: string): string {
|
|
92
|
+
let template: string;
|
|
93
|
+
|
|
94
|
+
if (this.config.promptStyle === 'custom') {
|
|
95
|
+
template = this.config.customPrompt || this.config.promptTemplate || PROMPT_TEMPLATES.default;
|
|
96
|
+
} else {
|
|
97
|
+
template = PROMPT_TEMPLATES[this.config.promptStyle];
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return template.replace('{diff}', this.truncateDiff(diff));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
private truncateDiff(diff: string, maxLength: number = 8000): string {
|
|
104
|
+
if (diff.length <= maxLength) {
|
|
105
|
+
return diff;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const lines = diff.split('\n');
|
|
109
|
+
const truncated: string[] = [];
|
|
110
|
+
let currentLength = 0;
|
|
111
|
+
|
|
112
|
+
for (const line of lines) {
|
|
113
|
+
if (currentLength + line.length > maxLength) {
|
|
114
|
+
truncated.push('\n... (diff truncated for brevity) ...');
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
truncated.push(line);
|
|
118
|
+
currentLength += line.length + 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return truncated.join('\n');
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
export type CommitType = 'feat' | 'fix' | 'refactor' | 'chore' | 'docs' | 'style' | 'test' | 'perf';
|
|
2
|
+
|
|
3
|
+
export class CommitTypeAnalyzer {
|
|
4
|
+
analyzeFromDiff(diff: string, files: string[]): CommitType {
|
|
5
|
+
const lowerDiff = diff.toLowerCase();
|
|
6
|
+
const filePaths = files.map(f => f.toLowerCase());
|
|
7
|
+
|
|
8
|
+
if (this.isTest(filePaths, lowerDiff)) return 'test';
|
|
9
|
+
if (this.isDocs(filePaths, lowerDiff)) return 'docs';
|
|
10
|
+
if (this.isChore(filePaths, lowerDiff)) return 'chore';
|
|
11
|
+
if (this.isStyle(lowerDiff)) return 'style';
|
|
12
|
+
if (this.isPerf(lowerDiff)) return 'perf';
|
|
13
|
+
if (this.isFix(lowerDiff)) return 'fix';
|
|
14
|
+
if (this.isFeat(diff, lowerDiff)) return 'feat';
|
|
15
|
+
|
|
16
|
+
return 'refactor';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
private isTest(files: string[], diff: string): boolean {
|
|
20
|
+
const testPatterns = ['.test.', '.spec.', '__tests__', '/tests/', '/test/'];
|
|
21
|
+
return files.some(f => testPatterns.some(p => f.includes(p))) ||
|
|
22
|
+
diff.includes('test(') || diff.includes('describe(') || diff.includes('it(');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
private isDocs(files: string[], diff: string): boolean {
|
|
26
|
+
const docPatterns = ['readme', '.md', 'documentation', '/docs/'];
|
|
27
|
+
return files.some(f => docPatterns.some(p => f.includes(p)));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private isChore(files: string[], diff: string): boolean {
|
|
31
|
+
const chorePatterns = [
|
|
32
|
+
'package.json',
|
|
33
|
+
'package-lock.json',
|
|
34
|
+
'yarn.lock',
|
|
35
|
+
'bun.lock',
|
|
36
|
+
'.gitignore',
|
|
37
|
+
'tsconfig',
|
|
38
|
+
'webpack',
|
|
39
|
+
'vite.config',
|
|
40
|
+
'.eslint',
|
|
41
|
+
'.prettier',
|
|
42
|
+
];
|
|
43
|
+
return files.some(f => chorePatterns.some(p => f.includes(p)));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private isStyle(diff: string): boolean {
|
|
47
|
+
const styleKeywords = ['formatting', 'whitespace', 'indent', 'prettier', 'eslint'];
|
|
48
|
+
const hasStyleKeywords = styleKeywords.some(k => diff.includes(k));
|
|
49
|
+
|
|
50
|
+
const hasCodeChanges = diff.includes('function') ||
|
|
51
|
+
diff.includes('class') ||
|
|
52
|
+
diff.includes('const') ||
|
|
53
|
+
diff.includes('import');
|
|
54
|
+
|
|
55
|
+
return hasStyleKeywords && !hasCodeChanges;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private isPerf(diff: string): boolean {
|
|
59
|
+
const perfKeywords = [
|
|
60
|
+
'performance',
|
|
61
|
+
'optimize',
|
|
62
|
+
'cache',
|
|
63
|
+
'memoize',
|
|
64
|
+
'debounce',
|
|
65
|
+
'throttle',
|
|
66
|
+
'lazy',
|
|
67
|
+
'async',
|
|
68
|
+
'usememo',
|
|
69
|
+
'usecallback',
|
|
70
|
+
];
|
|
71
|
+
return perfKeywords.some(k => diff.includes(k));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private isFix(diff: string): boolean {
|
|
75
|
+
const fixKeywords = [
|
|
76
|
+
'fix',
|
|
77
|
+
'bug',
|
|
78
|
+
'issue',
|
|
79
|
+
'error',
|
|
80
|
+
'crash',
|
|
81
|
+
'problem',
|
|
82
|
+
'resolve',
|
|
83
|
+
'correct',
|
|
84
|
+
'patch',
|
|
85
|
+
'hotfix',
|
|
86
|
+
];
|
|
87
|
+
return fixKeywords.some(k => diff.includes(k));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
private isFeat(diff: string, lowerDiff: string): boolean {
|
|
91
|
+
const hasNewFiles = diff.includes('new file mode');
|
|
92
|
+
const hasNewFunctions = lowerDiff.includes('+function') ||
|
|
93
|
+
lowerDiff.includes('+export') ||
|
|
94
|
+
lowerDiff.includes('+const') ||
|
|
95
|
+
lowerDiff.includes('+class');
|
|
96
|
+
|
|
97
|
+
const featKeywords = ['add', 'create', 'implement', 'feature', 'new'];
|
|
98
|
+
const hasFeatKeywords = featKeywords.some(k => lowerDiff.includes(k));
|
|
99
|
+
|
|
100
|
+
return hasNewFiles || (hasNewFunctions && hasFeatKeywords);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CommitType } from '@/ai/commit-type-analyzer.ts';
|
|
2
|
+
|
|
3
|
+
export class EmojiMapper {
|
|
4
|
+
private emojiMap: Record<CommitType, string> = {
|
|
5
|
+
feat: '✨',
|
|
6
|
+
fix: '🐛',
|
|
7
|
+
refactor: '♻️',
|
|
8
|
+
chore: '🔧',
|
|
9
|
+
docs: '📝',
|
|
10
|
+
style: '💄',
|
|
11
|
+
test: '✅',
|
|
12
|
+
perf: '⚡',
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
getEmoji(type: CommitType): string {
|
|
16
|
+
return this.emojiMap[type];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
replaceTypeWithEmoji(commitMessage: string): string {
|
|
20
|
+
for (const [type, emoji] of Object.entries(this.emojiMap)) {
|
|
21
|
+
const pattern = new RegExp(`^${type}`, 'i');
|
|
22
|
+
if (pattern.test(commitMessage)) {
|
|
23
|
+
return commitMessage.replace(pattern, emoji);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return commitMessage;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { AIService } from '@/ai/ai.service.ts';
|
|
2
|
+
import type { CommitaConfig } from '@/config/config.types.ts';
|
|
3
|
+
import { FileGrouper } from '@/git/file-grouper.ts';
|
|
4
|
+
import type { FileChange } from '@/git/git.service.ts';
|
|
5
|
+
import { GitService } from '@/git/git.service.ts';
|
|
6
|
+
import { ProjectDetector } from '@/git/project-detector.ts';
|
|
7
|
+
import { PatternMatcher } from '@/utils/pattern-matcher.ts';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
|
|
10
|
+
export interface CommitOptions {
|
|
11
|
+
all: boolean;
|
|
12
|
+
ignore: string;
|
|
13
|
+
push: boolean;
|
|
14
|
+
config?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class CommitHandler {
|
|
18
|
+
private gitService: GitService;
|
|
19
|
+
private fileGrouper: FileGrouper;
|
|
20
|
+
private aiService: AIService;
|
|
21
|
+
private config: CommitaConfig;
|
|
22
|
+
|
|
23
|
+
constructor(config: CommitaConfig) {
|
|
24
|
+
this.config = config;
|
|
25
|
+
this.gitService = new GitService();
|
|
26
|
+
this.fileGrouper = new FileGrouper();
|
|
27
|
+
this.aiService = new AIService(config);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async execute(options: CommitOptions): Promise<void> {
|
|
31
|
+
console.log(chalk.blue('🤖 Commita - AI-powered auto-commit\n'));
|
|
32
|
+
|
|
33
|
+
await this.gitService.init();
|
|
34
|
+
|
|
35
|
+
const boundaries = ProjectDetector.detect(this.gitService.getRootDir());
|
|
36
|
+
this.fileGrouper = new FileGrouper(boundaries);
|
|
37
|
+
|
|
38
|
+
const patternMatcher = new PatternMatcher(
|
|
39
|
+
PatternMatcher.parsePatterns(options.ignore)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const stagedChanges = await this.gitService.getStagedChanges();
|
|
44
|
+
|
|
45
|
+
if (!options.all && stagedChanges.length === 0) {
|
|
46
|
+
console.error(chalk.red('❌ Error: No staged changes found.\n'));
|
|
47
|
+
console.log(chalk.yellow('Either stage some changes or use the --all flag to process all changes.\n'));
|
|
48
|
+
console.log(chalk.gray('Examples:'));
|
|
49
|
+
console.log(chalk.gray(' git add <files> # Stage specific files'));
|
|
50
|
+
console.log(chalk.gray(' commita --all # Process all changes\n'));
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (stagedChanges.length > 0 && !options.all) {
|
|
55
|
+
console.log(chalk.yellow('📦 Found staged changes. Grouping and processing them...\n'));
|
|
56
|
+
await this.processGroupedStagedChanges(stagedChanges);
|
|
57
|
+
} else if (stagedChanges.length > 0 && options.all) {
|
|
58
|
+
console.log(chalk.yellow('⚠️ Ignoring staged changes due to --all flag\n'));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (options.all) {
|
|
62
|
+
await this.processAllChanges(patternMatcher);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (options.push) {
|
|
66
|
+
await this.pushChanges();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
console.log(chalk.green('\n✨ Done!\n'));
|
|
70
|
+
} catch (error) {
|
|
71
|
+
if (error instanceof Error) {
|
|
72
|
+
console.error(chalk.red(`\n❌ Error: ${error.message}\n`));
|
|
73
|
+
} else {
|
|
74
|
+
console.error(chalk.red('\n❌ An unknown error occurred\n'));
|
|
75
|
+
}
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async _processChangesInGroups(changes: FileChange[], isStaged: boolean): Promise<void> {
|
|
81
|
+
if (changes.length === 0) {
|
|
82
|
+
console.log(chalk.yellow(`No ${isStaged ? 'staged' : 'unstaged'} changes found to group. Skipping...`));
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (isStaged) {
|
|
87
|
+
const allFiles = changes.map(f => f.path);
|
|
88
|
+
await this.gitService.unstageFiles(allFiles);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const groups = this.fileGrouper.groupByPath(changes);
|
|
92
|
+
const optimizedGroups = this.fileGrouper.optimizeGroups(groups);
|
|
93
|
+
|
|
94
|
+
console.log(chalk.blue(`Found ${optimizedGroups.length} group(s) of ${isStaged ? 'staged' : 'unstaged'} changes:\n`));
|
|
95
|
+
|
|
96
|
+
for (const [index, group] of optimizedGroups.entries()) {
|
|
97
|
+
console.log(chalk.cyan(`\n[${index + 1}/${optimizedGroups.length}] Processing ${isStaged ? 'staged' : 'unstaged'}: ${group.scope}`));
|
|
98
|
+
console.log(chalk.gray(`Files: ${group.files.map(f => f.path).join(', ')}\n`));
|
|
99
|
+
|
|
100
|
+
const files = group.files.map(f => f.path);
|
|
101
|
+
const diff = await this.gitService.getDiff(files, false);
|
|
102
|
+
|
|
103
|
+
if (!diff) {
|
|
104
|
+
console.log(chalk.yellow(` No diff found for this ${isStaged ? 'staged' : 'unstaged'} group. Skipping...`));
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
console.log(chalk.cyan(' Generating commit message...'));
|
|
109
|
+
const message = await this.aiService.generateCommitMessage(diff, files, group.scope);
|
|
110
|
+
|
|
111
|
+
console.log(chalk.gray(' Commit message:'));
|
|
112
|
+
console.log(chalk.white(` ${message.replace(/\n/g, '\n ')}`));
|
|
113
|
+
console.log();
|
|
114
|
+
|
|
115
|
+
await this.gitService.stageFiles(files);
|
|
116
|
+
await this.gitService.commit(message);
|
|
117
|
+
console.log(chalk.green(` \u2713 Committed ${files.length} ${isStaged ? 'staged' : 'unstaged'} file(s)`));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private async processGroupedStagedChanges(stagedChanges: FileChange[]): Promise<void> {
|
|
122
|
+
await this._processChangesInGroups(stagedChanges, true);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private async processAllChanges(patternMatcher: PatternMatcher): Promise<void> {
|
|
126
|
+
const unstagedChanges = await this.gitService.getUnstagedChanges();
|
|
127
|
+
|
|
128
|
+
if (unstagedChanges.length === 0) {
|
|
129
|
+
console.log(chalk.yellow('No unstaged changes found.'));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const filteredChanges = patternMatcher.filterFiles(unstagedChanges);
|
|
134
|
+
|
|
135
|
+
if (filteredChanges.length === 0) {
|
|
136
|
+
console.log(chalk.yellow('All files were filtered out by ignore patterns.'));
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await this._processChangesInGroups(filteredChanges, false);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
private async pushChanges(): Promise<void> {
|
|
144
|
+
const hasRemote = await this.gitService.hasRemote();
|
|
145
|
+
|
|
146
|
+
if (!hasRemote) {
|
|
147
|
+
console.log(chalk.yellow('\n⚠️ No remote repository configured. Skipping push.'));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log(chalk.blue('\n📤 Pushing changes...'));
|
|
152
|
+
|
|
153
|
+
try {
|
|
154
|
+
await this.gitService.push();
|
|
155
|
+
console.log(chalk.green('✓ Changes pushed successfully'));
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.log(chalk.yellow('⚠️ Failed to push changes. You may need to push manually.'));
|
|
158
|
+
if (error instanceof Error) {
|
|
159
|
+
console.log(chalk.gray(` Reason: ${error.message}`));
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private extractScopeFromFiles(files: string[]): string {
|
|
165
|
+
if (files.length === 0) return 'root';
|
|
166
|
+
|
|
167
|
+
const firstFile = files[0];
|
|
168
|
+
if (!firstFile) return 'root';
|
|
169
|
+
|
|
170
|
+
const parts = firstFile.split('/');
|
|
171
|
+
|
|
172
|
+
if (parts.length === 1) return 'root';
|
|
173
|
+
if (parts.length === 2) return parts[0] || 'root';
|
|
174
|
+
|
|
175
|
+
const commonDirs = ['src', 'lib', 'app'];
|
|
176
|
+
const srcIndex = parts.findIndex(p => commonDirs.includes(p));
|
|
177
|
+
|
|
178
|
+
if (srcIndex !== -1 && srcIndex + 1 < parts.length) {
|
|
179
|
+
return parts[srcIndex + 1] || 'root';
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return parts[0] || 'root';
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { CommitOptions } from '@/cli/commit-handler.ts';
|
|
2
|
+
import { CommitHandler } from '@/cli/commit-handler.ts';
|
|
3
|
+
import type { SetOptions } from '@/cli/set-handler.ts';
|
|
4
|
+
import { SetHandler } from '@/cli/set-handler.ts';
|
|
5
|
+
import { ConfigLoader } from '@/config/config.loader.ts';
|
|
6
|
+
import chalk from 'chalk';
|
|
7
|
+
import { Command } from 'commander';
|
|
8
|
+
import packageJson from '../../package.json' with { type: 'json' };
|
|
9
|
+
|
|
10
|
+
export async function runCLI() {
|
|
11
|
+
const program = new Command();
|
|
12
|
+
|
|
13
|
+
program
|
|
14
|
+
.name('commita')
|
|
15
|
+
.description('AI-powered git auto-commit tool')
|
|
16
|
+
.version(packageJson.version, '-v, --version', 'Show version number')
|
|
17
|
+
.option('-a, --all', 'Process all changes grouped by folders', false)
|
|
18
|
+
.option('-i, --ignore <patterns>', 'Comma-separated patterns to exclude', '')
|
|
19
|
+
.option('--no-push', 'Skip pushing after commit')
|
|
20
|
+
.option('-c, --config <path>', 'Path to custom config file')
|
|
21
|
+
.action(async (options: CommitOptions) => {
|
|
22
|
+
try {
|
|
23
|
+
const configLoader = new ConfigLoader();
|
|
24
|
+
const config = await configLoader.load(options.config);
|
|
25
|
+
|
|
26
|
+
const handler = new CommitHandler(config);
|
|
27
|
+
await handler.execute(options);
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (error instanceof Error) {
|
|
30
|
+
console.error(chalk.red(`\n❌ Fatal error: ${error.message}\n`));
|
|
31
|
+
} else {
|
|
32
|
+
console.error(chalk.red('\n❌ An unknown fatal error occurred\n'));
|
|
33
|
+
}
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
program
|
|
39
|
+
.command('set <key-value>')
|
|
40
|
+
.description('Set configuration value (format: KEY=value or KEY to prompt)')
|
|
41
|
+
.option('-l, --local', 'Set in project .commita file instead of global ~/.commita')
|
|
42
|
+
.action(async (keyValue: string, options: SetOptions) => {
|
|
43
|
+
try {
|
|
44
|
+
const handler = new SetHandler();
|
|
45
|
+
await handler.execute(keyValue, options);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
if (error instanceof Error) {
|
|
48
|
+
console.error(chalk.red(`\n❌ Error: ${error.message}\n`));
|
|
49
|
+
} else {
|
|
50
|
+
console.error(chalk.red('\n❌ An unknown error occurred\n'));
|
|
51
|
+
}
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
await program.parseAsync(process.argv);
|
|
57
|
+
}
|
|
58
|
+
|