@zjex/git-workflow 0.5.2 → 0.6.0

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.
@@ -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[] = [];
@@ -10,6 +10,14 @@ import {
10
10
  divider,
11
11
  } from "../utils.js";
12
12
  import { getConfig } from "../config.js";
13
+ import {
14
+ extractTagPrefix,
15
+ getLatestTagCommand,
16
+ isValidVersionTag,
17
+ normalizeTagLookupStrategy,
18
+ shouldFetchAllTagsForCreateTag,
19
+ type TagLookupStrategy,
20
+ } from "../tag-utils.js";
13
21
 
14
22
  /**
15
23
  * 列出 tags(最新的显示在最下面,多个前缀分列展示)
@@ -29,7 +37,7 @@ export async function listTags(prefix?: string): Promise<void> {
29
37
 
30
38
  // 3. 过滤无效 tag(如 vnull、vundefined 等误操作产生的 tag)
31
39
  // 有效 tag 必须包含数字(版本号)
32
- const tags = allTags.filter((tag) => /\d/.test(tag));
40
+ const tags = allTags.filter(isValidVersionTag);
33
41
 
34
42
  // 4. 如果没有 tags,提示并返回
35
43
  if (tags.length === 0) {
@@ -57,7 +65,7 @@ export async function listTags(prefix?: string): Promise<void> {
57
65
  const grouped = new Map<string, string[]>();
58
66
  tags.forEach((tag) => {
59
67
  // 提取数字之前的字母部分作为前缀(如 "v0.1.0" -> "v")
60
- const prefix = tag.replace(/\d.*/, "") || "(无前缀)";
68
+ const prefix = extractTagPrefix(tag) || "(无前缀)";
61
69
  if (!grouped.has(prefix)) {
62
70
  grouped.set(prefix, []);
63
71
  }
@@ -147,18 +155,21 @@ interface TagChoice {
147
155
  }
148
156
 
149
157
  // 获取指定前缀的最新有效 tag(必须包含数字)
