7 Commits
Author SHA1 Message Date
unclesam 26b0342934 feat: 添加 Token 费用估算与自定义忽略规则
- 在 LLM 客户端中捕获流式响应的 Token 使用量
- 内置多模型官方定价表,支持自定义输入/输出单价覆盖
- 新增 `--price-input` 和 `--price-output` 配置项
- 支持通过 `.airignore` 文件自定义排除评审文件
- 更新中英日文文档,补充费用估算与忽略规则说明
2026-08-21 15:00:21 +08:00
unclesam 02e2e4edd3 fix(module): 更新 go.mod 与所有包导入路径为 github.com/unclesam-ly/git-air 2026-08-18 23:12:16 +08:00
unclesam 9de40815b0 fix(ui): 忽略 PrintError 的返回值 2026-08-18 11:30:49 +08:00
unclesam 16fd83412d fix(hook): pre-commit hook explicitly passes --staged flag 2026-08-18 11:25:48 +08:00
unclesam 1493b0a392 fix(hook): 支持 --cached/--staged 参数并优化 pre-commit 脚本 2026-08-18 11:21:22 +08:00
unclesam 0b4d02d232 fix(config): 调整配置加载优先级与项目配置覆盖
- 全局配置优先加载,项目级配置进行增量覆盖
- 环境变量保持最高优先级
- 移除项目根目录 config.yaml 与 configs/config.yaml 探测
- 根命令允许接收任意参数
2026-08-17 21:46:02 +08:00
unclesam 4515e28efc docs(readme): 更新中文说明并新增英文和日文文档 2026-08-17 18:06:22 +08:00
15 changed files with 1077 additions and 86 deletions

No files matched your search

