feat(updater): 引入 24 小时静默更新检测与一键升级功能
- 新增 `updater` 模块实现 GitHub Release 版本异步检查与本地缓存 - 新增 `git air update` 与 `git air version` 命令支持一键升级及版本检查 - 优化 CLI 终端交互,在首页渲染时提供友好的新版本升级提示卡片
This commit is contained in:
1 parent
10cbfc39a5
commit
7632652b7b
9 files changed
+398
-64
No files matched your search
@@ -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` 支持多层配置覆盖,优先级从高到低为:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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]")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user