@zjex/git-workflow 0.4.5 → 0.4.7

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/src/utils.ts CHANGED
@@ -1,6 +1,25 @@
1
1
  import { execSync, spawn, type ExecSyncOptions } from "child_process";
2
2
  import type { Ora } from "ora";
3
3
 
4
+ /**
5
+ * 全局 debug 模式标志
6
+ */
7
+ let debugMode = false;
8
+
9
+ /**
10
+ * 设置 debug 模式(由主入口调用)
11
+ */
12
+ export function setDebugMode(enabled: boolean): void {
13
+ debugMode = enabled;
14
+ }
15
+
16
+ /**
17
+ * 获取当前 debug 模式状态
18
+ */
19
+ export function isDebugMode(): boolean {
20
+ return debugMode;
21
+ }
22
+
4
23
  export interface Colors {
5
24
  red: (s: string) => string;
6
25
  green: (s: string) => string;
@@ -104,22 +123,70 @@ export function divider(): void {
104
123
  * 使用 spawn 异步执行命令,避免阻塞 spinner
105
124
  * @param command 命令字符串
106
125
  * @param spinner 可选的 ora spinner 实例
107
- * @returns Promise<boolean> 成功返回 true,失败返回 false
126
+ * @returns Promise<{success: boolean, error?: string}> 返回执行结果和错误信息
108
127
  */
109
- export function execAsync(command: string, spinner?: Ora): Promise<boolean> {
128
+ export function execAsync(
129
+ command: string,
130
+ spinner?: Ora,
131
+ ): Promise<{ success: boolean; error?: string }> {
110
132
  return new Promise((resolve) => {
111
- const [cmd, ...args] = command.split(" ");
133
+ // Debug 模式:显示执行的命令
134
+ if (debugMode) {
135
+ console.log(colors.dim(`\n[DEBUG] 执行命令: ${colors.cyan(command)}`));
136
+ }
112
137
 
113
- const process = spawn(cmd, args, {
138
+ // 使用 shell 模式执行命令,这样可以正确处理引号
139
+ const process = spawn(command, {
114
140
  stdio: spinner ? "pipe" : "inherit",
141
+ shell: true,
115
142
  });
116
143
 
144
+ let errorOutput = "";
145
+ let stdoutOutput = "";
146
+
147
+ // 捕获标准输出(debug 模式)
148
+ if (debugMode && process.stdout) {
149
+ process.stdout.on("data", (data) => {
150
+ stdoutOutput += data.toString();
151
+ });
152
+ }
153
+
154
+ // 捕获错误输出
155
+ if (process.stderr) {
156
+ process.stderr.on("data", (data) => {
157
+ errorOutput += data.toString();
158
+ });
159
+ }
160
+
117
161
  process.on("close", (code) => {
118
- resolve(code === 0);
162
+ // Debug 模式:显示退出码和输出
163
+ if (debugMode) {
164
+ console.log(colors.dim(`[DEBUG] 退出码: ${code}`));
165
+ if (stdoutOutput) {
166
+ console.log(colors.dim(`[DEBUG] 标准输出:\n${stdoutOutput}`));
167
+ }
168
+ if (errorOutput) {
169
+ // 根据退出码决定标签:成功时显示"输出信息",失败时显示"错误输出"
170
+ const label = code === 0 ? "输出信息" : "错误输出";
171
+ console.log(colors.dim(`[DEBUG] ${label}:\n${errorOutput}`));
172
+ }
173
+ }
174
+
175
+ if (code === 0) {
176
+ resolve({ success: true });
177
+ } else {
178
+ resolve({ success: false, error: errorOutput.trim() });
179
+ }
119
180
  });
120
181
 
121
- process.on("error", () => {
122
- resolve(false);
182
+ process.on("error", (err) => {
183
+ // Debug 模式:显示进程错误
184
+ if (debugMode) {
185
+ console.log(colors.dim(`[DEBUG] 进程错误: ${err.message}`));
186
+ console.log(colors.dim(`[DEBUG] 错误堆栈:\n${err.stack}`));
187
+ }
188
+
189
+ resolve({ success: false, error: err.message });
123
190
  });
124
191
  });
125
192
  }
@@ -138,9 +205,9 @@ export async function execWithSpinner(
138
205
  successMessage?: string,
139
206
  errorMessage?: string,
140
207
  ): Promise<boolean> {
141
- const success = await execAsync(command, spinner);
208
+ const result = await execAsync(command, spinner);
142
209
 
143
- if (success) {
210
+ if (result.success) {
144
211
  if (successMessage) {
145
212
  spinner.succeed(successMessage);
146
213
  } else {
@@ -152,7 +219,23 @@ export async function execWithSpinner(
152
219
  } else {
153
220
  spinner.fail();
154
221
  }
222
+
223
+ // 显示具体的错误信息
224
+ if (result.error) {
225
+ console.log(colors.dim(` ${result.error}`));
226
+ }
227
+
228
+ // Debug 模式:显示完整的命令和建议
229
+ if (debugMode) {
230
+ console.log(colors.yellow("\n[DEBUG] 故障排查信息:"));
231
+ console.log(colors.dim(` 命令: ${command}`));
232
+ console.log(colors.dim(` 工作目录: ${process.cwd()}`));
233
+ console.log(colors.dim(` Shell: ${process.env.SHELL || "unknown"}`));
234
+ console.log(
235
+ colors.dim(` 建议: 尝试在终端中直接运行上述命令以获取更多信息\n`),
236
+ );
237
+ }
155
238
  }
156
239
 
157
- return success;
240
+ return result.success;
158
241
  }
@@ -0,0 +1,378 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { spawn } from "child_process";
3
+
4
+ // Mock child_process
5
+ vi.mock("child_process", () => ({
6
+ spawn: vi.fn(),
7
+ execSync: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("ora", () => ({
11
+ default: vi.fn(() => ({
12
+ start: vi.fn().mockReturnThis(),
13
+ stop: vi.fn().mockReturnThis(),
14
+ succeed: vi.fn().mockReturnThis(),
15
+ fail: vi.fn().mockReturnThis(),
16
+ warn: vi.fn().mockReturnThis(),
17
+ })),
18
+ }));
19
+
20
+ describe("带引号的命令参数测试", () => {
21
+ beforeEach(() => {
22
+ vi.clearAllMocks();
23
+ });
24
+
25
+ afterEach(() => {
26
+ vi.restoreAllMocks();
27
+ });
28
+
29
+ describe("execAsync 函数", () => {
30
+ it("应该正确处理带引号的 tag 名称", async () => {
31
+ const mockSpawn = vi.mocked(spawn);
32
+ mockSpawn.mockImplementation(((command: string, options: any) => {
33
+ // 验证使用了 shell 模式
34
+ expect(options.shell).toBe(true);
35
+
36
+ // 验证命令字符串包含正确的引号
37
+ expect(command).toContain('"v1.5.3"');
38
+
39
+ return {
40
+ stderr: {
41
+ on: vi.fn(),
42
+ },
43
+ on: vi.fn((event: string, callback: (code: number) => void) => {
44
+ if (event === "close") {
45
+ setTimeout(() => callback(0), 0);
46
+ }
47
+ }),
48
+ } as any;
49
+ }) as any);
50
+
51
+ const { execAsync } = await import("../src/utils.js");
52
+ const result = await execAsync('git tag -a "v1.5.3" -m "Release v1.5.3"');
53
+
54
+ expect(result.success).toBe(true);
55
+ expect(mockSpawn).toHaveBeenCalledWith(
56
+ 'git tag -a "v1.5.3" -m "Release v1.5.3"',
57
+ expect.objectContaining({
58
+ shell: true,
59
+ }),
60
+ );
61
+ });
62
+
63
+ it("应该正确处理带空格的分支名称", async () => {
64
+ const mockSpawn = vi.mocked(spawn);
65
+ mockSpawn.mockImplementation(((command: string, options: any) => {
66
+ expect(options.shell).toBe(true);
67
+ expect(command).toContain('"feature/my branch"');
68
+
69
+ return {
70
+ stderr: {
71
+ on: vi.fn(),
72
+ },
73
+ on: vi.fn((event: string, callback: (code: number) => void) => {
74
+ if (event === "close") {
75
+ setTimeout(() => callback(0), 0);
76
+ }
77
+ }),
78
+ } as any;
79
+ }) as any);
80
+
81
+ const { execAsync } = await import("../src/utils.js");
82
+ const result = await execAsync('git push -u origin "feature/my branch"');
83
+
84
+ expect(result.success).toBe(true);
85
+ });
86
+
87
+ it("应该正确处理带特殊字符的 commit message", async () => {
88
+ const mockSpawn = vi.mocked(spawn);
89
+ mockSpawn.mockImplementation(((command: string, options: any) => {
90
+ expect(options.shell).toBe(true);
91
+ // 验证特殊字符被正确转义(在命令字符串中是转义的)
92
+ expect(command).toContain('feat: add \\"quotes\\" support');
93
+
94
+ return {
95
+ stderr: {
96
+ on: vi.fn(),
97
+ },
98
+ on: vi.fn((event: string, callback: (code: number) => void) => {
99
+ if (event === "close") {
100
+ setTimeout(() => callback(0), 0);
101
+ }
102
+ }),
103
+ } as any;
104
+ }) as any);
105
+
106
+ const { execAsync } = await import("../src/utils.js");
107
+ const result = await execAsync(
108
+ 'git commit -m "feat: add \\"quotes\\" support"',
109
+ );
110
+
111
+ expect(result.success).toBe(true);
112
+ });
113
+
114
+ it("应该正确处理带 emoji 的 tag 名称", async () => {
115
+ const mockSpawn = vi.mocked(spawn);
116
+ mockSpawn.mockImplementation(((command: string, options: any) => {
117
+ expect(options.shell).toBe(true);
118
+ expect(command).toContain('"v1.0.0-🎉"');
119
+
120
+ return {
121
+ stderr: {
122
+ on: vi.fn(),
123
+ },
124
+ on: vi.fn((event: string, callback: (code: number) => void) => {
125
+ if (event === "close") {
126
+ setTimeout(() => callback(0), 0);
127
+ }
128
+ }),
129
+ } as any;
130
+ }) as any);
131
+
132
+ const { execAsync } = await import("../src/utils.js");
133
+ const result = await execAsync('git tag -a "v1.0.0-🎉" -m "Release 🎉"');
134
+
135
+ expect(result.success).toBe(true);
136
+ });
137
+
138
+ it("应该正确处理带中文的 stash message", async () => {
139
+ const mockSpawn = vi.mocked(spawn);
140
+ mockSpawn.mockImplementation(((command: string, options: any) => {
141
+ expect(options.shell).toBe(true);
142
+ expect(command).toContain('"临时保存:修复bug"');
143
+
144
+ return {
145
+ stderr: {
146
+ on: vi.fn(),
147
+ },
148
+ on: vi.fn((event: string, callback: (code: number) => void) => {
149
+ if (event === "close") {
150
+ setTimeout(() => callback(0), 0);
151
+ }
152
+ }),
153
+ } as any;
154
+ }) as any);
155
+
156
+ const { execAsync } = await import("../src/utils.js");
157
+ const result = await execAsync('git stash push -m "临时保存:修复bug"');
158
+
159
+ expect(result.success).toBe(true);
160
+ });
161
+
162
+ it("应该正确捕获错误信息", async () => {
163
+ const mockSpawn = vi.mocked(spawn);
164
+ const errorMessage = "fatal: tag 'v1.5.3' already exists";
165
+
166
+ mockSpawn.mockImplementation((() => {
167
+ return {
168
+ stderr: {
169
+ on: vi.fn((event: string, callback: (data: Buffer) => void) => {
170
+ if (event === "data") {
171
+ setTimeout(() => callback(Buffer.from(errorMessage)), 0);
172
+ }
173
+ }),
174
+ },
175
+ on: vi.fn((event: string, callback: (code: number) => void) => {
176
+ if (event === "close") {
177
+ setTimeout(() => callback(1), 10);
178
+ }
179
+ }),
180
+ } as any;
181
+ }) as any);
182
+
183
+ const { execAsync } = await import("../src/utils.js");
184
+ const result = await execAsync('git tag -a "v1.5.3" -m "Release v1.5.3"');
185
+
186
+ expect(result.success).toBe(false);
187
+ expect(result.error).toBe(errorMessage);
188
+ });
189
+
190
+ it("应该处理命令执行错误", async () => {
191
+ const mockSpawn = vi.mocked(spawn);
192
+
193
+ mockSpawn.mockImplementation((() => {
194
+ return {
195
+ stderr: {
196
+ on: vi.fn(),
197
+ },
198
+ on: vi.fn((event: string, callback: (error?: Error) => void) => {
199
+ if (event === "error") {
200
+ setTimeout(() => callback(new Error("Command not found")), 0);
201
+ }
202
+ }),
203
+ } as any;
204
+ }) as any);
205
+
206
+ const { execAsync } = await import("../src/utils.js");
207
+ const result = await execAsync("invalid-command");
208
+
209
+ expect(result.success).toBe(false);
210
+ expect(result.error).toBe("Command not found");
211
+ });
212
+ });
213
+
214
+ describe("execWithSpinner 函数", () => {
215
+ it("应该在成功时显示成功消息", async () => {
216
+ const mockSpawn = vi.mocked(spawn);
217
+ mockSpawn.mockImplementation((() => {
218
+ return {
219
+ stderr: {
220
+ on: vi.fn(),
221
+ },
222
+ on: vi.fn((event: string, callback: (code: number) => void) => {
223
+ if (event === "close") {
224
+ setTimeout(() => callback(0), 0);
225
+ }
226
+ }),
227
+ } as any;
228
+ }) as any);
229
+
230
+ const ora = await import("ora");
231
+ const mockSpinner = {
232
+ start: vi.fn().mockReturnThis(),
233
+ succeed: vi.fn().mockReturnThis(),
234
+ fail: vi.fn().mockReturnThis(),
235
+ };
236
+ vi.mocked(ora.default).mockReturnValue(mockSpinner as any);
237
+
238
+ const { execWithSpinner } = await import("../src/utils.js");
239
+ const result = await execWithSpinner(
240
+ 'git tag -a "v1.0.0" -m "Release"',
241
+ mockSpinner as any,
242
+ "Tag 创建成功",
243
+ "Tag 创建失败",
244
+ );
245
+
246
+ expect(result).toBe(true);
247
+ expect(mockSpinner.succeed).toHaveBeenCalledWith("Tag 创建成功");
248
+ });
249
+
250
+ it("应该在失败时显示错误消息和详细信息", async () => {
251
+ const mockSpawn = vi.mocked(spawn);
252
+ const errorMessage = "fatal: Failed to resolve 'HEAD' as a valid ref.";
253
+
254
+ mockSpawn.mockImplementation((() => {
255
+ return {
256
+ stderr: {
257
+ on: vi.fn((event: string, callback: (data: Buffer) => void) => {
258
+ if (event === "data") {
259
+ setTimeout(() => callback(Buffer.from(errorMessage)), 0);
260
+ }
261
+ }),
262
+ },
263
+ on: vi.fn((event: string, callback: (code: number) => void) => {
264
+ if (event === "close") {
265
+ setTimeout(() => callback(1), 10);
266
+ }
267
+ }),
268
+ } as any;
269
+ }) as any);
270
+
271
+ const ora = await import("ora");
272
+ const mockSpinner = {
273
+ start: vi.fn().mockReturnThis(),
274
+ succeed: vi.fn().mockReturnThis(),
275
+ fail: vi.fn().mockReturnThis(),
276
+ };
277
+ vi.mocked(ora.default).mockReturnValue(mockSpinner as any);
278
+
279
+ // Mock console.log
280
+ const consoleLogSpy = vi
281
+ .spyOn(console, "log")
282
+ .mockImplementation(() => {});
283
+
284
+ const { execWithSpinner } = await import("../src/utils.js");
285
+ const result = await execWithSpinner(
286
+ 'git tag -a "v1.0.0" -m "Release"',
287
+ mockSpinner as any,
288
+ "Tag 创建成功",
289
+ "Tag 创建失败",
290
+ );
291
+
292
+ expect(result).toBe(false);
293
+ expect(mockSpinner.fail).toHaveBeenCalledWith("Tag 创建失败");
294
+ expect(consoleLogSpy).toHaveBeenCalledWith(
295
+ expect.stringContaining(errorMessage),
296
+ );
297
+
298
+ consoleLogSpy.mockRestore();
299
+ });
300
+ });
301
+
302
+ describe("实际命令场景测试", () => {
303
+ it("tag 命令:创建带特殊字符的 tag", async () => {
304
+ const mockSpawn = vi.mocked(spawn);
305
+ mockSpawn.mockImplementation((() => {
306
+ return {
307
+ stderr: { on: vi.fn() },
308
+ on: vi.fn((event: string, callback: (code: number) => void) => {
309
+ if (event === "close") setTimeout(() => callback(0), 0);
310
+ }),
311
+ } as any;
312
+ }) as any);
313
+
314
+ const { execAsync } = await import("../src/utils.js");
315
+
316
+ // 测试各种特殊情况
317
+ const testCases = [
318
+ 'git tag -a "v1.0.0-beta.1" -m "Release v1.0.0-beta.1"',
319
+ 'git tag -a "v1.0.0-rc.1" -m "Release v1.0.0-rc.1"',
320
+ 'git tag -a "release/2024-01-20" -m "Release 2024-01-20"',
321
+ ];
322
+
323
+ for (const cmd of testCases) {
324
+ const result = await execAsync(cmd);
325
+ expect(result.success).toBe(true);
326
+ }
327
+ });
328
+
329
+ it("branch 命令:删除带特殊字符的分支", async () => {
330
+ const mockSpawn = vi.mocked(spawn);
331
+ mockSpawn.mockImplementation((() => {
332
+ return {
333
+ stderr: { on: vi.fn() },
334
+ on: vi.fn((event: string, callback: (code: number) => void) => {
335
+ if (event === "close") setTimeout(() => callback(0), 0);
336
+ }),
337
+ } as any;
338
+ }) as any);
339
+
340
+ const { execAsync } = await import("../src/utils.js");
341
+
342
+ const testCases = [
343
+ 'git branch -D "feature/20240120-123-add-feature"',
344
+ 'git push origin --delete "feature/20240120-123-add-feature"',
345
+ ];
346
+
347
+ for (const cmd of testCases) {
348
+ const result = await execAsync(cmd);
349
+ expect(result.success).toBe(true);
350
+ }
351
+ });
352
+
353
+ it("stash 命令:创建带特殊字符的 stash", async () => {
354
+ const mockSpawn = vi.mocked(spawn);
355
+ mockSpawn.mockImplementation((() => {
356
+ return {
357
+ stderr: { on: vi.fn() },
358
+ on: vi.fn((event: string, callback: (code: number) => void) => {
359
+ if (event === "close") setTimeout(() => callback(0), 0);
360
+ }),
361
+ } as any;
362
+ }) as any);
363
+
364
+ const { execAsync } = await import("../src/utils.js");
365
+
366
+ const testCases = [
367
+ 'git stash push -m "临时保存:修复登录bug"',
368
+ 'git stash push -m "WIP: 添加\\"新功能\\""',
369
+ 'git stash branch "feature/from-stash" stash@{0}',
370
+ ];
371
+
372
+ for (const cmd of testCases) {
373
+ const result = await execAsync(cmd);
374
+ expect(result.success).toBe(true);
375
+ }
376
+ });
377
+ });
378
+ });