150
- function getLatestTag(prefix: string): string {
151
- const tags = execOutput(`git tag -l "${prefix}*" --sort=-v:refname`)
158
+ function getLatestTag(
159
+ prefix: string,
160
+ strategy: TagLookupStrategy = "latest",
161
+ ): string {
162
+ const tags = execOutput(getLatestTagCommand(prefix, strategy))
152
163
  .split("\n")
153
- .filter((tag) => tag && /\d/.test(tag)); // 过滤无效 tag
164
+ .filter((tag) => tag && isValidVersionTag(tag)); // 过滤无效 tag
154
165
  return tags[0] || "";
155
166
  }
156
167
 
157
168
  export async function createTag(inputPrefix?: string): Promise<void> {
158
169
  const config = getConfig();
159
- const fetchSpinner = ora("正在获取 tags...").start();
160
- exec("git fetch --tags", true);
161
- fetchSpinner.stop();
170
+ const tagLookupStrategy = normalizeTagLookupStrategy(
171
+ config.tagLookupStrategy,
172
+ );
162
173
 
163
174
  divider();
164
175
 
@@ -170,11 +181,17 @@ export async function createTag(inputPrefix?: string): Promise<void> {
170
181
  console.log(colors.dim(`(使用配置的默认前缀: ${prefix})`));
171
182
  }
172
183
 
184
+ if (shouldFetchAllTagsForCreateTag(tagLookupStrategy, prefix)) {
185
+ const fetchSpinner = ora("正在获取 tags...").start();
186
+ exec("git fetch --tags", true);
187
+ fetchSpinner.stop();
188
+ }
189
+
173
190
  if (!prefix) {
174
191
  // 过滤无效 tag(如 vnull、vundefined 等误操作产生的 tag)
175
192
  const allTags = execOutput("git tag -l")
176
193
  .split("\n")
177
- .filter((tag) => tag && /\d/.test(tag));
194
+ .filter((tag) => tag && isValidVersionTag(tag));
178
195
 
179
196
  // 仓库没有任何 tag 的情况
180
197
  if (allTags.length === 0) {
@@ -228,9 +245,7 @@ export async function createTag(inputPrefix?: string): Promise<void> {
228
245
  }
229
246
 
230
247
  // 从现有 tag 中提取前缀(数字之前的字母部分)
231
- const prefixes = [
232
- ...new Set(allTags.map((t) => t.replace(/\d.*/, "")).filter(Boolean)),
233
- ];
248
+ const prefixes = [...new Set(allTags.map(extractTagPrefix).filter(Boolean))];
234
249
 
235
250
  if (prefixes.length === 0) {
236
251
  // 有 tag 但无法提取前缀(比如纯数字 tag)
@@ -245,7 +260,7 @@ export async function createTag(inputPrefix?: string): Promise<void> {
245
260
  }
246
261
  } else {
247
262
  const prefixWithDate: PrefixInfo[] = prefixes.map((p) => {
248
- const latest = getLatestTag(p);
263
+ const latest = getLatestTag(p, tagLookupStrategy);
249
264
  const date = latest
250
265
  ? execOutput(`git log -1 --format=%ct "${latest}" 2>/dev/null`)
251
266
  : "0";
@@ -276,7 +291,14 @@ export async function createTag(inputPrefix?: string): Promise<void> {
276
291
  }
277
292
  }
278
293
 
279
- const latestTag = getLatestTag(prefix);
294
+ let latestTag = getLatestTag(prefix, tagLookupStrategy);
295
+
296
+ if (!latestTag && tagLookupStrategy === "latest") {
297
+ const fetchSpinner = ora("本地未找到对应 tag,正在全量同步一次...").start();
298
+ exec("git fetch --tags", true);
299
+ fetchSpinner.stop();
300
+ latestTag = getLatestTag(prefix, tagLookupStrategy);
301
+ }
280
302
 
281
303
  if (!latestTag) {
282
304
  const newTag = `${prefix}1.0.0`;
@@ -297,7 +319,9 @@ export async function createTag(inputPrefix?: string): Promise<void> {
297
319
  return;
298
320
  }
299
321
 
300
- console.log(colors.yellow(`当前最新 tag: ${latestTag}`));
322
+ const strategyText =
323
+ tagLookupStrategy === "latest" ? "最新创建" : "版本排序";
324
+ console.log(colors.yellow(`当前基准 tag (${strategyText}): ${latestTag}`));
301
325
 
302
326
  divider();
303
327
 
package/src/config.ts CHANGED
@@ -21,6 +21,8 @@ export interface GwConfig {
21
21
  hotfixIdLabel: string;
22
22
  // 默认 tag 前缀
23
23
  defaultTagPrefix?: string;
24
+ // tag 递增基准策略,all=全量按版本排序,latest=按最新创建的 tag 递增
25
+ tagLookupStrategy?: "all" | "latest";
24
26
  // 创建分支后是否自动推送,默认询问
25
27
  autoPush?: boolean;
26
28
  // commit 时是否自动暂存所有更改,默认 true
@@ -60,6 +62,7 @@ const defaultConfig: GwConfig = {
60
62
  requireId: false,
61
63
  featureIdLabel: "Story ID",
62
64
  hotfixIdLabel: "Issue ID",
65
+ tagLookupStrategy: "latest",
63
66
  autoStage: true,
64
67
  useEmoji: true,
65
68
  };
@@ -0,0 +1,37 @@
1
+ export type TagLookupStrategy = "all" | "latest";
2
+
3
+ export function isValidVersionTag(tag: string): boolean {
4
+ return /\d/.test(tag);
5
+ }
6
+
7
+ export function extractTagPrefix(tag: string): string {
8
+ return tag.replace(/\d.*/, "");
9
+ }
10
+
11
+ export function normalizeTagLookupStrategy(
12
+ value?: string,
13
+ ): TagLookupStrategy {
14
+ return value === "all" ? "all" : "latest";
15
+ }
16
+
17
+ export function getLatestTagCommand(
18
+ prefix: string,
19
+ strategy: TagLookupStrategy,
20
+ ): string {
21
+ if (strategy === "latest") {
22
+ return `git for-each-ref --sort=-creatordate --format="%(refname:short)" "refs/tags/${prefix}*"`;
23
+ }
24
+
25
+ return `git tag -l "${prefix}*" --sort=-v:refname`;
26
+ }
27
+
28
+ export function shouldFetchAllTagsForCreateTag(
29
+ strategy: TagLookupStrategy,
30
+ prefix?: string,
31
+ ): boolean {
32
+ if (strategy === "all") {
33
+ return true;
34
+ }
35
+
36
+ return !prefix;
37
+ }
@@ -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
  */
@@ -35,6 +35,7 @@ describe("Config 模块测试", () => {
35
35
  hotfixIdLabel: "Issue ID",
36
36
  autoStage: true,
37
37
  useEmoji: true,
38
+ tagLookupStrategy: "latest",
38
39
  });
39
40
  });
40
41
  });
@@ -92,6 +93,23 @@ describe("Config 模块测试", () => {
92
93
 
93
94
  expect(config.autoPush).toBe(true);
94
95
  });
96
+
97
+ it("应该加载 tagLookupStrategy 配置", () => {
98
+ const mockConfig = {
99
+ tagLookupStrategy: "latest" as const,
100
+ };
101
+
102
+ vi.mocked(existsSync).mockImplementation((path) => {
103
+ return path === ".gwrc.json";
104
+ });
105
+
106
+ vi.mocked(readFileSync).mockReturnValue(JSON.stringify(mockConfig));
107
+ vi.mocked(execOutput).mockReturnValue("");
108
+
109
+ const config = loadConfig();
110
+
111
+ expect(config.tagLookupStrategy).toBe("latest");
112
+ });
95
113
  });
96
114
 
97
115
  describe("全局配置", () => {