feat(updater): 引入 24 小时静默更新检测与一键升级功能

- 新增 `updater` 模块实现 GitHub Release 版本异步检查与本地缓存
- 新增 `git air update` 与 `git air version` 命令支持一键升级及版本检查
- 优化 CLI 终端交互,在首页渲染时提供友好的新版本升级提示卡片
This commit is contained in:
unclesam committed 2026-08-22 17:07:02 +08:00
1 parent 10cbfc39a5
commit 7632652b7b
9 files changed
+398 -64

No files matched your search

+14
View File
@@ -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` 支持多层配置覆盖,优先级从高到低为: `git-air` 支持多层配置覆盖,优先级从高到低为:
+14
View File
@@ -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 ## ⚙️ Configuration Hierarchy
`git-air` searches for configurations in the following priority order: `git-air` searches for configurations in the following priority order:
+67 -63
View File
@@ -65,80 +65,46 @@ $ git air
ユーザー認証ハンドラーに Redis キャッシュ層を追加し、Context のタイムアウト伝播を修正。 ユーザー認証ハンドラーに Redis キャッシュ層を追加し、Context のタイムアウト伝播を修正。
#### 詳細レビュー結果 #### 詳細レビュー結果
- [BLOCKER] internal/service/user.go:45 - 驥榊、ァ縺ェ繧サ繧ュ繝・繝ェ繝�ぅ�# 迚ケ螳壹�繝輔ぃ繧、繝ォ縺ョ縺ソ繧偵Ξ繝薙Η繝シ - [BLOCKER] internal/service/user.go:45 - 重大なセキュリティ脆弱性: ループ内で生SQL文字列を直接結合しています。SQLインジェクションのリスクがあります。
git air internal/service/chat.go // 推奨修正コード:
db.Where("username = ?", inputName).First(&user)
# 荳€譎ら噪縺ォ繝「繝�Ν繧Пrovider繧呈欠螳壹@縺ヲ螳溯。� - [WARNING] internal/service/user.go:82 - 潜在的リスク: Redis 接続エラーを握りつぶしています。キャッシュ障害時にDBへ負荷が集中します。
git air --provider deepseek --model deepseek-chat
- [WARNING] internal/service/user.go:103 - 並行処理の欠陥: 早期リターン時に Mutex の Unlock を忘れています(Mutex Leak)。デッドロックの危険性があります。
#### 結論
- 判定: [REJECT]
- スコア: 60 / 100
📊 Token: 入力 1,243 / 出力 412 | ≈ $0.0025
─────────────────────────────────────────────────────────────────
``` ```
--- ---
### 3. AI 繧ウ繝溘ャ繝医Γ繝�そ繝シ繧ク閾ェ蜍慕函謌� (`git air msg`) ## 📦 インストール
繧ウ繝溘ャ繝医Γ繝�そ繝シ繧ク縺ョ菴懈�縺ォ謔ゥ繧€蠢�ヲ√�繧ゅ≧縺ゅj縺セ縺帙s縲Agit-air` 縺後せ繝��繧ク繝ウ繧ー縺輔l縺溷キョ蛻�r蛻�梵縺励€,onventional Commits 貅匁侠縺ョ繝。繝�そ繝シ繧ク繧�**螟夊ィ€隱櫁�蜍暮←蠢�**縺ァ逕滓�縺励∪縺呻シ�
### 方法 1: Go Install(推奨)
```bash ```bash
# 1. 繧キ繧ケ繝�Β險€隱槭r閾ェ蜍墓、懷�縺励※逕滓� & 蟇セ隧ア蝙九さ繝溘ャ繝� go install github.com/unclesam-ly/git-air@latest
git air msg ```
# 2. 闍ア隱槭〒逕滓��医が繝シ繝励Φ繧ス繝シ繧ケ繝サ蝗ス髫帙�繝ュ繧ク繧ァ繧ッ繝亥髄縺托シ� ### 方法 2: ソースコードからビルド
git air msg -l en ```bash
git clone https://github.com/unclesam-ly/git-air.git
# 3. 譌・譛ャ隱� / 髻灘嵜隱槭〒逕滓� cd git-air
git air msg -l ja go build -o git-air .
git air msg -l ko sudo mv git-air /usr/local/bin/
# 4. 遒コ隱阪↑縺励〒逶エ謗・ git commit 繧貞ョ溯。�
git air msg -c
``` ```
--- ---
### 4. 繝ッ繝ウ繧ソ繝�メ縺ァ Pre-commit 繝輔ャ繧ッ繧堤匳骭イ ## 🚀 クイックスタート
Git 繝ェ繝昴ず繝医Μ逶エ荳九〒莉・荳九r螳溯。鯉シ� ### 1. モデルと API Key の設定
```bash
git air hook install
```
- **繧ウ繝溘ャ繝郁�蜍輔ヶ繝ュ繝�け讖溯�**�壹Ξ繝薙Η繝シ縺ァ `[BLOCKER]` 驥榊、ァ谺�髯・繧� `[REJECT]` 蛻、螳壹′讀懷�縺輔l縺溷�エ蜷医€~git-air` 縺ッ髱槭ぞ繝ュ縺ョ邨ゆコ�さ繝シ繝峨〒**繧ウ繝溘ャ繝茨シ�git commit`�峨r閾ェ蜍慕噪縺ォ荳ュ譁ュ繝サ繝悶Ο繝�け**縺励∪縺呻シ�
- **蜴ウ譬シ繝「繝シ繝� (`--strict`)**�啻[WARNING]` 隴ヲ蜻翫〒繧ゅさ繝溘ャ繝医r繝悶Ο繝�け縺励◆縺��エ蜷医� `git air --strict` 繧剃スソ逕ィ��
- **荳€譎ら噪縺ェ繝悶Ο繝�け隗」髯、**�夂キ頑€・縺ァ繧ウ繝溘ャ繝医r蠑キ蛻カ縺励◆縺��エ蜷医�縲;it 讓呎コ悶� `git commit --no-verify` 縺セ縺溘� `git air --no-block` 繧剃サ倅ク弱@縺セ縺吶€�
繝輔ャ繧ッ繧定ァ」髯、縺吶k蝣エ蜷茨シ� `git-air` は国内外の主要LLMのエンドポイントと推奨モデルを標準プリセットしています。`--provider` を指定するだけで簡単に切り替えられます:
```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縺ョ繧ィ繝ウ繝峨�繧、繝ウ繝医→謗ィ螂ィ繝「繝�Ν繧呈ィ呎コ悶�繝ェ繧サ繝�ヨ縺励※縺�∪縺吶€A--provider` 繧呈欠螳壹☆繧九□縺代〒邁。蜊倥↓蛻�j譖ソ縺医i繧後∪縺呻シ�
```bash ```bash
# 1. Google Gemini (推奨: 高速・低コスト) # 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 リポジトリ直下で以下を実行: Git リポジトリ直下で以下を実行:
```bash ```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`) 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`)
@@ -249,6 +250,9 @@ model: "gemini-3.7-flash"
# カスタム Token 料金設定 (任意、単位: 米ドル / 1M Tokens) # カスタム Token 料金設定 (任意、単位: 米ドル / 1M Tokens)
# price_input: 0.75 # price_input: 0.75
# price_output: 3.75 # price_output: 3.75
# AI コミットメッセージ言語設定 (任意: auto, zh, en, ja, ko)
# commit_lang: "auto"
``` ```
--- ---
+14 -1
View File
@@ -10,6 +10,7 @@ import (
"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/unclesam-ly/git-air/internal/updater"
) )
var rootCmd = &cobra.Command{ var rootCmd = &cobra.Command{
@@ -21,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...)
@@ -96,7 +100,16 @@ var rootCmd = &cobra.Command{
ui.PrintFooter() 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() outText := reviewOutput.String()
hasBlocker := strings.Contains(outText, "[BLOCKER]") || strings.Contains(outText, "[CRITICAL]") hasBlocker := strings.Contains(outText, "[BLOCKER]") || strings.Contains(outText, "[CRITICAL]")
hasReject := strings.Contains(outText, "[REJECT]") hasReject := strings.Contains(outText, "[REJECT]")
+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)
}
+13
View File
@@ -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 打印错误信息 // PrintError 打印错误信息
func PrintError(format string, a ...interface{}) { func PrintError(format string, a ...interface{}) {
msg := fmt.Sprintf(format, a...) msg := fmt.Sprintf(format, a...)
+173
View File
@@ -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)
}
+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)
}
}
}