feat: 拆分评审与提交消息提示词并增加 hook 备份与 JSON 输出

- 新增 custom_reviewer_prompt 和 custom_commit_msg_prompt,兼容旧 custom_prompt
- pre-commit hook 安装时备份已有 hook,卸载时恢复
- 支持 --format json 输出评审结果
- diff 发送前自动脱敏敏感信息
This commit is contained in:
unclesam committed 2026-08-23 22:31:30 +08:00
1 parent 39c58316a5
commit b04d70b99a
18 files changed
+637 -45

No files matched your search

+35 -12
View File
@@ -12,14 +12,17 @@ import (
// AppConfig 全局配置结构体
type AppConfig struct {
Provider string `yaml:"provider"` // gemini, deepseek, ollama, openai, custom
APIKey string `yaml:"api_key"` // API Key
BaseURL string `yaml:"base_url"` // 自定义请求地址
Model string `yaml:"model"` // 模型名称
CustomPrompt string `yaml:"custom_prompt"` // 用户自定义的 Base System Prompt
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 等)
Provider string `yaml:"provider"` // gemini, deepseek, ollama, openai, custom
APIKey string `yaml:"api_key"` // API Key
BaseURL string `yaml:"base_url"` // 自定义请求地址
Model string `yaml:"model"` // 模型名称
// CustomPrompt 为兼容旧配置保留,读取时会回退到 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) {
@@ -82,6 +85,11 @@ func LoadConfig() (*AppConfig, error) {
cfg.CommitLang = envLang
}
// 兼容旧版本:custom_prompt 等价于 reviewer 提示词。
if cfg.CustomReviewerPrompt == "" {
cfg.CustomReviewerPrompt = cfg.CustomPrompt
}
return cfg, nil
}
@@ -131,7 +139,15 @@ var configSetCmd = &cobra.Command{
cfg.BaseURL = u
}
if pr, _ := cmd.Flags().GetString("prompt"); pr != "" {
// 旧版 --prompt 继续写入旧字段,同时同步新字段,保证 v1.x 行为兼容。
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
@@ -171,11 +187,16 @@ var configGetCmd = &cobra.Command{
maskedKey = cfg.APIKey[:4] + "..." + cfg.APIKey[len(cfg.APIKey)-4:]
}
fmt.Printf("APIKey: %s\n", maskedKey)
hasPrompt := "否 (使用默认内置架构师 Prompt)"
if cfg.CustomPrompt != "" {
hasPrompt = "是 (已自定义)"
reviewerPrompt := "否 (使用默认内置架构师 Prompt)"
if cfg.CustomReviewerPrompt != "" {
reviewerPrompt = "是 (已自定义)"
}
fmt.Printf("CustomPrompt: %s\n", hasPrompt)
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
@@ -195,6 +216,8 @@ func init() {
configSetCmd.Flags().StringP("model", "m", "", "模型名称")
configSetCmd.Flags().StringP("base-url", "u", "", "自定义 API 地址")
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 等)")
+144 -13
View File
@@ -1,19 +1,142 @@
package cmd
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/unclesam-ly/git-air/internal/git"
"github.com/unclesam-ly/git-air/internal/ui"
"github.com/spf13/cobra"
)
const preCommitScript = `#!/bin/sh
# git-air pre-commit hook
const (
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 前代码自动化评审..."
git-air --staged
`
"%s" --staged
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{
Use: "hook",
@@ -22,25 +145,30 @@ var hookCmd = &cobra.Command{
var hookInstallCmd = &cobra.Command{
Use: "install",
Short: "在当前仓库一键安装 pre-commit 钩子",
Short: "在当前仓库安装 pre-commit 钩子并保留已有 Hook",
Run: func(cmd *cobra.Command, args []string) {
repoRoot, err := git.GetRepoRoot(cmd.Context())
if err != nil {
ui.PrintError("请在 Git 仓库根目录下执行此命令")
return
}
hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit")
if err := os.WriteFile(hookPath, []byte(preCommitScript), 0755); err != nil {
ui.PrintError("写入 hook 文件失败: %v", err)
hooksDir := filepath.Join(repoRoot, ".git", "hooks")
if err := os.MkdirAll(hooksDir, 0755); err != nil {
ui.PrintError("创建 hooks 目录失败: %v", err)
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{
Use: "uninstall",
Short: "移除当前仓库的 pre-commit 钩子",
Short: "移除 git-air Hook 并恢复已有 Hook",
Run: func(cmd *cobra.Command, args []string) {
repoRoot, err := git.GetRepoRoot(cmd.Context())
if err != nil {
@@ -48,8 +176,11 @@ var hookUninstallCmd = &cobra.Command{
return
}
hookPath := filepath.Join(repoRoot, ".git", "hooks", "pre-commit")
_ = os.Remove(hookPath)
ui.PrintSuccess("已成功卸载 pre-commit 钩子!")
if err := uninstallPreCommitHook(hookPath); err != nil {
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)
}
}
+1 -1
View File
@@ -87,7 +87,7 @@ var msgCmd = &cobra.Command{
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)
commitMsg, err := rev.GenerateCommitMsg(ctx, diff, lang, cfg.CustomCommitMsgPrompt)
if err != nil {
ui.PrintError("生成 Commit Message 失败: %v", err)
return
+50 -7
View File
@@ -2,6 +2,7 @@ package cmd
import (
"context"
"encoding/json"
"os"
"strings"
@@ -21,6 +22,11 @@ var rootCmd = &cobra.Command{
Args: cobra.ArbitraryArgs,
Run: func(cmd *cobra.Command, args []string) {
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()
@@ -76,17 +82,21 @@ var rootCmd = &cobra.Command{
// 5. 初始化评审引擎
customPrompt, _ := cmd.Flags().GetString("prompt")
if customPrompt == "" {
customPrompt = cfg.CustomPrompt
customPrompt = cfg.CustomReviewerPrompt
}
rev := reviewer.NewReviewer(llmClient, repoRoot, customPrompt)
// 6. 打印开场横幅并开始流式评审
ui.PrintBanner(cfg.Provider, cfg.Model)
if outputFormat == "text" {
ui.PrintBanner(cfg.Provider, cfg.Model)
}
printer := ui.NewStreamPrinter()
var reviewOutput strings.Builder
_, usage, err := rev.Execute(ctx, diff, true, func(chunk string) {
reviewOutput.WriteString(chunk)
printer.PrintChunk(chunk)
if outputFormat == "text" {
printer.PrintChunk(chunk)
}
})
if err != nil {
ui.PrintError("\n评审过程中发生错误: %v", err)
@@ -94,16 +104,48 @@ var rootCmd = &cobra.Command{
}
// 7. 打印 Token 消耗统计
if usage != nil {
if outputFormat == "text" && usage != nil {
ui.PrintUsage(usage.InputTokens, usage.OutputTokens, usage.EstimateCost())
}
ui.PrintFooter()
if outputFormat == "text" {
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(),
}
}
// 8. 检查新版本提醒(非阻塞)
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 {
if info != nil && outputFormat == "text" {
ui.PrintUpdateBanner(info.CurrentVersion, info.LatestVersion, info.ReleaseURL)
}
default:
@@ -153,4 +195,5 @@ func init() {
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")
}