@zjex/git-workflow 0.0.1

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.
@@ -0,0 +1,290 @@
1
+ import { execSync } from "child_process";
2
+ import { select, input } from "@inquirer/prompts";
3
+ import ora from "ora";
4
+ import {
5
+ colors,
6
+ theme,
7
+ divider,
8
+ execOutput,
9
+ type BranchType,
10
+ } from "../utils.js";
11
+ import { getBranchName } from "./branch.js";
12
+
13
+ interface StashEntry {
14
+ index: number;
15
+ branch: string;
16
+ message: string;
17
+ date: string;
18
+ files: string[];
19
+ }
20
+
21
+ function parseStashList(): StashEntry[] {
22
+ const raw = execOutput('git stash list --format="%gd|%s|%ar"');
23
+ if (!raw) return [];
24
+
25
+ return raw
26
+ .split("\n")
27
+ .filter(Boolean)
28
+ .map((line) => {
29
+ const [ref, subject, date] = line.split("|");
30
+ const index = parseInt(ref.match(/stash@\{(\d+)\}/)?.[1] || "0");
31
+
32
+ const branchMatch = subject.match(/(?:WIP on|On) ([^:]+):/);
33
+ const branch = branchMatch?.[1] || "unknown";
34
+
35
+ let message = subject.replace(/(?:WIP on|On) [^:]+:\s*/, "");
36
+ if (subject.startsWith("WIP")) {
37
+ message = message.replace(/^[a-f0-9]+ /, "");
38
+ }
39
+ message = message || "(no message)";
40
+
41
+ const filesRaw = execOutput(
42
+ `git stash show stash@{${index}} --name-only 2>/dev/null`
43
+ );
44
+ const files = filesRaw ? filesRaw.split("\n").filter(Boolean) : [];
45
+
46
+ return { index, branch, message, date, files };
47
+ });
48
+ }
49
+
50
+ function formatStashChoice(entry: StashEntry): string {
51
+ const fileCount = entry.files.length;
52
+ const filesInfo = fileCount > 0 ? colors.dim(` (${fileCount} 文件)`) : "";
53
+ return `${colors.yellow(`[${entry.index}]`)} ${colors.green(entry.branch)} ${
54
+ entry.message
55
+ }${filesInfo} ${colors.dim(entry.date)}`;
56
+ }
57
+
58
+ function showStashDetail(entry: StashEntry): void {
59
+ console.log();
60
+ console.log(colors.yellow(`Stash #${entry.index}`));
61
+ console.log(`分支: ${colors.green(entry.branch)}`);
62
+ console.log(`消息: ${entry.message}`);
63
+ console.log(`时间: ${colors.dim(entry.date)}`);
64
+
65
+ if (entry.files.length > 0) {
66
+ console.log(`文件 (${entry.files.length}):`);
67
+ entry.files
68
+ .slice(0, 10)
69
+ .forEach((f) => console.log(` ${colors.dim("•")} ${f}`));
70
+ if (entry.files.length > 10) {
71
+ console.log(colors.dim(` ... 还有 ${entry.files.length - 10} 个文件`));
72
+ }
73
+ }
74
+ }
75
+
76
+ export async function stash(): Promise<void> {
77
+ const entries = parseStashList();
78
+
79
+ if (entries.length === 0) {
80
+ console.log(colors.yellow("没有 stash 记录"));
81
+
82
+ const status = execOutput("git status --porcelain");
83
+ if (status) {
84
+ const doStash = await select({
85
+ message: "检测到未提交的变更,是否创建 stash?",
86
+ choices: [
87
+ { name: "是", value: true },
88
+ { name: "否", value: false },
89
+ ],
90
+ theme,
91
+ });
92
+ if (doStash) {
93
+ await createStash();
94
+ }
95
+ }
96
+ return;
97
+ }
98
+
99
+ console.log(colors.green(`共 ${entries.length} 个 stash:\n`));
100
+
101
+ const choices = entries.map((entry) => ({
102
+ name: formatStashChoice(entry),
103
+ value: entry.index.toString(),
104
+ }));
105
+ choices.push({ name: colors.dim("+ 创建新 stash"), value: "__new__" });
106
+ choices.push({ name: colors.dim("取消"), value: "__cancel__" });
107
+
108
+ const selected = await select({
109
+ message: "选择 stash:",
110
+ choices,
111
+ theme,
112
+ });
113
+
114
+ if (selected === "__cancel__") {
115
+ return;
116
+ }
117
+
118
+ if (selected === "__new__") {
119
+ await createStash();
120
+ return;
121
+ }
122
+
123
+ const entry = entries.find((e) => e.index.toString() === selected)!;
124
+ await showStashActions(entry);
125
+ }
126
+
127
+ async function showStashActions(entry: StashEntry): Promise<void> {
128
+ showStashDetail(entry);
129
+ divider();
130
+
131
+ const action = await select({
132
+ message: "操作:",
133
+ choices: [
134
+ { name: "应用 (保留 stash)", value: "apply" },
135
+ { name: "弹出 (应用并删除)", value: "pop" },
136
+ { name: "创建分支", value: "branch" },
137
+ { name: "查看差异", value: "diff" },
138
+ { name: "删除", value: "drop" },
139
+ { name: "返回列表", value: "back" },
140
+ { name: "取消", value: "cancel" },
141
+ ],
142
+ theme,
143
+ });
144
+
145
+ switch (action) {
146
+ case "apply":
147
+ applyStash(entry.index, false);
148
+ break;
149
+ case "pop":
150
+ applyStash(entry.index, true);
151
+ break;
152
+ case "branch":
153
+ await createBranchFromStash(entry.index);
154
+ break;
155
+ case "diff":
156
+ await showDiff(entry.index);
157
+ await showStashActions(entry);
158
+ break;
159
+ case "drop":
160
+ await dropStash(entry.index);
161
+ break;
162
+ case "back":
163
+ await stash();
164
+ break;
165
+ }
166
+ }
167
+
168
+ async function createStash(): Promise<void> {
169
+ const status = execOutput("git status --porcelain");
170
+ if (!status) {
171
+ console.log(colors.yellow("没有需要 stash 的变更"));
172
+ return;
173
+ }
174
+
175
+ const hasUntracked = status.split("\n").some((line) => line.startsWith("??"));
176
+
177
+ let includeUntracked = false;
178
+ if (hasUntracked) {
179
+ includeUntracked = await select({
180
+ message: "检测到未跟踪的文件,是否一并 stash?",
181
+ choices: [
182
+ { name: "是 (包含未跟踪文件)", value: true },
183
+ { name: "否 (仅已跟踪文件)", value: false },
184
+ ],
185
+ theme,
186
+ });
187
+ }
188
+
189
+ const message = await input({
190
+ message: "Stash 消息 (可选):",
191
+ theme,
192
+ });
193
+
194
+ const spinner = ora("创建 stash...").start();
195
+ try {
196
+ let cmd = "git stash push";
197
+ if (includeUntracked) cmd += " -u";
198
+ if (message) cmd += ` -m "${message.replace(/"/g, '\\"')}"`;
199
+ execSync(cmd, { stdio: "pipe" });
200
+ spinner.succeed("Stash 创建成功");
201
+ await stash();
202
+ } catch {
203
+ spinner.fail("Stash 创建失败");
204
+ }
205
+ }
206
+
207
+ function applyStash(index: number, pop: boolean): void {
208
+ const action = pop ? "pop" : "apply";
209
+ const spinner = ora(`${pop ? "弹出" : "应用"} stash...`).start();
210
+
211
+ try {
212
+ execSync(`git stash ${action} stash@{${index}}`, { stdio: "pipe" });
213
+ spinner.succeed(`Stash ${pop ? "已弹出" : "已应用"}`);
214
+ } catch {
215
+ spinner.fail("操作失败,可能存在冲突");
216
+ const status = execOutput("git status --porcelain");
217
+ if (status.includes("UU") || status.includes("AA")) {
218
+ console.log(colors.yellow("\n存在冲突,请手动解决后提交"));
219
+ }
220
+ }
221
+ }
222
+
223
+ async function showDiff(index: number): Promise<void> {
224
+ try {
225
+ execSync(`git stash show -p --color=always stash@{${index}}`, {
226
+ stdio: "inherit",
227
+ });
228
+ console.log();
229
+ await input({
230
+ message: colors.dim("按 Enter 返回菜单..."),
231
+ theme,
232
+ });
233
+ } catch {
234
+ console.log(colors.red("无法显示差异"));
235
+ }
236
+ }
237
+
238
+ async function createBranchFromStash(index: number): Promise<void> {
239
+ const type = await select({
240
+ message: "选择分支类型:",
241
+ choices: [
242
+ { name: "feature", value: "feature" as BranchType },
243
+ { name: "hotfix", value: "hotfix" as BranchType },
244
+ { name: "取消", value: "__cancel__" },
245
+ ],
246
+ theme,
247
+ });
248
+
249
+ if (type === "__cancel__") {
250
+ console.log(colors.yellow("已取消"));
251
+ return;
252
+ }
253
+
254
+ const branchName = await getBranchName(type as BranchType);
255
+ if (!branchName) return;
256
+
257
+ const spinner = ora(`创建分支 ${branchName}...`).start();
258
+ try {
259
+ execSync(`git stash branch "${branchName}" stash@{${index}}`, {
260
+ stdio: "pipe",
261
+ });
262
+ spinner.succeed(`分支已创建: ${branchName} (stash 已自动弹出)`);
263
+ } catch {
264
+ spinner.fail("创建分支失败");
265
+ }
266
+ }
267
+
268
+ async function dropStash(index: number): Promise<void> {
269
+ const confirmed = await select({
270
+ message: `确认删除 stash@{${index}}?`,
271
+ choices: [
272
+ { name: "是", value: true },
273
+ { name: "否", value: false },
274
+ ],
275
+ theme,
276
+ });
277
+
278
+ if (!confirmed) {
279
+ console.log(colors.yellow("已取消"));
280
+ return;
281
+ }
282
+
283
+ const spinner = ora("删除 stash...").start();
284
+ try {
285
+ execSync(`git stash drop stash@{${index}}`, { stdio: "pipe" });
286
+ spinner.succeed("Stash 已删除");
287
+ } catch {
288
+ spinner.fail("删除失败");
289
+ }
290
+ }
@@ -0,0 +1,277 @@
1
+ import { execSync } from "child_process";
2
+ import { select, input } from "@inquirer/prompts";
3
+ import ora from "ora";
4
+ import { colors, theme, exec, execOutput, divider } from "../utils.js";
5
+ import { getConfig } from "../config.js";
6
+
7
+ export async function listTags(prefix?: string): Promise<void> {
8
+ const spinner = ora("正在获取 tags...").start();
9
+ exec("git fetch --tags", true);
10
+ spinner.stop();
11
+
12
+ const pattern = prefix ? `${prefix}*` : "";
13
+ const tags = execOutput(`git tag -l ${pattern} --sort=-v:refname`)
14
+ .split("\n")
15
+ .filter(Boolean);
16
+
17
+ if (tags.length === 0) {
18
+ console.log(
19
+ colors.yellow(prefix ? `没有 '${prefix}' 开头的 tag` : "没有 tag")
20
+ );
21
+ return;
22
+ }
23
+
24
+ console.log(
25
+ colors.green(prefix ? `以 '${prefix}' 开头的 tags:` : "所有 tags:")
26
+ );
27
+ tags.slice(0, 20).forEach((tag) => console.log(` ${tag}`));
28
+
29
+ if (tags.length > 20) {
30
+ console.log(colors.yellow(`\n共 ${tags.length} 个,仅显示前 20 个`));
31
+ }
32
+ }
33
+
34
+ interface PrefixInfo {
35
+ prefix: string;
36
+ latest: string;
37
+ date: number;
38
+ }
39
+
40
+ interface TagChoice {
41
+ name: string;
42
+ value: string;
43
+ }
44
+
45
+ export async function createTag(inputPrefix?: string): Promise<void> {
46
+ const config = getConfig();
47
+ const fetchSpinner = ora("正在获取 tags...").start();
48
+ exec("git fetch --tags", true);
49
+ fetchSpinner.stop();
50
+
51
+ divider();
52
+
53
+ let prefix = inputPrefix;
54
+
55
+ // 如果没有指定前缀,优先使用配置文件中的默认前缀
56
+ if (!prefix && config.defaultTagPrefix) {
57
+ prefix = config.defaultTagPrefix;
58
+ console.log(colors.dim(`(使用配置的默认前缀: ${prefix})`));
59
+ }
60
+
61
+ if (!prefix) {
62
+ const allTags = execOutput("git tag -l").split("\n").filter(Boolean);
63
+ const prefixes = [
64
+ ...new Set(allTags.map((t) => t.replace(/[0-9].*/, "")).filter(Boolean)),
65
+ ];
66
+
67
+ if (prefixes.length === 0) {
68
+ prefix = await input({
69
+ message: "当前仓库没有 tag,请输入新前缀 (如 v):",
70
+ theme,
71
+ });
72
+ if (!prefix) {
73
+ console.log(colors.yellow("已取消"));
74
+ return;
75
+ }
76
+ } else {
77
+ const prefixWithDate: PrefixInfo[] = prefixes.map((p) => {
78
+ const latest = execOutput(
79
+ `git tag -l "${p}*" --sort=-v:refname | head -1`
80
+ );
81
+ const date = latest
82
+ ? execOutput(`git log -1 --format=%ct "${latest}" 2>/dev/null`)
83
+ : "0";
84
+ return { prefix: p, latest, date: parseInt(date) || 0 };
85
+ });
86
+ prefixWithDate.sort((a, b) => b.date - a.date);
87
+
88
+ const choices: TagChoice[] = prefixWithDate.map(
89
+ ({ prefix: p, latest }) => {
90
+ return { name: `${p} (最新: ${latest})`, value: p };
91
+ }
92
+ );
93
+ choices.push({ name: "输入新前缀...", value: "__new__" });
94
+
95
+ prefix = await select({
96
+ message: "选择 tag 前缀:",
97
+ choices,
98
+ theme,
99
+ });
100
+
101
+ if (prefix === "__new__") {
102
+ prefix = await input({ message: "请输入新前缀:", theme });
103
+ if (!prefix) {
104
+ console.log(colors.yellow("已取消"));
105
+ return;
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ const latestTag = execOutput(
112
+ `git tag -l "${prefix}*" --sort=-v:refname | head -1`
113
+ );
114
+
115
+ if (!latestTag) {
116
+ const newTag = `${prefix}1.0.0`;
117
+ console.log(
118
+ colors.yellow(`未找到 '${prefix}' 开头的 tag,将创建 ${newTag}`)
119
+ );
120
+ const ok = await select({
121
+ message: `确认创建 ${newTag}?`,
122
+ choices: [
123
+ { name: "是", value: true },
124
+ { name: "否", value: false },
125
+ ],
126
+ theme,
127
+ });
128
+ if (ok) {
129
+ doCreateTag(newTag);
130
+ }
131
+ return;
132
+ }
133
+
134
+ console.log(colors.yellow(`当前最新 tag: ${latestTag}`));
135
+
136
+ divider();
137
+
138
+ const version = latestTag.slice(prefix.length);
139
+
140
+ // 解析版本号,支持预发布版本如 1.0.0-beta.1
141
+ const preReleaseMatch = version.match(
142
+ /^(\d+)\.(\d+)\.(\d+)-([a-zA-Z]+)\.(\d+)$/
143
+ );
144
+ const match3 = version.match(/^(\d+)\.(\d+)\.(\d+)$/);
145
+ const match2 = version.match(/^(\d+)\.(\d+)$/);
146
+ const match1 = version.match(/^(\d+)$/);
147
+
148
+ let choices: TagChoice[] = [];
149
+
150
+ if (preReleaseMatch) {
151
+ // 预发布版本: 1.0.0-beta.1
152
+ const [, majorStr, minorStr, patchStr, preTag, preNumStr] = preReleaseMatch;
153
+ const major = Number(majorStr);
154
+ const minor = Number(minorStr);
155
+ const patch = Number(patchStr);
156
+ const preNum = Number(preNumStr);
157
+ const baseVersion = `${major}.${minor}.${patch}`;
158
+
159
+ choices = [
160
+ {
161
+ name: `pre → ${prefix}${baseVersion}-${preTag}.${preNum + 1}`,
162
+ value: `${prefix}${baseVersion}-${preTag}.${preNum + 1}`,
163
+ },
164
+ {
165
+ name: `release→ ${prefix}${baseVersion}`,
166
+ value: `${prefix}${baseVersion}`,
167
+ },
168
+ {
169
+ name: `patch → ${prefix}${major}.${minor}.${patch + 1}`,
170
+ value: `${prefix}${major}.${minor}.${patch + 1}`,
171
+ },
172
+ {
173
+ name: `minor → ${prefix}${major}.${minor + 1}.0`,
174
+ value: `${prefix}${major}.${minor + 1}.0`,
175
+ },
176
+ {
177
+ name: `major → ${prefix}${major + 1}.0.0`,
178
+ value: `${prefix}${major + 1}.0.0`,
179
+ },
180
+ ];
181
+ } else if (match3) {
182
+ const [, majorStr, minorStr, patchStr] = match3;
183
+ const major = Number(majorStr);
184
+ const minor = Number(minorStr);
185
+ const patch = Number(patchStr);
186
+ choices = [
187
+ {
188
+ name: `patch → ${prefix}${major}.${minor}.${patch + 1}`,
189
+ value: `${prefix}${major}.${minor}.${patch + 1}`,
190
+ },
191
+ {
192
+ name: `minor → ${prefix}${major}.${minor + 1}.0`,
193
+ value: `${prefix}${major}.${minor + 1}.0`,
194
+ },
195
+ {
196
+ name: `major → ${prefix}${major + 1}.0.0`,
197
+ value: `${prefix}${major + 1}.0.0`,
198
+ },
199
+ {
200
+ name: `alpha → ${prefix}${major}.${minor}.${patch + 1}-alpha.1`,
201
+ value: `${prefix}${major}.${minor}.${patch + 1}-alpha.1`,
202
+ },
203
+ {
204
+ name: `beta → ${prefix}${major}.${minor}.${patch + 1}-beta.1`,
205
+ value: `${prefix}${major}.${minor}.${patch + 1}-beta.1`,
206
+ },
207
+ {
208
+ name: `rc → ${prefix}${major}.${minor}.${patch + 1}-rc.1`,
209
+ value: `${prefix}${major}.${minor}.${patch + 1}-rc.1`,
210
+ },
211
+ ];
212
+ } else if (match2) {
213
+ const [, majorStr, minorStr] = match2;
214
+ const major = Number(majorStr);
215
+ const minor = Number(minorStr);
216
+ choices = [
217
+ {
218
+ name: `minor → ${prefix}${major}.${minor + 1}`,
219
+ value: `${prefix}${major}.${minor + 1}`,
220
+ },
221
+ {
222
+ name: `major → ${prefix}${major + 1}.0`,
223
+ value: `${prefix}${major + 1}.0`,
224
+ },
225
+ ];
226
+ } else if (match1) {
227
+ const num = Number(match1[1]);
228
+ choices = [
229
+ { name: `next → ${prefix}${num + 1}`, value: `${prefix}${num + 1}` },
230
+ ];
231
+ } else {
232
+ console.log(colors.red(`无法解析版本号: ${version}`));
233
+ return;
234
+ }
235
+
236
+ choices.push({ name: "取消", value: "__cancel__" });
237
+
238
+ const nextTag = await select({
239
+ message: "选择版本类型:",
240
+ choices,
241
+ theme,
242
+ });
243
+
244
+ if (nextTag === "__cancel__") {
245
+ console.log(colors.yellow("已取消"));
246
+ return;
247
+ }
248
+
249
+ doCreateTag(nextTag);
250
+ }
251
+
252
+ function doCreateTag(tagName: string): void {
253
+ divider();
254
+
255
+ const spinner = ora(`正在创建 tag: ${tagName}`).start();
256
+
257
+ try {
258
+ execSync(`git tag -a "${tagName}" -m "Release ${tagName}"`, {
259
+ stdio: "pipe",
260
+ });
261
+ spinner.succeed(`Tag 创建成功: ${tagName}`);
262
+ } catch {
263
+ spinner.fail("tag 创建失败");
264
+ return;
265
+ }
266
+
267
+ const pushSpinner = ora("正在推送到远程...").start();
268
+
269
+ try {
270
+ execSync(`git push origin "${tagName}"`, { stdio: "pipe" });
271
+ pushSpinner.succeed(`Tag 已推送: ${tagName}`);
272
+ } catch {
273
+ pushSpinner.warn(
274
+ `远程推送失败,可稍后手动执行: git push origin ${tagName}`
275
+ );
276
+ }
277
+ }
package/src/config.ts ADDED
@@ -0,0 +1,88 @@
1
+ import { existsSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { execOutput } from "./utils.js";
4
+
5
+ export interface GwConfig {
6
+ // 默认基础分支,不设置则自动检测 main/master
7
+ baseBranch?: string;
8
+ // feature 分支前缀,默认 "feature"
9
+ featurePrefix: string;
10
+ // hotfix 分支前缀,默认 "hotfix"
11
+ hotfixPrefix: string;
12
+ // 是否要求必填 ID,默认 false
13
+ requireId: boolean;
14
+ // ID 标签名称
15
+ featureIdLabel: string;
16
+ hotfixIdLabel: string;
17
+ // 默认 tag 前缀
18
+ defaultTagPrefix?: string;
19
+ // 创建分支后是否自动推送,默认询问
20
+ autoPush?: boolean;
21
+ }
22
+
23
+ const defaultConfig: GwConfig = {
24
+ featurePrefix: "feature",
25
+ hotfixPrefix: "hotfix",
26
+ requireId: false,
27
+ featureIdLabel: "Story ID",
28
+ hotfixIdLabel: "Issue ID",
29
+ };
30
+
31
+ function getGitRoot(): string {
32
+ return execOutput("git rev-parse --show-toplevel");
33
+ }
34
+
35
+ function findConfigFile(): string | null {
36
+ const configNames = [".gwrc.json", ".gwrc", "gw.config.json"];
37
+
38
+ // 先在当前目录找
39
+ for (const name of configNames) {
40
+ if (existsSync(name)) {
41
+ return name;
42
+ }
43
+ }
44
+
45
+ // 再在 git 根目录找
46
+ try {
47
+ const gitRoot = getGitRoot();
48
+ if (gitRoot) {
49
+ for (const name of configNames) {
50
+ const configPath = join(gitRoot, name);
51
+ if (existsSync(configPath)) {
52
+ return configPath;
53
+ }
54
+ }
55
+ }
56
+ } catch {
57
+ // 不在 git 仓库中
58
+ }
59
+
60
+ return null;
61
+ }
62
+
63
+ export function loadConfig(): GwConfig {
64
+ const configPath = findConfigFile();
65
+
66
+ if (!configPath) {
67
+ return defaultConfig;
68
+ }
69
+
70
+ try {
71
+ const content = readFileSync(configPath, "utf-8");
72
+ const userConfig = JSON.parse(content) as Partial<GwConfig>;
73
+ return { ...defaultConfig, ...userConfig };
74
+ } catch (e) {
75
+ console.warn(`配置文件解析失败: ${configPath}`);
76
+ return defaultConfig;
77
+ }
78
+ }
79
+
80
+ // 全局配置实例
81
+ let config: GwConfig | null = null;
82
+
83
+ export function getConfig(): GwConfig {
84
+ if (!config) {
85
+ config = loadConfig();
86
+ }
87
+ return config;
88
+ }