@zjex/git-workflow 0.5.2 → 0.5.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [v0.5.2](https://github.com/iamzjt-front-end/git-workflow/compare/v0.5.1...v0.5.2) (2026-02-06)
4
+
5
+ - 🔖 chore(release): 发布 v0.5.2 ([d2b659c](https://github.com/iamzjt-front-end/git-workflow/commit/d2b659c))
6
+ - refactor(update-notifier): Simplify version checking logic ([165e21f](https://github.com/iamzjt-front-end/git-workflow/commit/165e21f))
7
+
3
8
  ## [v0.5.1](https://github.com/iamzjt-front-end/git-workflow/compare/v0.5.0...v0.5.1) (2026-02-06)
4
9
 
5
10
  - 🔖 chore(release): 发布 v0.5.1 ([88c2089](https://github.com/iamzjt-front-end/git-workflow/commit/88c2089))
package/README.md CHANGED
@@ -12,7 +12,7 @@
12
12
  <a href="https://github.com/iamzjt-front-end/git-workflow"><img src="https://img.shields.io/github/stars/iamzjt-front-end/git-workflow?style=flat&colorA=18181B&colorB=F59E0B" alt="github stars"></a>
13
13
  <a href="https://github.com/iamzjt-front-end/git-workflow/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/@zjex/git-workflow?style=flat&colorA=18181B&colorB=10B981" alt="license"></a>
14
14
  <a href="https://nodejs.org"><img src="https://img.shields.io/badge/node-%3E%3D18-339933?style=flat&logo=node.js&logoColor=white&colorA=18181B" alt="node version"></a>
15
- <a href="https://github.com/iamzjt-front-end/git-workflow/actions"><img src="https://img.shields.io/badge/tests-573%20passed-success?style=flat&colorA=18181B" alt="tests"></a>
15
+ <a href="https://github.com/iamzjt-front-end/git-workflow/actions"><img src="https://img.shields.io/badge/tests-570%20passed-success?style=flat&colorA=18181B" alt="tests"></a>
16
16
  <a href="https://github.com/iamzjt-front-end/git-workflow/issues"><img src="https://img.shields.io/github/issues/iamzjt-front-end/git-workflow?style=flat&colorA=18181B&colorB=EC4899" alt="issues"></a>
17
17
  </p>
18
18
 
package/dist/index.js CHANGED
@@ -175,18 +175,19 @@ __export(update_notifier_exports, {
175
175
  checkForUpdates: () => checkForUpdates,
176
176
  clearUpdateCache: () => clearUpdateCache
177
177
  });
178
- import { execSync as execSync3 } from "child_process";
178
+ import { execSync as execSync3, spawn as spawn3 } from "child_process";
179
179
  import { readFileSync as readFileSync3, writeFileSync as writeFileSync4, existsSync as existsSync3, unlinkSync as unlinkSync2 } from "fs";
180
180
  import { homedir as homedir3 } from "os";
181
181
  import { join as join4 } from "path";
182
182
  import boxen2 from "boxen";
183
183
  import { select as select7 } from "@inquirer/prompts";
184
184
  import ora5 from "ora";
185
+ import semver from "semver";
185
186
  async function checkForUpdates(currentVersion, packageName = "@zjex/git-workflow", interactive = false) {
186
187
  try {
187
188
  const cache = readCache();
188
189
  const now = Date.now();
189
- if (cache?.latestVersion && cache.latestVersion !== currentVersion) {
190
+ if (cache?.latestVersion && semver.gt(cache.latestVersion, currentVersion)) {
190
191
  const isDismissed = cache.lastDismiss && now - cache.lastDismiss < DISMISS_INTERVAL;
191
192
  if (!isDismissed) {
192
193
  if (interactive) {
@@ -205,29 +206,45 @@ async function checkForUpdates(currentVersion, packageName = "@zjex/git-workflow
205
206
  }
206
207
  }
207
208
  }
208
- backgroundCheck(currentVersion, packageName);
209
+ spawnBackgroundCheck(packageName);
209
210
  } catch (error) {
210
211
  if (error?.constructor?.name === "ExitPromptError") {
211
212
  throw error;
212
213
  }
213
214
  }
214
215
  }
215
- function backgroundCheck(currentVersion, packageName) {
216
- setImmediate(async () => {
217
- try {
218
- const latestVersion = await getLatestVersion(packageName);
219
- if (latestVersion) {
220
- const cache = readCache() || {};
221
- writeCache({
222
- ...cache,
223
- lastCheck: Date.now(),
224
- latestVersion,
225
- checkedVersion: currentVersion
226
- });
227
- }
228
- } catch {
229
- }
230
- });
216
+ function spawnBackgroundCheck(packageName) {
217
+ try {
218
+ const cacheFile = join4(homedir3(), CACHE_FILE);
219
+ const script = `
220
+ const { execSync } = require('child_process');
221
+ const { writeFileSync, readFileSync, existsSync } = require('fs');
222
+ try {
223
+ const version = execSync('npm view ${packageName} version', {
224
+ encoding: 'utf-8',
225
+ timeout: 10000,
226
+ stdio: ['pipe', 'pipe', 'ignore']
227
+ }).trim();
228
+ if (version) {
229
+ let cache = {};
230
+ try {
231
+ if (existsSync('${cacheFile}')) {
232
+ cache = JSON.parse(readFileSync('${cacheFile}', 'utf-8'));
233
+ }
234
+ } catch {}
235
+ cache.lastCheck = Date.now();
236
+ cache.latestVersion = version;
237
+ writeFileSync('${cacheFile}', JSON.stringify(cache), 'utf-8');
238
+ }
239
+ } catch {}
240
+ `;
241
+ const child = spawn3("node", ["-e", script], {
242
+ detached: true,
243
+ stdio: "ignore"
244
+ });
245
+ child.unref();
246
+ } catch {
247
+ }
231
248
  }
232
249
  function isUsingVolta() {
233
250
  try {
@@ -237,19 +254,6 @@ function isUsingVolta() {
237
254
  return false;
238
255
  }
239
256
  }
240
- async function getLatestVersion(packageName) {
241
- try {
242
- const result = execSync3(`npm view ${packageName} version`, {
243
- encoding: "utf-8",
244
- timeout: 3e3,
245
- stdio: ["pipe", "pipe", "ignore"]
246
- // 忽略 stderr
247
- });
248
- return result.trim();
249
- } catch {
250
- return null;
251
- }
252
- }
253
257
  function showSimpleNotification(current, latest) {
254
258
  const message = `${colors.yellow("\u{1F389} \u53D1\u73B0\u65B0\u7248\u672C")} ${colors.dim(
255
259
  current
@@ -2808,10 +2812,10 @@ init_update_notifier();
2808
2812
 
2809
2813
  // src/commands/update.ts
2810
2814
  init_utils();
2811
- import { execSync as execSync4, spawn as spawn3 } from "child_process";
2815
+ import { execSync as execSync4, spawn as spawn4 } from "child_process";
2812
2816
  import ora6 from "ora";
2813
2817
  import boxen3 from "boxen";
2814
- import semver from "semver";
2818
+ import semver2 from "semver";
2815
2819
  import { existsSync as existsSync4, unlinkSync as unlinkSync3 } from "fs";
2816
2820
  import { homedir as homedir4 } from "os";
2817
2821
  import { join as join5 } from "path";
@@ -2825,9 +2829,9 @@ function clearUpdateCache2() {
2825
2829
  } catch {
2826
2830
  }
2827
2831
  }
2828
- async function getLatestVersion2(packageName) {
2832
+ async function getLatestVersion(packageName) {
2829
2833
  return new Promise((resolve) => {
2830
- const npmView = spawn3("npm", ["view", packageName, "version"], {
2834
+ const npmView = spawn4("npm", ["view", packageName, "version"], {
2831
2835
  stdio: ["ignore", "pipe", "ignore"],
2832
2836
  timeout: 5e3
2833
2837
  });
@@ -2865,14 +2869,14 @@ async function update(currentVersion) {
2865
2869
  console.log("");
2866
2870
  const spinner = ora6("\u6B63\u5728\u83B7\u53D6\u6700\u65B0\u7248\u672C\u4FE1\u606F...").start();
2867
2871
  try {
2868
- const latestVersion = await getLatestVersion2(packageName);
2872
+ const latestVersion = await getLatestVersion(packageName);
2869
2873
  if (!latestVersion) {
2870
2874
  spinner.fail("\u65E0\u6CD5\u83B7\u53D6\u6700\u65B0\u7248\u672C\u4FE1\u606F");
2871
2875
  console.log(colors.dim(" \u8BF7\u68C0\u67E5\u7F51\u7EDC\u8FDE\u63A5\u540E\u91CD\u8BD5"));
2872
2876
  return;
2873
2877
  }
2874
2878
  spinner.stop();
2875
- if (semver.gte(currentVersion, latestVersion)) {
2879
+ if (semver2.gte(currentVersion, latestVersion)) {
2876
2880
  console.log(
2877
2881
  boxen3(
2878
2882
  [
@@ -2916,7 +2920,7 @@ async function update(currentVersion) {
2916
2920
  console.log("");
2917
2921
  const updateCommand = usingVolta ? `volta install ${packageName}@latest` : `npm install -g ${packageName}@latest`;
2918
2922
  const [command, ...args] = updateCommand.split(" ");
2919
- const updateProcess = spawn3(command, args, {
2923
+ const updateProcess = spawn4(command, args, {
2920
2924
  stdio: "inherit"
2921
2925
  // 继承父进程的 stdio,显示实时输出
2922
2926
  });
@@ -2987,7 +2991,7 @@ async function update(currentVersion) {
2987
2991
  init_utils();
2988
2992
  import { execSync as execSync5 } from "child_process";
2989
2993
  import boxen4 from "boxen";
2990
- import { spawn as spawn4 } from "child_process";
2994
+ import { spawn as spawn5 } from "child_process";
2991
2995
  function parseGitLog(output) {
2992
2996
  const commits = [];
2993
2997
  const lines = output.trim().split("\n");
@@ -3205,7 +3209,7 @@ function formatTimelineStyle(commits) {
3205
3209
  function startInteractivePager(content) {
3206
3210
  const pager = process.env.PAGER || "less";
3207
3211
  try {
3208
- const pagerProcess = spawn4(pager, ["-R", "-S", "-F", "-X", "-i"], {
3212
+ const pagerProcess = spawn5(pager, ["-R", "-S", "-F", "-X", "-i"], {
3209
3213
  stdio: ["pipe", "inherit", "inherit"],
3210
3214
  env: { ...process.env, LESS: "-R -S -F -X -i" }
3211
3215
  });
@@ -3621,16 +3625,19 @@ var AI_PROVIDERS2 = {
3621
3625
  defaultModel: "qwen2.5-coder:14b"
3622
3626
  }
3623
3627
  };
3628
+ function parseCommitLine(line) {
3629
+ const parts = line.split("|");
3630
+ if (parts.length < 5) return null;
3631
+ const [hash, shortHash, subject, author, date] = parts;
3632
+ return { hash, shortHash, subject, author, date };
3633
+ }
3624
3634
  function getRecentCommits3(limit = 20) {
3625
3635
  try {
3626
3636
  const output = execOutput(
3627
3637
  `git log -${limit} --pretty=format:"%H|%h|%s|%an|%ad" --date=short`
3628
3638
  );
3629
3639
  if (!output) return [];
3630
- return output.split("\n").filter(Boolean).map((line) => {
3631
- const [hash, shortHash, subject, author, date] = line.split("|");
3632
- return { hash, shortHash, subject, author, date };
3633
- });
3640
+ return output.split("\n").filter(Boolean).map((line) => parseCommitLine(line)).filter((c) => c !== null);
3634
3641
  } catch {
3635
3642
  return [];
3636
3643
  }
@@ -4070,25 +4077,49 @@ async function review(hashes, options = {}) {
4070
4077
  let diff = "";
4071
4078
  let commits = [];
4072
4079
  if (hashes && hashes.length > 0) {
4073
- commits = hashes.map((hash) => {
4074
- const info = execOutput(
4075
- `git log -1 --pretty=format:"%H|%h|%s|%an|%ad" --date=short ${hash}`
4076
- );
4077
- if (!info) {
4078
- console.log(colors.red(`\u274C \u627E\u4E0D\u5230 commit: ${hash}`));
4080
+ if (hashes.length === 1 && hashes[0].includes("..") && !hashes[0].includes("...")) {
4081
+ const range = hashes[0];
4082
+ const [startHash, endHash] = range.split("..");
4083
+ const inclusiveRange = `${startHash}^..${endHash}`;
4084
+ try {
4085
+ const output = execOutput(
4086
+ `git log ${inclusiveRange} --pretty=format:"%H|%h|%s|%an|%ad" --date=short --reverse`
4087
+ );
4088
+ if (!output) {
4089
+ console.log(colors.red(`\u274C \u65E0\u6548\u7684 commit \u8303\u56F4: ${range}`));
4090
+ process.exit(1);
4091
+ }
4092
+ commits = output.split("\n").filter(Boolean).map((line) => parseCommitLine(line)).filter((c) => c !== null);
4093
+ diff = execOutput(`git diff ${inclusiveRange}`) || "";
4094
+ } catch {
4095
+ console.log(colors.red(`\u274C \u65E0\u6548\u7684 commit \u8303\u56F4: ${range}`));
4079
4096
  process.exit(1);
4080
4097
  }
4081
- const [fullHash, shortHash, subject, author, date] = info.split("|");
4082
- return { hash: fullHash, shortHash, subject, author, date };
4083
- });
4084
- diff = getMultipleCommitsDiff(hashes);
4098
+ } else {
4099
+ commits = hashes.map((hash) => {
4100
+ const info = execOutput(
4101
+ `git log -1 --pretty=format:"%H|%h|%s|%an|%ad" --date=short ${hash}`
4102
+ );
4103
+ if (!info) {
4104
+ console.log(colors.red(`\u274C \u627E\u4E0D\u5230 commit: ${hash}`));
4105
+ process.exit(1);
4106
+ }
4107
+ const commit2 = parseCommitLine(info);
4108
+ if (!commit2) {
4109
+ console.log(colors.red(`\u274C \u65E0\u6CD5\u89E3\u6790 commit \u4FE1\u606F: ${hash}`));
4110
+ process.exit(1);
4111
+ }
4112
+ return commit2;
4113
+ });
4114
+ diff = getMultipleCommitsDiff(hashes);
4115
+ }
4085
4116
  } else if (options.last) {
4086
4117
  commits = getRecentCommits3(options.last);
4087
4118
  diff = getMultipleCommitsDiff(commits.map((c) => c.hash));
4088
4119
  } else if (options.staged) {
4089
4120
  diff = getStagedDiff();
4090
4121
  } else {
4091
- const recentCommits = getRecentCommits3(20);
4122
+ const recentCommits = getRecentCommits3(10);
4092
4123
  const stagedDiff = getStagedDiff();
4093
4124
  const choices = [];
4094
4125
  if (stagedDiff) {
@@ -4201,7 +4232,7 @@ process.on("SIGTERM", () => {
4201
4232
  console.log("");
4202
4233
  process.exit(0);
4203
4234
  });
4204
- var version = true ? "0.5.2" : "0.0.0-dev";
4235
+ var version = true ? "0.5.3" : "0.0.0-dev";
4205
4236
  async function mainMenu() {
4206
4237
  console.log(
4207
4238
  colors.green(`
@@ -14,6 +14,9 @@ gw review abc1234
14
14
  # 审查多个 commits
15
15
  gw review abc1234 def5678
16
16
 
17
+ # 审查 commit 范围(包含 abc1234 和 def5678 的所有 commits)
18
+ gw review abc1234..def5678
19
+
17
20
  # 审查最近 N 个 commits
18
21
  gw review -n 3
19
22
  gw review --last 3
@@ -21,6 +21,9 @@ gw review abc1234
21
21
  # 审查多个 commits
22
22
  gw review abc1234 def5678
23
23
 
24
+ # 审查 commit 范围(包含 abc1234 和 def5678 的所有 commits)
25
+ gw review abc1234..def5678
26
+
24
27
  # 审查最近 N 个 commits
25
28
  gw review -n 3
26
29
 
@@ -137,6 +140,9 @@ gw c
137
140
  ```bash
138
141
  # 审查某个 PR 的所有 commits
139
142
  gw review abc1234 def5678 ghi9012
143
+
144
+ # 或使用范围语法审查从 abc1234 到 def5678 的所有 commits(包含两端)
145
+ gw review abc1234..def5678
140
146
  ```
141
147
 
142
148
  ### 3. 定期代码审计
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zjex/git-workflow",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "🚀 极简的 Git 工作流 CLI 工具,让分支管理和版本发布变得轻松愉快",
5
5
  "type": "module",
6
6
  "bin": {
@@ -71,6 +71,17 @@ const AI_PROVIDERS: Record<string, AIProvider> = {
71
71
 
72
72
  // ========== 辅助函数 ==========
73
73
 
74
+ /**
75
+ * 解析 git log 输出的 commit 信息
76
+ */
77
+ function parseCommitLine(line: string): CommitInfo | null {
78
+ const parts = line.split("|");
79
+ if (parts.length < 5) return null;
80
+
81
+ const [hash, shortHash, subject, author, date] = parts;
82
+ return { hash, shortHash, subject, author, date };
83
+ }
84
+
74
85
  /**
75
86
  * 获取最近的 commits 列表
76
87
  */
@@ -84,10 +95,8 @@ function getRecentCommits(limit: number = 20): CommitInfo[] {
84
95
  return output
85
96
  .split("\n")
86
97
  .filter(Boolean)
87
- .map((line) => {
88
- const [hash, shortHash, subject, author, date] = line.split("|");
89
- return { hash, shortHash, subject, author, date };
90
- });
98
+ .map((line) => parseCommitLine(line))
99
+ .filter((c): c is CommitInfo => c !== null);
91
100
  } catch {
92
101
  return [];
93
102
  }
@@ -629,19 +638,53 @@ export async function review(
629
638
 
630
639
  // 确定要审查的内容
631
640
  if (hashes && hashes.length > 0) {
632
- // 指定了 commit hash
633
- commits = hashes.map((hash) => {
634
- const info = execOutput(
635
- `git log -1 --pretty=format:"%H|%h|%s|%an|%ad" --date=short ${hash}`
636
- );
637
- if (!info) {
638
- console.log(colors.red(`❌ 找不到 commit: ${hash}`));
641
+ // 检查是否是范围语法 (abc123..def456)
642
+ if (hashes.length === 1 && hashes[0].includes("..") && !hashes[0].includes("...")) {
643
+ const range = hashes[0];
644
+ const [startHash, endHash] = range.split("..");
645
+
646
+ // 使用 startHash^..endHash 来包含起始 commit(闭区间 [A, B])
647
+ const inclusiveRange = `${startHash}^..${endHash}`;
648
+
649
+ // 获取范围内的所有 commits
650
+ try {
651
+ const output = execOutput(
652
+ `git log ${inclusiveRange} --pretty=format:"%H|%h|%s|%an|%ad" --date=short --reverse`
653
+ );
654
+ if (!output) {
655
+ console.log(colors.red(`❌ 无效的 commit 范围: ${range}`));
656
+ process.exit(1);
657
+ }
658
+ commits = output
659
+ .split("\n")
660
+ .filter(Boolean)
661
+ .map((line) => parseCommitLine(line))
662
+ .filter((c): c is CommitInfo => c !== null);
663
+ // 获取范围 diff
664
+ diff = execOutput(`git diff ${inclusiveRange}`) || "";
665
+ } catch {
666
+ console.log(colors.red(`❌ 无效的 commit 范围: ${range}`));
639
667
  process.exit(1);
640
668
  }
641
- const [fullHash, shortHash, subject, author, date] = info.split("|");
642
- return { hash: fullHash, shortHash, subject, author, date };
643
- });
644
- diff = getMultipleCommitsDiff(hashes);
669
+ } else {
670
+ // 指定了单个或多个 commit hash
671
+ commits = hashes.map((hash) => {
672
+ const info = execOutput(
673
+ `git log -1 --pretty=format:"%H|%h|%s|%an|%ad" --date=short ${hash}`
674
+ );
675
+ if (!info) {
676
+ console.log(colors.red(`❌ 找不到 commit: ${hash}`));
677
+ process.exit(1);
678
+ }
679
+ const commit = parseCommitLine(info);
680
+ if (!commit) {
681
+ console.log(colors.red(`❌ 无法解析 commit 信息: ${hash}`));
682
+ process.exit(1);
683
+ }
684
+ return commit;
685
+ });
686
+ diff = getMultipleCommitsDiff(hashes);
687
+ }
645
688
  } else if (options.last) {
646
689
  // 审查最近 N 个 commits
647
690
  commits = getRecentCommits(options.last);
@@ -651,7 +694,7 @@ export async function review(
651
694
  diff = getStagedDiff();
652
695
  } else {
653
696
  // 交互式选择
654
- const recentCommits = getRecentCommits(20);
697
+ const recentCommits = getRecentCommits(10);
655
698
  const stagedDiff = getStagedDiff();
656
699
 
657
700
  const choices: any[] = [];
@@ -1,10 +1,11 @@
1
- import { execSync } from "child_process";
1
+ import { execSync, spawn } from "child_process";
2
2
  import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
3
3
  import { homedir } from "os";
4
4
  import { join } from "path";
5
5
  import boxen from "boxen";
6
6
  import { select } from "@inquirer/prompts";
7
7
  import ora from "ora";
8
+ import semver from "semver";
8
9
  import { colors } from "./utils.js";
9
10
 
10
11
  const DISMISS_INTERVAL = 1000 * 60 * 60 * 24; // 24 小时后再次提示
@@ -33,10 +34,10 @@ export async function checkForUpdates(
33
34
  const cache = readCache();
34
35
  const now = Date.now();
35
36
 
36
- // 1. 先用缓存的结果提示用户(如果版本不一致)
37
+ // 1. 先用缓存的结果提示用户(如果有更新版本)
37
38
  if (
38
39
  cache?.latestVersion &&
39
- cache.latestVersion !== currentVersion
40
+ semver.gt(cache.latestVersion, currentVersion)
40
41
  ) {
41
42
  // 检查用户是否在 24 小时内关闭过提示
42
43
  const isDismissed =
@@ -61,8 +62,8 @@ export async function checkForUpdates(
61
62
  }
62
63
  }
63
64
 
64
- // 2. 后台异步检查更新(每次都检查,不阻塞)
65
- backgroundCheck(currentVersion, packageName);
65
+ // 2. 后台子进程检查更新(每次都检查,不阻塞)
66
+ spawnBackgroundCheck(packageName);
66
67
  } catch (error) {
67
68
  // 如果是用户按 Ctrl+C,重新抛出让全局处理
68
69
  if (error?.constructor?.name === "ExitPromptError") {
@@ -73,28 +74,47 @@ export async function checkForUpdates(
73
74
  }
74
75
 
75
76
  /**
76
- * 后台异步检查更新(不阻塞)
77
- * 每次运行命令时都异步检查一次
77
+ * 在子进程中检查更新(不阻塞主进程)
78
+ * 使用 unref() 确保主进程退出后子进程仍能完成
78
79
  */
79
- function backgroundCheck(currentVersion: string, packageName: string): void {
80
- // 使用 setImmediate 确保不阻塞主流程
81
- setImmediate(async () => {
82
- try {
83
- const latestVersion = await getLatestVersion(packageName);
84
-
85
- if (latestVersion) {
86
- const cache = readCache() || {};
87
- writeCache({
88
- ...cache,
89
- lastCheck: Date.now(),
90
- latestVersion,
91
- checkedVersion: currentVersion,
92
- });
93
- }
94
- } catch {
95
- // 静默失败
96
- }
97
- });
80
+ function spawnBackgroundCheck(packageName: string): void {
81
+ try {
82
+ const cacheFile = join(homedir(), CACHE_FILE);
83
+
84
+ // 使用 node -e 执行检查脚本
85
+ const script = `
86
+ const { execSync } = require('child_process');
87
+ const { writeFileSync, readFileSync, existsSync } = require('fs');
88
+ try {
89
+ const version = execSync('npm view ${packageName} version', {
90
+ encoding: 'utf-8',
91
+ timeout: 10000,
92
+ stdio: ['pipe', 'pipe', 'ignore']
93
+ }).trim();
94
+ if (version) {
95
+ let cache = {};
96
+ try {
97
+ if (existsSync('${cacheFile}')) {
98
+ cache = JSON.parse(readFileSync('${cacheFile}', 'utf-8'));
99
+ }
100
+ } catch {}
101
+ cache.lastCheck = Date.now();
102
+ cache.latestVersion = version;
103
+ writeFileSync('${cacheFile}', JSON.stringify(cache), 'utf-8');
104
+ }
105
+ } catch {}
106
+ `;
107
+
108
+ const child = spawn("node", ["-e", script], {
109
+ detached: true,
110
+ stdio: "ignore",
111
+ });
112
+
113
+ // unref 让主进程可以独立退出
114
+ child.unref();
115
+ } catch {
116
+ // 静默失败
117
+ }
98
118
  }
99
119
 
100
120
  /**
@@ -109,22 +129,6 @@ function isUsingVolta(): boolean {
109
129
  }
110
130
  }
111
131
 
112
- /**
113
- * 获取 npm 上的最新版本
114
- */
115
- async function getLatestVersion(packageName: string): Promise<string | null> {
116
- try {
117
- const result = execSync(`npm view ${packageName} version`, {
118
- encoding: "utf-8",
119
- timeout: 3000,
120
- stdio: ["pipe", "pipe", "ignore"], // 忽略 stderr
121
- });
122
- return result.trim();
123
- } catch {
124
- return null;
125
- }
126
- }
127
-
128
132
  /**
129
133
  * 显示简单的更新通知(非交互式,不阻塞)
130
134
  */
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
- import { execSync } from "child_process";
2
+ import { execSync, spawn } from "child_process";
3
3
  import { readFileSync, writeFileSync, existsSync, unlinkSync } from "fs";
4
4
  import { homedir } from "os";
5
5
  import { checkForUpdates, clearUpdateCache } from "../src/update-notifier";
@@ -28,6 +28,11 @@ describe("Update Notifier 模块测试", () => {
28
28
  vi.clearAllMocks();
29
29
  vi.mocked(homedir).mockReturnValue("/home/user");
30
30
  vi.useFakeTimers();
31
+
32
+ // Mock spawn 返回一个带 unref 的对象
33
+ vi.mocked(spawn).mockReturnValue({
34
+ unref: vi.fn(),
35
+ } as any);
31
36
  });
32
37
 
33
38
  afterEach(() => {
@@ -62,16 +67,20 @@ describe("Update Notifier 模块测试", () => {
62
67
  });
63
68
 
64
69
  describe("checkForUpdates 函数", () => {
65
- it("没有缓存时应该后台检查", async () => {
70
+ it("没有缓存时应该启动后台检查", async () => {
66
71
  vi.mocked(existsSync).mockReturnValue(false);
67
- vi.mocked(execSync).mockReturnValue("1.0.1" as any);
68
72
 
69
73
  await checkForUpdates("1.0.0");
70
74
 
71
- // 等待异步操作
72
- await vi.runAllTimersAsync();
73
-
74
- expect(writeFileSync).toHaveBeenCalled();
75
+ // 没有缓存时应该启动子进程检查
76
+ expect(spawn).toHaveBeenCalledWith(
77
+ "node",
78
+ expect.arrayContaining(["-e", expect.any(String)]),
79
+ expect.objectContaining({
80
+ detached: true,
81
+ stdio: "ignore",
82
+ })
83
+ );
75
84
  });
76
85
 
77
86
  it("版本相同时不应该显示提示", async () => {
@@ -153,61 +162,20 @@ describe("Update Notifier 模块测试", () => {
153
162
  consoleSpy.mockRestore();
154
163
  });
155
164
 
156
- it("每次运行都应该后台检查最新版本", async () => {
157
- const mockCache = {
158
- lastCheck: Date.now(),
159
- latestVersion: "1.0.0",
160
- checkedVersion: "1.0.0",
161
- };
162
-
163
- vi.mocked(existsSync).mockReturnValue(true);
164
- vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockCache));
165
- vi.mocked(execSync).mockReturnValue("1.0.1" as any);
166
-
167
- await checkForUpdates("1.0.0");
168
- await vi.runAllTimersAsync();
169
-
170
- // 每次运行都应该后台检查
171
- expect(writeFileSync).toHaveBeenCalled();
172
- });
173
-
174
- it("后台检查应该更新缓存中的最新版本", async () => {
175
- const mockCache = {
176
- lastCheck: Date.now() - 2 * 60 * 60 * 1000,
177
- latestVersion: "1.0.0",
178
- checkedVersion: "1.0.0",
179
- };
180
-
181
- vi.mocked(existsSync).mockReturnValue(true);
182
- vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockCache));
183
- vi.mocked(execSync).mockReturnValue("1.0.1" as any);
184
-
185
- await checkForUpdates("1.0.0");
186
- await vi.runAllTimersAsync();
187
-
188
- expect(writeFileSync).toHaveBeenCalled();
189
- });
190
-
191
- it("有新版本时每次都应该后台检查", async () => {
165
+ it("每次运行都应该启动后台检查", async () => {
192
166
  const mockCache = {
193
167
  lastCheck: Date.now(), // 刚刚检查过
194
- latestVersion: "1.0.1", // 有新版本
168
+ latestVersion: "1.0.0",
195
169
  checkedVersion: "1.0.0",
196
170
  };
197
171
 
198
172
  vi.mocked(existsSync).mockReturnValue(true);
199
173
  vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockCache));
200
- vi.mocked(execSync).mockReturnValue("1.0.2" as any);
201
-
202
- const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
203
174
 
204
175
  await checkForUpdates("1.0.0");
205
- await vi.runAllTimersAsync();
206
-
207
- // 即使刚检查过,有新版本时也应该继续检查
208
- expect(writeFileSync).toHaveBeenCalled();
209
176
 
210
- consoleSpy.mockRestore();
177
+ // 每次都应该启动子进程检查
178
+ expect(spawn).toHaveBeenCalled();
211
179
  });
212
180
 
213
181
  it("缓存文件损坏时应该静默处理", async () => {
@@ -310,7 +278,7 @@ describe("Update Notifier 模块测试", () => {
310
278
  { current: "1.0.0", latest: "1.0.1", shouldShow: true },
311
279
  { current: "1.0.0", latest: "1.1.0", shouldShow: true },
312
280
  { current: "1.0.0", latest: "2.0.0", shouldShow: true },
313
- { current: "1.0.1", latest: "1.0.0", shouldShow: true }, // 回滚场景也应该提示
281
+ { current: "1.0.1", latest: "1.0.0", shouldShow: false }, // 本地版本更高,不提示
314
282
  { current: "1.0.0", latest: "1.0.0", shouldShow: false },
315
283
  ];
316
284
 
@@ -343,23 +311,8 @@ describe("Update Notifier 模块测试", () => {
343
311
  });
344
312
 
345
313
  describe("缓存读写", () => {
346
- it("应该正确写入缓存", async () => {
347
- vi.mocked(existsSync).mockReturnValue(false);
348
- vi.mocked(execSync).mockReturnValue("1.0.1" as any);
349
-
350
- await checkForUpdates("1.0.0");
351
- await vi.runAllTimersAsync();
352
-
353
- expect(writeFileSync).toHaveBeenCalledWith(
354
- "/home/user/.gw-update-check",
355
- expect.stringContaining("1.0.1"),
356
- "utf-8"
357
- );
358
- });
359
-
360
314
  it("写入缓存失败时应该静默处理", async () => {
361
315
  vi.mocked(existsSync).mockReturnValue(false);
362
- vi.mocked(execSync).mockReturnValue("1.0.1" as any);
363
316
  vi.mocked(writeFileSync).mockImplementation(() => {
364
317
  throw new Error("Write failed");
365
318
  });
@@ -380,24 +333,24 @@ describe("Update Notifier 模块测试", () => {
380
333
  describe("网络请求", () => {
381
334
  it("获取最新版本失败时应该静默处理", async () => {
382
335
  vi.mocked(existsSync).mockReturnValue(false);
383
- vi.mocked(execSync).mockImplementation(() => {
384
- throw new Error("Network error");
336
+ vi.mocked(spawn).mockImplementation(() => {
337
+ throw new Error("Spawn error");
385
338
  });
386
339
 
387
340
  await expect(checkForUpdates("1.0.0")).resolves.not.toThrow();
388
341
  });
389
342
 
390
- it("应该使用正确的 npm 命令", async () => {
343
+ it("后台检查应该使用正确的参数", async () => {
391
344
  vi.mocked(existsSync).mockReturnValue(false);
392
- vi.mocked(execSync).mockReturnValue("1.0.1" as any);
393
345
 
394
346
  await checkForUpdates("1.0.0", "@zjex/git-workflow");
395
- await vi.runAllTimersAsync();
396
347
 
397
- expect(execSync).toHaveBeenCalledWith(
398
- "npm view @zjex/git-workflow version",
348
+ expect(spawn).toHaveBeenCalledWith(
349
+ "node",
350
+ expect.arrayContaining(["-e", expect.stringContaining("npm view @zjex/git-workflow version")]),
399
351
  expect.objectContaining({
400
- timeout: 3000,
352
+ detached: true,
353
+ stdio: "ignore",
401
354
  })
402
355
  );
403
356
  });