11 Commits
Author SHA1 Message Date
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
20 changed files with 1614 additions and 92 deletions

No files matched your search

+128 -6
View File
@@ -45,14 +45,45 @@
它完全剥离聊天上下文,像一个**坐在你工位旁边、嘴有点损但眼神极毒的资深架构师同事**,只对纯粹的代码变更(`git diff`)进行冷酷挑刺: 它完全剥离聊天上下文,像一个**坐在你工位旁边、嘴有点损但眼神极毒的资深架构师同事**,只对纯粹的代码变更(`git diff`)进行冷酷挑刺:
- ⚡ **Git 原生极简体验**:无需改变任何工作习惯,在命令行敲 `git air` 即可秒级唤起。 - ⚡ **Git 原生极简体验**:无需改变任何工作习惯,在命令行敲 `git air` 即可秒级唤起。
- 🧠 **全模型自由接入**:开箱即用预置 **Google Gemini、Anthropic Claude、xAI Grok、DeepSeek、本地离线 Ollama、OpenAI** 等全系列模型。 - 🧠 **全模型自由接入**:开箱即用预置 **Google Gemini 3.x、Anthropic Claude、xAI Grok、DeepSeek、阿里通义千问、智谱 GLM、月之暗面 Kimi、本地 Ollama、OpenAI** 等全系列模型。
- 🛡️ **智能降噪过滤**:自动剥离 `go.sum`、`package-lock.json`、`*.pb.go`、`*.gen.go` 等锁文件与自动生成代码,省 Token、防死循环。 - 🛡️ **智能降噪过滤**:自动剥离 `go.sum`、`package-lock.json`、`*.pb.go`、`*.gen.go` 等锁文件与自动生成代码,省 Token、防死循环。
- 🎯 **严谨架构师准则**:直奔主题,聚焦并发竞态、死锁、SQL 注入、空指针、资源泄露,并直接提供修复参考代码。 - 🎯 **严谨架构师准则**:直奔主题,聚焦并发竞态、死锁、SQL 注入、空指针、资源泄露,并直接提供修复参考代码。
- 📊 **Token 消耗与费用实时估算**:实时捕获并统计输入/输出 Token 消耗,内置 2026 最新官方定价表精准核算每次审查成本(本地离线模型自动标注免费)。
- ✨ **AI 智能 Commit Message**:一键根据代码变更生成规范的 Conventional Commits 提交说明,支持**中/英/日/韩**等多语言自适应。
- 📋 **团队规范适配(`.airules`)**:在项目根目录放置 `.airules`,AI 自动加载团队专属架构规范并最高优先级执行。 - 📋 **团队规范适配(`.airules`)**:在项目根目录放置 `.airules`,AI 自动加载团队专属架构规范并最高优先级执行。
- 🪝 **一键 Pre-commit 钩子**:一行命令挂载 Git 提交前自动拦截,在代码上库前把好第一道质量关。 - 🪝 **一键 Pre-commit 钩子**:一行命令挂载 Git 提交前自动拦截,在代码上库前把好第一道质量关。
--- ---
## 🖥️ 终端效果演示
```text
$ git air
[git-air] 代码评审中... (Engine: gemini / gemini-3.7-flash)
─────────────────────────────────────────────────────────────────
#### 变更概述
在用户鉴权模块中引入了 Redis 缓存逻辑,并调整了上下文超时传递机制。
#### 详细审查意见
- [BLOCKER] internal/service/user.go:45 - 严重安全隐患: 循环体内直接拼接原生 SQL 字符串,存在 SQL 注入风险。
// 建议修复方案:
db.Where("username = ?", inputName).First(&user)
- [WARNING] internal/service/user.go:82 - 潜在风险: 静默丢弃了 Redis 连接异常,当缓存宕机时可能造成缓存击穿。
- [WARNING] internal/service/user.go:103 - 并发隐患: 提前 return 时漏释放互斥锁(Mutex Leak),高并发下极易引发死锁。
#### 评审结论
- 结论: [REJECT]
- 评分: 60 / 100
📊 Token: 输入 1,243 / 输出 412 | ≈ $0.0025
─────────────────────────────────────────────────────────────────
```
---
## 📦 安装方式 ## 📦 安装方式
### 方式 1:通过 Go 一键安装(推荐) ### 方式 1:通过 Go 一键安装(推荐)
@@ -112,9 +143,15 @@ 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
``` ```
查看当前配置状态: 查看当前配置状态(含自定义费率与脱敏 Key):
```bash ```bash
git air config get git air config get
``` ```
@@ -142,13 +179,56 @@ git air --provider deepseek --model deepseek-chat
--- ---
### 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`**,死守代码质量底线!
- **严格模式 (`--strict`)**:若希望发现 `[WARNING]` 也强制阻断提交,可使用 `git air --strict`;
- **临时跳过阻断**:如确需强行提交,可使用 Git 原生命令 `git commit --no-verify` 或附加 `git-air --no-block`。
如需卸载钩子: 如需卸载钩子:
```bash ```bash
@@ -157,11 +237,25 @@ 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`)
@@ -181,6 +275,13 @@ model: "gemini-3.7-flash"
# 自定义基础提示词 (可选,留空则使用默认资深架构师准则) # 自定义基础提示词 (可选,留空则使用默认资深架构师准则)
# 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"
``` ```
--- ---
@@ -199,6 +300,27 @@ model: "gemini-3.7-flash"
--- ---
## 🛡️ 自定义忽略审查(`.airignore`)
除了内置自动忽略的锁文件与二进制外,你还可以在仓库根目录创建 `.airignore` 文件,自定义排除不需要 AI 评审的文件或目录:
```gitignore
# 忽略所有文档与原型设计
docs/
*.md
# 忽略测试 Mock 与假数据
tests/mock/
testdata/
# 忽略私密配置文件
*.env
secrets.yaml
```
---
## 📄 开源许可证 ## 📄 开源许可证
本项目采用 [MIT 许可证](LICENSE)。欢迎提交 Issue 与 Pull Request! 本项目采用 [MIT 许可证](LICENSE)。欢迎提交 Issue 与 Pull Request!
+142 -6
View File
@@ -44,14 +44,45 @@ While there are great cloud/CI-based PR review bots, **`git-air` is designed to
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`): 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. - ⚡ **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, Anthropic Claude, xAI Grok, DeepSeek, Local Ollama, OpenAI**, and any OpenAI-compatible custom endpoint. - 🧠 **Universal Multi-Model Support**: Pre-configured support for **Google Gemini 3.x, Anthropic Claude, xAI Grok, DeepSeek, Qwen, Zhipu GLM, Moonshot Kimi, Local Ollama, OpenAI**, and any OpenAI-compatible endpoint.
- 🛡️ **Smart Noise Reduction**: Automatically strips out `go.sum`, `package-lock.json`, `*.pb.go`, and lockfiles to save tokens and eliminate review loops. - 🛡️ **Smart Noise Reduction**: Automatically strips out `go.sum`, `package-lock.json`, `*.pb.go`, and lockfiles to save tokens and eliminate review loops.
- 🎯 **Strict Senior Architect Persona**: Zero corporate fluff. Directly flags concurrency deadlocks, SQL injections, nil dereferences, and resource leaks with copy-paste code patches. - 🎯 **Strict Senior Architect Persona**: Zero corporate fluff. Directly flags concurrency deadlocks, SQL injections, nil dereferences, and resource leaks with copy-paste code patches.
- 📊 **Real-time Token & Cost Estimation**: Accurately tracks prompt/completion token consumption with built-in official pricing rates (local offline models automatically marked free).
- ✨ **AI Commit Message Generator**: Automatically generates clean, standardized Conventional Commits messages with multi-language support (English, Japanese, Korean, Chinese).
- 📋 **Team Rules Support (`.airules`)**: Drop an `.airules` file in your repo root, and the AI will prioritize your team-specific guidelines. - 📋 **Team Rules Support (`.airules`)**: Drop an `.airules` file in your repo root, and the AI will prioritize your team-specific guidelines.
- 🪝 **One-Click Pre-commit Hook**: Mounts as a local Git hook in one command to block dangerous bugs before code is committed. - 🪝 **One-Click Pre-commit Hook**: Mounts as a local Git hook in one command to block dangerous bugs before code is committed.
--- ---
## 🖥️ Terminal Preview
```text
$ git air
[git-air] Reviewing code... (Engine: gemini / gemini-3.7-flash)
─────────────────────────────────────────────────────────────────
#### Change Summary
Added Redis caching layer to user authentication and refined context propagation.
#### Detailed Findings
- [BLOCKER] internal/service/user.go:45 - Critical Vulnerability: Direct SQL string concatenation inside loop. Potential SQL injection.
// Suggested Fix:
db.Where("username = ?", inputName).First(&user)
- [WARNING] internal/service/user.go:82 - Potential Risk: Silently discarding Redis error. May lead to cache stampede.
- [WARNING] internal/service/user.go:103 - Concurrency Issue: Mutex unlock skipped on early return branch. High risk of deadlock.
#### Verdict
- Status: [REJECT]
- Score: 60 / 100
📊 Token: Input 1,243 / Output 412 | ≈ $0.0025
─────────────────────────────────────────────────────────────────
```
---
## 📦 Installation ## 📦 Installation
### Option 1: Via Go Install (Recommended) ### Option 1: Via Go Install (Recommended)
@@ -111,9 +142,15 @@ git air config set --provider ollama --model qwen2.5-coder
# 12. OpenAI Official (GPT-4o / o3-mini) # 12. OpenAI Official (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. 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
``` ```
Check current configuration: Check current configuration (including masked keys and custom rates):
```bash ```bash
git air config get git air config get
``` ```
@@ -141,13 +178,56 @@ git air --provider deepseek --model deepseek-chat
--- ---
### 3. One-Click Git Pre-commit Hook ### 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: Install the hook inside any Git repository:
```bash ```bash
git air hook install git air hook install
``` ```
From now on, whenever you run `git commit`, `git-air` will automatically perform a code review in your terminal and warn you of any blocker issues! - **Automated Commit Gate**: When `git-air` flags any `[BLOCKER]` issue or outputs a `[REJECT]` verdict, it automatically exits with a non-zero status code, **directly intercepting and blocking `git commit`**!
- **Strict Mode (`--strict`)**: To block commits even on `[WARNING]` issues, use `git air --strict`;
- **Bypass Interception**: If you urgently need to bypass the check, use standard Git `git commit --no-verify` or pass `git air --no-block`.
To uninstall: To uninstall:
```bash ```bash
@@ -156,14 +236,49 @@ 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 ## ⚙️ Configuration Hierarchy
`git-air` searches for configurations in the following priority order: `git-air` searches for configurations in the following priority order:
1. **CLI Flags** (`--key`, `--model`, `--provider`, `--prompt`) 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`) 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) 3. **Project Config** (`./config.yaml` or `./.git-air.yaml` in repo root)
4. **Global User Config** (`~/.git-air/config.yaml`) 4. **Global User Config** (`~/.git-air/config.yaml`)
### `config.yaml` Example Template:
```yaml
# Provider: gemini, claude, grok, deepseek, qwen, zhipu, moonshot, siliconflow, ollama, openai, custom
provider: "gemini"
api_key: "YOUR_API_KEY_HERE"
model: "gemini-3.7-flash"
# Custom Base URL (Optional)
# base_url: "https://api.deepseek.com/v1"
# Custom System Prompt (Optional)
# custom_prompt: ""
# Custom Token Pricing (Optional, in USD per 1M tokens)
# price_input: 0.75
# price_output: 3.75
# AI Commit Message Language (Optional: auto, zh, en, ja, ko)
# commit_lang: "auto"
```
--- ---
## 📋 Custom Team Rules (`.airules`) ## 📋 Custom Team Rules (`.airules`)
@@ -180,6 +295,27 @@ Create an `.airules` file in your repository root. `git-air` will automatically
--- ---
## 🛡️ 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 ## 📄 License
This project is licensed under the [MIT License](LICENSE). Contributions, PRs, and issues are welcome! This project is licensed under the [MIT License](LICENSE). Contributions, PRs, and issues are welcome!
+188 -11
View File
@@ -44,22 +44,95 @@ GitHubなどのCI/CD上で動作するボットも優秀ですが、**`git-air`
チャット履歴のコンテキストを一切排除し、まるで**あなたの隣に座る口は悪いが目の鋭いシニアアーキテクト**のように、純粋な差分(`git diff`)だけを厳しくチェックします: チャット履歴のコンテキストを一切排除し、まるで**あなたの隣に座る口は悪いが目の鋭いシニアアーキテクト**のように、純粋な差分(`git diff`)だけを厳しくチェックします:
- ⚡ **Gitネイティブな極上の操作性**:いつもの作業フローを崩さず、ターミナルで `git air` と叩くだけで即座に起動。 - ⚡ **Gitネイティブな極上の操作性**:いつもの作業フローを崩さず、ターミナルで `git air` と叩くだけで即座に起動。
- 🧠 **主要モデルを完全網羅**:**Google Gemini、Anthropic Claude、xAI Grok、DeepSeek、ローカルOllama、OpenAI** などを設定なしで即座に切り替え可能。 - 🧠 **主要モデルを完全網羅**:**Google Gemini 3.x、Anthropic Claude、xAI Grok、DeepSeek、Qwen、智譜 GLM、Kimi、ローカルOllama、OpenAI** などを設定なしで即座に切り替え可能。
- 🛡️ **スマートなノイズ除去**:`go.sum` や `package-lock.json`、自動生成された `*.pb.go` を自動除外。Token消費を抑え、思考ループを防ぎます。 - 🛡️ **スマートなノイズ除去**:`go.sum` や `package-lock.json`、自動生成された `*.pb.go` を自動除外。Token消費を抑え、思考ループを防ぎます。
- 🎯 **厳格なシニアエンジニア基準**:お世辞や無駄口は一切なし。並行処理の競合、デッドロック、SQLインジェクション、NULLポインタ、リソースリークを指摘し、具体的な修正コードを直接提示します。 - 🎯 **厳格なシニアエンジニア基準**:お世辞や無駄口は一切なし。並行処理の競合、デッドロック、SQLインジェクション、NULLポインタ、リソースリークを指摘し、具体的な修正コードを直接提示します。
- 📊 **Token消費量と費用のリアルタイム見積もり**:入出力Token数を正確に集計し、各社最新の公式料金表に基づいて実行コストを即座に計算(ローカル完全オフラインモデルは無料表示)。
- ✨ **AI コミットメッセージ自動生成**:変更差分から Conventional Commits 準拠の高品質なメッセージを生成(**日本語・英語・韓国語・中国語**に自動適応)。
- 📋 **チーム規約の読み込み(`.airules`)**:リポジトリ直下に `.airules` を配置するだけで、チーム固有のアーキテクチャ規約を最優先で適用。 - 📋 **チーム規約の読み込み(`.airules`)**:リポジトリ直下に `.airules` を配置するだけで、チーム固有のアーキテクチャ規約を最優先で適用。
- 🪝 **ワンクリックで Pre-commit フック化**:コマンド1発でGitフックとして登録。危険なコードのコミットを未然にブロックします。 - 🪝 **ワンクリックで Pre-commit フック化**:コマンド1発でGitフックとして登録。危険なコードのコミットを未然にブロックします。
--- ---
## 📦 インストール ## 🖥️ ターミナル実行イメージ
### 方法 1: Go Install(推奨) ```text
```bash $ git air
go install github.com/unclesam-ly/git-air@latest
[git-air] コードレビュー中... (Engine: gemini / gemini-3.7-flash)
────────────────────────────────# 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
``` ```
### 方法 2: ソースコードからビルド 現在の設定を確認(カスタム料金やマスク化Keyを含む):
```bash
git air config get
```
---
### 2. よく使うレビューコマンド
```bash
# 現在ステージングされた(git add済み)変更をレビュー(デフォルト)
git air
# 直前のコミット内容をレビュー
git air HEAD~1
# ブランチ間の差分をレビュー
git air main..feature-agent
# 特定のファイルのみをレビュー
git air internal/service/chat.go
# 一時的にモデルやProviderを指定して実行
git air --provider deepseek --model deepseek-chat
```
---
### 3. AI コミットメッセージ自動生成 (`git air msg`)
コミットメッセージの作成に悩む必要はもうありません。`git-air` がコードの変更差分を分析し、Conventional Commits 準拠のメッセージを**多言語自動適応**と**自動ステージング**で生成します:
```bash
# 1. 基本的な使い方:差分を分析して対話型コミット
git air msg
# 2. 変更ファイルを自動ステージング(git add 不要)
git air msg -a
# 3. 単発で出力言語を指定(auto, zh, en, ja, ko)
git air msg -l en # 英語(オープンソース・国際プロジェクト向け)
git air msg -l ja # 日本語
git air msg -l ko # 韓国語
# 4. 最速ワンライナー:自動ステージング + 生成 + 即座にコミット
git air msg -a -c
# 5. グローバルデフォルト言語を永続設定(毎回 -l を付ける必要がなくなります)
git air config set --commit-lang ja # 今後常に日本語で生成
git air config set --commit-lang en # 今後常に英語で生成
git air config set --commit-lang auto # システム言語への自動追従に戻す
```
**実行イメージ:**
```text
$ git air msg -l ja
[git-air] Conventional Commit Message を生成中... (Language: 日本語 / Engine: gemini-3.7-flash)
─────────────────────────────────────────────────────────────────
feat(auth): Redisキャッシュを追加し、Mutexのデッドロックを修正
- ユーザー認証ハンドラーに Redis トークンキャッシュ層を導入
- 早期リターン時に Mutex の Unlock が漏れる並行処理のバグを修正
─────────────────────────────────────────────────────────────────
? このメッセージで直接 git commit を実行しますか?[Y/n]: y
[SUCCESS] コードのコミットに成功しました!🎉
```スコードからビルド
```bash ```bash
git clone https://github.com/unclesam-ly/git-air.git git clone https://github.com/unclesam-ly/git-air.git
cd git-air cd git-air
@@ -111,9 +184,15 @@ 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. コミットメッセージのデフォルト言語を永続設定 (任意: auto, zh, en, ja, ko)
git air config set --commit-lang ja
``` ```
現在の設定を確認: 現在の設定を確認(カスタム料金やマスク化Keyを含む):
```bash ```bash
git air config get git air config get
``` ```
@@ -141,13 +220,56 @@ git air --provider deepseek --model deepseek-chat
--- ---
### 3. ワンタッチで Pre-commit フックを登録 ### 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 リポジトリ直下で以下を実行: Git リポジトリ直下で以下を実行:
```bash ```bash
git air hook install git air hook install
``` ```
以降、`git commit` を実行するたびにターミナル上で自動レビューが走り、重大な欠陥があればコミット前に警告を出してくれます! - **コミット自動ブロック機能**:レビューで `[BLOCKER]` 重大欠陥や `[REJECT]` 判定が検出された場合、`git-air` は非ゼロの終了コードで**コミット(`git commit`)を自動的に中断・ブロック**します!
- **厳格モード (`--strict`)**:`[WARNING]` 警告でもコミットをブロックしたい場合は `git air --strict` を使用;
- **一時的なブロック解除**:緊急でコミットを強制したい場合は、Git 標準の `git commit --no-verify` または `git air --no-block` を付与します。
フックを解除する場合: フックを解除する場合:
```bash ```bash
@@ -156,13 +278,48 @@ git air hook uninstall
--- ---
### 5. バージョン確認とワンクリック自動更新 (`git air update`)
`git-air` には**軽量な24時間サイレント更新検出機能**が組み込まれており、新しいリリースが公開されるとターミナル下部で自動通知されます。手動での確認やアップデートも可能です:
```bash
# 現在のバージョンを確認し、更新をチェック
git air version
# 最新バージョンへ一括アップデート
git air update
```
---
## ⚙️ 設定の優先順位 ## ⚙️ 設定の優先順位
1. **コマンドライン引数** (`--key`, `--model`, `--provider`, `--prompt`) 1. **コマンドライン引数** (`--key`, `--model`, `--provider`, `--prompt`, `--lang`, `--price-input`, `--price-output`, `--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. **プロジェクト個別設定** (リポジトリ直下の `./config.yaml` または `./.git-air.yaml`) 3. **プロジェクト個別設定** (リポジトリ直下の `./config.yaml` または `./.git-air.yaml`)
4. **グローバル設定** (`~/.git-air/config.yaml`) 4. **グローバル設定** (`~/.git-air/config.yaml`)
### `config.yaml` 設定ファイル例:
```yaml
# プロバイダー: gemini, claude, grok, deepseek, qwen, zhipu, moonshot, siliconflow, ollama, openai, custom
provider: "gemini"
api_key: "YOUR_API_KEY_HERE"
model: "gemini-3.7-flash"
# カスタム API エンドポイント (任意)
# base_url: "https://api.deepseek.com/v1"
# カスタム System Prompt (任意)
# custom_prompt: ""
# カスタム Token 料金設定 (任意、単位: 米ドル / 1M Tokens)
# price_input: 0.75
# price_output: 3.75
# AI コミットメッセージ言語設定 (任意: auto, zh, en, ja, ko)
# commit_lang: "auto"
```
--- ---
## 📋 チーム規約のカスタマイズ(`.airules`) ## 📋 チーム規約のカスタマイズ(`.airules`)
@@ -179,6 +336,26 @@ git air hook uninstall
--- ---
## 🛡️ カスタム除外設定(`.airignore`)
組み込みの自動除外(ロックファイルやバイナリ)に加え、リポジトリ直下に `.airignore` を配置することで、任意のファイルやディレクトリをAIレビューから除外できます:
```gitignore
# ドキュメントや設計書を除外
docs/
*.md
# テストモックやダミーデータを除外
tests/mock/
testdata/
# 機密設定ファイルを除外
*.env
secrets.yaml
```
---
## 📄 ライセンス ## 📄 ライセンス
本プロジェクトは [MIT ライセンス](LICENSE) のもとで公開されています。Issue や Pull Request も大歓迎です! 本プロジェクトは [MIT ライセンス](LICENSE) のもとで公開されています。Issue や Pull Request も大歓迎です!
+35 -6
View File
@@ -5,18 +5,21 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"github.com/unclesam-ly/git-air/internal/ui"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/unclesam-ly/git-air/internal/ui"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
// AppConfig 全局配置结构体 // AppConfig 全局配置结构体
type AppConfig struct { type AppConfig struct {
Provider string `yaml:"provider"` // gemini, deepseek, ollama, openai, custom Provider string `yaml:"provider"` // gemini, deepseek, ollama, openai, custom
APIKey string `yaml:"api_key"` // API Key APIKey string `yaml:"api_key"` // API Key
BaseURL string `yaml:"base_url"` // 自定义请求地址 BaseURL string `yaml:"base_url"` // 自定义请求地址
Model string `yaml:"model"` // 模型名称 Model string `yaml:"model"` // 模型名称
CustomPrompt string `yaml:"custom_prompt"` // 用户自定义的 Base System Prompt CustomPrompt string `yaml:"custom_prompt"` // 用户自定义的 Base System Prompt
PriceInput float64 `yaml:"price_input,omitempty"` // 自定义输入单价 (每 1M tokens 美元,可选)
PriceOutput float64 `yaml:"price_output,omitempty"` // 自定义输出单价 (每 1M tokens 美元,可选)
CommitLang string `yaml:"commit_lang,omitempty"` // Commit Message 生成语言 (auto, zh, en, ja, ko 等)
} }
func getConfigPath() (string, error) { func getConfigPath() (string, error) {
@@ -75,6 +78,9 @@ 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
}
return cfg, nil return cfg, nil
} }
@@ -102,6 +108,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()
@@ -126,6 +133,15 @@ var configSetCmd = &cobra.Command{
if pr, _ := cmd.Flags().GetString("prompt"); pr != "" { if pr, _ := cmd.Flags().GetString("prompt"); pr != "" {
cfg.CustomPrompt = pr cfg.CustomPrompt = pr
} }
if pi, _ := cmd.Flags().GetFloat64("price-input"); pi > 0 {
cfg.PriceInput = pi
}
if po, _ := cmd.Flags().GetFloat64("price-output"); po > 0 {
cfg.PriceOutput = po
}
if 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)
return return
@@ -160,6 +176,16 @@ var configGetCmd = &cobra.Command{
hasPrompt = "是 (已自定义)" hasPrompt = "是 (已自定义)"
} }
fmt.Printf("CustomPrompt: %s\n", hasPrompt) fmt.Printf("CustomPrompt: %s\n", hasPrompt)
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")
}
}, },
} }
@@ -169,6 +195,9 @@ func init() {
configSetCmd.Flags().StringP("model", "m", "", "模型名称") configSetCmd.Flags().StringP("model", "m", "", "模型名称")
configSetCmd.Flags().StringP("base-url", "u", "", "自定义 API 地址") configSetCmd.Flags().StringP("base-url", "u", "", "自定义 API 地址")
configSetCmd.Flags().String("prompt", "", "自定义 Base System Prompt") configSetCmd.Flags().String("prompt", "", "自定义 Base System Prompt")
configSetCmd.Flags().Float64("price-input", 0, "自定义每 1M 输入 Token 美元价格")
configSetCmd.Flags().Float64("price-output", 0, "自定义每 1M 输出 Token 美元价格")
configSetCmd.Flags().String("commit-lang", "", "Commit Message 语言 (auto, zh, en, ja, ko 等)")
configCmd.AddCommand(configSetCmd) configCmd.AddCommand(configSetCmd)
configCmd.AddCommand(configGetCmd) configCmd.AddCommand(configGetCmd)
+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)
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)
}
+53 -7
View File
@@ -3,12 +3,14 @@ package cmd
import ( import (
"context" "context"
"os" "os"
"strings"
"github.com/spf13/cobra"
"github.com/unclesam-ly/git-air/internal/git" "github.com/unclesam-ly/git-air/internal/git"
"github.com/unclesam-ly/git-air/internal/llm" "github.com/unclesam-ly/git-air/internal/llm"
"github.com/unclesam-ly/git-air/internal/reviewer" "github.com/unclesam-ly/git-air/internal/reviewer"
"github.com/unclesam-ly/git-air/internal/ui" "github.com/unclesam-ly/git-air/internal/ui"
"github.com/spf13/cobra" "github.com/unclesam-ly/git-air/internal/updater"
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
@@ -20,6 +22,9 @@ var rootCmd = &cobra.Command{
Run: func(cmd *cobra.Command, args []string) { Run: func(cmd *cobra.Command, args []string) {
ctx := cmd.Context() ctx := cmd.Context()
// 后台非阻塞异步检查新版本
updateCh := updater.CheckAsync()
// 1. 检查并获取 Diff // 1. 检查并获取 Diff
if flagCached, _ := cmd.Flags().GetBool("cached"); flagCached { if flagCached, _ := cmd.Flags().GetBool("cached"); flagCached {
args = append([]string{"--cached"}, args...) args = append([]string{"--cached"}, args...)
@@ -53,10 +58,12 @@ var rootCmd = &cobra.Command{
// 3. 初始化大模型客户端 // 3. 初始化大模型客户端
llmClient, err := llm.NewClient(llm.Config{ llmClient, err := llm.NewClient(llm.Config{
Provider: cfg.Provider, Provider: cfg.Provider,
APIKey: cfg.APIKey, APIKey: cfg.APIKey,
BaseURL: cfg.BaseURL, BaseURL: cfg.BaseURL,
Model: cfg.Model, Model: cfg.Model,
PriceInput: cfg.PriceInput,
PriceOutput: cfg.PriceOutput,
}) })
if err != nil { if err != nil {
ui.PrintError("初始化模型失败: %v (提示: 请先执行 'git air config set --key YOUR_KEY')", err) ui.PrintError("初始化模型失败: %v (提示: 请先执行 'git air config set --key YOUR_KEY')", err)
@@ -76,15 +83,52 @@ var rootCmd = &cobra.Command{
// 6. 打印开场横幅并开始流式评审 // 6. 打印开场横幅并开始流式评审
ui.PrintBanner(cfg.Provider, cfg.Model) ui.PrintBanner(cfg.Provider, cfg.Model)
printer := ui.NewStreamPrinter() printer := ui.NewStreamPrinter()
_, err = rev.Execute(ctx, diff, true, func(chunk string) { var reviewOutput strings.Builder
_, usage, err := rev.Execute(ctx, diff, true, func(chunk string) {
reviewOutput.WriteString(chunk)
printer.PrintChunk(chunk) printer.PrintChunk(chunk)
}) })
if err != nil { if err != nil {
ui.PrintError("\n评审过程中发生错误: %v", err) ui.PrintError("\n评审过程中发生错误: %v", err)
return os.Exit(1)
}
// 7. 打印 Token 消耗统计
if usage != nil {
ui.PrintUsage(usage.InputTokens, usage.OutputTokens, usage.EstimateCost())
} }
ui.PrintFooter() ui.PrintFooter()
// 8. 检查新版本提醒(非阻塞)
select {
case info := <-updateCh:
if info != nil {
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)
}
}
}, },
} }
@@ -107,4 +151,6 @@ func init() {
rootCmd.Flags().String("prompt", "", "临时指定 System Prompt") rootCmd.Flags().String("prompt", "", "临时指定 System Prompt")
rootCmd.Flags().Bool("cached", false, "显式审查暂存区(git add)代码") rootCmd.Flags().Bool("cached", false, "显式审查暂存区(git add)代码")
rootCmd.Flags().Bool("staged", false, "显式审查暂存区(git add)代码") rootCmd.Flags().Bool("staged", false, "显式审查暂存区(git add)代码")
rootCmd.Flags().Bool("strict", false, "严格模式:遇到 WARNING 也会阻断退出")
rootCmd.Flags().Bool("no-block", false, "非阻断模式:即使发现 BLOCKER 也不以非零退出码阻断")
} }
+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)
}
+11 -1
View File
@@ -30,4 +30,14 @@ model: "gemini-3.7-flash"
# base_url: "" # base_url: ""
# 自定义全局评审提示词 (可选,留空则使用内置资深架构师准则) # 自定义全局评审提示词 (可选,留空则使用内置资深架构师准则)
custom_prompt: "" custom_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"
+20 -2
View File
@@ -32,6 +32,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 +58,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 +69,7 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
return "", err return "", err
} }
if strings.TrimSpace(stagedDiff) != "" { if strings.TrimSpace(stagedDiff) != "" {
return truncateDiffIfNeeded(FilterDiff(stagedDiff)), nil return truncateDiffIfNeeded(FilterDiff(stagedDiff, customIgnores...)), nil
} }
// 2. 如果暂存区为空,回退提取工作区未暂存的修改 // 2. 如果暂存区为空,回退提取工作区未暂存的修改
cmdArgs = []string{} cmdArgs = []string{}
@@ -64,7 +82,7 @@ func GetDiff(ctx context.Context, args ...string) (string, error) {
return "", err return "", err
} }
filteredDiff := FilterDiff(rawDiff) filteredDiff := FilterDiff(rawDiff, customIgnores...)
if strings.TrimSpace(filteredDiff) == "" { if strings.TrimSpace(filteredDiff) == "" {
return "", ErrEmptyDiff return "", ErrEmptyDiff
} }
+72 -14
View File
@@ -1,13 +1,14 @@
package git package git
import ( import (
"bufio"
"os"
"path/filepath" "path/filepath"
"regexp"
"strings" "strings"
) )
// 默认需要忽略的文件名与后缀列表 // 默认需要忽略的文件名与后缀列表
var ignoredPatterns = []string{ var defaultIgnoredPatterns = []string{
// 依赖与锁文件 // 依赖与锁文件
"go.sum", "go.sum",
"package-lock.json", "package-lock.json",
@@ -36,19 +37,45 @@ var ignoredPatterns = []string{
"*.pdf", "*.pdf",
} }
// isIgnoredFile 判断文件路径是否应该在 Code Review 中被忽略 // LoadAirIgnore 从仓库根目录加载 .airignore 文件中的自定义忽略规则
func isIgnoredFile(filePath string) bool { func LoadAirIgnore(repoRoot string) []string {
cleanPath := filepath.ToSlash(filePath) if repoRoot == "" {
return nil
}
ignoreFile := filepath.Join(repoRoot, ".airignore")
file, err := os.Open(ignoreFile)
if err != nil {
return nil
}
defer file.Close()
var patterns []string
scanner := bufio.NewScanner(file)
for scanner.Scan() {
trimmed := strings.TrimSpace(scanner.Text())
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
patterns = append(patterns, trimmed)
}
return patterns
}
// isIgnoredFile 判断文件路径是否命中忽略规则(内置规则 + 用户自定义规则)
func isIgnoredFile(filePath string, customPatterns []string) bool {
cleanPath := filepath.ToSlash(strings.TrimSpace(filePath))
baseName := filepath.Base(cleanPath) baseName := filepath.Base(cleanPath)
for _, pattern := range ignoredPatterns { // 1. 先匹配用户自定义规则 (.airignore)
// 精确匹配文件名(如 go.sum) for _, pattern := range customPatterns {
if pattern == baseName { if matchPattern(pattern, cleanPath, baseName) {
return true return true
} }
}
// 通配符匹配(如 *.pb.go) // 2. 再匹配内置默认规则
if matched, _ := filepath.Match(pattern, baseName); matched { for _, pattern := range defaultIgnoredPatterns {
if matchPattern(pattern, cleanPath, baseName) {
return true return true
} }
} }
@@ -56,10 +83,41 @@ func isIgnoredFile(filePath string) bool {
return false return false
} }
var diffHeaderRegex = regexp.MustCompile(`(?m)^diff --git a/(.*?) b/(.*?)$`) // matchPattern 单条规则智能匹配(支持目录/、通配符*、精确匹配)
func matchPattern(pattern, cleanPath, baseName string) bool {
pattern = filepath.ToSlash(strings.TrimSpace(pattern))
if pattern == "" {
return false
}
// FilterDiff 过滤掉 diff 中无意义的锁文件和自动生成文件的区块 // 目录规则:如 "docs/" 或 "vendor/"
func FilterDiff(rawDiff string) string { if strings.HasSuffix(pattern, "/") {
dirPrefix := strings.TrimSuffix(pattern, "/")
if strings.HasPrefix(cleanPath, dirPrefix+"/") || cleanPath == dirPrefix {
return true
}
}
// 精确匹配文件名或相对路径
if pattern == baseName || pattern == cleanPath {
return true
}
// 通配符匹配文件名(如 *.pb.go)
if matched, _ := filepath.Match(pattern, baseName); matched {
return true
}
// 通配符匹配全路径(如 mock/*.go)
if matched, _ := filepath.Match(pattern, cleanPath); matched {
return true
}
return false
}
// FilterDiff 过滤掉 diff 中命中忽略规则(如锁文件、生成的代码、.airignore 排除项)的文件区块
func FilterDiff(rawDiff string, customPatterns ...string) string {
if strings.TrimSpace(rawDiff) == "" { if strings.TrimSpace(rawDiff) == "" {
return "" return ""
} }
@@ -85,7 +143,7 @@ func FilterDiff(rawDiff string) string {
parts := strings.Fields(firstLine) parts := strings.Fields(firstLine)
if len(parts) >= 2 { if len(parts) >= 2 {
filePath := strings.TrimPrefix(parts[1], "b/") filePath := strings.TrimPrefix(parts[1], "b/")
if isIgnoredFile(filePath) { if isIgnoredFile(filePath, customPatterns) {
continue // 命中忽略规则,跳过这个文件的 diff continue // 命中忽略规则,跳过这个文件的 diff
} }
} }
+112
View File
@@ -0,0 +1,112 @@
package git
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestIsIgnoredFile(t *testing.T) {
customPatterns := []string{
"docs/",
"tests/mock/*.json",
"secrets.env",
}
tests := []struct {
filePath string
expected bool
}{
// 默认内置忽略规则
{"go.sum", true},
{"package-lock.json", true},
{"api/user.pb.go", true},
{"web/dist/app.min.js", true},
{"assets/logo.png", true},
// 用户自定义 .airignore 规则
{"docs/readme.md", true},
{"docs/api/v1.md", true},
{"tests/mock/user.json", true},
{"secrets.env", true},
// 不应被忽略的正常业务代码
{"cmd/root.go", false},
{"internal/service/user.go", false},
{"pkg/utils/helper.go", false},
}
for _, tt := range tests {
got := isIgnoredFile(tt.filePath, customPatterns)
if got != tt.expected {
t.Errorf("isIgnoredFile(%q) = %v, expected %v", tt.filePath, got, tt.expected)
}
}
}
func TestLoadAirIgnore(t *testing.T) {
tempDir, err := os.MkdirTemp("", "git-air-test-*")
if err != nil {
t.Fatalf("创建临时目录失败: %v", err)
}
defer os.RemoveAll(tempDir)
content := `# 忽略文档
docs/
# 忽略临时文件
*.tmp
*.log
`
ignoreFile := filepath.Join(tempDir, ".airignore")
if err := os.WriteFile(ignoreFile, []byte(content), 0644); err != nil {
t.Fatalf("写入 .airignore 失败: %v", err)
}
patterns := LoadAirIgnore(tempDir)
expected := []string{"docs/", "*.tmp", "*.log"}
if len(patterns) != len(expected) {
t.Fatalf("LoadAirIgnore 返回数量不符: got %v, expected %v", patterns, expected)
}
for i, p := range patterns {
if p != expected[i] {
t.Errorf("Pattern[%d] = %q, expected %q", i, p, expected[i])
}
}
}
func TestFilterDiff(t *testing.T) {
rawDiff := `diff --git a/go.sum b/go.sum
index 111..222 100644
--- a/go.sum
+++ b/go.sum
@@ -1 +1 @@
+some-package v1.0.0 h1:...
diff --git a/internal/service/user.go b/internal/service/user.go
index 333..444 100644
--- a/internal/service/user.go
+++ b/internal/service/user.go
@@ -1 +1 @@
+func HandleUser() {}
diff --git a/docs/spec.md b/docs/spec.md
index 555..666 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -1 +1 @@
+# API Spec
`
customPatterns := []string{"docs/"}
filtered := FilterDiff(rawDiff, customPatterns...)
if strings.Contains(filtered, "go.sum") {
t.Errorf("FilterDiff 应当过滤掉 go.sum")
}
if strings.Contains(filtered, "docs/spec.md") {
t.Errorf("FilterDiff 应当过滤掉 docs/spec.md")
}
if !strings.Contains(filtered, "internal/service/user.go") {
t.Errorf("FilterDiff 应当保留正常的业务代码 internal/service/user.go")
}
}
+150 -14
View File
@@ -12,16 +12,20 @@ import (
// Config 大模型统一配置 // Config 大模型统一配置
type Config struct { type Config struct {
Provider string // "gemini", "deepseek", "openai", "ollama", "custom" Provider string // "gemini", "deepseek", "openai", "ollama", "custom"
APIKey string // 密钥 (Ollama 本地可填任意字符或留空) APIKey string // 密钥 (Ollama 本地可填任意字符或留空)
BaseURL string // 自定义接口地址 (非必填,会自动根据 Provider 设置默认值) BaseURL string // 自定义接口地址 (非必填,会自动根据 Provider 设置默认值)
Model string // 模型名称 (非必填,会自动设置默认主力模型) Model string // 模型名称 (非必填,会自动设置默认主力模型)
PriceInput float64 // 用户自定义每 1M 输入 Token 美元价格 (可选,覆盖内置价格表)
PriceOutput float64 // 用户自定义每 1M 输出 Token 美元价格 (可选,覆盖内置价格表)
} }
// Client 统一大模型客户端 // Client 统一大模型客户端
type Client struct { type Client struct {
client *openai.Client client *openai.Client
model string model string
priceInput float64
priceOutput float64
} }
// NewClient 初始化客户端 // NewClient 初始化客户端
@@ -227,8 +231,10 @@ func NewClient(cfg Config) (*Client, error) {
clientConfig.BaseURL = baseURL clientConfig.BaseURL = baseURL
return &Client{ return &Client{
client: openai.NewClientWithConfig(clientConfig), client: openai.NewClientWithConfig(clientConfig),
model: model, model: model,
priceInput: cfg.PriceInput,
priceOutput: cfg.PriceOutput,
}, nil }, nil
} }
@@ -254,8 +260,122 @@ func (c *Client) Review(ctx context.Context, systemPrompt, diff string) (string,
return resp.Choices[0].Message.Content, nil return resp.Choices[0].Message.Content, nil
} }
// ReviewStream 流式逐字返回评审结果(打字机效果) // Usage 记录一次调用的 Token 消耗
func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, onChunk func(chunk string)) error { type Usage struct {
InputTokens int
OutputTokens int
Model string
Provider string
CustomPriceInput float64 // 用户自定义输入单价(每 1M tokens 美元,>0 优先使用)
CustomPriceOutput float64 // 用户自定义输出单价(每 1M tokens 美元,>0 优先使用)
}
// reviewPriceTable 各模型每 1M token 的美元单价(输入/输出)
// 数据来源:各厂商官方定价页面,更新于 2026-08
// 如实际价格有变动,可在 config.yaml 中通过 price_input/price_output 字段自定义覆盖
var reviewPriceTable = map[string][2]float64{
// ── Google Gemini ─────────────────────────────────────────────
// 3.x 系列(当前主力)
"gemini-3.7-flash": {0.75, 3.75}, // 推介价至 2026-12-31,之后 1.50/7.50
"gemini-3.6-flash": {1.50, 7.50},
"gemini-3.5-flash": {1.50, 9.00},
"gemini-3.5-flash-lite": {0.30, 2.50},
"gemini-3.1-flash-lite": {0.25, 1.50},
"gemini-3.1-pro": {2.00, 12.00},
// 2.5 系列(2026-10 退役)
"gemini-2.5-pro": {1.25, 10.00},
"gemini-2.5-flash": {0.30, 2.50},
"gemini-2.5-flash-lite": {0.10, 0.40},
// 旧款(兼容)
"gemini-2.0-flash": {0.10, 0.40},
// ── DeepSeek ─────────────────────────────────────────────────
// 注意:DeepSeek 已启用峰谷定价,以下为非高峰标准价
"deepseek-v4-flash": {0.22, 0.66},
"deepseek-chat": {0.22, 0.66}, // V4-Flash 别名
"deepseek-reasoner": {0.55, 2.19},
// ── Anthropic Claude ──────────────────────────────────────────
"claude-opus-5": {5.00, 25.00},
"claude-sonnet-5": {2.00, 10.00}, // 推介价至 2026-08-31
"anthropic/claude-sonnet-5": {2.00, 10.00},
"claude-haiku-4-5": {1.00, 5.00},
"anthropic/claude-haiku-4-5": {1.00, 5.00},
// 旧款(兼容)
"anthropic/claude-3.7-sonnet": {3.00, 15.00},
"anthropic/claude-3.5-sonnet": {3.00, 15.00},
"anthropic/claude-3.5-haiku": {0.80, 4.00},
// ── xAI Grok ─────────────────────────────────────────────────
"grok-4.6": {2.00, 6.00}, // >200k tokens 翻倍
"grok-4.3": {1.25, 2.50},
"grok-2-latest": {2.00, 6.00}, // 旧款映射
// ── OpenAI ───────────────────────────────────────────────────
"gpt-5.6-sol": {5.00, 30.00},
"gpt-5.6-terra": {2.00, 12.00},
"gpt-5.6-luna": {0.20, 1.20},
"gpt-4o": {2.50, 10.00}, // 旧款保留
"gpt-4o-mini": {0.15, 0.60},
"o3-mini": {1.10, 4.40},
// ── SiliconFlow(聚合平台,按 DeepSeek V4 主力估算)────────────
"deepseek-ai/DeepSeek-V3": {0.22, 0.66},
"deepseek-ai/DeepSeek-R1": {0.55, 2.19},
// ── Qwen / 阿里通义千问 ───────────────────────────────────────
"qwen-3.8-max": {2.00, 6.00},
"qwen-max": {2.00, 6.00},
"qwen-plus": {0.40, 1.20},
"qwen-3.7-flash": {0.03, 0.13},
// ── Kimi / Moonshot AI ────────────────────────────────────────
"kimi-k3": {3.00, 15.00},
"moonshot-v1-8k": {0.95, 4.00},
"moonshot-v1-32k": {1.90, 8.00},
"moonshot-v1-128k": {3.50, 15.00},
// ── 智谱 GLM ─────────────────────────────────────────────────
"glm-5.3": {1.40, 4.40},
"glm-5.2": {1.40, 4.40},
"glm-4-plus": {1.40, 4.40},
// ── Doubao / 字节跳动 ─────────────────────────────────────────
"doubao-seed-2.1-pro": {0.88, 4.42},
"doubao-pro-32k": {0.88, 4.42},
// ── Groq(超快推理,按量计费)────────────────────────────────
"llama-3.3-70b-versatile": {0.59, 0.79},
"llama-3.1-8b-instant": {0.05, 0.08},
// ── 本地模型(完全免费)──────────────────────────────────────
"ollama": {0, 0},
"local-model": {0, 0},
}
// EstimateCost 估算费用(美元),返回 -1 表示无定价数据
func (u *Usage) EstimateCost() float64 {
// 1. 优先使用用户在 config.yaml 中配置的自定义单价(每 1M tokens 美元)
if u.CustomPriceInput > 0 || u.CustomPriceOutput > 0 {
inputCost := float64(u.InputTokens) / 1_000_000 * u.CustomPriceInput
outputCost := float64(u.OutputTokens) / 1_000_000 * u.CustomPriceOutput
return inputCost + outputCost
}
// 2. 回退使用内置官方模型价格表
prices, ok := reviewPriceTable[u.Model]
if !ok {
return -1
}
// Ollama / 本地模型 price = 0,直接返回 0
inputCost := float64(u.InputTokens) / 1_000_000 * prices[0]
outputCost := float64(u.OutputTokens) / 1_000_000 * prices[1]
return inputCost + outputCost
}
// ReviewStream 流式逐字返回评审结果(打字机效果),同时返回 Token 使用量
func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, onChunk func(chunk string)) (*Usage, error) {
stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{ stream, err := c.client.CreateChatCompletionStream(ctx, openai.ChatCompletionRequest{
Model: c.model, Model: c.model,
Messages: []openai.ChatCompletionMessage{ Messages: []openai.ChatCompletionMessage{
@@ -264,18 +384,27 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on
}, },
Temperature: 0.2, Temperature: 0.2,
Stream: true, Stream: true,
StreamOptions: &openai.StreamOptions{
IncludeUsage: true,
},
}) })
if err != nil { if err != nil {
return fmt.Errorf("创建流式请求失败: %w", err) return nil, fmt.Errorf("创建流式请求失败: %w", err)
} }
if stream != nil { if stream != nil {
defer stream.Close() defer stream.Close()
} }
usage := &Usage{
Model: c.model,
CustomPriceInput: c.priceInput,
CustomPriceOutput: c.priceOutput,
}
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
return ctx.Err() return nil, ctx.Err()
default: default:
} }
@@ -284,8 +413,15 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on
break break
} }
if err != nil { if err != nil {
return fmt.Errorf("流式读取异常: %w", err) return nil, fmt.Errorf("流式读取异常: %w", err)
} }
// 捕获最后一个 chunk 中携带的 Usage 信息
if response.Usage.TotalTokens > 0 {
usage.InputTokens = response.Usage.PromptTokens
usage.OutputTokens = response.Usage.CompletionTokens
}
if len(response.Choices) > 0 { if len(response.Choices) > 0 {
chunk := response.Choices[0].Delta.Content chunk := response.Choices[0].Delta.Content
if chunk != "" { if chunk != "" {
@@ -294,5 +430,5 @@ func (c *Client) ReviewStream(ctx context.Context, systemPrompt, diff string, on
} }
} }
return nil return usage, nil
} }
+97
View File
@@ -0,0 +1,97 @@
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)
}
+58
View File
@@ -0,0 +1,58 @@
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)
}
}
}
+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 加载的项目规则)
+30 -6
View File
@@ -3,8 +3,9 @@ package reviewer
import ( import (
"context" "context"
"fmt" "fmt"
"github.com/unclesam-ly/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) (string, error) {
if strings.TrimSpace(diff) == "" {
return "", fmt.Errorf("diff 内容为空,无法生成 Commit Message")
}
targetLang := DetectLanguage(lang)
systemPrompt := BuildCommitMsgPrompt(targetLang)
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
} }
+35
View File
@@ -30,6 +30,41 @@ 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...)
+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.5"
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)
}
}
}