@zjex/git-workflow 0.5.3 → 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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [v0.5.3](https://github.com/iamzjt-front-end/git-workflow/compare/v0.5.2...v0.5.3) (2026-02-06)
4
+
5
+ - 🔖 chore(release): 发布 v0.5.3 ([a6b327d](https://github.com/iamzjt-front-end/git-workflow/commit/a6b327d))
6
+ - docs(review): Clarify commit range syntax and refactor parsing logic ([63a77d2](https://github.com/iamzjt-front-end/git-workflow/commit/63a77d2))
7
+ - 📝 docs: 自动更新测试数量徽章 [skip ci] ([21e365c](https://github.com/iamzjt-front-end/git-workflow/commit/21e365c))
8
+ - feat(review): Add commit range syntax support and improve update notifier ([93184b0](https://github.com/iamzjt-front-end/git-workflow/commit/93184b0))
9
+
3
10
  ## [v0.5.2](https://github.com/iamzjt-front-end/git-workflow/compare/v0.5.1...v0.5.2) (2026-02-06)
4
11
 
5
12
  - 🔖 chore(release): 发布 v0.5.2 ([d2b659c](https://github.com/iamzjt-front-end/git-workflow/commit/d2b659c))
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-570%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-579%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
@@ -421,6 +421,7 @@ var defaultConfig = {
421
421
  requireId: false,
422
422
  featureIdLabel: "Story ID",
423
423
  hotfixIdLabel: "Issue ID",
424
+ tagLookupStrategy: "latest",
424
425
  autoStage: true,
425
426
  useEmoji: true
426
427
  };
@@ -747,13 +748,38 @@ async function deleteBranch(branchArg) {
747
748
  init_utils();
748
749
  import { select as select2, input as input2 } from "@inquirer/prompts";
749
750
  import ora2 from "ora";
751
+
752
+ // src/tag-utils.ts
753
+ function isValidVersionTag(tag) {
754
+ return /\d/.test(tag);
755
+ }
756
+ function extractTagPrefix(tag) {
757
+ return tag.replace(/\d.*/, "");
758
+ }
759
+ function normalizeTagLookupStrategy(value) {
760
+ return value === "all" ? "all" : "latest";
761
+ }
762
+ function getLatestTagCommand(prefix, strategy) {
763
+ if (strategy === "latest") {
764
+ return `git for-each-ref --sort=-creatordate --format="%(refname:short)" "refs/tags/${prefix}*"`;
765
+ }
766
+ return `git tag -l "${prefix}*" --sort=-v:refname`;
767
+ }
768
+ function shouldFetchAllTagsForCreateTag(strategy, prefix) {
769
+ if (strategy === "all") {
770
+ return true;
771
+ }
772
+ return !prefix;
773
+ }
774
+
775
+ // src/commands/tag.ts
750
776
  async function listTags(prefix) {
751
777
  const spinner = ora2("\u6B63\u5728\u83B7\u53D6 tags...").start();
752
778
  exec("git fetch --tags", true);
753
779
  spinner.stop();
754
780
  const pattern = prefix ? `${prefix}*` : "";
755
781
  const allTags = execOutput(`git tag -l ${pattern} --sort=v:refname`).split("\n").filter(Boolean);
756
- const tags = allTags.filter((tag) => /\d/.test(tag));
782
+ const tags = allTags.filter(isValidVersionTag);
757
783
  if (tags.length === 0) {
758
784
  console.log(
759
785
  colors.yellow(prefix ? `\u6CA1\u6709 '${prefix}' \u5F00\u5934\u7684 tag` : "\u6CA1\u6709 tag")
@@ -773,7 +799,7 @@ async function listTags(prefix) {
773
799
  }
774
800
  const grouped = /* @__PURE__ */ new Map();
775
801
  tags.forEach((tag) => {
776
- const prefix2 = tag.replace(/\d.*/, "") || "(\u65E0\u524D\u7F00)";
802
+ const prefix2 = extractTagPrefix(tag) || "(\u65E0\u524D\u7F00)";
777
803
  if (!grouped.has(prefix2)) {
778
804
  grouped.set(prefix2, []);
779
805
  }
@@ -827,23 +853,28 @@ async function listTags(prefix) {
827
853
  console.log(" " + row);
828
854
  }
829
855
  }
830
- function getLatestTag(prefix) {
831
- const tags = execOutput(`git tag -l "${prefix}*" --sort=-v:refname`).split("\n").filter((tag) => tag && /\d/.test(tag));
856
+ function getLatestTag(prefix, strategy = "latest") {
857
+ const tags = execOutput(getLatestTagCommand(prefix, strategy)).split("\n").filter((tag) => tag && isValidVersionTag(tag));
832
858
  return tags[0] || "";
833
859
  }
834
860
  async function createTag(inputPrefix) {
835
861
  const config2 = getConfig();
836
- const fetchSpinner = ora2("\u6B63\u5728\u83B7\u53D6 tags...").start();
837
- exec("git fetch --tags", true);
838
- fetchSpinner.stop();
862
+ const tagLookupStrategy = normalizeTagLookupStrategy(
863
+ config2.tagLookupStrategy
864
+ );
839
865
  divider();
840
866
  let prefix = inputPrefix;
841
867
  if (!prefix && config2.defaultTagPrefix) {
842
868
  prefix = config2.defaultTagPrefix;
843
869
  console.log(colors.dim(`(\u4F7F\u7528\u914D\u7F6E\u7684\u9ED8\u8BA4\u524D\u7F00: ${prefix})`));
844
870
  }
871
+ if (shouldFetchAllTagsForCreateTag(tagLookupStrategy, prefix)) {
872
+ const fetchSpinner = ora2("\u6B63\u5728\u83B7\u53D6 tags...").start();
873
+ exec("git fetch --tags", true);
874
+ fetchSpinner.stop();
875
+ }
845
876
  if (!prefix) {
846
- const allTags = execOutput("git tag -l").split("\n").filter((tag) => tag && /\d/.test(tag));
877
+ const allTags = execOutput("git tag -l").split("\n").filter((tag) => tag && isValidVersionTag(tag));
847
878
  if (allTags.length === 0) {
848
879
  prefix = await input2({
849
880
  message: "\u5F53\u524D\u4ED3\u5E93\u6CA1\u6709 tag\uFF0C\u8BF7\u8F93\u5165\u524D\u7F00 (\u5982 v):",
@@ -889,9 +920,7 @@ async function createTag(inputPrefix) {
889
920
  }
890
921
  return;
891
922
  }
892
- const prefixes = [
893
- ...new Set(allTags.map((t) => t.replace(/\d.*/, "")).filter(Boolean))
894
- ];
923
+ const prefixes = [...new Set(allTags.map(extractTagPrefix).filter(Boolean))];
895
924
  if (prefixes.length === 0) {
896
925
  prefix = await input2({
897
926
  message: "\u8BF7\u8F93\u5165 tag \u524D\u7F00 (\u5982 v):",
@@ -904,7 +933,7 @@ async function createTag(inputPrefix) {
904
933
  }
905
934
  } else {
906
935
  const prefixWithDate = prefixes.map((p) => {
907
- const latest = getLatestTag(p);
936
+ const latest = getLatestTag(p, tagLookupStrategy);
908
937
  const date = latest ? execOutput(`git log -1 --format=%ct "${latest}" 2>/dev/null`) : "0";
909
938
  return { prefix: p, latest, date: parseInt(date) || 0 };
910
939
  });
@@ -929,7 +958,13 @@ async function createTag(inputPrefix) {
929
958
  }
930
959
  }
931
960
  }
932
- const latestTag = getLatestTag(prefix);
961
+ let latestTag = getLatestTag(prefix, tagLookupStrategy);
962
+ if (!latestTag && tagLookupStrategy === "latest") {
963
+ const fetchSpinner = ora2("\u672C\u5730\u672A\u627E\u5230\u5BF9\u5E94 tag\uFF0C\u6B63\u5728\u5168\u91CF\u540C\u6B65\u4E00\u6B21...").start();
964
+ exec("git fetch --tags", true);
965
+ fetchSpinner.stop();
966
+ latestTag = getLatestTag(prefix, tagLookupStrategy);
967
+ }
933
968
  if (!latestTag) {
934
969
  const newTag = `${prefix}1.0.0`;
935
970
  console.log(
@@ -948,7 +983,8 @@ async function createTag(inputPrefix) {
948
983
  }
949
984
  return;
950
985
  }
951
- console.log(colors.yellow(`\u5F53\u524D\u6700\u65B0 tag: ${latestTag}`));
986
+ const strategyText = tagLookupStrategy === "latest" ? "\u6700\u65B0\u521B\u5EFA" : "\u7248\u672C\u6392\u5E8F";
987
+ console.log(colors.yellow(`\u5F53\u524D\u57FA\u51C6 tag (${strategyText}): ${latestTag}`));
952
988
  divider();
953
989
  const version2 = latestTag.slice(prefix.length);
954
990
  const preReleaseMatch = version2.match(
@@ -1588,6 +1624,23 @@ async function init() {
1588
1624
  theme
1589
1625
  });
1590
1626
  if (defaultTagPrefix) config2.defaultTagPrefix = defaultTagPrefix;
1627
+ const tagLookupStrategy = await select4({
1628
+ message: "Tag \u9012\u589E\u57FA\u51C6\u7B56\u7565:",
1629
+ choices: [
1630
+ {
1631
+ name: "\u4EC5\u57FA\u4E8E\u6700\u65B0\u521B\u5EFA\u7684 Tag\uFF08\u9ED8\u8BA4\uFF09",
1632
+ value: "latest",
1633
+ description: "\u907F\u514D\u5386\u53F2\u8BEF\u6253\u7684\u9AD8\u7248\u672C tag \u5E72\u6270\u540E\u7EED\u9012\u589E"
1634
+ },
1635
+ {
1636
+ name: "\u5168\u91CF\u6392\u5E8F",
1637
+ value: "all",
1638
+ description: "\u5168\u91CF\u62C9\u53D6 tags\uFF0C\u5E76\u6309\u7248\u672C\u53F7\u6392\u5E8F\u540E\u53D6\u6700\u65B0\u503C"
1639
+ }
1640
+ ],
1641
+ theme
1642
+ });
1643
+ config2.tagLookupStrategy = tagLookupStrategy;
1591
1644
  const autoPushChoice = await select4({
1592
1645
  message: "\u521B\u5EFA\u5206\u652F\u540E\u662F\u5426\u81EA\u52A8\u63A8\u9001?",
1593
1646
  choices: [
@@ -4232,7 +4285,7 @@ process.on("SIGTERM", () => {
4232
4285
  console.log("");
4233
4286
  process.exit(0);
4234
4287
  });
4235
- var version = true ? "0.5.3" : "0.0.0-dev";
4288
+ var version = true ? "0.6.0" : "0.0.0-dev";
4236
4289
  async function mainMenu() {
4237
4290
  console.log(
4238
4291
  colors.green(`
@@ -89,6 +89,9 @@ $ gw init
89
89
 
90
90
  ```bash
91
91
  ? 默认 Tag 前缀 (留空则每次选择): v
92
+ ? Tag 递增基准策略:
93
+ ❯ 仅基于最新创建的 Tag(默认)
94
+ 全量排序
92
95
  ```
93
96
 
94
97
  ### 推送配置
@@ -343,4 +346,4 @@ vim .gwrc.json
343
346
 
344
347
  1. **定期更新** - 根据需求更新配置
345
348
  2. **备份配置** - 备份重要的配置文件
346
- 3. **版本控制** - 跟踪配置文件的变更历史
349
+ 3. **版本控制** - 跟踪配置文件的变更历史
@@ -42,6 +42,7 @@ gw.config.json # 明确的配置文件名
42
42
  "featureIdLabel": "Story ID",
43
43
  "hotfixIdLabel": "Issue ID",
44
44
  "defaultTagPrefix": "v",
45
+ "tagLookupStrategy": "latest",
45
46
  "autoPush": true,
46
47
  "autoStage": true,
47
48
  "useEmoji": true
@@ -59,6 +60,7 @@ gw.config.json # 明确的配置文件名
59
60
  "featureIdLabel": "Story ID",
60
61
  "hotfixIdLabel": "Issue ID",
61
62
  "defaultTagPrefix": "v",
63
+ "tagLookupStrategy": "latest",
62
64
  "autoPush": true,
63
65
  "autoStage": true,
64
66
  "useEmoji": true,
@@ -232,6 +234,22 @@ gw.config.json # 明确的配置文件名
232
234
  - 创建 tag 时直接使用 `v` 前缀
233
235
  - 跳过前缀选择界面
234
236
 
237
+ #### tagLookupStrategy
238
+
239
+ **类型:** `"all" | "latest"`
240
+ **默认值:** `"latest"`
241
+ **说明:** 创建 tag 时如何确定递增基准
242
+
243
+ ```json
244
+ {
245
+ "tagLookupStrategy": "latest"
246
+ }
247
+ ```
248
+
249
+ **选项说明:**
250
+ - `"latest"` - 默认行为。优先基于本地最新创建的 tag 递增,避免历史误打的高版本 tag 干扰排序;如果当前前缀在本地不存在,会自动回退到一次全量同步
251
+ - `"all"` - 全量拉取 tags,并按版本号排序后取最新值
252
+
235
253
  ### 提交配置
236
254
 
237
255
  #### autoStage
@@ -773,4 +791,4 @@ cp ../other-project/.gwrc.json .
773
791
 
774
792
  ---
775
793
 
776
- 通过合理配置,Git Workflow 可以完美适应你的工作流程。配置文件是工具的核心,掌握配置文件的使用是高效使用 Git Workflow 的关键。
794
+ 通过合理配置,Git Workflow 可以完美适应你的工作流程。配置文件是工具的核心,掌握配置文件的使用是高效使用 Git Workflow 的关键。
@@ -144,6 +144,7 @@ gw init
144
144
  "featureIdLabel": "Jira ID",
145
145
  "hotfixIdLabel": "Bug ID",
146
146
  "defaultTagPrefix": "v",
147
+ "tagLookupStrategy": "latest",
147
148
  "autoPush": true,
148
149
  "autoStage": true,
149
150
  "useEmoji": true,
@@ -186,6 +187,7 @@ gw init
186
187
  | 配置项 | 类型 | 默认值 | 说明 |
187
188
  | ------------------ | -------- | ------ | --------------------------------- |
188
189
  | `defaultTagPrefix` | `string` | - | 默认 tag 前缀,设置后跳过选择步骤 |
190
+ | `tagLookupStrategy` | `"all" \| "latest"` | `"latest"` | tag 递增基准策略:`latest` 优先基于最新创建的 tag,`all` 按版本全量排序 |
189
191
 
190
192
  ### 提交配置
191
193
 
@@ -475,4 +477,4 @@ cp project-a/.gwrc.json project-b/.gwrc.json
475
477
 
476
478
  ---
477
479
 
478
- 通过合理的配置,Git Workflow 可以完美适应你的工作流程。从简单的个人项目到复杂的企业级应用,都能找到合适的配置方案。
480
+ 通过合理的配置,Git Workflow 可以完美适应你的工作流程。从简单的个人项目到复杂的企业级应用,都能找到合适的配置方案。
@@ -436,6 +436,19 @@ jobs:
436
436
 
437
437
  设置后,创建标签时会跳过前缀选择步骤。
438
438
 
439
+ ### Tag 基准策略
440
+
441
+ ```json
442
+ {
443
+ "defaultTagPrefix": "v",
444
+ "tagLookupStrategy": "latest"
445
+ }
446
+ ```
447
+
448
+ `tagLookupStrategy` 支持两种模式:
449
+ - `all`:全量拉取并按版本号排序,兼容当前默认行为
450
+ - `latest`:优先基于最新创建的 tag 递增,适合历史上出现过误打高版本 tag 的仓库;如果本地没有该前缀的 tag,会自动回退一次全量同步
451
+
439
452
  ### 版本格式配置
440
453
 
441
454
  ```json
@@ -611,4 +624,4 @@ jobs:
611
624
 
612
625
  ---
613
626
 
614
- 通过系统化的 Tag 管理,你可以建立清晰的版本发布流程。Git Workflow 的智能版本递增和前缀检测功能,让版本管理变得简单而规范。
627
+ 通过系统化的 Tag 管理,你可以建立清晰的版本发布流程。Git Workflow 的智能版本递增和前缀检测功能,让版本管理变得简单而规范。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zjex/git-workflow",
3
- "version": "0.5.3",
3
+ "version": "0.6.0",
4
4
  "description": "🚀 极简的 Git 工作流 CLI 工具,让分支管理和版本发布变得轻松愉快",
5
5
  "type": "module",
6
6
  "bin": {
@@ -40,7 +40,7 @@
40
40
  "author": "zjex",
41
41
  "license": "MIT",
42
42
  "engines": {
43
- "node": ">=18.0.0",
43
+ "node": ">=21.3.0",
44
44
  "npm": ">=9.0.0"
45
45
  },
46
46
  "repository": {
@@ -148,6 +148,24 @@ export async function init(): Promise<void> {
148
148
  });
149
149
  if (defaultTagPrefix) config.defaultTagPrefix = defaultTagPrefix;
150
150
 
151
+ const tagLookupStrategy = await select({
152
+ message: "Tag 递增基准策略:",
153
+ choices: [
154
+ {
155
+ name: "仅基于最新创建的 Tag(默认)",
156
+ value: "latest",
157
+ description: "避免历史误打的高版本 tag 干扰后续递增",
158
+ },
159
+ {
160
+ name: "全量排序",
161
+ value: "all",
162
+ description: "全量拉取 tags,并按版本号排序后取最新值",
163
+ },
164
+ ],
165
+ theme,
166
+ });
167
+ config.tagLookupStrategy = tagLookupStrategy as "all" | "latest";
168
+
151
169
  // 自动推送
152
170
  const autoPushChoice = await select({
153
171
  message: "创建分支后是否自动推送?",
@@ -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
+ }
@@ -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("全局配置", () => {
@@ -40,6 +40,74 @@ describe("Init 模块测试", () => {
40
40
  const mockJoin = vi.mocked(join);
41
41
  const mockHomedir = vi.mocked(homedir);
42
42
 
43
+ async function mockBasicInitFlow(options?: {
44
+ scope?: "global" | "project";
45
+ exists?: boolean;
46
+ overwrite?: boolean;
47
+ requireId?: boolean;
48
+ featureRequireDescription?: boolean;
49
+ hotfixRequireDescription?: boolean;
50
+ tagLookupStrategy?: "all" | "latest";
51
+ autoPushChoice?: "ask" | "yes" | "no";
52
+ autoStage?: boolean;
53
+ useEmoji?: boolean;
54
+ enableAI?: boolean;
55
+ inputs?: {
56
+ baseBranch?: string;
57
+ featurePrefix?: string;
58
+ hotfixPrefix?: string;
59
+ featureIdLabel?: string;
60
+ hotfixIdLabel?: string;
61
+ defaultTagPrefix?: string;
62
+ };
63
+ }) {
64
+ const {
65
+ scope = "project",
66
+ exists = false,
67
+ overwrite,
68
+ requireId = false,
69
+ featureRequireDescription = false,
70
+ hotfixRequireDescription = false,
71
+ tagLookupStrategy = "latest",
72
+ autoPushChoice = "ask",
73
+ autoStage = true,
74
+ useEmoji = true,
75
+ enableAI = false,
76
+ inputs = {},
77
+ } = options ?? {};
78
+
79
+ mockExistsSync.mockReturnValue(exists);
80
+
81
+ const { select, input } = await import("@inquirer/prompts");
82
+ const selectMock = vi.mocked(select);
83
+ const inputMock = vi.mocked(input);
84
+
85
+ selectMock.mockResolvedValueOnce(scope);
86
+ if (exists) {
87
+ selectMock.mockResolvedValueOnce(overwrite ?? true);
88
+ }
89
+
90
+ selectMock
91
+ .mockResolvedValueOnce(requireId)
92
+ .mockResolvedValueOnce(featureRequireDescription)
93
+ .mockResolvedValueOnce(hotfixRequireDescription)
94
+ .mockResolvedValueOnce(tagLookupStrategy)
95
+ .mockResolvedValueOnce(autoPushChoice)
96
+ .mockResolvedValueOnce(autoStage)
97
+ .mockResolvedValueOnce(useEmoji)
98
+ .mockResolvedValueOnce(enableAI);
99
+
100
+ inputMock
101
+ .mockResolvedValueOnce(inputs.baseBranch ?? "")
102
+ .mockResolvedValueOnce(inputs.featurePrefix ?? "feature")
103
+ .mockResolvedValueOnce(inputs.hotfixPrefix ?? "hotfix")
104
+ .mockResolvedValueOnce(inputs.featureIdLabel ?? "Story ID")
105
+ .mockResolvedValueOnce(inputs.hotfixIdLabel ?? "Issue ID")
106
+ .mockResolvedValueOnce(inputs.defaultTagPrefix ?? "");
107
+
108
+ return { select: selectMock, input: inputMock };
109
+ }
110
+
43
111
  beforeEach(() => {
44
112
  vi.clearAllMocks();
45
113
  vi.spyOn(console, "log").mockImplementation(() => {});
@@ -53,24 +121,7 @@ describe("Init 模块测试", () => {
53
121
 
54
122
  describe("配置范围选择", () => {
55
123
  it("应该支持全局配置", async () => {
56
- mockExistsSync.mockReturnValue(false);
57
- const { select, input } = await import("@inquirer/prompts");
58
- vi.mocked(select)
59
- .mockResolvedValueOnce("global")
60
- .mockResolvedValueOnce(false)
61
- .mockResolvedValueOnce(false)
62
- .mockResolvedValueOnce(false)
63
- .mockResolvedValueOnce("ask")
64
- .mockResolvedValueOnce(true)
65
- .mockResolvedValueOnce(true)
66
- .mockResolvedValueOnce(false);
67
- vi.mocked(input)
68
- .mockResolvedValueOnce("")
69
- .mockResolvedValueOnce("feature")
70
- .mockResolvedValueOnce("hotfix")
71
- .mockResolvedValueOnce("Story ID")
72
- .mockResolvedValueOnce("Issue ID")
73
- .mockResolvedValueOnce("");
124
+ await mockBasicInitFlow({ scope: "global" });
74
125
  const { init } = await import("../src/commands/init.js");
75
126
  await init();
76
127
  expect(mockJoin).toHaveBeenCalledWith("/home/user", ".gwrc.json");
@@ -81,24 +132,7 @@ describe("Init 模块测试", () => {
81
132
  });
82
133
 
83
134
  it("应该支持项目配置", async () => {
84
- mockExistsSync.mockReturnValue(false);
85
- const { select, input } = await import("@inquirer/prompts");
86
- vi.mocked(select)
87
- .mockResolvedValueOnce("project")
88
- .mockResolvedValueOnce(false)
89
- .mockResolvedValueOnce(false)
90
- .mockResolvedValueOnce(false)
91
- .mockResolvedValueOnce("ask")
92
- .mockResolvedValueOnce(true)
93
- .mockResolvedValueOnce(true)
94
- .mockResolvedValueOnce(false);
95
- vi.mocked(input)
96
- .mockResolvedValueOnce("")
97
- .mockResolvedValueOnce("feature")
98
- .mockResolvedValueOnce("hotfix")
99
- .mockResolvedValueOnce("Story ID")
100
- .mockResolvedValueOnce("Issue ID")
101
- .mockResolvedValueOnce("");
135
+ await mockBasicInitFlow({ scope: "project" });
102
136
  const { init } = await import("../src/commands/init.js");
103
137
  await init();
104
138
  expect(mockWriteFileSync).toHaveBeenCalledWith(
@@ -110,25 +144,7 @@ describe("Init 模块测试", () => {
110
144
 
111
145
  describe("配置文件覆盖", () => {
112
146
  it("应该处理配置文件已存在的情况", async () => {
113
- mockExistsSync.mockReturnValue(true);
114
- const { select, input } = await import("@inquirer/prompts");
115
- vi.mocked(select)
116
- .mockResolvedValueOnce("global")
117
- .mockResolvedValueOnce(true)
118
- .mockResolvedValueOnce(false)
119
- .mockResolvedValueOnce(false)
120
- .mockResolvedValueOnce(false)
121
- .mockResolvedValueOnce("ask")
122
- .mockResolvedValueOnce(true)
123
- .mockResolvedValueOnce(true)
124
- .mockResolvedValueOnce(false);
125
- vi.mocked(input)
126
- .mockResolvedValueOnce("")
127
- .mockResolvedValueOnce("feature")
128
- .mockResolvedValueOnce("hotfix")
129
- .mockResolvedValueOnce("Story ID")
130
- .mockResolvedValueOnce("Issue ID")
131
- .mockResolvedValueOnce("");
147
+ await mockBasicInitFlow({ scope: "global", exists: true, overwrite: true });
132
148
  const { init } = await import("../src/commands/init.js");
133
149
  await init();
134
150
  expect(mockWriteFileSync).toHaveBeenCalled();
@@ -149,24 +165,16 @@ describe("Init 模块测试", () => {
149
165
 
150
166
  describe("基础配置", () => {
151
167
  it("应该正确配置分支前缀", async () => {
152
- mockExistsSync.mockReturnValue(false);
153
- const { select, input } = await import("@inquirer/prompts");
154
- vi.mocked(select)
155
- .mockResolvedValueOnce("project")
156
- .mockResolvedValueOnce(false)
157
- .mockResolvedValueOnce(false)
158
- .mockResolvedValueOnce(false)
159
- .mockResolvedValueOnce("ask")
160
- .mockResolvedValueOnce(true)
161
- .mockResolvedValueOnce(true)
162
- .mockResolvedValueOnce(false);
163
- vi.mocked(input)
164
- .mockResolvedValueOnce("develop")
165
- .mockResolvedValueOnce("feat")
166
- .mockResolvedValueOnce("fix")
167
- .mockResolvedValueOnce("Jira ID")
168
- .mockResolvedValueOnce("Bug ID")
169
- .mockResolvedValueOnce("v");
168
+ await mockBasicInitFlow({
169
+ inputs: {
170
+ baseBranch: "develop",
171
+ featurePrefix: "feat",
172
+ hotfixPrefix: "fix",
173
+ featureIdLabel: "Jira ID",
174
+ hotfixIdLabel: "Bug ID",
175
+ defaultTagPrefix: "v",
176
+ },
177
+ });
170
178
  const { init } = await import("../src/commands/init.js");
171
179
  await init();
172
180
  const writtenConfig = mockWriteFileSync.mock.calls[0][1] as string;
@@ -177,27 +185,11 @@ describe("Init 模块测试", () => {
177
185
  expect(config.featureIdLabel).toBe("Jira ID");
178
186
  expect(config.hotfixIdLabel).toBe("Bug ID");
179
187
  expect(config.defaultTagPrefix).toBe("v");
188
+ expect(config.tagLookupStrategy).toBe("latest");
180
189
  });
181
190
 
182
191
  it("应该正确配置 ID 要求", async () => {
183
- mockExistsSync.mockReturnValue(false);
184
- const { select, input } = await import("@inquirer/prompts");
185
- vi.mocked(select)
186
- .mockResolvedValueOnce("project")
187
- .mockResolvedValueOnce(true)
188
- .mockResolvedValueOnce(false)
189
- .mockResolvedValueOnce(false)
190
- .mockResolvedValueOnce("ask")
191
- .mockResolvedValueOnce(true)
192
- .mockResolvedValueOnce(true)
193
- .mockResolvedValueOnce(false);
194
- vi.mocked(input)
195
- .mockResolvedValueOnce("")
196
- .mockResolvedValueOnce("feature")
197
- .mockResolvedValueOnce("hotfix")
198
- .mockResolvedValueOnce("Story ID")
199
- .mockResolvedValueOnce("Issue ID")
200
- .mockResolvedValueOnce("");
192
+ await mockBasicInitFlow({ requireId: true });
201
193
  const { init } = await import("../src/commands/init.js");
202
194
  await init();
203
195
  const writtenConfig = mockWriteFileSync.mock.calls[0][1] as string;
@@ -206,57 +198,33 @@ describe("Init 模块测试", () => {
206
198
  });
207
199
 
208
200
  it("应该正确配置自动推送选项", async () => {
209
- mockExistsSync.mockReturnValue(false);
210
- const { select, input } = await import("@inquirer/prompts");
211
- vi.mocked(select)
212
- .mockResolvedValueOnce("project")
213
- .mockResolvedValueOnce(false)
214
- .mockResolvedValueOnce(false)
215
- .mockResolvedValueOnce(false)
216
- .mockResolvedValueOnce("yes")
217
- .mockResolvedValueOnce(true)
218
- .mockResolvedValueOnce(true)
219
- .mockResolvedValueOnce(false);
220
- vi.mocked(input)
221
- .mockResolvedValueOnce("")
222
- .mockResolvedValueOnce("feature")
223
- .mockResolvedValueOnce("hotfix")
224
- .mockResolvedValueOnce("Story ID")
225
- .mockResolvedValueOnce("Issue ID")
226
- .mockResolvedValueOnce("");
201
+ await mockBasicInitFlow({ autoPushChoice: "yes" });
227
202
  const { init } = await import("../src/commands/init.js");
228
203
  await init();
229
204
  const writtenConfig = mockWriteFileSync.mock.calls[0][1] as string;
230
205
  const config = JSON.parse(writtenConfig);
231
206
  expect(config.autoPush).toBe(true);
232
207
  });
208
+
209
+ it("应该支持配置 tagLookupStrategy 为 latest", async () => {
210
+ await mockBasicInitFlow({ tagLookupStrategy: "latest" });
211
+ const { init } = await import("../src/commands/init.js");
212
+ await init();
213
+ const writtenConfig = mockWriteFileSync.mock.calls[0][1] as string;
214
+ const config = JSON.parse(writtenConfig);
215
+ expect(config.tagLookupStrategy).toBe("latest");
216
+ });
233
217
  });
234
218
 
235
219
  describe("AI 配置", () => {
236
220
  it("应该正确配置 GitHub Models", async () => {
237
- mockExistsSync.mockReturnValue(false);
238
- const { select, input } = await import("@inquirer/prompts");
239
- vi.mocked(select)
240
- .mockResolvedValueOnce("project")
241
- .mockResolvedValueOnce(false)
242
- .mockResolvedValueOnce(false)
243
- .mockResolvedValueOnce(false)
244
- .mockResolvedValueOnce("ask")
245
- .mockResolvedValueOnce(true)
246
- .mockResolvedValueOnce(true)
247
- .mockResolvedValueOnce(true)
221
+ const { select, input } = await mockBasicInitFlow({ enableAI: true });
222
+ select
248
223
  .mockResolvedValueOnce("github")
249
224
  .mockResolvedValueOnce("zh-CN")
250
225
  .mockResolvedValueOnce(true)
251
226
  .mockResolvedValueOnce(true);
252
- vi.mocked(input)
253
- .mockResolvedValueOnce("")
254
- .mockResolvedValueOnce("feature")
255
- .mockResolvedValueOnce("hotfix")
256
- .mockResolvedValueOnce("Story ID")
257
- .mockResolvedValueOnce("Issue ID")
258
- .mockResolvedValueOnce("")
259
- .mockResolvedValueOnce("ghp_test_token");
227
+ input.mockResolvedValueOnce("ghp_test_token");
260
228
  const { init } = await import("../src/commands/init.js");
261
229
  await init();
262
230
  const writtenConfig = mockWriteFileSync.mock.calls[0][1] as string;
@@ -269,29 +237,13 @@ describe("Init 模块测试", () => {
269
237
  });
270
238
 
271
239
  it("应该正确配置 OpenAI", async () => {
272
- mockExistsSync.mockReturnValue(false);
273
- const { select, input } = await import("@inquirer/prompts");
274
- vi.mocked(select)
275
- .mockResolvedValueOnce("project")
276
- .mockResolvedValueOnce(false)
277
- .mockResolvedValueOnce(false)
278
- .mockResolvedValueOnce(false)
279
- .mockResolvedValueOnce("ask")
280
- .mockResolvedValueOnce(true)
281
- .mockResolvedValueOnce(true)
282
- .mockResolvedValueOnce(true)
240
+ const { select, input } = await mockBasicInitFlow({ enableAI: true });
241
+ select
283
242
  .mockResolvedValueOnce("openai")
284
243
  .mockResolvedValueOnce("en-US")
285
244
  .mockResolvedValueOnce(true)
286
245
  .mockResolvedValueOnce(true);
287
- vi.mocked(input)
288
- .mockResolvedValueOnce("")
289
- .mockResolvedValueOnce("feature")
290
- .mockResolvedValueOnce("hotfix")
291
- .mockResolvedValueOnce("Story ID")
292
- .mockResolvedValueOnce("Issue ID")
293
- .mockResolvedValueOnce("")
294
- .mockResolvedValueOnce("sk-test-key");
246
+ input.mockResolvedValueOnce("sk-test-key");
295
247
  const { init } = await import("../src/commands/init.js");
296
248
  await init();
297
249
  const writtenConfig = mockWriteFileSync.mock.calls[0][1] as string;
@@ -304,28 +256,12 @@ describe("Init 模块测试", () => {
304
256
  });
305
257
 
306
258
  it("应该正确配置 Ollama", async () => {
307
- mockExistsSync.mockReturnValue(false);
308
- const { select, input } = await import("@inquirer/prompts");
309
- vi.mocked(select)
310
- .mockResolvedValueOnce("project")
311
- .mockResolvedValueOnce(false)
312
- .mockResolvedValueOnce(false)
313
- .mockResolvedValueOnce(false)
314
- .mockResolvedValueOnce("ask")
315
- .mockResolvedValueOnce(true)
316
- .mockResolvedValueOnce(true)
317
- .mockResolvedValueOnce(true)
259
+ const { select } = await mockBasicInitFlow({ enableAI: true });
260
+ select
318
261
  .mockResolvedValueOnce("ollama")
319
262
  .mockResolvedValueOnce("zh-CN")
320
263
  .mockResolvedValueOnce(true)
321
264
  .mockResolvedValueOnce(true);
322
- vi.mocked(input)
323
- .mockResolvedValueOnce("")
324
- .mockResolvedValueOnce("feature")
325
- .mockResolvedValueOnce("hotfix")
326
- .mockResolvedValueOnce("Story ID")
327
- .mockResolvedValueOnce("Issue ID")
328
- .mockResolvedValueOnce("");
329
265
  const { init } = await import("../src/commands/init.js");
330
266
  await init();
331
267
  const writtenConfig = mockWriteFileSync.mock.calls[0][1] as string;
@@ -338,24 +274,7 @@ describe("Init 模块测试", () => {
338
274
  });
339
275
 
340
276
  it("应该正确配置禁用 AI", async () => {
341
- mockExistsSync.mockReturnValue(false);
342
- const { select, input } = await import("@inquirer/prompts");
343
- vi.mocked(select)
344
- .mockResolvedValueOnce("project")
345
- .mockResolvedValueOnce(false)
346
- .mockResolvedValueOnce(false)
347
- .mockResolvedValueOnce(false)
348
- .mockResolvedValueOnce("ask")
349
- .mockResolvedValueOnce(true)
350
- .mockResolvedValueOnce(true)
351
- .mockResolvedValueOnce(false);
352
- vi.mocked(input)
353
- .mockResolvedValueOnce("")
354
- .mockResolvedValueOnce("feature")
355
- .mockResolvedValueOnce("hotfix")
356
- .mockResolvedValueOnce("Story ID")
357
- .mockResolvedValueOnce("Issue ID")
358
- .mockResolvedValueOnce("");
277
+ await mockBasicInitFlow({ enableAI: false });
359
278
  const { init } = await import("../src/commands/init.js");
360
279
  await init();
361
280
  const writtenConfig = mockWriteFileSync.mock.calls[0][1] as string;
@@ -366,29 +285,13 @@ describe("Init 模块测试", () => {
366
285
 
367
286
  describe("配置验证", () => {
368
287
  it("应该验证 GitHub Token 不为空", async () => {
369
- mockExistsSync.mockReturnValue(false);
370
- const { select, input } = await import("@inquirer/prompts");
371
- vi.mocked(select)
372
- .mockResolvedValueOnce("project")
373
- .mockResolvedValueOnce(false)
374
- .mockResolvedValueOnce(false)
375
- .mockResolvedValueOnce(false)
376
- .mockResolvedValueOnce("ask")
377
- .mockResolvedValueOnce(true)
378
- .mockResolvedValueOnce(true)
379
- .mockResolvedValueOnce(true)
288
+ const { select, input } = await mockBasicInitFlow({ enableAI: true });
289
+ select
380
290
  .mockResolvedValueOnce("github")
381
291
  .mockResolvedValueOnce("zh-CN")
382
292
  .mockResolvedValueOnce(true)
383
293
  .mockResolvedValueOnce(true);
384
- vi.mocked(input)
385
- .mockResolvedValueOnce("")
386
- .mockResolvedValueOnce("feature")
387
- .mockResolvedValueOnce("hotfix")
388
- .mockResolvedValueOnce("Story ID")
389
- .mockResolvedValueOnce("Issue ID")
390
- .mockResolvedValueOnce("")
391
- .mockResolvedValueOnce("ghp_valid_token");
294
+ input.mockResolvedValueOnce("ghp_valid_token");
392
295
  const { init } = await import("../src/commands/init.js");
393
296
  await init();
394
297
  const inputCalls = vi.mocked(input).mock.calls;
@@ -403,29 +306,13 @@ describe("Init 模块测试", () => {
403
306
  });
404
307
 
405
308
  it("应该验证 OpenAI API Key 不为空", async () => {
406
- mockExistsSync.mockReturnValue(false);
407
- const { select, input } = await import("@inquirer/prompts");
408
- vi.mocked(select)
409
- .mockResolvedValueOnce("project")
410
- .mockResolvedValueOnce(false)
411
- .mockResolvedValueOnce(false)
412
- .mockResolvedValueOnce(false)
413
- .mockResolvedValueOnce("ask")
414
- .mockResolvedValueOnce(true)
415
- .mockResolvedValueOnce(true)
416
- .mockResolvedValueOnce(true)
309
+ const { select, input } = await mockBasicInitFlow({ enableAI: true });
310
+ select
417
311
  .mockResolvedValueOnce("openai")
418
312
  .mockResolvedValueOnce("en-US")
419
313
  .mockResolvedValueOnce(true)
420
314
  .mockResolvedValueOnce(true);
421
- vi.mocked(input)
422
- .mockResolvedValueOnce("")
423
- .mockResolvedValueOnce("feature")
424
- .mockResolvedValueOnce("hotfix")
425
- .mockResolvedValueOnce("Story ID")
426
- .mockResolvedValueOnce("Issue ID")
427
- .mockResolvedValueOnce("")
428
- .mockResolvedValueOnce("sk-valid-key");
315
+ input.mockResolvedValueOnce("sk-valid-key");
429
316
  const { init } = await import("../src/commands/init.js");
430
317
  await init();
431
318
  const inputCalls = vi.mocked(input).mock.calls;
@@ -442,24 +329,7 @@ describe("Init 模块测试", () => {
442
329
 
443
330
  describe("配置输出", () => {
444
331
  it("应该包含默认的 commit emojis", async () => {
445
- mockExistsSync.mockReturnValue(false);
446
- const { select, input } = await import("@inquirer/prompts");
447
- vi.mocked(select)
448
- .mockResolvedValueOnce("project")
449
- .mockResolvedValueOnce(false)
450
- .mockResolvedValueOnce(false)
451
- .mockResolvedValueOnce(false)
452
- .mockResolvedValueOnce("ask")
453
- .mockResolvedValueOnce(true)
454
- .mockResolvedValueOnce(true)
455
- .mockResolvedValueOnce(false);
456
- vi.mocked(input)
457
- .mockResolvedValueOnce("")
458
- .mockResolvedValueOnce("feature")
459
- .mockResolvedValueOnce("hotfix")
460
- .mockResolvedValueOnce("Story ID")
461
- .mockResolvedValueOnce("Issue ID")
462
- .mockResolvedValueOnce("");
332
+ await mockBasicInitFlow();
463
333
  const { init } = await import("../src/commands/init.js");
464
334
  await init();
465
335
  const writtenConfig = mockWriteFileSync.mock.calls[0][1] as string;
@@ -471,24 +341,7 @@ describe("Init 模块测试", () => {
471
341
  });
472
342
 
473
343
  it("应该显示成功消息", async () => {
474
- mockExistsSync.mockReturnValue(false);
475
- const { select, input } = await import("@inquirer/prompts");
476
- vi.mocked(select)
477
- .mockResolvedValueOnce("global")
478
- .mockResolvedValueOnce(false)
479
- .mockResolvedValueOnce(false)
480
- .mockResolvedValueOnce(false)
481
- .mockResolvedValueOnce("ask")
482
- .mockResolvedValueOnce(true)
483
- .mockResolvedValueOnce(true)
484
- .mockResolvedValueOnce(false);
485
- vi.mocked(input)
486
- .mockResolvedValueOnce("")
487
- .mockResolvedValueOnce("feature")
488
- .mockResolvedValueOnce("hotfix")
489
- .mockResolvedValueOnce("Story ID")
490
- .mockResolvedValueOnce("Issue ID")
491
- .mockResolvedValueOnce("");
344
+ await mockBasicInitFlow({ scope: "global" });
492
345
  const { init } = await import("../src/commands/init.js");
493
346
  await init();
494
347
  expect(console.log).toHaveBeenCalledWith(
@@ -497,24 +350,7 @@ describe("Init 模块测试", () => {
497
350
  });
498
351
 
499
352
  it("应该显示全局配置的提示信息", async () => {
500
- mockExistsSync.mockReturnValue(false);
501
- const { select, input } = await import("@inquirer/prompts");
502
- vi.mocked(select)
503
- .mockResolvedValueOnce("global")
504
- .mockResolvedValueOnce(false)
505
- .mockResolvedValueOnce(false)
506
- .mockResolvedValueOnce(false)
507
- .mockResolvedValueOnce("ask")
508
- .mockResolvedValueOnce(true)
509
- .mockResolvedValueOnce(true)
510
- .mockResolvedValueOnce(false);
511
- vi.mocked(input)
512
- .mockResolvedValueOnce("")
513
- .mockResolvedValueOnce("feature")
514
- .mockResolvedValueOnce("hotfix")
515
- .mockResolvedValueOnce("Story ID")
516
- .mockResolvedValueOnce("Issue ID")
517
- .mockResolvedValueOnce("");
353
+ await mockBasicInitFlow({ scope: "global" });
518
354
  const { init } = await import("../src/commands/init.js");
519
355
  await init();
520
356
  expect(console.log).toHaveBeenCalledWith(
package/tests/tag.test.ts CHANGED
@@ -1,46 +1,89 @@
1
1
  import { describe, it, expect } from "vitest";
2
+ import {
3
+ extractTagPrefix,
4
+ getLatestTagCommand,
5
+ isValidVersionTag,
6
+ normalizeTagLookupStrategy,
7
+ shouldFetchAllTagsForCreateTag,
8
+ } from "../src/tag-utils";
2
9
 
3
10
  describe("Tag 功能测试", () => {
11
+ describe("Tag 策略", () => {
12
+ it("默认应该使用 latest 策略", () => {
13
+ expect(normalizeTagLookupStrategy(undefined)).toBe("latest");
14
+ expect(normalizeTagLookupStrategy("unknown")).toBe("latest");
15
+ });
16
+
17
+ it("应该识别 latest 策略", () => {
18
+ expect(normalizeTagLookupStrategy("latest")).toBe("latest");
19
+ });
20
+
21
+ it("all 策略应该始终全量拉取 tags", () => {
22
+ expect(shouldFetchAllTagsForCreateTag("all", "v")).toBe(true);
23
+ expect(shouldFetchAllTagsForCreateTag("all")).toBe(true);
24
+ });
25
+
26
+ it("latest 策略在已知前缀时不应该全量拉取 tags", () => {
27
+ expect(shouldFetchAllTagsForCreateTag("latest", "v")).toBe(false);
28
+ });
29
+
30
+ it("latest 策略在未知前缀时应该回退为全量拉取", () => {
31
+ expect(shouldFetchAllTagsForCreateTag("latest")).toBe(true);
32
+ });
33
+
34
+ it("应该生成全量排序的最新 tag 查询命令", () => {
35
+ expect(getLatestTagCommand("v", "all")).toBe(
36
+ 'git tag -l "v*" --sort=-v:refname',
37
+ );
38
+ });
39
+
40
+ it("应该生成按时间获取最新 tag 的查询命令", () => {
41
+ expect(getLatestTagCommand("v", "latest")).toBe(
42
+ 'git for-each-ref --sort=-creatordate --format="%(refname:short)" "refs/tags/v*"',
43
+ );
44
+ });
45
+ });
46
+
4
47
  describe("前缀提取", () => {
5
48
  it("应该正确提取 v 前缀", () => {
6
49
  const tag = "v0.1.0";
7
- const prefix = tag.replace(/[0-9].*/, "");
50
+ const prefix = extractTagPrefix(tag);
8
51
  expect(prefix).toBe("v");
9
52
  });
10
53
 
11
54
  it("应该正确提取 release- 前缀", () => {
12
55
  const tag = "release-1.0.0";
13
- const prefix = tag.replace(/[0-9].*/, "");
56
+ const prefix = extractTagPrefix(tag);
14
57
  expect(prefix).toBe("release-");
15
58
  });
16
59
 
17
60
  it("应该正确提取 @ 开头的 scope 前缀", () => {
18
61
  const tag = "@scope/package@1.0.0";
19
- const prefix = tag.replace(/[0-9].*/, "");
62
+ const prefix = extractTagPrefix(tag);
20
63
  expect(prefix).toBe("@scope/package@");
21
64
  });
22
65
 
23
66
  it("应该正确处理无前缀 tag", () => {
24
67
  const tag = "1.0.0";
25
- const prefix = tag.replace(/[0-9].*/, "") || "(无前缀)";
68
+ const prefix = extractTagPrefix(tag) || "(无前缀)";
26
69
  expect(prefix).toBe("(无前缀)");
27
70
  });
28
71
 
29
72
  it("应该正确提取 g 前缀", () => {
30
73
  const tag = "g0.1.0";
31
- const prefix = tag.replace(/[0-9].*/, "");
74
+ const prefix = extractTagPrefix(tag);
32
75
  expect(prefix).toBe("g");
33
76
  });
34
77
 
35
78
  it("应该正确提取带下划线的前缀", () => {
36
79
  const tag = "version_1.0.0";
37
- const prefix = tag.replace(/[0-9].*/, "");
80
+ const prefix = extractTagPrefix(tag);
38
81
  expect(prefix).toBe("version_");
39
82
  });
40
83
 
41
84
  it("应该正确提取带点的前缀", () => {
42
85
  const tag = "v.1.0.0";
43
- const prefix = tag.replace(/[0-9].*/, "");
86
+ const prefix = extractTagPrefix(tag);
44
87
  expect(prefix).toBe("v.");
45
88
  });
46
89
  });
@@ -51,7 +94,7 @@ describe("Tag 功能测试", () => {
51
94
  const grouped = new Map<string, string[]>();
52
95
 
53
96
  tags.forEach((tag) => {
54
- const prefix = tag.replace(/[0-9].*/, "") || "(无前缀)";
97
+ const prefix = extractTagPrefix(tag) || "(无前缀)";
55
98
  if (!grouped.has(prefix)) {
56
99
  grouped.set(prefix, []);
57
100
  }
@@ -75,7 +118,7 @@ describe("Tag 功能测试", () => {
75
118
  const grouped = new Map<string, string[]>();
76
119
 
77
120
  tags.forEach((tag) => {
78
- const prefix = tag.replace(/[0-9].*/, "") || "(无前缀)";
121
+ const prefix = extractTagPrefix(tag) || "(无前缀)";
79
122
  if (!grouped.has(prefix)) {
80
123
  grouped.set(prefix, []);
81
124
  }
@@ -94,7 +137,7 @@ describe("Tag 功能测试", () => {
94
137
  const grouped = new Map<string, string[]>();
95
138
 
96
139
  tags.forEach((tag) => {
97
- const prefix = tag.replace(/[0-9].*/, "") || "(无前缀)";
140
+ const prefix = extractTagPrefix(tag) || "(无前缀)";
98
141
  if (!grouped.has(prefix)) {
99
142
  grouped.set(prefix, []);
100
143
  }
@@ -397,56 +440,56 @@ describe("Tag 功能测试", () => {
397
440
  describe("无效标签检测", () => {
398
441
  it("应该识别不包含数字的标签为无效", () => {
399
442
  const tag = "vnull";
400
- const isInvalid = !/\d/.test(tag);
443
+ const isInvalid = !isValidVersionTag(tag);
401
444
 
402
445
  expect(isInvalid).toBe(true);
403
446
  });
404
447
 
405
448
  it("应该识别 vundefined 为无效标签", () => {
406
449
  const tag = "vundefined";
407
- const isInvalid = !/\d/.test(tag);
450
+ const isInvalid = !isValidVersionTag(tag);
408
451
 
409
452
  expect(isInvalid).toBe(true);
410
453
  });
411
454
 
412
455
  it("应该识别空版本号为无效标签", () => {
413
456
  const tag = "v";
414
- const isInvalid = !/\d/.test(tag);
457
+ const isInvalid = !isValidVersionTag(tag);
415
458
 
416
459
  expect(isInvalid).toBe(true);
417
460
  });
418
461
 
419
462
  it("应该识别纯字母标签为无效", () => {
420
463
  const tag = "release";
421
- const isInvalid = !/\d/.test(tag);
464
+ const isInvalid = !isValidVersionTag(tag);
422
465
 
423
466
  expect(isInvalid).toBe(true);
424
467
  });
425
468
 
426
469
  it("应该识别包含数字的标签为有效", () => {
427
470
  const tag = "v1.0.0";
428
- const isValid = /\d/.test(tag);
471
+ const isValid = isValidVersionTag(tag);
429
472
 
430
473
  expect(isValid).toBe(true);
431
474
  });
432
475
 
433
476
  it("应该识别预发布版本为有效", () => {
434
477
  const tag = "v1.0.0-beta.1";
435
- const isValid = /\d/.test(tag);
478
+ const isValid = isValidVersionTag(tag);
436
479
 
437
480
  expect(isValid).toBe(true);
438
481
  });
439
482
 
440
483
  it("应该识别无前缀版本号为有效", () => {
441
484
  const tag = "1.0.0";
442
- const isValid = /\d/.test(tag);
485
+ const isValid = isValidVersionTag(tag);
443
486
 
444
487
  expect(isValid).toBe(true);
445
488
  });
446
489
 
447
490
  it("应该识别带前缀的单数字版本为有效", () => {
448
491
  const tag = "v1";
449
- const isValid = /\d/.test(tag);
492
+ const isValid = isValidVersionTag(tag);
450
493
 
451
494
  expect(isValid).toBe(true);
452
495
  });
@@ -463,7 +506,7 @@ describe("Tag 功能测试", () => {
463
506
  "v",
464
507
  "v2.0.0",
465
508
  ];
466
- const invalidTags = allTags.filter((tag) => !/\d/.test(tag));
509
+ const invalidTags = allTags.filter((tag) => !isValidVersionTag(tag));
467
510
 
468
511
  expect(invalidTags).toEqual(["vnull", "vundefined", "v"]);
469
512
  expect(invalidTags.length).toBe(3);
@@ -471,7 +514,7 @@ describe("Tag 功能测试", () => {
471
514
 
472
515
  it("应该在没有无效标签时返回空数组", () => {
473
516
  const allTags = ["v1.0.0", "v1.1.0", "v2.0.0", "release-1.0.0"];
474
- const invalidTags = allTags.filter((tag) => !/\d/.test(tag));
517
+ const invalidTags = allTags.filter((tag) => !isValidVersionTag(tag));
475
518
 
476
519
  expect(invalidTags).toEqual([]);
477
520
  expect(invalidTags.length).toBe(0);
@@ -479,7 +522,7 @@ describe("Tag 功能测试", () => {
479
522
 
480
523
  it("应该在全是无效标签时返回所有标签", () => {
481
524
  const allTags = ["vnull", "vundefined", "v", "release"];
482
- const invalidTags = allTags.filter((tag) => !/\d/.test(tag));
525
+ const invalidTags = allTags.filter((tag) => !isValidVersionTag(tag));
483
526
 
484
527
  expect(invalidTags).toEqual(allTags);
485
528
  expect(invalidTags.length).toBe(4);
@@ -493,7 +536,7 @@ describe("Tag 功能测试", () => {
493
536
  "vundefined",
494
537
  "v2.0.0-beta.1",
495
538
  ];
496
- const validTags = allTags.filter((tag) => /\d/.test(tag));
539
+ const validTags = allTags.filter(isValidVersionTag);
497
540
 
498
541
  expect(validTags).toEqual(["v1.0.0", "v1.1.0", "v2.0.0-beta.1"]);
499
542
  expect(validTags.length).toBe(3);
@@ -501,7 +544,7 @@ describe("Tag 功能测试", () => {
501
544
 
502
545
  it("应该处理空标签列表", () => {
503
546
  const allTags: string[] = [];
504
- const invalidTags = allTags.filter((tag) => !/\d/.test(tag));
547
+ const invalidTags = allTags.filter((tag) => !isValidVersionTag(tag));
505
548
 
506
549
  expect(invalidTags).toEqual([]);
507
550
  expect(invalidTags.length).toBe(0);
@@ -516,7 +559,7 @@ describe("Tag 功能测试", () => {
516
559
  "g1.0.0",
517
560
  "tag",
518
561
  ];
519
- const invalidTags = allTags.filter((tag) => !/\d/.test(tag));
562
+ const invalidTags = allTags.filter((tag) => !isValidVersionTag(tag));
520
563
 
521
564
  expect(invalidTags).toEqual(["vnull", "release-", "hotfix", "tag"]);
522
565
  expect(invalidTags.length).toBe(4);