@zjex/git-workflow 0.3.10 → 0.4.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.
@@ -1,6 +1,7 @@
1
- import { execSync } from "child_process";
1
+ import { execSync, spawn } from "child_process";
2
2
  import { select, input } from "@inquirer/prompts";
3
3
  import ora from "ora";
4
+ import boxen from "boxen";
4
5
  import {
5
6
  colors,
6
7
  theme,
@@ -222,19 +223,187 @@ function applyStash(index: number, pop: boolean): void {
222
223
 
223
224
  async function showDiff(index: number): Promise<void> {
224
225
  try {
225
- execSync(`git stash show -p --color=always stash@{${index}}`, {
226
- stdio: "inherit",
227
- });
228
- console.log();
226
+ // 获取差异内容(不使用颜色,我们自己格式化)
227
+ const diffOutput = execOutput(
228
+ `git stash show -p --no-color stash@{${index}}`
229
+ );
230
+
231
+ if (!diffOutput) {
232
+ console.log(colors.yellow("没有差异内容"));
233
+ await input({
234
+ message: colors.dim("按 Enter 返回菜单..."),
235
+ theme,
236
+ });
237
+ return;
238
+ }
239
+
240
+ // 获取统计信息
241
+ const statsOutput = execOutput(`git stash show --stat stash@{${index}}`);
242
+
243
+ // 解析差异内容,按文件分组
244
+ const files = parseDiffByFile(diffOutput);
245
+
246
+ // 构建完整输出
247
+ let fullOutput = "";
248
+
249
+ // 添加统计信息
250
+ if (statsOutput) {
251
+ const statsBox = boxen(statsOutput, {
252
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
253
+ margin: { top: 0, bottom: 1, left: 0, right: 0 },
254
+ borderStyle: "double",
255
+ borderColor: "yellow",
256
+ title: `📊 Stash #${index} 统计`,
257
+ titleAlignment: "center",
258
+ });
259
+ fullOutput += statsBox + "\n";
260
+ }
261
+
262
+ // 为每个文件创建边框
263
+ for (const file of files) {
264
+ const fileContent = formatFileDiff(file);
265
+ const fileBox = boxen(fileContent, {
266
+ padding: { top: 0, bottom: 0, left: 1, right: 1 },
267
+ margin: { top: 0, bottom: 1, left: 0, right: 0 },
268
+ borderStyle: "round",
269
+ borderColor: "cyan",
270
+ title: `📄 ${file.path}`,
271
+ titleAlignment: "left",
272
+ });
273
+ fullOutput += fileBox + "\n";
274
+ }
275
+
276
+ // 使用 less 分页器显示,等待用户退出
277
+ await startPager(fullOutput);
278
+ } catch (error) {
279
+ console.log(colors.red("无法显示差异"));
229
280
  await input({
230
281
  message: colors.dim("按 Enter 返回菜单..."),
231
282
  theme,
232
283
  });
233
- } catch {
234
- console.log(colors.red("无法显示差异"));
235
284
  }
236
285
  }
237
286
 
287
+ /**
288
+ * 解析差异内容,按文件分组
289
+ */
290
+ interface FileDiff {
291
+ path: string;
292
+ lines: string[];
293
+ }
294
+
295
+ function parseDiffByFile(diffOutput: string): FileDiff[] {
296
+ const files: FileDiff[] = [];
297
+ const lines = diffOutput.split("\n");
298
+ let currentFile: FileDiff | null = null;
299
+
300
+ for (const line of lines) {
301
+ // 检测文件头
302
+ if (line.startsWith("diff --git")) {
303
+ // 保存上一个文件
304
+ if (currentFile && currentFile.lines.length > 0) {
305
+ files.push(currentFile);
306
+ }
307
+
308
+ // 提取文件路径
309
+ const match = line.match(/diff --git a\/(.*?) b\/(.*?)$/);
310
+ const path = match ? match[2] : "unknown";
311
+
312
+ currentFile = { path, lines: [] };
313
+ } else if (currentFile) {
314
+ // 跳过 index 和 --- +++ 行
315
+ if (
316
+ line.startsWith("index ") ||
317
+ line.startsWith("--- ") ||
318
+ line.startsWith("+++ ")
319
+ ) {
320
+ continue;
321
+ }
322
+
323
+ currentFile.lines.push(line);
324
+ }
325
+ }
326
+
327
+ // 保存最后一个文件
328
+ if (currentFile && currentFile.lines.length > 0) {
329
+ files.push(currentFile);
330
+ }
331
+
332
+ return files;
333
+ }
334
+
335
+ /**
336
+ * 格式化文件差异内容
337
+ */
338
+ function formatFileDiff(file: FileDiff): string {
339
+ const formattedLines: string[] = [];
340
+
341
+ for (const line of file.lines) {
342
+ if (line.startsWith("@@")) {
343
+ // 位置信息 - 使用蓝色
344
+ formattedLines.push(colors.blue(line));
345
+ } else if (line.startsWith("+")) {
346
+ // 新增行 - 使用绿色
347
+ formattedLines.push(colors.green(line));
348
+ } else if (line.startsWith("-")) {
349
+ // 删除行 - 使用红色
350
+ formattedLines.push(colors.red(line));
351
+ } else {
352
+ // 上下文行 - 使用灰色
353
+ formattedLines.push(colors.dim(line));
354
+ }
355
+ }
356
+
357
+ return formattedLines.join("\n");
358
+ }
359
+
360
+ /**
361
+ * 启动分页器显示内容
362
+ */
363
+ function startPager(content: string): Promise<void> {
364
+ return new Promise((resolve) => {
365
+ const pager = process.env.PAGER || "less";
366
+
367
+ try {
368
+ // -R: 支持ANSI颜色代码
369
+ // -S: 不换行长行
370
+ // -F: 如果内容少于一屏则直接退出
371
+ // -X: 不清屏
372
+ // -i: 忽略大小写搜索
373
+ const pagerProcess = spawn(pager, ["-R", "-S", "-F", "-X", "-i"], {
374
+ stdio: ["pipe", "inherit", "inherit"],
375
+ env: { ...process.env, LESS: "-R -S -F -X -i" },
376
+ });
377
+
378
+ // 处理 stdin 的 EPIPE 错误(当 less 提前退出时)
379
+ pagerProcess.stdin.on("error", (err: NodeJS.ErrnoException) => {
380
+ if (err.code !== "EPIPE") {
381
+ console.error(err);
382
+ }
383
+ });
384
+
385
+ // 将内容写入分页器
386
+ pagerProcess.stdin.write(content);
387
+ pagerProcess.stdin.end();
388
+
389
+ // 等待分页器退出后返回菜单
390
+ pagerProcess.on("exit", () => {
391
+ resolve();
392
+ });
393
+
394
+ // 处理错误
395
+ pagerProcess.on("error", () => {
396
+ console.log(content);
397
+ resolve();
398
+ });
399
+ } catch (error) {
400
+ // 如果出错,直接输出内容
401
+ console.log(content);
402
+ resolve();
403
+ }
404
+ });
405
+ }
406
+
238
407
  async function createBranchFromStash(index: number): Promise<void> {
239
408
  const type = await select({
240
409
  message: "选择分支类型:",
@@ -624,3 +624,123 @@ export async function updateTag(): Promise<void> {
624
624
  }
625
625
  }
626
626
  }
627
+
628
+ /**
629
+ * 清理无效标签(不包含数字的标签)
630
+ */
631
+ export async function cleanInvalidTags(): Promise<void> {
632
+ const fetchSpinner = ora("正在获取 tags...").start();
633
+ exec("git fetch --tags", true);
634
+ fetchSpinner.stop();
635
+
636
+ divider();
637
+
638
+ // 获取所有标签
639
+ const allTags = execOutput("git tag -l").split("\n").filter(Boolean);
640
+
641
+ // 过滤出无效标签(不包含数字)
642
+ const invalidTags = allTags.filter((tag) => !/\d/.test(tag));
643
+
644
+ if (invalidTags.length === 0) {
645
+ console.log(colors.green("✅ 没有找到无效标签"));
646
+ return;
647
+ }
648
+
649
+ console.log(colors.yellow(`❌ 找到 ${invalidTags.length} 个无效标签:`));
650
+ console.log("");
651
+
652
+ // 显示每个无效标签的详细信息
653
+ for (const tag of invalidTags) {
654
+ try {
655
+ const commitHash = execOutput(`git rev-list -n 1 "${tag}"`).trim();
656
+ const commitDate = execOutput(`git log -1 --format=%ai "${tag}"`).trim();
657
+ const commitMsg = execOutput(`git log -1 --format=%s "${tag}"`).trim();
658
+
659
+ console.log(colors.red(` 标签: ${tag}`));
660
+ console.log(colors.dim(` Commit: ${commitHash}`));
661
+ console.log(colors.dim(` 日期: ${commitDate}`));
662
+ console.log(colors.dim(` 消息: ${commitMsg}`));
663
+ console.log("");
664
+ } catch {
665
+ console.log(colors.red(` 标签: ${tag}`));
666
+ console.log(colors.dim(` (无法获取提交信息)`));
667
+ console.log("");
668
+ }
669
+ }
670
+
671
+ divider();
672
+
673
+ const shouldClean = await select({
674
+ message: "是否删除这些无效标签?",
675
+ choices: [
676
+ { name: "是,删除所有无效标签", value: true },
677
+ { name: "否,取消操作", value: false },
678
+ ],
679
+ theme,
680
+ });
681
+
682
+ if (!shouldClean) {
683
+ console.log(colors.yellow("已取消"));
684
+ return;
685
+ }
686
+
687
+ divider();
688
+
689
+ // 删除本地标签
690
+ const localSpinner = ora("正在删除本地无效标签...").start();
691
+ let localSuccess = 0;
692
+ let localFailed = 0;
693
+
694
+ for (const tag of invalidTags) {
695
+ try {
696
+ execSync(`git tag -d "${tag}"`, { stdio: "pipe" });
697
+ localSuccess++;
698
+ } catch {
699
+ localFailed++;
700
+ }
701
+ }
702
+
703
+ if (localFailed === 0) {
704
+ localSpinner.succeed(`本地标签已删除: ${localSuccess} 个`);
705
+ } else {
706
+ localSpinner.warn(
707
+ `本地标签删除: 成功 ${localSuccess} 个,失败 ${localFailed} 个`
708
+ );
709
+ }
710
+
711
+ // 询问是否删除远程标签
712
+ const deleteRemote = await select({
713
+ message: "是否同时删除远程的无效标签?",
714
+ choices: [
715
+ { name: "是", value: true },
716
+ { name: "否", value: false },
717
+ ],
718
+ theme,
719
+ });
720
+
721
+ if (deleteRemote) {
722
+ const remoteSpinner = ora("正在删除远程无效标签...").start();
723
+ let remoteSuccess = 0;
724
+ let remoteFailed = 0;
725
+
726
+ for (const tag of invalidTags) {
727
+ try {
728
+ execSync(`git push origin --delete "${tag}"`, { stdio: "pipe" });
729
+ remoteSuccess++;
730
+ } catch {
731
+ remoteFailed++;
732
+ }
733
+ }
734
+
735
+ if (remoteFailed === 0) {
736
+ remoteSpinner.succeed(`远程标签已删除: ${remoteSuccess} 个`);
737
+ } else {
738
+ remoteSpinner.warn(
739
+ `远程标签删除: 成功 ${remoteSuccess} 个,失败 ${remoteFailed} 个`
740
+ );
741
+ }
742
+ }
743
+
744
+ console.log("");
745
+ console.log(colors.green("✨ 清理完成!"));
746
+ }
package/src/index.ts CHANGED
@@ -15,7 +15,13 @@ import { select } from "@inquirer/prompts";
15
15
  import { ExitPromptError } from "@inquirer/core";
16
16
  import { checkGitRepo, theme, colors } from "./utils.js";
17
17
  import { createBranch, deleteBranch } from "./commands/branch.js";
18
- import { listTags, createTag, deleteTag, updateTag } from "./commands/tag.js";
18
+ import {
19
+ listTags,
20
+ createTag,
21
+ deleteTag,
22
+ updateTag,
23
+ cleanInvalidTags,
24
+ } from "./commands/tag.js";
19
25
  import { release } from "./commands/release.js";
20
26
  import { init } from "./commands/init.js";
21
27
  import { stash } from "./commands/stash.js";
@@ -119,7 +125,7 @@ async function mainMenu(): Promise<void> {
119
125
  value: "hotfix",
120
126
  },
121
127
  {
122
- name: `[3] 🗑️ 删除分支 ${colors.dim("gw d")}`,
128
+ name: `[3] 🗑️ 删除分支 ${colors.dim("gw brd")}`,
123
129
  value: "delete",
124
130
  },
125
131
  {
@@ -143,15 +149,15 @@ async function mainMenu(): Promise<void> {
143
149
  value: "tags",
144
150
  },
145
151
  {
146
- name: `[9] 📦 发布版本 ${colors.dim("gw r")}`,
152
+ name: `[9] 发布版本 ${colors.dim("gw r")}`,
147
153
  value: "release",
148
154
  },
149
155
  {
150
- name: `[a] 💾 管理 stash ${colors.dim("gw s")}`,
156
+ name: `[a] 管理 stash ${colors.dim("gw s")}`,
151
157
  value: "stash",
152
158
  },
153
159
  {
154
- name: `[b] 📊 查看日志 ${colors.dim("gw log")}`,
160
+ name: `[b] 查看日志 ${colors.dim("gw log")}`,
155
161
  value: "log",
156
162
  },
157
163
  {
@@ -263,9 +269,8 @@ cli
263
269
  });
264
270
 
265
271
  cli
266
- .command("delete [branch]", "删除本地/远程分支")
267
- .alias("del")
268
- .alias("d")
272
+ .command("br:del [branch]", "删除本地/远程分支")
273
+ .alias("brd")
269
274
  .action(async (branch?: string) => {
270
275
  await checkForUpdates(version, "@zjex/git-workflow");
271
276
  checkGitRepo();
@@ -291,7 +296,7 @@ cli
291
296
  });
292
297
 
293
298
  cli
294
- .command("tag:delete", "删除 tag")
299
+ .command("tag:del", "删除 tag")
295
300
  .alias("td")
296
301
  .action(async () => {
297
302
  await checkForUpdates(version, "@zjex/git-workflow");
@@ -308,6 +313,15 @@ cli
308
313
  return updateTag();
309
314
  });
310
315
 
316
+ cli
317
+ .command("tag:clean", "清理无效 tag")
318
+ .alias("tc")
319
+ .action(async () => {
320
+ await checkForUpdates(version, "@zjex/git-workflow");
321
+ checkGitRepo();
322
+ return cleanInvalidTags();
323
+ });
324
+
311
325
  cli
312
326
  .command("release", "交互式选择版本号并更新 package.json")
313
327
  .alias("r")
@@ -364,77 +378,80 @@ cli
364
378
  return log(logOptions);
365
379
  });
366
380
 
367
- cli.command("clean", "清理缓存和临时文件").action(async () => {
368
- const { clearUpdateCache } = await import("./update-notifier.js");
369
- const { existsSync, unlinkSync, readdirSync } = await import("fs");
370
- const { homedir, tmpdir } = await import("os");
371
- const { join } = await import("path");
372
- const { select } = await import("@inquirer/prompts");
373
-
374
- let cleanedCount = 0;
375
- let deletedGlobalConfig = false;
376
-
377
- // 检查全局配置文件是否存在
378
- const globalConfig = join(homedir(), ".gwrc.json");
379
- const hasGlobalConfig = existsSync(globalConfig);
380
-
381
- // 如果有全局配置文件,询问是否删除
382
- if (hasGlobalConfig) {
383
- const shouldDeleteConfig = await select({
384
- message: "检测到全局配置文件,是否删除?",
385
- choices: [
386
- { name: "否,保留配置文件", value: false },
387
- { name: "是,删除配置文件", value: true },
388
- ],
389
- theme,
390
- });
391
-
392
- if (shouldDeleteConfig) {
393
- try {
394
- unlinkSync(globalConfig);
395
- cleanedCount++;
396
- deletedGlobalConfig = true;
397
- } catch {
398
- // 静默失败
381
+ cli
382
+ .command("clean", "清理缓存和临时文件")
383
+ .alias("cc")
384
+ .action(async () => {
385
+ const { clearUpdateCache } = await import("./update-notifier.js");
386
+ const { existsSync, unlinkSync, readdirSync } = await import("fs");
387
+ const { homedir, tmpdir } = await import("os");
388
+ const { join } = await import("path");
389
+ const { select } = await import("@inquirer/prompts");
390
+
391
+ let cleanedCount = 0;
392
+ let deletedGlobalConfig = false;
393
+
394
+ // 检查全局配置文件是否存在
395
+ const globalConfig = join(homedir(), ".gwrc.json");
396
+ const hasGlobalConfig = existsSync(globalConfig);
397
+
398
+ // 如果有全局配置文件,询问是否删除
399
+ if (hasGlobalConfig) {
400
+ const shouldDeleteConfig = await select({
401
+ message: "检测到全局配置文件,是否删除?",
402
+ choices: [
403
+ { name: "否,保留配置文件", value: false },
404
+ { name: "是,删除配置文件", value: true },
405
+ ],
406
+ theme,
407
+ });
408
+
409
+ if (shouldDeleteConfig) {
410
+ try {
411
+ unlinkSync(globalConfig);
412
+ cleanedCount++;
413
+ deletedGlobalConfig = true;
414
+ } catch {
415
+ // 静默失败
416
+ }
399
417
  }
400
418
  }
401
- }
402
419
 
403
- // 1. 清理更新缓存
404
- clearUpdateCache();
405
- cleanedCount++;
406
-
407
- // 2. 清理临时 commit 消息文件
408
- try {
409
- const tmpDir = tmpdir();
410
- const files = readdirSync(tmpDir);
411
- const gwTmpFiles = files.filter((f) => f.startsWith(".gw-commit-msg-"));
412
-
413
- for (const file of gwTmpFiles) {
414
- try {
415
- unlinkSync(join(tmpDir, file));
416
- cleanedCount++;
417
- } catch {
418
- // 静默失败
420
+ // 1. 清理更新缓存
421
+ clearUpdateCache();
422
+ cleanedCount++;
423
+
424
+ // 2. 清理临时 commit 消息文件
425
+ try {
426
+ const tmpDir = tmpdir();
427
+ const files = readdirSync(tmpDir);
428
+ const gwTmpFiles = files.filter((f) => f.startsWith(".gw-commit-msg-"));
429
+
430
+ for (const file of gwTmpFiles) {
431
+ try {
432
+ unlinkSync(join(tmpDir, file));
433
+ cleanedCount++;
434
+ } catch {
435
+ // 静默失败
436
+ }
419
437
  }
438
+ } catch {
439
+ // 静默失败
420
440
  }
421
- } catch {
422
- // 静默失败
423
- }
424
-
425
- console.log("");
426
- console.log(colors.green(`✔ 已清理 ${cleanedCount} 个文件`));
427
441
 
428
- if (deletedGlobalConfig) {
429
442
  console.log("");
430
- console.log(colors.yellow("⚠️ 全局配置文件已删除"));
431
- console.log(
432
- colors.dim(` 如需重新配置,请运行: ${colors.cyan("gw init")}`)
433
- );
434
- }
443
+ console.log(colors.green(`✔ 已清理 ${cleanedCount} 个文件`));
444
+
445
+ if (deletedGlobalConfig) {
446
+ console.log("");
447
+ console.log(colors.yellow("⚠️ 全局配置文件已删除"));
448
+ console.log(
449
+ colors.dim(` 如需重新配置,请运行: ${colors.cyan("gw init")}`)
450
+ );
451
+ }
435
452
 
436
- console.log("");
437
- });
453
+ console.log("");
454
+ });
438
455
 
439
456
  // 不使用 cac 的 version,手动处理 --version 和 --help
440
457
  cli.option("-v, --version", "显示版本号");
@@ -181,7 +181,7 @@ async function showUpdateMessage(
181
181
  console.log(
182
182
  boxen(message, {
183
183
  padding: { top: 1, bottom: 1, left: 3, right: 3 },
184
- margin: 1,
184
+ margin: { top: 0, bottom: 0, left: 1, right: 1 },
185
185
  borderStyle: "round",
186
186
  borderColor: "yellow",
187
187
  align: "center",