@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.
- package/CHANGELOG.md +12 -0
- package/README.md +1 -1
- package/dist/index.js +155 -71
- package/docs/commands/config.md +4 -1
- package/docs/commands/review.md +3 -0
- package/docs/config/config-file.md +19 -1
- package/docs/config/index.md +3 -1
- package/docs/guide/ai-review.md +6 -0
- package/docs/guide/tag-management.md +14 -1
- package/package.json +2 -2
- package/src/commands/init.ts +18 -0
- package/src/commands/review.ts +59 -16
- package/src/commands/tag.ts +39 -15
- package/src/config.ts +3 -0
- package/src/tag-utils.ts +37 -0
- package/src/update-notifier.ts +46 -42
- package/tests/config.test.ts +18 -0
- package/tests/init.test.ts +111 -275
- package/tests/tag.test.ts +67 -24
- package/tests/update-notifier.test.ts +29 -76
package/src/commands/review.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
633
|
-
|
|
634
|
-
const
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
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(
|
|
697
|
+
const recentCommits = getRecentCommits(10);
|
|
655
698
|
const stagedDiff = getStagedDiff();
|
|
656
699
|
|
|
657
700
|
const choices: any[] = [];
|
package/src/commands/tag.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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(
|
|
151
|
-
|
|
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 &&
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
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 &&
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/src/tag-utils.ts
ADDED
|
@@ -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
|
+
}
|
package/src/update-notifier.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
|
|
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
|
*/
|
package/tests/config.test.ts
CHANGED
|
@@ -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("全局配置", () => {
|