- 新增 custom_reviewer_prompt 和 custom_commit_msg_prompt,兼容旧 custom_prompt - pre-commit hook 安装时备份已有 hook,卸载时恢复 - 支持 --format json 输出评审结果 - diff 发送前自动脱敏敏感信息
191 lines
5.5 KiB
Go
191 lines
5.5 KiB
Go
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"
|
|
)
|
|
|
|
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 前代码自动化评审..."
|
|
"%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",
|
|
Short: "管理 Git Pre-commit 自动审查钩子",
|
|
}
|
|
|
|
var hookInstallCmd = &cobra.Command{
|
|
Use: "install",
|
|
Short: "在当前仓库安装 pre-commit 钩子并保留已有 Hook",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
repoRoot, err := git.GetRepoRoot(cmd.Context())
|
|
if err != nil {
|
|
ui.PrintError("请在 Git 仓库根目录下执行此命令")
|
|
return
|
|
}
|
|
hooksDir := filepath.Join(repoRoot, ".git", "hooks")
|
|
if err := os.MkdirAll(hooksDir, 0755); err != nil {
|
|
ui.PrintError("创建 hooks 目录失败: %v", err)
|
|
return
|
|
}
|
|
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: "移除 git-air Hook 并恢复已有 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 := uninstallPreCommitHook(hookPath); err != nil {
|
|
ui.PrintError("卸载 Hook 失败: %v", err)
|
|
return
|
|
}
|
|
ui.PrintSuccess("已卸载 git-air Hook;如原来存在 Hook,已恢复原文件。")
|
|
},
|
|
}
|
|
|
|
func init() {
|
|
hookCmd.AddCommand(hookInstallCmd)
|
|
hookCmd.AddCommand(hookUninstallCmd)
|
|
}
|