From 7632652b7be80a616c0aecd1c185eea7330819dd Mon Sep 17 00:00:00 2001 From: unclesam <2036253946@qq.com> Date: Sat, 22 Aug 2026 17:07:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(updater):=20=E5=BC=95=E5=85=A5=2024=20?= =?UTF-8?q?=E5=B0=8F=E6=97=B6=E9=9D=99=E9=BB=98=E6=9B=B4=E6=96=B0=E6=A3=80?= =?UTF-8?q?=E6=B5=8B=E4=B8=8E=E4=B8=80=E9=94=AE=E5=8D=87=E7=BA=A7=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `updater` 模块实现 GitHub Release 版本异步检查与本地缓存 - 新增 `git air update` 与 `git air version` 命令支持一键升级及版本检查 - 优化 CLI 终端交互,在首页渲染时提供友好的新版本升级提示卡片 --- README.md | 14 +++ README_EN.md | 14 +++ README_JA.md | 130 ++++++++++++----------- cmd/root.go | 15 ++- cmd/update.go | 35 +++++++ cmd/version.go | 38 +++++++ internal/ui/render.go | 13 +++ internal/updater/checker.go | 173 +++++++++++++++++++++++++++++++ internal/updater/checker_test.go | 30 ++++++ 9 files changed, 398 insertions(+), 64 deletions(-) create mode 100644 cmd/update.go create mode 100644 cmd/version.go create mode 100644 internal/updater/checker.go create mode 100644 internal/updater/checker_test.go diff --git a/README.md b/README.md index fc4c053..19fc6e9 100644 --- a/README.md +++ b/README.md @@ -214,6 +214,20 @@ git air hook uninstall --- +### 5. 版本查看与一键自动升级 (`git air update`) + +`git-air` 内置了**轻量级 24 小时静默更新检测**,当 GitHub 发布新版本时会在终端底部自动提醒;你也可以随时手动检查与一键升级: + +```bash +# 查看当前版本并在线检查更新 +git air version + +# 一键自动拉取并升级到最新版本 +git air update +``` + +--- + ## ⚙️ 配置文件说明 `git-air` 支持多层配置覆盖,优先级从高到低为: diff --git a/README_EN.md b/README_EN.md index 4425e3d..7bd17ae 100644 --- a/README_EN.md +++ b/README_EN.md @@ -211,6 +211,20 @@ git air hook uninstall --- +### 5. Version Check & One-Click Upgrade (`git air update`) + +`git-air` comes with **lightweight 24-hour background update detection** and alerts you in the terminal whenever a new release is available. You can also manually check or upgrade anytime: + +```bash +# Check current version and check for updates +git air version + +# One-click upgrade to the latest release +git air update +``` + +--- + ## ⚙️ Configuration Hierarchy `git-air` searches for configurations in the following priority order: diff --git a/README_JA.md b/README_JA.md index 092d2ff..6cc4f0a 100644 --- a/README_JA.md +++ b/README_JA.md @@ -65,80 +65,46 @@ $ git air ユーザー認証ハンドラーに Redis キャッシュ層を追加し、Context のタイムアウト伝播を修正。 #### 詳細レビュー結果 -- [BLOCKER] internal/service/user.go:45 - 重大なセキュリティè# 特定のファイルのみをレビュー -git air internal/service/chat.go +- [BLOCKER] internal/service/user.go:45 - 重大なセキュリティ脆弱性: ループ内で生SQL文字列を直接結合しています。SQLインジェクションのリスクがあります。 + // 推奨修正コード: + db.Where("username = ?", inputName).First(&user) -# 一時的にモデルやProviderを指定して実行 -git air --provider deepseek --model deepseek-chat +- [WARNING] internal/service/user.go:82 - 潜在的リスク: Redis 接続エラーを握りつぶしています。キャッシュ障害時にDBへ負荷が集中します。 + +- [WARNING] internal/service/user.go:103 - 並行処理の欠陥: 早期リターン時に Mutex の Unlock を忘れています(Mutex Leak)。デッドロックの危険性があります。 + +#### 結論 +- 判定: [REJECT] +- スコア: 60 / 100 + +📊 Token: 入力 1,243 / 出力 412 | ≈ $0.0025 +───────────────────────────────────────────────────────────────── ``` --- -### 3. AI コミットメッセージ自動生成 (`git air msg`) - -コミットメッセージの作成に悩む必要はもうありません。`git-air` がステージングされた差分を分析し、Conventional Commits 準拠のメッセージを**多言語自動適応**で生成します: +## 📦 インストール +### 方法 1: Go Install(推奨) ```bash -# 1. システム言語を自動検出して生成 & 対話型コミット -git air msg +go install github.com/unclesam-ly/git-air@latest +``` -# 2. 英語で生成(オープンソース・国際プロジェクト向け) -git air msg -l en - -# 3. 日本語 / 韓国語で生成 -git air msg -l ja -git air msg -l ko - -# 4. 確認なしで直接 git commit を実行 -git air msg -c +### 方法 2: ソースコードからビルド +```bash +git clone https://github.com/unclesam-ly/git-air.git +cd git-air +go build -o git-air . +sudo mv git-air /usr/local/bin/ ``` --- -### 4. ワンタッチで Pre-commit フックを登録 +## 🚀 クイックスタート -Git リポジトリ直下で以下を実行: -```bash -git air hook install -``` -- **コミット自動ブロック機能**:レビューで `[BLOCKER]` 重大欠陥や `[REJECT]` 判定が検出された場合、`git-air` は非ゼロの終了コードで**コミット(`git commit`)を自動的に中断・ブロック**します! -- **厳格モード (`--strict`)**:`[WARNING]` 警告でもコミットをブロックしたい場合は `git air --strict` を使用; -- **一時的なブロック解除**:緊急でコミットを強制したい場合は、Git 標準の `git commit --no-verify` または `git air --no-block` を付与します。 +### 1. モデルと API Key の設定 -フックを解除する場合: -```bash -git air hook uninstall -``` - ---- - -## ⚙️ 設定の優先順位 - -1. **コマンドライン引数** (`--key`, `--model`, `--provider`, `--prompt`, `--lang`, `--price-input`, `--price-output`, `--strict`, `--no-block`) -2. **環境変数** (`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`, `GIT_AIR_COMMIT_LANG`) -3. **プロジェクト個別設定** (リポジトリ直下の `./config.yaml` または `./.git-air.yaml`) -4. **グローバル設定** (`~/.git-air/config.yaml`) - -### `config.yaml` 設定ファイル例: -```yaml -# プロバイダー: gemini, claude, grok, deepseek, qwen, zhipu, moonshot, siliconflow, ollama, openai, custom -provider: "gemini" -api_key: "YOUR_API_KEY_HERE" -model: "gemini-3.7-flash" - -# カスタム API エンドポイント (任意) -# base_url: "https://api.deepseek.com/v1" - -# カスタム System Prompt (任意) -# custom_prompt: "" - -# カスタム Token 料金設定 (任意、単位: 米ドル / 1M Tokens) -# price_input: 0.75 -# price_output: 3.75 - -# AI コミットメッセージ言語設定 (任意: auto, zh, en, ja, ko) -# commit_lang: "auto" -```LLMのエンドポイントと推奨モデルを標準プリセットしています。`--provider` を指定するだけで簡単に切り替えられます: +`git-air` は国内外の主要LLMのエンドポイントと推奨モデルを標準プリセットしています。`--provider` を指定するだけで簡単に切り替えられます: ```bash # 1. Google Gemini (推奨: 高速・低コスト) @@ -209,7 +175,28 @@ 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 air msg -l en + +# 3. 日本語 / 韓国語で生成 +git air msg -l ja +git air msg -l ko + +# 4. 確認なしで直接 git commit を実行 +git air msg -c +``` + +--- + +### 4. ワンタッチで Pre-commit フックを登録 Git リポジトリ直下で以下を実行: ```bash @@ -226,10 +213,24 @@ git air hook uninstall --- +### 5. バージョン確認とワンクリック自動更新 (`git air update`) + +`git-air` には**軽量な24時間サイレント更新検出機能**が組み込まれており、新しいリリースが公開されるとターミナル下部で自動通知されます。手動での確認やアップデートも可能です: + +```bash +# 現在のバージョンを確認し、更新をチェック +git air version + +# 最新バージョンへ一括アップデート +git air update +``` + +--- + ## ⚙️ 設定の優先順位 -1. **コマンドライン引数** (`--key`, `--model`, `--provider`, `--prompt`, `--price-input`, `--price-output`, `--strict`, `--no-block`) -2. **環境変数** (`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`) +1. **コマンドライン引数** (`--key`, `--model`, `--provider`, `--prompt`, `--lang`, `--price-input`, `--price-output`, `--strict`, `--no-block`) +2. **環境変数** (`GIT_AIR_API_KEY`, `GIT_AIR_PROVIDER`, `GIT_AIR_MODEL`, `GIT_AIR_COMMIT_LANG`) 3. **プロジェクト個別設定** (リポジトリ直下の `./config.yaml` または `./.git-air.yaml`) 4. **グローバル設定** (`~/.git-air/config.yaml`) @@ -249,6 +250,9 @@ model: "gemini-3.7-flash" # カスタム Token 料金設定 (任意、単位: 米ドル / 1M Tokens) # price_input: 0.75 # price_output: 3.75 + +# AI コミットメッセージ言語設定 (任意: auto, zh, en, ja, ko) +# commit_lang: "auto" ``` --- diff --git a/cmd/root.go b/cmd/root.go index d07da68..241667b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,6 +10,7 @@ import ( "github.com/unclesam-ly/git-air/internal/llm" "github.com/unclesam-ly/git-air/internal/reviewer" "github.com/unclesam-ly/git-air/internal/ui" + "github.com/unclesam-ly/git-air/internal/updater" ) var rootCmd = &cobra.Command{ @@ -21,6 +22,9 @@ var rootCmd = &cobra.Command{ Run: func(cmd *cobra.Command, args []string) { ctx := cmd.Context() + // 后台非阻塞异步检查新版本 + updateCh := updater.CheckAsync() + // 1. 检查并获取 Diff if flagCached, _ := cmd.Flags().GetBool("cached"); flagCached { args = append([]string{"--cached"}, args...) @@ -96,7 +100,16 @@ var rootCmd = &cobra.Command{ ui.PrintFooter() - // 8. 门禁阻断检测 (Pre-commit 拦截逻辑) + // 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]") diff --git a/cmd/update.go b/cmd/update.go new file mode 100644 index 0000000..086551f --- /dev/null +++ b/cmd/update.go @@ -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) +} diff --git a/cmd/version.go b/cmd/version.go new file mode 100644 index 0000000..bff72b3 --- /dev/null +++ b/cmd/version.go @@ -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) +} diff --git a/internal/ui/render.go b/internal/ui/render.go index 0a07249..6bba2bb 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -52,6 +52,19 @@ func PrintUsage(inputTokens, outputTokens int, costUSD float64) { ) } +// 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 打印错误信息 func PrintError(format string, a ...interface{}) { msg := fmt.Sprintf(format, a...) diff --git a/internal/updater/checker.go b/internal/updater/checker.go new file mode 100644 index 0000000..25118d0 --- /dev/null +++ b/internal/updater/checker.go @@ -0,0 +1,173 @@ +package updater + +import ( + "context" + "encoding/json" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// CurrentVersion 当前编译版本号 +const CurrentVersion = "v1.0.4" + +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 || resp.StatusCode != http.StatusOK { + return nil + } + defer resp.Body.Close() + + 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) +} diff --git a/internal/updater/checker_test.go b/internal/updater/checker_test.go new file mode 100644 index 0000000..28d8ff4 --- /dev/null +++ b/internal/updater/checker_test.go @@ -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) + } + } +}