feat: 初始化 git-air AI 代码评审 CLI 工具

新增多模型 LLM 客户端、Git diff 提取与过滤、配置管理及 pre-commit 钩子支持。
This commit is contained in:
unclesam committed 2026-08-17 16:52:39 +08:00
commit 55f40e015d
18 files changed
+1315

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
# Binaries
git-air
git-air.exe
dist/
# Local sensitive configs
.git-air.yaml
# IDEs
.idea/
.vscode/
*.swp
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 unclesam-ly
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+185
View File
@@ -0,0 +1,185 @@
<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><br>
<em>A lightweight, lightning-fast, Git-native AI code reviewer written in pure Go.</em>
</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>
---
## 🌟 设计初衷与定位
市面上有许多优秀的云端与 CI/CD 级别的 AI Code Review 平台(如各类 PR 自动审查 Bot),它们在团队协作和远程代码门禁上表现出色。
**而 `git-air` 的定位是:将审查能力极致前置到开发者的本地终端(Pre-Commit / Local CLI)。**
在代码正式 push 或发起 PR 之前,在本地敲一行命令,就能以零延迟、极简的方式先做一轮深度扫描,把潜在的并发隐患、逻辑缺陷与敏感信息拦截在本地,作为开发者最轻量顺手的日常命令行结对编程助手。
- ⚡ **Git 原生极简体验**:无需改变任何工作习惯,在命令行敲 `git air` 即可秒级唤起。
- 🧠 **全模型自由接入**:开箱即用支持 **Google Gemini、DeepSeek、本地离线 Ollama、OpenAI** 以及任何兼容 OpenAI 协议的自定义端点。
- 🛡️ **智能降噪过滤**:自动剥离 `go.sum`、`package-lock.json`、`*.pb.go`、`*.gen.go` 等锁文件与自动生成代码,省 Token、防死循环。
- 🎯 **严谨架构师准则**:直奔主题,聚焦并发竞态、死锁、SQL 注入、空指针、资源泄露,并直接提供修复参考代码。
- 📋 **团队规范适配(`.airules`)**:在项目根目录放置 `.airules`,AI 自动加载团队专属架构规范并最高优先级执行。
- 🪝 **一键 Pre-commit 钩子**:一行命令挂载 Git 提交前自动拦截,在代码上库前把好第一道质量关。
---
## 📦 安装方式
### 方式 1:通过 Go 一键安装(推荐)
```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` 原生预置了国内外所有主流大模型的官方端点与推荐模型,只需指定 `--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)
git air config set --provider siliconflow --key "YOUR_KEY" --model deepseek-ai/DeepSeek-V3
# 6. 通义千问 (Qwen / 阿里百炼)
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. 月之暗面 (Kimi / Moonshot)
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. 本地 100% 离线隐私模型 (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
```
查看当前配置状态:
```bash
git air config get
```
---
### 2. 常用评审命令
```bash
# 评审当前已暂存(git add)的代码(默认行为)
git air
# 评审上一次 Commit 的代码
git air HEAD~1
# 评审特性分支与主干分支之间的所有差异
git air main..feature-agent
# 只评审指定文件的改动
git air internal/service/chat.go
# 临时指定模型或 Key 进行评审
git air --provider deepseek --model deepseek-chat
```
---
### 3. 一键安装 Git Pre-commit 拦截钩子
在你的 Git 项目根目录下执行:
```bash
git air hook install
```
以后团队成员在执行 `git commit` 时,`git-air` 会在终端自动执行审查,若存在阻断性缺陷将自动提醒!
如需卸载钩子:
```bash
git air hook uninstall
```
---
## ⚙️ 配置文件说明
`git-air` 支持多层配置覆盖,优先级从高到低为:
1. **命令行 Flags**(如 `--key`, `--model`, `--provider`)
2. **环境变量**(`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`)
3. **项目级配置**(当前 Git 项目根目录下的 `config.yaml` 或 `.git-air.yaml`)
4. **全局用户配置**(`~/.git-air/config.yaml`)
### `config.yaml` 示例模板:
```yaml
# 模型提供商: gemini, deepseek, ollama, openai, custom
provider: "gemini"
# API 密钥 (本地 ollama 可留空)
api_key: "YOUR_API_KEY_HERE"
# 模型名称
model: "gemini-3.7-flash"
# 自定义端点 (可选)
# base_url: "https://api.deepseek.com/v1"
# 自定义基础提示词 (可选,留空则使用默认资深架构师准则)
# custom_prompt: ""
```
---
## 📋 团队规则定制(`.airules`)
在任何 Git 仓库的根目录下创建一个 `.airules` 文件,`git-air` 在评审时会自动将其作为**团队最高优先级铁律**注入大模型:
```markdown
# 团队专属开发铁律
1. 所有 SQL 操作必须下沉至 DAO 层,严禁裸拼 SQL 字符串;
2. 并发操作共享变量必须加锁或使用原子包(sync/atomic);
3. 打开的连接/文件句柄必须紧跟 defer Close() 释放;
4. 严禁使用下划线 _ 盲目丢弃未处理的 error。
```
---
## 📄 开源许可证
本项目采用 [MIT 许可证](LICENSE)。欢迎提交 Issue 与 Pull Request!
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 1004 KiB

