26 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
unclesam fe64579cab fix(llm): 移除测试用 token 限制,保留完整的推理模型 temperature 兼容与 Usage 空指针修复 2026-08-22 21:10:05 +08:00
unclesam 738bce4a03 refactor(llm): 抽取 isTemperatureError 并优化推理模型重试条件 2026-08-22 20:39:24 +08:00
unclesam 4e8bcdd68e fix(llm): 兼容推理/Beta模型(如 o1/o3/gpt-5.6系列)固定 temperature=1 限制 2026-08-22 20:39:04 +08:00
unclesam 422bd581e0 chore(release): bump version to v1.0.5 2026-08-22 17:18:25 +08:00
unclesam a79d5106a0 docs: 补充永久设置 commit_lang 语言配置说明 2026-08-22 17:16:36 +08:00
unclesam 01df74b01e docs: 完善 git air msg 详细使用说明与多语言效果演示 2026-08-22 17:12:21 +08:00
unclesam b812ff62f8 fix(updater): 调整 HTTP 响应状态码的校验逻辑顺序
- 修复在 resp 为 nil 时可能引发状态码检查异常的问题
- 将状态码校验移至 defer resp.Body.Close() 之后以确保资源正确释放
2026-08-22 17:09:58 +08:00
unclesam 7632652b7b feat(updater): 引入 24 小时静默更新检测与一键升级功能
- 新增 `updater` 模块实现 GitHub Release 版本异步检查与本地缓存
- 新增 `git air update` 与 `git air version` 命令支持一键升级及版本检查
- 优化 CLI 终端交互,在首页渲染时提供友好的新版本升级提示卡片
2026-08-22 17:07:02 +08:00
unclesam 10cbfc39a5 fix(cmd): 修复自动暂存失败时未中断执行的隐患
- 检查 `git.StageAll` 的返回值,若失败则打印错误并终止提交流程
2026-08-22 17:01:35 +08:00
unclesam c7e8b449c9 feat(cmd): 优化提交命令的暂存区交互逻辑并支持上下文控制
- 新增 -a/--all 标志以支持自动暂存所有已修改的文件
- 增加对暂存区状态的检测,未暂存时提供友好的交互提示
- 为 Git 执行函数引入 context.Context 支持,确保超时与取消机制正常运作
2026-08-22 16:58:14 +08:00
unclesam 6eda653db6 feat(cli): 新增 AI 智能 Commit Message 生成与多语言支持
- 新增 `git air msg` 命令,支持根据暂存区或历史 Diff 自动生成 Conventional Commits 提交说明
- 支持中、英、日、韩多语言自适应及通过 CLI/配置文件/环境变量进行定制
- 完善全局配置体系,新增 `commit_lang` 选项及相关单元测试
2026-08-22 16:54:44 +08:00
unclesam 4d9674831b Merge remote-tracking branch 'origin/main' 2026-08-21 15:15:20 +08:00
unclesam 28c29e69df feat: 添加 Token 费用估算与自定义忽略规则
- 在 LLM 客户端中捕获流式响应的 Token 使用量
- 内置多模型官方定价表,支持自定义输入/输出单价覆盖
- 新增 `--price-input` 和 `--price-output` 配置项
- 支持通过 `.airignore` 文件自定义排除评审文件
- 更新中英日文文档,补充费用估算与忽略规则说明
2026-08-21 15:15:12 +08:00
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
29 changed files with 2712 additions and 118 deletions

No files matched your search

