├── .github ├── ISSUE_TEMPLATE │ ├── config.yml │ ├── feature_request.md │ └── bug_report.md ├── FUNDING.yml ├── dependabot.yaml ├── cspell.json ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── gitwhisper.yaml │ └── build.yaml ├── gw-test ├── assets └── images │ └── logo.png ├── dart_test.yaml ├── lib ├── src │ ├── version.dart │ ├── constants.dart │ ├── exceptions │ │ ├── exceptions.dart │ │ ├── api_exception.dart │ │ ├── error_handler.dart │ │ ├── api_exceptions.dart │ │ └── error_parser.dart │ ├── models │ │ ├── gw_commit.dart │ │ ├── language.dart │ │ ├── model_variants.dart │ │ ├── commit_generator.dart │ │ ├── commit_generator_factory.dart │ │ ├── openai_generator.dart │ │ ├── free_generator.dart │ │ ├── ollama_generator.dart │ │ ├── llama_generator.dart │ │ ├── claude_generator.dart │ │ ├── gemini_generator.dart │ │ ├── deepseek_generator.dart │ │ ├── github_generator.dart │ │ └── grok_generator.dart │ ├── commands │ │ ├── clear_defaults_command.dart │ │ ├── list_models_command.dart │ │ ├── change_language_command.dart │ │ ├── always_add_command.dart │ │ ├── show_defaults_command.dart │ │ ├── update_command.dart │ │ ├── save_key_command.dart │ │ ├── list_variants_command.dart │ │ ├── set_defaults_command.dart │ │ ├── analyze_command.dart │ │ └── commit_command.dart │ ├── command_runner.dart │ ├── config_manager.dart │ ├── commit_utils.dart │ └── git_utils.dart └── gitwhisper.dart ├── test └── ensure_build_test.dart ├── .gitignore ├── analysis_options.yaml ├── pubspec.yaml ├── bin └── main.dart ├── LICENSE ├── coverage_badge.svg ├── install.sh ├── CODE_OF_CONDUCT.md ├── install.ps1 ├── CONTRIBUTING.md ├── CHANGELOG.md └── README.md /.github/ISSUE_TEMPLATE/ config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /gw-test: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamngoni/gitwhisper/HEAD/gw-test -------------------------------------------------------------------------------- /assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamngoni/gitwhisper/HEAD/assets/images/logo.png -------------------------------------------------------------------------------- /dart_test.yaml: -------------------------------------------------------------------------------- 1 | tags: 2 | version-verify: 3 | skip: "Should only be run during pull request. Verifies if version file is updated." -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [iamngoni] 4 | custom: ["https://www.buymeacoffee.com/modestnerd"] 5 | -------------------------------------------------------------------------------- /lib/src/version.dart: -------------------------------------------------------------------------------- 1 | // Generated code. Do not modify. 2 | const packageVersion = String.fromEnvironment( 3 | 'APP_VERSION', 4 | defaultValue: '0.1.2', 5 | ); 6 | -------------------------------------------------------------------------------- /lib/src/constants.dart: -------------------------------------------------------------------------------- 1 | import 'package:dio/dio.dart'; 2 | import 'package:mason_logger/mason_logger.dart'; 3 | 4 | final Dio $dio = Dio(); 5 | final $logger = Logger( 6 | level: Level.verbose, 7 | ); 8 | -------------------------------------------------------------------------------- /test/ensure_build_test.dart: -------------------------------------------------------------------------------- 1 | @Tags(['version-verify']) 2 | library; 3 | 4 | import 'package:build_verify/build_verify.dart'; 5 | import 'package:test/test.dart'; 6 | 7 | void main() { 8 | test('ensure_build', expectBuildClean); 9 | } 10 | -------------------------------------------------------------------------------- /lib/gitwhisper.dart: -------------------------------------------------------------------------------- 1 | /// gitwhisper, A Very Good Project created by Very Good CLI. 2 | /// 3 | /// ```sh 4 | /// # activate gitwhisper 5 | /// dart pub global activate gitwhisper 6 | /// 7 | /// # see usage 8 | /// gitwhisper --help 9 | /// ``` 10 | library gitwhisper; 11 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | enable-beta-ecosystems: true 3 | updates: 4 | - package-ecosystem: "github-actions" 5 | directory: "/" 6 | schedule: 7 | interval: "daily" 8 | - package-ecosystem: "pub" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | -------------------------------------------------------------------------------- /lib/src/exceptions/exceptions.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // exceptions.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | export 'api_exception.dart'; 10 | export 'api_exceptions.dart'; 11 | export 'error_parser.dart'; 12 | export 'error_handler.dart'; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://www.dartlang.org/guides/libraries/private-files 2 | 3 | # Files and directories created by pub 4 | .dart_tool/ 5 | .packages 6 | build/ 7 | pubspec.lock 8 | 9 | # Files generated during tests 10 | .test_coverage.dart 11 | coverage/ 12 | .test_runner.dart 13 | 14 | # Android studio and IntelliJ 15 | .idea 16 | 17 | *.DS_Store 18 | AGENT.md 19 | -------------------------------------------------------------------------------- /lib/src/models/gw_commit.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // gw_commit.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/07/18. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | class GwCommit { 10 | const GwCommit(this.commit, this.model, this.dateTime); 11 | final String commit; 12 | final String model; 13 | final DateTime dateTime; 14 | } 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: A new feature to be added to the project 4 | title: "feat: " 5 | labels: feature 6 | --- 7 | 8 | **Description** 9 | 10 | Clearly describe what you are looking to add. The more context the better. 11 | 12 | **Requirements** 13 | 14 | - [ ] Checklist of requirements to be fulfilled 15 | 16 | **Additional Context** 17 | 18 | Add any other context or screenshots about the feature request go here. 19 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Create a report to help us improve 4 | title: "fix: " 5 | labels: bug 6 | --- 7 | 8 | **Description** 9 | 10 | A clear and concise description of what the bug is. 11 | 12 | **Steps To Reproduce** 13 | 14 | 1. Go to '...' 15 | 2. Click on '....' 16 | 3. Scroll down to '....' 17 | 4. See error 18 | 19 | **Expected Behavior** 20 | 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Additional Context** 28 | 29 | Add any other context about the problem here. 30 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | include: package:very_good_analysis/analysis_options.6.0.0.yaml 2 | 3 | analyzer: 4 | exclude: [lib/**.freezed.dart, lib/**.g.dart] 5 | 6 | language: 7 | strict-casts: true 8 | strict-raw-types: true 9 | 10 | linter: 11 | rules: 12 | avoid_relative_lib_imports: false 13 | public_member_api_docs: false 14 | lines_longer_than_80_chars: true 15 | avoid_dynamic_calls: true 16 | avoid_type_to_string: true 17 | always_declare_return_types: true 18 | always_specify_types: false 19 | omit_local_variable_types: false 20 | prefer_relative_imports: true 21 | always_use_package_imports: false 22 | avoid_setters_without_getters: false -------------------------------------------------------------------------------- /.github/cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2", 3 | "$schema": "https://raw.githubusercontent.com/streetsidesoftware/cspell/main/cspell.schema.json", 4 | "dictionaries": ["vgv_allowed", "vgv_forbidden"], 5 | "dictionaryDefinitions": [ 6 | { 7 | "name": "vgv_allowed", 8 | "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/allowed.txt", 9 | "description": "Allowed VGV Spellings" 10 | }, 11 | { 12 | "name": "vgv_forbidden", 13 | "path": "https://raw.githubusercontent.com/verygoodopensource/very_good_dictionaries/main/forbidden.txt", 14 | "description": "Forbidden VGV Spellings" 15 | } 16 | ], 17 | "useGitignore": true, 18 | "words": [ 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | ## Description 10 | 11 | 12 | 13 | ## Type of Change 14 | 15 | 16 | 17 | - [ ] ✨ New feature (non-breaking change which adds functionality) 18 | - [ ] 🛠️ Bug fix (non-breaking change which fixes an issue) 19 | - [ ] ❌ Breaking change (fix or feature that would cause existing functionality to change) 20 | - [ ] 🧹 Code refactor 21 | - [ ] ✅ Build configuration change 22 | - [ ] 📝 Documentation 23 | - [ ] 🗑️ Chore 24 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: gitwhisper 2 | description: AI-powered Git commit message generator that whispers the perfect commit message for your changes 3 | version: 0.1.12 4 | repository: https://github.com/iamngoni/gitwhisper 5 | homepage: https://github.com/iamngoni/gitwhisper 6 | 7 | environment: 8 | sdk: ">=3.0.0 <4.0.0" 9 | 10 | dependencies: 11 | args: ^2.6.0 12 | cli_completion: ^0.5.1 13 | dio: ^5.8.0+1 14 | mason_logger: ^0.3.2 15 | path: ^1.9.1 16 | pub_updater: ^0.5.0 17 | universal_io: ^2.2.2 18 | yaml: ^3.1.3 19 | 20 | dev_dependencies: 21 | build_runner: ^2.4.12 22 | build_verify: ^3.1.0 23 | build_version: ^2.1.1 24 | mocktail: ^1.0.4 25 | test: ^1.25.8 26 | very_good_analysis: ^7.0.0 27 | 28 | executables: 29 | gitwhisper: main 30 | gw: main 31 | -------------------------------------------------------------------------------- /bin/main.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // main.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:gitwhisper/src/command_runner.dart'; 10 | import 'package:universal_io/io.dart'; 11 | 12 | Future main(List args) async { 13 | await _flushThenExit(await GitWhisperCommandRunner().run(args)); 14 | } 15 | 16 | /// Flushes the stdout and stderr streams, then exits the program with the given 17 | /// status code. 18 | /// 19 | /// This returns a Future that will never complete, since the program will have 20 | /// exited already. This is useful to prevent Future chains from proceeding 21 | /// after you've decided to exit. 22 | Future _flushThenExit(int status) { 23 | return Future.wait([stdout.close(), stderr.close()]) 24 | .then((_) => exit(status)); 25 | } 26 | -------------------------------------------------------------------------------- /lib/src/commands/clear_defaults_command.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // clear_defaults_command.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/04. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:args/command_runner.dart'; 10 | import 'package:mason_logger/mason_logger.dart'; 11 | 12 | import '../config_manager.dart'; 13 | 14 | class ClearDefaultsCommand extends Command { 15 | ClearDefaultsCommand({ 16 | required Logger logger, 17 | }) : _logger = logger; 18 | 19 | @override 20 | String get description => 'Clears all defaults'; 21 | 22 | @override 23 | String get name => 'clear-defaults'; 24 | 25 | final Logger _logger; 26 | 27 | @override 28 | Future run() async { 29 | // Initialize config manager 30 | final configManager = ConfigManager(); 31 | await configManager.load(); 32 | 33 | // Clear the defaults 34 | configManager.clearDefaults(); 35 | await configManager.save(); 36 | 37 | _logger.success('All set defaults have been cleared 🍻'); 38 | return ExitCode.success.code; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Ngonidzashe Mangudya 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. -------------------------------------------------------------------------------- /coverage_badge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | coverage 16 | coverage 17 | 100% 18 | 100% 19 | 20 | -------------------------------------------------------------------------------- /lib/src/commands/list_models_command.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // list_models_command.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:args/command_runner.dart'; 10 | import 'package:mason_logger/mason_logger.dart'; 11 | 12 | class ListModelsCommand extends Command { 13 | ListModelsCommand({ 14 | required Logger logger, 15 | }) : _logger = logger; 16 | 17 | @override 18 | String get description => 'List available AI models'; 19 | 20 | @override 21 | String get name => 'list-models'; 22 | 23 | final Logger _logger; 24 | 25 | @override 26 | Future run() async { 27 | _logger 28 | ..info('Available models:') 29 | ..info(' - claude (Anthropic Claude)') 30 | ..info(' - openai (OpenAI GPT models)') 31 | ..info(' - gemini (Google Gemini)') 32 | ..info(' - grok (xAI Grok)') 33 | ..info(' - llama (Meta Llama)') 34 | ..info(' - deepseek (DeepSeek, Inc.)') 35 | ..info(' - github (GitHub)') 36 | ..info(' - ollama (Ollama)') 37 | ..info(' - free (LLM7.io - No API key required)'); 38 | return ExitCode.success.code; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | REPO="iamngoni/gitwhisper" 6 | VERSION=${1:-"latest"} 7 | BINARY_NAME="gitwhisper" 8 | 9 | detect_platform() { 10 | OS=$(uname -s | tr '[:upper:]' '[:lower:]') 11 | ARCH=$(uname -m) 12 | 13 | case "$ARCH" in 14 | x86_64) ARCH="amd64" ;; 15 | aarch64 | arm64) ARCH="arm64" ;; 16 | *) echo "Unsupported architecture: $ARCH" && exit 1 ;; 17 | esac 18 | 19 | echo "${OS}-${ARCH}" 20 | } 21 | 22 | install_binary() { 23 | PLATFORM=$(detect_platform) 24 | 25 | echo "Detected platform: $PLATFORM" 26 | 27 | if [[ "$VERSION" == "latest" ]]; then 28 | VERSION=$(curl -s https://api.github.com/repos/$REPO/releases/latest | grep -Po '"tag_name": "\K.*?(?=")') 29 | fi 30 | 31 | echo "Installing GitWhisper version: $VERSION" 32 | 33 | DOWNLOAD_URL="https://github.com/${REPO}/releases/download/${VERSION}/gitwhisper-${PLATFORM%%-*}.tar.gz" 34 | 35 | curl -L -o gitwhisper.tar.gz "$DOWNLOAD_URL" 36 | tar -xzf gitwhisper.tar.gz 37 | chmod +x gitwhisper 38 | 39 | sudo mv gitwhisper /usr/local/bin/gitwhisper 40 | sudo ln -sf /usr/local/bin/gitwhisper /usr/local/bin/gw 41 | 42 | echo "✅ Installed gitwhisper and gw to /usr/local/bin" 43 | gitwhisper --version || true 44 | } 45 | 46 | install_binary 47 | -------------------------------------------------------------------------------- /lib/src/models/language.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // language.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/07/18. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | enum Language { 10 | english('English', 'en', 'US'), 11 | spanish('Spanish', 'es', 'ES'), 12 | french('French', 'fr', 'FR'), 13 | german('German', 'de', 'DE'), 14 | chineseSimplified('Chinese (simplified)', 'zh', 'CN'), 15 | chineseTraditional('Chinese (traditional)', 'zh', 'TW'), 16 | japanese('Japanese', 'ja', 'JP'), 17 | korean('Korean', 'ko', 'KR'), 18 | arabic('Arabic', 'ar', 'SA'), 19 | italian('Italian', 'it', 'IT'), 20 | portuguese('Portuguese', 'pt', 'PT'), 21 | russian('Russian', 'ru', 'RU'), 22 | dutch('Dutch', 'nl', 'NL'), 23 | swedish('Swedish', 'sv', 'SE'), 24 | norwegian('Norwegian', 'no', 'NO'), 25 | danish('Danish', 'da', 'DK'), 26 | finnish('Finnish', 'fi', 'FI'), 27 | greek('Greek', 'el', 'GR'), 28 | turkish('Turkish', 'tr', 'TR'), 29 | hindi('Hindi', 'hi', 'IN'), 30 | englishUS('English (US)', 'en', 'US'), 31 | englishUK('English (UK)', 'en', 'GB'), 32 | shona('Shona', 'sn', 'ZW'), 33 | zulu('Zulu', 'zu', 'ZA'); 34 | 35 | const Language(this.name, this.code, this.countryCode); 36 | final String name; 37 | final String code; 38 | final String countryCode; 39 | 40 | @override 41 | String toString() => name; 42 | } 43 | -------------------------------------------------------------------------------- /.github/workflows/gitwhisper.yaml: -------------------------------------------------------------------------------- 1 | name: gitwhisper 2 | 3 | concurrency: 4 | group: ${{ github.workflow }}-${{ github.ref }} 5 | cancel-in-progress: true 6 | 7 | on: 8 | pull_request: 9 | paths: 10 | - ".github/workflows/gitwhisper.yaml" 11 | - "lib/**" 12 | - "test/**" 13 | - "pubspec.yaml" 14 | push: 15 | branches: 16 | - main 17 | paths: 18 | - ".github/workflows/gitwhisper.yaml" 19 | - "lib/**" 20 | - "test/**" 21 | - "pubspec.yaml" 22 | 23 | jobs: 24 | semantic-pull-request: 25 | uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/semantic_pull_request.yml@v1 26 | 27 | build: 28 | uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/dart_package.yml@v1 29 | 30 | spell-check: 31 | uses: VeryGoodOpenSource/very_good_workflows/.github/workflows/spell_check.yml@v1 32 | with: 33 | includes: | 34 | **/*.md 35 | !brick/**/*.md 36 | .*/**/*.md 37 | modified_files_only: false 38 | 39 | verify-version: 40 | runs-on: ubuntu-latest 41 | steps: 42 | - name: 📚 Git Checkout 43 | uses: actions/checkout@v2 44 | 45 | - name: 🎯 Setup Dart 46 | uses: dart-lang/setup-dart@v1 47 | with: 48 | sdk: "stable" 49 | 50 | - name: 📦 Install Dependencies 51 | run: | 52 | dart pub get 53 | 54 | - name: 🔎 Verify version 55 | run: dart run test --run-skipped -t version-verify 56 | -------------------------------------------------------------------------------- /lib/src/commands/change_language_command.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // change_language_command.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/07/18. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:args/command_runner.dart'; 10 | import 'package:mason_logger/mason_logger.dart'; 11 | 12 | import '../config_manager.dart'; 13 | import '../models/language.dart'; 14 | 15 | class ChangeLanguageCommand extends Command { 16 | ChangeLanguageCommand({ 17 | required Logger logger, 18 | }) : _logger = logger; 19 | 20 | final Logger _logger; 21 | 22 | @override 23 | String get description => 'Change language to use for commit messages'; 24 | 25 | @override 26 | String get name => 'change-language'; 27 | 28 | @override 29 | Future run() async { 30 | // Initialize config manager 31 | final configManager = ConfigManager(); 32 | await configManager.load(); 33 | 34 | const languages = Language.values; 35 | 36 | final Language language = _logger.chooseOne( 37 | 'Please select your preferred language for commit messages:', 38 | choices: languages, 39 | defaultValue: Language.english, 40 | display: (language) { 41 | return language.name; 42 | }, 43 | ); 44 | 45 | configManager.setWhisperLanguage(language); 46 | await configManager.save(); 47 | 48 | _logger.success( 49 | '✨ Great! ${language.name} has been set as your default language for ' 50 | 'Git commit messages.', 51 | ); 52 | return ExitCode.success.code; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /lib/src/models/model_variants.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // model_variants.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/02. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | /// Default model variants for each AI model provider 10 | class ModelVariants { 11 | /// Default OpenAI model variant 12 | static const String openaiDefault = 'gpt-4o'; 13 | 14 | /// Default Claude model variant 15 | static const String claudeDefault = 'claude-sonnet-4-5-20250929'; 16 | 17 | /// Default Gemini model variant 18 | static const String geminiDefault = 'gemini-2.0-flash'; 19 | 20 | /// Default Grok model variant 21 | static const String grokDefault = 'grok-2-latest'; 22 | 23 | /// Default Llama model variant 24 | static const String llamaDefault = 'llama-3-70b-instruct'; 25 | 26 | /// Default Deekseek model variant 27 | static const String deepseekDefault = 'deepseek-chat'; 28 | 29 | /// Default Github model variant 30 | static const String githubDefault = 'gpt-4o'; 31 | 32 | /// Default Ollama model variant 33 | static const String ollamaDefault = 'llama3.2:latest'; 34 | 35 | /// Get the default model variant for a given model 36 | static String getDefault(String model) { 37 | return switch (model.toLowerCase()) { 38 | 'openai' => openaiDefault, 39 | 'claude' => claudeDefault, 40 | 'gemini' => geminiDefault, 41 | 'grok' => grokDefault, 42 | 'llama' => llamaDefault, 43 | 'deepseek' => deepseekDefault, 44 | 'github' => githubDefault, 45 | 'ollama' => ollamaDefault, 46 | _ => throw ArgumentError('Unknown model: $model'), 47 | }; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /lib/src/models/commit_generator.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // commit_generator.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'dart:async'; 10 | 11 | import 'language.dart'; 12 | 13 | /// Abstract base class for AI commit message generators 14 | abstract class CommitGenerator { 15 | const CommitGenerator(this.apiKey, {this.variant}); 16 | final String? apiKey; 17 | final String? variant; 18 | 19 | /// Generate a commit message based on the git diff 20 | Future generateCommitMessage( 21 | String diff, 22 | Language language, { 23 | String? prefix, 24 | bool withEmoji = true, 25 | }); 26 | 27 | /// Generate an analysis of the provided diff for what's changed and possibly 28 | /// what can be made better 29 | Future analyzeChanges( 30 | String diff, 31 | Language language, 32 | ); 33 | 34 | /// Returns the name of the model 35 | String get modelName; 36 | 37 | /// Returns the default variant to use if none specified 38 | String get defaultVariant; 39 | 40 | /// Gets the actual variant to use (specified or default) 41 | String get actualVariant => 42 | (variant != null && variant!.isNotEmpty) ? variant! : defaultVariant; 43 | 44 | /// The maximum number of tokens allowed for the commit message generation. 45 | /// 46 | /// This limits the size of the generated commit message to ensure it remains 47 | /// concise and follows best practices for Git commit messages. 48 | /// A lower value encourages more focused, single-purpose commit messages. 49 | int get maxTokens => 300; 50 | 51 | /// The maximum number of tokens allowed for the analysis message generation. 52 | int get maxAnalysisTokens => 8000; 53 | } 54 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We are committed to providing a welcoming and inspiring community for all. We pledge to make participation in our project a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to a positive environment: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior: 18 | 19 | * Trolling, insulting/derogatory comments, and personal or political attacks 20 | * Public or private harassment 21 | * Publishing others' private information without explicit permission 22 | * Other conduct which could reasonably be considered inappropriate in a professional setting 23 | 24 | ## Enforcement 25 | 26 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 27 | 28 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, issues, and other contributions that are not aligned with this Code of Conduct. 29 | 30 | ## Scope 31 | 32 | This Code of Conduct applies within all project spaces and when an individual is representing the project or its community in public spaces. 33 | 34 | ## Enforcement 35 | 36 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by opening an issue or contacting the project maintainers. All complaints will be reviewed and investigated promptly and fairly. -------------------------------------------------------------------------------- /lib/src/models/commit_generator_factory.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // commit_generator_factory.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'claude_generator.dart'; 10 | import 'commit_generator.dart'; 11 | import 'deepseek_generator.dart'; 12 | import 'free_generator.dart'; 13 | import 'gemini_generator.dart'; 14 | import 'github_generator.dart'; 15 | import 'grok_generator.dart'; 16 | import 'llama_generator.dart'; 17 | import 'ollama_generator.dart'; 18 | import 'openai_generator.dart'; 19 | 20 | /// Factory for creating appropriate commit generators 21 | class CommitGeneratorFactory { 22 | static CommitGenerator create( 23 | String model, 24 | String? apiKey, { 25 | String? variant, 26 | String? baseUrl, 27 | }) { 28 | return switch (model.toLowerCase()) { 29 | 'claude' => ClaudeGenerator( 30 | apiKey, 31 | variant: variant, 32 | ), 33 | 'openai' => OpenAIGenerator( 34 | apiKey, 35 | variant: variant, 36 | ), 37 | 'gemini' => GeminiGenerator( 38 | apiKey, 39 | variant: variant, 40 | ), 41 | 'grok' => GrokGenerator( 42 | apiKey, 43 | variant: variant, 44 | ), 45 | 'llama' => LlamaGenerator( 46 | apiKey, 47 | variant: variant, 48 | ), 49 | 'deepseek' => DeepseekGenerator( 50 | apiKey, 51 | variant: variant, 52 | ), 53 | 'github' => GithubGenerator( 54 | apiKey, 55 | variant: variant, 56 | ), 57 | 'ollama' => switch (baseUrl) { 58 | null => throw ArgumentError('Missing baseUrl for Ollama'), 59 | _ => OllamaGenerator( 60 | baseUrl, 61 | apiKey, 62 | variant: variant, 63 | ), 64 | }, 65 | 'free' => FreeGenerator(), 66 | _ => throw ArgumentError('Unsupported model: $model'), 67 | }; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /lib/src/commands/always_add_command.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // always_add_command.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/05/30. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:args/command_runner.dart'; 10 | import 'package:mason_logger/mason_logger.dart'; 11 | 12 | import '../config_manager.dart'; 13 | 14 | class AlwaysAddCommand extends Command { 15 | AlwaysAddCommand({ 16 | required Logger logger, 17 | }) : _logger = logger; 18 | 19 | final Logger _logger; 20 | 21 | @override 22 | String get description => 'Always stage changes before committing'; 23 | 24 | @override 25 | String get name => 'always-add'; 26 | 27 | @override 28 | String get invocation => 'gw always-add '; 29 | 30 | @override 31 | Future run() async { 32 | bool alwaysAdd; 33 | 34 | // Check if value provided as positional argument 35 | if (argResults != null && argResults!.rest.isNotEmpty) { 36 | final arg = argResults!.rest.first; 37 | if (arg != 'true' && arg != 'false') { 38 | _logger.err('Value must be "true" or "false".'); 39 | return ExitCode.usage.code; 40 | } 41 | alwaysAdd = arg == 'true'; 42 | } else { 43 | // Use interactive prompt 44 | final String choice = _logger.chooseOne( 45 | 'Should GitWhisper automatically stage unstaged changes when no staged changes are found?', 46 | choices: ['yes', 'no'], 47 | defaultValue: 'yes', 48 | ); 49 | alwaysAdd = choice == 'yes'; 50 | } 51 | 52 | final configManager = ConfigManager(); 53 | await configManager.load(); 54 | 55 | configManager.setAlwaysAdd(value: alwaysAdd); 56 | await configManager.save(); 57 | 58 | if (alwaysAdd) { 59 | _logger.success( 60 | 'If there are no staged changes GitWhisper will now try to stage ' 61 | 'first before making a commit!', 62 | ); 63 | } else { 64 | _logger.success( 65 | 'If there are no staged changes, GitWhisper will abort mission!', 66 | ); 67 | } 68 | return ExitCode.success.code; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /install.ps1: -------------------------------------------------------------------------------- 1 | # Check for admin privileges 2 | $IsAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") 3 | if (-not $IsAdmin) { 4 | Write-Host "`n❌ Please run this script as Administrator.`n" -ForegroundColor Red 5 | Write-Host "Right-click on PowerShell and select 'Run as administrator'." 6 | Write-Host "`nPress any key to exit..." 7 | $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") 8 | exit 1 9 | } 10 | 11 | $repo = "iamngoni/gitwhisper" 12 | $apiUrl = "https://api.github.com/repos/$repo/releases/latest" 13 | 14 | # Get latest version tag 15 | try { 16 | Write-Host "📡 Fetching latest release..." 17 | $headers = @{ 'User-Agent' = 'gitwhisper-installer' } 18 | $latest = Invoke-RestMethod -Uri $apiUrl -Headers $headers 19 | $version = $latest.tag_name 20 | Write-Host "📦 Latest version: $version" 21 | } catch { 22 | Write-Error "❌ Failed to fetch latest release." 23 | exit 1 24 | } 25 | 26 | $downloadUrl = "https://github.com/$repo/releases/download/$version/gitwhisper-windows.tar.gz" 27 | $tmpDir = "$env:TEMP\gitwhisper-install" 28 | $installDir = "$env:ProgramFiles\GitWhisper" 29 | 30 | Write-Host "⬇️ Downloading GitWhisper $version..." 31 | 32 | # Prepare install dirs 33 | if (Test-Path $tmpDir) { Remove-Item $tmpDir -Recurse -Force } 34 | New-Item -ItemType Directory -Path $tmpDir | Out-Null 35 | New-Item -ItemType Directory -Force -Path $installDir | Out-Null 36 | 37 | # Download + extract 38 | Invoke-WebRequest -Uri $downloadUrl -OutFile "$tmpDir\gitwhisper.tar.gz" 39 | tar -xzf "$tmpDir\gitwhisper.tar.gz" -C $tmpDir 40 | 41 | # Move binaries 42 | Move-Item "$tmpDir\gitwhisper.exe" "$installDir\gitwhisper.exe" -Force 43 | Copy-Item "$installDir\gitwhisper.exe" "$installDir\gw.exe" -Force 44 | 45 | # Update system PATH if not already added 46 | $envPath = [Environment]::GetEnvironmentVariable("Path", [EnvironmentVariableTarget]::Machine) 47 | if ($envPath -notlike "*$installDir*") { 48 | [Environment]::SetEnvironmentVariable("Path", "$envPath;$installDir", [EnvironmentVariableTarget]::Machine) 49 | Write-Host "🔧 Added $installDir to system PATH." 50 | } 51 | 52 | Write-Host "✅ Installed gitwhisper and gw to $installDir" 53 | & "$installDir\gitwhisper.exe" --version 54 | -------------------------------------------------------------------------------- /lib/src/commands/show_defaults_command.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // show_defaults_command.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/04. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:args/command_runner.dart'; 10 | import 'package:mason_logger/mason_logger.dart'; 11 | 12 | import '../config_manager.dart'; 13 | 14 | class ShowDefaultsCommand extends Command { 15 | ShowDefaultsCommand({ 16 | required Logger logger, 17 | }) : _logger = logger; 18 | 19 | @override 20 | String get description => 'Shows current default settings'; 21 | 22 | @override 23 | String get name => 'show-defaults'; 24 | 25 | final Logger _logger; 26 | 27 | @override 28 | Future run() async { 29 | // Initialize config manager 30 | final configManager = ConfigManager(); 31 | await configManager.load(); 32 | 33 | // Get the current defaults 34 | final defaults = configManager.getDefaultModelAndVariant(); 35 | final ollamaBaseUrl = configManager.getOllamaBaseURL(); 36 | final confirmCommits = configManager.shouldConfirmCommits(); 37 | final allowEmojis = configManager.shouldAllowEmojis(); 38 | final alwaysAdd = configManager.shouldAlwaysAdd(); 39 | 40 | if (defaults == null) { 41 | _logger 42 | ..info('No defaults are currently set.') 43 | ..info( 44 | 'Use ${lightCyan.wrap('gitwhisper set-defaults')} to set defaults.', 45 | ); 46 | return ExitCode.success.code; 47 | } 48 | 49 | final (model, variant) = defaults; 50 | 51 | _logger 52 | ..info('Current defaults:') 53 | ..info(' ${lightCyan.wrap('Model')}: $model') 54 | ..info(' ${lightCyan.wrap('Variant')}: $variant'); 55 | 56 | if (model == 'ollama' && ollamaBaseUrl != null) { 57 | _logger.info(' ${lightCyan.wrap('Base URL')}: $ollamaBaseUrl'); 58 | } 59 | 60 | _logger 61 | ..info( 62 | ' ${lightCyan.wrap('Confirm commits')}: ${confirmCommits ? 'enabled' : 'disabled'}') 63 | ..info( 64 | ' ${lightCyan.wrap('Allow emojis')}: ${allowEmojis ? 'enabled' : 'disabled'}') 65 | ..info( 66 | ' ${lightCyan.wrap('Always add')}: ${alwaysAdd ? 'enabled' : 'disabled'}'); 67 | 68 | return ExitCode.success.code; 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /lib/src/commands/update_command.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // update_command.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:args/command_runner.dart'; 10 | import 'package:mason_logger/mason_logger.dart'; 11 | import 'package:pub_updater/pub_updater.dart'; 12 | 13 | import '../version.dart'; 14 | 15 | class UpdateCommand extends Command { 16 | UpdateCommand({ 17 | required Logger logger, 18 | required PubUpdater pubUpdater, 19 | }) : _logger = logger, 20 | _pubUpdater = pubUpdater; 21 | 22 | @override 23 | String get description => 'Update to the latest version'; 24 | 25 | @override 26 | String get name => 'update'; 27 | 28 | final Logger _logger; 29 | final PubUpdater _pubUpdater; 30 | 31 | @override 32 | Future run() async { 33 | final updateCheckProgress = _logger.progress('Checking for updates'); 34 | late final String latestVersion; 35 | 36 | try { 37 | latestVersion = await _pubUpdater.getLatestVersion('gitwhisper'); 38 | } catch (e) { 39 | updateCheckProgress.fail('Failed to check for updates'); 40 | return ExitCode.software.code; 41 | } 42 | 43 | updateCheckProgress.complete('Update check complete'); 44 | 45 | if (latestVersion == packageVersion) { 46 | _logger.success('GitWhisper is already at the latest version.'); 47 | return ExitCode.success.code; 48 | } 49 | 50 | final updateProgress = _logger.progress('Updating to $latestVersion'); 51 | 52 | try { 53 | await _pubUpdater.update(packageName: 'gitwhisper'); 54 | updateProgress.complete('Updated to $latestVersion'); 55 | final url = link( 56 | message: 57 | 'https://pub.dev/packages/gitwhisper/versions/$latestVersion/changelog', 58 | uri: Uri.parse( 59 | 'https://pub.dev/packages/gitwhisper/versions/$latestVersion/changelog'), 60 | ); 61 | _logger 62 | ..info('') 63 | ..info( 64 | 'See the release notes here: $url', 65 | ); 66 | 67 | return ExitCode.success.code; 68 | } catch (e) { 69 | updateProgress.fail('Failed to update GitWhisper'); 70 | _logger.err('Error updating GitWhisper: $e'); 71 | return ExitCode.software.code; 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /lib/src/models/openai_generator.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // openai_generator.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:dio/dio.dart'; 10 | 11 | import '../commit_utils.dart'; 12 | import '../constants.dart'; 13 | import 'commit_generator.dart'; 14 | import 'language.dart'; 15 | import 'model_variants.dart'; 16 | 17 | class OpenAIGenerator extends CommitGenerator { 18 | OpenAIGenerator(super.apiKey, {super.variant}); 19 | 20 | @override 21 | String get modelName => 'openai'; 22 | 23 | @override 24 | String get defaultVariant => ModelVariants.getDefault(modelName); 25 | 26 | @override 27 | Future generateCommitMessage( 28 | String diff, 29 | Language language, { 30 | String? prefix, 31 | bool withEmoji = true, 32 | }) async { 33 | final prompt = getCommitPrompt( 34 | diff, 35 | language, 36 | prefix: prefix, 37 | withEmoji: withEmoji, 38 | ); 39 | 40 | final Response> response = await $dio.post( 41 | 'https://api.openai.com/v1/chat/completions', 42 | options: Options( 43 | headers: { 44 | 'Content-Type': 'application/json', 45 | 'Authorization': 'Bearer $apiKey', 46 | }, 47 | ), 48 | data: { 49 | 'model': actualVariant, 50 | 'store': true, 51 | 'messages': [ 52 | {'role': 'user', 'content': prompt}, 53 | ], 54 | 'max_tokens': maxTokens, 55 | }, 56 | ); 57 | 58 | if (response.statusCode == 200) { 59 | return response.data!['choices'][0]['message']['content'] 60 | .toString() 61 | .trim(); 62 | } else { 63 | throw Exception( 64 | 'API request failed with status: ${response.statusCode}, data: ${response.data}', 65 | ); 66 | } 67 | } 68 | 69 | @override 70 | Future analyzeChanges(String diff, Language language) async { 71 | final prompt = getAnalysisPrompt(diff, language); 72 | 73 | final Response> response = await $dio.post( 74 | 'https://api.openai.com/v1/chat/completions', 75 | options: Options( 76 | headers: { 77 | 'Content-Type': 'application/json', 78 | 'Authorization': 'Bearer $apiKey', 79 | }, 80 | ), 81 | data: { 82 | 'model': actualVariant, 83 | 'store': true, 84 | 'messages': [ 85 | {'role': 'user', 'content': prompt}, 86 | ], 87 | 'max_tokens': maxAnalysisTokens, 88 | }, 89 | ); 90 | 91 | if (response.statusCode == 200) { 92 | return response.data!['choices'][0]['message']['content'] 93 | .toString() 94 | .trim(); 95 | } else { 96 | throw Exception( 97 | 'API request failed with status: ${response.statusCode}, data: ${response.data}', 98 | ); 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /lib/src/models/free_generator.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // free_generator.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/12/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:dio/dio.dart'; 10 | 11 | import '../commit_utils.dart'; 12 | import '../constants.dart'; 13 | import 'commit_generator.dart'; 14 | import 'language.dart'; 15 | 16 | /// A free generator that uses LLM7.io's free API. 17 | /// No API key required - completely free to use. 18 | /// 19 | /// Anonymous tier limits: 20 | /// - 8k chars per request 21 | /// - 60 requests per hour 22 | /// - 10 requests per minute 23 | /// - 1 request per second 24 | class FreeGenerator extends CommitGenerator { 25 | FreeGenerator() : super(null); 26 | 27 | static const String _baseUrl = 'https://api.llm7.io/v1'; 28 | 29 | @override 30 | String get modelName => 'free'; 31 | 32 | @override 33 | String get defaultVariant => 'default'; 34 | 35 | @override 36 | Future generateCommitMessage( 37 | String diff, 38 | Language language, { 39 | String? prefix, 40 | bool withEmoji = true, 41 | }) async { 42 | final prompt = getCommitPrompt( 43 | diff, 44 | language, 45 | prefix: prefix, 46 | withEmoji: withEmoji, 47 | ); 48 | 49 | final Response> response = await $dio.post( 50 | '$_baseUrl/chat/completions', 51 | options: Options( 52 | headers: { 53 | 'Content-Type': 'application/json', 54 | }, 55 | ), 56 | data: { 57 | 'model': 'default', 58 | 'messages': [ 59 | {'role': 'user', 'content': prompt}, 60 | ], 61 | 'max_tokens': maxTokens, 62 | }, 63 | ); 64 | 65 | if (response.statusCode == 200) { 66 | return response.data!['choices'][0]['message']['content'] 67 | .toString() 68 | .trim(); 69 | } else { 70 | throw Exception( 71 | 'API request failed with status: ${response.statusCode}, data: ${response.data}', 72 | ); 73 | } 74 | } 75 | 76 | @override 77 | Future analyzeChanges(String diff, Language language) async { 78 | final prompt = getAnalysisPrompt(diff, language); 79 | 80 | final Response> response = await $dio.post( 81 | '$_baseUrl/chat/completions', 82 | options: Options( 83 | headers: { 84 | 'Content-Type': 'application/json', 85 | }, 86 | ), 87 | data: { 88 | 'model': 'default', 89 | 'messages': [ 90 | {'role': 'user', 'content': prompt}, 91 | ], 92 | 'max_tokens': maxAnalysisTokens, 93 | }, 94 | ); 95 | 96 | if (response.statusCode == 200) { 97 | return response.data!['choices'][0]['message']['content'] 98 | .toString() 99 | .trim(); 100 | } else { 101 | throw Exception( 102 | 'API request failed with status: ${response.statusCode}, data: ${response.data}', 103 | ); 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /lib/src/models/ollama_generator.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // ollama_generator.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/07/05. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:dio/dio.dart'; 10 | 11 | import '../commit_utils.dart'; 12 | import '../constants.dart'; 13 | import '../exceptions/exceptions.dart'; 14 | import 'commit_generator.dart'; 15 | import 'language.dart'; 16 | import 'model_variants.dart'; 17 | 18 | class OllamaGenerator extends CommitGenerator { 19 | OllamaGenerator(this.baseUrl, super.apiKey, {super.variant}); 20 | 21 | final String baseUrl; 22 | 23 | @override 24 | String get modelName => 'ollama'; 25 | 26 | @override 27 | String get defaultVariant => ModelVariants.getDefault(modelName); 28 | 29 | @override 30 | Future generateCommitMessage( 31 | String diff, 32 | Language language, { 33 | String? prefix, 34 | bool withEmoji = true, 35 | }) async { 36 | final prompt = getCommitPrompt( 37 | diff, 38 | language, 39 | prefix: prefix, 40 | withEmoji: withEmoji, 41 | ); 42 | 43 | try { 44 | final Response> response = await $dio.post( 45 | '$baseUrl/api/generate', 46 | options: Options( 47 | headers: { 48 | 'Content-Type': 'application/json', 49 | }, 50 | ), 51 | data: { 52 | 'model': actualVariant, 53 | 'prompt': prompt, 54 | 'stream': false, 55 | 'max_tokens': maxTokens, 56 | }, 57 | ); 58 | 59 | if (response.statusCode == 200) { 60 | return response.data!['response'].toString().trim(); 61 | } else { 62 | throw ServerException( 63 | message: 'Unexpected response from OpenAI API', 64 | statusCode: response.statusCode ?? 500, 65 | ); 66 | } 67 | } on DioException catch (e) { 68 | throw ErrorParser.parseProviderError('ollama', e); 69 | } 70 | } 71 | 72 | @override 73 | Future analyzeChanges(String diff, Language language) async { 74 | final prompt = getAnalysisPrompt(diff, language); 75 | 76 | try { 77 | final Response> response = await $dio.post( 78 | '$baseUrl/api/generate', 79 | options: Options( 80 | headers: { 81 | 'Content-Type': 'application/json', 82 | }, 83 | ), 84 | data: { 85 | 'model': actualVariant, 86 | 'prompt': prompt, 87 | 'stream': false, 88 | 'max_tokens': maxTokens, 89 | }, 90 | ); 91 | 92 | if (response.statusCode == 200) { 93 | return response.data!['response'].toString().trim(); 94 | } else { 95 | throw ServerException( 96 | message: 'Unexpected response from OpenAI API', 97 | statusCode: response.statusCode ?? 500, 98 | ); 99 | } 100 | } on DioException catch (e) { 101 | throw ErrorParser.parseProviderError('ollama', e); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /lib/src/models/llama_generator.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // llama_generator.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:dio/dio.dart'; 10 | 11 | import '../commit_utils.dart'; 12 | import '../constants.dart'; 13 | import '../exceptions/exceptions.dart'; 14 | import 'commit_generator.dart'; 15 | import 'language.dart'; 16 | import 'model_variants.dart'; 17 | 18 | class LlamaGenerator extends CommitGenerator { 19 | LlamaGenerator(super.apiKey, {super.variant}); 20 | 21 | @override 22 | String get modelName => 'llama'; 23 | 24 | @override 25 | String get defaultVariant => ModelVariants.getDefault(modelName); 26 | 27 | @override 28 | Future generateCommitMessage( 29 | String diff, 30 | Language language, { 31 | String? prefix, 32 | bool withEmoji = true, 33 | }) async { 34 | final prompt = getCommitPrompt( 35 | diff, 36 | language, 37 | prefix: prefix, 38 | withEmoji: withEmoji, 39 | ); 40 | 41 | try { 42 | final Response> response = await $dio.post( 43 | 'https://api.llama.api/v1/completions', 44 | options: Options( 45 | headers: { 46 | 'Content-Type': 'application/json', 47 | 'Authorization': 'Bearer $apiKey', 48 | }, 49 | ), 50 | data: { 51 | 'model': actualVariant, 52 | 'prompt': prompt, 53 | 'max_tokens': maxTokens, 54 | }, 55 | ); 56 | 57 | if (response.statusCode == 200) { 58 | // Adjust the parsing logic based on actual Llama API response structure 59 | return response.data!['choices'][0]['text'].toString().trim(); 60 | } else { 61 | throw ServerException( 62 | message: 'Unexpected response from Llama API', 63 | statusCode: response.statusCode ?? 500, 64 | ); 65 | } 66 | } on DioException catch (e) { 67 | throw ErrorParser.parseProviderError('llama', e); 68 | } 69 | } 70 | 71 | @override 72 | Future analyzeChanges(String diff, Language language) async { 73 | final prompt = getAnalysisPrompt(diff, language); 74 | 75 | try { 76 | final Response> response = await $dio.post( 77 | 'https://api.llama.api/v1/completions', 78 | options: Options( 79 | headers: { 80 | 'Content-Type': 'application/json', 81 | 'Authorization': 'Bearer $apiKey', 82 | }, 83 | ), 84 | data: { 85 | 'model': actualVariant, 86 | 'prompt': prompt, 87 | 'max_tokens': maxAnalysisTokens, 88 | }, 89 | ); 90 | 91 | if (response.statusCode == 200) { 92 | // Adjust the parsing logic based on actual Llama API response structure 93 | return response.data!['choices'][0]['text'].toString().trim(); 94 | } else { 95 | throw ServerException( 96 | message: 'Unexpected response from Llama API', 97 | statusCode: response.statusCode ?? 500, 98 | ); 99 | } 100 | } on DioException catch (e) { 101 | throw ErrorParser.parseProviderError('llama', e); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /lib/src/commands/save_key_command.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // save_key_command.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:args/command_runner.dart'; 10 | import 'package:mason_logger/mason_logger.dart'; 11 | 12 | import '../config_manager.dart'; 13 | 14 | class SaveKeyCommand extends Command { 15 | SaveKeyCommand({ 16 | required Logger logger, 17 | }) : _logger = logger { 18 | argParser 19 | ..addOption( 20 | 'model', 21 | abbr: 'm', 22 | help: 'AI model to save the key for', 23 | allowed: [ 24 | 'claude', 25 | 'openai', 26 | 'gemini', 27 | 'grok', 28 | 'llama', 29 | 'deepseek', 30 | 'github', 31 | 'ollama', 32 | ], 33 | allowedHelp: { 34 | 'claude': 'Anthropic Claude', 35 | 'openai': 'OpenAI GPT models', 36 | 'gemini': 'Google Gemini', 37 | 'grok': 'xAI Grok', 38 | 'llama': 'Meta Llama', 39 | 'deepseek': 'DeepSeek, Inc.', 40 | 'github': 'Github', 41 | 'ollama': 'Ollama', 42 | }, 43 | ) 44 | ..addOption( 45 | 'key', 46 | abbr: 'k', 47 | help: 'API key to save', 48 | ); 49 | } 50 | 51 | @override 52 | String get description => 'Save an API key for future use'; 53 | 54 | @override 55 | String get name => 'save-key'; 56 | 57 | final Logger _logger; 58 | 59 | @override 60 | Future run() async { 61 | // Get model name from args or prompt user to choose 62 | String? modelName = argResults?['model'] as String?; 63 | modelName ??= _logger.chooseOne( 64 | 'Select the AI model to save the key for:', 65 | choices: [ 66 | 'claude', 67 | 'openai', 68 | 'gemini', 69 | 'grok', 70 | 'llama', 71 | 'deepseek', 72 | 'github', 73 | 'ollama', 74 | ], 75 | defaultValue: 'openai', 76 | ); 77 | 78 | // Get API key from args or prompt user to enter 79 | String? apiKey = argResults?['key'] as String?; 80 | if (apiKey == null) { 81 | if (modelName == 'ollama') { 82 | final bool needsKey = _logger.confirm( 83 | 'Ollama typically runs locally and doesn\'t require an API key. Do you still want to set one?', 84 | defaultValue: false, 85 | ); 86 | if (!needsKey) { 87 | _logger.info('No API key needed for Ollama. Configuration complete.'); 88 | return ExitCode.success.code; 89 | } 90 | } 91 | 92 | apiKey = _logger.prompt( 93 | 'Enter the API key for $modelName:', 94 | hidden: true, 95 | ); 96 | 97 | if (apiKey.trim().isEmpty) { 98 | _logger.err('API key cannot be empty.'); 99 | return ExitCode.usage.code; 100 | } 101 | } 102 | 103 | // Initialize config manager 104 | final configManager = ConfigManager(); 105 | await configManager.load(); 106 | 107 | // Save the API key 108 | configManager.setApiKey(modelName!, apiKey); 109 | await configManager.save(); 110 | 111 | _logger.success('API key for $modelName saved successfully.'); 112 | return ExitCode.success.code; 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /lib/src/models/claude_generator.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // claude_generator.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:dio/dio.dart'; 10 | 11 | import '../commit_utils.dart'; 12 | import '../constants.dart'; 13 | import '../exceptions/exceptions.dart'; 14 | import 'commit_generator.dart'; 15 | import 'language.dart'; 16 | import 'model_variants.dart'; 17 | 18 | class ClaudeGenerator extends CommitGenerator { 19 | ClaudeGenerator(super.apiKey, {super.variant}); 20 | 21 | @override 22 | String get modelName => 'claude'; 23 | 24 | @override 25 | String get defaultVariant => ModelVariants.getDefault(modelName); 26 | 27 | @override 28 | Future generateCommitMessage( 29 | String diff, 30 | Language language, { 31 | String? prefix, 32 | bool withEmoji = true, 33 | }) async { 34 | final prompt = getCommitPrompt( 35 | diff, 36 | language, 37 | prefix: prefix, 38 | withEmoji: withEmoji, 39 | ); 40 | 41 | try { 42 | final Response> response = await $dio.post( 43 | 'https://api.anthropic.com/v1/messages', 44 | options: Options( 45 | headers: { 46 | 'x-api-key': apiKey, 47 | 'anthropic-version': '2023-06-01', 48 | }, 49 | ), 50 | data: { 51 | 'model': actualVariant, 52 | 'max_tokens': maxTokens, 53 | 'messages': [ 54 | { 55 | 'role': 'user', 56 | 'content': prompt, 57 | }, 58 | ], 59 | }, 60 | ); 61 | 62 | if (response.statusCode == 200) { 63 | return response.data!['content'][0]['text'].toString().trim(); 64 | } else { 65 | throw ServerException( 66 | message: 'Unexpected response from Claude API', 67 | statusCode: response.statusCode ?? 500, 68 | ); 69 | } 70 | } on DioException catch (e) { 71 | throw ErrorParser.parseProviderError('claude', e); 72 | } 73 | } 74 | 75 | @override 76 | Future analyzeChanges(String diff, Language language) async { 77 | final prompt = getAnalysisPrompt(diff, language); 78 | 79 | try { 80 | final Response> response = await $dio.post( 81 | 'https://api.anthropic.com/v1/messages', 82 | options: Options( 83 | headers: { 84 | 'x-api-key': apiKey, 85 | 'anthropic-version': '2023-06-01', 86 | }, 87 | ), 88 | data: { 89 | 'model': actualVariant, 90 | 'max_tokens': maxAnalysisTokens, 91 | 'messages': [ 92 | { 93 | 'role': 'user', 94 | 'content': prompt, 95 | }, 96 | ], 97 | }, 98 | ); 99 | 100 | if (response.statusCode == 200) { 101 | return response.data!['content'][0]['text'].toString().trim(); 102 | } else { 103 | throw ServerException( 104 | message: 'Unexpected response from Claude API', 105 | statusCode: response.statusCode ?? 500, 106 | ); 107 | } 108 | } on DioException catch (e) { 109 | throw ErrorParser.parseProviderError('claude', e); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /lib/src/models/gemini_generator.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // gemini_generator.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:dio/dio.dart'; 10 | 11 | import '../commit_utils.dart'; 12 | import '../constants.dart'; 13 | import '../exceptions/api_exceptions.dart'; 14 | import '../exceptions/exceptions.dart'; 15 | import 'commit_generator.dart'; 16 | import 'language.dart'; 17 | import 'model_variants.dart'; 18 | 19 | class GeminiGenerator extends CommitGenerator { 20 | GeminiGenerator(super.apiKey, {super.variant}); 21 | 22 | @override 23 | String get modelName => 'gemini'; 24 | 25 | @override 26 | String get defaultVariant => ModelVariants.getDefault(modelName); 27 | 28 | @override 29 | Future generateCommitMessage( 30 | String diff, 31 | Language language, { 32 | String? prefix, 33 | bool withEmoji = true, 34 | }) async { 35 | final prompt = getCommitPrompt( 36 | diff, 37 | language, 38 | prefix: prefix, 39 | withEmoji: withEmoji, 40 | ); 41 | 42 | try { 43 | final Response> response = await $dio.post( 44 | 'https://generativelanguage.googleapis.com/v1beta/models/$actualVariant:generateContent?key=$apiKey', 45 | data: { 46 | 'contents': [ 47 | { 48 | 'parts': [ 49 | {'text': prompt}, 50 | ], 51 | } 52 | ], 53 | 'generationConfig': { 54 | 'maxOutputTokens': maxTokens, 55 | }, 56 | }, 57 | ); 58 | 59 | if (response.statusCode == 200) { 60 | return response.data!['candidates'][0]['content']['parts'][0]['text'] 61 | .toString() 62 | .trim(); 63 | } else { 64 | throw ServerException( 65 | message: 'Unexpected response from Gemini API', 66 | statusCode: response.statusCode ?? 500, 67 | ); 68 | } 69 | } on DioException catch (e) { 70 | throw ErrorParser.parseProviderError('gemini', e); 71 | } 72 | } 73 | 74 | @override 75 | Future analyzeChanges(String diff, Language language) async { 76 | final prompt = getAnalysisPrompt(diff, language); 77 | 78 | try { 79 | final Response> response = await $dio.post( 80 | 'https://generativelanguage.googleapis.com/v1beta/models/$actualVariant:generateContent?key=$apiKey', 81 | data: { 82 | 'contents': [ 83 | { 84 | 'parts': [ 85 | {'text': prompt}, 86 | ], 87 | } 88 | ], 89 | 'generationConfig': { 90 | 'maxOutputTokens': maxAnalysisTokens, 91 | }, 92 | }, 93 | ); 94 | 95 | if (response.statusCode == 200) { 96 | return response.data!['candidates'][0]['content']['parts'][0]['text'] 97 | .toString() 98 | .trim(); 99 | } else { 100 | throw ServerException( 101 | message: 'Unexpected response from Gemini API', 102 | statusCode: response.statusCode ?? 500, 103 | ); 104 | } 105 | } on DioException catch (e) { 106 | throw ErrorParser.parseProviderError('gemini', e); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /lib/src/models/deepseek_generator.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // openai_generator.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:dio/dio.dart'; 10 | 11 | import '../commit_utils.dart'; 12 | import '../constants.dart'; 13 | import '../exceptions/exceptions.dart'; 14 | import 'commit_generator.dart'; 15 | import 'language.dart'; 16 | import 'model_variants.dart'; 17 | 18 | class DeepseekGenerator extends CommitGenerator { 19 | DeepseekGenerator(super.apiKey, {super.variant}); 20 | 21 | @override 22 | String get modelName => 'deepseek'; 23 | 24 | @override 25 | String get defaultVariant => ModelVariants.getDefault(modelName); 26 | 27 | @override 28 | Future generateCommitMessage( 29 | String diff, 30 | Language language, { 31 | String? prefix, 32 | bool withEmoji = true, 33 | }) async { 34 | final prompt = getCommitPrompt( 35 | diff, 36 | language, 37 | prefix: prefix, 38 | withEmoji: withEmoji, 39 | ); 40 | 41 | try { 42 | final Response> response = await $dio.post( 43 | 'https://api.deepseek.com/v1/chat/completions', 44 | options: Options( 45 | headers: { 46 | 'Content-Type': 'application/json', 47 | 'Authorization': 'Bearer $apiKey', 48 | }, 49 | ), 50 | data: { 51 | 'model': actualVariant, 52 | 'store': true, 53 | 'messages': [ 54 | {'role': 'user', 'content': prompt}, 55 | ], 56 | 'max_tokens': maxTokens, 57 | }, 58 | ); 59 | 60 | if (response.statusCode == 200) { 61 | return response.data!['choices'][0]['message']['content'] 62 | .toString() 63 | .trim(); 64 | } else { 65 | throw ServerException( 66 | message: 'Unexpected response from DeepSeek API', 67 | statusCode: response.statusCode ?? 500, 68 | ); 69 | } 70 | } on DioException catch (e) { 71 | throw ErrorParser.parseProviderError('deepseek', e); 72 | } 73 | } 74 | 75 | @override 76 | Future analyzeChanges(String diff, Language language) async { 77 | final prompt = getAnalysisPrompt(diff, language); 78 | 79 | try { 80 | final Response> response = await $dio.post( 81 | 'https://api.deepseek.com/v1/chat/completions', 82 | options: Options( 83 | headers: { 84 | 'Content-Type': 'application/json', 85 | 'Authorization': 'Bearer $apiKey', 86 | }, 87 | ), 88 | data: { 89 | 'model': actualVariant, 90 | 'store': true, 91 | 'messages': [ 92 | {'role': 'user', 'content': prompt}, 93 | ], 94 | 'max_tokens': maxAnalysisTokens, 95 | }, 96 | ); 97 | 98 | if (response.statusCode == 200) { 99 | return response.data!['choices'][0]['message']['content'] 100 | .toString() 101 | .trim(); 102 | } else { 103 | throw ServerException( 104 | message: 'Unexpected response from DeepSeek API', 105 | statusCode: response.statusCode ?? 500, 106 | ); 107 | } 108 | } on DioException catch (e) { 109 | throw ErrorParser.parseProviderError('deepseek', e); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /lib/src/models/github_generator.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // github_generator.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:dio/dio.dart'; 10 | 11 | import '../commit_utils.dart'; 12 | import '../constants.dart'; 13 | import '../exceptions/exceptions.dart'; 14 | import 'commit_generator.dart'; 15 | import 'language.dart'; 16 | import 'model_variants.dart'; 17 | 18 | class GithubGenerator extends CommitGenerator { 19 | GithubGenerator(super.apiKey, {super.variant}); 20 | 21 | @override 22 | String get modelName => 'github'; 23 | 24 | @override 25 | String get defaultVariant => ModelVariants.getDefault(modelName); 26 | 27 | @override 28 | Future generateCommitMessage( 29 | String diff, 30 | Language language, { 31 | String? prefix, 32 | bool withEmoji = true, 33 | }) async { 34 | final prompt = getCommitPrompt( 35 | diff, 36 | language, 37 | prefix: prefix, 38 | withEmoji: withEmoji, 39 | ); 40 | 41 | try { 42 | final Response> response = await $dio.post( 43 | 'https://models.inference.ai.azure.com/chat/completions', 44 | options: Options( 45 | headers: { 46 | 'Content-Type': 'application/json', 47 | 'Authorization': 'Bearer $apiKey', 48 | }, 49 | ), 50 | data: { 51 | 'model': actualVariant, 52 | 'store': true, 53 | 'messages': [ 54 | {'role': 'user', 'content': prompt}, 55 | ], 56 | 'max_tokens': maxTokens, 57 | }, 58 | ); 59 | 60 | if (response.statusCode == 200) { 61 | return response.data!['choices'][0]['message']['content'] 62 | .toString() 63 | .trim(); 64 | } else { 65 | throw ServerException( 66 | message: 'Unexpected response from GitHub API', 67 | statusCode: response.statusCode ?? 500, 68 | ); 69 | } 70 | } on DioException catch (e) { 71 | throw ErrorParser.parseProviderError('github', e); 72 | } 73 | } 74 | 75 | @override 76 | Future analyzeChanges(String diff, Language language) async { 77 | final prompt = getAnalysisPrompt(diff, language); 78 | 79 | try { 80 | final Response> response = await $dio.post( 81 | 'https://models.inference.ai.azure.com/chat/completions', 82 | options: Options( 83 | headers: { 84 | 'Content-Type': 'application/json', 85 | 'Authorization': 'Bearer $apiKey', 86 | }, 87 | ), 88 | data: { 89 | 'model': actualVariant, 90 | 'store': true, 91 | 'messages': [ 92 | {'role': 'user', 'content': prompt}, 93 | ], 94 | 'max_tokens': maxAnalysisTokens, 95 | }, 96 | ); 97 | 98 | if (response.statusCode == 200) { 99 | return response.data!['choices'][0]['message']['content'] 100 | .toString() 101 | .trim(); 102 | } else { 103 | throw ServerException( 104 | message: 'Unexpected response from GitHub API', 105 | statusCode: response.statusCode ?? 500, 106 | ); 107 | } 108 | } on DioException catch (e) { 109 | throw ErrorParser.parseProviderError('github', e); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /lib/src/models/grok_generator.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // grok_generator.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:dio/dio.dart'; 10 | 11 | import '../commit_utils.dart'; 12 | import '../constants.dart'; 13 | import '../exceptions/exceptions.dart'; 14 | import 'commit_generator.dart'; 15 | import 'language.dart'; 16 | import 'model_variants.dart'; 17 | 18 | class GrokGenerator extends CommitGenerator { 19 | GrokGenerator(super.apiKey, {super.variant}); 20 | 21 | @override 22 | String get defaultVariant => ModelVariants.getDefault(modelName); 23 | 24 | @override 25 | String get modelName => 'grok'; 26 | 27 | @override 28 | Future generateCommitMessage( 29 | String diff, 30 | Language language, { 31 | String? prefix, 32 | bool withEmoji = true, 33 | }) async { 34 | final prompt = getCommitPrompt( 35 | diff, 36 | language, 37 | prefix: prefix, 38 | withEmoji: withEmoji, 39 | ); 40 | 41 | try { 42 | final Response> response = await $dio.post( 43 | 'https://api.x.ai/v1/chat/completions', 44 | options: Options( 45 | headers: { 46 | 'Content-Type': 'application/json', 47 | 'Authorization': 'Bearer $apiKey', 48 | }, 49 | ), 50 | data: { 51 | 'model': actualVariant, 52 | 'messages': [ 53 | {'role': 'user', 'content': prompt}, 54 | ], 55 | 'max_tokens': maxTokens, 56 | }, 57 | ); 58 | 59 | if (response.statusCode == 200) { 60 | // Adjust the parsing logic based on actual Grok API response structure 61 | return response.data!['choices'][0]['message']['content'] 62 | .toString() 63 | .trim(); 64 | } else { 65 | throw ServerException( 66 | message: 'Unexpected response from Grok API', 67 | statusCode: response.statusCode ?? 500, 68 | ); 69 | } 70 | } on DioException catch (e) { 71 | throw ErrorParser.parseProviderError('grok', e); 72 | } 73 | } 74 | 75 | @override 76 | Future analyzeChanges(String diff, Language language) async { 77 | final prompt = getAnalysisPrompt(diff, language); 78 | 79 | try { 80 | final Response> response = await $dio.post( 81 | 'https://api.x.ai/v1/chat/completions', 82 | options: Options( 83 | headers: { 84 | 'Content-Type': 'application/json', 85 | 'Authorization': 'Bearer $apiKey', 86 | }, 87 | ), 88 | data: { 89 | 'model': actualVariant, 90 | 'messages': [ 91 | {'role': 'user', 'content': prompt}, 92 | ], 93 | 'max_tokens': maxAnalysisTokens, 94 | }, 95 | ); 96 | 97 | if (response.statusCode == 200) { 98 | // Adjust the parsing logic based on actual Grok API response structure 99 | return response.data!['choices'][0]['message']['content'] 100 | .toString() 101 | .trim(); 102 | } else { 103 | throw ServerException( 104 | message: 'Unexpected response from Grok API', 105 | statusCode: response.statusCode ?? 500, 106 | ); 107 | } 108 | } on DioException catch (e) { 109 | throw ErrorParser.parseProviderError('grok', e); 110 | } 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to GitWhisper 2 | 3 | Thanks for contributing! We appreciate your help in making GitWhisper better. 4 | 5 | ## Getting Started 6 | 7 | 1. **Fork the repository** and clone your fork locally 8 | 2. **Create a branch** with a descriptive name: 9 | - `feat/brief-description` for new features 10 | - `fix/brief-description` for bug fixes 11 | - `docs/brief-description` for documentation changes 12 | - `refactor/brief-description` for code refactoring 13 | 14 | ## Development Setup 15 | 16 | ### Prerequisites 17 | - Dart SDK (^3.5.0) 18 | - Git 19 | 20 | ### Installation 21 | ```bash 22 | # Install dependencies 23 | dart pub get 24 | 25 | # Activate the package locally for testing 26 | dart pub global activate --source=path . 27 | ``` 28 | 29 | ## Making Changes 30 | 31 | 1. **Write your code** following the existing code style 32 | 2. **Run tests** to ensure nothing breaks: 33 | ```bash 34 | dart test 35 | ``` 36 | 3. **Test your changes** manually: 37 | ```bash 38 | # Test the CLI locally 39 | dart run bin/gitwhisper.dart commit --model 40 | ``` 41 | 4. **Update documentation** if you're adding new features or changing behavior 42 | 43 | ## Code Style 44 | 45 | - Follow Dart's official style guide 46 | - This project uses [very_good_analysis](https://pub.dev/packages/very_good_analysis) for linting 47 | - Run analysis before committing: 48 | ```bash 49 | dart analyze 50 | ``` 51 | 52 | ## Commit Messages 53 | 54 | We use conventional commits with emojis (it's what GitWhisper does!): 55 | - `feat: ✨ Add new feature` 56 | - `fix: 🐛 Fix bug description` 57 | - `docs: 📚 Update documentation` 58 | - `test: 🧪 Add or update tests` 59 | - `refactor: ♻️ Refactor code` 60 | - `chore: 🔧 Update build or dependencies` 61 | 62 | Use GitWhisper itself to generate your commit messages! 63 | 64 | ## Pull Request Process 65 | 66 | 1. **Open a PR** against the `master` branch 67 | 2. **Reference any related issues** in the PR description 68 | 3. **Explain what you changed and why** - help reviewers understand your changes 69 | 4. **Ensure CI passes** - all tests and checks must pass 70 | 5. **Respond to feedback** - be open to suggestions and changes 71 | 72 | ### Hacktoberfest 73 | If your PR is for Hacktoberfest, mention `hacktoberfest` in the PR description. 74 | 75 | ## What to Contribute 76 | 77 | We welcome contributions of all kinds: 78 | 79 | - 🐛 **Bug fixes** - Help us squash bugs! 80 | - ✨ **New features** - Have an idea? Let's discuss it first by opening an issue 81 | - 📚 **Documentation** - Improvements to README, code comments, or examples 82 | - 🧪 **Tests** - More test coverage is always appreciated 83 | - 🌍 **Translations** - Add support for more languages 84 | - 🤖 **Model support** - Add support for new AI models 85 | 86 | ### Areas That Need Help 87 | - Additional test coverage 88 | - Performance optimizations 89 | - Better error handling and user feedback 90 | - Support for more AI models and variants 91 | 92 | ## Guidelines 93 | 94 | - **Keep changes focused** - Small, incremental changes are easier to review 95 | - **One feature per PR** - Don't bundle unrelated changes together 96 | - **Test your changes** - Ensure everything works as expected 97 | - **Be respectful** - See [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) for community rules 98 | 99 | ## Questions? 100 | 101 | If you have questions or need help, feel free to: 102 | - Open an issue for discussion 103 | - Ask in your PR if you need guidance 104 | 105 | Thank you for contributing to GitWhisper! 🎉 -------------------------------------------------------------------------------- /lib/src/command_runner.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // command_runner.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:args/args.dart'; 10 | import 'package:args/command_runner.dart'; 11 | import 'package:cli_completion/cli_completion.dart'; 12 | import 'package:mason_logger/mason_logger.dart'; 13 | import 'package:pub_updater/pub_updater.dart'; 14 | 15 | import 'commands/always_add_command.dart'; 16 | import 'commands/analyze_command.dart'; 17 | import 'commands/change_language_command.dart'; 18 | import 'commands/clear_defaults_command.dart'; 19 | import 'commands/commit_command.dart'; 20 | import 'commands/list_models_command.dart'; 21 | import 'commands/list_variants_command.dart'; 22 | import 'commands/save_key_command.dart'; 23 | import 'commands/set_defaults_command.dart'; 24 | import 'commands/show_defaults_command.dart'; 25 | import 'commands/update_command.dart'; 26 | import 'constants.dart'; 27 | import 'version.dart'; 28 | 29 | class GitWhisperCommandRunner extends CompletionCommandRunner { 30 | GitWhisperCommandRunner({ 31 | PubUpdater? pubUpdater, 32 | }) : _pubUpdater = pubUpdater ?? PubUpdater(), 33 | super('gitwhisper', 'AI-powered Git commit message generator') { 34 | argParser 35 | ..addFlag( 36 | 'version', 37 | abbr: 'v', 38 | negatable: false, 39 | help: 'Print the current version.', 40 | ) 41 | ..addFlag( 42 | 'verbose', 43 | help: 'Enable verbose logging.', 44 | negatable: false, 45 | ); 46 | 47 | // Add commands 48 | addCommand(CommitCommand(logger: $logger)); 49 | addCommand(AnalyzeCommand(logger: $logger)); 50 | addCommand(ListModelsCommand(logger: $logger)); 51 | addCommand(ListVariantsCommand(logger: $logger)); 52 | addCommand(SaveKeyCommand(logger: $logger)); 53 | addCommand(SetDefaultsCommand(logger: $logger)); 54 | addCommand(ShowDefaultsCommand(logger: $logger)); 55 | addCommand(ClearDefaultsCommand(logger: $logger)); 56 | addCommand(AlwaysAddCommand(logger: $logger)); 57 | addCommand(ChangeLanguageCommand(logger: $logger)); 58 | addCommand(UpdateCommand(logger: $logger, pubUpdater: _pubUpdater)); 59 | } 60 | 61 | @override 62 | void printUsage() { 63 | final url = link( 64 | message: 'iamngoni 🚀', 65 | uri: Uri.parse('https://github.com/iamngoni'), 66 | ); 67 | $logger 68 | ..info('') 69 | ..info( 70 | 'GitWhisper (by $url) - Your AI companion for crafting perfect commit messages.', 71 | ) 72 | ..info('') 73 | ..info(usage); 74 | } 75 | 76 | final PubUpdater _pubUpdater; 77 | 78 | @override 79 | Future run(Iterable args) async { 80 | try { 81 | final argsToUse = args.isEmpty ? ['commit'] : args; 82 | final topLevelResults = parse(argsToUse); 83 | return await runCommand(topLevelResults) ?? ExitCode.success.code; 84 | } on FormatException catch (e, stackTrace) { 85 | $logger 86 | ..err(e.message) 87 | ..detail(stackTrace.toString()); 88 | printUsage(); 89 | return ExitCode.usage.code; 90 | } on UsageException catch (e) { 91 | $logger 92 | ..err(e.message) 93 | ..info('') 94 | ..info(e.usage); 95 | return ExitCode.usage.code; 96 | } 97 | } 98 | 99 | @override 100 | Future runCommand(ArgResults topLevelResults) async { 101 | // Handle version flag 102 | if (topLevelResults['version'] == true) { 103 | $logger.info('gitwhisper version: $packageVersion'); 104 | return ExitCode.success.code; 105 | } 106 | 107 | // Handle no command 108 | final commandResult = await super.runCommand(topLevelResults); 109 | return commandResult; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /lib/src/exceptions/api_exception.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // api_exception.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | /// Base class for all API-related exceptions 10 | abstract class ApiException implements Exception { 11 | const ApiException({ 12 | required this.message, 13 | required this.statusCode, 14 | this.errorType, 15 | this.errorCode, 16 | this.requestId, 17 | this.retryAfter, 18 | }); 19 | 20 | /// Human-readable error message 21 | final String message; 22 | 23 | /// HTTP status code 24 | final int statusCode; 25 | 26 | /// API-specific error type (e.g., 'invalid_request_error', 'rate_limit_error') 27 | final String? errorType; 28 | 29 | /// API-specific error code 30 | final String? errorCode; 31 | 32 | /// Request ID for debugging (if available) 33 | final String? requestId; 34 | 35 | /// Retry after duration in seconds (for rate limiting) 36 | final int? retryAfter; 37 | 38 | /// Whether this error is retryable 39 | bool get isRetryable => statusCode >= 500 || statusCode == 429; 40 | 41 | /// Whether this error is due to rate limiting 42 | bool get isRateLimited => statusCode == 429; 43 | 44 | /// Whether this error is due to authentication issues 45 | bool get isAuthenticationError => statusCode == 401; 46 | 47 | /// Whether this error is due to permission issues 48 | bool get isPermissionError => statusCode == 403; 49 | 50 | /// Whether this error is due to invalid request 51 | bool get isInvalidRequest => statusCode == 400; 52 | 53 | /// Whether this error is due to server issues 54 | bool get isServerError => statusCode >= 500; 55 | 56 | /// Get a user-friendly error message with recovery suggestions 57 | String get userFriendlyMessage { 58 | switch (statusCode) { 59 | case 400: 60 | return 'Invalid request: $message\n' 61 | 'Please check your request parameters and try again.'; 62 | case 401: 63 | return 'Authentication failed: $message\n' 64 | 'Please check your API key and ensure it\'s valid.'; 65 | case 403: 66 | return 'Permission denied: $message\n' 67 | 'Please check your API key permissions or account status.'; 68 | case 404: 69 | return 'Resource not found: $message\n' 70 | 'Please check the model name or endpoint URL.'; 71 | case 413: 72 | return 'Request too large: $message\n' 73 | 'Please reduce the size of your request (shorter diff or prompt).'; 74 | case 429: 75 | final retryMsg = retryAfter != null 76 | ? ' Please wait $retryAfter seconds before retrying.' 77 | : ' Please wait before retrying.'; 78 | return 'Rate limit exceeded: $message$retryMsg'; 79 | case 500: 80 | return 'Server error: $message\n' 81 | 'This is a temporary issue. Please try again later.'; 82 | case 503: 83 | return 'Service unavailable: $message\n' 84 | 'The service is temporarily down. Please try again later.'; 85 | case 529: 86 | return 'Service overloaded: $message\n' 87 | 'The service is experiencing high traffic. Please try again later.'; 88 | default: 89 | return 'API error ($statusCode): $message'; 90 | } 91 | } 92 | 93 | @override 94 | String toString() { 95 | final buffer = StringBuffer('${runtimeType}: $message'); 96 | 97 | if (statusCode != 0) { 98 | buffer.write(' (HTTP $statusCode)'); 99 | } 100 | 101 | if (errorType != null) { 102 | buffer.write(' [Type: $errorType]'); 103 | } 104 | 105 | if (errorCode != null) { 106 | buffer.write(' [Code: $errorCode]'); 107 | } 108 | 109 | if (requestId != null) { 110 | buffer.write(' [Request ID: $requestId]'); 111 | } 112 | 113 | return buffer.toString(); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /lib/src/exceptions/error_handler.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // error_handler.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import '../constants.dart'; 10 | import 'api_exception.dart'; 11 | import 'api_exceptions.dart'; 12 | 13 | /// Utility class for handling errors in commands 14 | class ErrorHandler { 15 | /// Handle API errors with appropriate user feedback 16 | static void handleApiError(ApiException error, {String? context}) { 17 | $logger.err('API Error: $error'); 18 | 19 | // Display user-friendly error message 20 | final contextMsg = context != null ? ' while $context' : ''; 21 | $logger 22 | ..err('Error$contextMsg:') 23 | ..err(error.userFriendlyMessage); 24 | 25 | // Show additional debugging info if available 26 | if (error.requestId != null) { 27 | $logger.detail('Request ID: ${error.requestId}'); 28 | } 29 | } 30 | 31 | /// Handle general exceptions 32 | static void handleGeneralError(Exception error, {String? context}) { 33 | $logger.err('General Error: $error'); 34 | 35 | // Display user-friendly error message 36 | final contextMsg = context != null ? ' while $context' : ''; 37 | $logger 38 | ..err('An unexpected error occurred$contextMsg:') 39 | ..err(error.toString()); 40 | } 41 | 42 | /// Handle errors with retry suggestions 43 | static void handleErrorWithRetry( 44 | ApiException error, { 45 | String? context, 46 | bool showRetryInfo = true, 47 | }) { 48 | handleApiError(error, context: context); 49 | 50 | if (showRetryInfo) { 51 | if (error.isRetryable) { 52 | $logger 53 | ..info('') 54 | ..info('This error is retryable. You can:') 55 | ..info('• Try running the command again'); 56 | 57 | if (error.isRateLimited && error.retryAfter != null) { 58 | $logger.info('• Wait ${error.retryAfter} seconds before retrying'); 59 | } else if (error.isRateLimited) { 60 | $logger.info('• Wait a few minutes before retrying'); 61 | } else if (error.isServerError) { 62 | $logger.info('• Wait a few minutes and try again'); 63 | } 64 | } else { 65 | $logger 66 | ..info('') 67 | ..info('This error requires your attention before retrying.'); 68 | } 69 | } 70 | } 71 | 72 | /// Handle errors with fallback options 73 | static void handleErrorWithFallback( 74 | ApiException error, { 75 | String? context, 76 | List? fallbackOptions, 77 | }) { 78 | handleApiError(error, context: context); 79 | 80 | if (fallbackOptions != null && fallbackOptions.isNotEmpty) { 81 | $logger 82 | ..info('') 83 | ..info('You can try these alternatives:'); 84 | for (final option in fallbackOptions) { 85 | $logger.info('• $option'); 86 | } 87 | } 88 | } 89 | 90 | /// Get a short error summary for display 91 | static String getErrorSummary(ApiException error) { 92 | switch (error.runtimeType) { 93 | case AuthenticationException: 94 | return 'Authentication failed - check your API key'; 95 | case PermissionException: 96 | return 'Permission denied - check your account access'; 97 | case RateLimitException: 98 | return 'Rate limit exceeded - please wait before retrying'; 99 | case InvalidRequestException: 100 | return 'Invalid request - check your parameters'; 101 | case ResourceNotFoundException: 102 | return 'Resource not found - check your configuration'; 103 | case RequestTooLargeException: 104 | return 'Request too large - reduce the size of your changes'; 105 | case ServerException: 106 | return 'Server error - temporary issue, please try again'; 107 | case ServiceOverloadedException: 108 | return 'Service overloaded - please try again later'; 109 | case InsufficientBalanceException: 110 | return 'Insufficient balance - add funds to your account'; 111 | case TimeoutException: 112 | return 'Request timeout - reduce request size or try again'; 113 | default: 114 | return 'API error occurred'; 115 | } 116 | } 117 | 118 | /// Check if an error suggests switching to a different model 119 | static bool shouldSuggestModelSwitch(ApiException error) { 120 | return error.isServerError || 121 | error is ServiceOverloadedException || 122 | error is RequestTooLargeException || 123 | error is TimeoutException; 124 | } 125 | 126 | /// Get model switch suggestions based on error type 127 | static List getModelSwitchSuggestions(ApiException error) { 128 | final suggestions = []; 129 | 130 | if (error is ServiceOverloadedException || error.isServerError) { 131 | suggestions 132 | ..add('Try switching to a different AI provider temporarily') 133 | ..add('Use a different model variant if available'); 134 | } 135 | 136 | if (error is RequestTooLargeException || error is TimeoutException) { 137 | suggestions 138 | ..add('Switch to a model with a larger context window') 139 | ..add('Use a faster model for quicker processing'); 140 | } 141 | 142 | return suggestions; 143 | } 144 | 145 | /// Format error for logging 146 | static String formatErrorForLogging(Exception error, {String? context}) { 147 | final buffer = StringBuffer(); 148 | 149 | if (context != null) { 150 | buffer.writeln('Context: $context'); 151 | } 152 | 153 | buffer 154 | ..writeln('Error Type: ${error.runtimeType}') 155 | ..writeln('Error Message: $error'); 156 | 157 | if (error is ApiException) { 158 | buffer.writeln('Status Code: ${error.statusCode}'); 159 | if (error.errorType != null) { 160 | buffer.writeln('Error Type: ${error.errorType}'); 161 | } 162 | if (error.errorCode != null) { 163 | buffer.writeln('Error Code: ${error.errorCode}'); 164 | } 165 | if (error.requestId != null) { 166 | buffer.writeln('Request ID: ${error.requestId}'); 167 | } 168 | } 169 | 170 | return buffer.toString(); 171 | } 172 | } 173 | -------------------------------------------------------------------------------- /lib/src/exceptions/api_exceptions.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // api_exceptions.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'api_exception.dart'; 10 | 11 | /// Exception for authentication-related errors (401) 12 | class AuthenticationException extends ApiException { 13 | const AuthenticationException({ 14 | required super.message, 15 | super.statusCode = 401, 16 | super.errorType, 17 | super.errorCode, 18 | super.requestId, 19 | }); 20 | 21 | @override 22 | String get userFriendlyMessage => 'Authentication failed: $message\n' 23 | 'Solutions:\n' 24 | '• Check that your API key is correct and valid\n' 25 | '• Ensure your API key has not expired\n' 26 | '• Verify you\'re using the correct API key for this service\n' 27 | '• Make sure your API key is properly formatted (no extra spaces)'; 28 | } 29 | 30 | /// Exception for permission-related errors (403) 31 | class PermissionException extends ApiException { 32 | const PermissionException({ 33 | required super.message, 34 | super.statusCode = 403, 35 | super.errorType, 36 | super.errorCode, 37 | super.requestId, 38 | }); 39 | 40 | @override 41 | String get userFriendlyMessage => 'Permission denied: $message\n' 42 | 'Solutions:\n' 43 | '• Check your API key permissions\n' 44 | '• Ensure your account has access to this model\n' 45 | '• Verify your account is in good standing\n' 46 | '• Check if billing is enabled for your account'; 47 | } 48 | 49 | /// Exception for rate limiting errors (429) 50 | class RateLimitException extends ApiException { 51 | const RateLimitException({ 52 | required super.message, 53 | super.statusCode = 429, 54 | super.errorType, 55 | super.errorCode, 56 | super.requestId, 57 | super.retryAfter, 58 | }); 59 | 60 | @override 61 | String get userFriendlyMessage { 62 | final retryMsg = retryAfter != null 63 | ? 'Please wait $retryAfter seconds before retrying.' 64 | : 'Please wait before retrying.'; 65 | 66 | return 'Rate limit exceeded: $message\n' 67 | 'Solutions:\n' 68 | '• Reduce the frequency of your requests\n' 69 | '• Implement exponential backoff in your retry logic\n' 70 | '• Consider upgrading your API plan for higher limits\n' 71 | '• $retryMsg'; 72 | } 73 | } 74 | 75 | /// Exception for invalid request errors (400, 422) 76 | class InvalidRequestException extends ApiException { 77 | const InvalidRequestException({ 78 | required super.message, 79 | super.statusCode = 400, 80 | super.errorType, 81 | super.errorCode, 82 | super.requestId, 83 | }); 84 | 85 | @override 86 | String get userFriendlyMessage => 'Invalid request: $message\n' 87 | 'Solutions:\n' 88 | '• Check your request parameters\n' 89 | '• Ensure the model name is correct\n' 90 | '• Verify the request format matches the API specification\n' 91 | '• Check that all required fields are provided'; 92 | } 93 | 94 | /// Exception for resource not found errors (404) 95 | class ResourceNotFoundException extends ApiException { 96 | const ResourceNotFoundException({ 97 | required super.message, 98 | super.statusCode = 404, 99 | super.errorType, 100 | super.errorCode, 101 | super.requestId, 102 | }); 103 | 104 | @override 105 | String get userFriendlyMessage => 'Resource not found: $message\n' 106 | 'Solutions:\n' 107 | '• Check the model name is correct\n' 108 | '• Verify the API endpoint URL\n' 109 | '• Ensure the resource exists and is accessible\n' 110 | '• Check your API version'; 111 | } 112 | 113 | /// Exception for request too large errors (413) 114 | class RequestTooLargeException extends ApiException { 115 | const RequestTooLargeException({ 116 | required super.message, 117 | super.statusCode = 413, 118 | super.errorType, 119 | super.errorCode, 120 | super.requestId, 121 | }); 122 | 123 | @override 124 | String get userFriendlyMessage => 'Request too large: $message\n' 125 | 'Solutions:\n' 126 | '• Reduce the size of your git diff\n' 127 | '• Break large changes into smaller commits\n' 128 | '• Exclude unnecessary files from your git diff\n' 129 | '• Use a model with a larger context window'; 130 | } 131 | 132 | /// Exception for server errors (500, 502, 503) 133 | class ServerException extends ApiException { 134 | const ServerException({ 135 | required super.message, 136 | required super.statusCode, 137 | super.errorType, 138 | super.errorCode, 139 | super.requestId, 140 | }); 141 | 142 | @override 143 | String get userFriendlyMessage => 'Server error: $message\n' 144 | 'This is a temporary issue on the service provider\'s side.\n' 145 | 'Solutions:\n' 146 | '• Wait a few minutes and try again\n' 147 | '• Check the service status page\n' 148 | '• Try using a different model if available\n' 149 | '• Contact support if the issue persists'; 150 | } 151 | 152 | /// Exception for service overload errors (529) 153 | class ServiceOverloadedException extends ApiException { 154 | const ServiceOverloadedException({ 155 | required super.message, 156 | super.statusCode = 529, 157 | super.errorType, 158 | super.errorCode, 159 | super.requestId, 160 | }); 161 | 162 | @override 163 | String get userFriendlyMessage => 'Service overloaded: $message\n' 164 | 'The service is experiencing high traffic.\n' 165 | 'Solutions:\n' 166 | '• Wait a few minutes and try again\n' 167 | '• Try during off-peak hours\n' 168 | '• Use exponential backoff for retries\n' 169 | '• Consider switching to a different model temporarily'; 170 | } 171 | 172 | /// Exception for insufficient balance/credits (402) 173 | class InsufficientBalanceException extends ApiException { 174 | const InsufficientBalanceException({ 175 | required super.message, 176 | super.statusCode = 402, 177 | super.errorType, 178 | super.errorCode, 179 | super.requestId, 180 | }); 181 | 182 | @override 183 | String get userFriendlyMessage => 'Insufficient balance: $message\n' 184 | 'Solutions:\n' 185 | '• Add funds to your account\n' 186 | '• Check your billing information\n' 187 | '• Verify your payment method\n' 188 | '• Review your usage limits'; 189 | } 190 | 191 | /// Exception for timeout errors 192 | class TimeoutException extends ApiException { 193 | const TimeoutException({ 194 | required super.message, 195 | super.statusCode = 504, 196 | super.errorType, 197 | super.errorCode, 198 | super.requestId, 199 | }); 200 | 201 | @override 202 | String get userFriendlyMessage => 'Request timeout: $message\n' 203 | 'Solutions:\n' 204 | '• Reduce the size of your request\n' 205 | '• Try again with a shorter prompt\n' 206 | '• Use a faster model if available\n' 207 | '• Check your network connection'; 208 | } 209 | -------------------------------------------------------------------------------- /lib/src/commands/list_variants_command.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // list_variants_command.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/02. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:args/command_runner.dart'; 10 | import 'package:mason_logger/mason_logger.dart'; 11 | 12 | class ListVariantsCommand extends Command { 13 | ListVariantsCommand({ 14 | required Logger logger, 15 | }) : _logger = logger { 16 | argParser.addOption( 17 | 'model', 18 | abbr: 'm', 19 | help: 'Model to list variants for', 20 | allowed: [ 21 | 'claude', 22 | 'openai', 23 | 'gemini', 24 | 'grok', 25 | 'llama', 26 | 'deepseek', 27 | 'github', 28 | 'ollama', 29 | 'free', 30 | ], 31 | ); 32 | } 33 | 34 | @override 35 | String get description => 'List available variants for AI models'; 36 | 37 | @override 38 | String get name => 'list-variants'; 39 | 40 | final Logger _logger; 41 | 42 | @override 43 | Future run() async { 44 | final model = argResults?['model'] as String?; 45 | 46 | if (model != null) { 47 | _listVariantsForModel(model); 48 | } else { 49 | _listAllVariants(); 50 | } 51 | 52 | return ExitCode.success.code; 53 | } 54 | 55 | void _listVariantsForModel(String model) { 56 | _logger.info('Available variants for $model:'); 57 | 58 | switch (model) { 59 | case 'openai': 60 | _logger.info(' - gpt-4o (default)'); 61 | _logger.info(' - gpt-5'); 62 | _logger.info(' - gpt-5-mini'); 63 | _logger.info(' - gpt-5-nano'); 64 | _logger.info(' - gpt-5-pro'); 65 | _logger.info(' - gpt-4.1'); 66 | _logger.info(' - gpt-4.1-mini'); 67 | _logger.info(' - gpt-4.1-nano'); 68 | _logger.info(' - gpt-4.5-preview'); 69 | _logger.info(' - gpt-4o'); 70 | _logger.info(' - gpt-4o-mini'); 71 | _logger.info(' - gpt-realtime'); 72 | _logger.info(' - gpt-realtime-mini'); 73 | _logger.info(' - o1-preview'); 74 | _logger.info(' - o1-mini'); 75 | _logger.info(' - o3-mini'); 76 | case 'claude': 77 | _logger.info(' - claude-sonnet-4-20250514 (default)'); 78 | _logger.info(' - claude-sonnet-4-5-20250929'); 79 | _logger.info(' - claude-opus-4-1-20250805'); 80 | _logger.info(' - claude-opus-4-20250514'); 81 | _logger.info(' - claude-3-7-sonnet-20250219'); 82 | _logger.info(' - claude-3-7-sonnet-latest'); 83 | _logger.info(' - claude-3-5-sonnet-latest'); 84 | _logger.info(' - claude-3-5-sonnet-20241022'); 85 | _logger.info(' - claude-3-5-sonnet-20240620'); 86 | _logger.info(' - claude-3-opus-20240307'); 87 | _logger.info(' - claude-3-sonnet-20240307'); 88 | _logger.info(' - claude-3-haiku-20240307'); 89 | case 'gemini': 90 | _logger.info(' - gemini-2.0-flash (default)'); 91 | _logger.info(' - gemini-2.5-pro (advanced reasoning with thinking)'); 92 | _logger.info(' - gemini-2.5-flash (updated Sep 2025)'); 93 | _logger.info(' - gemini-2.5-flash-lite (most cost-efficient)'); 94 | _logger.info(' - gemini-2.5-flash-image (image generation)'); 95 | _logger.info(' - gemini-2.5-computer-use (agent interaction)'); 96 | _logger.info(' - gemini-1.5-pro-002 (2M tokens)'); 97 | _logger.info(' - gemini-1.5-flash-002 (1M tokens)'); 98 | _logger.info(' - gemini-1.5-flash-8b (cost effective)'); 99 | case 'grok': 100 | _logger.info(' - grok-2-latest (default)'); 101 | _logger.info(' - grok-4 (most intelligent)'); 102 | _logger.info(' - grok-4-heavy (most powerful)'); 103 | _logger.info(' - grok-4-fast (efficient reasoning)'); 104 | _logger.info(' - grok-code-fast-1 (agentic coding)'); 105 | _logger.info(' - grok-3 (reasoning capabilities)'); 106 | _logger.info(' - grok-3-mini (faster responses)'); 107 | case 'llama': 108 | _logger.info(' - llama-3-70b-instruct (default)'); 109 | _logger.info(' - llama-3-8b-instruct'); 110 | _logger.info(' - llama-3.1-8b-instruct'); 111 | _logger.info(' - llama-3.1-70b-instruct'); 112 | _logger.info(' - llama-3.1-405b-instruct'); 113 | _logger.info(' - llama-3.2-1b-instruct'); 114 | _logger.info(' - llama-3.2-3b-instruct'); 115 | _logger.info(' - llama-3.3-70b-instruct'); 116 | case 'deepseek': 117 | _logger.info(' - deepseek-chat (default)'); 118 | _logger.info(' - deepseek-v3.2-exp (latest experimental)'); 119 | _logger.info(' - deepseek-v3.1 (hybrid reasoning)'); 120 | _logger.info(' - deepseek-v3.1-terminus'); 121 | _logger.info(' - deepseek-r1-0528 (upgraded reasoning)'); 122 | _logger.info(' - deepseek-v3-0324 (improved post-training)'); 123 | _logger.info(' - deepseek-reasoner'); 124 | case 'github': 125 | _logger.info(' - gpt-4o (default)'); 126 | _logger.info(' - DeepSeek-R1'); 127 | _logger.info(' - Llama-3.3-70B-Instruct'); 128 | _logger.info(' - Deepseek-V3'); 129 | _logger.info(' - Phi-4-mini-instruct'); 130 | _logger.info(' - Codestral 25.01'); 131 | _logger.info(' - Mistral Large 24.11'); 132 | final url = link( 133 | message: 'https://github.com/marketplace?type=models', 134 | uri: Uri.parse('https://github.com/marketplace?type=models'), 135 | ); 136 | _logger.info( 137 | ' - etc. Check more on $url', 138 | ); 139 | case 'ollama': 140 | final url = link( 141 | message: 'here.', 142 | uri: Uri.parse('https://ollama.com/search'), 143 | ); 144 | _logger.info('Check Ollama models $url'); 145 | case 'free': 146 | _logger.info(' No variant selection - uses LLM7.io default model'); 147 | _logger.info(''); 148 | _logger.info(' Anonymous tier limits:'); 149 | _logger.info(' • 8k chars per request'); 150 | _logger.info(' • 60 requests/hour, 10 requests/min, 1 request/sec'); 151 | _logger.info(''); 152 | final freeUrl = link( 153 | message: 'LLM7.io', 154 | uri: Uri.parse('https://llm7.io'), 155 | ); 156 | _logger.info(' Powered by $freeUrl - No API key required!'); 157 | } 158 | } 159 | 160 | void _listAllVariants() { 161 | _listVariantsForModel('openai'); 162 | _logger.info(''); 163 | _listVariantsForModel('claude'); 164 | _logger.info(''); 165 | _listVariantsForModel('gemini'); 166 | _logger.info(''); 167 | _listVariantsForModel('grok'); 168 | _logger.info(''); 169 | _listVariantsForModel('llama'); 170 | _logger.info(''); 171 | _listVariantsForModel('deepseek'); 172 | _logger.info(''); 173 | _listVariantsForModel('github'); 174 | _logger.info(''); 175 | _listVariantsForModel('ollama'); 176 | _logger.info(''); 177 | _listVariantsForModel('free'); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 0.1.12 2 | - **Free Model (No API Key Required!)** - New `free` model option powered by LLM7.io. Use GitWhisper without any API key setup: `gw commit --model free` 3 | - **Git Tagging Support** - New `--tag` / `-t` flag to create a git tag alongside your commit (e.g., `gw commit -t v1.0.0`) 4 | - **Auto-push Tags** - When using `--auto-push` with `--tag`, both the commit and tag are pushed to the remote 5 | - **Improved Ticket Prefix** - Fixed ticket prefix formatting to correctly include the prefix in generated commit messages (e.g., `JIRA-123 -> fix: 🐛 Fix bug`) 6 | 7 | ## 0.1.11 8 | - Build for ARM64 9 | 10 | ## 0.1.10 11 | - **Git Editor Integration** - Edit commit messages in your preferred Git editor (vim, nano, VS Code, etc.) instead of inline prompt 12 | - **Improved Edit Workflow** - After editing, the commit message returns to the confirmation menu for review instead of auto-committing 13 | - **Better UX** - Respects Git's editor configuration hierarchy: `GIT_EDITOR` → `$EDITOR` → `vi` as fallback 14 | 15 | ## 0.1.9 16 | - **Emoji Control** - New `--allow-emojis` / `--no-allow-emojis` flag to control emoji inclusion in commit messages (defaults to enabled) 17 | - **Updated Model Variants** - Refreshed all AI model variants with latest releases: 18 | - OpenAI: Added GPT-5 family (gpt-5, gpt-5-mini, gpt-5-nano, gpt-5-pro), GPT-4.1 family, and gpt-realtime models 19 | - Claude: Added claude-sonnet-4-5-20250929 and claude-opus-4-1-20250805 20 | - Gemini: Updated to Gemini 2.5 family (gemini-2.5-pro, gemini-2.5-flash, gemini-2.5-flash-lite, gemini-2.5-flash-image, gemini-2.5-computer-use) 21 | - Grok: Added grok-4, grok-4-heavy, grok-4-fast, and grok-code-fast-1 22 | - DeepSeek: Added deepseek-v3.2-exp, deepseek-v3.1, deepseek-r1-0528, and more 23 | - **Build Improvements** - Added dynamic version injection via yq in build workflow 24 | - **Code Documentation** - Added comprehensive method documentation for commit prompt utilities 25 | 26 | ## 0.1.2 27 | - **Interactive Commit Confirmation** - Review, edit, retry with different models, or discard AI-generated messages 28 | - **Enhanced User Experience** - All commands now use interactive prompts with smart defaults and guided workflows 29 | - **Multi-repo Support** - Confirmation workflow works across single and multiple repositories 30 | - **Improved Security** - Hidden input for API keys and better Ollama handling 31 | 32 | ## 0.0.59 33 | - feat: ✨ Add language support to commit and analysis generation 34 | 35 | ## 0.0.58 36 | - chore: 🔧 Update documentation 37 | 38 | ## 0.0.57 39 | - Make gitwhisper available on all platforms through various installation channels 40 | 41 | ## 0.0.53 42 | - fix: 🐛 Fix API key to be optional 43 | 44 | ## 0.0.52 45 | - fix: 🐛 API key issue with Ollama 46 | 47 | ## 0.0.51 48 | - feat: ✨ Add Ollama support 49 | 50 | ## 0.0.50 51 | - feat: ✨ Make gitwhisper installable through Homebrew 52 | 53 | ## 0.0.49 54 | - fix: 🐛 Add Windows compatibility for file permissions 55 | 56 | ## 0.0.48 57 | - feat: ✨ Update Claude model variants and default version 58 | 59 | ## 0.0.47 60 | - enhancements 61 | 62 | ## 0.0.46 63 | - fix: 🐛 Update success message, support singular repo 64 | 65 | ## 0.0.45 66 | - feat: ✨ Add folderPath to GitUtils.runGitCommit 67 | 68 | ## 0.0.44 69 | - fix: 🐛 Fix Git add, pass workingDirectory 70 | 71 | ## 0.0.43 72 | - fix: 🐛 Pass folderPath to git diff command 73 | 74 | ## 0.0.42 75 | - fix: 🐛 multi repo options 76 | 77 | ## 0.0.41 78 | - feat: ✨ Implement analysis on multiple git repos 79 | - feat: ✨ Implement commit command in subfolders 80 | - refactor: ♻️ Improve git utils with subfolder support 81 | 82 | ## 0.0.40 83 | - fix: 🐛 remove argOptions 84 | 85 | ## 0.0.39 86 | - fix: 🐛 remove always add abbreviation 87 | 88 | ## 0.0.38 89 | - fix: 🐛 Handle null home directory, throw exception if null 90 | - feat: ✨ Add always-add command to allow you to skip running `git add` manually 91 | - feat: ✨ Stage all unstaged files if configured 92 | 93 | ## 0.0.37 94 | - refactor: ♻️ Simplify git push confirmation logic 95 | 96 | ## 0.0.36 97 | - fix: 🐛 Handle missing remote URL during push 98 | 99 | ## 0.0.35 100 | - feat: ✨ Add auto-push support (by [Takudzwa Nyanhanga](https://github.com/abcdOfficialzw)) 101 | 102 | ## 0.0.34 103 | - fix: remove markdown changes 104 | 105 | ## 0.0.33 106 | - render markdown properly 107 | 108 | ## 0.0.32 109 | - feat: ✨ Update Gemini model variants and API integration, dynamic endpoint support 110 | 111 | ## 0.0.31 112 | - increase max output tokens for analysis 113 | 114 | ## 0.0.30 115 | - lower mason_logger dependency version 116 | 117 | ## 0.0.29 118 | - feat: ✨ Add analyze command for detailed code change analysis 119 | 120 | 121 | ## 0.0.28 122 | - refactor: ♻️ Update commit message generation prompt 123 | 124 | ## 0.0.27 125 | - feat: ✨ Add mandatory format rules for commit messages 126 | 127 | ## 0.0.26 128 | - refactor: 🔧 Remove debug print statement, bump version to 0.0.26 129 | 130 | ## 0.0.25 131 | - fix: make AI aware of the prefix 132 | 133 | ## 0.0.24 134 | - chore: update release notes url 135 | 136 | ## 0.0.23 137 | - fix: formatting issue (regression) 138 | 139 | ## 0.0.22 140 | - refactor: ♻️ Remove manual commit message prefix formatting logic 141 | - feat: ✨ Add prefix support to AI commit message generation 142 | - docs: 📚 Update commit prompt with prefix instructions 143 | 144 | 145 | ## 0.0.21 146 | - docs: 📚 Update commit message guide with format details 147 | 148 | ## 0.0.20 149 | - refactor: ♻️ Enhance prompt formatting for commit message generation 150 | 151 | ## 0.0.16 152 | - docs: 📝 update commit message guidelines to include emojis 153 | 154 | ## 0.0.15 155 | - chore: 🧹 remove unused process_run dependency from pubspec.yaml 156 | 157 | ## 0.0.14 158 | - docs: expand commit types with mandatory emojis in prompt 159 | 160 | ## 0.0.13 161 | - feat: extract commit prompt to shared utility module 162 | 163 | ## 0.0.12+1 164 | - Added `Deepseek-V3`, `Phi-4-mini-instruct`, `Codestral 25.01`, and `Mistral Large 24.11` to `list_variants_command`. 165 | - Updated README with a link to check for more models on GitHub Marketplace. 166 | 167 | ## 0.0.12 168 | - Updated README to include GitHub models and authentication instructions. 169 | - Enhanced command options to support new 'github' model. 170 | - Added `GithubGenerator` for generating commit messages using GitHub model. 171 | - Updated `model_variants` with a new default variant for GitHub. 172 | 173 | ## 0.0.11 174 | - Integrated Deepseek model into the project 175 | - Updated model listing and validation to include Deepseek 176 | - Added Deepseek-specific generator implementation 177 | - Updated documentation to reflect the new model addition 178 | - Incremented version to 0.0.11 for release with new feature 179 | 180 | ## 0.0.10 181 | - update README with better documentation of the commands 182 | 183 | ## 0.0.9 184 | - feat: default to 'commit' command when args are empty, add 'gw' executable alias 185 | 186 | ## 0.0.8 187 | - fix(set_defaults_command): remove default values to enforce mandatory options 188 | 189 | ## 0.0.7 190 | - set and clear default model and variant for future use 191 | 192 | ## 0.0.6 193 | - resolve dart sdk constraint issue 194 | 195 | ## 0.0.5 196 | - fix(claude_generator): update API endpoint and model selection 197 | - refactor(dependencies): remove curl_logger_dio_interceptor and update model variants 198 | - feat(commit): add model-variant option to commit command 199 | - feat(list-variants): update and expand model variant lists for all models 200 | - fix(models): use ModelVariants for default model variants across all generators 201 | 202 | ## 0.0.3 203 | - testing configurations 204 | 205 | ## 0.0.2 206 | - setup basic features for the tool 207 | -------------------------------------------------------------------------------- /lib/src/config_manager.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // config_manager.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'dart:convert'; 10 | import 'dart:io'; 11 | 12 | import 'package:path/path.dart' as path; 13 | import 'package:yaml/yaml.dart'; 14 | 15 | import 'constants.dart'; 16 | import 'models/language.dart'; 17 | 18 | /// Manages configuration and API keys for the application 19 | class ConfigManager { 20 | static const String _configFileName = '.git_whisper.yaml'; 21 | Map _config = {}; 22 | 23 | /// Loads configuration from the config file 24 | Future load() async { 25 | final configFile = File(_getConfigPath()); 26 | if (configFile.existsSync()) { 27 | final yamlString = await configFile.readAsString(); 28 | final yamlMap = loadYaml(yamlString); 29 | _config = _convertYamlToMap(yamlMap as YamlMap); 30 | } else { 31 | // Initialize with empty config if file doesn't exist 32 | _config = {'api_keys': {}}; 33 | } 34 | } 35 | 36 | /// Saves the current configuration to the config file 37 | Future save() async { 38 | final configFile = File(_getConfigPath()); 39 | final yamlString = json.encode(_config); 40 | await configFile.writeAsString(yamlString); 41 | 42 | // Set file permissions on Unix-like systems only 43 | if (!Platform.isWindows) { 44 | try { 45 | await Process.run('chmod', ['600', _getConfigPath()]); 46 | } catch (e) { 47 | $logger.warn('Warning: Failed to set file permissions: $e'); 48 | } 49 | } 50 | } 51 | 52 | /// Gets the API key for the specified model 53 | String? getApiKey(String model) { 54 | return (_config['api_keys'] as Map)[model.toLowerCase()] 55 | as String?; 56 | } 57 | 58 | /// Gets the API key for the specified model 59 | (String, String)? getDefaultModelAndVariant() { 60 | if (_config.containsKey('defaults')) { 61 | final String model = 62 | (_config['defaults'] as Map)['model'] as String; 63 | final String? variant = 64 | (_config['defaults'] as Map)['variant'] as String?; 65 | 66 | // Return empty string if variant is not set, commit command will use generator default 67 | return (model, variant ?? ''); 68 | } else { 69 | return null; 70 | } 71 | } 72 | 73 | /// Gets the API key for the specified model 74 | String? getOllamaBaseURL() { 75 | if (_config.containsKey('ollamaBaseUrl')) { 76 | final String baseUrl = _config['ollamaBaseUrl'] as String; 77 | return baseUrl; 78 | } else { 79 | return null; 80 | } 81 | } 82 | 83 | /// Sets the API key for the specified model 84 | void setApiKey(String model, String apiKey) { 85 | if (_config['api_keys'] == null) { 86 | _config['api_keys'] = {}; 87 | } 88 | (_config['api_keys'] as Map)[model.toLowerCase()] = apiKey; 89 | } 90 | 91 | /// Get the language set for commit messages 92 | Language getWhisperLanguage() { 93 | if (_config['language'] == null) { 94 | return Language.english; 95 | } 96 | 97 | final language = _config['language'].toString().split(';'); 98 | 99 | return Language.values 100 | .where( 101 | (e) => e.code == language.first && e.countryCode == language.last, 102 | ) 103 | .firstOrNull ?? 104 | Language.english; 105 | } 106 | 107 | /// Set the default language to be used for commits 108 | void setWhisperLanguage(Language language) { 109 | final languageString = '${language.code};${language.countryCode}'.trim(); 110 | _config['language'] = languageString; 111 | } 112 | 113 | /// Sets the default model and default variant 114 | void setDefaults(String model, String? modelVariant) { 115 | if (_config['defaults'] == null) { 116 | _config['defaults'] = {}; 117 | } 118 | (_config['defaults'] as Map)['model'] = model; 119 | if (modelVariant != null && modelVariant.isNotEmpty) { 120 | (_config['defaults'] as Map)['variant'] = modelVariant; 121 | } else { 122 | // Remove variant from config if null/empty so it falls back to generator default 123 | (_config['defaults'] as Map).remove('variant'); 124 | } 125 | } 126 | 127 | /// Sets the base URL to use for Ollama 128 | void setOllamaBaseURL(String baseUrl) { 129 | if (_config['ollamaBaseUrl'] == null) { 130 | _config['ollamaBaseUrl'] = 'http://localhost:11434'; 131 | } 132 | _config['ollamaBaseUrl'] = baseUrl; 133 | } 134 | 135 | /// Set always add value 136 | void setAlwaysAdd({required bool value}) { 137 | _config['always_add'] = value; 138 | } 139 | 140 | /// Get the value of always add 141 | bool shouldAlwaysAdd() { 142 | return _config['always_add'] as bool? ?? false; 143 | } 144 | 145 | /// Set confirm commits value 146 | void setConfirmCommits({required bool value}) { 147 | _config['confirm_commits'] = value; 148 | } 149 | 150 | /// Get the value of confirm commits 151 | bool shouldConfirmCommits() { 152 | return _config['confirm_commits'] as bool? ?? false; 153 | } 154 | 155 | /// Set allow emojis value 156 | void setAllowEmojis({required bool value}) { 157 | _config['allow_emojis'] = value; 158 | } 159 | 160 | /// Get the value of allow emojis 161 | bool shouldAllowEmojis() { 162 | return _config['allow_emojis'] as bool? ?? true; 163 | } 164 | 165 | /// Check if user has accepted the free model disclaimer 166 | bool hasAcceptedFreeDisclaimer() { 167 | return _config['free_disclaimer_accepted'] as bool? ?? false; 168 | } 169 | 170 | /// Set that user has accepted the free model disclaimer 171 | void setFreeDisclaimerAccepted() { 172 | _config['free_disclaimer_accepted'] = true; 173 | } 174 | 175 | /// Clears the default model and default variant 176 | void clearDefaults() { 177 | if (_config['defaults'] != null) { 178 | _config.remove('defaults'); 179 | } 180 | } 181 | 182 | /// Gets the path to the config file 183 | String _getConfigPath() { 184 | String? home; 185 | 186 | if (Platform.isMacOS || Platform.isLinux) { 187 | home = Platform.environment['HOME']; 188 | } else if (Platform.isWindows) { 189 | home = Platform.environment['USERPROFILE']; 190 | } 191 | 192 | if (home == null) { 193 | throw Exception('Could not determine the user home directory.'); 194 | } 195 | 196 | return path.join(home, _configFileName); 197 | } 198 | 199 | /// Converts a YamlMap to a regular Map 200 | Map _convertYamlToMap(YamlMap yamlMap) { 201 | final map = {}; 202 | for (final entry in yamlMap.entries) { 203 | if (entry.value is YamlMap) { 204 | map[entry.key.toString()] = _convertYamlToMap(entry.value as YamlMap); 205 | } else if (entry.value is YamlList) { 206 | map[entry.key.toString()] = _convertYamlList(entry.value as YamlList); 207 | } else { 208 | map[entry.key.toString()] = entry.value; 209 | } 210 | } 211 | return map; 212 | } 213 | 214 | /// Converts a YamlList to a regular List 215 | List _convertYamlList(YamlList yamlList) { 216 | return yamlList.map((item) { 217 | if (item is YamlMap) { 218 | return _convertYamlToMap(item); 219 | } else if (item is YamlList) { 220 | return _convertYamlList(item); 221 | } else { 222 | return item; 223 | } 224 | }).toList(); 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: Build and Release GitWhisper 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | build: 10 | runs-on: ${{ matrix.os }} 11 | strategy: 12 | matrix: 13 | include: 14 | - os: ubuntu-latest 15 | target: gitwhisper 16 | archive: gitwhisper-linux.tar.gz 17 | - os: macos-latest 18 | target: gitwhisper 19 | archive: gitwhisper-macos.tar.gz 20 | - os: windows-latest 21 | target: gitwhisper.exe 22 | archive: gitwhisper-windows.tar.gz 23 | 24 | steps: 25 | - uses: actions/checkout@v3 26 | 27 | - uses: dart-lang/setup-dart@v1 28 | - run: dart pub get 29 | 30 | - name: Install yq (Linux) 31 | if: runner.os == 'Linux' 32 | run: | 33 | sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 34 | sudo chmod a+x /usr/local/bin/yq 35 | 36 | - name: Install yq (macOS) 37 | if: runner.os == 'macOS' 38 | run: brew install yq 39 | 40 | - name: Install yq (Windows) 41 | if: runner.os == 'Windows' 42 | run: choco install yq 43 | 44 | - name: Compile binary (Unix) 45 | if: runner.os != 'Windows' 46 | run: dart compile exe -DAPP_VERSION=$(yq '.version' pubspec.yaml) bin/main.dart -o ${{ matrix.target }} 47 | 48 | - name: Compile binary (Windows) 49 | if: runner.os == 'Windows' 50 | shell: pwsh 51 | run: | 52 | $version = yq '.version' pubspec.yaml 53 | dart compile exe -DAPP_VERSION=$version bin/main.dart -o ${{ matrix.target }} 54 | 55 | - name: Archive binary 56 | run: tar -czf ${{ matrix.archive }} ${{ matrix.target }} 57 | 58 | - name: Calculate sha256 (Linux/macOS) 59 | id: hash_unix 60 | if: runner.os != 'Windows' 61 | run: echo "sha256=$(shasum -a 256 ${{ matrix.archive }} | cut -d ' ' -f1)" >> $GITHUB_OUTPUT 62 | shell: bash 63 | 64 | - name: Calculate sha256 (Windows) 65 | id: hash_win 66 | if: runner.os == 'Windows' 67 | run: | 68 | $hash = Get-FileHash ${{ matrix.archive }} -Algorithm SHA256 69 | echo "sha256=$($hash.Hash)" >> $env:GITHUB_OUTPUT 70 | shell: pwsh 71 | 72 | - name: Upload to GitHub Release 73 | uses: softprops/action-gh-release@v1 74 | with: 75 | files: | 76 | gitwhisper-*.tar.gz 77 | env: 78 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 79 | 80 | - name: Build and publish .deb to APT repo 81 | if: runner.os == 'Linux' 82 | run: | 83 | version=${GITHUB_REF_NAME#v} 84 | 85 | # Build amd64 package 86 | mkdir -p deb-amd64/usr/local/bin 87 | cp gitwhisper deb-amd64/usr/local/bin/ 88 | ln -s /usr/local/bin/gitwhisper deb-amd64/usr/local/bin/gw 89 | 90 | mkdir -p deb-amd64/DEBIAN 91 | cat < deb-amd64/DEBIAN/control 92 | Package: gitwhisper 93 | Version: ${version} 94 | Section: utils 95 | Priority: optional 96 | Architecture: amd64 97 | Maintainer: Ngonidzashe Mangudya 98 | Description: AI-assisted Git commit CLI. Includes both `gitwhisper` and `gw` commands. 99 | EOF 100 | 101 | dpkg-deb --build deb-amd64 102 | mv deb-amd64.deb gitwhisper_${version}_amd64.deb 103 | 104 | # Build arm64 package (cross-compile) 105 | dart compile exe -DAPP_VERSION=$(yq '.version' pubspec.yaml) bin/main.dart -o gitwhisper-arm64 --target-os linux 106 | 107 | mkdir -p deb-arm64/usr/local/bin 108 | cp gitwhisper-arm64 deb-arm64/usr/local/bin/gitwhisper 109 | ln -s /usr/local/bin/gitwhisper deb-arm64/usr/local/bin/gw 110 | 111 | mkdir -p deb-arm64/DEBIAN 112 | cat < deb-arm64/DEBIAN/control 113 | Package: gitwhisper 114 | Version: ${version} 115 | Section: utils 116 | Priority: optional 117 | Architecture: arm64 118 | Maintainer: Ngonidzashe Mangudya 119 | Description: AI-assisted Git commit CLI. Includes both `gitwhisper` and `gw` commands. 120 | EOF 121 | 122 | dpkg-deb --build deb-arm64 123 | mv deb-arm64.deb gitwhisper_${version}_arm64.deb 124 | 125 | # Clone and update APT repo 126 | git clone --depth=1 --branch=gh-pages https://github.com/iamngoni/gitwhisper-apt apt-repo 127 | cd apt-repo 128 | 129 | mkdir -p pool/main/g/gitwhisper 130 | mkdir -p dists/stable/main/binary-amd64 131 | mkdir -p dists/stable/main/binary-arm64 132 | 133 | # Copy packages 134 | cp ../gitwhisper_${version}_amd64.deb pool/main/g/gitwhisper/ 135 | cp ../gitwhisper_${version}_arm64.deb pool/main/g/gitwhisper/ 136 | 137 | # Generate package indices 138 | dpkg-scanpackages --arch amd64 pool /dev/null > dists/stable/main/binary-amd64/Packages 139 | gzip -kf dists/stable/main/binary-amd64/Packages 140 | 141 | dpkg-scanpackages --arch arm64 pool /dev/null > dists/stable/main/binary-arm64/Packages 142 | gzip -kf dists/stable/main/binary-arm64/Packages 143 | 144 | # Create Release file 145 | cat < dists/stable/Release 146 | Origin: GitWhisper 147 | Label: GitWhisper 148 | Suite: stable 149 | Codename: stable 150 | Architectures: amd64 arm64 151 | Components: main 152 | Description: GitWhisper APT Repository 153 | EOF 154 | 155 | git config user.name "github-actions" 156 | git config user.email "github-actions@github.com" 157 | git add . 158 | git commit -m "Publish gitwhisper ${version} (amd64 and arm64)" 159 | git push https://x-access-token:${{ secrets.HOMEBREW_TAP_PAT }}@github.com/iamngoni/gitwhisper-apt gh-pages 160 | 161 | update-homebrew: 162 | if: startsWith(github.ref, 'refs/tags/') 163 | runs-on: macos-latest 164 | needs: build 165 | steps: 166 | - name: Clone and update formula 167 | run: | 168 | git clone https://github.com/iamngoni/homebrew-gitwhisper.git 169 | cd homebrew-gitwhisper 170 | curl -LO https://github.com/iamngoni/gitwhisper/releases/download/${{ github.ref_name }}/gitwhisper-macos.tar.gz 171 | sha256=$(shasum -a 256 gitwhisper-macos.tar.gz | cut -d ' ' -f1) 172 | 173 | echo "class Gitwhisper < Formula" > Formula/gitwhisper.rb 174 | echo " desc \"AI-assisted git commit CLI\"" >> Formula/gitwhisper.rb 175 | echo " homepage \"https://github.com/iamngoni/gitwhisper\"" >> Formula/gitwhisper.rb 176 | echo " url \"https://github.com/iamngoni/gitwhisper/releases/download/${{ github.ref_name }}/gitwhisper-macos.tar.gz\"" >> Formula/gitwhisper.rb 177 | echo " sha256 \"$sha256\"" >> Formula/gitwhisper.rb 178 | echo " version \"${{ github.ref_name }}\"" >> Formula/gitwhisper.rb 179 | echo "" >> Formula/gitwhisper.rb 180 | echo " def install" >> Formula/gitwhisper.rb 181 | echo " bin.install \"gitwhisper\"" >> Formula/gitwhisper.rb 182 | echo " bin.install_symlink \"gitwhisper\" => \"gw\"" >> Formula/gitwhisper.rb 183 | echo " end" >> Formula/gitwhisper.rb 184 | echo "end" >> Formula/gitwhisper.rb 185 | 186 | git config user.name "github-actions" 187 | git config user.email "github-actions@github.com" 188 | git add Formula/gitwhisper.rb 189 | git commit -m "Update formula to ${{ github.ref_name }}" 190 | git push https://x-access-token:${{ secrets.HOMEBREW_TAP_PAT }}@github.com/iamngoni/homebrew-gitwhisper HEAD:main 191 | -------------------------------------------------------------------------------- /lib/src/commands/set_defaults_command.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // set_defaults_command.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/04. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:args/command_runner.dart'; 10 | import 'package:mason_logger/mason_logger.dart'; 11 | 12 | import '../config_manager.dart'; 13 | 14 | class SetDefaultsCommand extends Command { 15 | SetDefaultsCommand({ 16 | required Logger logger, 17 | }) : _logger = logger { 18 | argParser 19 | ..addOption( 20 | 'model', 21 | abbr: 'm', 22 | help: 'AI model to save the key for', 23 | allowed: [ 24 | 'claude', 25 | 'openai', 26 | 'gemini', 27 | 'grok', 28 | 'llama', 29 | 'deepseek', 30 | 'github', 31 | 'ollama', 32 | 'free', 33 | ], 34 | allowedHelp: { 35 | 'claude': 'Anthropic Claude', 36 | 'openai': 'OpenAI GPT models', 37 | 'gemini': 'Google Gemini', 38 | 'grok': 'xAI Grok', 39 | 'llama': 'Meta Llama', 40 | 'deepseek': 'DeepSeek, Inc.', 41 | 'github': 'Github', 42 | 'ollama': 'Ollama', 43 | 'free': 'Free (LLM7.io) - No API key required', 44 | }, 45 | ) 46 | ..addOption( 47 | 'model-variant', 48 | abbr: 'v', 49 | help: 'Specific variant of the AI model to use', 50 | valueHelp: 'gpt-4o, claude-3-opus, gemini-pro, etc.', 51 | ) 52 | ..addOption( 53 | 'base-url', 54 | abbr: 'u', 55 | help: 'Base URL to use for ollama, defaults to http://localhost:11434', 56 | valueHelp: 'http://localhost:11434', 57 | ) 58 | ..addFlag( 59 | 'confirm-commits', 60 | help: 'Always confirm commit messages before applying', 61 | defaultsTo: false, 62 | ) 63 | ..addFlag( 64 | 'no-confirm-commits', 65 | help: 'Never confirm commit messages (auto-commit)', 66 | defaultsTo: false, 67 | ) 68 | ..addFlag( 69 | 'allow-emojis', 70 | help: 'Include emojis in commit messages', 71 | defaultsTo: true, 72 | negatable: true, 73 | ); 74 | } 75 | 76 | @override 77 | String get description => 'Set defaults for future use'; 78 | 79 | @override 80 | String get name => 'set-defaults'; 81 | 82 | final Logger _logger; 83 | 84 | @override 85 | Future run() async { 86 | // Get model name from args or prompt user to choose 87 | String? modelName = argResults?['model'] as String?; 88 | modelName ??= _logger.chooseOne( 89 | 'Select the AI model to set as default:', 90 | choices: [ 91 | 'claude', 92 | 'openai', 93 | 'gemini', 94 | 'grok', 95 | 'llama', 96 | 'deepseek', 97 | 'github', 98 | 'ollama', 99 | ], 100 | defaultValue: 'openai', 101 | ); 102 | 103 | String? modelVariant = argResults?['model-variant'] as String?; 104 | String? baseUrl = argResults?['base-url'] as String?; 105 | 106 | // Handle confirm commits flags 107 | final confirmCommits = argResults?['confirm-commits'] as bool? ?? false; 108 | final noConfirmCommits = 109 | argResults?['no-confirm-commits'] as bool? ?? false; 110 | 111 | if (confirmCommits && noConfirmCommits) { 112 | _logger.err( 113 | 'Cannot use both --confirm-commits and --no-confirm-commits flags.'); 114 | return ExitCode.usage.code; 115 | } 116 | 117 | // Handle emoji flag 118 | final allowEmojis = argResults?['allow-emojis'] as bool?; 119 | 120 | // For Ollama, ask about base URL if not provided 121 | if (modelName == 'ollama' && baseUrl == null) { 122 | final bool customBaseUrl = _logger.confirm( 123 | 'Do you want to set a custom base URL for Ollama?', 124 | ); 125 | if (customBaseUrl) { 126 | baseUrl = _logger.prompt( 127 | 'Enter the base URL for Ollama:', 128 | defaultValue: 'http://localhost:11434', 129 | ); 130 | } 131 | } 132 | 133 | if (baseUrl != null && modelName != 'ollama') { 134 | _logger.err('Base URL can only be set for Ollama'); 135 | return ExitCode.usage.code; 136 | } 137 | 138 | // Prompt for model variant if not provided 139 | if (modelVariant == null) { 140 | final bool setVariant = _logger.confirm( 141 | 'Do you want to set a specific model variant for $modelName?', 142 | ); 143 | if (setVariant) { 144 | modelVariant = _logger.prompt( 145 | 'Enter the model variant (e.g., gpt-4o, claude-3-opus, gemini-pro):', 146 | ); 147 | if (modelVariant.trim().isEmpty) { 148 | _logger.warn('No variant specified, skipping variant setting.'); 149 | modelVariant = null; 150 | } 151 | } 152 | } 153 | 154 | // Initialize config manager 155 | final configManager = ConfigManager(); 156 | await configManager.load(); 157 | 158 | // Set default model and variant (pass null if variant not provided) 159 | configManager.setDefaults(modelName!, modelVariant); 160 | await configManager.save(); 161 | 162 | if (modelVariant != null && modelVariant.isNotEmpty) { 163 | _logger.success( 164 | '$modelName -> $modelVariant has been set as the default model for' 165 | ' commits.', 166 | ); 167 | } else { 168 | _logger.success( 169 | '$modelName has been set as the default model (will use default variant).', 170 | ); 171 | } 172 | 173 | // Handle Ollama-specific base URL 174 | if (modelName == 'ollama' && baseUrl != null) { 175 | configManager.setOllamaBaseURL(baseUrl); 176 | await configManager.save(); 177 | _logger.success( 178 | '$modelName baseUrl has been set to $baseUrl.', 179 | ); 180 | } 181 | 182 | // Handle confirm commits setting 183 | if (confirmCommits) { 184 | configManager.setConfirmCommits(value: true); 185 | await configManager.save(); 186 | _logger.success( 187 | 'Commit confirmation enabled. All commits will require confirmation.'); 188 | } else if (noConfirmCommits) { 189 | configManager.setConfirmCommits(value: false); 190 | await configManager.save(); 191 | _logger.success( 192 | 'Commit confirmation disabled. All commits will be automatic.'); 193 | } else if (modelName != null && !confirmCommits && !noConfirmCommits) { 194 | // Ask about commit confirmation if setting up for first time 195 | final bool shouldConfirm = _logger.confirm( 196 | 'Do you want to confirm commit messages before they are applied? (Recommended for new users)', 197 | defaultValue: false, 198 | ); 199 | configManager.setConfirmCommits(value: shouldConfirm); 200 | await configManager.save(); 201 | 202 | if (shouldConfirm) { 203 | _logger.info( 204 | 'Commit confirmation enabled. Use --confirm flag or set this as default.'); 205 | } else { 206 | _logger 207 | .info('Commit confirmation disabled. Commits will be automatic.'); 208 | } 209 | } 210 | 211 | // Handle emoji setting 212 | if (allowEmojis != null) { 213 | configManager.setAllowEmojis(value: allowEmojis); 214 | await configManager.save(); 215 | _logger.success( 216 | allowEmojis 217 | ? 'Emojis enabled in commit messages.' 218 | : 'Emojis disabled in commit messages.', 219 | ); 220 | } 221 | 222 | if (modelVariant == null && 223 | baseUrl == null && 224 | !confirmCommits && 225 | !noConfirmCommits) { 226 | _logger.info( 227 | 'Default model set to $modelName (no specific variant or base URL configured).', 228 | ); 229 | } 230 | 231 | return ExitCode.success.code; 232 | } 233 | } 234 | -------------------------------------------------------------------------------- /lib/src/commit_utils.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // commit_utils.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/19. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'models/language.dart'; 10 | 11 | /// Generates a prompt for creating a git commit message based on staged changes. 12 | /// 13 | /// Takes the [diff] of staged changes and inserts it into a template prompt 14 | /// that instructs an AI assistant to generate a conventional commit message. 15 | /// 16 | /// Returns a formatted prompt string ready to be sent to an AI assistant. 17 | String getCommitPrompt( 18 | String diff, 19 | Language language, { 20 | String? prefix, 21 | bool withEmoji = true, 22 | }) { 23 | return withEmoji 24 | ? getCommitPromptWithEmoji(diff, language, prefix: prefix) 25 | : getCommitPromptWithNoEmoji(diff, language, prefix: prefix); 26 | } 27 | 28 | /// Generates a commit prompt that includes emoji formatting. 29 | /// 30 | /// Takes the [diff] of staged changes and [language] preference, and creates 31 | /// a prompt instructing an AI to generate conventional commit messages with emojis. 32 | /// 33 | /// The optional [prefix] can be used to add a prefix to the commit messages. 34 | /// 35 | /// Returns a formatted prompt string with emoji requirements included. 36 | String getCommitPromptWithEmoji( 37 | String diff, 38 | Language language, { 39 | String? prefix, 40 | }) { 41 | final hasPrefix = prefix != null && prefix.isNotEmpty; 42 | final prefixNote = hasPrefix 43 | ? ''' 44 | TICKET PREFIX REQUIREMENT: 45 | You MUST include the ticket prefix "$prefix ->" at the start of EVERY commit message. 46 | 47 | Format for commit messages with prefix: 48 | $prefix -> fix: 🐛 Fix login validation, handle empty input 49 | $prefix -> feat: ✨ Add dark mode toggle, persist setting 50 | 51 | The prefix "$prefix ->" must appear BEFORE the commit type on every line. 52 | ''' 53 | : ''; 54 | 55 | final languageInstruction = language != Language.english 56 | ? ''' 57 | 58 | LANGUAGE REQUIREMENT: 59 | Generate the commit message description in ${language.name}. The commit type (e.g., "feat:", "fix:") and emoji must remain in English, but the description should be written in ${language.name}. 60 | 61 | Example format for ${language.name}: 62 | - feat: ✨ [Description in ${language.name}] 63 | - fix: 🐛 [Description in ${language.name}] 64 | 65 | ''' 66 | : ''; 67 | 68 | final prompt = ''' 69 | You are an assistant that generates commit messages. 70 | 71 | Based on the following diff of staged changes, generate valid, concise, and conventional commit messages. Each message must follow this strict format: 72 | : 73 | 74 | Where: 75 | - is a valid conventional type (always in English) 76 | - is the matching emoji 77 | - is in imperative mood ("Fix bug", not "Fixed bug")${language != Language.english ? ' and written in ${language.name}' : ''} 78 | - Optional context (e.g., small body) must be **on the same line**, comma-separated after the description 79 | 80 | Do NOT include: 81 | - Blank lines 82 | - Multiline messages 83 | - Commit bodies or footers below the header 84 | - Summaries, intros, or explanations 85 | 86 | MANDATORY FORMAT RULES: 87 | 1. IMPERATIVE VERB: Always use "Add", "Fix", "Update", etc. (NOT "Added", "Fixed", "Updated") 88 | 2. CAPITALIZE: First word must be capitalized 89 | 3. CONCISE: Keep descriptions concise (preferably under 50 characters) 90 | 4. TYPES AND EMOJIS: Must use ONLY from the approved list below 91 | 5. Only generate multiple commit messages if changes are truly unrelated$languageInstruction 92 | 93 | $prefixNote 94 | 95 | ### Commit types and emojis: 96 | - feat: ✨ New feature 97 | - fix: 🐛 Bug fix 98 | - docs: 📚 Documentation 99 | - style: 💄 Code formatting only 100 | - refactor: ♻️ Code improvements 101 | - test: 🧪 Tests 102 | - chore: 🔧 Tooling/maintenance 103 | - perf: ⚡ Performance improvements 104 | - ci: 👷 CI/CD 105 | - build: 📦 Build system/dependencies 106 | - revert: ⏪ Reverting a commit 107 | 108 | ⚠️ Output must only be properly formatted commit message(s). Nothing else. Violation is not acceptable 109 | 110 | Here's the diff: 111 | $diff 112 | '''; 113 | 114 | return prompt; 115 | } 116 | 117 | /// Generates a commit prompt without emoji formatting. 118 | /// 119 | /// Takes the [diff] of staged changes and [language] preference, and creates 120 | /// a prompt instructing an AI to generate conventional commit messages without emojis. 121 | /// 122 | /// The optional [prefix] can be used to add a prefix to the commit messages. 123 | /// 124 | /// Returns a formatted prompt string without emoji requirements. 125 | String getCommitPromptWithNoEmoji( 126 | String diff, 127 | Language language, { 128 | String? prefix, 129 | }) { 130 | final hasPrefix = prefix != null && prefix.isNotEmpty; 131 | final prefixNote = hasPrefix 132 | ? ''' 133 | TICKET PREFIX REQUIREMENT: 134 | You MUST include the ticket prefix "$prefix ->" at the start of EVERY commit message. 135 | 136 | Format for commit messages with prefix: 137 | $prefix -> fix: Fix login validation, handle empty input 138 | $prefix -> feat: Add dark mode toggle, persist setting 139 | 140 | The prefix "$prefix ->" must appear BEFORE the commit type on every line. 141 | ''' 142 | : ''; 143 | 144 | final languageInstruction = language != Language.english 145 | ? ''' 146 | 147 | LANGUAGE REQUIREMENT: 148 | Generate the commit message description in ${language.name}. 149 | The commit type (e.g., "feat:", "fix:") must remain in English, but the description should be written in ${language.name}. 150 | 151 | Example format for ${language.name}: 152 | - feat: [Description in ${language.name}] 153 | - fix: [Description in ${language.name}] 154 | 155 | ''' 156 | : ''; 157 | 158 | final prompt = ''' 159 | You are an assistant that generates commit messages. 160 | 161 | Based on the following diff of staged changes, generate valid, concise, and conventional commit messages. 162 | Each message must follow this strict format: 163 | : 164 | 165 | Where: 166 | - is a valid conventional type (always in English) 167 | - is in imperative mood ("Fix bug", not "Fixed bug")${language != Language.english ? ' and written in ${language.name}' : ''} 168 | - Optional context (e.g., small body) must be **on the same line**, comma-separated after the description 169 | 170 | Do NOT include: 171 | - Blank lines 172 | - Multiline messages 173 | - Commit bodies or footers below the header 174 | - Summaries, intros, or explanations 175 | 176 | MANDATORY FORMAT RULES: 177 | 1. IMPERATIVE VERB: Always use "Add", "Fix", "Update", etc. (NOT "Added", "Fixed", "Updated") 178 | 2. CAPITALIZE: First word must be capitalized 179 | 3. CONCISE: Keep descriptions concise (preferably under 50 characters) 180 | 4. TYPES: Must use ONLY from the approved list below 181 | 5. Only generate multiple commit messages if changes are truly unrelated$languageInstruction 182 | 183 | $prefixNote 184 | 185 | ### Commit types: 186 | - feat: New feature 187 | - fix: Bug fix 188 | - docs: Documentation 189 | - style: Code formatting only 190 | - refactor: Code improvements 191 | - test: Tests 192 | - chore: Tooling/maintenance 193 | - perf: Performance improvements 194 | - ci: CI/CD 195 | - build: Build system/dependencies 196 | - revert: Reverting a commit 197 | 198 | ⚠️ Output must only be properly formatted commit message(s). Nothing else. Violation is not acceptable. 199 | 200 | Here's the diff: 201 | $diff 202 | '''; 203 | 204 | return prompt; 205 | } 206 | 207 | String getAnalysisPrompt(String diff, Language language) { 208 | final languageInstruction = language != Language.english 209 | ? ''' 210 | 211 | LANGUAGE REQUIREMENT: 212 | Provide the analysis response in ${language.name}. All section headers, explanations, and content should be written in ${language.name}. 213 | 214 | ''' 215 | : ''; 216 | 217 | final prompt = ''' 218 | # Code Change Analyzer 219 | 220 | You are a specialized code review assistant focused on analyzing git diffs and providing terminal-friendly feedback. 221 | 222 | ## Your task: 223 | 224 | Analyze the provided diff and deliver a clear, structured analysis${language != Language.english ? ' in ${language.name}' : ''} that includes: 225 | 226 | 1. **Overview Summary** 227 | - Brief description of what changes were made 228 | - The apparent purpose of these changes 229 | - Files affected and their roles 230 | 231 | 2. **Technical Analysis** 232 | - Identify the key functional changes 233 | - Note any architectural or structural modifications 234 | - Highlight important API changes or dependency updates 235 | 236 | 3. **Code Quality Assessment** 237 | - Evaluate the quality of implemented changes 238 | - Identify any code smells or potential issues 239 | - Suggest better patterns or approaches where applicable 240 | 241 | 4. **Optimization Opportunities** 242 | - Point out any performance concerns 243 | - Suggest more efficient alternatives 244 | - Identify opportunities for code reuse or abstraction 245 | 246 | 5. **Security & Edge Cases** 247 | - Highlight potential security vulnerabilities 248 | - Note any missing input validation or error handling 249 | - Identify edge cases that might not be handled 250 | 251 | ## IMPORTANT: Ignore trivial changes 252 | 253 | - IGNORE whitespace-only changes (indentation, line breaks, spacing) 254 | - IGNORE code formatting changes that don't affect functionality 255 | - IGNORE simple line shifts without actual content changes 256 | - IGNORE comment-only changes unless they are substantial or important 257 | - Focus ONLY on changes that affect functionality, logic, architecture, or security 258 | 259 | ## Terminal-Friendly Format: 260 | 261 | Format your analysis for optimal display in a terminal environment: 262 | 263 | 1. Use simple terminal-friendly formatting: 264 | - Separate sections with clear dividers (e.g., "-------------") 265 | - Use symbols (*, >, +, -) instead of markdown bullets 266 | - Highlight important points with uppercase or symbols (⚠️, ✅, ⚡) 267 | - Keep line width to 80-100 characters maximum 268 | 269 | 2. Use simple text highlighting: 270 | - Make headers UPPERCASE or use symbols like "==" for emphasis 271 | - Use plain ASCII characters for emphasis (*, _, |) 272 | - Maintain consistent indentation for readability 273 | 274 | 3. Structure for scannability: 275 | - Start with a 2-3 line executive summary 276 | - Use short paragraphs (3-5 lines maximum) 277 | - Use lists for multiple related points 278 | - Include line numbers in [brackets] when referencing specific code 279 | 280 | Keep your analysis balanced - highlight both positive aspects and areas for improvement. Prioritize the most important findings over trivial issues.$languageInstruction 281 | 282 | ## Diff to analyze: 283 | 284 | $diff 285 | 286 | Response should be only markdown formatted response. 287 | '''; 288 | 289 | return prompt; 290 | } 291 | -------------------------------------------------------------------------------- /lib/src/exceptions/error_parser.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // error_parser.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'package:dio/dio.dart'; 10 | 11 | import 'api_exception.dart'; 12 | import 'api_exceptions.dart'; 13 | 14 | /// Utility class for parsing API errors from different providers 15 | class ErrorParser { 16 | /// Parse error response from different API providers 17 | static ApiException parseError(DioException error) { 18 | final statusCode = error.response?.statusCode ?? 0; 19 | final responseData = error.response?.data; 20 | final requestId = _extractRequestId(error.response?.headers); 21 | 22 | // Handle network/connection errors 23 | if (error.type == DioExceptionType.connectionTimeout || 24 | error.type == DioExceptionType.sendTimeout || 25 | error.type == DioExceptionType.receiveTimeout) { 26 | return TimeoutException( 27 | message: 'Request timed out: ${error.message}', 28 | requestId: requestId, 29 | ); 30 | } 31 | 32 | if (error.type == DioExceptionType.connectionError) { 33 | return const ServerException( 34 | message: 'Connection error: Unable to connect to the API service', 35 | statusCode: 503, 36 | ); 37 | } 38 | 39 | // Parse error based on response format 40 | if (responseData is Map) { 41 | return _parseErrorResponse(statusCode, responseData, requestId); 42 | } 43 | 44 | // Fallback for unknown error format 45 | return _createGenericError(statusCode, error.message, requestId); 46 | } 47 | 48 | /// Parse error response based on API provider format 49 | static ApiException _parseErrorResponse( 50 | int statusCode, 51 | Map data, 52 | String? requestId, 53 | ) { 54 | String message = 'Unknown error'; 55 | String? errorType; 56 | String? errorCode; 57 | int? retryAfter; 58 | 59 | // Parse different API response formats 60 | if (data.containsKey('error')) { 61 | final errorData = data['error']; 62 | if (errorData is Map) { 63 | // OpenAI/DeepSeek/Grok/GitHub format 64 | message = errorData['message']?.toString() ?? message; 65 | errorType = errorData['type']?.toString(); 66 | errorCode = errorData['code']?.toString(); 67 | } else if (errorData is String) { 68 | // Simple error format 69 | message = errorData; 70 | } 71 | } else if (data.containsKey('message')) { 72 | // Direct message format 73 | message = data['message']?.toString() ?? message; 74 | } else if (data.containsKey('detail')) { 75 | // Detail format (other APIs) 76 | message = data['detail']?.toString() ?? message; 77 | } 78 | 79 | // Extract retry-after header for rate limiting 80 | if (statusCode == 429 && data.containsKey('retry_after')) { 81 | retryAfter = data['retry_after'] as int?; 82 | } 83 | 84 | return _createSpecificError( 85 | statusCode: statusCode, 86 | message: message, 87 | errorType: errorType, 88 | errorCode: errorCode, 89 | requestId: requestId, 90 | retryAfter: retryAfter, 91 | ); 92 | } 93 | 94 | /// Create specific error based on status code 95 | static ApiException _createSpecificError({ 96 | required int statusCode, 97 | required String message, 98 | String? errorType, 99 | String? errorCode, 100 | String? requestId, 101 | int? retryAfter, 102 | }) { 103 | switch (statusCode) { 104 | case 400: 105 | case 422: 106 | return InvalidRequestException( 107 | message: message, 108 | statusCode: statusCode, 109 | errorType: errorType, 110 | errorCode: errorCode, 111 | requestId: requestId, 112 | ); 113 | case 401: 114 | return AuthenticationException( 115 | message: message, 116 | errorType: errorType, 117 | errorCode: errorCode, 118 | requestId: requestId, 119 | ); 120 | case 402: 121 | return InsufficientBalanceException( 122 | message: message, 123 | errorType: errorType, 124 | errorCode: errorCode, 125 | requestId: requestId, 126 | ); 127 | case 403: 128 | return PermissionException( 129 | message: message, 130 | errorType: errorType, 131 | errorCode: errorCode, 132 | requestId: requestId, 133 | ); 134 | case 404: 135 | return ResourceNotFoundException( 136 | message: message, 137 | errorType: errorType, 138 | errorCode: errorCode, 139 | requestId: requestId, 140 | ); 141 | case 413: 142 | return RequestTooLargeException( 143 | message: message, 144 | errorType: errorType, 145 | errorCode: errorCode, 146 | requestId: requestId, 147 | ); 148 | case 429: 149 | return RateLimitException( 150 | message: message, 151 | errorType: errorType, 152 | errorCode: errorCode, 153 | requestId: requestId, 154 | retryAfter: retryAfter, 155 | ); 156 | case 500: 157 | case 502: 158 | case 503: 159 | return ServerException( 160 | message: message, 161 | statusCode: statusCode, 162 | errorType: errorType, 163 | errorCode: errorCode, 164 | requestId: requestId, 165 | ); 166 | case 504: 167 | return TimeoutException( 168 | message: message, 169 | errorType: errorType, 170 | errorCode: errorCode, 171 | requestId: requestId, 172 | ); 173 | case 529: 174 | return ServiceOverloadedException( 175 | message: message, 176 | errorType: errorType, 177 | errorCode: errorCode, 178 | requestId: requestId, 179 | ); 180 | default: 181 | return _createGenericError(statusCode, message, requestId); 182 | } 183 | } 184 | 185 | /// Create generic error for unknown status codes 186 | static ApiException _createGenericError( 187 | int statusCode, 188 | String? message, 189 | String? requestId, 190 | ) { 191 | return ServerException( 192 | message: message ?? 'Unknown error occurred', 193 | statusCode: statusCode, 194 | requestId: requestId, 195 | ); 196 | } 197 | 198 | /// Extract request ID from response headers 199 | static String? _extractRequestId(Headers? headers) { 200 | if (headers == null) return null; 201 | 202 | // Check common request ID header names 203 | final requestIdHeaders = [ 204 | 'request-id', 205 | 'x-request-id', 206 | 'cf-ray', 207 | 'x-trace-id', 208 | 'trace-id', 209 | ]; 210 | 211 | for (final headerName in requestIdHeaders) { 212 | final value = headers.value(headerName); 213 | if (value != null && value.isNotEmpty) { 214 | return value; 215 | } 216 | } 217 | 218 | return null; 219 | } 220 | 221 | /// Parse provider-specific error formats 222 | static ApiException parseProviderError( 223 | String provider, 224 | DioException error, 225 | ) { 226 | final baseError = parseError(error); 227 | 228 | // Add provider-specific error handling if needed 229 | switch (provider.toLowerCase()) { 230 | case 'anthropic': 231 | case 'claude': 232 | return _parseClaudeError(error, baseError); 233 | case 'openai': 234 | return _parseOpenAIError(error, baseError); 235 | case 'gemini': 236 | return _parseGeminiError(error, baseError); 237 | case 'deepseek': 238 | return _parseDeepseekError(error, baseError); 239 | case 'grok': 240 | return _parseGrokError(error, baseError); 241 | default: 242 | return baseError; 243 | } 244 | } 245 | 246 | /// Parse Claude/Anthropic specific errors 247 | static ApiException _parseClaudeError( 248 | DioException error, ApiException baseError) { 249 | final responseData = error.response?.data; 250 | if (responseData is Map && 251 | responseData.containsKey('error')) { 252 | final errorData = responseData['error']; 253 | if (errorData is Map) { 254 | final errorType = errorData['type']; 255 | 256 | // Handle Claude-specific error types 257 | switch (errorType) { 258 | case 'overloaded_error': 259 | return ServiceOverloadedException( 260 | message: errorData['message']?.toString() ?? 261 | 'Service is temporarily overloaded', 262 | errorType: errorType?.toString(), 263 | requestId: _extractRequestId(error.response?.headers), 264 | ); 265 | case 'authentication_error': 266 | return AuthenticationException( 267 | message: errorData['message']?.toString() ?? 'Invalid API key', 268 | errorType: errorType?.toString(), 269 | requestId: _extractRequestId(error.response?.headers), 270 | ); 271 | } 272 | } 273 | } 274 | return baseError; 275 | } 276 | 277 | /// Parse OpenAI specific errors 278 | static ApiException _parseOpenAIError( 279 | DioException error, 280 | ApiException baseError, 281 | ) { 282 | return baseError; 283 | } 284 | 285 | /// Parse Gemini specific errors 286 | static ApiException _parseGeminiError( 287 | DioException error, ApiException baseError) { 288 | final responseData = error.response?.data; 289 | if (responseData is Map && 290 | responseData.containsKey('error')) { 291 | final errorData = responseData['error']; 292 | if (errorData is Map) { 293 | final status = errorData['status']; 294 | final message = errorData['message']; 295 | 296 | // Handle Gemini-specific error statuses 297 | switch (status) { 298 | case 'RESOURCE_EXHAUSTED': 299 | return RateLimitException( 300 | message: message?.toString() ?? 'Rate limit exceeded', 301 | errorType: status?.toString(), 302 | requestId: _extractRequestId(error.response?.headers), 303 | ); 304 | case 'FAILED_PRECONDITION': 305 | return PermissionException( 306 | message: message?.toString() ?? 307 | 'API access not available in your region', 308 | errorType: status?.toString(), 309 | requestId: _extractRequestId(error.response?.headers), 310 | ); 311 | } 312 | } 313 | } 314 | return baseError; 315 | } 316 | 317 | /// Parse DeepSeek specific errors 318 | static ApiException _parseDeepseekError( 319 | DioException error, 320 | ApiException baseError, 321 | ) { 322 | // DeepSeek uses OpenAI-compatible format 323 | return baseError; 324 | } 325 | 326 | /// Parse Grok specific errors 327 | static ApiException _parseGrokError( 328 | DioException error, ApiException baseError) { 329 | // Grok uses OpenAI-compatible format 330 | return baseError; 331 | } 332 | } 333 | -------------------------------------------------------------------------------- /lib/src/commands/analyze_command.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // analyze_command.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/05/08. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'dart:io'; 10 | 11 | import 'package:args/command_runner.dart'; 12 | import 'package:mason_logger/mason_logger.dart'; 13 | import 'package:path/path.dart' as path; 14 | 15 | import '../config_manager.dart'; 16 | import '../exceptions/exceptions.dart'; 17 | import '../git_utils.dart'; 18 | import '../models/commit_generator_factory.dart'; 19 | 20 | class AnalyzeCommand extends Command { 21 | AnalyzeCommand({ 22 | required Logger logger, 23 | }) : _logger = logger { 24 | argParser 25 | ..addOption( 26 | 'model', 27 | abbr: 'm', 28 | help: 'AI model to use', 29 | allowed: [ 30 | 'claude', 31 | 'openai', 32 | 'gemini', 33 | 'grok', 34 | 'llama', 35 | 'deepseek', 36 | 'github', 37 | 'ollama', 38 | 'free', 39 | ], 40 | allowedHelp: { 41 | 'claude': 'Anthropic Claude', 42 | 'openai': 'OpenAI GPT models', 43 | 'gemini': 'Google Gemini', 44 | 'grok': 'xAI Grok', 45 | 'llama': 'Meta Llama', 46 | 'deepseek': 'DeepSeek, Inc.', 47 | 'github': 'Github', 48 | 'ollama': 'Ollama', 49 | 'free': 'Free (LLM7.io) - No API key required', 50 | }, 51 | ) 52 | ..addOption( 53 | 'key', 54 | abbr: 'k', 55 | help: 'API key for the selected model', 56 | ) 57 | ..addOption( 58 | 'model-variant', 59 | abbr: 'v', 60 | help: 'Specific variant of the AI model to use', 61 | valueHelp: 'gpt-4o, claude-3-opus, gemini-pro, etc.', 62 | ); 63 | } 64 | 65 | final Logger _logger; 66 | 67 | @override 68 | String get description => 'Generate an analysis based on file changes'; 69 | 70 | @override 71 | String get name => 'analyze'; 72 | 73 | @override 74 | Future run() async { 75 | // Initialize config manager 76 | final configManager = ConfigManager(); 77 | await configManager.load(); 78 | 79 | // Get the language to use for analysis 80 | final language = configManager.getWhisperLanguage(); 81 | 82 | List? subGitRepos; 83 | 84 | // Check if we're in a git repository; if not, check subfolders 85 | if (!await GitUtils.isGitRepository()) { 86 | _logger 87 | .warn('Not a git repository. Checking subfolders for git repos...'); 88 | subGitRepos = await GitUtils.findGitReposInSubfolders(); 89 | if (subGitRepos.isEmpty) { 90 | _logger.err( 91 | 'No git repository found in subfolders. Please run from a git repository.'); 92 | return ExitCode.usage.code; 93 | } 94 | } 95 | 96 | final bool hasSubGitRepos = subGitRepos != null; 97 | 98 | if (hasSubGitRepos) { 99 | final String response = _logger.chooseOne( 100 | 'GitWhisper has discovered git repositories in subfolders but not in this' 101 | ' current folder, would you like to continue?', 102 | choices: ['continue', 'abort'], 103 | defaultValue: 'continue', 104 | ); 105 | 106 | if (response == 'abort') { 107 | return ExitCode.usage.code; 108 | } 109 | } 110 | 111 | // Get the model name and variant from args, config, or defaults 112 | String? modelName = argResults?['model'] as String?; 113 | String? modelVariant = argResults?['model-variant'] as String? ?? ''; 114 | 115 | // If modelName is not provided, use config or prompt user 116 | if (modelName == null) { 117 | final (String, String)? defaults = 118 | configManager.getDefaultModelAndVariant(); 119 | if (defaults != null) { 120 | modelName = defaults.$1; 121 | modelVariant = defaults.$2; 122 | } else { 123 | // Prompt user to select model 124 | modelName = _logger.chooseOne( 125 | 'Select the AI model for analysis:', 126 | choices: [ 127 | 'claude', 128 | 'openai', 129 | 'gemini', 130 | 'grok', 131 | 'llama', 132 | 'deepseek', 133 | 'github', 134 | 'ollama', 135 | 'free', 136 | ], 137 | defaultValue: 'openai', 138 | ); 139 | } 140 | } 141 | 142 | // Get API key (from args, config, or environment) 143 | var apiKey = argResults?['key'] as String?; 144 | apiKey ??= 145 | configManager.getApiKey(modelName!) ?? _getEnvironmentApiKey(modelName); 146 | 147 | if ((apiKey == null || apiKey.isEmpty) && 148 | modelName != 'ollama' && 149 | modelName != 'free') { 150 | _logger.err( 151 | 'No API key provided for $modelName. Please provide an API key using --key or save one using "gw save-key".', 152 | ); 153 | return ExitCode.usage.code; 154 | } 155 | 156 | // Show disclaimer for free model on first use 157 | if (modelName == 'free' && !configManager.hasAcceptedFreeDisclaimer()) { 158 | _logger 159 | ..info('') 160 | ..info( 161 | '┌─────────────────────────────────────────────────────────────┐') 162 | ..info( 163 | '│ FREE MODEL DISCLAIMER │') 164 | ..info( 165 | '├─────────────────────────────────────────────────────────────┤') 166 | ..info( 167 | '│ This free model is powered by LLM7.io - a third-party │') 168 | ..info( 169 | '│ service providing free, anonymous access to AI models. │') 170 | ..info( 171 | '│ │') 172 | ..info( 173 | '│ Anonymous tier limits: │') 174 | ..info( 175 | '│ • 8k chars per request │') 176 | ..info( 177 | '│ • 60 requests/hour, 10 requests/min, 1 request/sec │') 178 | ..info( 179 | '│ │') 180 | ..info( 181 | '│ Please note: │') 182 | ..info( 183 | '│ • Your code diffs will be sent to LLM7.io servers │') 184 | ..info( 185 | '│ • Service availability is not guaranteed │') 186 | ..info( 187 | '│ • For production use, consider a paid API provider │') 188 | ..info( 189 | '│ │') 190 | ..info( 191 | '│ Learn more: https://llm7.io │') 192 | ..info( 193 | '└─────────────────────────────────────────────────────────────┘') 194 | ..info(''); 195 | 196 | final response = _logger.chooseOne( 197 | 'Do you accept these terms and wish to continue?', 198 | choices: ['yes', 'no'], 199 | defaultValue: 'yes', 200 | ); 201 | 202 | if (response == 'no') { 203 | _logger.info('Free model usage cancelled.'); 204 | return ExitCode.usage.code; 205 | } 206 | 207 | // Save acceptance so we don't show again 208 | configManager.setFreeDisclaimerAccepted(); 209 | await configManager.save(); 210 | _logger.success('Disclaimer accepted. You won\'t see this again.'); 211 | } 212 | 213 | // Create the appropriate AI generator based on model name 214 | final generator = CommitGeneratorFactory.create( 215 | modelName!, 216 | apiKey, 217 | variant: modelVariant, 218 | ); 219 | 220 | if (!hasSubGitRepos) { 221 | // --- Single repo flow --- 222 | final hasStagedChanges = await GitUtils.hasStagedChanges(); 223 | late final String diff; 224 | 225 | if (hasStagedChanges) { 226 | _logger.info('Checking staged files for changes.'); 227 | diff = await GitUtils.getStagedDiff(); 228 | } else { 229 | _logger.info('Checking for changes in all unstaged files.'); 230 | diff = await GitUtils.getUnstagedDiff(); 231 | } 232 | 233 | if (diff.isEmpty) { 234 | _logger.err('No changes detected in staged or unstaged files.'); 235 | return ExitCode.usage.code; 236 | } 237 | 238 | try { 239 | _logger.info('Analyzing changes using $modelName' 240 | '${modelVariant.isNotEmpty ? ' ($modelVariant)' : ''}...'); 241 | 242 | // Generate analysis with AI 243 | final analysis = await generator.analyzeChanges(diff, language); 244 | 245 | if (analysis.trim().isEmpty) { 246 | _logger.err('Error: Failed to generate analysis'); 247 | return ExitCode.software.code; 248 | } 249 | 250 | _logger 251 | ..info('') 252 | ..success(analysis); 253 | 254 | return ExitCode.success.code; 255 | } on ApiException catch (e) { 256 | ErrorHandler.handleErrorWithRetry( 257 | e, 258 | context: 'analyzing changes', 259 | ); 260 | 261 | if (ErrorHandler.shouldSuggestModelSwitch(e)) { 262 | final suggestions = ErrorHandler.getModelSwitchSuggestions(e); 263 | ErrorHandler.handleErrorWithFallback( 264 | e, 265 | fallbackOptions: suggestions, 266 | ); 267 | } 268 | 269 | return ExitCode.software.code; 270 | } catch (e) { 271 | ErrorHandler.handleGeneralError( 272 | e as Exception, 273 | context: 'analyzing changes', 274 | ); 275 | return ExitCode.software.code; 276 | } 277 | } else { 278 | // --- Multi-repo flow --- 279 | int successCount = 0; 280 | final List failedRepos = []; 281 | final foldersWithChanges = []; 282 | 283 | // Only process repos with staged or unstaged changes 284 | for (final repo in subGitRepos) { 285 | final hasStaged = await GitUtils.hasStagedChanges(folderPath: repo); 286 | final hasUnstaged = await GitUtils.hasUnstagedChanges(folderPath: repo); 287 | 288 | if (hasStaged || hasUnstaged) { 289 | foldersWithChanges.add(repo); 290 | } 291 | } 292 | 293 | if (foldersWithChanges.isEmpty) { 294 | _logger.err('No changes detected in any subfolder repositories.'); 295 | return ExitCode.usage.code; 296 | } 297 | 298 | for (final repo in foldersWithChanges) { 299 | final repoName = path.basename(repo); 300 | String diff = ''; 301 | bool usedStaged = false; 302 | 303 | if (await GitUtils.hasStagedChanges(folderPath: repo)) { 304 | diff = await GitUtils.getStagedDiff(folderPath: repo); 305 | usedStaged = true; 306 | } else if (await GitUtils.hasUnstagedChanges(folderPath: repo)) { 307 | diff = await GitUtils.getUnstagedDiff(folderPath: repo); 308 | } 309 | 310 | if (diff.isEmpty) { 311 | _logger.warn('[$repoName] No changes detected, skipping.'); 312 | continue; 313 | } 314 | 315 | try { 316 | _logger.info( 317 | '[$repoName] Analyzing ${usedStaged ? 'staged' : 'unstaged'} changes using $modelName' 318 | '${modelVariant.isNotEmpty ? ' ($modelVariant)' : ''}...'); 319 | 320 | final analysis = await generator.analyzeChanges(diff, language); 321 | 322 | if (analysis.trim().isEmpty) { 323 | _logger.err('[$repoName] Error: Failed to generate analysis'); 324 | failedRepos.add(repoName); 325 | continue; 326 | } 327 | 328 | _logger 329 | ..info('\n----------- $repoName -----------\n') 330 | ..success(analysis) 331 | ..info('\n----------------------------------\n'); 332 | 333 | successCount++; 334 | } on ApiException catch (e) { 335 | _logger.err('[$repoName] ${ErrorHandler.getErrorSummary(e)}'); 336 | failedRepos.add(repoName); 337 | continue; 338 | } catch (e) { 339 | _logger.err('[$repoName] Error analyzing the changes: $e'); 340 | failedRepos.add(repoName); 341 | continue; 342 | } 343 | } 344 | 345 | if (failedRepos.isNotEmpty) { 346 | _logger.err('Analysis failed in: ${failedRepos.join(', ')}'); 347 | } 348 | _logger.success('Analysis complete for $successCount git repos.'); 349 | return failedRepos.isEmpty 350 | ? ExitCode.success.code 351 | : ExitCode.software.code; 352 | } 353 | } 354 | 355 | String? _getEnvironmentApiKey(String modelName) { 356 | return switch (modelName.toLowerCase()) { 357 | 'claude' => Platform.environment['ANTHROPIC_API_KEY'], 358 | 'openai' => Platform.environment['OPENAI_API_KEY'], 359 | 'gemini' => Platform.environment['GEMINI_API_KEY'], 360 | 'grok' => Platform.environment['GROK_API_KEY'], 361 | 'llama' => Platform.environment['LLAMA_API_KEY'], 362 | 'deepseek' => Platform.environment['DEEPSEEK_API_KEY'], 363 | _ => null, 364 | }; 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /lib/src/git_utils.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // git_utils.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'dart:io'; 10 | 11 | import 'package:path/path.dart' as path; 12 | 13 | import 'constants.dart'; 14 | 15 | /// Utility class for Git operations 16 | class GitUtils { 17 | // Get the full path of the current working directory 18 | String getCurrentDirectoryPath() { 19 | return Directory.current.path; 20 | } 21 | 22 | /// Returns a list of subdirectories (1 level down) that are git repositories. 23 | /// If current directory is a git repo, returns an empty list. 24 | static Future> findGitReposInSubfolders() async { 25 | final dir = Directory.current; 26 | final subdirs = 27 | dir.listSync(followLinks: false).whereType().toList(); 28 | 29 | final List gitRepos = []; 30 | 31 | for (final subdir in subdirs) { 32 | final gitDir = Directory('${subdir.path}/.git'); 33 | if (gitDir.existsSync()) { 34 | // Quick check: .git folder exists, but let's confirm with git command 35 | final subResult = await Process.run( 36 | 'git', 37 | ['rev-parse', '--is-inside-work-tree'], 38 | workingDirectory: subdir.path, 39 | ); 40 | if (subResult.exitCode == 0 && 41 | (subResult.stdout as String).trim() == 'true') { 42 | gitRepos.add(subdir.path); 43 | } 44 | } 45 | } 46 | 47 | return gitRepos; 48 | } 49 | 50 | /// Get the diff of staged changes 51 | static Future getStagedDiff({String? folderPath}) async { 52 | final result = await Process.run( 53 | 'git', 54 | ['diff', '--cached'], 55 | workingDirectory: folderPath, 56 | ); 57 | return result.exitCode == 0 ? (result.stdout as String) : ''; 58 | } 59 | 60 | /// Get the diff of unstagged changes 61 | static Future getUnstagedDiff({String? folderPath}) async { 62 | final result = await Process.run( 63 | 'git', 64 | ['diff'], 65 | workingDirectory: folderPath, 66 | ); 67 | return result.exitCode == 0 ? (result.stdout as String) : ''; 68 | } 69 | 70 | /// Check if there are staged changes 71 | static Future hasStagedChanges({String? folderPath}) async { 72 | final result = await Process.run( 73 | 'git', 74 | ['diff', '--cached', '--name-only'], 75 | workingDirectory: folderPath, 76 | ); 77 | return result.exitCode == 0 && (result.stdout as String).trim().isNotEmpty; 78 | } 79 | 80 | /// Returns a list of folder paths (from the input) that have staged changes. 81 | static Future> foldersWithStagedChanges( 82 | List folders) async { 83 | final result = []; 84 | for (final folder in folders) { 85 | final gitResult = await Process.run( 86 | 'git', 87 | ['diff', '--cached', '--name-only'], 88 | workingDirectory: folder, 89 | ); 90 | if (gitResult.exitCode == 0 && 91 | (gitResult.stdout as String).trim().isNotEmpty) { 92 | result.add(folder); 93 | } 94 | } 95 | return result; 96 | } 97 | 98 | /// Returns a list of folder paths (from the input) that have unstaged changes. 99 | static Future> foldersWithUnstagedChanges( 100 | List folders) async { 101 | final result = []; 102 | for (final folder in folders) { 103 | final gitResult = await Process.run( 104 | 'git', 105 | ['diff', '--name-only'], 106 | workingDirectory: folder, 107 | ); 108 | if (gitResult.exitCode == 0 && 109 | (gitResult.stdout as String).trim().isNotEmpty) { 110 | result.add(folder); 111 | } 112 | } 113 | return result; 114 | } 115 | 116 | /// Check if there are unstaged changes 117 | static Future hasUnstagedChanges({String? folderPath}) async { 118 | final result = await Process.run( 119 | 'git', 120 | ['diff', '--name-only'], 121 | workingDirectory: folderPath, 122 | ); 123 | return result.exitCode == 0 && (result.stdout as String).trim().isNotEmpty; 124 | } 125 | 126 | /// Check if there are untracked files 127 | static Future hasUntrackedFiles({String? folderPath}) async { 128 | final result = await Process.run( 129 | 'git', 130 | ['ls-files', '--others', '--exclude-standard'], 131 | workingDirectory: folderPath, 132 | ); 133 | return result.exitCode == 0 && (result.stdout as String).trim().isNotEmpty; 134 | } 135 | 136 | /// Returns a list of folder paths (from the input) that have untracked files. 137 | static Future> foldersWithUntrackedFiles( 138 | List folders) async { 139 | final result = []; 140 | for (final folder in folders) { 141 | final gitResult = await Process.run( 142 | 'git', 143 | ['ls-files', '--others', '--exclude-standard'], 144 | workingDirectory: folder, 145 | ); 146 | if (gitResult.exitCode == 0 && 147 | (gitResult.stdout as String).trim().isNotEmpty) { 148 | result.add(folder); 149 | } 150 | } 151 | return result; 152 | } 153 | 154 | /// Check if the current directory is a Git repository 155 | static Future isGitRepository() async { 156 | final result = 157 | await Process.run('git', ['rev-parse', '--is-inside-work-tree']); 158 | return result.exitCode == 0 && (result.stdout as String).trim() == 'true'; 159 | } 160 | 161 | /// Run git commit 162 | static Future runGitCommit({ 163 | required String message, 164 | bool autoPush = false, 165 | String? folderPath, 166 | String? tag, 167 | }) async { 168 | final args = ['commit', '-m', message]; 169 | final result = await Process.run( 170 | 'git', 171 | args, 172 | workingDirectory: folderPath, 173 | ); 174 | if (result.exitCode != 0) { 175 | throw Exception('Error during git commit: ${result.stderr}'); 176 | } else { 177 | // Create tag if provided 178 | if (tag != null && tag.isNotEmpty) { 179 | final tagResult = await Process.run( 180 | 'git', 181 | ['tag', tag], 182 | workingDirectory: folderPath, 183 | ); 184 | if (tagResult.exitCode != 0) { 185 | throw Exception('Error creating tag: ${tagResult.stderr}'); 186 | } 187 | if (folderPath != null) { 188 | final folderName = path.basename(folderPath); 189 | $logger.success('[$folderName] Tag $tag created successfully!'); 190 | } else { 191 | $logger.success('Tag $tag created successfully!'); 192 | } 193 | } 194 | 195 | if (!autoPush) { 196 | if (folderPath != null) { 197 | final folderName = path.basename(folderPath); 198 | $logger.success('[$folderName] Commit successful! 🎉'); 199 | } else { 200 | $logger.success('Commit successful! 🎉'); 201 | } 202 | } else { 203 | /// Push the commit if autoPush is true 204 | $logger.info('Commit successful! Syncing with remote branch.'); 205 | 206 | final branchName = await Process.run( 207 | 'git', 208 | ['rev-parse', '--abbrev-ref', 'HEAD'], 209 | workingDirectory: folderPath, 210 | ); 211 | final remoteNameNoneUrl = await Process.run('git', ['remote']); 212 | 213 | if (branchName.exitCode != 0 || remoteNameNoneUrl.exitCode != 0) { 214 | throw Exception( 215 | 'Error getting branch or remote name: ${branchName.stderr}', 216 | ); 217 | } 218 | 219 | final branch = (branchName.stdout as String).trim(); 220 | final remoteNoneUrl = (remoteNameNoneUrl.stdout as String).trim(); 221 | 222 | /// Run the git push command 223 | final pushResult = await Process.run( 224 | 'git', 225 | ['push', remoteNoneUrl, branch], 226 | workingDirectory: folderPath, 227 | ); 228 | if (pushResult.exitCode != 0) { 229 | throw Exception('Error during git push: ${pushResult.stderr}'); 230 | } else { 231 | if (folderPath != null) { 232 | final folderName = path.basename(folderPath); 233 | $logger.success( 234 | '[$folderName] Pushed to $remoteNoneUrl/$branch successfully! 🎉'); 235 | } else { 236 | $logger 237 | .success('Pushed to $remoteNoneUrl/$branch successfully! 🎉'); 238 | } 239 | } 240 | 241 | // Push tag if it was created 242 | if (tag != null && tag.isNotEmpty) { 243 | final pushTagResult = await Process.run( 244 | 'git', 245 | ['push', remoteNoneUrl, tag], 246 | workingDirectory: folderPath, 247 | ); 248 | if (pushTagResult.exitCode != 0) { 249 | throw Exception('Error pushing tag: ${pushTagResult.stderr}'); 250 | } else { 251 | if (folderPath != null) { 252 | final folderName = path.basename(folderPath); 253 | $logger.success('[$folderName] Tag $tag pushed successfully!'); 254 | } else { 255 | $logger.success('Tag $tag pushed successfully!'); 256 | } 257 | } 258 | } 259 | } 260 | } 261 | } 262 | 263 | /// Stages all unstaged changes (including new files) and returns the number 264 | /// of files added to the index 265 | static Future stageAllUnstagedFilesAndCount({String? folderPath}) async { 266 | // Get currently staged files before 267 | final beforeResult = await Process.run( 268 | 'git', 269 | ['diff', '--cached', '--name-only'], 270 | workingDirectory: folderPath, 271 | ); 272 | if (beforeResult.exitCode != 0) { 273 | throw Exception( 274 | 'Failed to get staged files before: ${beforeResult.stderr}', 275 | ); 276 | } 277 | final before = (beforeResult.stdout as String) 278 | .trim() 279 | .split('\n') 280 | .where((f) => f.isNotEmpty) 281 | .toSet(); 282 | 283 | // Stage all changes (including new files) 284 | final addResult = await Process.run( 285 | 'git', 286 | ['add', '.'], 287 | workingDirectory: folderPath, 288 | ); 289 | if (addResult.exitCode != 0) { 290 | throw Exception('Failed to stage all changes: ${addResult.stderr}'); 291 | } 292 | 293 | // Get currently staged files after 294 | final afterResult = await Process.run( 295 | 'git', 296 | ['diff', '--cached', '--name-only'], 297 | workingDirectory: folderPath, 298 | ); 299 | if (afterResult.exitCode != 0) { 300 | throw Exception( 301 | 'Failed to get staged files after: ${afterResult.stderr}', 302 | ); 303 | } 304 | final after = (afterResult.stdout as String) 305 | .trim() 306 | .split('\n') 307 | .where((f) => f.isNotEmpty) 308 | .toSet(); 309 | 310 | // Return the number of files newly staged 311 | return after.difference(before).length; 312 | } 313 | 314 | /// Opens the user's configured Git editor to edit a commit message. 315 | /// 316 | /// This function: 317 | /// 1. Gets the user's Git editor configuration (or falls back to $EDITOR or vi) 318 | /// 2. Writes the initial message to a temporary file 319 | /// 3. Opens the editor for the user to edit the message 320 | /// 4. Reads back the edited message and cleans it up 321 | /// 322 | /// Returns the edited message, or null if the user left it empty or the editor failed. 323 | static Future openGitEditor(String initialMessage) async { 324 | // Get the configured Git editor 325 | final editorResult = await Process.run('git', ['var', 'GIT_EDITOR']); 326 | String editor; 327 | 328 | if (editorResult.exitCode == 0 && 329 | (editorResult.stdout as String).trim().isNotEmpty) { 330 | editor = (editorResult.stdout as String).trim(); 331 | } else { 332 | // Fall back to EDITOR environment variable or vi 333 | editor = Platform.environment['EDITOR'] ?? 'vi'; 334 | } 335 | 336 | // Create a temporary file for the commit message 337 | final tempDir = Directory.systemTemp; 338 | final tempFile = File( 339 | '${tempDir.path}/GITWHISPER_EDITMSG_${DateTime.now().millisecondsSinceEpoch}'); 340 | 341 | try { 342 | // Write the initial message to the temp file 343 | await tempFile.writeAsString(initialMessage); 344 | 345 | // Open the editor (use Process.start to allow interactive editing) 346 | final process = await Process.start( 347 | '/bin/sh', 348 | ['-c', '$editor "${tempFile.path}"'], 349 | mode: ProcessStartMode.inheritStdio, 350 | ); 351 | 352 | final exitCode = await process.exitCode; 353 | 354 | if (exitCode != 0) { 355 | $logger.err('Editor exited with code $exitCode'); 356 | return null; 357 | } 358 | 359 | // Read the edited message 360 | final editedMessage = await tempFile.readAsString(); 361 | 362 | // Clean up: remove comment lines (lines starting with #) and trim 363 | final cleanedLines = editedMessage 364 | .split('\n') 365 | .where((line) => !line.trimLeft().startsWith('#')) 366 | .toList(); 367 | 368 | final cleaned = cleanedLines.join('\n').trim(); 369 | 370 | return cleaned.isNotEmpty ? cleaned : null; 371 | } finally { 372 | // Clean up the temp file 373 | if (await tempFile.exists()) { 374 | await tempFile.delete(); 375 | } 376 | } 377 | } 378 | 379 | /// Removes Markdown-style code block markers (``` or ```dart) from a string. 380 | /// 381 | /// This is useful when dealing with AI-generated or Markdown-formatted text 382 | /// that includes code fences around commit messages or snippets. 383 | /// 384 | /// Example: 385 | /// ```dart 386 | /// final raw = '```dart\nfix: improve performance of query\n```'; 387 | /// final cleaned = stripMarkdownCodeBlocks(raw); 388 | /// print(cleaned); // Output: fix: improve performance of query 389 | /// ``` 390 | /// 391 | /// - Removes opening code fences like ``` or ```dart at the start of the string 392 | /// - Removes closing ``` at the end of the string 393 | /// - Trims any leading/trailing whitespace 394 | /// 395 | /// [input] is the original string with possible Markdown code block syntax. 396 | /// Returns the cleaned string without Markdown code block delimiters. 397 | static String stripMarkdownCodeBlocks(String input) { 398 | final codeBlockPattern = RegExp(r'^```(\w+)?\n?|```$', multiLine: true); 399 | return input.replaceAll(codeBlockPattern, '').trim(); 400 | } 401 | } 402 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | gitwhisper 3 |
4 | 5 | ![coverage][coverage_badge] 6 | [![style: very good analysis][very_good_analysis_badge]][very_good_analysis_link] 7 | [![License: MIT][license_badge]][license_link] 8 | 9 | Generated by the [Very Good CLI][very_good_cli_link] 🤖 10 | 11 | --- 12 | 13 | Git Whisper is an AI-powered Git commit message generator that whispers the perfect commit message based on your staged changes. 14 | 15 | --- 16 | 17 | [Be nice and buy me a coffee alright or matcha 🤣](https://www.buymeacoffee.com/modestnerd) 18 | 19 | --- 20 | 21 | ## Editor Integrations 🔌 22 | 23 | GitWhisper is also available as plugins for popular editors: 24 | 25 | ### Visual Studio Code 26 | Install the GitWhisper extension directly from the marketplace: 27 | - **Extension**: [GitWhisper for VS Code](https://marketplace.visualstudio.com/items?itemName=gitwhisper.gitwhisper) 28 | - **Developer**: Panashe Mushinyi 29 | - **Features**: Generate commit messages directly within VS Code's Git interface 30 | 31 | ### JetBrains IDEs 32 | Available for all JetBrains IDEs (IntelliJ IDEA, WebStorm, PhpStorm, etc.): 33 | - **Plugin**: [GitWhisper for JetBrains](https://plugins.jetbrains.com/plugin/28057-gitwhisper) 34 | - **Developer**: Panashe Mushinyi 35 | - **Features**: Seamless integration with JetBrains VCS tools 36 | 37 | ## Getting Started 🚀 38 | 39 | If you have `Dart` installed, activate globally via: 40 | 41 | ```sh 42 | dart pub global activate gitwhisper 43 | ``` 44 | 45 | Or locally via: 46 | 47 | ```sh 48 | dart pub global activate --source=path 49 | ``` 50 | 51 | Or on Mac using Homebrew: 52 | 53 | ```bash 54 | brew tap iamngoni/homebrew-gitwhisper 55 | brew install gitwhisper 56 | ``` 57 | 58 | Or on Debian/Ubuntu using APT (supports amd64 and arm64): 59 | ```bash 60 | echo "deb [trusted=yes] https://iamngoni.github.io/gitwhisper-apt stable main" | sudo tee /etc/apt/sources.list.d/gitwhisper.list 61 | sudo apt update 62 | sudo apt install gitwhisper 63 | ``` 64 | 65 | Or directly with curl: 66 | ```bash 67 | curl -sSL https://raw.githubusercontent.com/iamngoni/gitwhisper/master/install.sh | bash 68 | ``` 69 | 70 | Or on the beloved Windows using Powershell (as Administrator): 71 | ```bash 72 | irm https://raw.githubusercontent.com/iamngoni/gitwhisper/master/install.ps1 | iex 73 | ``` 74 | 75 | Or download the executable binary that will work on your operating system directly [here.](https://github.com/iamngoni/gitwhisper/releases) 76 | 77 | ## Features 78 | 79 | - 🤖 Leverages various AI models to analyze your code changes and generate meaningful commit messages 80 | - 🔄 Follows conventional commit format with emojis: ` : ` 81 | - ✨ **Interactive commit confirmation** - Review, edit, retry with different models, or discard generated messages 82 | - 📋 Pre-fills the Git commit editor for easy review and modification 83 | - 🚀️ Supports automatic pushing of commits to the remote repository 84 | - 🔍 Code analysis to understand staged changes and get suggestions for improvements 85 | - 🎫 Supports ticket number prefixing for commit messages 86 | - 🏷️ Create git tags alongside commits with optional auto-push 87 | - 🧩 Choose specific model variants (gpt-4o, claude-3-opus, etc.) 88 | - 🔑 Securely saves API keys for future use 89 | - 🌍 Multi-language support for commit messages and analysis 90 | - 🔌 Supports multiple AI models: 91 | - Claude (Anthropic) 92 | - OpenAI (GPT) 93 | - Gemini (Google) 94 | - Grok (xAI) 95 | - Llama (Meta) 96 | - Deepseek (DeepSeek, Inc.) 97 | - GitHub Models (Free, rate-limited) 98 | - All Ollama models 99 | - Free (LLM7.io) - No API key required! 100 | 101 | ## Usage 102 | 103 | ```bash 104 | # Generate a commit message (main command) 105 | gitwhisper commit --model openai 106 | gitwhisper # shorthand for 'gitwhisper commit' - runs commit command by default 107 | gw # even shorter command - also runs 'gitwhisper commit' by default 108 | 109 | # Choose a specific model variant 110 | gitwhisper commit --model openai --model-variant gpt-4o 111 | gw commit --model openai --model-variant gpt-4o 112 | 113 | # Add a ticket number prefix to your commit message 114 | gitwhisper commit --prefix "JIRA-123" 115 | gw commit --prefix "JIRA-123" 116 | 117 | # Automatically push the commit to the remote repository 118 | gitwhisper commit --auto-push 119 | gw commit --auto-push 120 | gw commit -a # shorthand for --auto-push 121 | 122 | # Create a git tag for this commit 123 | gitwhisper commit --tag v1.0.0 124 | gw commit -t v1.0.0 125 | 126 | # Combine tag with auto-push to push both commit and tag 127 | gw commit -t v1.0.0 -a 128 | 129 | # Use free model (no API key required!) 130 | gitwhisper commit --model free 131 | gw commit -m free 132 | 133 | # Analyze your changes (staged/unstaged) with AI 134 | gitwhisper analyze 135 | gw analyze 136 | 137 | # List available models 138 | gitwhisper list-models 139 | gw list-models 140 | 141 | # List available variants for a specific model 142 | gitwhisper list-variants --model claude 143 | gw list-variants --model claude 144 | 145 | # Change the language for commit messages and analysis 146 | gitwhisper change-language 147 | gw change-language 148 | 149 | # Save an API key for future use 150 | gitwhisper save-key --model claude --key "your-claude-key" 151 | gw save-key --model claude --key "your-claude-key" 152 | 153 | # Set defaults (model is required, model-variant is optional) 154 | gitwhisper set-defaults --model openai --model-variant gpt-4o 155 | gw set-defaults --model openai --model-variant gpt-4o 156 | 157 | # Set just the default model (without variant) 158 | gitwhisper set-defaults --model claude 159 | gw set-defaults --model claude 160 | 161 | # Set defaults for Ollama with custom base URL 162 | gitwhisper set-defaults --model ollama --model-variant llama3 --base-url http://localhost:11434 163 | gw set-defaults --model ollama --model-variant llama3 --base-url http://localhost:11434 164 | 165 | # Show current defaults 166 | gitwhisper show-defaults 167 | gw show-defaults 168 | 169 | # Clear defaults 170 | gitwhisper clear-defaults 171 | gw clear-defaults 172 | 173 | # Always stage changes first 174 | gitwhisper always-add true 175 | gw always-add true 176 | 177 | # Get help 178 | gitwhisper --help 179 | gw --help 180 | ``` 181 | 182 | ## Shorter Command 183 | Instead of using the full `gitwhisper` command you can also use the shortened one `gw`. Both `gitwhisper` and `gw` without any subcommands will automatically run the `commit` command by default. 184 | 185 | ## Interactive Commit Confirmation 186 | 187 | GitWhisper now features an interactive commit confirmation workflow that gives you full control over your commit messages: 188 | 189 | ### What You Can Do: 190 | - **Apply**: Use the generated commit message as-is 191 | - **Edit**: Modify the commit message before applying 192 | - **Retry**: Generate a new message with the same model 193 | - **Try Different Model**: Generate with a different AI model 194 | - **Discard**: Cancel and exit without committing 195 | 196 | ### Example Workflow: 197 | ```bash 198 | $ gitwhisper commit 199 | 🔮 Analyzing your changes... 200 | ✨ Generated commit message: feat: ✨ Add user authentication system 201 | 202 | Options: 203 | [A] Apply commit message 204 | [E] Edit commit message 205 | [R] Retry with same model 206 | [M] Try different model 207 | [D] Discard and exit 208 | 209 | What would you like to do? (A/e/r/m/d): 210 | ``` 211 | 212 | ## Command Structure 213 | 214 | GitWhisper uses a command-based structure: 215 | 216 | - `commit`: Generate and apply a commit message with interactive confirmation (main command) 217 | - `analyze`: Examine changes (staged/unstaged) and provide detailed code analysis with suggestions for improvements 218 | - `list-models`: Show all supported AI models 219 | - `list-variants`: Show available variants for each AI model 220 | - `change-language`: Set the language for AI-generated commit messages and analysis 221 | - `save-key`: Store an API key for future use 222 | - `update`: Update GitWhisper to the latest version 223 | - `set-defaults`: Set default model and variant for future use (supports --base-url for Ollama) 224 | - `show-defaults`: Display current default settings 225 | - `clear-defaults`: Clear any set default preferences 226 | 227 | ## API Keys 228 | 229 | You can provide API keys in several ways: 230 | 231 | 1. **Command line argument**: `--key "your-api-key"` 232 | 2. **Environment variables**: 233 | - `ANTHROPIC_API_KEY` (for Claude) 234 | - `OPENAI_API_KEY` (for OpenAI) 235 | - `GEMINI_API_KEY` (for Gemini) 236 | - `GROK_API_KEY` (for Grok) 237 | - `LLAMA_API_KEY` (for Llama) 238 | 3. **Saved configuration**: Use the `save-key` command to store your API key permanently 239 | 240 | ## Model Variants 241 | 242 | GitWhisper supports a comprehensive range of model variants: 243 | 244 | ### OpenAI 245 | - `gpt-4` (default) 246 | - `gpt-4-turbo-2024-04-09` 247 | - `gpt-4o` 248 | - `gpt-4o-mini` 249 | - `gpt-4.5-preview` 250 | - `gpt-3.5-turbo-0125` 251 | - `gpt-3.5-turbo-instruct` 252 | - `o1-preview` 253 | - `o1-mini` 254 | - `o3-mini` 255 | 256 | ### Claude (Anthropic) 257 | - `claude-3-opus-20240307` (default) 258 | - `claude-3-sonnet-20240307` 259 | - `claude-3-haiku-20240307` 260 | - `claude-3-5-sonnet-20240620` 261 | - `claude-3-5-sonnet-20241022` 262 | - `claude-3-7-sonnet-20250219` 263 | 264 | ### Gemini (Google) 265 | - `gemini-2.5-pro-preview-05-06` (advanced reasoning, 1M token context) 266 | - `gemini-2.5-flash-preview-04-17` (adaptive thinking, cost efficient) 267 | - `gemini-2.0-flash` (default, fast performance) 268 | - `gemini-2.0-flash-lite` (lowest latency) 269 | - `gemini-1.5-pro-002` (supports up to 2M tokens) 270 | - `gemini-1.5-flash-002` (supports up to 1M tokens) 271 | - `gemini-1.5-flash-8b` (most cost effective) 272 | 273 | ### Grok (xAI) 274 | - `grok-1` (default) 275 | - `grok-2` 276 | - `grok-3` 277 | - `grok-2-mini` 278 | 279 | ### Llama (Meta) 280 | - `llama-3-70b-instruct` (default) 281 | - `llama-3-8b-instruct` 282 | - `llama-3.1-8b-instruct` 283 | - `llama-3.1-70b-instruct` 284 | - `llama-3.1-405b-instruct` 285 | - `llama-3.2-1b-instruct` 286 | - `llama-3.2-3b-instruct` 287 | - `llama-3.3-70b-instruct` 288 | 289 | ### Deekseek (DeepSeek, Inc.) 290 | - `deekseek-chat` (default) 291 | - `deepseek-reasoner` 292 | 293 | ### GitHub (Free to use models) - rate limited 294 | - `gpt-4o` (default) 295 | - `Llama-3.3-70B-Instruct` 296 | - `DeepSeek-R1` 297 | - `etc - Check for more here` [https://github.com/marketplace?type=models](https://github.com/marketplace?type=models) 298 | 299 | To run GitHub models you may need the following: 300 | > To authenticate with the model you will need to generate a personal access token (PAT) in your GitHub settings. Create your PAT token by following instructions here: https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens 301 | 302 | ### Ollama (self-hosted) 303 | - Check Ollama models here: [https://ollama.com/search](https://ollama.com/search) 304 | 305 | ### Free (LLM7.io) - No API Key Required! 306 | A completely free option powered by [LLM7.io](https://llm7.io). No signup, no API key needed - just use it! 307 | 308 | **Anonymous tier limits:** 309 | - 8k chars per request 310 | - 60 requests per hour 311 | - 10 requests per minute 312 | - 1 request per second 313 | 314 | > **Note:** The free model is powered by a third-party service. Your code diffs will be sent to LLM7.io servers. Service availability is not guaranteed. For production use, consider a paid API provider. 315 | 316 | ## How It Works 317 | 318 | Git Whisper: 319 | 1. Checks if you have staged changes in your repository 320 | 2. Retrieves the diff of your staged changes 321 | 3. Sends the diff to the selected AI model 322 | 4. Generates a commit message following the conventional commit format with emojis 323 | 5. **Shows you an interactive confirmation** - review, edit, retry, or discard the message 324 | 6. Applies any prefix/ticket number if specified 325 | 7. Submits the commit with the confirmed message 326 | 327 | ## Language Support 328 | 329 | GitWhisper supports generating commit messages and analysis in multiple languages: 330 | 331 | - **English** (default) 332 | - **Spanish** 333 | - **French** 334 | - **German** 335 | - **Chinese (Simplified & Traditional)** 336 | - **Japanese** 337 | - **Korean** 338 | - **Arabic** 339 | - **Italian** 340 | - **Portuguese** 341 | - **Russian** 342 | - **Dutch** 343 | - **Swedish** 344 | - **Norwegian** 345 | - **Danish** 346 | - **Finnish** 347 | - **Greek** 348 | - **Turkish** 349 | - **Hindi** 350 | - **Shona** 351 | - **Zulu** 352 | 353 | ### Language Behavior 354 | 355 | When using non-English languages: 356 | - **Commit messages**: The commit type (e.g., `feat:`, `fix:`) and emoji remain in English for tool compatibility, while the description is generated in your selected language 357 | - **Analysis**: The entire analysis response is provided in your selected language 358 | 359 | Example commit message in Spanish: 360 | ``` 361 | feat: ✨ Agregar funcionalidad de modo oscuro 362 | ``` 363 | 364 | Use the `change-language` command to set your preferred language: 365 | ```bash 366 | gitwhisper change-language 367 | gw change-language 368 | ``` 369 | 370 | ## Configuration 371 | 372 | Configuration is stored in `~/.git_whisper.yaml` and typically contains your saved API keys and language preference: 373 | 374 | ```yaml 375 | api_keys: 376 | claude: "your-claude-key" 377 | openai: "your-openai-key" 378 | # ... 379 | whisper_language: english 380 | ``` 381 | 382 | ## Requirements 383 | 384 | - Dart SDK (^3.5.0) 385 | - Git installed and available in your PATH 386 | 387 | ## Conventional Commit Format 388 | Git Whisper generates commit messages following the **conventional commit format** with emojis: `fix: 🐛 Fix login validation` 389 | 390 | ### With Prefix 391 | If a prefix (e.g., a ticket number or task ID) is provided, Git Whisper intelligently formats it based on the number of commit messages: 392 | 393 | - For a **single commit message**, the prefix appears **after the emoji**: 394 | 395 | `fix: 🐛 PREFIX-123 -> Fix login validation` 396 | 397 | - For **multiple unrelated commit messages**, the prefix appears in **bold at the top**, and each message starts with an arrow after the emoji: 398 | 399 | ``` 400 | PREFIX-123 401 | feat: ✨ Add dark mode toggle 402 | fix: 🐛 Resolve token refresh bug 403 | ``` 404 | 405 | This ensures your commits are always clean, readable, and traceable. 406 | 407 | ### Common Commit Types and Emojis 408 | 409 | | Type | Emoji | Description | 410 | |------------|-------|--------------------------------------------------| 411 | | `feat` | ✨ | New feature | 412 | | `fix` | 🐛 | Bug fix | 413 | | `docs` | 📚 | Documentation changes | 414 | | `style` | 💄 | Code style changes (formatting, whitespace, etc.)| 415 | | `refactor` | ♻️ | Code refactoring (no new features or fixes) | 416 | | `test` | 🧪 | Adding or updating tests | 417 | | `chore` | 🔧 | Build process or auxiliary tool changes | 418 | | `perf` | ⚡ | Performance improvements | 419 | | `ci` | 👷 | Continuous Integration/Deployment changes | 420 | | `build` | 📦 | Build system or dependency changes | 421 | | `revert` | ⏪ | Revert a previous commit | 422 | 423 | ## Contributing 424 | 425 | Contributions are welcome! Please feel free to submit a Pull Request. 426 | 427 | 1. Fork the repository 428 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`) 429 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`) 430 | 4. Push to the branch (`git push origin feature/amazing-feature`) 431 | 5. Open a Pull Request 432 | 433 | ## License 434 | 435 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 436 | 437 | --- 438 | 439 | [coverage_badge]: coverage_badge.svg 440 | [license_badge]: https://img.shields.io/badge/license-MIT-blue.svg 441 | [license_link]: https://opensource.org/licenses/MIT 442 | [very_good_analysis_badge]: https://img.shields.io/badge/style-very_good_analysis-B22C89.svg 443 | [very_good_analysis_link]: https://pub.dev/packages/very_good_analysis 444 | [very_good_cli_link]: https://github.com/VeryGoodOpenSource/very_good_cli 445 | -------------------------------------------------------------------------------- /lib/src/commands/commit_command.dart: -------------------------------------------------------------------------------- 1 | // 2 | // gitwhisper 3 | // commit_command.dart 4 | // 5 | // Created by Ngonidzashe Mangudya on 2025/03/01. 6 | // Copyright (c) 2025 Codecraft Solutions. All rights reserved. 7 | // 8 | 9 | import 'dart:io'; 10 | 11 | import 'package:args/command_runner.dart'; 12 | import 'package:mason_logger/mason_logger.dart'; 13 | import 'package:path/path.dart' as path; 14 | 15 | import '../config_manager.dart'; 16 | import '../exceptions/exceptions.dart'; 17 | import '../git_utils.dart'; 18 | import '../models/commit_generator.dart'; 19 | import '../models/commit_generator_factory.dart'; 20 | import '../models/language.dart'; 21 | 22 | class CommitCommand extends Command { 23 | CommitCommand({ 24 | required Logger logger, 25 | }) : _logger = logger { 26 | argParser 27 | ..addOption( 28 | 'model', 29 | abbr: 'm', 30 | help: 'AI model to use', 31 | allowed: [ 32 | 'claude', 33 | 'openai', 34 | 'gemini', 35 | 'grok', 36 | 'llama', 37 | 'deepseek', 38 | 'github', 39 | 'ollama', 40 | 'free', 41 | ], 42 | allowedHelp: { 43 | 'claude': 'Anthropic Claude', 44 | 'openai': 'OpenAI GPT models', 45 | 'gemini': 'Google Gemini', 46 | 'grok': 'xAI Grok', 47 | 'llama': 'Meta Llama', 48 | 'deepseek': 'DeepSeek, Inc.', 49 | 'github': 'Github', 50 | 'ollama': 'Ollama', 51 | 'free': 'Free (LLM7.io) - No API key required', 52 | }, 53 | ) 54 | ..addOption( 55 | 'key', 56 | abbr: 'k', 57 | help: 'API key for the selected model', 58 | ) 59 | ..addOption( 60 | 'prefix', 61 | abbr: 'p', 62 | help: 'Prefix to add to commit message (e.g., JIRA ticket number)', 63 | valueHelp: 'PREFIX-123', 64 | ) 65 | ..addOption( 66 | 'model-variant', 67 | abbr: 'v', 68 | help: 'Specific variant of the AI model to use', 69 | valueHelp: 'gpt-4o, claude-3-opus, gemini-pro, etc.', 70 | ) 71 | ..addFlag( 72 | 'auto-push', 73 | abbr: 'a', 74 | help: 'Automatically push the commit to the remote repository', 75 | ) 76 | ..addFlag( 77 | 'confirm', 78 | abbr: 'c', 79 | help: 'Confirm commit message before applying', 80 | negatable: true, 81 | ) 82 | ..addOption( 83 | 'tag', 84 | abbr: 't', 85 | help: 'Create a git tag for this commit', 86 | valueHelp: 'v1.0.0', 87 | ); 88 | } 89 | 90 | @override 91 | String get description => 'Generate a commit message based on staged changes'; 92 | 93 | @override 94 | String get name => 'commit'; 95 | 96 | final Logger _logger; 97 | 98 | /// Runs the commit process, handling both single and multi-repo scenarios. 99 | /// Returns an appropriate exit code. 100 | @override 101 | Future run() async { 102 | // Initialize config manager 103 | final configManager = ConfigManager(); 104 | await configManager.load(); 105 | 106 | List? subGitRepos; 107 | 108 | // Check if we're in a git repository 109 | if (!await GitUtils.isGitRepository()) { 110 | _logger.warn('Not a git repository. Checking subfolders...'); 111 | 112 | subGitRepos = await GitUtils.findGitReposInSubfolders(); 113 | if (subGitRepos.isEmpty) { 114 | _logger.err( 115 | 'No git repository found in subfolders. Please run in a git repository, ' 116 | 'or initialize one with `git init`.', 117 | ); 118 | return ExitCode.usage.code; 119 | } 120 | } 121 | 122 | final bool hasSubGitRepos = subGitRepos != null; 123 | 124 | if (hasSubGitRepos) { 125 | final String response = _logger.chooseOne( 126 | 'GitWhisper has discovered git repositories in subfolders but not in this' 127 | ' current folder, would you like to continue?', 128 | choices: ['continue', 'abort'], 129 | defaultValue: 'continue', 130 | ); 131 | 132 | if (response == 'abort') { 133 | return ExitCode.usage.code; 134 | } 135 | } 136 | 137 | final bool hasStagedChanges = !hasSubGitRepos 138 | ? await GitUtils.hasStagedChanges() 139 | : (await GitUtils.foldersWithStagedChanges(subGitRepos)).isNotEmpty; 140 | 141 | // Check if there are staged changes 142 | if (!hasStagedChanges) { 143 | // Check if we should always add unstaged files 144 | if (configManager.shouldAlwaysAdd()) { 145 | // Check for unstaged changes 146 | final hasUnstagedChanges = !hasSubGitRepos 147 | ? await GitUtils.hasUnstagedChanges() 148 | : (await GitUtils.foldersWithUnstagedChanges(subGitRepos)) 149 | .isNotEmpty; 150 | 151 | if (hasUnstagedChanges) { 152 | _logger.info( 153 | 'Unstaged changes found. Staging all changes and new files...'); 154 | if (!hasSubGitRepos) { 155 | final int stagedFiles = 156 | await GitUtils.stageAllUnstagedFilesAndCount(); 157 | _logger.success('$stagedFiles files have been staged.'); 158 | } else { 159 | final List foldersWithUnstagedChanges = 160 | await GitUtils.foldersWithUnstagedChanges(subGitRepos); 161 | for (final f in foldersWithUnstagedChanges) { 162 | final int stagedFiles = 163 | await GitUtils.stageAllUnstagedFilesAndCount(folderPath: f); 164 | final folderName = path.basename(f); 165 | _logger.success( 166 | '[$folderName] $stagedFiles files have been staged.'); 167 | } 168 | } 169 | } else { 170 | _logger.err('No staged or unstaged changes found!'); 171 | return ExitCode.usage.code; 172 | } 173 | } else { 174 | // Check for unstaged or untracked changes 175 | final hasUnstagedChanges = !hasSubGitRepos 176 | ? await GitUtils.hasUnstagedChanges() 177 | : (await GitUtils.foldersWithUnstagedChanges(subGitRepos)) 178 | .isNotEmpty; 179 | 180 | final hasUntrackedFiles = !hasSubGitRepos 181 | ? await GitUtils.hasUntrackedFiles() 182 | : (await GitUtils.foldersWithUntrackedFiles(subGitRepos)) 183 | .isNotEmpty; 184 | 185 | if (hasUnstagedChanges || hasUntrackedFiles) { 186 | final String response = _logger.chooseOne( 187 | 'No staged changes found, but there are ${hasUnstagedChanges ? 'unstaged changes' : ''}${hasUnstagedChanges && hasUntrackedFiles ? ' and ' : ''}${hasUntrackedFiles ? 'untracked files' : ''}. Would you like to stage them and continue?', 188 | choices: ['yes', 'no'], 189 | defaultValue: 'yes', 190 | ); 191 | 192 | if (response == 'yes') { 193 | _logger.info('Staging all changes and new files...'); 194 | if (!hasSubGitRepos) { 195 | final int stagedFiles = 196 | await GitUtils.stageAllUnstagedFilesAndCount(); 197 | _logger.success('$stagedFiles files have been staged.'); 198 | } else { 199 | final List foldersWithChanges = []; 200 | if (hasUnstagedChanges) { 201 | foldersWithChanges.addAll( 202 | await GitUtils.foldersWithUnstagedChanges(subGitRepos)); 203 | } 204 | if (hasUntrackedFiles) { 205 | foldersWithChanges.addAll( 206 | await GitUtils.foldersWithUntrackedFiles(subGitRepos)); 207 | } 208 | // Remove duplicates 209 | final uniqueFolders = foldersWithChanges.toSet().toList(); 210 | 211 | for (final f in uniqueFolders) { 212 | final int stagedFiles = 213 | await GitUtils.stageAllUnstagedFilesAndCount(folderPath: f); 214 | final folderName = path.basename(f); 215 | _logger.success( 216 | '[$folderName] $stagedFiles files have been staged.'); 217 | } 218 | } 219 | } else { 220 | return ExitCode.usage.code; 221 | } 222 | } else { 223 | _logger.err('No staged, unstaged, or untracked changes found!'); 224 | return ExitCode.usage.code; 225 | } 226 | } 227 | } 228 | 229 | // Get the model name and variant from args, config, or defaults 230 | String? modelName = argResults?['model'] as String?; 231 | String? modelVariant = argResults?['model-variant'] as String? ?? ''; 232 | 233 | // If modelName is not provided, use config or fallback to openai 234 | if (modelName == null) { 235 | final (String, String)? defaults = 236 | configManager.getDefaultModelAndVariant(); 237 | if (defaults != null) { 238 | modelName = defaults.$1; 239 | modelVariant = defaults.$2; 240 | } else { 241 | modelName = 'openai'; 242 | modelVariant = ''; 243 | } 244 | } 245 | 246 | // Get API key (from args, config, or environment) 247 | var apiKey = argResults?['key'] as String?; 248 | apiKey ??= 249 | configManager.getApiKey(modelName) ?? _getEnvironmentApiKey(modelName); 250 | 251 | if ((apiKey == null || apiKey.isEmpty) && 252 | modelName != 'ollama' && 253 | modelName != 'free') { 254 | _logger.err( 255 | 'No API key provided for $modelName. Please provide an API key using --key.', 256 | ); 257 | return ExitCode.usage.code; 258 | } 259 | 260 | // Show disclaimer for free model on first use 261 | if (modelName == 'free' && !configManager.hasAcceptedFreeDisclaimer()) { 262 | _logger 263 | ..info('') 264 | ..info( 265 | '┌─────────────────────────────────────────────────────────────┐') 266 | ..info( 267 | '│ FREE MODEL DISCLAIMER │') 268 | ..info( 269 | '├─────────────────────────────────────────────────────────────┤') 270 | ..info( 271 | '│ This free model is powered by LLM7.io - a third-party │') 272 | ..info( 273 | '│ service providing free, anonymous access to AI models. │') 274 | ..info( 275 | '│ │') 276 | ..info( 277 | '│ Anonymous tier limits: │') 278 | ..info( 279 | '│ • 8k chars per request │') 280 | ..info( 281 | '│ • 60 requests/hour, 10 requests/min, 1 request/sec │') 282 | ..info( 283 | '│ │') 284 | ..info( 285 | '│ Please note: │') 286 | ..info( 287 | '│ • Your code diffs will be sent to LLM7.io servers │') 288 | ..info( 289 | '│ • Service availability is not guaranteed │') 290 | ..info( 291 | '│ • For production use, consider a paid API provider │') 292 | ..info( 293 | '│ │') 294 | ..info( 295 | '│ Learn more: https://llm7.io │') 296 | ..info( 297 | '└─────────────────────────────────────────────────────────────┘') 298 | ..info(''); 299 | 300 | final response = _logger.chooseOne( 301 | 'Do you accept these terms and wish to continue?', 302 | choices: ['yes', 'no'], 303 | defaultValue: 'yes', 304 | ); 305 | 306 | if (response == 'no') { 307 | _logger.info('Free model usage cancelled.'); 308 | return ExitCode.usage.code; 309 | } 310 | 311 | // Save acceptance so we don't show again 312 | configManager.setFreeDisclaimerAccepted(); 313 | await configManager.save(); 314 | _logger.success('Disclaimer accepted. You won\'t see this again.'); 315 | } 316 | 317 | // Get prefix if available for things like ticket numbers 318 | final prefix = argResults?['prefix'] as String?; 319 | 320 | // Get tag if provided 321 | final tag = argResults?['tag'] as String?; 322 | 323 | // Handle --auto-push, fallback to false if not provided 324 | final autoPush = (argResults?['auto-push'] as bool?) ?? false; 325 | 326 | // Handle --confirm, fallback to global config, then false 327 | // Check if --confirm or --no-confirm was explicitly provided 328 | final confirm = argResults?.wasParsed('confirm') == true 329 | ? (argResults?['confirm'] as bool) 330 | : configManager.shouldConfirmCommits(); 331 | 332 | // Get ollamaBaseUrl from configs 333 | final String? ollamaBaseUrl = configManager.getOllamaBaseURL(); 334 | 335 | // Create the appropriate AI generator based on model name 336 | final generator = CommitGeneratorFactory.create( 337 | modelName, 338 | apiKey, 339 | variant: modelVariant, 340 | baseUrl: ollamaBaseUrl ?? 'http://localhost:11434', 341 | ); 342 | 343 | // Get the language to use for commit messages 344 | final language = configManager.getWhisperLanguage(); 345 | 346 | // Get emoji setting 347 | final withEmoji = configManager.shouldAllowEmojis(); 348 | 349 | // --- Single repo flow --- 350 | if (!hasSubGitRepos) { 351 | final diff = await GitUtils.getStagedDiff(); 352 | if (diff.isEmpty) { 353 | _logger.err('No changes detected in staged files.'); 354 | return ExitCode.usage.code; 355 | } 356 | 357 | try { 358 | _logger.info('Analyzing staged changes using $modelName' 359 | '${modelVariant.isNotEmpty ? ' ($modelVariant)' : ''}' 360 | '${prefix != null ? ' for ticket $prefix' : ''}...'); 361 | 362 | // Generate commit message with AI 363 | String commitMessage = await generator.generateCommitMessage( 364 | diff, 365 | language, 366 | prefix: prefix, 367 | withEmoji: withEmoji, 368 | ); 369 | 370 | try { 371 | commitMessage = GitUtils.stripMarkdownCodeBlocks(commitMessage); 372 | } catch (_) { 373 | // Silent prayer that it works 374 | } 375 | 376 | if (commitMessage.trim().isEmpty) { 377 | _logger.err('Error: Generated commit message is empty!'); 378 | return ExitCode.software.code; 379 | } 380 | 381 | // Handle confirmation workflow if enabled 382 | if (confirm) { 383 | final finalMessage = await _handleCommitConfirmation( 384 | commitMessage: commitMessage, 385 | generator: generator, 386 | diff: diff, 387 | language: language, 388 | prefix: prefix, 389 | modelName: modelName, 390 | modelVariant: modelVariant, 391 | ollamaBaseUrl: ollamaBaseUrl, 392 | configManager: configManager, 393 | withEmoji: withEmoji, 394 | ); 395 | 396 | if (finalMessage == null) { 397 | // User cancelled 398 | return ExitCode.usage.code; 399 | } 400 | 401 | commitMessage = finalMessage; 402 | } else { 403 | // Show message without confirmation 404 | _logger 405 | ..info('\n---------------------------------\n') 406 | ..info(commitMessage) 407 | ..info('\n---------------------------------\n'); 408 | } 409 | 410 | try { 411 | await GitUtils.runGitCommit( 412 | message: commitMessage, 413 | autoPush: autoPush, 414 | tag: tag, 415 | ); 416 | } catch (e) { 417 | _logger.err('Error setting commit message: $e'); 418 | return ExitCode.software.code; 419 | } 420 | 421 | return ExitCode.success.code; 422 | } on ApiException catch (e) { 423 | ErrorHandler.handleErrorWithRetry( 424 | e, 425 | context: 'generating commit message', 426 | ); 427 | 428 | if (ErrorHandler.shouldSuggestModelSwitch(e)) { 429 | final suggestions = ErrorHandler.getModelSwitchSuggestions(e); 430 | ErrorHandler.handleErrorWithFallback( 431 | e, 432 | fallbackOptions: suggestions, 433 | ); 434 | } 435 | 436 | return ExitCode.software.code; 437 | } catch (e) { 438 | ErrorHandler.handleGeneralError( 439 | e as Exception, 440 | context: 'generating commit message', 441 | ); 442 | return ExitCode.software.code; 443 | } 444 | } else { 445 | // --- Multi-repo flow --- 446 | final foldersWithStagedChanges = 447 | await GitUtils.foldersWithStagedChanges(subGitRepos); 448 | _logger.info('Working in ${foldersWithStagedChanges.length} git repos'); 449 | 450 | int successCount = 0; 451 | final List failedRepos = []; 452 | 453 | for (final f in foldersWithStagedChanges) { 454 | final folderName = path.basename(f); 455 | final diff = await GitUtils.getStagedDiff(folderPath: f); 456 | if (diff.isEmpty) { 457 | _logger.warn( 458 | '[$folderName] No changes detected in staged files, skipping.'); 459 | continue; 460 | } 461 | 462 | try { 463 | _logger.info('[$folderName] Analyzing staged changes using $modelName' 464 | '${modelVariant.isNotEmpty ? ' ($modelVariant)' : ''}' 465 | '${prefix != null ? ' for ticket $prefix' : ''}...'); 466 | 467 | // Generate commit message with AI 468 | String commitMessage = await generator.generateCommitMessage( 469 | diff, 470 | language, 471 | prefix: prefix, 472 | withEmoji: withEmoji, 473 | ); 474 | 475 | try { 476 | commitMessage = GitUtils.stripMarkdownCodeBlocks(commitMessage); 477 | } catch (_) { 478 | // Silent prayer that it works 479 | } 480 | 481 | if (commitMessage.trim().isEmpty) { 482 | _logger 483 | .err('[$folderName] Error: Generated commit message is empty'); 484 | failedRepos.add(folderName); 485 | continue; 486 | } 487 | 488 | // Handle confirmation workflow if enabled 489 | if (confirm) { 490 | _logger.info('[$folderName] Review commit message:'); 491 | final finalMessage = await _handleCommitConfirmation( 492 | commitMessage: commitMessage, 493 | generator: generator, 494 | diff: diff, 495 | language: language, 496 | prefix: prefix, 497 | modelName: modelName, 498 | modelVariant: modelVariant, 499 | ollamaBaseUrl: ollamaBaseUrl, 500 | configManager: configManager, 501 | withEmoji: withEmoji, 502 | ); 503 | 504 | if (finalMessage == null) { 505 | // User cancelled this repo 506 | _logger.warn('[$folderName] Commit cancelled by user.'); 507 | failedRepos.add(folderName); 508 | continue; 509 | } 510 | 511 | commitMessage = finalMessage; 512 | } else { 513 | // Show message without confirmation 514 | _logger 515 | ..info('\n----------- $folderName -----------\n') 516 | ..info(commitMessage) 517 | ..info('\n-----------------------------------\n'); 518 | } 519 | 520 | try { 521 | await GitUtils.runGitCommit( 522 | message: commitMessage, 523 | autoPush: autoPush, 524 | folderPath: f, 525 | tag: tag, 526 | ); 527 | successCount++; 528 | } catch (e) { 529 | _logger.err('[$folderName] Error setting commit message: $e'); 530 | failedRepos.add(folderName); 531 | continue; 532 | } 533 | } on ApiException catch (e) { 534 | _logger.err('[$folderName] ${ErrorHandler.getErrorSummary(e)}'); 535 | failedRepos.add(folderName); 536 | continue; 537 | } catch (e) { 538 | _logger.err('[$folderName] Error generating commit message: $e'); 539 | failedRepos.add(folderName); 540 | continue; 541 | } 542 | } 543 | 544 | if (failedRepos.isNotEmpty) { 545 | _logger.err('Failed in: ${failedRepos.join(', ')}'); 546 | } 547 | 548 | if (successCount == 1) { 549 | _logger.success('Processed 1 git repository.'); 550 | } else { 551 | _logger.success('Processed $successCount git repositories.'); 552 | } 553 | 554 | return failedRepos.isEmpty 555 | ? ExitCode.success.code 556 | : ExitCode.software.code; 557 | } 558 | } 559 | 560 | String? _getEnvironmentApiKey(String modelName) { 561 | return switch (modelName.toLowerCase()) { 562 | 'claude' => Platform.environment['ANTHROPIC_API_KEY'], 563 | 'openai' => Platform.environment['OPENAI_API_KEY'], 564 | 'gemini' => Platform.environment['GEMINI_API_KEY'], 565 | 'grok' => Platform.environment['GROK_API_KEY'], 566 | 'llama' => Platform.environment['LLAMA_API_KEY'], 567 | 'deepseek' => Platform.environment['DEEPSEEK_API_KEY'], 568 | _ => null, 569 | }; 570 | } 571 | 572 | /// Handles the confirmation workflow for commit messages 573 | /// Returns the final commit message to use, or null if user cancelled 574 | Future _handleCommitConfirmation({ 575 | required String commitMessage, 576 | required CommitGenerator generator, 577 | required String diff, 578 | required Language language, 579 | required String? prefix, 580 | required String modelName, 581 | required String modelVariant, 582 | required String? ollamaBaseUrl, 583 | required ConfigManager configManager, 584 | required bool withEmoji, 585 | }) async { 586 | String currentMessage = commitMessage; 587 | 588 | while (true) { 589 | _logger 590 | ..info('\n---------------------------------\n') 591 | ..info(currentMessage) 592 | ..info('\n---------------------------------\n'); 593 | 594 | final action = _logger.chooseOne( 595 | 'What would you like to do with this commit message?', 596 | choices: ['commit', 'edit', 'retry', 'discard'], 597 | defaultValue: 'commit', 598 | ); 599 | 600 | switch (action) { 601 | case 'commit': 602 | return currentMessage; 603 | 604 | case 'edit': 605 | _logger.info('Opening editor...'); 606 | final editedMessage = await GitUtils.openGitEditor(currentMessage); 607 | if (editedMessage != null && editedMessage.trim().isNotEmpty) { 608 | currentMessage = editedMessage; 609 | continue; 610 | } else { 611 | _logger.warn( 612 | 'Empty commit message or editor cancelled, returning to options...'); 613 | continue; 614 | } 615 | 616 | case 'retry': 617 | final retryOption = _logger.chooseOne( 618 | 'How would you like to retry?', 619 | choices: ['same model', 'different model', 'add context'], 620 | defaultValue: 'same model', 621 | ); 622 | 623 | switch (retryOption) { 624 | case 'same model': 625 | _logger.info('Regenerating with $modelName...'); 626 | try { 627 | currentMessage = await generator.generateCommitMessage( 628 | diff, 629 | language, 630 | prefix: prefix, 631 | withEmoji: withEmoji, 632 | ); 633 | currentMessage = 634 | GitUtils.stripMarkdownCodeBlocks(currentMessage); 635 | } catch (e) { 636 | _logger.err('Failed to regenerate commit message: $e'); 637 | continue; 638 | } 639 | break; 640 | 641 | case 'different model': 642 | final newModelName = _logger.chooseOne( 643 | 'Select a different model:', 644 | choices: [ 645 | 'claude', 646 | 'openai', 647 | 'gemini', 648 | 'grok', 649 | 'llama', 650 | 'deepseek', 651 | 'github', 652 | 'ollama', 653 | ], 654 | defaultValue: modelName == 'openai' ? 'claude' : 'openai', 655 | ); 656 | 657 | // Get API key for new model 658 | var newApiKey = configManager.getApiKey(newModelName) ?? 659 | _getEnvironmentApiKey(newModelName); 660 | 661 | if ((newApiKey == null || newApiKey.isEmpty) && 662 | newModelName != 'ollama') { 663 | _logger.err( 664 | 'No API key found for $newModelName. Please save one using "gw save-key".'); 665 | continue; 666 | } 667 | 668 | // Create new generator 669 | final newGenerator = CommitGeneratorFactory.create( 670 | newModelName, 671 | newApiKey, 672 | baseUrl: ollamaBaseUrl ?? 'http://localhost:11434', 673 | ); 674 | 675 | _logger.info('Regenerating with $newModelName...'); 676 | try { 677 | currentMessage = await newGenerator.generateCommitMessage( 678 | diff, 679 | language, 680 | prefix: prefix, 681 | withEmoji: withEmoji, 682 | ); 683 | currentMessage = 684 | GitUtils.stripMarkdownCodeBlocks(currentMessage); 685 | } catch (e) { 686 | _logger.err( 687 | 'Failed to generate commit message with $newModelName: $e'); 688 | continue; 689 | } 690 | break; 691 | 692 | case 'add context': 693 | final context = _logger.prompt( 694 | 'Add context or instructions for the AI (e.g., "make it more technical", "focus on performance"):', 695 | ); 696 | 697 | if (context.trim().isEmpty) { 698 | _logger.warn('No context provided, using same model...'); 699 | } 700 | 701 | _logger.info('Regenerating with additional context...'); 702 | try { 703 | // For now, we'll just regenerate - in the future, we could modify the prompt 704 | currentMessage = await generator.generateCommitMessage( 705 | diff, 706 | language, 707 | prefix: prefix, 708 | withEmoji: withEmoji, 709 | ); 710 | currentMessage = 711 | GitUtils.stripMarkdownCodeBlocks(currentMessage); 712 | } catch (e) { 713 | _logger.err('Failed to regenerate commit message: $e'); 714 | continue; 715 | } 716 | break; 717 | } 718 | continue; 719 | 720 | case 'discard': 721 | _logger.info('Commit cancelled.'); 722 | return null; 723 | } 724 | } 725 | } 726 | } 727 | --------------------------------------------------------------------------------