+175
View File
@@ -0,0 +1,175 @@
package cmd
import (
"fmt"
"git-air/internal/ui"
"os"
"path/filepath"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)
// AppConfig 全局配置结构体
type AppConfig struct {
Provider string `yaml:"provider"` // gemini, deepseek, ollama, openai, custom
APIKey string `yaml:"api_key"` // API Key
BaseURL string `yaml:"base_url"` // 自定义请求地址
Model string `yaml:"model"` // 模型名称
CustomPrompt string `yaml:"custom_prompt"` // 用户自定义的 Base System Prompt
}
func getConfigPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dir := filepath.Join(home, ".git-air")
if err := os.MkdirAll(dir, 0755); err != nil {
return "", err
}
return filepath.Join(dir, "config.yaml"), nil
}
// LoadConfig 读取全局配置(优先使用环境变量,其次读取 yaml 文件)
func LoadConfig() (*AppConfig, error) {
cfg := &AppConfig{
Provider: "gemini",
Model: "gemini-3.7-flash",
}
// 1. 优先探测当前项目根目录下的配置文件
localCandidates := []string{"config.yaml", ".git-air.yaml", ".git-air.yml", "configs/config.yaml"}
loaded := false
for _, name := range localCandidates {
if data, err := os.ReadFile(name); err == nil && len(data) > 0 {
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("解析配置文件失败: %w", err)
}
loaded = true
break
}
}
// 2. 如果项目根目录下没有,则回退读取用户全局配置 ~/.git-air/config.yaml
if !loaded {
configPath, err := getConfigPath()
if err == nil {
if data, err := os.ReadFile(configPath); err == nil {
_ = yaml.Unmarshal(data, cfg)
}
}
}
// 3. 环境变量最高优先级覆盖
if envKey := os.Getenv("GIT_AIR_API_KEY"); envKey != "" {
cfg.APIKey = envKey
}
if envProvider := os.Getenv("GIT_AIR_PROVIDER"); envProvider != "" {
cfg.Provider = envProvider
}
if envModel := os.Getenv("GIT_AIR_MODEL"); envModel != "" {
cfg.Model = envModel
}
if envBaseURL := os.Getenv("GIT_AIR_BASE_URL"); envBaseURL != "" {
cfg.BaseURL = envBaseURL
}
return cfg, nil
}
// SaveConfig 保存配置到 ~/.git-air/config.yaml
func SaveConfig(cfg *AppConfig) error {
configPath, err := getConfigPath()
if err != nil {
return err
}
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0600)
}
// configCmd 配置子命令
var configCmd = &cobra.Command{
Use: "config",
Short: "查看或修改 git-air 全局配置 (~/.git-air/config.yaml)",
}
var configSetCmd = &cobra.Command{
Use: "set",
Short: "设置全局配置项",
Example: ` git air config set --provider gemini --key YOUR_KEY
git air config set --provider deepseek --key YOUR_KEY --model deepseek-chat
git air config set --provider ollama --model qwen2.5-coder`,
Run: func(cmd *cobra.Command, args []string) {
cfg, err := LoadConfig()
if err != nil || cfg == nil {
cfg = &AppConfig{
Provider: "gemini",
Model: "gemini-3.7-flash",
}
}
if p, _ := cmd.Flags().GetString("provider"); p != "" {
cfg.Provider = p
}
if k, _ := cmd.Flags().GetString("key"); k != "" {
cfg.APIKey = k
}
if m, _ := cmd.Flags().GetString("model"); m != "" {
cfg.Model = m
}
if u, _ := cmd.Flags().GetString("base-url"); u != "" {
cfg.BaseURL = u
}
if pr, _ := cmd.Flags().GetString("prompt"); pr != "" {
cfg.CustomPrompt = pr
}
if err := SaveConfig(cfg); err != nil {
ui.PrintError("保存配置失败: %v", err)
return
}
ui.PrintSuccess("配置更新成功!")
},
}
var configGetCmd = &cobra.Command{
Use: "get",
Short: "查看当前所有配置",
Run: func(cmd *cobra.Command, args []string) {
cfg, err := LoadConfig()
if err != nil || cfg == nil {
cfg = &AppConfig{
Provider: "gemini",
Model: "gemini-3.7-flash",
}
}
path, _ := getConfigPath()
fmt.Printf("配置文件路径: %s\n\n", path)
fmt.Printf("Provider: %s\n", cfg.Provider)
fmt.Printf("Model: %s\n", cfg.Model)
fmt.Printf("BaseURL: %s\n", cfg.BaseURL)
maskedKey := "未配置"
if len(cfg.APIKey) > 8 {
maskedKey = cfg.APIKey[:4] + "..." + cfg.APIKey[len(cfg.APIKey)-4:]
}
fmt.Printf("APIKey: %s\n", maskedKey)
hasPrompt := "否 (使用默认内置架构师 Prompt)"
if cfg.CustomPrompt != "" {
hasPrompt = "是 (已自定义)"
}
fmt.Printf("CustomPrompt: %s\n", hasPrompt)
},
}
func init() {
configSetCmd.Flags().StringP("provider", "p", "", "模型提供商 (gemini, claude, grok, deepseek, qwen, zhipu, moonshot, siliconflow, doubao, minimax, yi, groq, openrouter, ollama, openai)")
configSetCmd.Flags().StringP("key", "k", "", "API 密钥")
configSetCmd.Flags().StringP("model", "m", "", "模型名称")
configSetCmd.Flags().StringP("base-url", "u", "", "自定义 API 地址")
configSetCmd.Flags().String("prompt", "", "自定义 Base System Prompt")
configCmd.AddCommand(configSetCmd)
configCmd.AddCommand(configGetCmd)
}
+60
View File
@@ -0,0 +1,60 @@
package cmd
import (
"git-air/internal/git"
"git-air/internal/ui"
"os"
"path/filepath"
"github.com/spf13/cobra"
)
const preCommitScript = `#!/bin/sh
# git-air pre-commit hook
echo "[git-air] 正在执行 Commit 前代码自动化评审..."
git-air --cached
# 如果评审发现阻断性问题,可通过退出码阻断 commit
`
var hookCmd = &cobra.Command{
Use: "hook",
Short: "管理 Git Pre-commit 自动审查钩子",
}
var hookInstallCmd = &cobra.Command{
Use: "install",
Short: "在当前仓库一键安装 pre-commit 钩子",
Run: func(cmd *cobra.Command, args []string) {
repoRoot, err := git.GetRepoRoot(cmd.Context())
if err != nil {
ui.PrintError("请在 Git 仓库根目录下执行此命令")
return
}
hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit")
if err := os.WriteFile(hookPath, []byte(preCommitScript), 0755); err != nil {
ui.PrintError("写入 hook 文件失败: %v", err)
return
}
ui.PrintSuccess("已成功安装 pre-commit 钩子!每次执行 git commit 时将自动触发评审。")
},
}
var hookUninstallCmd = &cobra.Command{
Use: "uninstall",
Short: "移除当前仓库的 pre-commit 钩子",
Run: func(cmd *cobra.Command, args []string) {
repoRoot, err := git.GetRepoRoot(cmd.Context())
if err != nil {
ui.PrintError("请在 Git 仓库根目录下执行此命令")
return
}
hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit")
_ = os.Remove(hookPath)
ui.PrintSuccess("已成功卸载 pre-commit 钩子!")
},
}
func init() {
hookCmd.AddCommand(hookInstallCmd)
hookCmd.AddCommand(hookUninstallCmd)
}
+101
View File
@@ -0,0 +1,101 @@
package cmd
import (
"context"
"git-air/internal/git"
"git-air/internal/llm"
"git-air/internal/reviewer"
"git-air/internal/ui"
"os"
"github.com/spf13/cobra"
)
var rootCmd = &cobra.Command{
Use: "git-air [git diff 参数,如 HEAD~1, main..feature, 文件名等]",
Short: "git-air: 极轻量、极速的 Git 原生 AI 代码评审工具",
Long: `git-air 是一个纯 Go 编写的 Git AI 评审利器。
支持一键评审暂存区、指定 Commit、分支差异或单文件,无缝兼容 Gemini、DeepSeek、Ollama 和 OpenAI。`,
Run: func(cmd *cobra.Command, args []string) {
ctx := cmd.Context()
// 1. 检查并获取 Diff
diff, err := git.GetDiff(ctx, args...)
if err != nil {
ui.PrintError("%v", err)
return
}
// 2. 加载配置
cfg, err := LoadConfig()
if err != nil {
ui.PrintError("加载配置失败: %v", err)
return
}
// 允许命令行临时覆盖模型/Key
if flagKey, _ := cmd.Flags().GetString("key"); flagKey != "" {
cfg.APIKey = flagKey
}
if flagModel, _ := cmd.Flags().GetString("model"); flagModel != "" {
cfg.Model = flagModel
}
if flagProvider, _ := cmd.Flags().GetString("provider"); flagProvider != "" {
cfg.Provider = flagProvider
}
// 3. 初始化大模型客户端
llmClient, err := llm.NewClient(llm.Config{
Provider: cfg.Provider,
APIKey: cfg.APIKey,
BaseURL: cfg.BaseURL,
Model: cfg.Model,
})
if err != nil {
ui.PrintError("初始化模型失败: %v (提示: 请先执行 'git air config set --key YOUR_KEY')", err)
return
}
// 4. 获取仓库根目录 (用于加载 .airules)
repoRoot, _ := git.GetRepoRoot(ctx)
// 5. 初始化评审引擎
customPrompt, _ := cmd.Flags().GetString("prompt")
if customPrompt == "" {
customPrompt = cfg.CustomPrompt
}
rev := reviewer.NewReviewer(llmClient, repoRoot, customPrompt)
// 6. 打印开场横幅并开始流式评审
ui.PrintBanner(cfg.Provider, cfg.Model)
printer := ui.NewStreamPrinter()
_, err = rev.Execute(ctx, diff, true, func(chunk string) {
printer.PrintChunk(chunk)
})
if err != nil {
ui.PrintError("\n评审过程中发生错误: %v", err)
return
}
ui.PrintFooter()
},
}
func Execute() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := rootCmd.ExecuteContext(ctx); err != nil {
os.Exit(1)
}
}
func init() {
rootCmd.AddCommand(configCmd)
rootCmd.AddCommand(hookCmd)
rootCmd.Flags().StringP("key", "k", "", "临时指定 API Key")
rootCmd.Flags().StringP("model", "m", "", "临时指定模型名称")
rootCmd.Flags().StringP("provider", "p", "", "临时指定 Provider (gemini, deepseek, ollama)")
rootCmd.Flags().String("prompt", "", "临时指定 System Prompt")
}
+33
View File
@@ -0,0 +1,33 @@
# ==========================================
# git-air 配置文件 (~/.git-air/config.yaml)
# ==========================================
# 模型提供商,可选:
# - 国际主流: gemini, claude (Anthropic), grok (xAI), openai, groq, openrouter, ollama
# - 国内主流: deepseek, qwen (通义千问), zhipu (智谱GLM), moonshot (Kimi), siliconflow (硅基流动), doubao (字节豆包), minimax, yi (零一万物)
# - 自定义端点: custom (支持任何兼容 OpenAI 的第三方/私有化端点)
provider: "gemini"
# API 密钥 (如果是本地 ollama/lmstudio 可填任意字符或留空)
api_key: "YOUR_API_KEY_HERE"
# 模型名称 (可选,若留空则自动分配各平台的默认主力模型)
# gemini: gemini-3.7-flash
# claude: anthropic/claude-3.7-sonnet
# grok: grok-2-latest
# deepseek: deepseek-chat (或 deepseek-reasoner)
# qwen: qwen-plus (或 qwen2.5-coder-32b-instruct)
# zhipu: glm-4-plus (或 glm-4-flash)
# moonshot: moonshot-v1-8k
# siliconflow: deepseek-ai/DeepSeek-V3
# openrouter: anthropic/claude-3.7-sonnet
# groq: llama-3.3-70b-versatile
# ollama: qwen2.5-coder
# openai: gpt-4o-mini (或 gpt-4o)
model: "gemini-3.7-flash"
# 自定义 API 接口地址 (可选,不填默认走对应 Provider 的官方端点)
# base_url: ""
# 自定义全局评审提示词 (可选,留空则使用内置资深架构师准则)
custom_prompt: ""
+11
View File
@@ -0,0 +1,11 @@
module git-air
go 1.26.6
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/sashabaranov/go-openai v1.42.0 // indirect
github.com/spf13/cobra v1.10.2 // indirect
github.com/spf13/pflag v1.0.9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+14
View File
@@ -0,0 +1,14 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sashabaranov/go-openai v1.42.0 h1:fgeZx7/D8dRT//PwXAGe9ylOMtj6vrs999uWF71K+f8=
github.com/sashabaranov/go-openai v1.42.0/go.mod h1:lj5b/K+zjTSFxVLijLSTDZuP7adOgerWeFyZLUhAKRg=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+101
View File
@@ -0,0 +1,101 @@
package git
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
)
var (
ErrNotGitRepo = errors.New("当前目录不是 Git 仓库(未找到 .git)")
ErrEmptyDiff = errors.New("没有检测到任何需要评审的代码变更(Diff 为空)")
)
// IsGitRepo 检查当前工作目录是否在一个 Git 仓库内
func IsGitRepo(ctx context.Context) bool {
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--is-inside-work-tree")
err := cmd.Run()
return err == nil
}
// GetRepoRoot 获取当前 Git 仓库的根目录绝对路径
func GetRepoRoot(ctx context.Context) (string, error) {
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
out, err := cmd.Output()
if err != nil {
return "", ErrNotGitRepo
}
return strings.TrimSpace(string(out)), nil
}
const maxDiffSize = 300 * 1024 // 限制最大 300KB Diff,避免打爆上下文与 OOM
// GetDiff 提取并过滤 Git 代码差异
// args 可传:
// - 无参数:默认先查暂存区 (git diff --cached),若暂存区为空则自动查工作区 (git diff)
// - 自定义参数:如 "HEAD~1"、"main..feature"、"internal/service/chat.go" 等
func GetDiff(ctx context.Context, args ...string) (string, error) {
if !IsGitRepo(ctx) {
return "", ErrNotGitRepo
}
var cmdArgs []string
if len(args) == 0 {
// 1. 默认先提取暂存区代码 (准备 commit 的代码)
stagedDiff, err := execGitDiff(ctx, "--cached")
if err != nil {
return "", err
}
if strings.TrimSpace(stagedDiff) != "" {
return truncateDiffIfNeeded(FilterDiff(stagedDiff)), nil
}
// 2. 如果暂存区为空,回退提取工作区未暂存的修改
cmdArgs = []string{}
} else {
cmdArgs = args
}
rawDiff, err := execGitDiff(ctx, cmdArgs...)
if err != nil {
return "", err
}
filteredDiff := FilterDiff(rawDiff)
if strings.TrimSpace(filteredDiff) == "" {
return "", ErrEmptyDiff
}
return truncateDiffIfNeeded(filteredDiff), nil
}
func truncateDiffIfNeeded(diff string) string {
runes := []rune(diff)
if len(runes) > maxDiffSize {
runes = runes[:maxDiffSize]
}
return string(runes) + "\n\n[WARNING: 代码变更量过大,已自动截断前 300KB 内容进行评审]"
}
// execGitDiff 执行底层的 git diff 命令
func execGitDiff(ctx context.Context, args ...string) (string, error) {
fullArgs := append([]string{"diff"}, args...)
cmd := exec.CommandContext(ctx, "git", fullArgs...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if errors.Is(err, exec.ErrNotFound) {
return "", errors.New("系统未检测到 git 命令,请确保已安装 Git 并配置到系统 PATH 中")
}
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return "", fmt.Errorf("git diff 执行失败: %s (%w)", errMsg, err)
}
return "", fmt.Errorf("git diff 执行失败: %w", err)
}
return stdout.String(), nil
}
+97
View File
@@ -0,0 +1,97 @@
package git
import (
"path/filepath"
"regexp"
"strings"
)
// 默认需要忽略的文件名与后缀列表
var ignoredPatterns = []string{
// 依赖与锁文件
"go.sum",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"composer.lock",
"Cargo.lock",
"Gemfile.lock",
// 自动生成的代码
"*.pb.go",
"*.gen.go",
"*_generated.go",
"*.g.dart",
// 压缩/静态构建产物
"*.min.js",
"*.min.css",
"*.map",
"*.bundle.js",
// 图片与多媒体
"*.png",
"*.jpg",
"*.jpeg",
"*.gif",
"*.svg",
"*.ico",
"*.pdf",
}
// isIgnoredFile 判断文件路径是否应该在 Code Review 中被忽略
func isIgnoredFile(filePath string) bool {
cleanPath := filepath.ToSlash(filePath)
baseName := filepath.Base(cleanPath)
for _, pattern := range ignoredPatterns {
// 精确匹配文件名(如 go.sum)
if pattern == baseName {
return true
}
// 通配符匹配(如 *.pb.go)
if matched, _ := filepath.Match(pattern, baseName); matched {
return true
}
}
return false
}
var diffHeaderRegex = regexp.MustCompile(`(?m)^diff --git a/(.*?) b/(.*?)$`)
// FilterDiff 过滤掉 diff 中无意义的锁文件和自动生成文件的区块
func FilterDiff(rawDiff string) string {
if strings.TrimSpace(rawDiff) == "" {
return ""
}
// 按照 diff --git 切分各个文件的差异区块
chunks := strings.Split(rawDiff, "diff --git ")
var filteredChunks []string
for _, chunk := range chunks {
if strings.TrimSpace(chunk) == "" {
continue
}
// 还原切分前缀
fullChunk := "diff --git " + chunk
lines := strings.Split(chunk, "\n")
if len(lines) == 0 {
continue
}
// 解析第一行中的文件路径,如:a/internal/ent/schema.go b/internal/ent/schema.go
firstLine := lines[0]
parts := strings.Fields(firstLine)
if len(parts) >= 2 {
filePath := strings.TrimPrefix(parts[1], "b/")
if isIgnoredFile(filePath) {
continue // 命中忽略规则,跳过这个文件的 diff
}
}
filteredChunks = append(filteredChunks, fullChunk)
}
return strings.Join(filteredChunks, "")
}
+298
View File
@@ -0,0 +1,298 @@
package llm
import (
"context"
"errors"
"fmt"
"io"
"strings"
"github.com/sashabaranov/go-openai"
)
// Config 大模型统一配置
type Config struct {
Provider string // "gemini", "deepseek", "openai", "ollama", "custom"
APIKey string // 密钥 (Ollama 本地可填任意字符或留空)
BaseURL string // 自定义接口地址 (非必填,会自动根据 Provider 设置默认值)
Model string // 模型名称 (非必填,会自动设置默认主力模型)
}
// Client 统一大模型客户端
type Client struct {
client *openai.Client
model string
}
// NewClient 初始化客户端
func NewClient(cfg Config) (*Client, error) {
provider := strings.ToLower(strings.TrimSpace(cfg.Provider))
if provider == "" {
provider = "gemini"
}
apiKey := cfg.APIKey
baseURL := cfg.BaseURL
model := cfg.Model
// 智能适配各大主流模型的官方地址与主力模型
switch provider {
case "gemini", "google":
if baseURL == "" {
baseURL = "https://generativelanguage.googleapis.com/v1beta/openai/"
}
if model == "" {
model = "gemini-3.7-flash"
}
if apiKey == "" {
return nil, fmt.Errorf("gemini 必须配置 api_key")
}
case "deepseek":
if baseURL == "" {
baseURL = "https://api.deepseek.com/v1"
}
if model == "" {
model = "deepseek-chat"
}
if apiKey == "" {
return nil, fmt.Errorf("deepseek 必须配置 api_key")
}
case "siliconflow", "silicon":
if baseURL == "" {
baseURL = "https://api.siliconflow.cn/v1"
}
if model == "" {
model = "deepseek-ai/DeepSeek-V3"
}
if apiKey == "" {
return nil, fmt.Errorf("siliconflow 必须配置 api_key")
}
case "qwen", "dashscope", "aliyun":
if baseURL == "" {
baseURL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
}
if model == "" {
model = "qwen-plus"
}
if apiKey == "" {
return nil, fmt.Errorf("qwen/dashscope 必须配置 api_key")
}
case "zhipu", "glm":
if baseURL == "" {
baseURL = "https://open.bigmodel.cn/api/paas/v4/"
}
if model == "" {
model = "glm-4-plus"
}
if apiKey == "" {
return nil, fmt.Errorf("zhipu 必须配置 api_key")
}
case "moonshot", "kimi":
if baseURL == "" {
baseURL = "https://api.moonshot.cn/v1"
}
if model == "" {
model = "moonshot-v1-8k"
}
if apiKey == "" {
return nil, fmt.Errorf("moonshot/kimi 必须配置 api_key")
}
case "doubao", "volcengine":
if baseURL == "" {
baseURL = "https://ark.cn-beijing.volces.com/api/v3"
}
if model == "" {
model = "doubao-pro-32k"
}
if apiKey == "" {
return nil, fmt.Errorf("doubao 必须配置 api_key (并在 model 中填写接入点 Endpoint ID)")
}
case "minimax":
if baseURL == "" {
baseURL = "https://api.minimax.chat/v1"
}
if model == "" {
model = "abab6.5s-chat"
}
if apiKey == "" {
return nil, fmt.Errorf("minimax 必须配置 api_key")
}
case "yi", "lingyi":
if baseURL == "" {
baseURL = "https://api.lingyiwanwu.com/v1"
}
if model == "" {
model = "yi-lightning"
}
if apiKey == "" {
return nil, fmt.Errorf("yi 必须配置 api_key")
}
case "groq":
if baseURL == "" {
baseURL = "https://api.groq.com/openai/v1"
}
if model == "" {
model = "llama-3.3-70b-versatile"
}
if apiKey == "" {
return nil, fmt.Errorf("groq 必须配置 api_key")
}
case "grok", "xai":
if baseURL == "" {
baseURL = "https://api.x.ai/v1"
}
if model == "" {
model = "grok-2-latest"
}
if apiKey == "" {
return nil, fmt.Errorf("grok/xai 必须配置 api_key")
}
case "claude", "anthropic":
if baseURL == "" {
baseURL = "https://openrouter.ai/api/v1"
}
if model == "" {
model = "anthropic/claude-3.7-sonnet"
}
if apiKey == "" {
return nil, fmt.Errorf("claude 必须配置 api_key (默认走 OpenRouter 统一网关,或通过 base_url 指定自建代理端点)")
}
case "openrouter":
if baseURL == "" {
baseURL = "https://openrouter.ai/api/v1"
}
if model == "" {
model = "anthropic/claude-3.7-sonnet"
}
if apiKey == "" {
return nil, fmt.Errorf("openrouter 必须配置 api_key")
}
case "ollama":
if baseURL == "" {
baseURL = "http://localhost:11434/v1"
}
if model == "" {
model = "qwen2.5-coder"
}
if apiKey == "" {
apiKey = "ollama" // 本地 ollama 传非空占位符即可
}
case "lmstudio", "vllm":
if baseURL == "" {
baseURL = "http://localhost:1234/v1"
}
if model == "" {
model = "local-model"
}
if apiKey == "" {
apiKey = "local"
}
case "openai":
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
if model == "" {
model = "gpt-4o-mini"
}
if apiKey == "" {
return nil, fmt.Errorf("openai 必须配置 api_key")
}
default:
// custom 自定义端点(只要提供了 baseURL 就能直连)
if baseURL == "" {
return nil, fmt.Errorf("未知 Provider: '%s',如需自定义请提供 base_url", provider)
}
if model == "" {
model = "gpt-4o"
}
}
clientConfig := openai.DefaultConfig(apiKey)
clientConfig.BaseURL = baseURL
return &Client{
client: openai.NewClientWithConfig(clientConfig),
model: model,
}, nil
}
// Review 一次性完整返回代码评审结果
func (c *Client) Review(ctx context.Context, systemPrompt, diff string) (string, error) {
resp, err := c.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{
Model: c.model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: diff},
},
Temperature: 0.2, // 代码评审低随机性
})
if err != nil {
return "", fmt.Errorf("代码评审调用失败: %w", err)
}
if len(resp.Choices) == 0 {
return "", fmt.Errorf("模型未返回任何有效评审结果")
}
return resp.Choices[0].Message.Content, nil
}
// ReviewStream 流式逐字返回评审结果(打字机效果)
func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, onChunk func(chunk string)) error {
stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
Model: c.model,
Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: diff},
},
Temperature: 0.2,
Stream: true,
})
if err != nil {
return fmt.Errorf("创建流式请求失败: %w", err)
}
if stream != nil {
defer stream.Close()
}
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
}
response, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return fmt.Errorf("流式读取异常: %w", err)
}
if len(response.Choices) > 0 {
chunk := response.Choices[0].Delta.Content
if chunk != "" {
onChunk(chunk)
}
}
}
return nil
}
+51
View File
@@ -0,0 +1,51 @@
package reviewer
import (
"strings"
)
// DefaultBaseSystemPrompt 默认内置的资深架构师评审准则
const DefaultBaseSystemPrompt = `你是一位拥有 15 年高并发系统设计经验的资深架构师与代码安全专家。
你的任务是对开发者提交的 Git Diff 增量代码进行工业级、严苛、精炼的代码评审(Code Review)。
### 评审原则:
1. 严禁任何客套话与无意义吹捧,直奔技术实质。
2. 聚焦于逻辑缺陷、并发竞态、安全漏洞、内存与协程泄漏、异常处理等实质性问题,不要纠结纯粹的代码缩进或格式。
3. 发现具体问题时,必须直接给出修改前后的对比例子或修复代码块。
4. 如果代码质量优秀且无缺陷,直接返回 "LGTM: 代码质量良好,未发现明显缺陷",不要强行挑刺。
### 重点审查维度:
- 并发与竞态安全:Goroutine 泄露、Channel 阻塞、锁竞争与未释放、共享变量无锁并发读写。
- 安全与权限:SQL 注入风险、敏感密钥/Token 硬编码、未校验的外部输入、未做权限校验。
- 资源与错误处理:连接/文件未 defer Close()、错误被盲目下划线 _ 忽略、Context 未向后传递。
- 性能与边界:大循环内的频繁内存分配或数据库查询、切片越界、空指针异常(NIL Dereference)。
### 输出格式规范(使用清晰严谨的纯文本标签):
#### 变更概述
(用 1-2 句话总结本次代码改动核心内容)
#### 详细审查意见
(如果有问题,按严重程度列出;若无问题则省略此段)
- [BLOCKER] 文件路径:行号 - 问题描述及潜在事故危害(必须附带修复代码块)
- [WARNING] 文件路径:行号 - 潜在隐患或性能优化点
- [SUGGESTION] 文件路径:行号 - 更优雅的 Idiomatic 写法
#### 评审结论
- 结论: [PASS / WARN / REJECT]
- 评分: X / 100
`
// BuildSystemPrompt 构建最终 Prompt(合并 Base Prompt 与 rules.go 加载的项目规则)
func BuildSystemPrompt(customPrompt string, repoRoot string) string {
var builder strings.Builder
// 1. 如果用户指定了自定义 Base Prompt 则使用,否则使用默认内置 Prompt
basePrompt := strings.TrimSpace(customPrompt)
if basePrompt == "" {
basePrompt = DefaultBaseSystemPrompt
}
builder.WriteString(basePrompt)
// 2. 调用 rules.go 读取项目专属规则
customRules := LoadCustomRules(repoRoot)
if customRules != "" {
builder.WriteString("\n\n### 团队专属规范(最高优先级准则):\n")
builder.WriteString(customRules)
}
return builder.String()
}
+40
View File
@@ -0,0 +1,40 @@
package reviewer
import (
"context"
"fmt"
"git-air/internal/llm"
"strings"
)
type Reviewer struct {
client *llm.Client
repoRoot string
customPrompt string
}
// NewReviewer 初始化评审引擎
// customPrompt 可传入用户自定义配置的 Prompt,传空字符串则使用默认 Prompt
func NewReviewer(client *llm.Client, repoRoot string, customPrompt string) *Reviewer {
return &Reviewer{
client: client,
repoRoot: repoRoot,
customPrompt: customPrompt,
}
}
// Execute 执行代码评审
func (r *Reviewer) Execute(ctx context.Context, diff string, stream bool, onChunk func(chunk string)) (string, error) {
if strings.TrimSpace(diff) == "" {
return "", fmt.Errorf("diff 内容为空,无需评审")
}
// 动态组装最终 System Prompt
systemPrompt := BuildSystemPrompt(r.customPrompt, r.repoRoot)
if stream && onChunk != nil {
err := r.client.ReviewStream(ctx, systemPrompt, diff, onChunk)
return "", err
}
return r.client.Review(ctx, systemPrompt, diff)
}
+43
View File
@@ -0,0 +1,43 @@
package reviewer
import (
"os"
"path/filepath"
"strings"
)
// RuleFileCandidates 支持的团队规则文件名优先级列表
var RuleFileCandidates = []string{
".airules",
".git-air-rules",
".git-air.yaml",
".git-air.md",
}
// LoadCustomRules 读取项目根目录下的团队专属规则
func LoadCustomRules(repoRoot string) string {
if repoRoot == "" {
return ""
}
rulePath := FindRuleFile(repoRoot)
if rulePath == "" {
return ""
}
data, err := os.ReadFile(rulePath)
if err != nil || len(data) == 0 {
return ""
}
return strings.TrimSpace(string(data))
}
// FindRuleFile 探测当前仓库根目录下是否存在匹配的规则文件,返回其绝对路径
func FindRuleFile(repoRoot string) string {
for _, filename := range RuleFileCandidates {
fullPath := filepath.Join(repoRoot, filename)
if info, err := os.Stat(fullPath); err == nil && !info.IsDir() {
return fullPath
}
}
return ""
}
+66
View File
@@ -0,0 +1,66 @@
package ui
import (
"fmt"
"os"
"strings"
)
// ANSI 终端颜色常量
const (
colorReset = "\033[0m"
colorRed = "\033[1;31m"
colorGreen = "\033[1;32m"
colorYellow = "\033[1;33m"
colorBlue = "\033[1;34m"
colorCyan = "\033[1;36m"
colorGray = "\033[0;90m"
colorBold = "\033[1m"
)
// PrintBanner 打印开场横幅与当前评审状态
func PrintBanner(provider, model string) {
fmt.Printf("%s[git-air]%s 代码评审中... %s(Engine: %s / %s)%s\n", colorCyan, colorReset, colorGray, provider, model, colorReset)
fmt.Println(colorGray + strings.Repeat("─", 65) + colorReset)
}
// PrintFooter 打印结束分界线
func PrintFooter() {
fmt.Println()
fmt.Println(colorGray + strings.Repeat("─", 65) + colorReset)
}
// PrintError 打印错误信息
func PrintError(format string, a ...interface{}) {
msg := fmt.Sprintf(format, a...)
fmt.Fprintf(os.Stderr, "%s[ERROR]%s %s\n", colorRed, colorReset, msg)
}
// PrintSuccess 打印成功信息
func PrintSuccess(format string, a ...interface{}) {
msg := fmt.Sprintf(format, a...)
fmt.Printf("%s[SUCCESS]%s %s\n", colorGreen, colorReset, msg)
}
// StreamPrinter 带有标签高亮的流式输出打印器
type StreamPrinter struct{}
func NewStreamPrinter() *StreamPrinter {
return &StreamPrinter{}
}
// PrintChunk 逐字流式输出,遇到关键标签进行颜色增强
func (p *StreamPrinter) PrintChunk(chunk string) {
// 对关键标签进行颜色替换
highlighted := chunk
highlighted = strings.ReplaceAll(highlighted, "[BLOCKER]", colorRed+"[BLOCKER]"+colorReset)
highlighted = strings.ReplaceAll(highlighted, "[CRITICAL]", colorRed+"[CRITICAL]"+colorReset)
highlighted = strings.ReplaceAll(highlighted, "[WARNING]", colorYellow+"[WARNING]"+colorReset)
highlighted = strings.ReplaceAll(highlighted, "[WARN]", colorYellow+"[WARN]"+colorReset)
highlighted = strings.ReplaceAll(highlighted, "[SUGGESTION]", colorBlue+"[SUGGESTION]"+colorReset)
highlighted = strings.ReplaceAll(highlighted, "[PASS]", colorGreen+"[PASS]"+colorReset)
highlighted = strings.ReplaceAll(highlighted, "[LGTM]", colorGreen+"[LGTM]"+colorReset)
highlighted = strings.ReplaceAll(highlighted, "[REJECT]", colorRed+"[REJECT]"+colorReset)
fmt.Print(highlighted)
}
+7
View File
@@ -0,0 +1,7 @@
package main
import "git-air/cmd"
func main() {
cmd.Execute()
}