+87 -8
View File
@@ -15,25 +15,74 @@
<a href="https://github.com/unclesam-ly/git-air/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" /></a> <a href="https://github.com/unclesam-ly/git-air/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" /></a>
</p> </p>
<p align="center">
<a href="README.md"><strong>简体中文</strong></a> |
<a href="README_EN.md">English</a> |
<a href="README_JA.md">日本語</a>
</p>
--- ---
## 🌟 设计初衷与定位 ## 🌟 为什么做 git-air?
市面上有许多优秀的云端与 CI/CD 级别的 AI Code Review 平台(如各类 PR 自动审查 Bot),它们在团队协作和远程代码门禁上表现出色。 ### 痛点:AI 写代码一时爽,Review 起来火葬场
**而 `git-air` 的定位是:将审查能力极致前置到开发者的本地终端(Pre-Commit / Local CLI)。** 现在的开发工作流几乎离不开 AI 辅助(Cursor、Copilot、各种大模型插件)。几秒钟生成上百行代码,写起来确实爽快。
在代码正式 push 或发起 PR 之前,在本地敲一行命令,就能以零延迟、极简的方式先做一轮深度扫描,把潜在的并发隐患、逻辑缺陷与敏感信息拦截在本地,作为开发者最轻量顺手的日常命令行结对编程助手。 **但这也带来了一个全新的致命痛点:**
- **“看起来太完美”的视觉欺骗**:AI 生成的代码排版工整、注释漂亮、语法标准,极容易让人产生盲目信任;
- **生成 AI 的“自恋惯性”**:在同一个对话上下文里,你问写代码的那个 AI *“有没有 Bug”*,它往往带有思维惯性与自恋幻觉,大概率自信满满地回复 *“逻辑完全正确、非常健壮”*;
- **隐蔽暗坑极难肉眼捕捉**:比如在某个 `if err != nil` 提前返回时漏了解锁(Mutex Leak)、协程内部缺少 `recover`、循环内未释放的连接……这些隐患肉眼一扫极易漏掉。
这导致了一个尴尬的死循环:**AI 负责批量生产代码,人类程序员负责在半夜线上故障时擦屁股。**
---
### 解法:把审查能力极致前置到本地终端(Pre-Commit)
市面上有许多优秀的云端与 CI/CD 级别的代码审查平台,而 **`git-air` 的定位是:在代码正式 commit 或发起 PR 之前,作为你本地终端里完全独立的“第三方审查员”。**
它完全剥离聊天上下文,像一个**坐在你工位旁边、嘴有点损但眼神极毒的资深架构师同事**,只对纯粹的代码变更(`git diff`)进行冷酷挑刺:
- ⚡ **Git 原生极简体验**:无需改变任何工作习惯,在命令行敲 `git air` 即可秒级唤起。 - ⚡ **Git 原生极简体验**:无需改变任何工作习惯,在命令行敲 `git air` 即可秒级唤起。
- 🧠 **全模型自由接入**:开箱即用支持 **Google Gemini、DeepSeek、本地离线 Ollama、OpenAI** 以及任何兼容 OpenAI 协议的自定义端点。 - 🧠 **全模型自由接入**:开箱即用预置 **Google Gemini 3.x、Anthropic Claude、xAI Grok、DeepSeek、阿里通义千问、智谱 GLM、月之暗面 Kimi、本地 Ollama、OpenAI** 等全系列模型。
- 🛡️ **智能降噪过滤**:自动剥离 `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 最新官方定价表精准核算每次审查成本(本地离线模型自动标注免费)。
- 📋 **团队规范适配(`.airules`)**:在项目根目录放置 `.airules`,AI 自动加载团队专属架构规范并最高优先级执行。 - 📋 **团队规范适配(`.airules`)**:在项目根目录放置 `.airules`,AI 自动加载团队专属架构规范并最高优先级执行。
- 🪝 **一键 Pre-commit 钩子**:一行命令挂载 Git 提交前自动拦截,在代码上库前把好第一道质量关。 - 🪝 **一键 Pre-commit 钩子**:一行命令挂载 Git 提交前自动拦截,在代码上库前把好第一道质量关。
--- ---
## 🖥️ 终端效果演示
```text
$ git air
[git-air] 代码评审中... (Engine: gemini / gemini-3.7-flash)
─────────────────────────────────────────────────────────────────
#### 变更概述
在用户鉴权模块中引入了 Redis 缓存逻辑,并调整了上下文超时传递机制。
#### 详细审查意见
- [BLOCKER] internal/service/user.go:45 - 严重安全隐患: 循环体内直接拼接原生 SQL 字符串,存在 SQL 注入风险。
// 建议修复方案:
db.Where("username = ?", inputName).First(&user)
- [WARNING] internal/service/user.go:82 - 潜在风险: 静默丢弃了 Redis 连接异常,当缓存宕机时可能造成缓存击穿。
- [WARNING] internal/service/user.go:103 - 并发隐患: 提前 return 时漏释放互斥锁(Mutex Leak),高并发下极易引发死锁。
#### 评审结论
- 结论: [REJECT]
- 评分: 60 / 100
📊 Token: 输入 1,243 / 输出 412 | ≈ $0.0025
─────────────────────────────────────────────────────────────────
```
---
## 📦 安装方式 ## 📦 安装方式
### 方式 1:通过 Go 一键安装(推荐) ### 方式 1:通过 Go 一键安装(推荐)
@@ -93,9 +142,12 @@ git air config set --provider ollama --model qwen2.5-coder
# 12. OpenAI 官方 (GPT-4o / o3-mini) # 12. OpenAI 官方 (GPT-4o / o3-mini)
git air config set --provider openai --key "YOUR_KEY" --model gpt-4o-mini git air config set --provider openai --key "YOUR_KEY" --model gpt-4o-mini
# 13. 自定义 Token 计费费率 (可选:覆盖内置价格表,单位: 美元/1M Tokens)
git air config set --price-input 0.75 --price-output 3.75
``` ```
查看当前配置状态: 查看当前配置状态(含自定义费率与脱敏 Key):
```bash ```bash
git air config get git air config get
``` ```
@@ -129,7 +181,9 @@ git air --provider deepseek --model deepseek-chat
```bash ```bash
git air hook install git air hook install
``` ```
以后团队成员在执行 `git commit` 时,`git-air` 会在终端自动执行审查,若存在阻断性缺陷将自动提醒! - **自动门禁阻断**:当审查发现 `[BLOCKER]` 严重缺陷或 `[REJECT]` 评审结论时,`git-air` 会自动以非零退出码**直接拦截并阻断本次 `git commit`**,死守代码质量底线!
- **严格模式 (`--strict`)**:若希望发现 `[WARNING]` 也强制阻断提交,可使用 `git air --strict`;
- **临时跳过阻断**:如确需强行提交,可使用 Git 原生命令 `git commit --no-verify` 或附加 `git-air --no-block`。
如需卸载钩子: 如需卸载钩子:
```bash ```bash
@@ -141,7 +195,7 @@ git air hook uninstall
## ⚙️ 配置文件说明 ## ⚙️ 配置文件说明
`git-air` 支持多层配置覆盖,优先级从高到低为: `git-air` 支持多层配置覆盖,优先级从高到低为:
1. **命令行 Flags**(如 `--key`, `--model`, `--provider`) 1. **命令行 Flags**(如 `--key`, `--model`, `--provider`, `--strict`, `--no-block`)
2. **环境变量**(`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`) 2. **环境变量**(`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`)
3. **项目级配置**(当前 Git 项目根目录下的 `config.yaml` 或 `.git-air.yaml`) 3. **项目级配置**(当前 Git 项目根目录下的 `config.yaml` 或 `.git-air.yaml`)
4. **全局用户配置**(`~/.git-air/config.yaml`) 4. **全局用户配置**(`~/.git-air/config.yaml`)
@@ -162,6 +216,10 @@ model: "gemini-3.7-flash"
# 自定义基础提示词 (可选,留空则使用默认资深架构师准则) # 自定义基础提示词 (可选,留空则使用默认资深架构师准则)
# custom_prompt: "" # custom_prompt: ""
# 自定义模型单价 (可选,单位: 美元/1M Tokens,用于精准核算自定义中转或企业私有接口费用)
# price_input: 0.75
# price_output: 3.75
``` ```
--- ---
@@ -180,6 +238,27 @@ model: "gemini-3.7-flash"
--- ---
## 🛡️ 自定义忽略审查(`.airignore`)
除了内置自动忽略的锁文件与二进制外,你还可以在仓库根目录创建 `.airignore` 文件,自定义排除不需要 AI 评审的文件或目录:
```gitignore
# 忽略所有文档与原型设计
docs/
*.md
# 忽略测试 Mock 与假数据
tests/mock/
testdata/
# 忽略私密配置文件
*.env
secrets.yaml
```
---
## 📄 开源许可证 ## 📄 开源许可证
本项目采用 [MIT 许可证](LICENSE)。欢迎提交 Issue 与 Pull Request! 本项目采用 [MIT 许可证](LICENSE)。欢迎提交 Issue 与 Pull Request!
+259
View File
@@ -0,0 +1,259 @@
<p align="center">
<img src="assets/logo.png" width="180" height="180" alt="git-air logo" />
</p>
<h1 align="center">git-air 🍃</h1>
<p align="center">
<strong>A lightweight, lightning-fast, Git-native AI code reviewer written in pure Go.</strong>
</p>
<p align="center">
<a href="https://go.dev/"><img src="https://img.shields.io/badge/Go-1.26%2B-00ADD8?style=flat&logo=go" alt="Go Version" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
<a href="https://github.com/unclesam-ly/git-air/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" /></a>
</p>
<p align="center">
<a href="README.md">简体中文</a> |
<a href="README_EN.md"><strong>English</strong></a> |
<a href="README_JA.md">日本語</a>
</p>
---
## 🌟 Why git-air?
### The Pain Point: AI Writes Code Fast, But Reviewing It Is a Nightmare
Most developers today rely heavily on AI coding assistants (Cursor, Copilot, and LLM extensions). Generating hundreds of lines of code in seconds feels incredible.
**However, this introduces a critical new problem:**
- **The "Looks Perfect" Illusion**: AI-generated code features neat indentation, convincing naming, and flawless syntax, making it dangerously easy to trust blindly;
- **The Confirmation Bias of Generating Models**: In the same conversation thread, asking the very same AI *"Are there any bugs here?"* almost always yields a self-flattering response: *"The logic is completely solid and robust!"*;
- **Subtle Traps Missed by Human Eyes**: Overlooked unlocked mutexes on early `return` paths, missing `recover()` inside goroutines, unreleased database connections in loops... These critical traps easily slip past quick manual skimming.
This creates an absurd cycle: **AI mass-produces code, while human engineers spend late nights fixing production crashes.**
---
### The Solution: Shift Code Review Directly to Your Local Terminal (Pre-Commit)
While there are great cloud/CI-based PR review bots, **`git-air` is designed to be your independent, cold-blooded "third-party auditor" right inside your local terminal before you ever type `git commit`.**
Completely isolated from your chat history, it acts like a **grumpy but sharp senior architect sitting beside your desk**, ruthlessly inspecting only raw code changes (`git diff`):
- ⚡ **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.
- 🛡️ **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.
- 📊 **Real-time Token & Cost Estimation**: Accurately tracks prompt/completion token consumption with built-in official pricing rates (local offline models automatically marked free).
- 📋 **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.
---
## 🖥️ Terminal Preview
```text
$ git air
[git-air] Reviewing code... (Engine: gemini / gemini-3.7-flash)
─────────────────────────────────────────────────────────────────
#### Change Summary
Added Redis caching layer to user authentication and refined context propagation.
#### Detailed Findings
- [BLOCKER] internal/service/user.go:45 - Critical Vulnerability: Direct SQL string concatenation inside loop. Potential SQL injection.
// Suggested Fix:
db.Where("username = ?", inputName).First(&user)
- [WARNING] internal/service/user.go:82 - Potential Risk: Silently discarding Redis error. May lead to cache stampede.
- [WARNING] internal/service/user.go:103 - Concurrency Issue: Mutex unlock skipped on early return branch. High risk of deadlock.
#### Verdict
- Status: [REJECT]
- Score: 60 / 100
📊 Token: Input 1,243 / Output 412 | ≈ $0.0025
─────────────────────────────────────────────────────────────────
```
---
## 📦 Installation
### Option 1: Via Go Install (Recommended)
```bash
go install github.com/unclesam-ly/git-air@latest
```
### Option 2: Build from Source
```bash
git clone https://github.com/unclesam-ly/git-air.git
cd git-air
go build -o git-air .
sudo mv git-air /usr/local/bin/
```
---
## 🚀 Quick Start
### 1. Configure Model & API Key
`git-air` comes with pre-configured endpoints and default models for all major AI providers. Simply pass `--provider`:
```bash
# 1. Google Gemini (Recommended: fast & cost-effective)
git air config set --provider gemini --key "YOUR_KEY"
# 2. Anthropic Claude (Claude-3.7 / 3.5 Sonnet)
git air config set --provider claude --key "YOUR_KEY" --model anthropic/claude-3.7-sonnet
# 3. xAI Grok (Grok-2 / Grok-3)
git air config set --provider grok --key "YOUR_KEY" --model grok-2-latest
# 4. DeepSeek (Deep reasoning)
git air config set --provider deepseek --key "YOUR_KEY" --model deepseek-chat
# 5. SiliconFlow (DeepSeek-V3 hosted)
git air config set --provider siliconflow --key "YOUR_KEY" --model deepseek-ai/DeepSeek-V3
# 6. Qwen / DashScope (Alibaba Cloud)
git air config set --provider qwen --key "YOUR_KEY" --model qwen-plus
# 7. Zhipu AI (GLM-4)
git air config set --provider zhipu --key "YOUR_KEY" --model glm-4-plus
# 8. Moonshot AI (Kimi)
git air config set --provider moonshot --key "YOUR_KEY"
# 9. Groq (Ultra-fast inference)
git air config set --provider groq --key "YOUR_KEY" --model llama-3.3-70b-versatile
# 10. OpenRouter (Universal AI Gateway)
git air config set --provider openrouter --key "YOUR_KEY"
# 11. 100% Offline Local Privacy (Ollama)
git air config set --provider ollama --model qwen2.5-coder
# 12. OpenAI Official (GPT-4o / o3-mini)
git air config set --provider openai --key "YOUR_KEY" --model gpt-4o-mini
# 13. Custom Token Pricing (Optional: override default rates, USD/1M tokens)
git air config set --price-input 0.75 --price-output 3.75
```
Check current configuration (including masked keys and custom rates):
```bash
git air config get
```
---
### 2. Everyday Commands
```bash
# Review currently staged changes (default)
git air
# Review the previous commit
git air HEAD~1
# Review differences between branches
git air main..feature-agent
# Review a specific file
git air internal/service/chat.go
# Temporarily override provider or model
git air --provider deepseek --model deepseek-chat
```
---
### 3. One-Click Git Pre-commit Hook
Install the hook inside any Git repository:
```bash
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`**!
- **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`.
To uninstall:
```bash
git air hook uninstall
```
---
## ⚙️ Configuration Hierarchy
`git-air` searches for configurations in the following priority order:
1. **CLI Flags** (`--key`, `--model`, `--provider`, `--prompt`, `--price-input`, `--price-output`, `--strict`, `--no-block`)
2. **Environment Variables** (`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`)
3. **Project Config** (`./config.yaml` or `./.git-air.yaml` in repo root)
4. **Global User Config** (`~/.git-air/config.yaml`)
### `config.yaml` Example Template:
```yaml
# Provider: gemini, claude, grok, deepseek, qwen, zhipu, moonshot, siliconflow, ollama, openai, custom
provider: "gemini"
api_key: "YOUR_API_KEY_HERE"
model: "gemini-3.7-flash"
# Custom Base URL (Optional)
# base_url: "https://api.deepseek.com/v1"
# Custom System Prompt (Optional)
# custom_prompt: ""
# Custom Token Pricing (Optional, in USD per 1M tokens)
# price_input: 0.75
# price_output: 3.75
```
---
## 📋 Custom Team Rules (`.airules`)
Create an `.airules` file in your repository root. `git-air` will automatically inject these rules into the model with highest priority:
```markdown
# Team Coding Guidelines
1. All SQL operations must reside in the DAO/repository layer. No raw SQL string concatenation;
2. Concurrent shared variable access must use sync.RWMutex or sync/atomic;
3. Opened resource handles (DB rows, HTTP resp.Body, files) must be immediately closed via defer;
4. Never discard errors with blank identifiers (_ = err).
```
---
## 🛡️ Custom Ignore Rules (`.airignore`)
In addition to built-in ignored lockfiles and binaries, create an `.airignore` file in your repository root to exclude specific paths from AI review:
```gitignore
# Ignore documentation & design specs
docs/
*.md
# Ignore test mocks and fixtures
tests/mock/
testdata/
# Ignore sensitive config files
*.env
secrets.yaml
```
---
## 📄 License
This project is licensed under the [MIT License](LICENSE). Contributions, PRs, and issues are welcome!
+257
View File
@@ -0,0 +1,257 @@
<p align="center">
<img src="assets/logo.png" width="180" height="180" alt="git-air logo" />
</p>
<h1 align="center">git-air 🍃</h1>
<p align="center">
<strong>Go言語で書かれた、空気のように軽快で高速なGitネイティブAIコードレビューツール。</strong>
</p>
<p align="center">
<a href="https://go.dev/"><img src="https://img.shields.io/badge/Go-1.26%2B-00ADD8?style=flat&logo=go" alt="Go Version" /></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License" /></a>
<a href="https://github.com/unclesam-ly/git-air/pulls"><img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg" alt="PRs Welcome" /></a>
</p>
<p align="center">
<a href="README.md">简体中文</a> |
<a href="README_EN.md">English</a> |
<a href="README_JA.md"><strong>日本語</strong></a>
</p>
---
## 🌟 なぜ git-air を開発したのか?
### 課題:AIが書いたコードは気持ちいいが、レビューは悪夢
現代の開発では、AI(Cursor、Copilot、各種LLMプラグイン)を活用することが当たり前になりました。数秒で数十行・数百行のコードが生成されるため、開発スピードは劇的に向上します。
**しかし、そこには見過ごせない重大な課題が存在します:**
- **「見た目が完璧すぎる」という錯覚**:インデントやコメント、命名が綺麗なため、バグがないと盲信しやすい;
- **生成AI自身の「自己肯定バイアス」**:同じチャット内で生成AIに「このコードにバグはある?」と聞いても、「問題なく堅牢に動作します!」と返ってくることが多い;
- **人間の目で見落としやすい潜在的な罠**:`if err != nil` の早期リターン時にロック解除(Mutex Leak)を忘れたり、Goroutine 内で `recover` がなかったり、ループ内でコネクションが解放されなかったりするバグは、目視では非常に見落としやすい。
その結果、**「AIがコードを大量生産し、深夜に人間が本番障害の対応に追われる」**という本末転倒な状況が生まれます。
---
### 解決策:レビューをローカルターミナル(Pre-Commit)に完全前置
GitHubなどのCI/CD上で動作するボットも優秀ですが、**`git-air` はコードを `git commit` する前に、ローカル端末上で動作する完全に独立した「第三者監査役」として設計されています。**
チャット履歴のコンテキストを一切排除し、まるで**あなたの隣に座る口は悪いが目の鋭いシニアアーキテクト**のように、純粋な差分(`git diff`)だけを厳しくチェックします:
- ⚡ **Gitネイティブな極上の操作性**:いつもの作業フローを崩さず、ターミナルで `git air` と叩くだけで即座に起動。
- 🧠 **主要モデルを完全網羅**:**Google Gemini 3.x、Anthropic Claude、xAI Grok、DeepSeek、Qwen、智譜 GLM、Kimi、ローカルOllama、OpenAI** などを設定なしで即座に切り替え可能。
- 🛡️ **スマートなノイズ除去**:`go.sum` や `package-lock.json`、自動生成された `*.pb.go` を自動除外。Token消費を抑え、思考ループを防ぎます。
- 🎯 **厳格なシニアエンジニア基準**:お世辞や無駄口は一切なし。並行処理の競合、デッドロック、SQLインジェクション、NULLポインタ、リソースリークを指摘し、具体的な修正コードを直接提示します。
- 📊 **Token消費量と費用のリアルタイム見積もり**:入出力Token数を正確に集計し、各社最新の公式料金表に基づいて実行コストを即座に計算(ローカル完全オフラインモデルは無料表示)。
- 📋 **チーム規約の読み込み(`.airules`)**:リポジトリ直下に `.airules` を配置するだけで、チーム固有のアーキテクチャ規約を最優先で適用。
- 🪝 **ワンクリックで Pre-commit フック化**:コマンド1発でGitフックとして登録。危険なコードのコミットを未然にブロックします。
---
## 🖥️ ターミナル実行イメージ
```text
$ git air
[git-air] コードレビュー中... (Engine: gemini / gemini-3.7-flash)
─────────────────────────────────────────────────────────────────
#### 変更概要
ユーザー認証ハンドラーに Redis キャッシュ層を追加し、Context のタイムアウト伝播を修正。
#### 詳細レビュー結果
- [BLOCKER] internal/service/user.go:45 - 重大なセキュリティ脆弱性: ループ内で生SQL文字列を直接結合しています。SQLインジェクションのリスクがあります。
// 推奨修正コード:
db.Where("username = ?", inputName).First(&user)
- [WARNING] internal/service/user.go:82 - 潜在的リスク: Redis 接続エラーを握りつぶしています。キャッシュ障害時にDBへ負荷が集中します。
- [WARNING] internal/service/user.go:103 - 並行処理の欠陥: 早期リターン時に Mutex の Unlock を忘れています(Mutex Leak)。デッドロックの危険性があります。
#### 結論
- 判定: [REJECT]
- スコア: 60 / 100
📊 Token: 入力 1,243 / 出力 412 | ≈ $0.0025
─────────────────────────────────────────────────────────────────
```
---
## 📦 インストール
### 方法 1: Go Install(推奨)
```bash
go install github.com/unclesam-ly/git-air@latest
```
### 方法 2: ソースコードからビルド
```bash
git clone https://github.com/unclesam-ly/git-air.git
cd git-air
go build -o git-air .
sudo mv git-air /usr/local/bin/
```
---
## 🚀 クイックスタート
### 1. モデルと API Key の設定
`git-air` は国内外の主要LLMのエンドポイントと推奨モデルを標準プリセットしています。`--provider` を指定するだけで簡単に切り替えられます:
```bash
# 1. Google Gemini (推奨: 高速・低コスト)
git air config set --provider gemini --key "YOUR_KEY"
# 2. Anthropic Claude (Claude-3.7 / 3.5 Sonnet)
git air config set --provider claude --key "YOUR_KEY" --model anthropic/claude-3.7-sonnet
# 3. xAI Grok (Grok-2 / Grok-3)
git air config set --provider grok --key "YOUR_KEY" --model grok-2-latest
# 4. DeepSeek (高度な推論・思考モデル)
git air config set --provider deepseek --key "YOUR_KEY" --model deepseek-chat
# 5. SiliconFlow (DeepSeek-V3 高速ホスティング)
git air config set --provider siliconflow --key "YOUR_KEY" --model deepseek-ai/DeepSeek-V3
# 6. 通義千問 (Qwen / Alibaba Cloud)
git air config set --provider qwen --key "YOUR_KEY" --model qwen-plus
# 7. 智譜 AI (GLM-4)
git air config set --provider zhipu --key "YOUR_KEY" --model glm-4-plus
# 8. Moonshot AI (Kimi)
git air config set --provider moonshot --key "YOUR_KEY"
# 9. Groq (超高速推論)
git air config set --provider groq --key "YOUR_KEY" --model llama-3.3-70b-versatile
# 10. OpenRouter (マルチモデル統合ゲートウェイ)
git air config set --provider openrouter --key "YOUR_KEY"
# 11. ローカル完全オフライン・プライバシー重視 (Ollama)
git air config set --provider ollama --model qwen2.5-coder
# 12. OpenAI 公式 (GPT-4o / o3-mini)
git air config set --provider openai --key "YOUR_KEY" --model gpt-4o-mini
# 13. カスタム Token 料金設定 (任意:組み込み料金表を上書き、単位: 米ドル/1M Tokens)
git air config set --price-input 0.75 --price-output 3.75
```
現在の設定を確認(カスタム料金やマスク化Keyを含む):
```bash
git air config get
```
---
### 2. よく使うレビューコマンド
```bash
# 現在ステージングされた(git add済み)変更をレビュー(デフォルト)
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
```
---
### 3. ワンタッチで Pre-commit フックを登録
Git リポジトリ直下で以下を実行:
```bash
git air hook install
```
- **コミット自動ブロック機能**:レビューで `[BLOCKER]` 重大欠陥や `[REJECT]` 判定が検出された場合、`git-air` は非ゼロの終了コードで**コミット(`git commit`)を自動的に中断・ブロック**します!
- **厳格モード (`--strict`)**:`[WARNING]` 警告でもコミットをブロックしたい場合は `git air --strict` を使用;
- **一時的なブロック解除**:緊急でコミットを強制したい場合は、Git 標準の `git commit --no-verify` または `git air --no-block` を付与します。
フックを解除する場合:
```bash
git air hook uninstall
```
---
## ⚙️ 設定の優先順位
1. **コマンドライン引数** (`--key`, `--model`, `--provider`, `--prompt`, `--price-input`, `--price-output`, `--strict`, `--no-block`)
2. **環境変数** (`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`)
3. **プロジェクト個別設定** (リポジトリ直下の `./config.yaml` または `./.git-air.yaml`)
4. **グローバル設定** (`~/.git-air/config.yaml`)
### `config.yaml` 設定ファイル例:
```yaml
# プロバイダー: gemini, claude, grok, deepseek, qwen, zhipu, moonshot, siliconflow, ollama, openai, custom
provider: "gemini"
api_key: "YOUR_API_KEY_HERE"
model: "gemini-3.7-flash"
# カスタム API エンドポイント (任意)
# base_url: "https://api.deepseek.com/v1"
# カスタム System Prompt (任意)
# custom_prompt: ""
# カスタム Token 料金設定 (任意、単位: 米ドル / 1M Tokens)
# price_input: 0.75
# price_output: 3.75
```
---
## 📋 チーム規約のカスタマイズ(`.airules`)
リポジトリのルートディレクトリに `.airules` ファイルを作成すると、AI はそのルールを最優先事項として適用します:
```markdown
# チーム共通開発ルール
1. すべての SQL 処理は DAO 層に集約し、文字列結合による生 SQL は禁止とする;
2. 並行処理で共有変数を扱う場合は、sync.RWMutex または sync/atomic を使用すること;
3. オープンしたリソース(DB rows、HTTP レスポンス、ファイル)は defer Close() で確実に解放すること;
4. error をアンダースコア(_ = err)で無条件に無視することを禁止する。
```
---
## 🛡️ カスタム除外設定(`.airignore`)
組み込みの自動除外(ロックファイルやバイナリ)に加え、リポジトリ直下に `.airignore` を配置することで、任意のファイルやディレクトリをAIレビューから除外できます:
```gitignore
# ドキュメントや設計書を除外
docs/
*.md
# テストモックやダミーデータを除外
tests/mock/
testdata/
# 機密設定ファイルを除外
*.env
secrets.yaml
```
---
## 📄 ライセンス
本プロジェクトは [MIT ライセンス](LICENSE) のもとで公開されています。Issue や Pull Request も大歓迎です!
+39 -24
View File
@@ -2,21 +2,23 @@ package cmd
import ( import (
"fmt" "fmt"
"git-air/internal/ui"
"os" "os"
"path/filepath" "path/filepath"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/unclesam-ly/git-air/internal/ui"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// AppConfig 全局配置结构体 // AppConfig 全局配置结构体
type AppConfig struct { type AppConfig struct {
Provider string `yaml:"provider"` // gemini, deepseek, ollama, openai, custom Provider string `yaml:"provider"` // gemini, deepseek, ollama, openai, custom
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 string `yaml:"custom_prompt"` // 用户自定义的 Base System Prompt
PriceInput float64 `yaml:"price_input,omitempty"` // 自定义输入单价 (每 1M tokens 美元,可选)
PriceOutput float64 `yaml:"price_output,omitempty"` // 自定义输出单价 (每 1M tokens 美元,可选)
} }
func getConfigPath() (string, error) { func getConfigPath() (string, error) {
@@ -31,34 +33,34 @@ func getConfigPath() (string, error) {
return filepath.Join(dir, "config.yaml"), nil return filepath.Join(dir, "config.yaml"), nil
} }
// LoadConfig 读取全局配置(优先使用环境变量,其次读取 yaml 文件) // LoadConfig 智能读取配置:
// 1. 先读取用户全局配置 ~/.git-air/config.yaml (保证 APIKey 等全局生效)
// 2. 若当前项目存在 .git-air.yaml 或 git-air.yaml,则进行项目级增量覆盖
// 3. 环境变量最高优先级覆盖
func LoadConfig() (*AppConfig, error) { func LoadConfig() (*AppConfig, error) {
cfg := &AppConfig{ cfg := &AppConfig{
Provider: "gemini", Provider: "gemini",
Model: "gemini-3.7-flash", Model: "gemini-3.7-flash",
} }
// 1. 优先探测当前项目根目录下的配置文件 // 1. 首先加载全局配置 ~/.git-air/config.yaml
localCandidates := []string{"config.yaml", ".git-air.yaml", ".git-air.yml", "configs/config.yaml"} configPath, err := getConfigPath()
loaded := false if err != nil {
// 隐式跳过时,可考虑记录日志或在 verbose 模式下输出
for _, name := range localCandidates { } else if data, err := os.ReadFile(configPath); err == nil && len(data) > 0 {
if data, err := os.ReadFile(name); err == nil && len(data) > 0 { if err := yaml.Unmarshal(data, cfg); err != nil {
if err := yaml.Unmarshal(data, cfg); err != nil { return nil, fmt.Errorf("解析全局配置文件 [%s] 失败: %w", configPath, err)
return nil, fmt.Errorf("解析配置文件失败: %w", err)
}
loaded = true
break
} }
} }
// 2. 如果项目根目录下没有,则回退读取用户全局配置 ~/.git-air/config.yaml // 2. 检查当前项目是否有专属于 git-air 的配置文件 (.git-air.yaml / git-air.yaml) 进行增量覆盖
if !loaded { localCandidates := []string{".git-air.yaml", ".git-air.yml", "git-air.yaml"}
configPath, err := getConfigPath() for _, name := range localCandidates {
if err == nil { if data, err := os.ReadFile(name); err == nil && len(data) > 0 {
if data, err := os.ReadFile(configPath); err == nil { if err := yaml.Unmarshal(data, cfg); err != nil {
_ = yaml.Unmarshal(data, cfg) return nil, fmt.Errorf("解析项目配置文件 [%s] 失败: %w", name, err)
} }
break
} }
} }
@@ -126,6 +128,12 @@ var configSetCmd = &cobra.Command{
if pr, _ := cmd.Flags().GetString("prompt"); pr != "" { if pr, _ := cmd.Flags().GetString("prompt"); pr != "" {
cfg.CustomPrompt = pr cfg.CustomPrompt = pr
} }
if pi, _ := cmd.Flags().GetFloat64("price-input"); pi > 0 {
cfg.PriceInput = pi
}
if po, _ := cmd.Flags().GetFloat64("price-output"); po > 0 {
cfg.PriceOutput = po
}
if err := SaveConfig(cfg); err != nil { if err := SaveConfig(cfg); err != nil {
ui.PrintError("保存配置失败: %v", err) ui.PrintError("保存配置失败: %v", err)
return return
@@ -160,6 +168,11 @@ var configGetCmd = &cobra.Command{
hasPrompt = "是 (已自定义)" hasPrompt = "是 (已自定义)"
} }
fmt.Printf("CustomPrompt: %s\n", hasPrompt) fmt.Printf("CustomPrompt: %s\n", hasPrompt)
if cfg.PriceInput > 0 || cfg.PriceOutput > 0 {
fmt.Printf("CustomPrice: 输入 $%.4f/1M, 输出 $%.4f/1M (已自定义覆盖)\n", cfg.PriceInput, cfg.PriceOutput)
} else {
fmt.Printf("CustomPrice: 默认 (跟随官方最新价格表)\n")
}
}, },
} }
@@ -169,6 +182,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().Float64("price-input", 0, "自定义每 1M 输入 Token 美元价格")
configSetCmd.Flags().Float64("price-output", 0, "自定义每 1M 输出 Token 美元价格")
configCmd.AddCommand(configSetCmd) configCmd.AddCommand(configSetCmd)
configCmd.AddCommand(configGetCmd) configCmd.AddCommand(configGetCmd)
+3 -4
View File
@@ -1,19 +1,18 @@
package cmd package cmd
import ( import (
"git-air/internal/git"
"git-air/internal/ui"
"os" "os"
"path/filepath" "path/filepath"
"github.com/unclesam-ly/git-air/internal/git"
"github.com/unclesam-ly/git-air/internal/ui"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
const preCommitScript = `#!/bin/sh const preCommitScript = `#!/bin/sh
# git-air pre-commit hook # git-air pre-commit hook
echo "[git-air] 正在执行 Commit 前代码自动化评审..." echo "[git-air] 正在执行 Commit 前代码自动化评审..."
git-air --cached git-air --staged
# 如果评审发现阻断性问题,可通过退出码阻断 commit
` `
var hookCmd = &cobra.Command{ var hookCmd = &cobra.Command{
+52 -10
View File
@@ -2,13 +2,14 @@ package cmd
import ( import (
"context" "context"
"git-air/internal/git"
"git-air/internal/llm"
"git-air/internal/reviewer"
"git-air/internal/ui"
"os" "os"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/unclesam-ly/git-air/internal/git"
"github.com/unclesam-ly/git-air/internal/llm"
"github.com/unclesam-ly/git-air/internal/reviewer"
"github.com/unclesam-ly/git-air/internal/ui"
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
@@ -16,10 +17,17 @@ var rootCmd = &cobra.Command{
Short: "git-air: 极轻量、极速的 Git 原生 AI 代码评审工具", Short: "git-air: 极轻量、极速的 Git 原生 AI 代码评审工具",
Long: `git-air 是一个纯 Go 编写的 Git AI 评审利器。 Long: `git-air 是一个纯 Go 编写的 Git AI 评审利器。
支持一键评审暂存区、指定 Commit、分支差异或单文件,无缝兼容 Gemini、DeepSeek、Ollama 和 OpenAI。`, 支持一键评审暂存区、指定 Commit、分支差异或单文件,无缝兼容 Gemini、DeepSeek、Ollama 和 OpenAI。`,
Args: cobra.ArbitraryArgs,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
ctx := cmd.Context() ctx := cmd.Context()
// 1. 检查并获取 Diff // 1. 检查并获取 Diff
if flagCached, _ := cmd.Flags().GetBool("cached"); flagCached {
args = append([]string{"--cached"}, args...)
} else if flagStaged, _ := cmd.Flags().GetBool("staged"); flagStaged {
args = append([]string{"--cached"}, args...)
}
diff, err := git.GetDiff(ctx, args...) diff, err := git.GetDiff(ctx, args...)
if err != nil { if err != nil {
ui.PrintError("%v", err) ui.PrintError("%v", err)
@@ -46,10 +54,12 @@ var rootCmd = &cobra.Command{
// 3. 初始化大模型客户端 // 3. 初始化大模型客户端
llmClient, err := llm.NewClient(llm.Config{ llmClient, err := llm.NewClient(llm.Config{
Provider: cfg.Provider, Provider: cfg.Provider,
APIKey: cfg.APIKey, APIKey: cfg.APIKey,
BaseURL: cfg.BaseURL, BaseURL: cfg.BaseURL,
Model: cfg.Model, Model: cfg.Model,
PriceInput: cfg.PriceInput,
PriceOutput: cfg.PriceOutput,
}) })
if err != nil { if err != nil {
ui.PrintError("初始化模型失败: %v (提示: 请先执行 'git air config set --key YOUR_KEY')", err) ui.PrintError("初始化模型失败: %v (提示: 请先执行 'git air config set --key YOUR_KEY')", err)
@@ -69,15 +79,43 @@ var rootCmd = &cobra.Command{
// 6. 打印开场横幅并开始流式评审 // 6. 打印开场横幅并开始流式评审
ui.PrintBanner(cfg.Provider, cfg.Model) ui.PrintBanner(cfg.Provider, cfg.Model)
printer := ui.NewStreamPrinter() printer := ui.NewStreamPrinter()
_, err = rev.Execute(ctx, diff, true, func(chunk string) { var reviewOutput strings.Builder
_, usage, err := rev.Execute(ctx, diff, true, func(chunk string) {
reviewOutput.WriteString(chunk)
printer.PrintChunk(chunk) printer.PrintChunk(chunk)
}) })
if err != nil { if err != nil {
ui.PrintError("\n评审过程中发生错误: %v", err) ui.PrintError("\n评审过程中发生错误: %v", err)
return os.Exit(1)
}
// 7. 打印 Token 消耗统计
if usage != nil {
ui.PrintUsage(usage.InputTokens, usage.OutputTokens, usage.EstimateCost())
} }
ui.PrintFooter() ui.PrintFooter()
// 8. 门禁阻断检测 (Pre-commit 拦截逻辑)
outText := reviewOutput.String()
hasBlocker := strings.Contains(outText, "[BLOCKER]") || strings.Contains(outText, "[CRITICAL]")
hasReject := strings.Contains(outText, "[REJECT]")
hasWarning := strings.Contains(outText, "[WARNING]") || strings.Contains(outText, "[WARN]")
strict, _ := cmd.Flags().GetBool("strict")
noBlock, _ := cmd.Flags().GetBool("no-block")
if !noBlock {
if hasBlocker || hasReject {
ui.PrintError("[git-air] 审查未通过:检测到 [BLOCKER] 严重缺陷或 [REJECT] 结论,已拦截提交!")
ui.PrintError("修复上述问题后再提交;如需临时跳过可执行 'git commit --no-verify' 或加上 '--no-block'")
os.Exit(1)
} else if strict && hasWarning {
ui.PrintError("[git-air] 严格模式(--strict)生效:检测到 [WARNING] 警告,已拦截提交!")
ui.PrintError("请优化上述警告或去掉 --strict 标志。")
os.Exit(1)
}
}
}, },
} }
@@ -98,4 +136,8 @@ func init() {
rootCmd.Flags().StringP("model", "m", "", "临时指定模型名称") rootCmd.Flags().StringP("model", "m", "", "临时指定模型名称")
rootCmd.Flags().StringP("provider", "p", "", "临时指定 Provider (gemini, deepseek, ollama)") rootCmd.Flags().StringP("provider", "p", "", "临时指定 Provider (gemini, deepseek, ollama)")
rootCmd.Flags().String("prompt", "", "临时指定 System Prompt") rootCmd.Flags().String("prompt", "", "临时指定 System Prompt")
rootCmd.Flags().Bool("cached", false, "显式审查暂存区(git add)代码")
rootCmd.Flags().Bool("staged", false, "显式审查暂存区(git add)代码")
rootCmd.Flags().Bool("strict", false, "严格模式:遇到 WARNING 也会阻断退出")
rootCmd.Flags().Bool("no-block", false, "非阻断模式:即使发现 BLOCKER 也不以非零退出码阻断")
} }
+7
View File
@@ -31,3 +31,10 @@ model: "gemini-3.7-flash"
# 自定义全局评审提示词 (可选,留空则使用内置资深架构师准则) # 自定义全局评审提示词 (可选,留空则使用内置资深架构师准则)
custom_prompt: "" custom_prompt: ""
# 自定义模型 Token 计费费率 (可选,单位:美元 / 每 1M Tokens)
# 默认情况下 git-air 内置了各主流模型官方最新价格表;
# 大模型厂商价格可能动态调整,请以实际价格为准
# 若你的中转平台价格有变或使用私有计费,可在此自定义覆盖:
# price_input: 0.75 # 每 1M 输入 Token 美元单价
# price_output: 3.75 # 每 1M 输出 Token 美元单价
+1 -1
View File
@@ -1,4 +1,4 @@
module git-air module github.com/unclesam-ly/git-air
go 1.26.6 go 1.26.6
+5 -2
View File
@@ -43,6 +43,9 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
return "", ErrNotGitRepo return "", ErrNotGitRepo
} }
repoRoot, _ := GetRepoRoot(ctx)
customIgnores := LoadAirIgnore(repoRoot)
var cmdArgs []string var cmdArgs []string
if len(args) == 0 { if len(args) == 0 {
// 1. 默认先提取暂存区代码 (准备 commit 的代码) // 1. 默认先提取暂存区代码 (准备 commit 的代码)
@@ -51,7 +54,7 @@ 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)), nil return truncateDiffIfNeeded(FilterDiff(stagedDiff, customIgnores...)), nil
} }
// 2. 如果暂存区为空,回退提取工作区未暂存的修改 // 2. 如果暂存区为空,回退提取工作区未暂存的修改
cmdArgs = []string{} cmdArgs = []string{}
@@ -64,7 +67,7 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
return "", err return "", err
} }
filteredDiff := FilterDiff(rawDiff) filteredDiff := FilterDiff(rawDiff, customIgnores...)
if strings.TrimSpace(filteredDiff) == "" { if strings.TrimSpace(filteredDiff) == "" {
return "", ErrEmptyDiff return "", ErrEmptyDiff
} }
+72 -14
View File
@@ -1,13 +1,14 @@
package git package git
import ( import (
"bufio"
"os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
) )
// 默认需要忽略的文件名与后缀列表 // 默认需要忽略的文件名与后缀列表
var ignoredPatterns = []string{ var defaultIgnoredPatterns = []string{
// 依赖与锁文件 // 依赖与锁文件
"go.sum", "go.sum",
"package-lock.json", "package-lock.json",
@@ -36,19 +37,45 @@ var ignoredPatterns = []string{
"*.pdf", "*.pdf",
} }
// isIgnoredFile 判断文件路径是否应该在 Code Review 中被忽略 // LoadAirIgnore 从仓库根目录加载 .airignore 文件中的自定义忽略规则
func isIgnoredFile(filePath string) bool { func LoadAirIgnore(repoRoot string) []string {
cleanPath := filepath.ToSlash(filePath) if repoRoot == "" {
return nil
}
ignoreFile := filepath.Join(repoRoot, ".airignore")
file, err := os.Open(ignoreFile)
if err != nil {
return nil
}
defer file.Close()
var patterns []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
trimmed := strings.TrimSpace(scanner.Text())
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
patterns = append(patterns, trimmed)
}
return patterns
}
// isIgnoredFile 判断文件路径是否命中忽略规则(内置规则 + 用户自定义规则)
func isIgnoredFile(filePath string, customPatterns []string) bool {
cleanPath := filepath.ToSlash(strings.TrimSpace(filePath))
baseName := filepath.Base(cleanPath) baseName := filepath.Base(cleanPath)
for _, pattern := range ignoredPatterns { // 1. 先匹配用户自定义规则 (.airignore)
// 精确匹配文件名(如 go.sum) for _, pattern := range customPatterns {
if pattern == baseName { if matchPattern(pattern, cleanPath, baseName) {
return true return true
} }
}
// 通配符匹配(如 *.pb.go) // 2. 再匹配内置默认规则
if matched, _ := filepath.Match(pattern, baseName); matched { for _, pattern := range defaultIgnoredPatterns {
if matchPattern(pattern, cleanPath, baseName) {
return true return true
} }
} }
@@ -56,10 +83,41 @@ func isIgnoredFile(filePath string) bool {
return false return false
} }
var diffHeaderRegex = regexp.MustCompile(`(?m)^diff --git a/(.*?) b/(.*?)$`) // matchPattern 单条规则智能匹配(支持目录/、通配符*、精确匹配)
func matchPattern(pattern, cleanPath, baseName string) bool {
pattern = filepath.ToSlash(strings.TrimSpace(pattern))
if pattern == "" {
return false
}
// FilterDiff 过滤掉 diff 中无意义的锁文件和自动生成文件的区块 // 目录规则:如 "docs/" 或 "vendor/"
func FilterDiff(rawDiff string) string { if strings.HasSuffix(pattern, "/") {
dirPrefix := strings.TrimSuffix(pattern, "/")
if strings.HasPrefix(cleanPath, dirPrefix+"/") || cleanPath == dirPrefix {
return true
}
}
// 精确匹配文件名或相对路径
if pattern == baseName || pattern == cleanPath {
return true
}
// 通配符匹配文件名(如 *.pb.go)
if matched, _ := filepath.Match(pattern, baseName); matched {
return true
}
// 通配符匹配全路径(如 mock/*.go)
if matched, _ := filepath.Match(pattern, cleanPath); matched {
return true
}
return false
}
// FilterDiff 过滤掉 diff 中命中忽略规则(如锁文件、生成的代码、.airignore 排除项)的文件区块
func FilterDiff(rawDiff string, customPatterns ...string) string {
if strings.TrimSpace(rawDiff) == "" { if strings.TrimSpace(rawDiff) == "" {
return "" return ""
} }
@@ -85,7 +143,7 @@ func FilterDiff(rawDiff string) string {
parts := strings.Fields(firstLine) parts := strings.Fields(firstLine)
if len(parts) >= 2 { if len(parts) >= 2 {
filePath := strings.TrimPrefix(parts[1], "b/") filePath := strings.TrimPrefix(parts[1], "b/")
if isIgnoredFile(filePath) { if isIgnoredFile(filePath, customPatterns) {
continue // 命中忽略规则,跳过这个文件的 diff continue // 命中忽略规则,跳过这个文件的 diff
} }
} }
+112
View File
@@ -0,0 +1,112 @@
package git
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestIsIgnoredFile(t *testing.T) {
customPatterns := []string{
"docs/",
"tests/mock/*.json",
"secrets.env",
}
tests := []struct {
filePath string
expected bool
}{
// 默认内置忽略规则
{"go.sum", true},
{"package-lock.json", true},
{"api/user.pb.go", true},
{"web/dist/app.min.js", true},
{"assets/logo.png", true},
// 用户自定义 .airignore 规则
{"docs/readme.md", true},
{"docs/api/v1.md", true},
{"tests/mock/user.json", true},
{"secrets.env", true},
// 不应被忽略的正常业务代码
{"cmd/root.go", false},
{"internal/service/user.go", false},
{"pkg/utils/helper.go", false},
}
for _, tt := range tests {
got := isIgnoredFile(tt.filePath, customPatterns)
if got != tt.expected {
t.Errorf("isIgnoredFile(%q) = %v, expected %v", tt.filePath, got, tt.expected)
}
}
}
func TestLoadAirIgnore(t *testing.T) {
tempDir, err := os.MkdirTemp("", "git-air-test-*")
if err != nil {
t.Fatalf("创建临时目录失败: %v", err)
}
defer os.RemoveAll(tempDir)
content := `# 忽略文档
docs/
# 忽略临时文件
*.tmp
*.log
`
ignoreFile := filepath.Join(tempDir, ".airignore")
if err := os.WriteFile(ignoreFile, []byte(content), 0644); err != nil {
t.Fatalf("写入 .airignore 失败: %v", err)
}
patterns := LoadAirIgnore(tempDir)
expected := []string{"docs/", "*.tmp", "*.log"}
if len(patterns) != len(expected) {
t.Fatalf("LoadAirIgnore 返回数量不符: got %v, expected %v", patterns, expected)
}
for i, p := range patterns {
if p != expected[i] {
t.Errorf("Pattern[%d] = %q, expected %q", i, p, expected[i])
}
}
}
func TestFilterDiff(t *testing.T) {
rawDiff := `diff --git a/go.sum b/go.sum
index 111..222 100644
--- a/go.sum
+++ b/go.sum
@@ -1 +1 @@
+some-package v1.0.0 h1:...
diff --git a/internal/service/user.go b/internal/service/user.go
index 333..444 100644
--- a/internal/service/user.go
+++ b/internal/service/user.go
@@ -1 +1 @@
+func HandleUser() {}
diff --git a/docs/spec.md b/docs/spec.md
index 555..666 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -1 +1 @@
+# API Spec
`
customPatterns := []string{"docs/"}
filtered := FilterDiff(rawDiff, customPatterns...)
if strings.Contains(filtered, "go.sum") {
t.Errorf("FilterDiff 应当过滤掉 go.sum")
}
if strings.Contains(filtered, "docs/spec.md") {
t.Errorf("FilterDiff 应当过滤掉 docs/spec.md")
}
if !strings.Contains(filtered, "internal/service/user.go") {
t.Errorf("FilterDiff 应当保留正常的业务代码 internal/service/user.go")
}
}
+150 -14
View File
@@ -12,16 +12,20 @@ import (
// Config 大模型统一配置 // Config 大模型统一配置
type Config struct { type Config struct {
Provider string // "gemini", "deepseek", "openai", "ollama", "custom" Provider string // "gemini", "deepseek", "openai", "ollama", "custom"
APIKey string // 密钥 (Ollama 本地可填任意字符或留空) APIKey string // 密钥 (Ollama 本地可填任意字符或留空)
BaseURL string // 自定义接口地址 (非必填,会自动根据 Provider 设置默认值) BaseURL string // 自定义接口地址 (非必填,会自动根据 Provider 设置默认值)
Model string // 模型名称 (非必填,会自动设置默认主力模型) Model string // 模型名称 (非必填,会自动设置默认主力模型)
PriceInput float64 // 用户自定义每 1M 输入 Token 美元价格 (可选,覆盖内置价格表)
PriceOutput float64 // 用户自定义每 1M 输出 Token 美元价格 (可选,覆盖内置价格表)
} }
// Client 统一大模型客户端 // Client 统一大模型客户端
type Client struct { type Client struct {
client *openai.Client client *openai.Client
model string model string
priceInput float64
priceOutput float64
} }
// NewClient 初始化客户端 // NewClient 初始化客户端
@@ -227,8 +231,10 @@ func NewClient(cfg Config) (*Client, error) {
clientConfig.BaseURL = baseURL clientConfig.BaseURL = baseURL
return &Client{ return &Client{
client: openai.NewClientWithConfig(clientConfig), client: openai.NewClientWithConfig(clientConfig),
model: model, model: model,
priceInput: cfg.PriceInput,
priceOutput: cfg.PriceOutput,
}, nil }, nil
} }
@@ -254,8 +260,122 @@ func (c *Client) Review(ctx context.Context, systemPrompt, diff string) (string,
return resp.Choices[0].Message.Content, nil return resp.Choices[0].Message.Content, nil
} }
// ReviewStream 流式逐字返回评审结果(打字机效果) // Usage 记录一次调用的 Token 消耗
func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, onChunk func(chunk string)) error { type Usage struct {
InputTokens int
OutputTokens int
Model string
Provider string
CustomPriceInput float64 // 用户自定义输入单价(每 1M tokens 美元,>0 优先使用)
CustomPriceOutput float64 // 用户自定义输出单价(每 1M tokens 美元,>0 优先使用)
}
// reviewPriceTable 各模型每 1M token 的美元单价(输入/输出)
// 数据来源:各厂商官方定价页面,更新于 2026-08
// 如实际价格有变动,可在 config.yaml 中通过 price_input/price_output 字段自定义覆盖
var reviewPriceTable = map[string][2]float64{
// ── Google Gemini ─────────────────────────────────────────────
// 3.x 系列(当前主力)
"gemini-3.7-flash": {0.75, 3.75}, // 推介价至 2026-12-31,之后 1.50/7.50
"gemini-3.6-flash": {1.50, 7.50},
"gemini-3.5-flash": {1.50, 9.00},
"gemini-3.5-flash-lite": {0.30, 2.50},
"gemini-3.1-flash-lite": {0.25, 1.50},
"gemini-3.1-pro": {2.00, 12.00},
// 2.5 系列(2026-10 退役)
"gemini-2.5-pro": {1.25, 10.00},
"gemini-2.5-flash": {0.30, 2.50},
"gemini-2.5-flash-lite": {0.10, 0.40},
// 旧款(兼容)
"gemini-2.0-flash": {0.10, 0.40},
// ── DeepSeek ─────────────────────────────────────────────────
// 注意:DeepSeek 已启用峰谷定价,以下为非高峰标准价
"deepseek-v4-flash": {0.22, 0.66},
"deepseek-chat": {0.22, 0.66}, // V4-Flash 别名
"deepseek-reasoner": {0.55, 2.19},
// ── Anthropic Claude ──────────────────────────────────────────
"claude-opus-5": {5.00, 25.00},
"claude-sonnet-5": {2.00, 10.00}, // 推介价至 2026-08-31
"anthropic/claude-sonnet-5": {2.00, 10.00},
"claude-haiku-4-5": {1.00, 5.00},
"anthropic/claude-haiku-4-5": {1.00, 5.00},
// 旧款(兼容)
"anthropic/claude-3.7-sonnet": {3.00, 15.00},
"anthropic/claude-3.5-sonnet": {3.00, 15.00},
"anthropic/claude-3.5-haiku": {0.80, 4.00},
// ── xAI Grok ─────────────────────────────────────────────────
"grok-4.6": {2.00, 6.00}, // >200k tokens 翻倍
"grok-4.3": {1.25, 2.50},
"grok-2-latest": {2.00, 6.00}, // 旧款映射
// ── OpenAI ───────────────────────────────────────────────────
"gpt-5.6-sol": {5.00, 30.00},
"gpt-5.6-terra": {2.00, 12.00},
"gpt-5.6-luna": {0.20, 1.20},
"gpt-4o": {2.50, 10.00}, // 旧款保留
"gpt-4o-mini": {0.15, 0.60},
"o3-mini": {1.10, 4.40},
// ── SiliconFlow(聚合平台,按 DeepSeek V4 主力估算)────────────
"deepseek-ai/DeepSeek-V3": {0.22, 0.66},
"deepseek-ai/DeepSeek-R1": {0.55, 2.19},
// ── Qwen / 阿里通义千问 ───────────────────────────────────────
"qwen-3.8-max": {2.00, 6.00},
"qwen-max": {2.00, 6.00},
"qwen-plus": {0.40, 1.20},
"qwen-3.7-flash": {0.03, 0.13},
// ── Kimi / Moonshot AI ────────────────────────────────────────
"kimi-k3": {3.00, 15.00},
"moonshot-v1-8k": {0.95, 4.00},
"moonshot-v1-32k": {1.90, 8.00},
"moonshot-v1-128k": {3.50, 15.00},
// ── 智谱 GLM ─────────────────────────────────────────────────
"glm-5.3": {1.40, 4.40},
"glm-5.2": {1.40, 4.40},
"glm-4-plus": {1.40, 4.40},
// ── Doubao / 字节跳动 ─────────────────────────────────────────
"doubao-seed-2.1-pro": {0.88, 4.42},
"doubao-pro-32k": {0.88, 4.42},
// ── Groq(超快推理,按量计费)────────────────────────────────
"llama-3.3-70b-versatile": {0.59, 0.79},
"llama-3.1-8b-instant": {0.05, 0.08},
// ── 本地模型(完全免费)──────────────────────────────────────
"ollama": {0, 0},
"local-model": {0, 0},
}
// EstimateCost 估算费用(美元),返回 -1 表示无定价数据
func (u *Usage) EstimateCost() float64 {
// 1. 优先使用用户在 config.yaml 中配置的自定义单价(每 1M tokens 美元)
if u.CustomPriceInput > 0 || u.CustomPriceOutput > 0 {
inputCost := float64(u.InputTokens) / 1_000_000 * u.CustomPriceInput
outputCost := float64(u.OutputTokens) / 1_000_000 * u.CustomPriceOutput
return inputCost + outputCost
}
// 2. 回退使用内置官方模型价格表
prices, ok := reviewPriceTable[u.Model]
if !ok {
return -1
}
// Ollama / 本地模型 price = 0,直接返回 0
inputCost := float64(u.InputTokens) / 1_000_000 * prices[0]
outputCost := float64(u.OutputTokens) / 1_000_000 * prices[1]
return inputCost + outputCost
}
// ReviewStream 流式逐字返回评审结果(打字机效果),同时返回 Token 使用量
func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, onChunk func(chunk string)) (*Usage, error) {
stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{ stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
Model: c.model, Model: c.model,
Messages: []openai.ChatCompletionMessage{ Messages: []openai.ChatCompletionMessage{
@@ -264,18 +384,27 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on
}, },
Temperature: 0.2, Temperature: 0.2,
Stream: true, Stream: true,
StreamOptions: &openai.StreamOptions{
IncludeUsage: true,
},
}) })
if err != nil { if err != nil {
return fmt.Errorf("创建流式请求失败: %w", err) return nil, fmt.Errorf("创建流式请求失败: %w", err)
} }
if stream != nil { if stream != nil {
defer stream.Close() defer stream.Close()
} }
usage := &Usage{
Model: c.model,
CustomPriceInput: c.priceInput,
CustomPriceOutput: c.priceOutput,
}
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return nil, ctx.Err()
default: default:
} }
@@ -284,8 +413,15 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on
break break
} }
if err != nil { if err != nil {
return fmt.Errorf("流式读取异常: %w", err) return nil, fmt.Errorf("流式读取异常: %w", err)
} }
// 捕获最后一个 chunk 中携带的 Usage 信息
if response.Usage.TotalTokens > 0 {
usage.InputTokens = response.Usage.PromptTokens
usage.OutputTokens = response.Usage.CompletionTokens
}
if len(response.Choices) > 0 { if len(response.Choices) > 0 {
chunk := response.Choices[0].Delta.Content chunk := response.Choices[0].Delta.Content
if chunk != "" { if chunk != "" {
@@ -294,5 +430,5 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on
} }
} }
return nil return usage, nil
} }
+9 -7
View File
@@ -3,8 +3,9 @@ package reviewer
import ( import (
"context" "context"
"fmt" "fmt"
"git-air/internal/llm"
"strings" "strings"
"github.com/unclesam-ly/git-air/internal/llm"
) )
type Reviewer struct { type Reviewer struct {
@@ -23,18 +24,19 @@ func NewReviewer(client *llm.Client, repoRoot string, customPrompt string) *Revi
} }
} }
// Execute 执行代码评审 // Execute 执行代码评审,返回 (result, usage, error)
func (r *Reviewer) Execute(ctx context.Context, diff string, stream bool, onChunk func(chunk string)) (string, error) { func (r *Reviewer) Execute(ctx context.Context, diff string, stream bool, onChunk func(chunk string)) (string, *llm.Usage, error) {
if strings.TrimSpace(diff) == "" { if strings.TrimSpace(diff) == "" {
return "", fmt.Errorf("diff 内容为空,无需评审") return "", nil, fmt.Errorf("diff 内容为空,无需评审")
} }
// 动态组装最终 System Prompt // 动态组装最终 System Prompt
systemPrompt := BuildSystemPrompt(r.customPrompt, r.repoRoot) systemPrompt := BuildSystemPrompt(r.customPrompt, r.repoRoot)
if stream && onChunk != nil { if stream && onChunk != nil {
err := r.client.ReviewStream(ctx, systemPrompt, diff, onChunk) usage, err := r.client.ReviewStream(ctx, systemPrompt, diff, onChunk)
return "", err return "", usage, err
} }
return r.client.Review(ctx, systemPrompt, diff) result, err := r.client.Review(ctx, systemPrompt, diff)
return result, nil, err
} }
+23 -1
View File
@@ -30,10 +30,32 @@ func PrintFooter() {
fmt.Println(colorGray + strings.Repeat("─", 65) + colorReset) fmt.Println(colorGray + strings.Repeat("─", 65) + colorReset)
} }
// PrintUsage 打印 Token 消耗与费用估算
func PrintUsage(inputTokens, outputTokens int, costUSD float64) {
if inputTokens == 0 && outputTokens == 0 {
return
}
costStr := ""
if costUSD < 0 {
costStr = "费用未知"
} else if costUSD == 0 {
costStr = "本地免费"
} else {
costStr = fmt.Sprintf("≈ $%.4f", costUSD)
}
fmt.Printf("\n- %sToken: 输入 %s%d%s / 输出 %s%d%s | %s%s%s\n",
colorBold,
colorCyan, inputTokens, colorGray,
colorCyan, outputTokens, colorGray,
colorCyan, costStr, colorReset,
)
}
// PrintError 打印错误信息 // PrintError 打印错误信息
func PrintError(format string, a ...interface{}) { func PrintError(format string, a ...interface{}) {
msg := fmt.Sprintf(format, a...) msg := fmt.Sprintf(format, a...)
fmt.Fprintf(os.Stderr, "%s[ERROR]%s %s\n", colorRed, colorReset, msg) _, _ = fmt.Fprintf(os.Stderr, "%s[ERROR]%s %s\n", colorRed, colorReset, msg)
} }
// PrintSuccess 打印成功信息 // PrintSuccess 打印成功信息
+1 -1
View File
@@ -1,6 +1,6 @@
package main package main
import "git-air/cmd" import "github.com/unclesam-ly/git-air/cmd"
func main() { func main() {
cmd.Execute() cmd.Execute()