├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── demo.png └── logo.jpg ├── cmd ├── actions.go └── commands.go ├── go.mod ├── go.sum ├── internal ├── ai │ ├── ai.go │ └── generic │ │ └── generic.go └── git │ ├── diff.go │ ├── git_test.go │ └── repo.go ├── main.go └── pkg ├── config └── config.go └── utils ├── emoji.go └── prompt.go /.gitignore: -------------------------------------------------------------------------------- 1 | # Go workspace file 2 | go.work 3 | go.work.sum 4 | 5 | # env file 6 | .env 7 | 8 | .git 9 | 10 | /build 11 | gitc 12 | 13 | # config file 14 | config.json -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## [0.2.0] - 2025-05-15 4 | ### Added 5 | - Experimental support for Grok (xAI) and DeepSeek AI providers. 6 | - New `--url` flag for custom API endpoints. 7 | - Interactive mode for commit message preview and editing. 8 | 9 | ### Changed 10 | - Updated README with accurate provider status and improved clarity. 11 | - Revised config structure to remove `open_ai` field. 12 | 13 | ### Fixed 14 | - API key persistence issues in configuration. 15 | - Improved validation for configuration settings. 16 | 17 | ## [0.1.1] - 2025-04-01 18 | - Initial release with OpenAI support. -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # 🤝 Contributing to `gitc` 2 | 3 | Thank you for your interest in contributing to **`gitc`** — an AI-powered CLI tool for generating commit messages from your git diffs. 4 | 5 | We welcome all types of contributions, whether you're fixing bugs, suggesting features, improving documentation, or writing tests. 6 | 7 | 8 | ## 📌 Table of Contents 9 | * [Code of Conduct](#-code-of-conduct) 10 | * [Getting Started](#-getting-started) 11 | * [Development Setup](#-development-setup) 12 | * [Making Contributions](#-making-contributions) 13 | * [Commit Message Guidelines](#-commit-message-guidelines) 14 | * [Pull Request Process](#-pull-request-process) 15 | * [Feature Suggestions & Bugs](#-feature-suggestions--bugs) 16 | 17 | ## 📜 Code of Conduct 18 | We follow a [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). Please treat everyone respectfully and kindly. 19 | 20 | ## ⚙️ Getting Started 21 | 1. **Fork** the repository. 22 | 2. **Clone** your fork locally: 23 | ```bash 24 | git clone https://github.com//gitc.git 25 | cd gitc 26 | ``` 27 | 3. Create a new branch: 28 | ```bash 29 | git checkout -b feature/my-feature 30 | ``` 31 | 32 | ## 🛠 Development Setup 33 | Make sure you have: 34 | * Go ≥ **1.18** 35 | * Git 36 | * (Optional) OpenAI API Key for testing 37 | 38 | Install dependencies: 39 | ```bash 40 | go mod tidy 41 | ``` 42 | 43 | Build the project: 44 | ```bash 45 | go build -o gitc ./cmd/gitc 46 | ``` 47 | 48 | Run the tool: 49 | ```bash 50 | ./gitc --help 51 | ``` 52 | 53 | Run tests: 54 | ```bash 55 | go test ./... 56 | ``` 57 | 58 | ## ✍️ Making Contributions 59 | You can contribute in the following ways: 60 | * 🐛 Bug Fixes 61 | * 📄 Documentation Improvements 62 | * 🚀 New Features 63 | * ✅ Tests and Coverage 64 | * 💡 Suggesting Ideas and Discussions 65 | 66 | If unsure, [open a discussion](https://github.com/rezatg/gitc/discussions) or [create an issue](https://github.com/rezatg/gitc/issues) before starting work. 67 | 68 | ## 🧾 Commit Message Guidelines 69 | We follow [Conventional Commits](https://www.conventionalcommits.org) to ensure readable, semantic commit history. 70 | 71 | Example: 72 | ```bash 73 | feat(config): add support for Gemini provider 74 | fix(cli): handle missing config gracefully 75 | docs(readme): update installation instructions 76 | ``` 77 | 78 | > You can even use `gitc` itself to generate commit messages: 79 | ```bash 80 | gitc -a --commit-type feat 81 | ``` 82 | 83 | ## 🚀 Pull Request Process 84 | sure your PR targets the `main` branch. 85 | 2. Make sure all tests pass. 86 | 3. Write a meaningful title and description. 87 | 4. Link related issues (e.g., `Closes #42`). 88 | 5. Add relevant labels if possible. 89 | 6. Wait for code review and address feedback. 90 | 91 | ## 💡 Feature Suggestions & Bugs 92 | * 💬 Found a bug? [Open an issue](https://github.com/rezatg/gitc/issues) 93 | * 💡 Have a feature idea? [Start a discussion](https://github.com/rezatg/gitc/discussions) 94 | * 🙌 Want to help but don’t know where to start? Look for [good first issues](https://github.com/rezatg/gitc/labels/good%20first%20issue) 95 | 96 | ## 🫶 Thank You! 97 | Your time, effort, and ideas make `gitc` better. We're thrilled to have you here 🙌 98 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Reza-TG 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | gitc AI-Powered Commits 3 |
4 | 5 | # ✨ gitc - AI-Powered Git Commit Messages 6 | 7 | [![Go Reference](https://pkg.go.dev/badge/github.com/rezatg/gitc)](https://pkg.go.dev/github.com/rezatg/gitc) 8 | [![Go Version](https://img.shields.io/github/go-mod/go-version/rezatg/gitc?logo=go)](go.mod) 9 | [![Sourcegraph](https://sourcegraph.com/github.com/rezatg/gitc/-/badge.svg)](https://sourcegraph.com/github.com/rezatg/gitc?badge) 10 | [![Discussions](https://img.shields.io/github/discussions/rezatg/gitc?color=58a6ff&label=Discussions&logo=github)](https://github.com/rezatg/gitc/discussions) 11 | [![Downloads](https://img.shields.io/github/downloads/rezatg/gitc/total?color=blue)](https://github.com/rezatg/gitc/releases) 12 | [![License](https://img.shields.io/badge/license-MIT-green)](LICENSE) 13 | [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/rezatg/gitc) 14 | 15 |
16 | Installation • 17 | Features • 18 | Configuration • 19 | Usage • 20 | Full Options • 21 | AI Providers 22 |
23 |
24 | 25 | > `gitc` is a fast, lightweight CLI tool that uses AI to generate clear, consistent, and standards-compliant commit messages — directly from your Git diffs. With built-in support for [Conventional Commits](https://www.conventionalcommits.org), [Gitmoji](https://gitmoji.dev), and fully customizable rules, `gitc` helps you and your team write better commits, faster 26 | 27 | # 🚀 Features 28 | `gitc` streamlines your Git workflow by automating professional commit message creation with AI. Its robust feature set ensures flexibility and precision for developers and teams. 29 | 30 | - ### 🧠 AI and Commit Generation 31 | - **AI-Powered Commit Messages**: Generates high-quality commit messages using OpenAI's API, analyzing staged git changes for context-aware results. 32 | - **Multilingual Support**: Creates commit messages in multiple languages (e.g., English, Persian, Russian) to suit global teams. 33 | - **Extensible AI Providers**: Supports OpenAI with plans for Anthropic and other providers, ensuring future-proofing. 34 | 35 | - ### 📝 Commit Standards and Customization 36 | - **Conventional Commits**: Adheres to standard commit types (`feat`, `fix`, `docs`, `style`, `refactor`, `test`, `chore`, `build`, `ci`, `revert`, `init`, `security`) for semantic versioning. 37 | - **Gitmoji Integration**: Optionally adds Gitmoji emojis (e.g., ✨ for `feat`, 🚑 for `fix`) for visually appealing commits. 38 | - **Custom Commit Conventions**: Supports JSON-based custom prefixes (e.g., JIRA ticket IDs) for tailored commit formats. 39 | 40 | - ### 🔧 Git Integration 41 | - **Optimized Git Diff Processing**: Automatically retrieves and filters staged git diff, excluding irrelevant files (e.g., `node_modules/*`, `*.lock`). 42 | - **Configurable Exclusions**: Customize file exclusion patterns via config file to focus on relevant changes. 43 | 44 | - ### ⚙️ Environment & Configuration 45 | - **Flexible Configuration**: Customize via CLI flags, environment variables, or a JSON config file (`~/.gitc/config.json`). 46 | - **Proxy Support**: Configurable proxy settings for API requests in restricted environments. 47 | - **Timeout and Redirect Control**: Adjustable timeouts and HTTP redirect limits for reliable API interactions. 48 | - **Environment Variable Support**: Simplifies setup for sensitive data (e.g., API keys) and common settings. 49 | 50 | - ### ⚡️ Performance & Reliability 51 | - **Fast Processing**: Leverages [sonic](https://github.com/bytedance/sonic) for rapid JSON parsing and [fasthttp](https://github.com/valyala/fasthttp) for efficient HTTP requests. 52 | - **Error Handling**: Robust validation and error messages ensure reliable operation. 53 | 54 | ## 📦 Installation 55 | ### Prerequisites: 56 | - Go: Version **1.18** or higher (required for building from source). 57 | - Git: Required for retrieving staged changes. 58 | - OpenAI API Key: Required for AI-powered commit message generation. Set it via the `AI_API_KEY` environment variable or in the config file. 59 | 60 | #### Quick Install: 61 | ```bash 62 | go install github.com/rezatg/gitc@latest 63 | ``` 64 | 65 | ### Manual Install 66 | 1. Download binary from [releases](https://github.com/rezatg/gitc/releases) 67 | 2. `chmod +x gitc` 68 | 3. Move to `/usr/local/bin` 69 | 70 | ### Verify Installation 71 | After installation, verify the tool is installed correctly and check its version: 72 | ```bash 73 | gitc --version 74 | ``` 75 | 76 | # 💻 Basic Usage 77 | ```bash 78 | # 1. Stage your changes 79 | git add . # or gitc -a 80 | 81 | # 2. Generate perfect commit message 82 | gitc 83 | 84 | # Pro Tip: Add emojis and specify language 85 | gitc --emoji --lang fa 86 | 87 | # Custom commit type 88 | gitc --commit-type fix 89 | ``` 90 | 91 | ## Environment Variables 92 | ```bash 93 | export OPENAI_API_KEY="sk-your-key-here" 94 | export GITC_LANGUAGE="fa" 95 | export GITC_MODEL="gpt-4" 96 | ``` 97 | 98 | # ⚙️ Configuration 99 | Config File (`~/.gitc/config.json`) : 100 | ```json 101 | { 102 | "provider": "openai", 103 | "max_length": 200, 104 | "proxy": "", 105 | "language": "en", 106 | "timeout": 10, 107 | "commit_type": "", 108 | "custom-convention": "", 109 | "use_gitmoji": false, 110 | "max_redirects": 5, 111 | "open_ai": { 112 | "api_key": "sk-your-key-here", 113 | "model": "gpt-4o-mini", 114 | "url": "https://api.openai.com/v1/chat/completions" 115 | } 116 | } 117 | ``` 118 | 119 | ### Update Configuration 120 | ```bash 121 | gitc config --api-key "sk-your-key-here" --model "gpt-4o-mini" --lang en 122 | ``` 123 | 124 | 125 | # 📚 Full Options 126 | The following CLI flags are available for the `ai-commit` command and its `config` subcommand. All flags can also be set via environment variables or the `~/.gitc/config.json` file. 127 | 128 | | Flag | Alias | Description | Default | Environment Variable | Example | 129 | |------|-------|-------------|---------|----------------------|---------| 130 | | `--all` | `-a` | Stage all changes before generating commit message (equivalent to `git add .`) | `false` | `GITC_STAGE_ALL` | `-all` or `-a` 131 | | `--provider` | - | AI provider to use (e.g., `openai`, `anthropic`) | `openai` | `AI_PROVIDER` | `--provider openai` | 132 | | `--url` | `-u` | Custom API URL for the AI provider | Provider-specific | `GITC_API_URL` | `--url https://api.x.ai/v1/chat/completions` 133 | | `--model` | - | OpenAI model for commit message generation | `gpt-4o-mini` | - | `--model gpt-4o` | 134 | | `--lang` | - | Language for commit messages (e.g., `en`, `fa`, `ru`) | `en` | `GITC_LANGUAGE` | `--lang fa` | 135 | | `--timeout` | - | Request timeout in seconds | `10` | - | `--timeout 15` | 136 | | `--maxLength` | - | Maximum length of the commit message | `200` | - | `--maxLength 150` | 137 | | `--api-key` | `-k` | API key for the AI provider | - | `AI_API_KEY` | `--api-key sk-xxx` | 138 | | `--proxy` | `-p` | Proxy URL for API requests | - | `GITC_PROXY` | `--proxy http://proxy.example.com:8080` | 139 | | `--commit-type` | `-t` | Commit type for Conventional Commits (e.g., `feat`, `fix`) | - | `GITC_COMMIT_TYPE` | `--commit-type feat` | 140 | | `--custom-convention` | `-C` | Custom commit message convention (JSON format) | - | `GITC_CUSTOM_CONVENTION` | `--custom-convention '{"prefix": "JIRA-123"}'` | 141 | | `--emoji` | `-g` | Add Gitmoji to the commit message | `false` | `GITC_GITMOJI` | `--emoji` | 142 | | `--no-emoji` | - | Disables Gitmoji in commit messages (overrides `--emoji` and config file) | `false` | - | `--no-emoji` 143 | | `--max-redirects` | `-r` | Maximum number of HTTP redirects | `5` | `GITC_MAX_REDIRECTS` | `--max-redirects 10` | 144 | | `--config` | `-c` | Path to the configuration file | `~/.gitc/config.json` | `GITC_CONFIG_PATH` | `--config ./my-config.json` | 145 | 146 | > [!NOTE] 147 | > - Flags for the `config` subcommand are similar but exclude defaults, as they override the config file. 148 | > - **Flags** > **Environment Variables** > **Config File** — This is the order of precedence when multiple settings are provided. 149 | > - The `--custom-convention` flag expects a JSON string with a `prefix` field (e.g., `{"prefix": "JIRA-123"}`). 150 | > - The `--version` flag displays the current tool version (e.g., `0.2.0`) and can be used to verify installation. 151 | > - The `--all` flag (alias `-a`) stages all changes in the working directory before generating the commit message, streamlining the workflow. For example, `gitc -a --emoji` stages all changes and generates a commit message with Gitmoji. 152 | > - Environment variables take precedence over config file settings but are overridden by CLI flags. 153 | > - You can reset all configuration values to their defaults by using gitc config `gitc reset-config`. 154 | 155 | 156 | ## 🤖 AI Providers 157 | `gitc` is designed to be AI-provider agnostic. While it currently supports OpenAI out of the box, support for additional providers is on the roadmap to ensure flexibility and future-proofing. 158 | 159 | | Provider | Supported Models | Required Configuration | Status | 160 | | --- | --- | --- | --- | 161 | | **OpenAI** | `gpt-4o`, `gpt-4o-mini`, `gpt-3.5-turbo` | `api_key`, `model`, `url` (optional) | ✅ Supported (default) | 162 | | **Grok (xAI)** | grok-3 (experimental) | `api_key`, `model`, `url` | 🧪 Experimental Support | 163 | | **DeepSeek** | deepseek-rag (experimental) | `api_key`, `model`, `url` | 🧪 Experimental Support | 164 | | **Gemini (Google)** | Coming Soon | - | 🔜 Planned | 165 | | **Others** | - | - | 🧪 Under consideration | 166 | > ℹ️ We're actively working on supporting multiple AI backends to give you more control, flexibility, and performance. Have a provider you'd like to see? [Open a discussion](https://github.com/rezatg/gitc/discussions)! 167 | 168 | ## 🤝 Contributing 169 | 170 | We welcome contributions! Please check out the [contributing guide](CONTRIBUTING.md) before making a PR. 171 | 172 | ## ⭐️ Star History 173 | [![Star History Chart](https://api.star-history.com/svg?repos=rezatg/gitc&type=Date)](https://www.star-history.com/#rezatg/gitc&Date) 174 | -------------------------------------------------------------------------------- /assets/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rezatg/gitc/bc4416770d61e0ff0e85b26d3bd4683cb807475f/assets/demo.png -------------------------------------------------------------------------------- /assets/logo.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rezatg/gitc/bc4416770d61e0ff0e85b26d3bd4683cb807475f/assets/logo.jpg -------------------------------------------------------------------------------- /cmd/actions.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strings" 7 | "time" 8 | 9 | "github.com/rezatg/gitc/internal/ai" 10 | "github.com/rezatg/gitc/internal/ai/generic" 11 | "github.com/rezatg/gitc/internal/git" 12 | "github.com/rezatg/gitc/pkg/config" 13 | "github.com/rezatg/gitc/pkg/utils" 14 | "github.com/urfave/cli/v2" 15 | ) 16 | 17 | // App encapsulates the application logic and dependencies 18 | type App struct { 19 | gitService git.GitService 20 | config *config.Config 21 | } 22 | 23 | // NewApp creates a new App instance 24 | func NewApp(gitService git.GitService, config *config.Config) *App { 25 | return &App{ 26 | gitService: gitService, 27 | config: config, 28 | } 29 | } 30 | 31 | // ConfigureAI constructs the AI configuration with proper validation 32 | func (a *App) ConfigureAI(c *cli.Context) (*ai.Config, error) { 33 | cfg := &ai.Config{ 34 | Provider: c.String("provider"), 35 | Model: c.String("model"), 36 | APIKey: c.String("api-key"), 37 | Timeout: time.Duration(c.Int("timeout")) * time.Second, 38 | MaxLength: c.Int("max-length"), 39 | Language: c.String("lang"), 40 | MaxRedirects: c.Int("max-redirects"), 41 | Proxy: c.String("proxy"), 42 | CommitType: c.String("commit-type"), 43 | CustomConvention: c.String("custom-convention"), 44 | UseGitmoji: !c.Bool("no-emoji") && c.Bool("emoji"), 45 | URL: c.String("url"), 46 | } 47 | 48 | // Apply config defaults 49 | a.applyConfigDefaults(cfg) 50 | 51 | // Validate required fields 52 | if err := a.validateConfig(a.config); err != nil { 53 | return nil, fmt.Errorf("invalid AI configuration: %w", err) 54 | } 55 | 56 | return cfg, nil 57 | } 58 | 59 | // generateCommitMessage generates a commit message based on git diff and AI configuration. 60 | func (a *App) generateCommitMessage(ctx context.Context, diff string, cfg *ai.Config) (string, error) { 61 | provider, err := a.initAIProvider(cfg) 62 | if err != nil { 63 | return "", fmt.Errorf("failed to initialize AI provider: %w", err) 64 | } 65 | 66 | ctx, cancel := context.WithTimeout(ctx, cfg.Timeout) 67 | defer cancel() 68 | 69 | opts := ai.MessageOptions{ 70 | Model: cfg.Model, 71 | Language: cfg.Language, 72 | CommitType: cfg.CommitType, 73 | CustomConvention: cfg.CustomConvention, 74 | MaxLength: cfg.MaxLength, 75 | MaxRedirects: cfg.MaxRedirects, 76 | } 77 | 78 | msg, err := provider.GenerateCommitMessage(ctx, diff, opts) 79 | if err != nil { 80 | return "", fmt.Errorf("failed to generate commit message: %w", err) 81 | } 82 | 83 | // Apply Gitmoji if enabled 84 | if cfg.UseGitmoji { 85 | msg = utils.AddGitmojiToCommitMessage(msg) 86 | } 87 | 88 | return msg, nil 89 | } 90 | 91 | // CommitAction handles the generation of commit messages 92 | func (a *App) CommitAction(c *cli.Context) error { 93 | // Stage all changes if --all (-a) flag is set 94 | if c.Bool("all") { 95 | if err := a.gitService.StageAll(c.Context); err != nil { 96 | return fmt.Errorf("❌ failed to stage changes: %v", err) 97 | } 98 | 99 | fmt.Println("✅ All changes staged successfully") 100 | } 101 | 102 | // Fetch git diff for staged changes 103 | diff, err := a.gitService.GetDiff(c.Context) 104 | if err != nil { 105 | return fmt.Errorf("❌ failed to get git diff: %v", err) 106 | } else if diff == "" { 107 | return fmt.Errorf("❌ nothing staged for commit") 108 | } 109 | 110 | // Configure AI settings 111 | cfg, err := a.ConfigureAI(c) 112 | if err != nil { 113 | return fmt.Errorf("❌ failed to build AI config: %w", err) 114 | } 115 | 116 | // Generate commit message 117 | msg, err := a.generateCommitMessage(c.Context, diff, cfg) 118 | if err != nil { 119 | return fmt.Errorf("❌ failed to generate commit message: %w", err) 120 | } 121 | 122 | fmt.Println("✅ Commit message generated. You can now run:") 123 | fmt.Printf(" git commit -m \"%s\"\n", strings.ReplaceAll(msg, "\n", "")) 124 | return nil 125 | } 126 | 127 | // ConfigAction handles configuration updates 128 | func (a *App) ConfigAction(c *cli.Context) error { 129 | cfg := *a.config 130 | a.updateConfigFromFlags(&cfg, c) 131 | 132 | if err := a.validateConfig(&cfg); err != nil { 133 | return fmt.Errorf("invalid configuration: %w", err) 134 | } 135 | 136 | if err := config.Save(&cfg); err != nil { 137 | return fmt.Errorf("failed to save config: %w", err) 138 | } 139 | 140 | a.config = &cfg 141 | fmt.Println("✅ Configuration updated successfully") 142 | return nil 143 | } 144 | 145 | // applyConfigDefaults sets default values for unset AI configuration fields. 146 | func (a *App) applyConfigDefaults(cfg *ai.Config) { 147 | if cfg.Provider == "" { 148 | cfg.Provider = a.config.Provider 149 | } 150 | if cfg.Model == "" { 151 | switch cfg.Provider { 152 | case "openai": 153 | cfg.Model = "gpt-4o-mini" 154 | case "grok": 155 | cfg.Model = "grok-3" 156 | case "deepseek": 157 | cfg.Model = "deepseek-rag" 158 | default: 159 | cfg.Model = a.config.Model 160 | } 161 | } 162 | if cfg.APIKey == "" { 163 | cfg.APIKey = a.config.APIKey 164 | } 165 | if cfg.Timeout == 0 { 166 | cfg.Timeout = time.Duration(a.config.Timeout) * time.Second 167 | } 168 | if cfg.MaxLength == 0 { 169 | cfg.MaxLength = a.config.MaxLength 170 | } 171 | if cfg.Language == "" { 172 | cfg.Language = a.config.Language 173 | } 174 | if cfg.MaxRedirects == 0 { 175 | cfg.MaxRedirects = a.config.MaxRedirects 176 | } 177 | if cfg.URL == "" { 178 | switch cfg.Provider { 179 | case "openai": 180 | cfg.URL = "https://api.openai.com/v1/chat/completions" 181 | case "grok": 182 | cfg.URL = "https://api.x.ai/v1/chat/completions" 183 | case "deepseek": 184 | cfg.URL = "https://api.deepseek.com/v1/chat/completions" // فرضی 185 | default: 186 | cfg.URL = a.config.URL 187 | } 188 | } 189 | } 190 | 191 | // initAIProvider initializes the AI provider based on the configuration. 192 | func (a *App) initAIProvider(cfg *ai.Config) (ai.AIProvider, error) { 193 | return generic.NewGenericProvider(cfg.APIKey, cfg.Proxy, cfg.URL, cfg.Provider) 194 | } 195 | 196 | // validateConfig checks if the AI configuration is valid. 197 | func (a *App) validateConfig(cfg *config.Config) error { 198 | if cfg.Provider == "" { 199 | return fmt.Errorf("AI provider is required") 200 | } 201 | if cfg.APIKey == "" { 202 | return fmt.Errorf("API key is required") 203 | } 204 | if cfg.Timeout <= 0 { 205 | return fmt.Errorf("timeout must be positive") 206 | } 207 | if cfg.MaxLength <= 0 { 208 | return fmt.Errorf("max length must be positive") 209 | } 210 | return nil 211 | } 212 | 213 | // updateConfigFromFlags updates the configuration based on CLI flags. 214 | func (a *App) updateConfigFromFlags(cfg *config.Config, c *cli.Context) { 215 | if provider := c.String("provider"); provider != "" { 216 | cfg.Provider = provider 217 | } 218 | if model := c.String("model"); model != "" { 219 | cfg.Model = model 220 | } 221 | if apiKey := c.String("api-key"); apiKey != "" { 222 | cfg.APIKey = apiKey 223 | } 224 | if lang := c.String("lang"); lang != "" { 225 | cfg.Language = lang 226 | } 227 | if timeout := c.Int("timeout"); timeout != 0 { 228 | cfg.Timeout = timeout 229 | } 230 | if maxLength := c.Int("maxLength"); maxLength != 0 { 231 | cfg.MaxLength = maxLength 232 | } 233 | if proxy := c.String("proxy"); proxy != "" { 234 | cfg.Proxy = proxy 235 | } 236 | if commitType := c.String("commit-type"); commitType != "" { 237 | cfg.CommitType = commitType 238 | } 239 | if customConvention := c.String("custom-convention"); customConvention != "" { 240 | cfg.CustomConvention = customConvention 241 | } 242 | if c.IsSet("no-emoji") { 243 | cfg.UseGitmoji = !c.Bool("no-emoji") 244 | } else if c.IsSet("emoji") { 245 | cfg.UseGitmoji = c.Bool("emoji") 246 | } 247 | if maxRedirects := c.Int("max-redirects"); maxRedirects != 0 { 248 | cfg.MaxRedirects = maxRedirects 249 | } 250 | if url := c.String("url"); url != "" { 251 | cfg.URL = url 252 | } 253 | } 254 | -------------------------------------------------------------------------------- /cmd/commands.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/rezatg/gitc/internal/git" 7 | "github.com/rezatg/gitc/pkg/config" 8 | "github.com/urfave/cli/v2" 9 | ) 10 | 11 | // Version defines the current version of the gitc tool. 12 | const Version = "0.2.0" 13 | 14 | var appInstance *App 15 | 16 | // Commands defines the CLI application configuration. 17 | var Commands = &cli.App{ 18 | Name: "gitc", 19 | Usage: "Generate AI-powered commit messages", 20 | Version: Version, 21 | Flags: []cli.Flag{ 22 | &cli.BoolFlag{ 23 | Name: "all", 24 | Aliases: []string{"a"}, 25 | Usage: "Stage all changes before generating commit message (equivalent to 'git add .')", 26 | EnvVars: []string{"GITC_STAGE_ALL"}, 27 | }, 28 | &cli.StringFlag{ 29 | Name: "provider", 30 | Value: "openai", 31 | Usage: "AI provider to use (openai, anthropic)", 32 | }, 33 | &cli.StringFlag{ 34 | Name: "model", 35 | Value: "gpt-4o-mini", 36 | Usage: "Specify the OpenAI model", 37 | }, 38 | &cli.StringFlag{ 39 | Name: "lang", 40 | Value: "en", 41 | Usage: "Set commit message language (en, fa, ru, etc.)", 42 | }, 43 | &cli.IntFlag{ 44 | Name: "timeout", 45 | Value: 10, 46 | Usage: "Set request timeout in seconds", 47 | }, 48 | &cli.IntFlag{ 49 | Name: "maxLength", 50 | Value: 200, 51 | Usage: "Set maximum output length of AI response", 52 | }, 53 | &cli.StringFlag{ 54 | Name: "api-key", 55 | Aliases: []string{"k"}, 56 | Usage: "API key for the AI provider", 57 | EnvVars: []string{"AI_API_KEY"}, 58 | }, 59 | &cli.StringFlag{ 60 | Name: "proxy", 61 | Aliases: []string{"p"}, 62 | Usage: "Proxy URL for API requests (e.g., http://proxy.example.com:8080)", 63 | EnvVars: []string{"GITC_PROXY"}, 64 | }, 65 | &cli.StringFlag{ 66 | Name: "commit-type", 67 | Aliases: []string{"t"}, 68 | Usage: "Commit type for Conventional Commits (e.g., feat, fix, docs)", 69 | EnvVars: []string{"GITC_COMMIT_TYPE"}, 70 | }, 71 | &cli.StringFlag{ 72 | Name: "custom-convention", 73 | Aliases: []string{"C"}, 74 | Usage: "Custom commit message convention in JSON format (e.g., '{\"prefix\": \"JIRA-123\"}')", 75 | EnvVars: []string{"GITC_CUSTOM_CONVENTION"}, 76 | }, 77 | &cli.BoolFlag{ 78 | Name: "emoji", 79 | Aliases: []string{"g"}, 80 | Usage: "Add Gitmoji to the commit message based on commit type", 81 | EnvVars: []string{"GITC_GITMOJI"}, 82 | }, 83 | &cli.BoolFlag{ 84 | Name: "no-emoji", 85 | Usage: "Disable Gitmoji in the commit message (overrides --emoji)", 86 | }, 87 | &cli.IntFlag{ 88 | Name: "max-redirects", 89 | Aliases: []string{"r"}, 90 | Value: 5, 91 | Usage: "Maximum number of HTTP redirects to follow", 92 | EnvVars: []string{"GITC_MAX_REDIRECTS"}, 93 | }, 94 | &cli.StringFlag{ 95 | Name: "config", 96 | Aliases: []string{"c"}, 97 | Usage: "Path to config file", 98 | EnvVars: []string{"GITC_CONFIG_PATH"}, 99 | }, 100 | }, 101 | Before: func(c *cli.Context) error { 102 | // Set config path if provided via flag or environment variable 103 | if configPath := c.String("config"); configPath != "" { 104 | config.SetConfigPath(configPath) 105 | } 106 | 107 | // Load config 108 | cfg, err := config.Load() 109 | if err != nil { 110 | return fmt.Errorf("failed to load config: %w", err) 111 | } 112 | 113 | // Initialize dependencies 114 | gitService := git.NewGitService() 115 | appInstance = NewApp(gitService, cfg) 116 | return nil 117 | }, 118 | Action: func(c *cli.Context) error { 119 | return appInstance.CommitAction(c) 120 | }, 121 | Commands: []*cli.Command{ 122 | { 123 | Name: "config", 124 | Aliases: []string{"cfg"}, 125 | Usage: "Configure AI provider settings", 126 | Flags: []cli.Flag{ 127 | &cli.StringFlag{ 128 | Name: "provider", 129 | Aliases: []string{"ai"}, 130 | Usage: "AI provider to use (openai, anthropic)", 131 | }, 132 | &cli.StringFlag{ 133 | Name: "model", 134 | Usage: "Specify the OpenAI model", 135 | }, 136 | &cli.StringFlag{ 137 | Name: "lang", 138 | Usage: "Set commit message language (en, fa, ru, etc.)", 139 | }, 140 | &cli.StringFlag{ 141 | Name: "proxy", 142 | Aliases: []string{"p"}, 143 | Usage: "Proxy URL for API requests (e.g., http://proxy.example.com:8080)", 144 | }, 145 | &cli.IntFlag{ 146 | Name: "timeout", 147 | Usage: "Set request timeout in seconds", 148 | }, 149 | &cli.IntFlag{ 150 | Name: "maxLength", 151 | Usage: "Set maximum output length of AI response", 152 | }, 153 | &cli.IntFlag{ 154 | Name: "max-redirects", 155 | Aliases: []string{"r"}, 156 | Usage: "Set maximum number of HTTP redirects", 157 | }, 158 | &cli.StringFlag{ 159 | Name: "api-key", 160 | Aliases: []string{"k"}, 161 | Usage: "API key for the AI provider", 162 | }, 163 | &cli.StringFlag{ 164 | Name: "commit-type", 165 | Aliases: []string{"t"}, 166 | Usage: "Commit type for Conventional Commits (e.g., feat, fix, docs)", 167 | }, 168 | &cli.StringFlag{ 169 | Name: "custom-convention", 170 | Aliases: []string{"C"}, 171 | Usage: "Custom commit message convention in JSON format (e.g., '{\"prefix\": \"JIRA-123\"}')", 172 | }, 173 | &cli.BoolFlag{ 174 | Name: "emoji", 175 | Aliases: []string{"g"}, 176 | Usage: "Add Gitmoji to the commit message based on commit type", 177 | }, 178 | &cli.BoolFlag{ 179 | Name: "no-emoji", 180 | Usage: "Disable Gitmoji in the commit message", 181 | }, 182 | &cli.StringFlag{ 183 | Name: "config", 184 | Aliases: []string{"c"}, 185 | Usage: "Path to config file", 186 | EnvVars: []string{"GITC_CONFIG_PATH"}, 187 | }, 188 | }, 189 | Action: func(c *cli.Context) error { 190 | // Set config path if provided 191 | if configPath := c.String("config"); configPath != "" { 192 | config.SetConfigPath(configPath) 193 | } 194 | 195 | // Load config 196 | cfg, err := config.Load() 197 | if err != nil { 198 | return fmt.Errorf("failed to load config: %w", err) 199 | } 200 | 201 | // Initialize dependencies 202 | gitService := git.NewGitService() 203 | app := NewApp(gitService, cfg) 204 | return app.ConfigAction(c) 205 | }, 206 | }, { 207 | Name: "reset-config", 208 | Usage: "Reset gitc configuration to default values", 209 | Action: func(c *cli.Context) error { 210 | if err := config.Reset(); err != nil { 211 | return fmt.Errorf("failed to reset config: %w", err) 212 | } 213 | 214 | fmt.Println("✅ Configuration reset to defaults.") 215 | return nil 216 | }, 217 | }, 218 | }, 219 | } 220 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/rezatg/gitc 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/bytedance/sonic v1.13.2 7 | github.com/urfave/cli/v2 v2.27.6 8 | github.com/valyala/fasthttp v1.62.0 9 | ) 10 | 11 | require ( 12 | github.com/andybalholm/brotli v1.1.1 // indirect 13 | github.com/bytedance/sonic/loader v0.2.4 // indirect 14 | github.com/cloudwego/base64x v0.1.5 // indirect 15 | github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect 16 | github.com/klauspost/compress v1.18.0 // indirect 17 | github.com/klauspost/cpuid/v2 v2.0.9 // indirect 18 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 19 | github.com/twitchyliquid64/golang-asm v0.15.1 // indirect 20 | github.com/valyala/bytebufferpool v1.0.0 // indirect 21 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 22 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect 23 | golang.org/x/net v0.40.0 // indirect 24 | golang.org/x/text v0.25.0 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= 2 | github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= 3 | github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= 4 | github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4= 5 | github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= 6 | github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= 7 | github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= 8 | github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4= 9 | github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= 10 | github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY= 11 | github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= 12 | github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= 17 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 18 | github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4= 19 | github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= 20 | github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M= 21 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 22 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 23 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 24 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 25 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 26 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 27 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 28 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 29 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 30 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 31 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 32 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 33 | github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= 34 | github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= 35 | github.com/urfave/cli/v2 v2.27.6 h1:VdRdS98FNhKZ8/Az8B7MTyGQmpIr36O1EHybx/LaZ4g= 36 | github.com/urfave/cli/v2 v2.27.6/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= 37 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 38 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 39 | github.com/valyala/fasthttp v1.62.0 h1:8dKRBX/y2rCzyc6903Zu1+3qN0H/d2MsxPPmVNamiH0= 40 | github.com/valyala/fasthttp v1.62.0/go.mod h1:FCINgr4GKdKqV8Q0xv8b+UxPV+H/O5nNFo3D+r54Htg= 41 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= 42 | github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= 43 | github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= 44 | github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= 45 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670 h1:18EFjUmQOcUvxNYSkA6jO9VAiXCnxFY6NyDX0bHDmkU= 46 | golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= 47 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 48 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 49 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 50 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 51 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 52 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 53 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 54 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 55 | nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50= 56 | -------------------------------------------------------------------------------- /internal/ai/ai.go: -------------------------------------------------------------------------------- 1 | package ai 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // AIProvider defines the interface for AI providers 9 | type AIProvider interface { 10 | GenerateCommitMessage(ctx context.Context, diff string, opts MessageOptions) (string, error) 11 | } 12 | 13 | // Config holds AI provider configuration 14 | type Config struct { 15 | Provider string 16 | APIKey string 17 | URL string 18 | Timeout time.Duration 19 | MaxLength int 20 | Model string 21 | Language string 22 | CommitType string 23 | CustomConvention string 24 | MaxRedirects int 25 | UseGitmoji bool 26 | 27 | Proxy string 28 | } 29 | 30 | type MessageOptions struct { 31 | Model string 32 | Language string 33 | CommitType string 34 | CustomConvention string 35 | MaxLength int 36 | MaxRedirects int 37 | } 38 | -------------------------------------------------------------------------------- /internal/ai/generic/generic.go: -------------------------------------------------------------------------------- 1 | package generic 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/bytedance/sonic" 10 | "github.com/rezatg/gitc/internal/ai" 11 | "github.com/rezatg/gitc/pkg/utils" 12 | "github.com/valyala/fasthttp" 13 | "github.com/valyala/fasthttp/fasthttpproxy" 14 | ) 15 | 16 | // Default URLs for supported providers 17 | const ( 18 | defaultOpenAIURL = "https://api.openai.com/v1/chat/completions" 19 | defaultGrokURL = "https://api.x.ai/v1/chat/completions" 20 | defaultDeepSeekURL = "https://api.deepseek.com/v1/chat/completions" 21 | systemPrompt = "You are an AI assistant that generates concise and meaningful Git commit messages." 22 | ) 23 | 24 | // GenericProvider implements the AIProvider interface for OpenAI-compatible APIs 25 | type GenericProvider struct { 26 | apiKey string 27 | client *fasthttp.Client 28 | url string 29 | provider string 30 | } 31 | 32 | // NewGenericProvider creates a new provider for OpenAI-compatible APIs 33 | func NewGenericProvider(apiKey, proxy, url, provider string) (*GenericProvider, error) { 34 | if apiKey == "" { 35 | return nil, errors.New("API key is required") 36 | } 37 | if url == "" { 38 | switch provider { 39 | case "openai": 40 | url = defaultOpenAIURL 41 | case "grok": 42 | url = defaultGrokURL 43 | case "deepseek": 44 | url = defaultDeepSeekURL 45 | default: 46 | return nil, fmt.Errorf("no default URL for provider: %s", provider) 47 | } 48 | } 49 | 50 | client := &fasthttp.Client{ 51 | MaxConnsPerHost: 10, 52 | } 53 | 54 | if proxy != "" { 55 | client.Dial = fasthttpproxy.FasthttpHTTPDialer(proxy) 56 | } 57 | 58 | return &GenericProvider{ 59 | apiKey: apiKey, 60 | client: client, 61 | url: url, 62 | provider: provider, 63 | }, nil 64 | } 65 | 66 | type Request struct { 67 | Model string `json:"model"` 68 | Messages []Message `json:"messages"` 69 | MaxTokens int `json:"max_tokens,omitempty"` 70 | Temperature float32 `json:"temperature,omitempty"` 71 | } 72 | 73 | type Response struct { 74 | Choices []struct { 75 | Message Message `json:"message"` 76 | } `json:"choices"` 77 | Error struct { 78 | Message string `json:"message"` 79 | } `json:"error,omitempty"` 80 | } 81 | 82 | type Message struct { 83 | Role string `json:"role"` 84 | Content string `json:"content"` 85 | } 86 | 87 | // GenerateCommitMessage generates a commit message using the API 88 | func (p *GenericProvider) GenerateCommitMessage(ctx context.Context, diff string, opts ai.MessageOptions) (string, error) { 89 | // Adjust prompt based on provider if needed 90 | prompt := utils.GetPromptForSingleCommit(diff, opts.CommitType, opts.CustomConvention, opts.Language) 91 | 92 | reqBody := Request{ 93 | Model: opts.Model, 94 | // Store: false, 95 | Messages: []Message{ 96 | {"system", systemPrompt}, 97 | {"user", prompt}, 98 | }, 99 | MaxTokens: max(512, opts.MaxLength), // More tokens for complete messages 100 | Temperature: 0.7, // Slightly creative but controlled 101 | } 102 | 103 | jsonData, err := sonic.Marshal(reqBody) 104 | if err != nil { 105 | return "", fmt.Errorf("failed to encode JSON: %v", err) 106 | } 107 | 108 | req := fasthttp.AcquireRequest() 109 | defer fasthttp.ReleaseRequest(req) 110 | 111 | req.SetRequestURI(p.url) 112 | req.Header.SetMethod("POST") 113 | req.Header.Set("Authorization", "Bearer "+p.apiKey) 114 | req.Header.Set("Content-Type", "application/json") 115 | req.SetBody(jsonData) 116 | 117 | resp := fasthttp.AcquireResponse() 118 | defer fasthttp.ReleaseResponse(resp) 119 | 120 | if err := p.client.DoRedirects(req, resp, opts.MaxRedirects); err != nil { 121 | return "", fmt.Errorf("API request failed: %w", err) 122 | } 123 | 124 | var res Response 125 | if err = sonic.Unmarshal(resp.Body(), &res); err != nil { 126 | return "", fmt.Errorf("failed to parse response: %v", err) 127 | } 128 | 129 | if statusCode := resp.StatusCode(); statusCode != fasthttp.StatusOK { 130 | if res.Error.Message != "" { 131 | return "", fmt.Errorf("API error [%d] from %s: %s", statusCode, p.provider, res.Error.Message) 132 | } 133 | 134 | return "", fmt.Errorf("API returned status %d from %s: %s", statusCode, p.provider, resp.Body()) 135 | } 136 | 137 | if res.Error.Message != "" { 138 | return "", fmt.Errorf("API error from %s: %s", p.provider, res.Error.Message) 139 | } else if len(res.Choices) == 0 { 140 | return "", fmt.Errorf("no response from %s", p.provider) 141 | } 142 | 143 | commitMessage := strings.TrimSpace(res.Choices[0].Message.Content) 144 | if commitMessage == "" { 145 | return "", fmt.Errorf("empty commit message generated by %s", p.provider) 146 | } 147 | 148 | return commitMessage, nil 149 | } 150 | -------------------------------------------------------------------------------- /internal/git/diff.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "os/exec" 9 | "strings" 10 | ) 11 | 12 | // GitService defines the interface for git operations 13 | type GitService interface { 14 | GetDiff(ctx context.Context) (string, error) 15 | StageAll(ctx context.Context) error 16 | } 17 | 18 | // gitServiceImpl implements GitService 19 | type gitServiceImpl struct { 20 | excludeFiles []string 21 | } 22 | 23 | // NewGitService creates a new GitService 24 | func NewGitService(excludeFiles ...string) GitService { 25 | return &gitServiceImpl{ 26 | excludeFiles: append(defaultExcludeFiles, excludeFiles...), 27 | } 28 | } 29 | 30 | // defaultExcludeFiles defines common files and folders to ignore in git diffs 31 | var defaultExcludeFiles = []string{ 32 | "package-lock.json", "pnpm-lock.yaml", "yarn.lock", "*.lock", 33 | "*.min.js", "*.bundle.js", 34 | "node_modules/*", "dist/*", "build/*", 35 | "*.log", "*.bak", "*.swp", 36 | // "*.png", "*.jpg", "*.jpeg", "*.gif", "*.svg", "*.ico", 37 | // "*.woff", "*.woff2", "*.ttf", "*.eot", 38 | // "*.pdf", "*.zip", "*.gz", 39 | } 40 | 41 | // GetDiff retrieves the git diff for staged changes 42 | func (g *gitServiceImpl) GetDiff(ctx context.Context) (string, error) { 43 | return GetDiffStaged(ctx, g.excludeFiles) 44 | } 45 | 46 | // getGitRoot retrieves the root directory of the git repository 47 | func getGitRoot() (string, error) { 48 | cmd := exec.Command("git", "rev-parse", "--show-toplevel") 49 | 50 | var out bytes.Buffer 51 | cmd.Stdout = &out 52 | cmd.Stderr = &out 53 | if err := cmd.Run(); err != nil { 54 | return "", fmt.Errorf("not a git repository: %w", err) 55 | } 56 | 57 | return strings.TrimSpace(out.String()), nil 58 | } 59 | 60 | // getExcludeFileArgs converts exclude paths into git diff exclude args 61 | func getExcludeFileArgs(excludeFiles []string) []string { 62 | args := make([]string, len(excludeFiles)) 63 | for i, f := range excludeFiles { 64 | args[i] = fmt.Sprintf(":(exclude)%s", f) 65 | } 66 | return args 67 | } 68 | 69 | // processDiff applies cleanup to reduce unnecessary lines 70 | func processDiff(diff string) string { 71 | var builder strings.Builder 72 | lines := strings.Split(diff, "\n") 73 | inHunk := false 74 | 75 | for _, line := range lines { 76 | trimmed := strings.TrimSpace(line) 77 | if trimmed == "" { 78 | continue 79 | } 80 | 81 | switch { 82 | case strings.HasPrefix(trimmed, "index "), 83 | strings.HasPrefix(trimmed, "--- "), 84 | strings.HasPrefix(trimmed, "+++ "): 85 | continue 86 | case strings.HasPrefix(trimmed, "@@"): 87 | inHunk = true 88 | parts := strings.SplitN(trimmed, "@@", 3) 89 | if len(parts) >= 3 { 90 | builder.WriteString("@@" + strings.TrimSpace(parts[2]) + "\n") 91 | } 92 | case strings.HasPrefix(trimmed, " ") && inHunk: 93 | continue 94 | default: 95 | builder.WriteString(trimmed + "\n") 96 | } 97 | } 98 | 99 | return strings.TrimSpace(builder.String()) 100 | } 101 | 102 | // GetDiffStaged retrieves the optimized git diff for staged changes with exclusions 103 | func GetDiffStaged(ctx context.Context, extraExcludeFiles []string) (string, error) { 104 | rootPath, err := getGitRoot() 105 | if err != nil { 106 | return "", err 107 | } 108 | 109 | // Construct git diff command 110 | args := []string{ 111 | "diff", 112 | "--staged", 113 | "--diff-algorithm=minimal", 114 | "--unified=3", 115 | "--ignore-all-space", 116 | "--ignore-blank-lines", 117 | "--no-color", 118 | "--no-ext-diff", 119 | "--no-renames", 120 | "--ignore-submodules", 121 | } 122 | args = append(args, getExcludeFileArgs(extraExcludeFiles)...) 123 | 124 | cmd := exec.CommandContext(ctx, "git", args...) 125 | cmd.Dir = rootPath 126 | var out bytes.Buffer 127 | cmd.Stdout = &out 128 | cmd.Stderr = &out 129 | 130 | if err := cmd.Run(); err != nil { 131 | if ctx.Err() == context.DeadlineExceeded { 132 | return "", fmt.Errorf("git diff timed out: %w", ctx.Err()) 133 | } 134 | return "", fmt.Errorf("failed to get staged diff: %w", err) 135 | } 136 | 137 | rawDiff := strings.TrimSpace(out.String()) 138 | if rawDiff == "" { 139 | return "", errors.New("no staged changes found") 140 | } 141 | 142 | // Process diff to remove unnecessary lines 143 | optimizedDiff := processDiff(rawDiff) 144 | if optimizedDiff == "" { 145 | return "", errors.New("no meaningful staged changes after processing") 146 | } 147 | 148 | return optimizedDiff, nil 149 | } 150 | 151 | // StageAll stages all changes in the working directory (equivalent to 'git add .'). 152 | func (s *gitServiceImpl) StageAll(ctx context.Context) error { 153 | cmd := exec.CommandContext(ctx, "git", "add", ".") 154 | if output, err := cmd.CombinedOutput(); err != nil { 155 | return fmt.Errorf("git add . failed: %s", string(output)) 156 | } 157 | 158 | return nil 159 | } 160 | -------------------------------------------------------------------------------- /internal/git/git_test.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "os" 7 | "os/exec" 8 | "strings" 9 | "testing" 10 | "time" 11 | ) 12 | 13 | // ------------------- processDiff ------------------- 14 | 15 | func TestProcessDiff_RemovesUnnecessaryLines(t *testing.T) { 16 | input := ` 17 | diff --git a/main.go b/main.go 18 | index 123456..abcdef 100644 19 | --- a/main.go 20 | +++ b/main.go 21 | @@ -1,4 +1,4 @@ 22 | package main 23 | -import "fmt" 24 | +import "log" 25 | 26 | func main() { 27 | fmt.Println("Hello") 28 | } 29 | ` 30 | expectedContains := "@@" 31 | expectedDoesNotContain := []string{ 32 | "index ", "--- ", "+++ ", " package main", 33 | } 34 | 35 | result := processDiff(input) 36 | 37 | if !strings.Contains(result, expectedContains) { 38 | t.Errorf("expected diff to contain '%s'", expectedContains) 39 | } 40 | 41 | for _, notExpected := range expectedDoesNotContain { 42 | if strings.Contains(result, notExpected) { 43 | t.Errorf("expected diff NOT to contain '%s'", notExpected) 44 | } 45 | } 46 | } 47 | 48 | // ------------------- getGitRoot ------------------- 49 | 50 | func TestGetGitRoot_NotInRepo(t *testing.T) { 51 | originalDir, _ := os.Getwd() 52 | defer os.Chdir(originalDir) 53 | 54 | tmpDir := t.TempDir() 55 | if err := os.Chdir(tmpDir); err != nil { 56 | t.Fatalf("failed to change dir: %v", err) 57 | } 58 | 59 | _, err := getGitRoot() 60 | if err == nil { 61 | t.Fatal("expected error when not in a git repo") 62 | } 63 | if !strings.Contains(err.Error(), "not a git repository") { 64 | t.Errorf("unexpected error: %v", err) 65 | } 66 | } 67 | 68 | // ------------------- GetDiffStaged ------------------- 69 | 70 | func TestGetDiffStaged_NoChanges(t *testing.T) { 71 | ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) 72 | defer cancel() 73 | 74 | // Check if we're in a git repo 75 | _, err := exec.Command("git", "rev-parse", "--is-inside-work-tree").Output() 76 | if err != nil { 77 | t.Skip("skipping test: not inside a git repository") 78 | } 79 | 80 | // Make sure there are no staged changes 81 | _ = exec.Command("git", "reset").Run() 82 | 83 | diff, err := GetDiffStaged(ctx, nil) 84 | if err == nil { 85 | t.Error("expected error for no staged changes, got nil") 86 | } 87 | if !errors.Is(err, errors.New("no staged changes found")) && diff != "" { 88 | t.Errorf("unexpected diff output: %s", diff) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /internal/git/repo.go: -------------------------------------------------------------------------------- 1 | package git 2 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/rezatg/gitc/cmd" 8 | ) 9 | 10 | func main() { 11 | if err := cmd.Commands.Run(os.Args); err != nil { 12 | log.Fatal(err) 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/bytedance/sonic" 9 | ) 10 | 11 | // Config holds the main configuration structure for the gitc CLI tool 12 | type Config struct { 13 | Provider string `json:"provider"` 14 | APIKey string `json:"api_key"` 15 | Model string `json:"model"` 16 | URL string `json:"url"` 17 | MaxLength int `json:"max_length"` 18 | Proxy string `json:"proxy"` 19 | Language string `json:"language"` 20 | Timeout int `json:"timeout"` 21 | CommitType string `json:"commit_type"` 22 | CustomConvention string `json:"custom_convention"` 23 | UseGitmoji bool `json:"use_gitmoji"` 24 | MaxRedirects int `json:"max_redirects"` 25 | } 26 | 27 | // DefaultConfig returns a default config with fallback values 28 | func DefaultConfig() *Config { 29 | return &Config{ 30 | Provider: "openai", 31 | APIKey: os.Getenv("AI_API_KEY"), 32 | Model: "gpt-4o-mini", 33 | URL: "https://api.openai.com/v1/chat/completions", 34 | MaxLength: 250, 35 | Proxy: "", 36 | Language: "en", 37 | Timeout: 10, 38 | CommitType: "", 39 | CustomConvention: "", 40 | UseGitmoji: false, 41 | MaxRedirects: 5, 42 | } 43 | } 44 | 45 | // configPath points to ~/.gitc/config.json by default (hidden config file) 46 | var configPath = filepath.Join(userHomeDir(), ".gitc", "config.json") 47 | 48 | // SetConfigPath updates the configuration file path 49 | func SetConfigPath(path string) { 50 | configPath = path 51 | } 52 | 53 | // Load loads the configuration from file or creates a default one if it doesn't exist 54 | func Load() (*Config, error) { 55 | absPath, err := filepath.Abs(configPath) 56 | if err != nil { 57 | return nil, fmt.Errorf("failed to resolve config path: %w", err) 58 | } 59 | 60 | data, err := os.ReadFile(absPath) 61 | if os.IsNotExist(err) { 62 | cfg := DefaultConfig() 63 | if err := Save(cfg); err != nil { 64 | return nil, fmt.Errorf("failed to create default config: %w", err) 65 | } 66 | return cfg, nil 67 | } else if err != nil { 68 | return nil, fmt.Errorf("failed to read config file: %w", err) 69 | } 70 | 71 | var cfg Config 72 | if err := sonic.Unmarshal(data, &cfg); err != nil { 73 | return nil, fmt.Errorf("failed to unmarshal config: %w", err) 74 | } 75 | 76 | // Apply defaults for unset fields 77 | defaults := DefaultConfig() 78 | if cfg.Provider == "" { 79 | cfg.Provider = defaults.Provider 80 | } 81 | if cfg.APIKey == "" { 82 | cfg.APIKey = defaults.APIKey 83 | } 84 | if cfg.Model == "" { 85 | switch cfg.Provider { 86 | case "openai": 87 | cfg.Model = "gpt-4o-mini" 88 | case "grok": 89 | cfg.Model = "grok-3" 90 | case "deepseek": 91 | cfg.Model = "deepseek-rag" 92 | default: 93 | cfg.Model = defaults.Model 94 | } 95 | } 96 | if cfg.URL == "" { 97 | switch cfg.Provider { 98 | case "openai": 99 | cfg.URL = "https://api.openai.com/v1/chat/completions" 100 | case "grok": 101 | cfg.URL = "https://api.x.ai/v1/chat/completions" 102 | case "deepseek": 103 | cfg.URL = "https://api.deepseek.com/v1/chat/completions" 104 | default: 105 | cfg.URL = defaults.URL 106 | } 107 | } 108 | if cfg.MaxLength == 0 { 109 | cfg.MaxLength = defaults.MaxLength 110 | } 111 | if cfg.Language == "" { 112 | cfg.Language = defaults.Language 113 | } 114 | if cfg.Timeout == 0 { 115 | cfg.Timeout = defaults.Timeout 116 | } 117 | if cfg.MaxRedirects == 0 { 118 | cfg.MaxRedirects = defaults.MaxRedirects 119 | } 120 | 121 | return &cfg, nil 122 | } 123 | 124 | // Save saves the configuration to file 125 | func Save(cfg *Config) error { 126 | absPath, err := filepath.Abs(configPath) 127 | if err != nil { 128 | return fmt.Errorf("failed to resolve config path: %w", err) 129 | } 130 | 131 | dir := filepath.Dir(absPath) 132 | if err := os.MkdirAll(dir, 0755); err != nil { 133 | return fmt.Errorf("failed to create config directory: %w", err) 134 | } 135 | 136 | data, err := sonic.MarshalIndent(cfg, "", " ") 137 | if err != nil { 138 | return fmt.Errorf("failed to marshal config: %w", err) 139 | } else if err := os.WriteFile(absPath, data, 0600); err != nil { 140 | return fmt.Errorf("failed to write config file: %w", err) 141 | } 142 | 143 | return nil 144 | } 145 | 146 | // Reset overwrites the config file with default values 147 | func Reset() error { 148 | return Save(DefaultConfig()) 149 | } 150 | 151 | // userHomeDir gets the current user's home directory 152 | func userHomeDir() string { 153 | home, err := os.UserHomeDir() 154 | if err != nil { 155 | panic("cannot determine user home directory: " + err.Error()) 156 | } 157 | return home 158 | } 159 | -------------------------------------------------------------------------------- /pkg/utils/emoji.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | // Define the mapping of commit types to Gitmojis 10 | var typeToGitmoji = map[string]string{ 11 | "feat": "✨", // New feature 12 | "fix": "🚑", // Bug fix 13 | "docs": "📝", // Documentation 14 | "style": "💄", // Code style 15 | "refactor": "♻️", // Code refactoring 16 | "perf": "⚡️", // Performance improvements 17 | "test": "✅", // Tests 18 | "chore": "🔧", // Maintenance 19 | "build": "🏗️", // Build system 20 | "ci": "🤖", // CI/CD 21 | "revert": "⏪", // Reverts 22 | "init": "🎉", // Initial commit 23 | "security": "🔒", // Security fixes 24 | } 25 | 26 | // AddGitmojiToCommitMessage adds a Gitmoji to the commit message based on its type. 27 | func AddGitmojiToCommitMessage(commitMessage string) string { 28 | // Extract the commit type (e.g., "feat" from "feat: description") 29 | match := regexp.MustCompile(`^[a-zA-Z]+`).FindString(commitMessage) 30 | if match == "" { 31 | return commitMessage // No valid type found, return unchanged 32 | } 33 | 34 | // Get the corresponding Gitmoji 35 | gitmoji, exists := typeToGitmoji[strings.ToLower(match)] 36 | if !exists { 37 | return commitMessage // Type not recognized, return unchanged 38 | } 39 | 40 | // Add Gitmoji to the start of the message 41 | return fmt.Sprintf("%s %s", gitmoji, commitMessage) 42 | } 43 | -------------------------------------------------------------------------------- /pkg/utils/prompt.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // GetPromptForSingleCommit generates a prompt for creating a git commit message. 9 | func GetPromptForSingleCommit(diff, commitType, customMessageConvention, language string) string { 10 | // Normalize language input 11 | language = strings.ToLower(strings.TrimSpace(language)) 12 | if language == "" { 13 | language = "en" 14 | } 15 | 16 | return fmt.Sprintf(`You're a git commit message generator. Based on this diff: 17 | %s 18 | 19 | Generate a professional git commit message in %s following these rules: 20 | 1. Structure the message with: 21 | - First line: ": " (max 50 characters) 22 | - Blank line 23 | - Body: Summarize key changes (max 100 characters per line) 24 | 2. Use present tense and imperative mood (e.g., "Add", "Fix", "Update") 25 | 3. Include specific changes (e.g., new features, configs, interfaces) 26 | 4. %s 27 | 5. %s 28 | 6. Return plain text without Markdown, code blocks, or extra characters like \ 29 | 7. Do not include prefixes like "This commit" or extra explanations 30 | 31 | Example output: 32 | feat: add new feature 33 | 34 | Add feature to improve performance. Update config handling. 35 | 36 | Only return the commit message, no explanations.`, 37 | diff, language, 38 | getTypeInstruction(commitType), 39 | getConventionInstruction(customMessageConvention)) 40 | } 41 | 42 | func getTypeInstruction(commitType string) string { 43 | if commitType != "" { 44 | return fmt.Sprintf("Use type '%s'", commitType) 45 | } 46 | return "Choose appropriate type (feat, fix, docs, style, refactor, test, chore, build, ci, revert, init, security)" 47 | } 48 | 49 | func getConventionInstruction(convention string) string { 50 | if convention != "" { 51 | return fmt.Sprintf("Follow custom convention: %s", convention) 52 | } 53 | return "Follow Conventional Commits" 54 | } 55 | --------------------------------------------------------------------------------