+192 -12
View File
@@ -15,22 +15,73 @@
<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** 等全系列模型。
- 🔒 **本地敏感信息自动脱敏**:在 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 最新官方定价表精准核算每次审查成本(本地离线模型自动标注免费)。
- ✨ **AI 智能 Commit Message**:一键根据代码变更生成规范的 Conventional Commits 提交说明,支持**中/英/日/韩**等多语言自适应。
- 📋 **团队规范适配(`.airules`)**:在项目根目录放置 `.airules`,AI 自动加载团队专属架构规范并最高优先级执行。 - 📋 **团队规范适配(`.airules`)**:在项目根目录放置 `.airules`,AI 自动加载团队专属架构规范并最高优先级执行。
- 🪝 **一键 Pre-commit 钩子**:一行命令挂载 Git 提交前自动拦截,在代码上库前把好第一道质量关。 - 🪝 **一键 Pre-commit 钩子**:一行命令挂载 Git 提交前自动拦截(支持原 Hook 备份与链式调用),在代码上库前把好第一道质量关。
---
## 🖥️ 终端效果演示
```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
─────────────────────────────────────────────────────────────────
```
--- ---
@@ -93,9 +144,21 @@ 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
# 14. 永久设置 Commit Message 默认语言 (可选: auto, zh, en, ja, ko)
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):
```bash ```bash
git air config get git air config get
``` ```
@@ -119,30 +182,91 @@ 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
``` ```
--- ---
### 3. 一键安装 Git Pre-commit 拦截钩子 ### 3. AI 智能生成规范 Commit Message (`git air msg`)
写代码一时爽,写 Commit 抓耳挠腮?`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 # 生成英文说明(适合 GitHub 开源项目)
git air msg -l ja # 生成日语说明
git air msg -l ko # 生成韩语说明
# 4. 极速一条龙:自动暂存 + 生成 + 免确认直接提交
git air msg -a -c
# 5. 永久修改全局默认提交语言(无需每次加 -l 参数)
git air config set --commit-lang en # 以后永久默认生成英文
git air config set --commit-lang ja # 以后永久默认生成日文
git air config set --commit-lang auto # 恢复自动跟随系统
```
**效果演示:**
```text
$ git air msg -l zh
[git-air] 正在生成 Conventional Commit Message... (Language: 简体中文 / Engine: gemini-3.7-flash)
─────────────────────────────────────────────────────────────────
feat(auth): 引入 Redis 缓存并修复 Mutex 死锁隐患
- 在用户认证模块新增 Redis Token 缓存机制
- 修复提前 return 时未释放互斥锁的并发隐患
─────────────────────────────────────────────────────────────────
? 是否直接以此信息执行 git commit?[Y/n]: y
[SUCCESS] 代码提交成功!🎉
```
---
### 4. 一键安装 Git Pre-commit 拦截钩子
在你的 Git 项目根目录下执行: 在你的 Git 项目根目录下执行:
```bash ```bash
git air hook install git air hook install
``` ```
以后团队成员在执行 `git commit` 时,`git-air` 会在终端自动执行审查,若存在阻断性缺陷将自动提醒! - **自动门禁阻断**:当审查发现 `[BLOCKER]` 严重缺陷或 `[REJECT]` 评审结论时,`git-air` 会自动以非零退出码**直接拦截并阻断本次 `git commit`**,死守代码质量底线!
- **原 Hook 链式执行**:若仓库原本已有 `pre-commit`,`git-air` 会自动安全备份并在审查放行后**链式调用原有 Hook**,绝不破坏现有工程配置!
- **严格模式 (`--strict`)**:若希望发现 `[WARNING]` 也强制阻断提交,可使用 `git air --strict`;
- **临时跳过阻断**:如确需强行提交,可使用 Git 原生命令 `git commit --no-verify` 或附加 `git-air --no-block`。
如需卸载钩子: 如需卸载钩子并完全恢复原 Hook:
```bash ```bash
git air hook uninstall git air hook uninstall
``` ```
--- ---
### 5. 版本查看与一键自动升级 (`git air update`)
`git-air` 内置了**轻量级 24 小时静默更新检测**,当 GitHub 发布新版本时会在终端底部自动提醒;你也可以随时手动检查与一键升级:
```bash
# 查看当前版本并在线检查更新
git air version
# 一键自动拉取并升级到最新版本
git air update
```
---
## ⚙️ 配置文件说明 ## ⚙️ 配置文件说明
`git-air` 支持多层配置覆盖,优先级从高到低为: `git-air` 支持多层配置覆盖,优先级从高到低为:
1. **命令行 Flags**(如 `--key`, `--model`, `--provider`) 1. **命令行 Flags**(如 `--key`, `--model`, `--provider`, `--lang`, `--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`, `GIT_AIR_COMMIT_LANG`)
3. **项目级配置**(当前 Git 项目根目录下的 `config.yaml` 或 `.git-air.yaml`) 3. **项目级配置**(当前 Git 项目根目录下的 `config.yaml` 或 `.git-air.yaml`)
4. **全局用户配置**(`~/.git-air/config.yaml`) 4. **全局用户配置**(`~/.git-air/config.yaml`)
@@ -161,9 +285,45 @@ 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)
# price_input: 0.75
# price_output: 3.75
# AI Commit Message 生成语言 (可选: auto, zh, en, ja, ko)
# 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`)
@@ -180,6 +340,26 @@ 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!
+362
View File
@@ -0,0 +1,362 @@
<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.
- 🔒 **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.
- 🎯 **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).
- ✨ **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.
- 🪝 **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.
---
## 🖥️ 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
# 14. Permanently Set Default Commit Message Language (auto, zh, en, ja, ko)
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):
```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
# Output structured JSON report (for CI/CD pipelines and scripts)
git air --format json
```
---
### 3. AI-Powered Commit Message Generation (`git air msg`)
Struggling to write concise commit messages? `git-air` inspects your code changes and automatically crafts a clean Conventional Commits message with **auto-language adaptation** and **auto-staging**:
```bash
# 1. Basic usage: analyze staged changes & interactively commit
git air msg
# 2. Auto-stage all modified files (no need for manual git add)
git air msg -a
# 3. Explicitly specify natural language for one-off run (auto, zh, en, ja, ko)
git air msg -l en # English (ideal for open-source repos)
git air msg -l ja # Japanese
git air msg -l ko # Korean
# 4. Instant one-liner: auto-stage + generate + direct commit
git air msg -a -c
# 5. Permanently set global default commit language (no need for -l flag)
git air config set --commit-lang en # Default to English forever
git air config set --commit-lang ja # Default to Japanese forever
git air config set --commit-lang auto # Reset to auto-detect system locale
```
**Terminal Preview:**
```text
$ git air msg -l en
[git-air] Generating Conventional Commit Message... (Language: English / Engine: gemini-3.7-flash)
─────────────────────────────────────────────────────────────────
feat(auth): add Redis token caching and fix mutex deadlock
- Introduce Redis caching layer for user authentication
- Fix concurrency bug where mutex was not unlocked on early return
─────────────────────────────────────────────────────────────────
? Execute git commit with this message? [Y/n]: y
[SUCCESS] Code committed successfully! 🎉
```
---
### 4. 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`**!
- **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`;
- **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 and fully restore your original hook:
```bash
git air hook uninstall
```
---
### 5. Version Check & One-Click Upgrade (`git air update`)
`git-air` comes with **lightweight 24-hour background update detection** and alerts you in the terminal whenever a new release is available. You can also manually check or upgrade anytime:
```bash
# Check current version and check for updates
git air version
# One-click upgrade to the latest release
git air update
```
---
## ⚙️ Configuration Hierarchy
`git-air` searches for configurations in the following priority order:
1. **CLI Flags** (`--key`, `--model`, `--provider`, `--prompt`, `--lang`, `--price-input`, `--price-output`, `--strict`, `--no-block`)
2. **Environment Variables** (`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`, `GIT_AIR_COMMIT_LANG`)
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_reviewer_prompt: ""
# custom_commit_msg_prompt: ""
# Legacy compatibility field (supported throughout v1.x)
# custom_prompt: ""
# Custom Token Pricing (Optional, in USD per 1M tokens)
# price_input: 0.75
# price_output: 3.75
# AI Commit Message Language (Optional: auto, zh, en, ja, ko)
# 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`)
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!
+356
View File
@@ -0,0 +1,356 @@
<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** などを設定なしで即座に切り替え可能。
- 🔒 **ローカルでの機密情報自動マスキング**:LLM への送信前に秘密鍵、API Key、Bearer Token、DB接続文字列などを自動マスキング(情報漏洩リスクゼロ)。
- 🛡️ **スマートなノイズ除去**:`go.sum` や `package-lock.json`、自動生成された `*.pb.go` を自動除外。Token消費を抑え、思考ループを防ぎます。
- 🎯 **厳格なシニアエンジニア基準**:お世辞や無駄口は一切なし。並行処理の競合、デッドロック、SQLインジェクション、NULLポインタ、リソースリークを指摘し、具体的な修正コードを直接提示します。
- 📊 **Token消費量と費用のリアルタイム見積もり**:入出力Token数を正確に集計し、各社最新の公式料金表に基づいて実行コストを即座に計算(ローカル完全オフラインモデルは無料表示)。
- ✨ **AI コミットメッセージ自動生成**:変更差分から Conventional Commits 準拠の高品質なメッセージを生成(**日本語・英語・韓国語・中国語**に自動適応)。
- 📋 **チーム規約の読み込み(`.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
# 14. コミットメッセージのデフォルト言語を永続設定 (任意: auto, zh, en, ja, ko)
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を含む):
```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
# 構造化 JSON レポートを出力(CI/CD パイプラインや自動化スクリプト向け)
git air --format json
```
---
### 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] コードのコミットに成功しました!🎉
```
---
### 4. ワンタッチで Pre-commit フックを登録
Git リポジトリ直下で以下を実行:
```bash
git air hook install
```
- **コミット自動ブロック機能**:レビューで `[BLOCKER]` 重大欠陥や `[REJECT]` 判定が検出された場合、`git-air` は非ゼロの終了コードで**コミット(`git commit`)を自動的に中断・ブロック**します!
- **既存フックのチェーン実行**:リポジトリに既存の `pre-commit` がある場合、自動的にバックアップされ、レビュー通過後に**元のフックをチェーン実行**します。
- **厳格モード (`--strict`)**:`[WARNING]` 警告でもコミットをブロックしたい場合は `git air --strict` を使用;
- **一時的なブロック解除**:緊急でコミットを強制したい場合は、Git 標準の `git commit --no-verify` または `git air --no-block` を付与します。
フックを解除し、元のフックを完全復元する場合:
```bash
git air hook uninstall
```
---
### 5. バージョン確認とワンクリック自動更新 (`git air update`)
`git-air` には**軽量な24時間サイレント更新検出機能**が組み込まれており、新しいリリースが公開されるとターミナル下部で自動通知されます。手動での確認やアップデートも可能です:
```bash
# 現在のバージョンを確認し、更新をチェック
git air version
# 最新バージョンへ一括アップデート
git air update
```
---
## ⚙️ 設定の優先順位
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`)
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"
# カスタムプロンプト設定 (任意)
# custom_reviewer_prompt: ""
# custom_commit_msg_prompt: ""
# 旧版互換フィールド (v1.xで利用可能)
# custom_prompt: ""
# カスタム Token 料金設定 (任意、単位: 米ドル / 1M Tokens)
# price_input: 0.75
# price_output: 3.75
# AI コミットメッセージ言語設定 (任意: auto, zh, en, ja, ko)
# 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` ファイルを作成すると、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 も大歓迎です!
+74 -22
View File
@@ -2,11 +2,11 @@ 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"
) )
@@ -16,7 +16,13 @@ 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 美元,可选)
PriceOutput float64 `yaml:"price_output,omitempty"` // 自定义输出单价 (每 1M tokens 美元,可选)
CommitLang string `yaml:"commit_lang,omitempty"` // Commit Message 生成语言 (auto, zh, en, ja, ko 等)
} }
func getConfigPath() (string, error) { func getConfigPath() (string, error) {
@@ -31,37 +37,37 @@ 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 模式下输出
} else if data, err := os.ReadFile(configPath); err == nil && len(data) > 0 {
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil, fmt.Errorf("解析全局配置文件 [%s] 失败: %w", configPath, err)
}
}
// 2. 检查当前项目是否有专属于 git-air 的配置文件 (.git-air.yaml / git-air.yaml) 进行增量覆盖
localCandidates := []string{".git-air.yaml", ".git-air.yml", "git-air.yaml"}
for _, name := range localCandidates { for _, name := range localCandidates {
if data, err := os.ReadFile(name); 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("解析配置文件失败: %w", err) return nil, fmt.Errorf("解析项目配置文件 [%s] 失败: %w", name, err)
} }
loaded = true
break 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. 环境变量最高优先级覆盖 // 3. 环境变量最高优先级覆盖
if envKey := os.Getenv("GIT_AIR_API_KEY"); envKey != "" { if envKey := os.Getenv("GIT_AIR_API_KEY"); envKey != "" {
cfg.APIKey = envKey cfg.APIKey = envKey
@@ -75,6 +81,14 @@ func LoadConfig() (*AppConfig, error) {
if envBaseURL := os.Getenv("GIT_AIR_BASE_URL"); envBaseURL != "" { if envBaseURL := os.Getenv("GIT_AIR_BASE_URL"); envBaseURL != "" {
cfg.BaseURL = envBaseURL cfg.BaseURL = envBaseURL
} }
if envLang := os.Getenv("GIT_AIR_COMMIT_LANG"); envLang != "" {
cfg.CommitLang = envLang
}
// 兼容旧版本:custom_prompt 等价于 reviewer 提示词。
if cfg.CustomReviewerPrompt == "" {
cfg.CustomReviewerPrompt = cfg.CustomPrompt
}
return cfg, nil return cfg, nil
} }
@@ -102,6 +116,7 @@ var configSetCmd = &cobra.Command{
Short: "设置全局配置项", Short: "设置全局配置项",
Example: ` git air config set --provider gemini --key YOUR_KEY 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 deepseek --key YOUR_KEY --model deepseek-chat
git air config set --commit-lang ja
git air config set --provider ollama --model qwen2.5-coder`, git air config set --provider ollama --model qwen2.5-coder`,
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
cfg, err := LoadConfig() cfg, err := LoadConfig()
@@ -124,7 +139,24 @@ 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 {
cfg.PriceInput = pi
}
if po, _ := cmd.Flags().GetFloat64("price-output"); po > 0 {
cfg.PriceOutput = po
}
if cl, _ := cmd.Flags().GetString("commit-lang"); cl != "" {
cfg.CommitLang = cl
} }
if err := SaveConfig(cfg); err != nil { if err := SaveConfig(cfg); err != nil {
ui.PrintError("保存配置失败: %v", err) ui.PrintError("保存配置失败: %v", err)
@@ -155,11 +187,26 @@ 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 = "是 (已自定义)"
}
commitPrompt := "否 (使用默认 Commit Message Prompt)"
if cfg.CustomCommitMsgPrompt != "" {
commitPrompt = "是 (已自定义)"
}
fmt.Printf("ReviewerPrompt: %s\n", reviewerPrompt)
fmt.Printf("CommitMsgPrompt: %s\n", commitPrompt)
commitLang := "auto (自动识别系统语言)"
if cfg.CommitLang != "" {
commitLang = cfg.CommitLang
}
fmt.Printf("CommitLang: %s\n", commitLang)
if cfg.PriceInput > 0 || cfg.PriceOutput > 0 {
fmt.Printf("CustomPrice: 输入 $%.4f/1M, 输出 $%.4f/1M (已自定义覆盖)\n", cfg.PriceInput, cfg.PriceOutput)
} else {
fmt.Printf("CustomPrice: 默认 (跟随官方最新价格表)\n")
} }
fmt.Printf("CustomPrompt: %s\n", hasPrompt)
}, },
} }
@@ -169,6 +216,11 @@ 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-output", 0, "自定义每 1M 输出 Token 美元价格")
configSetCmd.Flags().String("commit-lang", "", "Commit Message 语言 (auto, zh, en, ja, ko 等)")
configCmd.AddCommand(configSetCmd) configCmd.AddCommand(configSetCmd)
configCmd.AddCommand(configGetCmd) configCmd.AddCommand(configGetCmd)
+145 -15
View File
@@ -1,20 +1,142 @@
package cmd package cmd
import ( import (
"git-air/internal/git" "errors"
"git-air/internal/ui" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/unclesam-ly/git-air/internal/git"
"github.com/unclesam-ly/git-air/internal/ui"
) )
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 --cached "%s" --staged
# 如果评审发现阻断性问题,可通过退出码阻断 commit 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",
@@ -23,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 {
@@ -49,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)
}
}
+170
View File
@@ -0,0 +1,170 @@
package cmd
import (
"bufio"
"context"
"fmt"
"os"
"os/exec"
"strings"
"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 msgCmd = &cobra.Command{
Use: "msg [git diff 参数,如 HEAD~1, main..feature, 文件名等]",
Aliases: []string{"commit-msg", "commit"},
Short: "根据代码变更智能生成规范的 Conventional Commits 提交信息",
Example: ` git air msg # 根据当前暂存区代码自动生成
git air msg -l ja # 指定生成日语提交说明
git air msg -l en # 指定生成英文提交说明
git air msg -c # 生成后直接执行 git commit`,
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
}
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,
PriceInput: cfg.PriceInput,
PriceOutput: cfg.PriceOutput,
})
if err != nil {
ui.PrintError("初始化模型失败: %v", err)
return
}
// 4. 确定目标语言 (命令行 Flag > 配置文件 > 自动探测系统语言)
lang, _ := cmd.Flags().GetString("lang")
if lang == "" {
lang = cfg.CommitLang
}
repoRoot, _ := git.GetRepoRoot(ctx)
rev := reviewer.NewReviewer(llmClient, repoRoot, "")
targetLang := reviewer.DetectLanguage(lang)
langDesc := "English"
switch targetLang {
case "zh":
langDesc = "简体中文"
case "ja":
langDesc = "日本語"
case "ko":
langDesc = "한국어"
}
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")
commitMsg, err := rev.GenerateCommitMsg(ctx, diff, lang, cfg.CustomCommitMsgPrompt)
if err != nil {
ui.PrintError("生成 Commit Message 失败: %v", err)
return
}
// 5. 打印生成的提交信息
fmt.Printf("\033[1;32m%s\033[0m\n", commitMsg)
fmt.Println("\033[0;90m" + strings.Repeat("─", 65) + "\033[0m")
// 6. 是否自动暂存与提交
flagAll, _ := cmd.Flags().GetBool("all")
flagCommit, _ := cmd.Flags().GetBool("commit")
if flagCommit {
if flagAll {
if err := git.StageAll(ctx); err != nil {
ui.PrintError("自动暂存失败: %v", err)
return
}
}
executeGitCommit(ctx, commitMsg)
return
}
// 检查暂存区状态
hasStaged, _ := git.HasStagedChanges(ctx)
reader := bufio.NewReader(os.Stdin)
if !hasStaged && !flagAll {
fmt.Print("? 检测到改动尚未暂存 (unstaged),是否自动暂存并提交 (git add -A)?[Y/n]: ")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(strings.ToLower(input))
if input == "" || input == "y" || input == "yes" {
if err := git.StageAll(ctx); err != nil {
ui.PrintError("自动暂存失败: %v", err)
return
}
executeGitCommit(ctx, commitMsg)
} else {
fmt.Println("\033[0;90m已跳过提交。请执行 'git add <文件>' 暂存修改后再提交。\033[0m")
}
return
}
// 交互式询问是否直接提交
fmt.Print("? 是否直接以此信息执行 git commit?[Y/n]: ")
input, _ := reader.ReadString('\n')
input = strings.TrimSpace(strings.ToLower(input))
if input == "" || input == "y" || input == "yes" {
executeGitCommit(ctx, commitMsg)
} else {
fmt.Println("\033[0;90m已跳过提交。你可以复制上方内容手动提交。\033[0m")
}
},
}
func executeGitCommit(ctx context.Context, msg string) {
cmd := exec.CommandContext(ctx, "git", "commit", "-m", msg)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
ui.PrintError("执行 git commit 失败: %v", err)
return
}
ui.PrintSuccess("代码提交成功!🎉")
}
func init() {
msgCmd.Flags().StringP("lang", "l", "", "生成语言 (auto, zh, en, ja, ko)")
msgCmd.Flags().BoolP("all", "a", false, "自动暂存所有已修改的文件 (git add -A)")
msgCmd.Flags().BoolP("commit", "c", false, "生成后无需确认直接执行 git commit")
msgCmd.Flags().StringP("key", "k", "", "临时指定 API Key")
msgCmd.Flags().StringP("model", "m", "", "临时指定模型名称")
msgCmd.Flags().StringP("provider", "p", "", "临时指定 Provider")
rootCmd.AddCommand(msgCmd)
}
+105 -7
View File
@@ -2,13 +2,16 @@ package cmd
import ( import (
"context" "context"
"git-air/internal/git" "encoding/json"
"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"
"github.com/unclesam-ly/git-air/internal/updater"
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
@@ -16,10 +19,25 @@ 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()
outputFormat, _ := cmd.Flags().GetString("format")
if outputFormat != "text" && outputFormat != "json" {
ui.PrintError("不支持的输出格式 %q,可选: text, json", outputFormat)
os.Exit(2)
}
// 后台非阻塞异步检查新版本
updateCh := updater.CheckAsync()
// 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)
@@ -50,6 +68,8 @@ var rootCmd = &cobra.Command{
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)
@@ -62,22 +82,95 @@ 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()
_, 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)
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)
return os.Exit(1)
} }
// 7. 打印 Token 消耗统计
if outputFormat == "text" && usage != nil {
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(),
}
}
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 {
case info := <-updateCh:
if info != nil && outputFormat == "text" {
ui.PrintUpdateBanner(info.CurrentVersion, info.LatestVersion, info.ReleaseURL)
}
default:
}
// 9. 门禁阻断检测 (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 +191,9 @@ 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 也不以非零退出码阻断")
rootCmd.Flags().String("format", "text", "输出格式: text 或 json")
} }
+35
View File
@@ -0,0 +1,35 @@
package cmd
import (
"fmt"
"os"
"os/exec"
"github.com/spf13/cobra"
"github.com/unclesam-ly/git-air/internal/ui"
"github.com/unclesam-ly/git-air/internal/updater"
)
var updateCmd = &cobra.Command{
Use: "update",
Aliases: []string{"upgrade"},
Short: "一键升级 git-air 到最新版本",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("🔍 正在检查并拉取最新版本 (当前: %s)...\n", updater.CurrentVersion)
updateCmd := exec.Command("go", "install", "github.com/unclesam-ly/git-air@latest")
updateCmd.Stdout = os.Stdout
updateCmd.Stderr = os.Stderr
if err := updateCmd.Run(); err != nil {
ui.PrintError("升级失败: %v (提示: 请确保已安装 Go 并配置环境变量)", err)
return
}
ui.PrintSuccess("git-air 已成功升级至最新版本!🎉")
},
}
func init() {
rootCmd.AddCommand(updateCmd)
}
+38
View File
@@ -0,0 +1,38 @@
package cmd
import (
"fmt"
"runtime"
"github.com/spf13/cobra"
"github.com/unclesam-ly/git-air/internal/ui"
"github.com/unclesam-ly/git-air/internal/updater"
)
var versionCmd = &cobra.Command{
Use: "version",
Short: "查看当前 git-air 版本信息及更新检测",
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf("git-air %s (%s/%s, Go %s)\n",
updater.CurrentVersion,
runtime.GOOS,
runtime.GOARCH,
runtime.Version(),
)
checkFlag, _ := cmd.Flags().GetBool("check")
if checkFlag {
info := updater.CheckUpdate(true)
if info != nil {
ui.PrintUpdateBanner(info.CurrentVersion, info.LatestVersion, info.ReleaseURL)
} else {
ui.PrintSuccess("当前已是最新版本!")
}
}
},
}
func init() {
versionCmd.Flags().BoolP("check", "c", true, "向 GitHub 检查是否有可用新版本")
rootCmd.AddCommand(versionCmd)
}
+18 -2
View File
@@ -29,5 +29,21 @@ 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)
# 默认情况下 git-air 内置了各主流模型官方最新价格表;
# 大模型厂商价格可能动态调整,请以实际价格为准
# 若你的中转平台价格有变或使用私有计费,可在此自定义覆盖:
# price_input: 0.75 # 每 1M 输入 Token 美元单价
# price_output: 3.75 # 每 1M 输出 Token 美元单价
# AI Commit Message 生成语言 (可选,默认 "auto" 自动跟随系统语言,可选: zh, en, ja, ko)
commit_lang: "auto"
+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
+27 -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 (
@@ -32,6 +34,21 @@ func GetRepoRoot(ctx context.Context) (string, error) {
return strings.TrimSpace(string(out)), nil return strings.TrimSpace(string(out)), nil
} }
// HasStagedChanges 检查当前 Git 暂存区是否有待提交的文件改动
func HasStagedChanges(ctx context.Context) (bool, error) {
stagedDiff, err := execGitDiff(ctx, "--cached")
if err != nil {
return false, err
}
return strings.TrimSpace(stagedDiff) != "", nil
}
// StageAll 执行 git add -A 暂存当前所有改动
func StageAll(ctx context.Context) error {
cmd := exec.CommandContext(ctx, "git", "add", "-A")
return cmd.Run()
}
const maxDiffSize = 300 * 1024 // 限制最大 300KB Diff,避免打爆上下文与 OOM const maxDiffSize = 300 * 1024 // 限制最大 300KB Diff,避免打爆上下文与 OOM
// GetDiff 提取并过滤 Git 代码差异 // GetDiff 提取并过滤 Git 代码差异
@@ -43,6 +60,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 +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)), nil truncated := truncateDiffIfNeeded(FilterDiff(stagedDiff, customIgnores...))
return security.RedactSecrets(truncated), nil
} }
// 2. 如果暂存区为空,回退提取工作区未暂存的修改 // 2. 如果暂存区为空,回退提取工作区未暂存的修改
cmdArgs = []string{} cmdArgs = []string{}
@@ -64,21 +85,23 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
return "", err return "", err
} }
filteredDiff := FilterDiff(rawDiff) 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) {
+74 -16
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 == "" {
baseName := filepath.Base(cleanPath) return nil
}
ignoreFile := filepath.Join(repoRoot, ".airignore")
file, err := os.Open(ignoreFile)
if err != nil {
return nil
}
defer file.Close()
for _, pattern := range ignoredPatterns { var patterns []string
// 精确匹配文件名(如 go.sum) scanner := bufio.NewScanner(file)
if pattern == baseName { for scanner.Scan() {
return true trimmed := strings.TrimSpace(scanner.Text())
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
patterns = append(patterns, trimmed)
}
return patterns
} }
// 通配符匹配(如 *.pb.go) // isIgnoredFile 判断文件路径是否命中忽略规则(内置规则 + 用户自定义规则)
if matched, _ := filepath.Match(pattern, baseName); matched { func isIgnoredFile(filePath string, customPatterns []string) bool {
cleanPath := filepath.ToSlash(strings.TrimSpace(filePath))
baseName := filepath.Base(cleanPath)
// 1. 先匹配用户自定义规则 (.airignore)
for _, pattern := range customPatterns {
if matchPattern(pattern, cleanPath, baseName) {
return true
}
}
// 2. 再匹配内置默认规则
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")
}
}
+195 -12
View File
@@ -16,12 +16,16 @@ type Config struct {
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 初始化客户端
@@ -229,19 +233,55 @@ func NewClient(cfg Config) (*Client, error) {
return &Client{ return &Client{
client: openai.NewClientWithConfig(clientConfig), client: openai.NewClientWithConfig(clientConfig),
model: model, model: model,
priceInput: cfg.PriceInput,
priceOutput: cfg.PriceOutput,
}, nil }, nil
} }
// isReasoningModel 判断是否为推理或带有 Beta 限制的模型(这类模型要求 temperature 固定为 1)
func isReasoningModel(model string) bool {
m := strings.ToLower(model)
return strings.HasPrefix(m, "o1") ||
strings.HasPrefix(m, "o3") ||
strings.HasPrefix(m, "o4") ||
strings.Contains(m, "reasoner") ||
strings.Contains(m, "gpt-5") ||
strings.Contains(m, "luna") ||
strings.Contains(m, "terra") ||
strings.Contains(m, "sol")
}
// isTemperatureError 检测 API 报错是否由 temperature 参数限制触发
func isTemperatureError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "temperature") || strings.Contains(msg, "beta-limitations")
}
// Review 一次性完整返回代码评审结果 // Review 一次性完整返回代码评审结果
func (c *Client) Review(ctx context.Context, systemPrompt, diff string) (string, error) { func (c *Client) Review(ctx context.Context, systemPrompt, diff string) (string, error) {
resp, err := c.client.CreateChatCompletion(ctx, openai.ChatCompletionRequest{ temp := float32(0.2)
if isReasoningModel(c.model) {
temp = 1.0
}
req := openai.ChatCompletionRequest{
Model: c.model, Model: c.model,
Messages: []openai.ChatCompletionMessage{ Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt}, {Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: diff}, {Role: openai.ChatMessageRoleUser, Content: diff},
}, },
Temperature: 0.2, // 代码评审低随机性 Temperature: temp,
}) }
resp, err := c.client.CreateChatCompletion(ctx, req)
// 针对限制了 temperature 的模型,且首次非 1.0 时,自动调整为 1.0 重试
if err != nil && req.Temperature != 1.0 && isTemperatureError(err) {
req.Temperature = 1.0
resp, err = c.client.CreateChatCompletion(ctx, req)
}
if err != nil { if err != nil {
return "", fmt.Errorf("代码评审调用失败: %w", err) return "", fmt.Errorf("代码评审调用失败: %w", err)
@@ -254,28 +294,164 @@ 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 {
stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{ 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) {
temp := float32(0.2)
if isReasoningModel(c.model) {
temp = 1.0
}
req := openai.ChatCompletionRequest{
Model: c.model, Model: c.model,
Messages: []openai.ChatCompletionMessage{ Messages: []openai.ChatCompletionMessage{
{Role: openai.ChatMessageRoleSystem, Content: systemPrompt}, {Role: openai.ChatMessageRoleSystem, Content: systemPrompt},
{Role: openai.ChatMessageRoleUser, Content: diff}, {Role: openai.ChatMessageRoleUser, Content: diff},
}, },
Temperature: 0.2, Temperature: temp,
Stream: true, Stream: true,
}) StreamOptions: &openai.StreamOptions{
IncludeUsage: true,
},
}
stream, err := c.client.CreateChatCompletionStream(ctx, req)
// 针对限制了 temperature 的模型,且首次非 1.0 时,自动调整为 1.0 重试
if err != nil && req.Temperature != 1.0 && isTemperatureError(err) {
req.Temperature = 1.0
stream, err = c.client.CreateChatCompletionStream(ctx, req)
}
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 +460,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 != nil && 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 +477,5 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on
} }
} }
return nil return usage, nil
} }
+51
View File
@@ -0,0 +1,51 @@
package llm
import (
"fmt"
"testing"
)
func TestIsReasoningModel(t *testing.T) {
tests := []struct {
model string
expected bool
}{
{"o1", true},
{"o1-mini", true},
{"o1-preview", true},
{"o3-mini", true},
{"gpt-5.6-luna", true},
{"gpt-5.6-sol", true},
{"gpt-5.6-terra", true},
{"deepseek-reasoner", true},
{"gemini-3.7-flash", false},
{"claude-4.6-sonnet", false},
{"gpt-4o", false},
}
for _, tt := range tests {
got := isReasoningModel(tt.model)
if got != tt.expected {
t.Errorf("isReasoningModel(%q) = %v, expected %v", tt.model, got, tt.expected)
}
}
}
func TestIsTemperatureError(t *testing.T) {
tests := []struct {
errStr string
expected bool
}{
{"this model has beta-limitations, temperature, top_p and n are fixed at 1", true},
{"temperature must be 1.0", true},
{"invalid api key", false},
{"rate limit exceeded", false},
}
for _, tt := range tests {
got := isTemperatureError(fmt.Errorf("%s", tt.errStr))
if got != tt.expected {
t.Errorf("isTemperatureError(%q) = %v, expected %v", tt.errStr, got, tt.expected)
}
}
}
+108
View File
@@ -0,0 +1,108 @@
package reviewer
import (
"fmt"
"os"
"strings"
)
// DetectLanguage 智能解析目标自然语言
// 优先级:传入指定语言 -> 系统 LANG/LC_ALL 环境变量 -> 默认 "en"
func DetectLanguage(specifiedLang string) string {
lang := strings.ToLower(strings.TrimSpace(specifiedLang))
if lang != "" && lang != "auto" {
return NormalizeLang(lang)
}
// 尝试从系统环境变量中探测
envLang := os.Getenv("LC_ALL")
if envLang == "" {
envLang = os.Getenv("LANG")
}
if envLang == "" {
envLang = os.Getenv("LC_MESSAGES")
}
envLang = strings.ToLower(envLang)
if strings.HasPrefix(envLang, "zh") {
return "zh"
} else if strings.HasPrefix(envLang, "ja") {
return "ja"
} else if strings.HasPrefix(envLang, "ko") {
return "ko"
}
return "en"
}
// NormalizeLang 规范化语言缩写
func NormalizeLang(lang string) string {
switch strings.ToLower(lang) {
case "zh", "cn", "chinese", "zh-cn", "zh-tw", "中文":
return "zh"
case "ja", "jp", "japanese", "ja-jp", "日本語", "日文":
return "ja"
case "ko", "kr", "korean", "ko-kr", "한국어", "韩文":
return "ko"
default:
return "en"
}
}
// BuildCommitMsgPrompt 构建用于生成 Conventional Commits 的专业提示词
func BuildCommitMsgPrompt(lang string) string {
langName := "English"
exampleSummary := "feat(auth): add Redis token caching and fix mutex deadlock"
exampleBullet1 := "- Introduce Redis caching layer for user authentication"
exampleBullet2 := "- Fix concurrency bug where mutex was not unlocked on early return"
switch lang {
case "zh":
langName = "简体中文 (Simplified Chinese)"
exampleSummary = "feat(auth): 引入 Redis 缓存并修复 Mutex 死锁隐患"
exampleBullet1 = "- 在用户认证模块新增 Redis Token 缓存机制"
exampleBullet2 = "- 修复提前 return 时未释放互斥锁的并发隐患"
case "ja":
langName = "日本語 (Japanese)"
exampleSummary = "feat(auth): Redisキャッシュを追加し、Mutexのデッドロックを修正"
exampleBullet1 = "- ユーザー認証ハンドラーに Redis トークンキャッシュ層を導入"
exampleBullet2 = "- 早期リターン時に Mutex の Unlock が漏れる並行処理のバグを修正"
case "ko":
langName = "한국어 (Korean)"
exampleSummary = "feat(auth): Redis 캐시 도입 및 Mutex 데드락 버그 수정"
exampleBullet1 = "- 사용자 인증 핸들러에 Redis 토큰 캐시 계층 추가"
exampleBullet2 = "- 조기 반환 시 Mutex가 해제되지 않는 동시성 문제 해결"
}
return fmt.Sprintf(`你是一位精通 Git 版本控制与 Conventional Commits 规范的资深架构师。
你的任务是根据开发者提供的 Git Diff 增量代码,生成一条极其规范、精炼、清晰的 Git Commit Message。
### 规则要求:
1. 必须使用 Conventional Commits 规范:<type>(<scope>): <subject>
- type 可选:feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
- scope 为本次变动的核心模块或文件名(英文小写,如 auth, db, ui, config 等)
2. 【语言要求】:除 type(scope) 以外的主题与说明必须使用【%s】撰写!
3. 输出结构:
- 第一行:精炼的主题行(不超过 60 个字符,不带句号)
- 第二行:留空行
- 第三行及之后(可选):使用 1~3 个无序列表项 (-) 简述核心改动要点
4. 严禁任何 Markdown 代码块包裹,严禁任何客套话或多余解释,直接输出纯文本 Commit Message!
### 示例参考:
%s
%s
%s
`, 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
}
+68
View File
@@ -0,0 +1,68 @@
package reviewer
import (
"strings"
"testing"
)
func TestDetectLanguage(t *testing.T) {
tests := []struct {
input string
expected string
}{
{"zh", "zh"},
{"ZH", "zh"},
{"zh-CN", "zh"},
{"中文", "zh"},
{"ja", "ja"},
{"ja-JP", "ja"},
{"Japanese", "ja"},
{"日本語", "ja"},
{"ko", "ko"},
{"ko-KR", "ko"},
{"한국어", "ko"},
{"en", "en"},
{"en-US", "en"},
{"English", "en"},
{"fr", "en"}, // 未预置语言回退到 en
}
for _, tt := range tests {
got := DetectLanguage(tt.input)
if got != tt.expected {
t.Errorf("DetectLanguage(%q) = %q, expected %q", tt.input, got, tt.expected)
}
}
}
func TestBuildCommitMsgPrompt(t *testing.T) {
langs := []struct {
lang string
expected string
}{
{"zh", "简体中文"},
{"ja", "日本語"},
{"ko", "한국어"},
{"en", "English"},
}
for _, tt := range langs {
prompt := BuildCommitMsgPrompt(tt.lang)
if !strings.Contains(prompt, tt.expected) {
t.Errorf("BuildCommitMsgPrompt(%q) 应包含语言名称 %q", tt.lang, tt.expected)
}
if !strings.Contains(prompt, "Conventional Commits") {
t.Errorf("BuildCommitMsgPrompt(%q) 应包含 Conventional Commits 说明", tt.lang)
}
}
}
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 约束不应丢失")
}
}
+33 -19
View File
@@ -5,29 +5,43 @@ import (
) )
// DefaultBaseSystemPrompt 默认内置的资深架构师评审准则 // DefaultBaseSystemPrompt 默认内置的资深架构师评审准则
const DefaultBaseSystemPrompt = `你是一位拥有 15 年高并发系统设计经验的资深架构师与代码安全专家。 const DefaultBaseSystemPrompt = `你是一位拥有 15 年以上高可用架构、高并发底层设计与代码安全审计经验的顶级首席架构师(Principal Engineer)。
你的任务是对开发者提交的 Git Diff 增量代码进行工业级、严苛、精炼的代码评审(Code Review)。 你的核心使命是对开发者提交的 Git Diff 增量代码进行工业级、严苛、实证化、高信噪比的代码审查(Code Review)。
### 评审原则: ### 一、 核心审查原则(铁律)
1. 严禁任何客套话与无意义吹捧,直奔技术实质。 1. 【零客套、纯硬核】:严禁任何寒暄、恭维或废话,直接输出技术审查实质。
2. 聚焦于逻辑缺陷、并发竞态、安全漏洞、内存与协程泄漏、异常处理等实质性问题,不要纠结纯粹的代码缩进或格式。 2. 【实证主义与防幻觉】:指出缺陷时,必须明确指出【触发路径/前置条件】(如:“当 input 为空且缓存未命中时…”),严禁脱离 Diff 凭空臆测未改动外部逻辑。
3. 发现具体问题时,必须直接给出修改前后的对比例子或修复代码块。 3. 【最小侵入修复】:给出的修复方案必须语法正确、开箱即用,优先给出最小改动补丁,严禁使用包含大量省略号的伪代码。
4. 如果代码质量优秀且无缺陷,直接返回 "LGTM: 代码质量良好,未发现明显缺陷",不要强行挑刺。 4. 【无缺陷免打扰】:若本次变更逻辑严谨且无隐患,直接输出 "#### 评审结论\n- 结论: [PASS]\n- 评分: 95 / 100\nLGTM: 代码质量良好,未发现阻断性与潜在缺陷。"。
### 重点审查维度: ### 二、 缺陷严重级别定义(严格执行,不得随意跨级)
- 并发与竞态安全:Goroutine 泄露、Channel 阻塞、锁竞争与未释放、共享变量无锁并发读写。 - [BLOCKER] (阻断级/必须拦截):
- 安全与权限:SQL 注入风险、敏感密钥/Token 硬编码、未校验的外部输入、未做权限校验。 - 必然或高概率导致:线上 Panic/Crash、死锁/活锁、Goroutine/内存泄露、并发竞态读写破坏、SQL 注入/越权/密钥硬编码、事务脏读/数据丢失、未释放资源句柄。
- 资源与错误处理:连接/文件未 defer Close()、错误被盲目下划线 _ 忽略、Context 未向后传递。 - 【结论强制为 [REJECT],评分 < 60】
- 性能与边界:大循环内的频繁内存分配或数据库查询、切片越界、空指针异常(NIL Dereference)。 - [WARNING] (告警级/潜在隐患):
### 输出格式规范(使用清晰严谨的纯文本标签): - 潜在风险或性能退化:缺乏超时控制/重试风暴、大循环内频繁堆分配/N+1 查询、静默吞掉底层 Error、边界条件未覆盖、缺少 defer 兜底。
- 【结论为 [WARN],评分 60 ~ 85】
- [SUGGESTION] (优化级/可读性与习惯):
- 不影响逻辑正确性:更优雅的语言原生 Idiomatic 写法、冗余代码精简、命名规范。
- 【结论为 [PASS],评分 85 ~ 100】
### 三、 重点技术维度专项排查清单
1. 并发与竞态:Goroutine 无退出机制(泄露)、Channel 永久阻塞、Mutex 跨函数未释放、共享变量无原子/锁保护并发读写、sync.WaitGroup 传值而非指针。
2. 资源与内存:文件/连接/DB rows 未立即 defer Close()、time.After 在循环/select 中堆积内存、大切片底层数组无法 GC。
3. 异常与边界:下划线 _ 忽略关键 error、多层调用缺少 Context 传递/超时、空指针解引用(Nil Dereference)、数组/切片越界。
4. 安全与权限:动态拼接 SQL 语句、未转义外部输入、硬编码敏感凭证、缺少幂等性保障。
### 四、 输出格式规范(必须严格遵循 Markdown 格式)
#### 变更概述 #### 变更概述
(用 1-2 句话总结本次代码改动核心内容) (用 1-2 句话客观概括本次变更的业务逻辑与技术影响)
#### 详细审查意见 #### 详细审查意见
(如果有问题,按严重程度列出;若无问题则省略此段) (若存在问题,按严重程度递减排列;若无缺陷则此段直接省略)
- [BLOCKER] 文件路径:行号 - 问题描述及潜在事故危害(必须附带修复代码块) - [BLOCKER] 文件路径:行号 - 缺陷描述:说明触发场景、事故危害及调用路径
- [WARNING] 文件路径:行号 - 潜在隐患或性能优化点 // 建议修复代码:
- [SUGGESTION] 文件路径:行号 - 更优雅的 Idiomatic 写法 ` + "```go" + `
// 修复后的代码块
` + "```" + `
- [WARNING] 文件路径:行号 - 潜在隐患:说明风险点及性能优化建议
- [SUGGESTION] 文件路径:行号 - 优化建议:更地道的代码组织方式
#### 评审结论 #### 评审结论
- 结论: [PASS / WARN / REJECT] - 结论: [PASS / WARN / REJECT]
- 评分: X / 100 - 评分: X / 100 (0-100分)
` `
// BuildSystemPrompt 构建最终 Prompt(合并 Base Prompt 与 rules.go 加载的项目规则) // BuildSystemPrompt 构建最终 Prompt(合并 Base Prompt 与 rules.go 加载的项目规则)
+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])
}
}
+30 -6
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,41 @@ 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 "", usage, err
}
result, err := r.client.Review(ctx, systemPrompt, diff)
return result, nil, err
}
// GenerateCommitMsg 根据 Diff 智能生成符合 Conventional Commits 规范的提交信息
func (r *Reviewer) GenerateCommitMsg(ctx context.Context, diff string, lang string, customPrompt string) (string, error) {
if strings.TrimSpace(diff) == "" {
return "", fmt.Errorf("diff 内容为空,无法生成 Commit Message")
}
targetLang := DetectLanguage(lang)
systemPrompt := BuildCommitMsgPromptWithCustom(targetLang, customPrompt)
msg, err := r.client.Review(ctx, systemPrompt, diff)
if err != nil {
return "", err return "", err
} }
return r.client.Review(ctx, systemPrompt, diff) // 清理多余的代码块标记或前后空白
cleanMsg := strings.TrimSpace(msg)
cleanMsg = strings.TrimPrefix(cleanMsg, "```gitcommit")
cleanMsg = strings.TrimPrefix(cleanMsg, "```")
cleanMsg = strings.TrimSuffix(cleanMsg, "```")
return strings.TrimSpace(cleanMsg), 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)
}
}
+36 -1
View File
@@ -30,10 +30,45 @@ 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,
)
}
// PrintUpdateBanner 打印新版本升级提醒卡片
func PrintUpdateBanner(currentVer, latestVer, releaseURL string) {
fmt.Println()
fmt.Println(colorYellow + "┌─────────────────────────────────────────────────────────────┐" + colorReset)
fmt.Printf(colorYellow+"│"+colorReset+" 🚀 %sgit-air 新版本发布: %s%s%s → %s%s%s\n",
colorBold, colorGray, currentVer, colorReset,
colorGreen, latestVer, colorReset,
)
fmt.Println(colorYellow + "│" + colorReset + " 运行以下命令一键升级: " + colorYellow + "│" + colorReset)
fmt.Printf(colorYellow+"│"+colorReset+" %sgo install github.com/unclesam-ly/git-air@latest%s "+colorYellow+"│"+colorReset+"\n", colorCyan, colorReset)
fmt.Println(colorYellow + "└─────────────────────────────────────────────────────────────┘" + 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 打印成功信息
+177
View File
@@ -0,0 +1,177 @@
package updater
import (
"context"
"encoding/json"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// CurrentVersion 当前编译版本号
const CurrentVersion = "v1.0.7"
const (
githubAPIURL = "https://api.github.com/repos/unclesam-ly/git-air/releases/latest"
checkInterval = 24 * time.Hour
httpTimeout = 1500 * time.Millisecond
)
type UpdateState struct {
LastChecked int64 `json:"last_checked"`
LatestVer string `json:"latest_ver"`
ReleaseURL string `json:"release_url"`
}
type UpdateInfo struct {
CurrentVersion string
LatestVersion string
ReleaseURL string
}
func getCachePath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
dir := filepath.Join(home, ".git-air")
_ = os.MkdirAll(dir, 0755)
return filepath.Join(dir, "update_check.json"), nil
}
func readCache() (*UpdateState, error) {
path, err := getCachePath()
if err != nil {
return nil, err
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var state UpdateState
if err := json.Unmarshal(data, &state); err != nil {
return nil, err
}
return &state, nil
}
func writeCache(state *UpdateState) {
path, err := getCachePath()
if err != nil {
return
}
data, err := json.Marshal(state)
if err == nil {
_ = os.WriteFile(path, data, 0600)
}
}
// CheckUpdate 检查是否有新版本(带 24 小时本地缓存与极短超时,静默处理所有异常)
func CheckUpdate(force bool) *UpdateInfo {
now := time.Now().Unix()
// 1. 如果不是强制检查,优先读取 24 小时内的本地缓存
if !force {
if state, err := readCache(); err == nil && state != nil {
if now-state.LastChecked < int64(checkInterval.Seconds()) {
if IsNewerVersion(state.LatestVer, CurrentVersion) {
return &UpdateInfo{
CurrentVersion: CurrentVersion,
LatestVersion: state.LatestVer,
ReleaseURL: state.ReleaseURL,
}
}
return nil
}
}
}
// 2. 发起轻量级 GitHub API 请求
ctx, cancel := context.WithTimeout(context.Background(), httpTimeout)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", githubAPIURL, nil)
if err != nil {
return nil
}
req.Header.Set("User-Agent", "git-air-cli")
req.Header.Set("Accept", "application/vnd.github.v3+json")
client := &http.Client{Timeout: httpTimeout}
resp, err := client.Do(req)
if err != nil || resp == nil {
return nil
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil
}
var release struct {
TagName string `json:"tag_name"`
HTMLURL string `json:"html_url"`
}
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil || release.TagName == "" {
return nil
}
// 3. 写入本地缓存
writeCache(&UpdateState{
LastChecked: now,
LatestVer: release.TagName,
ReleaseURL: release.HTMLURL,
})
// 4. 比对版本
if IsNewerVersion(release.TagName, CurrentVersion) {
return &UpdateInfo{
CurrentVersion: CurrentVersion,
LatestVersion: release.TagName,
ReleaseURL: release.HTMLURL,
}
}
return nil
}
// CheckAsync 后台并发静默检查,返回 channel 供主流程结束前快速获取
func CheckAsync() <-chan *UpdateInfo {
ch := make(chan *UpdateInfo, 1)
go func() {
info := CheckUpdate(false)
ch <- info
}()
return ch
}
// IsNewerVersion 语义化版本号比对 (如 "v1.0.5" > "v1.0.4")
func IsNewerVersion(latest, current string) bool {
latestClean := strings.TrimPrefix(strings.TrimSpace(latest), "v")
currentClean := strings.TrimPrefix(strings.TrimSpace(current), "v")
if latestClean == "" || currentClean == "" || latestClean == currentClean {
return false
}
latestParts := strings.Split(latestClean, ".")
currentParts := strings.Split(currentClean, ".")
for i := 0; i < len(latestParts) && i < len(currentParts); i++ {
lNum, err1 := strconv.Atoi(latestParts[i])
cNum, err2 := strconv.Atoi(currentParts[i])
if err1 != nil || err2 != nil {
return latestClean > currentClean
}
if lNum > cNum {
return true
} else if lNum < cNum {
return false
}
}
return len(latestParts) > len(currentParts)
}
+30
View File
@@ -0,0 +1,30 @@
package updater
import (
"testing"
)
func TestIsNewerVersion(t *testing.T) {
tests := []struct {
latest string
current string
expected bool
}{
{"v1.0.5", "v1.0.4", true},
{"v1.1.0", "v1.0.4", true},
{"v2.0.0", "v1.9.9", true},
{"v1.0.4", "v1.0.4", false},
{"v1.0.3", "v1.0.4", false},
{"v1.0.4.1", "v1.0.4", true},
{"1.0.5", "1.0.4", true},
{"", "v1.0.4", false},
{"v1.0.4", "", false},
}
for _, tt := range tests {
got := IsNewerVersion(tt.latest, tt.current)
if got != tt.expected {
t.Errorf("IsNewerVersion(%q, %q) = %v, expected %v", tt.latest, tt.current, got, tt.expected)
}
}
}
+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()