6 Commits
Author SHA1 Message Date
unclesam 49867987ab docs: 在全语言文档中补充 --reviewer-prompt 与 --commit-msg-prompt 配置命令 2026-08-23 22:39:42 +08:00
unclesam 35193909e8 docs: 在 README_JA.md 中补充プロンプト移行说明 2026-08-23 22:38:45 +08:00
unclesam 5a2c95f72b docs: 完善敏感信息脱敏、JSON结构化输出与Hook备份恢复的说明 2026-08-23 22:37:29 +08:00
unclesam b04d70b99a feat: 拆分评审与提交消息提示词并增加 hook 备份与 JSON 输出
- 新增 custom_reviewer_prompt 和 custom_commit_msg_prompt,兼容旧 custom_prompt
- pre-commit hook 安装时备份已有 hook,卸载时恢复
- 支持 --format json 输出评审结果
- diff 发送前自动脱敏敏感信息
2026-08-23 22:31:30 +08:00
unclesam 39c58316a5 Merge remote-tracking branch 'origin/main'
# Conflicts:
#	internal/llm/model_test.go
2026-08-22 21:30:34 +08:00
unclesam 16618303ed fix(llm): 移除测试用 token 限制,保留完整的推理模型 temperature 兼容与 Usage 空指针修复 2026-08-22 21:30:15 +08:00
19 changed files with 684 additions and 106 deletions

No files matched your search

