@zjex/git-workflow 0.2.19 → 0.2.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zjex/git-workflow",
3
- "version": "0.2.19",
3
+ "version": "0.2.21",
4
4
  "description": "🚀 极简的 Git 工作流 CLI 工具,让分支管理和版本发布变得轻松愉快",
5
5
  "type": "module",
6
6
  "bin": {
@@ -42,10 +42,12 @@
42
42
  "@inquirer/prompts": "^7.0.0",
43
43
  "boxen": "^8.0.1",
44
44
  "cac": "^6.7.14",
45
- "ora": "^9.0.0"
45
+ "ora": "^9.0.0",
46
+ "semver": "^7.7.3"
46
47
  },
47
48
  "devDependencies": {
48
49
  "@types/node": "^25.0.3",
50
+ "@types/semver": "^7.7.1",
49
51
  "changelogen": "^0.6.2",
50
52
  "husky": "^9.1.7",
51
53
  "tsup": "^8.5.1",
@@ -5,7 +5,11 @@ import { colors, theme, execOutput, divider } from "../utils.js";
5
5
  import { getConfig } from "../config.js";
6
6
  import { generateAICommitMessage, isAICommitAvailable } from "../ai-service.js";
7
7
 
8
- // Conventional Commits 类型 + Gitmoji
8
+ /**
9
+ * Conventional Commits 类型定义 + Gitmoji
10
+ * 遵循 https://www.conventionalcommits.org/ 规范
11
+ * 使用 https://gitmoji.dev/ emoji
12
+ */
9
13
  const DEFAULT_COMMIT_TYPES = [
10
14
  { type: "feat", emoji: "✨", description: "新功能" },
11
15
  { type: "fix", emoji: "🐛", description: "修复 Bug" },
@@ -17,11 +21,15 @@ const DEFAULT_COMMIT_TYPES = [
17
21
  { type: "build", emoji: "📦", description: "构建/依赖相关" },
18
22
  { type: "ci", emoji: "👷", description: "CI/CD 相关" },
19
23
  { type: "chore", emoji: "🔧", description: "其他杂项" },
20
- { type: "revert", emoji: "⏪", description: "回退提交" },
21
24
  ] as const;
22
25
 
23
26
  type CommitType = (typeof DEFAULT_COMMIT_TYPES)[number]["type"];
24
27
 
28
+ /**
29
+ * 获取提交类型列表(支持自定义 emoji)
30
+ * @param config 用户配置
31
+ * @returns 提交类型列表
32
+ */
25
33
  function getCommitTypes(config: ReturnType<typeof getConfig>) {
26
34
  const customEmojis = config.commitEmojis || {};
27
35
  return DEFAULT_COMMIT_TYPES.map((item) => ({
@@ -30,11 +38,18 @@ function getCommitTypes(config: ReturnType<typeof getConfig>) {
30
38
  }));
31
39
  }
32
40
 
41
+ /**
42
+ * 文件状态接口
43
+ */
33
44
  interface FileStatus {
34
- status: string;
35
- file: string;
45
+ status: string; // M=修改, A=新增, D=删除, ?=未跟踪
46
+ file: string; // 文件路径
36
47
  }
37
48
 
49
+ /**
50
+ * 解析 git status 输出
51
+ * @returns 已暂存和未暂存的文件列表
52
+ */
38
53
  function parseGitStatus(): { staged: FileStatus[]; unstaged: FileStatus[] } {
39
54
  const output = execOutput("git status --porcelain");
40
55
  if (!output) return { staged: [], unstaged: [] };
@@ -44,9 +59,9 @@ function parseGitStatus(): { staged: FileStatus[]; unstaged: FileStatus[] } {
44
59
 
45
60
  for (const line of output.split("\n")) {
46
61
  if (!line) continue;
47
- const indexStatus = line[0];
48
- const workTreeStatus = line[1];
49
- const file = line.slice(3);
62
+ const indexStatus = line[0]; // 暂存区状态
63
+ const workTreeStatus = line[1]; // 工作区状态
64
+ const file = line.slice(3); // 文件路径
50
65
 
51
66
  // 已暂存的更改 (index 有状态)
52
67
  if (indexStatus !== " " && indexStatus !== "?") {
@@ -63,23 +78,33 @@ function parseGitStatus(): { staged: FileStatus[]; unstaged: FileStatus[] } {
63
78
  return { staged, unstaged };
64
79
  }
65
80
 
81
+ /**
82
+ * 格式化文件状态显示(带颜色)
83
+ * @param status 文件状态
84
+ * @returns 带颜色的状态字符串
85
+ */
66
86
  function formatFileStatus(status: string): string {
67
87
  const statusMap: Record<string, string> = {
68
- M: colors.yellow("M"),
69
- A: colors.green("A"),
70
- D: colors.red("D"),
71
- R: colors.yellow("R"),
72
- C: colors.yellow("C"),
73
- "?": colors.green("?"),
88
+ M: colors.yellow("M"), // 修改
89
+ A: colors.green("A"), // 新增
90
+ D: colors.red("D"), // 删除
91
+ R: colors.yellow("R"), // 重命名
92
+ C: colors.yellow("C"), // 复制
93
+ "?": colors.green("?"), // 未跟踪
74
94
  };
75
95
  return statusMap[status] || status;
76
96
  }
77
97
 
98
+ /**
99
+ * 交互式提交命令
100
+ * 支持 AI 自动生成和手动编写两种模式
101
+ * 遵循 Conventional Commits 规范
102
+ */
78
103
  export async function commit(): Promise<void> {
79
104
  const config = getConfig();
80
105
  let { staged, unstaged } = parseGitStatus();
81
106
 
82
- // 如果有未暂存的更改,根据配置决定是否自动暂存
107
+ // ========== 步骤 1: 处理未暂存的文件 ==========
83
108
  if (unstaged.length > 0) {
84
109
  const autoStage = config.autoStage ?? true;
85
110
 
@@ -131,7 +156,7 @@ export async function commit(): Promise<void> {
131
156
  }
132
157
  }
133
158
 
134
- // 没有暂存的更改
159
+ // ========== 步骤 2: 检查是否有文件可提交 ==========
135
160
  if (staged.length === 0) {
136
161
  console.log(colors.yellow("工作区干净,没有需要提交的更改"));
137
162
  return;
@@ -144,7 +169,7 @@ export async function commit(): Promise<void> {
144
169
  }
145
170
  divider();
146
171
 
147
- // 询问用户选择手动还是 AI 生成
172
+ // ========== 步骤 3: 选择提交方式(AI 或手动)==========
148
173
  const aiAvailable = isAICommitAvailable(config);
149
174
  let commitMode: "ai" | "manual" = "manual";
150
175
 
@@ -167,10 +192,12 @@ export async function commit(): Promise<void> {
167
192
  });
168
193
  }
169
194
 
170
- let message: string;
195
+ // 初始化 commit message 变量
196
+ let message: string = "";
171
197
 
198
+ // ========== 步骤 4: 生成 commit message ==========
199
+ // AI 生成模式
172
200
  if (commitMode === "ai") {
173
- // AI 生成模式
174
201
  const spinner = ora("AI 正在分析代码变更...").start();
175
202
 
176
203
  try {
@@ -208,11 +235,12 @@ export async function commit(): Promise<void> {
208
235
  }
209
236
  }
210
237
 
238
+ // 手动输入模式
211
239
  if (commitMode === "manual") {
212
- // 手动输入模式(原有逻辑)
213
240
  message = await buildManualCommitMessage(config);
214
241
  }
215
242
 
243
+ // ========== 步骤 5: 预览并确认提交 ==========
216
244
  divider();
217
245
  console.log("提交信息预览:");
218
246
  console.log(colors.green(message));
@@ -232,9 +260,23 @@ export async function commit(): Promise<void> {
232
260
  return;
233
261
  }
234
262
 
263
+ // ========== 步骤 6: 执行提交 ==========
235
264
  const spinner = ora("正在提交...").start();
236
265
 
237
266
  try {
267
+ // 提交前再次检查是否有暂存的文件
268
+ const finalStatus = parseGitStatus();
269
+ if (finalStatus.staged.length === 0) {
270
+ spinner.fail("没有暂存的文件可以提交");
271
+ console.log("");
272
+ console.log(colors.yellow("请先暂存文件:"));
273
+ console.log(colors.cyan(" git add <file>"));
274
+ console.log(colors.dim(" 或"));
275
+ console.log(colors.cyan(" git add -A"));
276
+ console.log("");
277
+ return;
278
+ }
279
+
238
280
  // 使用 -m 参数,需要转义引号
239
281
  const escapedMessage = message.replace(/"/g, '\\"');
240
282
  execSync(`git commit -m "${escapedMessage}"`, { stdio: "pipe" });
@@ -245,14 +287,26 @@ export async function commit(): Promise<void> {
245
287
  console.log(colors.dim(`commit: ${commitHash}`));
246
288
  } catch (error) {
247
289
  spinner.fail("提交失败");
290
+ console.log("");
291
+
292
+ // 显示详细错误信息
248
293
  if (error instanceof Error) {
249
- console.log(colors.red(error.message));
294
+ console.log(colors.red("错误信息:"));
295
+ console.log(colors.dim(` ${error.message}`));
250
296
  }
297
+
298
+ console.log("");
299
+ console.log(colors.yellow("你可以手动执行以下命令:"));
300
+ console.log(colors.cyan(` git commit -m "${message}"`));
301
+ console.log("");
251
302
  }
252
303
  }
253
304
 
254
305
  /**
255
306
  * 手动构建 commit message
307
+ * 通过交互式问答收集信息,构建符合 Conventional Commits 规范的提交信息
308
+ * @param config 用户配置
309
+ * @returns 完整的 commit message
256
310
  */
257
311
  async function buildManualCommitMessage(
258
312
  config: ReturnType<typeof getConfig>
@@ -260,23 +314,30 @@ async function buildManualCommitMessage(
260
314
  // 获取提交类型(支持自定义 emoji)
261
315
  const commitTypes = getCommitTypes(config);
262
316
 
263
- // 选择提交类型
317
+ // ========== 1. 选择提交类型 ==========
264
318
  const typeChoice = await select({
265
319
  message: "选择提交类型:",
266
- choices: commitTypes.map((t) => ({
267
- name: `${t.emoji} ${t.type.padEnd(10)} ${colors.dim(t.description)}`,
268
- value: t,
269
- })),
320
+ choices: commitTypes.map((t) => {
321
+ // 使用固定宽度格式化,不依赖 emoji 宽度
322
+ const typeText = t.type.padEnd(10);
323
+ // 针对 refactor 特殊处理,因为 ♻️ emoji 在不同终端宽度不一致
324
+ const spacing = t.type === "refactor" ? " " : " ";
325
+ return {
326
+ name: `${t.emoji}${spacing}${typeText} ${colors.dim(t.description)}`,
327
+ value: t,
328
+ };
329
+ }),
330
+ pageSize: commitTypes.length, // 显示所有选项,不滚动
270
331
  theme,
271
332
  });
272
333
 
273
- // 输入 scope (可选)
334
+ // ========== 2. 输入 scope (可选) ==========
274
335
  const scope = await input({
275
336
  message: "输入影响范围 scope (可跳过):",
276
337
  theme,
277
338
  });
278
339
 
279
- // 输入简短描述
340
+ // ========== 3. 输入简短描述 (必填) ==========
280
341
  const subject = await input({
281
342
  message: "输入简短描述:",
282
343
  validate: (value) => {
@@ -287,13 +348,13 @@ async function buildManualCommitMessage(
287
348
  theme,
288
349
  });
289
350
 
290
- // 输入详细描述 (可选)
351
+ // ========== 4. 输入详细描述 (可选) ==========
291
352
  const body = await input({
292
353
  message: "输入详细描述 (可跳过):",
293
354
  theme,
294
355
  });
295
356
 
296
- // 是否有破坏性变更
357
+ // ========== 5. 是否有破坏性变更 ==========
297
358
  const hasBreaking = await select({
298
359
  message: "是否包含破坏性变更 (BREAKING CHANGE)?",
299
360
  choices: [
@@ -312,13 +373,13 @@ async function buildManualCommitMessage(
312
373
  });
313
374
  }
314
375
 
315
- // 关联 Issue (可选)
376
+ // ========== 6. 关联 Issue (可选) ==========
316
377
  const issues = await input({
317
378
  message: "关联 Issue (如 #123, 可跳过):",
318
379
  theme,
319
380
  });
320
381
 
321
- // 构建 commit message
382
+ // ========== 7. 构建 commit message ==========
322
383
  const { type, emoji } = typeChoice;
323
384
  const scopePart = scope ? `(${scope})` : "";
324
385
  const breakingMark = hasBreaking ? "!" : "";
@@ -330,7 +391,7 @@ async function buildManualCommitMessage(
330
391
  // Header: [emoji] type(scope)!: subject
331
392
  let message = `${emojiPrefix}${type}${scopePart}${breakingMark}: ${subject}`;
332
393
 
333
- // Body
394
+ // Body (可选)
334
395
  if (body || hasBreaking || issues) {
335
396
  message += "\n";
336
397
 
@@ -25,7 +25,7 @@ Tag 命令:
25
25
  gw tag:delete 删除 tag
26
26
  gw td 同上 (别名)
27
27
 
28
- gw tag:update 修改 tag 消息
28
+ gw tag:update 重命名 tag
29
29
  gw tu 同上 (别名)
30
30
 
31
31
  发布命令:
@@ -42,6 +42,9 @@ Tag 命令:
42
42
  gw update 检查并更新到最新版本
43
43
  gw upt 同上 (别名)
44
44
 
45
+ 清理命令:
46
+ gw clean 清理缓存文件
47
+
45
48
  Stash 命令:
46
49
  gw stash 交互式管理 stash
47
50
  gw s 同上 (别名)
@@ -417,7 +417,7 @@ export async function deleteTag(): Promise<void> {
417
417
  }
418
418
 
419
419
  /**
420
- * 修改 tag(重新打标签)
420
+ * 修改 tag 名称(重命名 tag)
421
421
  */
422
422
  export async function updateTag(): Promise<void> {
423
423
  const fetchSpinner = ora("正在获取 tags...").start();
@@ -438,47 +438,71 @@ export async function updateTag(): Promise<void> {
438
438
  const choices = tags.map((tag) => ({ name: tag, value: tag }));
439
439
  choices.push({ name: "取消", value: "__cancel__" });
440
440
 
441
- const tagToUpdate = await select({
442
- message: "选择要修改的 tag:",
441
+ const oldTag = await select({
442
+ message: "选择要重命名的 tag:",
443
443
  choices,
444
444
  theme,
445
445
  });
446
446
 
447
- if (tagToUpdate === "__cancel__") {
447
+ if (oldTag === "__cancel__") {
448
448
  console.log(colors.yellow("已取消"));
449
449
  return;
450
450
  }
451
451
 
452
- const newMessage = await input({
453
- message: "输入新的 tag 消息:",
454
- default: `Release ${tagToUpdate}`,
452
+ console.log("");
453
+ console.log(colors.dim(`当前 tag: ${oldTag}`));
454
+ console.log("");
455
+
456
+ const newTag = await input({
457
+ message: "输入新的 tag 名称:",
458
+ default: oldTag,
455
459
  theme,
456
460
  });
457
461
 
458
- if (!newMessage) {
462
+ if (!newTag || newTag === oldTag) {
459
463
  console.log(colors.yellow("已取消"));
460
464
  return;
461
465
  }
462
466
 
467
+ // 检查新 tag 是否已存在
468
+ const existingTags = execOutput("git tag -l").split("\n").filter(Boolean);
469
+ if (existingTags.includes(newTag)) {
470
+ console.log(colors.red(`Tag ${newTag} 已存在,无法重命名`));
471
+ return;
472
+ }
473
+
463
474
  divider();
464
475
 
465
- const spinner = ora(`正在更新 tag: ${tagToUpdate}`).start();
476
+ const spinner = ora(`正在重命名 tag: ${oldTag} → ${newTag}`).start();
466
477
 
467
478
  try {
479
+ // 获取旧 tag 的 commit 和消息
480
+ const commit = execOutput(`git rev-list -n 1 "${oldTag}"`).trim();
481
+ const message = execOutput(
482
+ `git tag -l --format='%(contents)' "${oldTag}"`
483
+ ).trim();
484
+
485
+ // 创建新 tag(指向同一个 commit)
486
+ if (message) {
487
+ execSync(`git tag -a "${newTag}" "${commit}" -m "${message}"`, {
488
+ stdio: "pipe",
489
+ });
490
+ } else {
491
+ execSync(`git tag "${newTag}" "${commit}"`, { stdio: "pipe" });
492
+ }
493
+
468
494
  // 删除旧 tag
469
- execSync(`git tag -d "${tagToUpdate}"`, { stdio: "pipe" });
470
- // 创建新 tag(在同一个 commit 上)
471
- execSync(`git tag -a "${tagToUpdate}" -m "${newMessage}"`, {
472
- stdio: "pipe",
473
- });
474
- spinner.succeed(`Tag 已更新: ${tagToUpdate}`);
475
- } catch {
476
- spinner.fail("tag 更新失败");
495
+ execSync(`git tag -d "${oldTag}"`, { stdio: "pipe" });
496
+
497
+ spinner.succeed(`Tag 已重命名: ${oldTag} ${newTag}`);
498
+ } catch (error) {
499
+ spinner.fail("tag 重命名失败");
500
+ console.log(colors.red(String(error)));
477
501
  return;
478
502
  }
479
503
 
480
504
  const pushRemote = await select({
481
- message: "是否推送到远程(会强制覆盖)?",
505
+ message: "是否同步到远程?",
482
506
  choices: [
483
507
  { name: "是", value: true },
484
508
  { name: "否", value: false },
@@ -487,13 +511,16 @@ export async function updateTag(): Promise<void> {
487
511
  });
488
512
 
489
513
  if (pushRemote) {
490
- const pushSpinner = ora("正在推送到远程...").start();
514
+ const pushSpinner = ora("正在同步到远程...").start();
491
515
  try {
492
- execSync(`git push origin "${tagToUpdate}" --force`, { stdio: "pipe" });
493
- pushSpinner.succeed(`Tag 已推送: ${tagToUpdate}`);
516
+ // 推送新 tag
517
+ execSync(`git push origin "${newTag}"`, { stdio: "pipe" });
518
+ // 删除远程旧 tag
519
+ execSync(`git push origin --delete "${oldTag}"`, { stdio: "pipe" });
520
+ pushSpinner.succeed(`远程 tag 已同步: ${oldTag} → ${newTag}`);
494
521
  } catch {
495
522
  pushSpinner.warn(
496
- `远程推送失败,可稍后手动执行: git push origin ${tagToUpdate} --force`
523
+ `远程同步失败,可稍后手动执行:\n git push origin ${newTag}\n git push origin --delete ${oldTag}`
497
524
  );
498
525
  }
499
526
  }
@@ -1,8 +1,28 @@
1
1
  import { execSync } from "child_process";
2
2
  import ora from "ora";
3
3
  import boxen from "boxen";
4
+ import semver from "semver";
5
+ import { existsSync, unlinkSync } from "fs";
6
+ import { homedir } from "os";
7
+ import { join } from "path";
4
8
  import { colors } from "../utils.js";
5
9
 
10
+ const CACHE_FILE = ".gw-update-check";
11
+
12
+ /**
13
+ * 清理更新缓存文件
14
+ */
15
+ function clearUpdateCache(): void {
16
+ try {
17
+ const cacheFile = join(homedir(), CACHE_FILE);
18
+ if (existsSync(cacheFile)) {
19
+ unlinkSync(cacheFile);
20
+ }
21
+ } catch {
22
+ // 静默失败
23
+ }
24
+ }
25
+
6
26
  /**
7
27
  * 获取 npm 上的最新版本
8
28
  */
@@ -42,11 +62,12 @@ export async function update(currentVersion: string): Promise<void> {
42
62
 
43
63
  spinner.stop();
44
64
 
45
- if (latestVersion === currentVersion) {
65
+ // 使用 semver 比较版本
66
+ if (semver.gte(currentVersion, latestVersion)) {
46
67
  console.log(
47
68
  boxen(
48
69
  [
49
- colors.bold("✅ 已是最新版本"),
70
+ colors.green(colors.bold("✅ 已是最新版本")),
50
71
  "",
51
72
  `当前版本: ${colors.green(currentVersion)}`,
52
73
  ].join("\n"),
@@ -63,21 +84,30 @@ export async function update(currentVersion: string): Promise<void> {
63
84
  }
64
85
 
65
86
  // 有新版本
87
+ const versionText = `${currentVersion} → ${latestVersion}`;
88
+ const maxWidth = Math.max(
89
+ "🎉 发现新版本!".length,
90
+ versionText.length,
91
+ "✨ 更新完成!".length,
92
+ "请重新打开终端使用新版本".length
93
+ );
94
+
66
95
  console.log(
67
96
  boxen(
68
97
  [
69
- colors.bold("🎉 发现新版本!"),
98
+ colors.yellow(colors.bold("🎉 发现新版本!")),
70
99
  "",
71
100
  `${colors.dim(currentVersion)} → ${colors.green(
72
101
  colors.bold(latestVersion)
73
102
  )}`,
74
103
  ].join("\n"),
75
104
  {
76
- padding: 1,
105
+ padding: { top: 1, bottom: 1, left: 3, right: 3 },
77
106
  margin: { top: 0, bottom: 1, left: 2, right: 2 },
78
107
  borderStyle: "round",
79
108
  borderColor: "yellow",
80
- align: "left",
109
+ align: "center",
110
+ width: 40,
81
111
  }
82
112
  )
83
113
  );
@@ -91,20 +121,25 @@ export async function update(currentVersion: string): Promise<void> {
91
121
  });
92
122
 
93
123
  updateSpinner.succeed(colors.green("更新成功!"));
124
+
125
+ // 清理缓存文件
126
+ clearUpdateCache();
127
+
94
128
  console.log("");
95
129
  console.log(
96
130
  boxen(
97
131
  [
98
- colors.bold("✨ 更新完成!"),
132
+ colors.green(colors.bold("✨ 更新完成!")),
99
133
  "",
100
134
  colors.dim("请重新打开终端使用新版本"),
101
135
  ].join("\n"),
102
136
  {
103
- padding: 1,
137
+ padding: { top: 1, bottom: 1, left: 3, right: 3 },
104
138
  margin: { top: 0, bottom: 1, left: 2, right: 2 },
105
139
  borderStyle: "round",
106
140
  borderColor: "green",
107
- align: "left",
141
+ align: "center",
142
+ width: 40,
108
143
  }
109
144
  )
110
145
  );