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

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

No files matched your search

+101
View File
@@ -0,0 +1,101 @@
package git
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
)
var (
ErrNotGitRepo = errors.New("当前目录不是 Git 仓库(未找到 .git)")
ErrEmptyDiff = errors.New("没有检测到任何需要评审的代码变更(Diff 为空)")
)
// IsGitRepo 检查当前工作目录是否在一个 Git 仓库内
func IsGitRepo(ctx context.Context) bool {
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--is-inside-work-tree")
err := cmd.Run()
return err == nil
}
// GetRepoRoot 获取当前 Git 仓库的根目录绝对路径
func GetRepoRoot(ctx context.Context) (string, error) {
cmd := exec.CommandContext(ctx, "git", "rev-parse", "--show-toplevel")
out, err := cmd.Output()
if err != nil {
return "", ErrNotGitRepo
}
return strings.TrimSpace(string(out)), nil
}
const maxDiffSize = 300 * 1024 // 限制最大 300KB Diff,避免打爆上下文与 OOM
// GetDiff 提取并过滤 Git 代码差异
// args 可传:
// - 无参数:默认先查暂存区 (git diff --cached),若暂存区为空则自动查工作区 (git diff)
// - 自定义参数:如 "HEAD~1"、"main..feature"、"internal/service/chat.go" 等
func GetDiff(ctx context.Context, args ...string) (string, error) {
if !IsGitRepo(ctx) {
return "", ErrNotGitRepo
}
var cmdArgs []string
if len(args) == 0 {
// 1. 默认先提取暂存区代码 (准备 commit 的代码)
stagedDiff, err := execGitDiff(ctx, "--cached")
if err != nil {
return "", err
}
if strings.TrimSpace(stagedDiff) != "" {
return truncateDiffIfNeeded(FilterDiff(stagedDiff)), nil
}
// 2. 如果暂存区为空,回退提取工作区未暂存的修改
cmdArgs = []string{}
} else {
cmdArgs = args
}
rawDiff, err := execGitDiff(ctx, cmdArgs...)
if err != nil {
return "", err
}
filteredDiff := FilterDiff(rawDiff)
if strings.TrimSpace(filteredDiff) == "" {
return "", ErrEmptyDiff
}
return truncateDiffIfNeeded(filteredDiff), nil
}
func truncateDiffIfNeeded(diff string) string {
runes := []rune(diff)
if len(runes) > maxDiffSize {
runes = runes[:maxDiffSize]
}
return string(runes) + "\n\n[WARNING: 代码变更量过大,已自动截断前 300KB 内容进行评审]"
}
// execGitDiff 执行底层的 git diff 命令
func execGitDiff(ctx context.Context, args ...string) (string, error) {
fullArgs := append([]string{"diff"}, args...)
cmd := exec.CommandContext(ctx, "git", fullArgs...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
if errors.Is(err, exec.ErrNotFound) {
return "", errors.New("系统未检测到 git 命令,请确保已安装 Git 并配置到系统 PATH 中")
}
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return "", fmt.Errorf("git diff 执行失败: %s (%w)", errMsg, err)
}
return "", fmt.Errorf("git diff 执行失败: %w", err)
}
return stdout.String(), nil
}
+97
View File
@@ -0,0 +1,97 @@
package git
import (
"path/filepath"
"regexp"
"strings"
)
// 默认需要忽略的文件名与后缀列表
var ignoredPatterns = []string{
// 依赖与锁文件
"go.sum",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
"composer.lock",
"Cargo.lock",
"Gemfile.lock",
// 自动生成的代码
"*.pb.go",
"*.gen.go",
"*_generated.go",
"*.g.dart",
// 压缩/静态构建产物
"*.min.js",
"*.min.css",
"*.map",
"*.bundle.js",
// 图片与多媒体
"*.png",
"*.jpg",
"*.jpeg",
"*.gif",
"*.svg",
"*.ico",
"*.pdf",
}
// isIgnoredFile 判断文件路径是否应该在 Code Review 中被忽略
func isIgnoredFile(filePath string) bool {
cleanPath := filepath.ToSlash(filePath)
baseName := filepath.Base(cleanPath)
for _, pattern := range ignoredPatterns {
// 精确匹配文件名(如 go.sum)
if pattern == baseName {
return true
}
// 通配符匹配(如 *.pb.go)
if matched, _ := filepath.Match(pattern, baseName); matched {
return true
}
}
return false
}
var diffHeaderRegex = regexp.MustCompile(`(?m)^diff --git a/(.*?) b/(.*?)$`)
// FilterDiff 过滤掉 diff 中无意义的锁文件和自动生成文件的区块
func FilterDiff(rawDiff string) string {
if strings.TrimSpace(rawDiff) == "" {
return ""
}
// 按照 diff --git 切分各个文件的差异区块
chunks := strings.Split(rawDiff, "diff --git ")
var filteredChunks []string
for _, chunk := range chunks {
if strings.TrimSpace(chunk) == "" {
continue
}
// 还原切分前缀
fullChunk := "diff --git " + chunk
lines := strings.Split(chunk, "\n")
if len(lines) == 0 {
continue
}
// 解析第一行中的文件路径,如:a/internal/ent/schema.go b/internal/ent/schema.go
firstLine := lines[0]
parts := strings.Fields(firstLine)
if len(parts) >= 2 {
filePath := strings.TrimPrefix(parts[1], "b/")
if isIgnoredFile(filePath) {
continue // 命中忽略规则,跳过这个文件的 diff
}
}
filteredChunks = append(filteredChunks, fullChunk)
}
return strings.Join(filteredChunks, "")
}