+42 -3
View File
@@ -46,12 +46,13 @@
- ⚡ **Git 原生极简体验**:无需改变任何工作习惯,在命令行敲 `git air` 即可秒级唤起。 - ⚡ **Git 原生极简体验**:无需改变任何工作习惯,在命令行敲 `git air` 即可秒级唤起。
- 🧠 **全模型自由接入**:开箱即用预置 **Google Gemini 3.x、Anthropic Claude、xAI Grok、DeepSeek、阿里通义千问、智谱 GLM、月之暗面 Kimi、本地 Ollama、OpenAI** 等全系列模型。 - 🧠 **全模型自由接入**:开箱即用预置 **Google Gemini 3.x、Anthropic Claude、xAI Grok、DeepSeek、阿里通义千问、智谱 GLM、月之暗面 Kimi、本地 Ollama、OpenAI** 等全系列模型。
- 🔒 **本地敏感信息自动脱敏**:在 Diff 发送给大模型前,自动在本地脱敏私钥证书、API Key、Bearer Token、数据库连接串等敏感数据,零泄露风险。
- 🛡️ **智能降噪过滤**:自动剥离 `go.sum`、`package-lock.json`、`*.pb.go`、`*.gen.go` 等锁文件与自动生成代码,省 Token、防死循环。 - 🛡️ **智能降噪过滤**:自动剥离 `go.sum`、`package-lock.json`、`*.pb.go`、`*.gen.go` 等锁文件与自动生成代码,省 Token、防死循环。
- 🎯 **严谨架构师准则**:直奔主题,聚焦并发竞态、死锁、SQL 注入、空指针、资源泄露,并直接提供修复参考代码。 - 🎯 **严谨架构师准则**:直奔主题,聚焦并发竞态、死锁、SQL 注入、空指针、资源泄露,并直接提供修复参考代码。
- 📊 **Token 消耗与费用实时估算**:实时捕获并统计输入/输出 Token 消耗,内置 2026 最新官方定价表精准核算每次审查成本(本地离线模型自动标注免费)。 - 📊 **Token 消耗与费用实时估算**:实时捕获并统计输入/输出 Token 消耗,内置 2026 最新官方定价表精准核算每次审查成本(本地离线模型自动标注免费)。
- ✨ **AI 智能 Commit Message**:一键根据代码变更生成规范的 Conventional Commits 提交说明,支持**中/英/日/韩**等多语言自适应。 - ✨ **AI 智能 Commit Message**:一键根据代码变更生成规范的 Conventional Commits 提交说明,支持**中/英/日/韩**等多语言自适应。
- 📋 **团队规范适配(`.airules`)**:在项目根目录放置 `.airules`,AI 自动加载团队专属架构规范并最高优先级执行。 - 📋 **团队规范适配(`.airules`)**:在项目根目录放置 `.airules`,AI 自动加载团队专属架构规范并最高优先级执行。
- 🪝 **一键 Pre-commit 钩子**:一行命令挂载 Git 提交前自动拦截,在代码上库前把好第一道质量关。 - 🪝 **一键 Pre-commit 钩子**:一行命令挂载 Git 提交前自动拦截(支持原 Hook 备份与链式调用),在代码上库前把好第一道质量关。
--- ---
@@ -149,6 +150,12 @@ git air config set --price-input 0.75 --price-output 3.75
# 14. 永久设置 Commit Message 默认语言 (可选: auto, zh, en, ja, ko) # 14. 永久设置 Commit Message 默认语言 (可选: auto, zh, en, ja, ko)
git air config set --commit-lang en git air config set --commit-lang en
# 15. 配置专属代码审查自定义提示词 (可选)
git air config set --reviewer-prompt "你是一位严谨的 Go 资深专家,重点关注并发与内存"
# 16. 配置专属 Commit Message 自定义提示词 (可选)
git air config set --commit-msg-prompt "生成提交信息时,严格使用 Angular 规范"
``` ```
查看当前配置状态(含自定义费率与脱敏 Key): 查看当前配置状态(含自定义费率与脱敏 Key):
@@ -175,6 +182,9 @@ git air internal/service/chat.go
# 临时指定模型或 Key 进行评审 # 临时指定模型或 Key 进行评审
git air --provider deepseek --model deepseek-chat git air --provider deepseek --model deepseek-chat
# 输出结构化 JSON 报告(便于 CI/CD 自动化流水线或脚本消费)
git air --format json
``` ```
--- ---
@@ -227,10 +237,11 @@ feat(auth): 引入 Redis 缓存并修复 Mutex 死锁隐患
git air hook install git air hook install
``` ```
- **自动门禁阻断**:当审查发现 `[BLOCKER]` 严重缺陷或 `[REJECT]` 评审结论时,`git-air` 会自动以非零退出码**直接拦截并阻断本次 `git commit`**,死守代码质量底线! - **自动门禁阻断**:当审查发现 `[BLOCKER]` 严重缺陷或 `[REJECT]` 评审结论时,`git-air` 会自动以非零退出码**直接拦截并阻断本次 `git commit`**,死守代码质量底线!
- **原 Hook 链式执行**:若仓库原本已有 `pre-commit`,`git-air` 会自动安全备份并在审查放行后**链式调用原有 Hook**,绝不破坏现有工程配置!
- **严格模式 (`--strict`)**:若希望发现 `[WARNING]` 也强制阻断提交,可使用 `git air --strict`; - **严格模式 (`--strict`)**:若希望发现 `[WARNING]` 也强制阻断提交,可使用 `git air --strict`;
- **临时跳过阻断**:如确需强行提交,可使用 Git 原生命令 `git commit --no-verify` 或附加 `git-air --no-block`。 - **临时跳过阻断**:如确需强行提交,可使用 Git 原生命令 `git commit --no-verify` 或附加 `git-air --no-block`。
如需卸载钩子: 如需卸载钩子并完全恢复原 Hook:
```bash ```bash
git air hook uninstall git air hook uninstall
``` ```
@@ -274,6 +285,9 @@ model: "gemini-3.7-flash"
# base_url: "https://api.deepseek.com/v1" # base_url: "https://api.deepseek.com/v1"
# 自定义基础提示词 (可选,留空则使用默认资深架构师准则) # 自定义基础提示词 (可选,留空则使用默认资深架构师准则)
# custom_reviewer_prompt: ""
# custom_commit_msg_prompt: ""
# 旧版兼容字段(v1.x 仍可使用)
# custom_prompt: "" # custom_prompt: ""
# 自定义模型单价 (可选,单位: 美元/1M Tokens) # 自定义模型单价 (可选,单位: 美元/1M Tokens)
@@ -284,6 +298,32 @@ model: "gemini-3.7-flash"
# commit_lang: "auto" # commit_lang: "auto"
``` ```
### 提示词字段迁移说明
旧版本只有一个通用的 `custom_prompt` 字段。随着 `git-air` 同时支持代码 Review 和 AI Commit Message,两个场景的目标不同,继续共用一个字段容易造成提示词混用和行为不明确。
因此从当前版本开始,建议分别使用:
```yaml
custom_reviewer_prompt: "用于代码 Review 的提示词"
custom_commit_msg_prompt: "用于生成 Commit Message 的提示词"
```
`custom_prompt` 暂时仍然兼容,并会作为 `custom_reviewer_prompt` 使用。迁移方式:
```bash
git air config set --reviewer-prompt "你的 Reviewer Prompt"
git air config set --commit-msg-prompt "你的 Commit Message Prompt"
```
生命周期安排:
- `v1.x`:继续支持 `custom_prompt`;
- `v1.1.0`:标记为 deprecated,并提示用户迁移;
- `v2.0.0`:移除 `custom_prompt` 字段。
这是为了避免破坏现有用户配置,同时让两个 Prompt 的职责更加清晰。
--- ---
## 📋 团队规则定制(`.airules`) ## 📋 团队规则定制(`.airules`)
@@ -323,4 +363,3 @@ secrets.yaml
## 📄 开源许可证 ## 📄 开源许可证
本项目采用 [MIT 许可证](LICENSE)。欢迎提交 Issue 与 Pull Request! 本项目采用 [MIT 许可证](LICENSE)。欢迎提交 Issue 与 Pull Request!
+44 -3
View File
@@ -45,12 +45,13 @@ Completely isolated from your chat history, it acts like a **grumpy but sharp se
- ⚡ **Git-Native Simplicity**: No workflow changes needed. Just run `git air` in your terminal for instant feedback. - ⚡ **Git-Native Simplicity**: No workflow changes needed. Just run `git air` in your terminal for instant feedback.
- 🧠 **Universal Multi-Model Support**: Pre-configured support for **Google Gemini 3.x, Anthropic Claude, xAI Grok, DeepSeek, Qwen, Zhipu GLM, Moonshot Kimi, Local Ollama, OpenAI**, and any OpenAI-compatible endpoint. - 🧠 **Universal Multi-Model Support**: Pre-configured support for **Google Gemini 3.x, Anthropic Claude, xAI Grok, DeepSeek, Qwen, Zhipu GLM, Moonshot Kimi, Local Ollama, OpenAI**, and any OpenAI-compatible endpoint.
- 🔒 **Local Secret Redaction**: Automatically masks private keys, API keys, Bearer tokens, and database connection strings before sending diffs to the LLM (zero leakage risk).
- 🛡️ **Smart Noise Reduction**: Automatically strips out `go.sum`, `package-lock.json`, `*.pb.go`, and lockfiles to save tokens and eliminate review loops. - 🛡️ **Smart Noise Reduction**: Automatically strips out `go.sum`, `package-lock.json`, `*.pb.go`, and lockfiles to save tokens and eliminate review loops.
- 🎯 **Strict Senior Architect Persona**: Zero corporate fluff. Directly flags concurrency deadlocks, SQL injections, nil dereferences, and resource leaks with copy-paste code patches. - 🎯 **Strict Senior Architect Persona**: Zero corporate fluff. Directly flags concurrency deadlocks, SQL injections, nil dereferences, and resource leaks with copy-paste code patches.
- 📊 **Real-time Token & Cost Estimation**: Accurately tracks prompt/completion token consumption with built-in official pricing rates (local offline models automatically marked free). - 📊 **Real-time Token & Cost Estimation**: Accurately tracks prompt/completion token consumption with built-in official pricing rates (local offline models automatically marked free).
- ✨ **AI Commit Message Generator**: Automatically generates clean, standardized Conventional Commits messages with multi-language support (English, Japanese, Korean, Chinese). - ✨ **AI Commit Message Generator**: Automatically generates clean, standardized Conventional Commits messages with multi-language support (English, Japanese, Korean, Chinese).
- 📋 **Team Rules Support (`.airules`)**: Drop an `.airules` file in your repo root, and the AI will prioritize your team-specific guidelines. - 📋 **Team Rules Support (`.airules`)**: Drop an `.airules` file in your repo root, and the AI will prioritize your team-specific guidelines.
- 🪝 **One-Click Pre-commit Hook**: Mounts as a local Git hook in one command to block dangerous bugs before code is committed. - 🪝 **One-Click Pre-commit Hook**: Mounts as a local Git hook in one command (with automatic backup and chain execution of existing hooks) to block bugs before code is committed.
--- ---
@@ -148,6 +149,12 @@ git air config set --price-input 0.75 --price-output 3.75
# 14. Permanently Set Default Commit Message Language (auto, zh, en, ja, ko) # 14. Permanently Set Default Commit Message Language (auto, zh, en, ja, ko)
git air config set --commit-lang en git air config set --commit-lang en
# 15. Set Dedicated Custom Code Reviewer Prompt (Optional)
git air config set --reviewer-prompt "You are a principal Go architect focusing on concurrency and memory safety"
# 16. Set Dedicated Custom Commit Message Prompt (Optional)
git air config set --commit-msg-prompt "Always follow the strict Angular commit conventions"
``` ```
Check current configuration (including masked keys and custom rates): Check current configuration (including masked keys and custom rates):
@@ -174,6 +181,9 @@ git air internal/service/chat.go
# Temporarily override provider or model # Temporarily override provider or model
git air --provider deepseek --model deepseek-chat git air --provider deepseek --model deepseek-chat
# Output structured JSON report (for CI/CD pipelines and scripts)
git air --format json
``` ```
--- ---
@@ -226,10 +236,11 @@ Install the hook inside any Git repository:
git air hook install git air hook install
``` ```
- **Automated Commit Gate**: When `git-air` flags any `[BLOCKER]` issue or outputs a `[REJECT]` verdict, it automatically exits with a non-zero status code, **directly intercepting and blocking `git commit`**! - **Automated Commit Gate**: When `git-air` flags any `[BLOCKER]` issue or outputs a `[REJECT]` verdict, it automatically exits with a non-zero status code, **directly intercepting and blocking `git commit`**!
- **Chain Execution of Existing Hooks**: If a `pre-commit` hook already exists, `git-air` automatically backs it up and **chains into your original hook** once review passes, without disrupting existing workflows.
- **Strict Mode (`--strict`)**: To block commits even on `[WARNING]` issues, use `git air --strict`; - **Strict Mode (`--strict`)**: To block commits even on `[WARNING]` issues, use `git air --strict`;
- **Bypass Interception**: If you urgently need to bypass the check, use standard Git `git commit --no-verify` or pass `git air --no-block`. - **Bypass Interception**: If you urgently need to bypass the check, use standard Git `git commit --no-verify` or pass `git air --no-block`.
To uninstall: To uninstall and fully restore your original hook:
```bash ```bash
git air hook uninstall git air hook uninstall
``` ```
@@ -269,6 +280,9 @@ model: "gemini-3.7-flash"
# base_url: "https://api.deepseek.com/v1" # base_url: "https://api.deepseek.com/v1"
# Custom System Prompt (Optional) # Custom System Prompt (Optional)
# custom_reviewer_prompt: ""
# custom_commit_msg_prompt: ""
# Legacy compatibility field (supported throughout v1.x)
# custom_prompt: "" # custom_prompt: ""
# Custom Token Pricing (Optional, in USD per 1M tokens) # Custom Token Pricing (Optional, in USD per 1M tokens)
@@ -279,6 +293,34 @@ model: "gemini-3.7-flash"
# commit_lang: "auto" # commit_lang: "auto"
``` ```
### Prompt Field Migration
Older versions provided a single `custom_prompt` field. Since `git-air` supports both code review and AI commit-message generation, sharing one prompt can lead to mixed instructions and unclear behavior.
Use separate fields instead:
```yaml
custom_reviewer_prompt: "Prompt for code review"
custom_commit_msg_prompt: "Prompt for commit message generation"
```
The legacy `custom_prompt` field remains compatible for now and is treated as `custom_reviewer_prompt`.
Migration commands:
```bash
git air config set --reviewer-prompt "Your reviewer prompt"
git air config set --commit-msg-prompt "Your commit message prompt"
```
Deprecation timeline:
- `v1.x`: `custom_prompt` remains supported;
- `v1.1.0`: the field is marked deprecated and migration is recommended;
- `v2.0.0`: `custom_prompt` will be removed.
This keeps existing configurations working while making the two prompt responsibilities explicit.
--- ---
## 📋 Custom Team Rules (`.airules`) ## 📋 Custom Team Rules (`.airules`)
@@ -318,4 +360,3 @@ secrets.yaml
## 📄 License ## 📄 License
This project is licensed under the [MIT License](LICENSE). Contributions, PRs, and issues are welcome! This project is licensed under the [MIT License](LICENSE). Contributions, PRs, and issues are welcome!
+62 -67
View File
@@ -45,12 +45,13 @@ GitHubなどのCI/CD上で動作するボットも優秀ですが、**`git-air`
- ⚡ **Gitネイティブな極上の操作性**:いつもの作業フローを崩さず、ターミナルで `git air` と叩くだけで即座に起動。 - ⚡ **Gitネイティブな極上の操作性**:いつもの作業フローを崩さず、ターミナルで `git air` と叩くだけで即座に起動。
- 🧠 **主要モデルを完全網羅**:**Google Gemini 3.x、Anthropic Claude、xAI Grok、DeepSeek、Qwen、智譜 GLM、Kimi、ローカルOllama、OpenAI** などを設定なしで即座に切り替え可能。 - 🧠 **主要モデルを完全網羅**:**Google Gemini 3.x、Anthropic Claude、xAI Grok、DeepSeek、Qwen、智譜 GLM、Kimi、ローカルOllama、OpenAI** などを設定なしで即座に切り替え可能。
- 🔒 **ローカルでの機密情報自動マスキング**:LLM への送信前に秘密鍵、API Key、Bearer Token、DB接続文字列などを自動マスキング(情報漏洩リスクゼロ)。
- 🛡️ **スマートなノイズ除去**:`go.sum` や `package-lock.json`、自動生成された `*.pb.go` を自動除外。Token消費を抑え、思考ループを防ぎます。 - 🛡️ **スマートなノイズ除去**:`go.sum` や `package-lock.json`、自動生成された `*.pb.go` を自動除外。Token消費を抑え、思考ループを防ぎます。
- 🎯 **厳格なシニアエンジニア基準**:お世辞や無駄口は一切なし。並行処理の競合、デッドロック、SQLインジェクション、NULLポインタ、リソースリークを指摘し、具体的な修正コードを直接提示します。 - 🎯 **厳格なシニアエンジニア基準**:お世辞や無駄口は一切なし。並行処理の競合、デッドロック、SQLインジェクション、NULLポインタ、リソースリークを指摘し、具体的な修正コードを直接提示します。
- 📊 **Token消費量と費用のリアルタイム見積もり**:入出力Token数を正確に集計し、各社最新の公式料金表に基づいて実行コストを即座に計算(ローカル完全オフラインモデルは無料表示)。 - 📊 **Token消費量と費用のリアルタイム見積もり**:入出力Token数を正確に集計し、各社最新の公式料金表に基づいて実行コストを即座に計算(ローカル完全オフラインモデルは無料表示)。
- ✨ **AI コミットメッセージ自動生成**:変更差分から Conventional Commits 準拠の高品質なメッセージを生成(**日本語・英語・韓国語・中国語**に自動適応)。 - ✨ **AI コミットメッセージ自動生成**:変更差分から Conventional Commits 準拠の高品質なメッセージを生成(**日本語・英語・韓国語・中国語**に自動適応)。
- 📋 **チーム規約の読み込み(`.airules`)**:リポジトリ直下に `.airules` を配置するだけで、チーム固有のアーキテクチャ規約を最優先で適用。 - 📋 **チーム規約の読み込み(`.airules`)**:リポジトリ直下に `.airules` を配置するだけで、チーム固有のアーキテクチャ規約を最優先で適用。
- 🪝 **ワンクリックで Pre-commit フック化**:コマンド1発でGitフックとして登録。危険なコードのコミットを未然にブロックします。 - 🪝 **ワンクリックで Pre-commit フック化**:コマンド1発でGitフックとして登録(既存フックの自動バックアップ&チェーン実行に対応)。危険なコードのコミットを未然にブロックします。
--- ---
@@ -60,79 +61,37 @@ GitHubなどのCI/CD上で動作するボットも優秀ですが、**`git-air`
$ git air $ git air
[git-air] コードレビュー中... (Engine: gemini / gemini-3.7-flash) [git-air] コードレビュー中... (Engine: gemini / gemini-3.7-flash)
────────────────────────────────# 13. カスタム Token 料金設定 (任意:組み込み料金表を上書き、単位: 米ドル/1M Tokens) ─────────────────────────────────────────────────────────────────
git air config set --price-input 0.75 --price-output 3.75 #### 変更概要
ユーザー認証ハンドラーに Redis キャッシュ層を追加し、Context のタイムアウト伝播を修正。
# 14. コミットメッセージのデフォルト言語を永続設定 (任意: auto, zh, en, ja, ko) #### 詳細レビュー結果
git air config set --commit-lang ja - [BLOCKER] internal/service/user.go:45 - 重大なセキュリティ脆弱性: ループ内で生SQL文字列を直接結合しています。SQLインジェクションのリスクがあります。
``` // 推奨修正コード:
db.Where("username = ?", inputName).First(&user)
現在の設定を確認(カスタム料金やマスク化Keyを含む): - [WARNING] internal/service/user.go:82 - 潜在的リスク: Redis 接続エラーを握りつぶしています。キャッシュ障害時にDBへ負荷が集中します。
```bash
git air config get - [WARNING] internal/service/user.go:103 - 並行処理の欠陥: 早期リターン時に Mutex の Unlock を忘れています(Mutex Leak)。デッドロックの危険性があります。
#### 結論
- 判定: [REJECT]
- スコア: 60 / 100
📊 Token: 入力 1,243 / 出力 412 | ≈ $0.0025
─────────────────────────────────────────────────────────────────
``` ```
--- ---
### 2. よく使うレビューコマンド ## 📦 インストール
### 方法 1: Go Install(推奨)
```bash ```bash
# 現在ステージングされた(git add済み)変更をレビュー(デフォルト) go install github.com/unclesam-ly/git-air@latest
git air
# 直前のコミット内容をレビュー
git air HEAD~1
# ブランチ間の差分をレビュー
git air main..feature-agent
# 特定のファイルのみをレビュー
git air internal/service/chat.go
# 一時的にモデルやProviderを指定して実行
git air --provider deepseek --model deepseek-chat
``` ```
--- ### 方法 2: ソースコードからビルド
### 3. AI コミットメッセージ自動生成 (`git air msg`)
コミットメッセージの作成に悩む必要はもうありません。`git-air` がコードの変更差分を分析し、Conventional Commits 準拠のメッセージを**多言語自動適応**と**自動ステージング**で生成します:
```bash
# 1. 基本的な使い方:差分を分析して対話型コミット
git air msg
# 2. 変更ファイルを自動ステージング(git add 不要)
git air msg -a
# 3. 単発で出力言語を指定(auto, zh, en, ja, ko)
git air msg -l en # 英語(オープンソース・国際プロジェクト向け)
git air msg -l ja # 日本語
git air msg -l ko # 韓国語
# 4. 最速ワンライナー:自動ステージング + 生成 + 即座にコミット
git air msg -a -c
# 5. グローバルデフォルト言語を永続設定(毎回 -l を付ける必要がなくなります)
git air config set --commit-lang ja # 今後常に日本語で生成
git air config set --commit-lang en # 今後常に英語で生成
git air config set --commit-lang auto # システム言語への自動追従に戻す
```
**実行イメージ:**
```text
$ git air msg -l ja
[git-air] Conventional Commit Message を生成中... (Language: 日本語 / Engine: gemini-3.7-flash)
─────────────────────────────────────────────────────────────────
feat(auth): Redisキャッシュを追加し、Mutexのデッドロックを修正
- ユーザー認証ハンドラーに Redis トークンキャッシュ層を導入
- 早期リターン時に Mutex の Unlock が漏れる並行処理のバグを修正
─────────────────────────────────────────────────────────────────
? このメッセージで直接 git commit を実行しますか?[Y/n]: y
[SUCCESS] コードのコミットに成功しました!🎉
```スコードからビルド
```bash ```bash
git clone https://github.com/unclesam-ly/git-air.git git clone https://github.com/unclesam-ly/git-air.git
cd git-air cd git-air
@@ -190,6 +149,12 @@ git air config set --price-input 0.75 --price-output 3.75
# 14. コミットメッセージのデフォルト言語を永続設定 (任意: auto, zh, en, ja, ko) # 14. コミットメッセージのデフォルト言語を永続設定 (任意: auto, zh, en, ja, ko)
git air config set --commit-lang ja git air config set --commit-lang ja
# 15. コードレビュー専用のカスタムプロンプトを設定 (任意)
git air config set --reviewer-prompt "並行処理とメモリ安全性を最重要視するGoシニアアーキテクトとしてレビューしてください"
# 16. コミットメッセージ専用のカスタムプロンプトを設定 (任意)
git air config set --commit-msg-prompt "AngularのConventional Commits規範に厳格に従って生成してください"
``` ```
現在の設定を確認(カスタム料金やマスク化Keyを含む): 現在の設定を確認(カスタム料金やマスク化Keyを含む):
@@ -216,6 +181,9 @@ git air internal/service/chat.go
# 一時的にモデルやProviderを指定して実行 # 一時的にモデルやProviderを指定して実行
git air --provider deepseek --model deepseek-chat git air --provider deepseek --model deepseek-chat
# 構造化 JSON レポートを出力(CI/CD パイプラインや自動化スクリプト向け)
git air --format json
``` ```
--- ---
@@ -268,10 +236,11 @@ Git リポジトリ直下で以下を実行:
git air hook install git air hook install
``` ```
- **コミット自動ブロック機能**:レビューで `[BLOCKER]` 重大欠陥や `[REJECT]` 判定が検出された場合、`git-air` は非ゼロの終了コードで**コミット(`git commit`)を自動的に中断・ブロック**します! - **コミット自動ブロック機能**:レビューで `[BLOCKER]` 重大欠陥や `[REJECT]` 判定が検出された場合、`git-air` は非ゼロの終了コードで**コミット(`git commit`)を自動的に中断・ブロック**します!
- **既存フックのチェーン実行**:リポジトリに既存の `pre-commit` がある場合、自動的にバックアップされ、レビュー通過後に**元のフックをチェーン実行**します。
- **厳格モード (`--strict`)**:`[WARNING]` 警告でもコミットをブロックしたい場合は `git air --strict` を使用; - **厳格モード (`--strict`)**:`[WARNING]` 警告でもコミットをブロックしたい場合は `git air --strict` を使用;
- **一時的なブロック解除**:緊急でコミットを強制したい場合は、Git 標準の `git commit --no-verify` または `git air --no-block` を付与します。 - **一時的なブロック解除**:緊急でコミットを強制したい場合は、Git 標準の `git commit --no-verify` または `git air --no-block` を付与します。
フックを解除する場合: フックを解除し、元のフックを完全復元する場合:
```bash ```bash
git air hook uninstall git air hook uninstall
``` ```
@@ -294,7 +263,7 @@ git air update
## ⚙️ 設定の優先順位 ## ⚙️ 設定の優先順位
1. **コマンドライン引数** (`--key`, `--model`, `--provider`, `--prompt`, `--lang`, `--price-input`, `--price-output`, `--strict`, `--no-block`) 1. **コマンドライン引数** (`--key`, `--model`, `--provider`, `--prompt`, `--lang`, `--format`, `--price-input`, `--price-output`, `--strict`, `--no-block`)
2. **環境変数** (`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`, `GIT_AIR_COMMIT_LANG`) 2. **環境変数** (`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`, `GIT_AIR_COMMIT_LANG`)
3. **プロジェクト個別設定** (リポジトリ直下の `./config.yaml` または `./.git-air.yaml`) 3. **プロジェクト個別設定** (リポジトリ直下の `./config.yaml` または `./.git-air.yaml`)
4. **グローバル設定** (`~/.git-air/config.yaml`) 4. **グローバル設定** (`~/.git-air/config.yaml`)
@@ -309,7 +278,10 @@ model: "gemini-3.7-flash"
# カスタム API エンドポイント (任意) # カスタム API エンドポイント (任意)
# base_url: "https://api.deepseek.com/v1" # base_url: "https://api.deepseek.com/v1"
# カスタム System Prompt (任意) # カスタムプロンプト設定 (任意)
# custom_reviewer_prompt: ""
# custom_commit_msg_prompt: ""
# 旧版互換フィールド (v1.xで利用可能)
# custom_prompt: "" # custom_prompt: ""
# カスタム Token 料金設定 (任意、単位: 米ドル / 1M Tokens) # カスタム Token 料金設定 (任意、単位: 米ドル / 1M Tokens)
@@ -320,6 +292,29 @@ model: "gemini-3.7-flash"
# commit_lang: "auto" # commit_lang: "auto"
``` ```
### プロンプト設定フィールドの移行について
旧バージョンでは単一の `custom_prompt` フィールドのみを提供していましたが、`git-air` がコードレビューと AI コミットメッセージ生成の両方に対応したため、2 つのシナリオでプロンプトの目的が異なります。
そのため、現在のバージョン以降はそれぞれ個別のフィールドを使用することを推奨します:
```yaml
custom_reviewer_prompt: "コードレビュー用のカスタムプロンプト"
custom_commit_msg_prompt: "コミットメッセージ生成用のカスタムプロンプト"
```
なお、旧版の `custom_prompt` は引き続き互換性が保たれ、`custom_reviewer_prompt` として自動的に扱われます。CLI からの設定方法:
```bash
git air config set --reviewer-prompt "あなたの Reviewer Prompt"
git air config set --commit-msg-prompt "あなたの Commit Message Prompt"
```
ライフサイクル方針:
- `v1.x`:`custom_prompt` を継続サポート;
- `v1.1.0`:非推奨(deprecated)として警告を表示;
- `v2.0.0`:`custom_prompt` フィールドを完全削除。
--- ---
## 📋 チーム規約のカスタマイズ(`.airules`) ## 📋 チーム規約のカスタマイズ(`.airules`)
+28 -5
View File
@@ -16,7 +16,10 @@ type AppConfig struct {
APIKey string `yaml:"api_key"` // API Key APIKey string `yaml:"api_key"` // API Key
BaseURL string `yaml:"base_url"` // 自定义请求地址 BaseURL string `yaml:"base_url"` // 自定义请求地址
Model string `yaml:"model"` // 模型名称 Model string `yaml:"model"` // 模型名称
CustomPrompt string `yaml:"custom_prompt"` // 用户自定义的 Base System Prompt // CustomPrompt 为兼容旧配置保留,读取时会回退到 CustomReviewerPrompt。
CustomPrompt string `yaml:"custom_prompt,omitempty"`
CustomReviewerPrompt string `yaml:"custom_reviewer_prompt,omitempty"`
CustomCommitMsgPrompt string `yaml:"custom_commit_msg_prompt,omitempty"`
PriceInput float64 `yaml:"price_input,omitempty"` // 自定义输入单价 (每 1M tokens 美元,可选) PriceInput float64 `yaml:"price_input,omitempty"` // 自定义输入单价 (每 1M tokens 美元,可选)
PriceOutput float64 `yaml:"price_output,omitempty"` // 自定义输出单价 (每 1M tokens 美元,可选) PriceOutput float64 `yaml:"price_output,omitempty"` // 自定义输出单价 (每 1M tokens 美元,可选)
CommitLang string `yaml:"commit_lang,omitempty"` // Commit Message 生成语言 (auto, zh, en, ja, ko 等) CommitLang string `yaml:"commit_lang,omitempty"` // Commit Message 生成语言 (auto, zh, en, ja, ko 等)
@@ -82,6 +85,11 @@ func LoadConfig() (*AppConfig, error) {
cfg.CommitLang = envLang cfg.CommitLang = envLang
} }
// 兼容旧版本:custom_prompt 等价于 reviewer 提示词。
if cfg.CustomReviewerPrompt == "" {
cfg.CustomReviewerPrompt = cfg.CustomPrompt
}
return cfg, nil return cfg, nil
} }
@@ -131,7 +139,15 @@ var configSetCmd = &cobra.Command{
cfg.BaseURL = u cfg.BaseURL = u
} }
if pr, _ := cmd.Flags().GetString("prompt"); pr != "" { if pr, _ := cmd.Flags().GetString("prompt"); pr != "" {
// 旧版 --prompt 继续写入旧字段,同时同步新字段,保证 v1.x 行为兼容。
cfg.CustomPrompt = pr cfg.CustomPrompt = pr
cfg.CustomReviewerPrompt = pr
}
if pr, _ := cmd.Flags().GetString("reviewer-prompt"); pr != "" {
cfg.CustomReviewerPrompt = pr
}
if pr, _ := cmd.Flags().GetString("commit-msg-prompt"); pr != "" {
cfg.CustomCommitMsgPrompt = pr
} }
if pi, _ := cmd.Flags().GetFloat64("price-input"); pi > 0 { if pi, _ := cmd.Flags().GetFloat64("price-input"); pi > 0 {
cfg.PriceInput = pi cfg.PriceInput = pi
@@ -171,11 +187,16 @@ var configGetCmd = &cobra.Command{
maskedKey = cfg.APIKey[:4] + "..." + cfg.APIKey[len(cfg.APIKey)-4:] maskedKey = cfg.APIKey[:4] + "..." + cfg.APIKey[len(cfg.APIKey)-4:]
} }
fmt.Printf("APIKey: %s\n", maskedKey) fmt.Printf("APIKey: %s\n", maskedKey)
hasPrompt := "否 (使用默认内置架构师 Prompt)" reviewerPrompt := "否 (使用默认内置架构师 Prompt)"
if cfg.CustomPrompt != "" { if cfg.CustomReviewerPrompt != "" {
hasPrompt = "是 (已自定义)" reviewerPrompt = "是 (已自定义)"
} }
fmt.Printf("CustomPrompt: %s\n", hasPrompt) commitPrompt := "否 (使用默认 Commit Message Prompt)"
if cfg.CustomCommitMsgPrompt != "" {
commitPrompt = "是 (已自定义)"
}
fmt.Printf("ReviewerPrompt: %s\n", reviewerPrompt)
fmt.Printf("CommitMsgPrompt: %s\n", commitPrompt)
commitLang := "auto (自动识别系统语言)" commitLang := "auto (自动识别系统语言)"
if cfg.CommitLang != "" { if cfg.CommitLang != "" {
commitLang = cfg.CommitLang commitLang = cfg.CommitLang
@@ -195,6 +216,8 @@ func init() {
configSetCmd.Flags().StringP("model", "m", "", "模型名称") configSetCmd.Flags().StringP("model", "m", "", "模型名称")
configSetCmd.Flags().StringP("base-url", "u", "", "自定义 API 地址") configSetCmd.Flags().StringP("base-url", "u", "", "自定义 API 地址")
configSetCmd.Flags().String("prompt", "", "自定义 Base System Prompt") configSetCmd.Flags().String("prompt", "", "自定义 Base System Prompt")
configSetCmd.Flags().String("reviewer-prompt", "", "自定义代码 Reviewer Prompt")
configSetCmd.Flags().String("commit-msg-prompt", "", "自定义 Commit Message Prompt")
configSetCmd.Flags().Float64("price-input", 0, "自定义每 1M 输入 Token 美元价格") configSetCmd.Flags().Float64("price-input", 0, "自定义每 1M 输入 Token 美元价格")
configSetCmd.Flags().Float64("price-output", 0, "自定义每 1M 输出 Token 美元价格") configSetCmd.Flags().Float64("price-output", 0, "自定义每 1M 输出 Token 美元价格")
configSetCmd.Flags().String("commit-lang", "", "Commit Message 语言 (auto, zh, en, ja, ko 等)") configSetCmd.Flags().String("commit-lang", "", "Commit Message 语言 (auto, zh, en, ja, ko 等)")
+144 -13
View File
@@ -1,19 +1,142 @@
package cmd package cmd
import ( import (
"errors"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/unclesam-ly/git-air/internal/git" "github.com/unclesam-ly/git-air/internal/git"
"github.com/unclesam-ly/git-air/internal/ui" "github.com/unclesam-ly/git-air/internal/ui"
"github.com/spf13/cobra"
) )
const preCommitScript = `#!/bin/sh const (
# git-air pre-commit hook preCommitMarker = "# git-air-managed-pre-commit-v1"
preCommitBackupSuffix = ".git-air-backup"
preCommitBackupFileName = "pre-commit.git-air-backup"
)
func preCommitScript(bin ...string) string {
binPath := "git-air"
if len(bin) > 0 && bin[0] != "" {
binPath = bin[0]
}
return fmt.Sprintf(`#!/bin/sh
%s
echo "[git-air] 正在执行 Commit 前代码自动化评审..." echo "[git-air] 正在执行 Commit 前代码自动化评审..."
git-air --staged "%s" --staged
` git_air_status=$?
if [ "$git_air_status" -ne 0 ]; then
exit "$git_air_status"
fi
hook_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
old_hook="$hook_dir/%s"
if [ -x "$old_hook" ]; then
exec "$old_hook" "$@"
fi
exit 0
`, preCommitMarker, binPath, preCommitBackupFileName)
}
func isGitAirHook(data []byte) bool {
content := string(data)
return strings.Contains(content, preCommitMarker) ||
strings.Contains(content, "# git-air pre-commit hook") ||
strings.Contains(content, "git-air --staged")
}
func installPreCommitHook(hookPath string) error {
backupPath := hookPath + preCommitBackupSuffix
hookData, hookReadErr := os.ReadFile(hookPath)
hookExists := hookReadErr == nil
if hookReadErr != nil && !errors.Is(hookReadErr, os.ErrNotExist) {
return hookReadErr
}
// 幂等安装:已经是 git-air 包装 Hook 时直接返回,绝不把自己再次备份。
if hookExists && isGitAirHook(hookData) {
return nil
}
if _, err := os.Stat(backupPath); err == nil {
return fmt.Errorf("检测到已有 git-air 备份文件 %s,请先执行 hook uninstall", backupPath)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if hookExists {
// 保留原 Hook:先移动为备份,再安装包装 Hook。写入失败时尝试恢复。
if err := os.Rename(hookPath, backupPath); err != nil {
return fmt.Errorf("备份已有 pre-commit Hook 失败: %w", err)
}
}
binPath := "git-air"
if exe, err := os.Executable(); err == nil && exe != "" {
binPath = exe
}
if err := os.WriteFile(hookPath, []byte(preCommitScript(binPath)), 0755); err != nil {
if hookExists {
_ = os.Remove(hookPath)
_ = os.Rename(backupPath, hookPath)
}
return fmt.Errorf("写入 git-air pre-commit Hook 失败: %w", err)
}
return nil
}
func uninstallPreCommitHook(hookPath string) error {
data, err := os.ReadFile(hookPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("当前仓库没有 git-air pre-commit Hook")
}
return err
}
if !isGitAirHook(data) {
return fmt.Errorf("当前 pre-commit Hook 不是由 git-air 管理,已保留原文件")
}
backupPath := hookPath + preCommitBackupSuffix
if _, err := os.Stat(backupPath); err == nil {
return restoreOriginalHook(hookPath, backupPath)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
return os.Remove(hookPath)
}
// restoreOriginalHook 恢复备份 Hook。
// POSIX 系统优先使用 Rename 的原子替换;Windows 目标文件存在时 Rename 可能失败,
// 此时先把包装 Hook 移到临时路径,恢复失败则尝试回滚,避免先删除导致数据丢失。
func restoreOriginalHook(hookPath, backupPath string) error {
if err := os.Rename(backupPath, hookPath); err == nil {
return nil
}
tempPath := hookPath + ".git-air-restore-temp"
if _, err := os.Stat(tempPath); err == nil {
return fmt.Errorf("恢复原 pre-commit Hook 失败:临时文件已存在 %s", tempPath)
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if err := os.Rename(hookPath, tempPath); err != nil {
return fmt.Errorf("暂存 git-air Hook 失败: %w", err)
}
if err := os.Rename(backupPath, hookPath); err != nil {
_ = os.Rename(tempPath, hookPath)
return fmt.Errorf("恢复原 pre-commit Hook 失败: %w", err)
}
if err := os.Remove(tempPath); err != nil {
return fmt.Errorf("清理临时 Hook 文件失败: %w", err)
}
return nil
}
var hookCmd = &cobra.Command{ var hookCmd = &cobra.Command{
Use: "hook", Use: "hook",
@@ -22,25 +145,30 @@ var hookCmd = &cobra.Command{
var hookInstallCmd = &cobra.Command{ var hookInstallCmd = &cobra.Command{
Use: "install", Use: "install",
Short: "在当前仓库一键安装 pre-commit 钩子", Short: "在当前仓库安装 pre-commit 钩子并保留已有 Hook",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
repoRoot, err := git.GetRepoRoot(cmd.Context()) repoRoot, err := git.GetRepoRoot(cmd.Context())
if err != nil { if err != nil {
ui.PrintError("请在 Git 仓库根目录下执行此命令") ui.PrintError("请在 Git 仓库根目录下执行此命令")
return return
} }
hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit") hooksDir := filepath.Join(repoRoot, ".git", "hooks")
if err := os.WriteFile(hookPath, []byte(preCommitScript), 0755); err != nil { if err := os.MkdirAll(hooksDir, 0755); err != nil {
ui.PrintError("写入 hook 文件失败: %v", err) ui.PrintError("创建 hooks 目录失败: %v", err)
return return
} }
ui.PrintSuccess("已成功安装 pre-commit 钩子!每次执行 git commit 时将自动触发评审。") hookPath := filepath.Join(hooksDir, "pre-commit")
if err := installPreCommitHook(hookPath); err != nil {
ui.PrintError("安装 Hook 失败: %v", err)
return
}
ui.PrintSuccess("已安装 git-air Hook;已有 Hook 已保存在 pre-commit.git-air-backup,提交通过后会继续执行。")
}, },
} }
var hookUninstallCmd = &cobra.Command{ var hookUninstallCmd = &cobra.Command{
Use: "uninstall", Use: "uninstall",
Short: "移除当前仓库的 pre-commit 钩子", Short: "移除 git-air Hook 并恢复已有 Hook",
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
repoRoot, err := git.GetRepoRoot(cmd.Context()) repoRoot, err := git.GetRepoRoot(cmd.Context())
if err != nil { if err != nil {
@@ -48,8 +176,11 @@ var hookUninstallCmd = &cobra.Command{
return return
} }
hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit") hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit")
_ = os.Remove(hookPath) if err := uninstallPreCommitHook(hookPath); err != nil {
ui.PrintSuccess("已成功卸载 pre-commit 钩子!") ui.PrintError("卸载 Hook 失败: %v", err)
return
}
ui.PrintSuccess("已卸载 git-air Hook;如原来存在 Hook,已恢复原文件。")
}, },
} }
+87
View File
@@ -0,0 +1,87 @@
package cmd
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestInstallPreCommitHookIsIdempotent(t *testing.T) {
dir := t.TempDir()
hookPath := filepath.Join(dir, "pre-commit")
original := "#!/bin/sh\necho original\n"
if err := os.WriteFile(hookPath, []byte(original), 0755); err != nil {
t.Fatal(err)
}
if err := installPreCommitHook(hookPath); err != nil {
t.Fatal(err)
}
backupPath := hookPath + preCommitBackupSuffix
backup, err := os.ReadFile(backupPath)
if err != nil {
t.Fatal(err)
}
if string(backup) != original {
t.Fatalf("原 Hook 备份内容被改变: %q", backup)
}
first, err := os.ReadFile(hookPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(first), preCommitMarker) {
t.Fatal("安装后的 Hook 缺少 git-air 标记")
}
if err := installPreCommitHook(hookPath); err != nil {
t.Fatal(err)
}
second, err := os.ReadFile(hookPath)
if err != nil {
t.Fatal(err)
}
if string(second) != string(first) {
t.Fatal("重复安装不应改写 git-air Hook")
}
backupAfter, err := os.ReadFile(backupPath)
if err != nil {
t.Fatal(err)
}
if string(backupAfter) != original {
t.Fatal("重复安装不应改变原 Hook 备份")
}
}
func TestPreCommitScriptRunsOnlyExecutableBackup(t *testing.T) {
script := preCommitScript()
if !strings.Contains(script, "if [ -x \"$old_hook\" ]; then") {
t.Fatalf("Hook 没有检查备份文件的可执行权限:\n%s", script)
}
}
func TestUninstallPreCommitHookRestoresOriginal(t *testing.T) {
dir := t.TempDir()
hookPath := filepath.Join(dir, "pre-commit")
original := "#!/bin/sh\necho original\n"
if err := os.WriteFile(hookPath, []byte(original), 0755); err != nil {
t.Fatal(err)
}
if err := installPreCommitHook(hookPath); err != nil {
t.Fatal(err)
}
if err := uninstallPreCommitHook(hookPath); err != nil {
t.Fatal(err)
}
restored, err := os.ReadFile(hookPath)
if err != nil {
t.Fatal(err)
}
if string(restored) != original {
t.Fatalf("原 Hook 未正确恢复: %q", restored)
}
if _, err := os.Stat(hookPath + preCommitBackupSuffix); !os.IsNotExist(err) {
t.Fatalf("备份文件未清理: %v", err)
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ var msgCmd = &cobra.Command{
fmt.Printf("\033[1;36m[git-air]\033[0m 正在生成 Conventional Commit Message... \033[0;90m(Language: %s / Engine: %s)\033[0m\n", langDesc, cfg.Model) fmt.Printf("\033[1;36m[git-air]\033[0m 正在生成 Conventional Commit Message... \033[0;90m(Language: %s / Engine: %s)\033[0m\n", langDesc, cfg.Model)
fmt.Println("\033[0;90m" + strings.Repeat("─", 65) + "\033[0m") fmt.Println("\033[0;90m" + strings.Repeat("─", 65) + "\033[0m")
commitMsg, err := rev.GenerateCommitMsg(ctx, diff, lang) commitMsg, err := rev.GenerateCommitMsg(ctx, diff, lang, cfg.CustomCommitMsgPrompt)
if err != nil { if err != nil {
ui.PrintError("生成 Commit Message 失败: %v", err) ui.PrintError("生成 Commit Message 失败: %v", err)
return return
+47 -4
View File
@@ -2,6 +2,7 @@ package cmd
import ( import (
"context" "context"
"encoding/json"
"os" "os"
"strings" "strings"
@@ -21,6 +22,11 @@ var rootCmd = &cobra.Command{
Args: cobra.ArbitraryArgs, Args: cobra.ArbitraryArgs,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
ctx := cmd.Context() ctx := cmd.Context()
outputFormat, _ := cmd.Flags().GetString("format")
if outputFormat != "text" && outputFormat != "json" {
ui.PrintError("不支持的输出格式 %q,可选: text, json", outputFormat)
os.Exit(2)
}
// 后台非阻塞异步检查新版本 // 后台非阻塞异步检查新版本
updateCh := updater.CheckAsync() updateCh := updater.CheckAsync()
@@ -76,17 +82,21 @@ var rootCmd = &cobra.Command{
// 5. 初始化评审引擎 // 5. 初始化评审引擎
customPrompt, _ := cmd.Flags().GetString("prompt") customPrompt, _ := cmd.Flags().GetString("prompt")
if customPrompt == "" { if customPrompt == "" {
customPrompt = cfg.CustomPrompt customPrompt = cfg.CustomReviewerPrompt
} }
rev := reviewer.NewReviewer(llmClient, repoRoot, customPrompt) rev := reviewer.NewReviewer(llmClient, repoRoot, customPrompt)
// 6. 打印开场横幅并开始流式评审 // 6. 打印开场横幅并开始流式评审
if outputFormat == "text" {
ui.PrintBanner(cfg.Provider, cfg.Model) ui.PrintBanner(cfg.Provider, cfg.Model)
}
printer := ui.NewStreamPrinter() printer := ui.NewStreamPrinter()
var reviewOutput strings.Builder var reviewOutput strings.Builder
_, usage, err := rev.Execute(ctx, diff, true, func(chunk string) { _, usage, err := rev.Execute(ctx, diff, true, func(chunk string) {
reviewOutput.WriteString(chunk) reviewOutput.WriteString(chunk)
if outputFormat == "text" {
printer.PrintChunk(chunk) printer.PrintChunk(chunk)
}
}) })
if err != nil { if err != nil {
ui.PrintError("\n评审过程中发生错误: %v", err) ui.PrintError("\n评审过程中发生错误: %v", err)
@@ -94,16 +104,48 @@ var rootCmd = &cobra.Command{
} }
// 7. 打印 Token 消耗统计 // 7. 打印 Token 消耗统计
if usage != nil { if outputFormat == "text" && usage != nil {
ui.PrintUsage(usage.InputTokens, usage.OutputTokens, usage.EstimateCost()) ui.PrintUsage(usage.InputTokens, usage.OutputTokens, usage.EstimateCost())
} }
if outputFormat == "text" {
ui.PrintFooter() ui.PrintFooter()
}
if outputFormat == "json" {
report := reviewer.ParseReviewReport(reviewOutput.String())
var reportUsage interface{}
if usage != nil {
reportUsage = struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
Model string `json:"model,omitempty"`
Provider string `json:"provider,omitempty"`
CostUSD float64 `json:"cost_usd"`
}{
InputTokens: usage.InputTokens,
OutputTokens: usage.OutputTokens,
Model: usage.Model,
Provider: cfg.Provider,
CostUSD: usage.EstimateCost(),
}
}
// 8. 检查新版本提醒(非阻塞) finalReport := struct {
ReviewReport reviewer.ReviewReport `json:"review"`
Usage interface{} `json:"usage"`
}{
ReviewReport: report,
Usage: reportUsage,
}
if err := json.NewEncoder(os.Stdout).Encode(finalReport); err != nil {
ui.PrintError("输出 JSON 失败: %v", err)
}
}
// 8. 检查新版本提醒(非阻塞读取,仅在 text 模式下展示)
select { select {
case info := <-updateCh: case info := <-updateCh:
if info != nil { if info != nil && outputFormat == "text" {
ui.PrintUpdateBanner(info.CurrentVersion, info.LatestVersion, info.ReleaseURL) ui.PrintUpdateBanner(info.CurrentVersion, info.LatestVersion, info.ReleaseURL)
} }
default: default:
@@ -153,4 +195,5 @@ func init() {
rootCmd.Flags().Bool("staged", false, "显式审查暂存区(git add)代码") rootCmd.Flags().Bool("staged", false, "显式审查暂存区(git add)代码")
rootCmd.Flags().Bool("strict", false, "严格模式:遇到 WARNING 也会阻断退出") rootCmd.Flags().Bool("strict", false, "严格模式:遇到 WARNING 也会阻断退出")
rootCmd.Flags().Bool("no-block", false, "非阻断模式:即使发现 BLOCKER 也不以非零退出码阻断") rootCmd.Flags().Bool("no-block", false, "非阻断模式:即使发现 BLOCKER 也不以非零退出码阻断")
rootCmd.Flags().String("format", "text", "输出格式: text 或 json")
} }
+8 -2
View File
@@ -29,8 +29,14 @@ model: "gemini-3.7-flash"
# 自定义 API 接口地址 (可选,不填默认走对应 Provider 的官方端点) # 自定义 API 接口地址 (可选,不填默认走对应 Provider 的官方端点)
# base_url: "" # base_url: ""
# 自定义全局评审提示词 (可选,留空则使用内置资深架构师准则) # 自定义代码 Reviewer 提示词 (可选,留空则使用内置资深架构师准则)
custom_prompt: "" # 旧字段 custom_prompt 仍兼容,但建议迁移为 custom_reviewer_prompt
custom_reviewer_prompt: ""
# 旧版兼容字段(v1.x 仍可使用)
# custom_prompt: ""
# 自定义 Commit Message 提示词 (可选,追加在内置 Conventional Commits 约束之前)
custom_commit_msg_prompt: ""
# 自定义模型 Token 计费费率 (可选,单位:美元 / 每 1M Tokens) # 自定义模型 Token 计费费率 (可选,单位:美元 / 每 1M Tokens)
# 默认情况下 git-air 内置了各主流模型官方最新价格表; # 默认情况下 git-air 内置了各主流模型官方最新价格表;
+9 -4
View File
@@ -7,6 +7,8 @@ import (
"fmt" "fmt"
"os/exec" "os/exec"
"strings" "strings"
"github.com/unclesam-ly/git-air/internal/security"
) )
var ( var (
@@ -69,7 +71,8 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
return "", err return "", err
} }
if strings.TrimSpace(stagedDiff) != "" { if strings.TrimSpace(stagedDiff) != "" {
return truncateDiffIfNeeded(FilterDiff(stagedDiff, customIgnores...)), nil truncated := truncateDiffIfNeeded(FilterDiff(stagedDiff, customIgnores...))
return security.RedactSecrets(truncated), nil
} }
// 2. 如果暂存区为空,回退提取工作区未暂存的修改 // 2. 如果暂存区为空,回退提取工作区未暂存的修改
cmdArgs = []string{} cmdArgs = []string{}
@@ -82,21 +85,23 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
return "", err return "", err
} }
filteredDiff := FilterDiff(rawDiff, customIgnores...) truncatedDiff := truncateDiffIfNeeded(FilterDiff(rawDiff, customIgnores...))
filteredDiff := security.RedactSecrets(truncatedDiff)
if strings.TrimSpace(filteredDiff) == "" { if strings.TrimSpace(filteredDiff) == "" {
return "", ErrEmptyDiff return "", ErrEmptyDiff
} }
return truncateDiffIfNeeded(filteredDiff), nil return filteredDiff, nil
} }
func truncateDiffIfNeeded(diff string) string { func truncateDiffIfNeeded(diff string) string {
runes := []rune(diff) runes := []rune(diff)
if len(runes) > maxDiffSize { if len(runes) > maxDiffSize {
runes = runes[:maxDiffSize] runes = runes[:maxDiffSize]
}
return string(runes) + "\n\n[WARNING: 代码变更量过大,已自动截断前 300KB 内容进行评审]" return string(runes) + "\n\n[WARNING: 代码变更量过大,已自动截断前 300KB 内容进行评审]"
} }
return diff
}
// execGitDiff 执行底层的 git diff 命令 // execGitDiff 执行底层的 git diff 命令
func execGitDiff(ctx context.Context, args ...string) (string, error) { func execGitDiff(ctx context.Context, args ...string) (string, error) {
+1 -1
View File
@@ -19,7 +19,7 @@ func TestIsReasoningModel(t *testing.T) {
{"gpt-5.6-terra", true}, {"gpt-5.6-terra", true},
{"deepseek-reasoner", true}, {"deepseek-reasoner", true},
{"gemini-3.7-flash", false}, {"gemini-3.7-flash", false},
{"claude-3.7-sonnet", false}, {"claude-4.6-sonnet", false},
{"gpt-4o", false}, {"gpt-4o", false},
} }
+11
View File
@@ -95,3 +95,14 @@ func BuildCommitMsgPrompt(lang string) string {
%s %s
`, langName, exampleSummary, exampleBullet1, exampleBullet2) `, langName, exampleSummary, exampleBullet1, exampleBullet2)
} }
// BuildCommitMsgPromptWithCustom 在内置 Conventional Commits 约束基础上追加用户规则。
// 保留内置约束,避免自定义提示词意外要求模型输出非法提交格式。
func BuildCommitMsgPromptWithCustom(lang, customPrompt string) string {
basePrompt := BuildCommitMsgPrompt(lang)
customPrompt = strings.TrimSpace(customPrompt)
if customPrompt == "" {
return basePrompt
}
return customPrompt + "\n\n### git-air 内置提交信息约束(必须遵守):\n" + basePrompt
}
+10
View File
@@ -56,3 +56,13 @@ func TestBuildCommitMsgPrompt(t *testing.T) {
} }
} }
} }
func TestBuildCommitMsgPromptWithCustom(t *testing.T) {
prompt := BuildCommitMsgPromptWithCustom("en", "Use a short imperative subject and mention breaking changes explicitly.")
if !strings.Contains(prompt, "Use a short imperative subject") {
t.Fatal("自定义 Commit Message Prompt 未被合并")
}
if !strings.Contains(prompt, "Conventional Commits") {
t.Fatal("内置 Conventional Commits 约束不应丢失")
}
}
+73
View File
@@ -0,0 +1,73 @@
package reviewer
import (
"regexp"
"strconv"
"strings"
)
type Finding struct {
Severity string `json:"severity"`
Location string `json:"location,omitempty"`
Message string `json:"message"`
}
type ReviewReport struct {
Format string `json:"format"`
Verdict string `json:"verdict,omitempty"`
Score int `json:"score,omitempty"`
Findings []Finding `json:"findings"`
Raw string `json:"raw"`
}
var (
verdictPattern = regexp.MustCompile(`(?mi)(?:结论|verdict)\s*[::]\s*\[?(PASS|WARN|WARNING|REJECT|BLOCKER|CRITICAL)\]?`)
scorePattern = regexp.MustCompile(`(?mi)(?:评分|score)\s*[::]\s*(\d{1,3})`)
findingPattern = regexp.MustCompile(`(?m)^\s*-\s*\[(BLOCKER|CRITICAL|WARNING|WARN|SUGGESTION|INFO)\]\s+(.+?)\s*$`)
)
// ParseReviewReport 将模型的 Markdown Review 转成稳定的 JSON 结构。
// Raw 始终保留完整原文,解析失败时下游仍可自行处理。
func ParseReviewReport(raw string) ReviewReport {
report := ReviewReport{
Format: "git-air-review/v1",
Findings: []Finding{},
Raw: strings.TrimSpace(raw),
}
if match := verdictPattern.FindStringSubmatch(raw); len(match) > 1 {
report.Verdict = strings.ToUpper(match[1])
}
if match := scorePattern.FindStringSubmatch(raw); len(match) > 1 {
report.Score, _ = strconv.Atoi(match[1])
if report.Score > 100 {
report.Score = 100
}
}
for _, match := range findingPattern.FindAllStringSubmatch(raw, -1) {
location, message := splitFindingContent(match[2])
report.Findings = append(report.Findings, Finding{
Severity: strings.ToUpper(match[1]),
Location: location,
Message: message,
})
}
return report
}
func splitFindingContent(content string) (string, string) {
content = strings.TrimSpace(content)
parts := strings.SplitN(content, " - ", 2)
if len(parts) == 1 {
return "", content
}
location := strings.TrimSpace(parts[0])
message := strings.TrimSpace(parts[1])
// 没有行号/路径特征时,不要误把普通句子当成 location。
if !strings.Contains(location, ":") {
return "", content
}
return location, message
}
+25
View File
@@ -0,0 +1,25 @@
package reviewer
import "testing"
func TestParseReviewReport(t *testing.T) {
raw := `#### 详细审查意见
- [BLOCKER] internal/user.go:45 - 存在 SQL 注入风险
- [WARNING] internal/user.go:82 - Redis 错误被忽略
- [WARNING] 缺少超时控制
#### 评审结论
- 结论: [REJECT]
- 评分: 58 / 100
`
report := ParseReviewReport(raw)
if report.Verdict != "REJECT" || report.Score != 58 {
t.Fatalf("结论解析错误: %+v", report)
}
if len(report.Findings) != 3 || report.Findings[0].Location != "internal/user.go:45" {
t.Fatalf("问题解析错误: %+v", report.Findings)
}
if report.Findings[2].Location != "" || report.Findings[2].Message != "缺少超时控制" {
t.Fatalf("无位置信息的问题解析错误: %+v", report.Findings[2])
}
}
+2 -2
View File
@@ -42,13 +42,13 @@ func (r *Reviewer) Execute(ctx context.Context, diff string, stream bool, onChun
} }
// GenerateCommitMsg 根据 Diff 智能生成符合 Conventional Commits 规范的提交信息 // GenerateCommitMsg 根据 Diff 智能生成符合 Conventional Commits 规范的提交信息
func (r *Reviewer) GenerateCommitMsg(ctx context.Context, diff string, lang string) (string, error) { func (r *Reviewer) GenerateCommitMsg(ctx context.Context, diff string, lang string, customPrompt string) (string, error) {
if strings.TrimSpace(diff) == "" { if strings.TrimSpace(diff) == "" {
return "", fmt.Errorf("diff 内容为空,无法生成 Commit Message") return "", fmt.Errorf("diff 内容为空,无法生成 Commit Message")
} }
targetLang := DetectLanguage(lang) targetLang := DetectLanguage(lang)
systemPrompt := BuildCommitMsgPrompt(targetLang) systemPrompt := BuildCommitMsgPromptWithCustom(targetLang, customPrompt)
msg, err := r.client.Review(ctx, systemPrompt, diff) msg, err := r.client.Review(ctx, systemPrompt, diff)
if err != nil { if err != nil {
+51
View File
@@ -0,0 +1,51 @@
package security
import "regexp"
type redactRule struct {
pattern *regexp.Regexp
replacement string
}
var redactRules = []redactRule{
{
// PEM 私钥必须优先处理,避免后续规则只替换其中一部分。
pattern: regexp.MustCompile(`(?s)-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----`),
replacement: "[REDACTED_SECRET]",
},
{
// 常见 Token 前缀:OpenAI/GitHub/AWS/Slack 等。
pattern: regexp.MustCompile(`(?i)\b(?:sk-[A-Za-z0-9]{16,}|gh[pousr]_[A-Za-z0-9_]{16,}|AKIA[0-9A-Z]{16}|xox[baprs]-[A-Za-z0-9-]{10,})\b`),
replacement: "[REDACTED_SECRET]",
},
{
// Authorization: Bearer ...
pattern: regexp.MustCompile(`(?i)(Authorization\s*:\s*Bearer\s+)[A-Za-z0-9._~+/=-]+`),
replacement: "${1}[REDACTED_BEARER_TOKEN]",
},
{
// JWT。
pattern: regexp.MustCompile(`\b[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b`),
replacement: "[REDACTED_SECRET]",
},
{
// 数据库连接串。
pattern: regexp.MustCompile(`(?i)\b(?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?):\/\/[^\s"'<>]+`),
replacement: "[REDACTED_SECRET]",
},
{
// 常见密钥字段赋值。值至少 8 个字符,减少普通变量误报。
pattern: regexp.MustCompile(`(?i)(\b(?:api[_-]?key|access[_-]?key|secret(?:[_-]?key)?|password|passwd|token)\b\s*[:=]\s*["']?)[^\s"' ,;]{8,}`),
replacement: "${1}[REDACTED_SECRET]",
},
}
// RedactSecrets 在 Diff 发送给模型前替换常见敏感信息。
// 脱敏只作用于发送给模型的字符串,不修改工作区文件或 Git 内容。
func RedactSecrets(input string) string {
output := input
for _, rule := range redactRules {
output = rule.pattern.ReplaceAllString(output, rule.replacement)
}
return output
}
+38
View File
@@ -0,0 +1,38 @@
package security
import (
"strings"
"testing"
)
func TestRedactSecrets(t *testing.T) {
input := `
api_key = "sk-12345678901234567890"
Authorization: Bearer my-secret-bearer-token-123
password: "this-is-a-secret-password"
-----BEGIN RSA PRIVATE KEY-----
secret private key content
-----END RSA PRIVATE KEY-----
`
if strings.Contains(input, "[REDACTED_") {
t.Fatal("测试输入不应预先包含脱敏占位符")
}
output := RedactSecrets(input)
if output == input {
t.Fatal("RedactSecrets 未改变包含敏感信息的输入")
}
for _, secret := range []string{
"sk-12345678901234567890",
"my-secret-bearer-token-123",
"this-is-a-secret-password",
"secret private key content",
} {
if strings.Contains(output, secret) {
t.Fatalf("敏感信息未被脱敏: %q\noutput: %s", secret, output)
}
}
if !strings.Contains(output, "[REDACTED_SECRET]") || !strings.Contains(output, "[REDACTED_BEARER_TOKEN]") {
t.Fatalf("脱敏占位符不完整: %s", output)
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ import (
) )
// CurrentVersion 当前编译版本号 // CurrentVersion 当前编译版本号
const CurrentVersion = "v1.0.6" const CurrentVersion = "v1.0.7"
const ( const (
githubAPIURL = "https://api.github.com/repos/unclesam-ly/git-air/releases/latest" githubAPIURL = "https://api.github.com/repos/unclesam-ly/git-air/releases/latest"