@zjex/git-workflow 0.4.2 → 0.4.4

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,189 @@
1
+ import { execSync } from "child_process";
2
+ import { select, input, confirm } from "@inquirer/prompts";
3
+ import { colors, theme, execOutput, divider } from "../utils.js";
4
+
5
+ /**
6
+ * 获取最近的 commits
7
+ * @param limit 数量限制
8
+ * @returns commit 列表
9
+ */
10
+ function getRecentCommits(limit: number = 20): Array<{
11
+ hash: string;
12
+ message: string;
13
+ date: string;
14
+ }> {
15
+ const output = execOutput(`git log -${limit} --pretty=format:"%H|%s|%ai"`);
16
+
17
+ if (!output) return [];
18
+
19
+ return output.split("\n").map((line) => {
20
+ const [hash, message, date] = line.split("|");
21
+ return { hash, message, date };
22
+ });
23
+ }
24
+
25
+ /**
26
+ * 根据 hash 获取 commit 信息
27
+ * @param hash commit hash (可以是短 hash)
28
+ * @returns commit 信息或 null
29
+ */
30
+ function getCommitByHash(hash: string): {
31
+ hash: string;
32
+ message: string;
33
+ date: string;
34
+ } | null {
35
+ try {
36
+ const output = execOutput(`git log -1 ${hash} --pretty=format:"%H|%s|%ai"`);
37
+
38
+ if (!output) return null;
39
+
40
+ const [fullHash, message, date] = output.split("|");
41
+ return { hash: fullHash, message, date };
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * 修改指定 commit 的提交信息
49
+ * @param commitHash 可选的 commit hash
50
+ */
51
+ export async function amend(commitHash?: string): Promise<void> {
52
+ console.log(colors.cyan("修改 Commit 提交信息"));
53
+ divider();
54
+
55
+ let selectedCommit: { hash: string; message: string; date: string };
56
+
57
+ // ========== 步骤 1: 确定要修改的 commit ==========
58
+ if (commitHash) {
59
+ // 如果指定了 hash,直接获取该 commit
60
+ const commit = getCommitByHash(commitHash);
61
+ if (!commit) {
62
+ console.log(colors.red(`✖ 找不到 commit: ${commitHash}`));
63
+ return;
64
+ }
65
+ selectedCommit = commit;
66
+ } else {
67
+ // 否则让用户选择
68
+ const commits = getRecentCommits(20);
69
+
70
+ if (commits.length === 0) {
71
+ console.log(colors.yellow("没有找到任何 commit"));
72
+ return;
73
+ }
74
+
75
+ selectedCommit = await select({
76
+ message: "选择要修改的 commit:",
77
+ choices: commits.map((c) => ({
78
+ name: `${colors.yellow(c.hash.slice(0, 7))} ${c.message} ${colors.dim(c.date)}`,
79
+ value: c,
80
+ description: c.message,
81
+ })),
82
+ pageSize: 15,
83
+ theme,
84
+ });
85
+ }
86
+
87
+ console.log("");
88
+ console.log("当前 commit 信息:");
89
+ console.log(` Hash: ${colors.yellow(selectedCommit.hash.slice(0, 7))}`);
90
+ console.log(` Message: ${selectedCommit.message}`);
91
+ console.log(` Date: ${colors.dim(selectedCommit.date)}`);
92
+ divider();
93
+
94
+ // ========== 步骤 2: 输入新的 commit message ==========
95
+ const newMessage = await input({
96
+ message: "输入新的 commit message:",
97
+ default: selectedCommit.message,
98
+ validate: (value) => {
99
+ if (!value.trim()) {
100
+ return "commit message 不能为空";
101
+ }
102
+ return true;
103
+ },
104
+ theme,
105
+ });
106
+
107
+ // ========== 步骤 3: 预览并确认 ==========
108
+ divider();
109
+ console.log("修改预览:");
110
+ console.log(
111
+ ` Commit: ${colors.yellow(selectedCommit.hash.slice(0, 7))}`,
112
+ );
113
+ console.log(` 旧 Message: ${colors.dim(selectedCommit.message)}`);
114
+ console.log(` 新 Message: ${colors.green(newMessage)}`);
115
+ divider();
116
+
117
+ // 检查是否是最新的 commit
118
+ const latestHash = execOutput("git rev-parse HEAD");
119
+ const isLatestCommit = selectedCommit.hash === latestHash;
120
+
121
+ if (!isLatestCommit) {
122
+ console.log(
123
+ colors.yellow(
124
+ "⚠️ 警告: 修改非最新 commit 需要使用 rebase,可能会改变 commit hash",
125
+ ),
126
+ );
127
+ console.log(colors.dim(" 这会影响已推送到远程的 commit,请谨慎操作"));
128
+ console.log("");
129
+ }
130
+
131
+ const shouldProceed = await confirm({
132
+ message: "确认修改?",
133
+ default: false,
134
+ theme,
135
+ });
136
+
137
+ if (!shouldProceed) {
138
+ console.log(colors.yellow("已取消"));
139
+ return;
140
+ }
141
+
142
+ // ========== 步骤 4: 执行修改 ==========
143
+ try {
144
+ if (isLatestCommit) {
145
+ // 最新 commit,使用 amend
146
+ execSync(`git commit --amend -m "${newMessage.replace(/"/g, '\\"')}"`, {
147
+ stdio: "pipe",
148
+ });
149
+
150
+ console.log("");
151
+ console.log(colors.green("✔ 修改成功"));
152
+ } else {
153
+ // 非最新 commit,使用 rebase
154
+ console.log("");
155
+ console.log(colors.cyan("正在执行 rebase..."));
156
+
157
+ // 使用 git rebase -i 的自动化方式
158
+ const parentHash = execOutput(`git rev-parse ${selectedCommit.hash}^`);
159
+
160
+ // 创建临时的 rebase 脚本
161
+ const rebaseScript = `#!/bin/sh
162
+ if [ "$1" = "${selectedCommit.hash}" ]; then
163
+ echo "${newMessage}" > "$2"
164
+ fi
165
+ `;
166
+
167
+ // 使用 filter-branch 修改 commit message
168
+ const filterCmd = `git filter-branch -f --msg-filter 'if [ "$GIT_COMMIT" = "${selectedCommit.hash}" ]; then echo "${newMessage.replace(/"/g, '\\"')}"; else cat; fi' ${parentHash}..HEAD`;
169
+
170
+ execSync(filterCmd, { stdio: "pipe" });
171
+
172
+ console.log(colors.green("✔ 修改成功"));
173
+ console.log("");
174
+ console.log(colors.yellow("⚠️ 注意: commit hash 已改变"));
175
+ console.log(colors.dim(" 如果已推送到远程,需要使用 force push:"));
176
+ console.log(colors.cyan(" git push --force-with-lease"));
177
+ }
178
+
179
+ console.log("");
180
+ } catch (error) {
181
+ console.log("");
182
+ console.log(colors.red("✖ 修改失败"));
183
+ if (error instanceof Error) {
184
+ console.log(colors.dim(error.message));
185
+ }
186
+ console.log("");
187
+ throw error;
188
+ }
189
+ }
@@ -7,6 +7,7 @@ import {
7
7
  theme,
8
8
  exec,
9
9
  execOutput,
10
+ execWithSpinner,
10
11
  getMainBranch,
11
12
  divider,
12
13
  type BranchType,
@@ -35,8 +36,8 @@ export async function getBranchName(type: BranchType): Promise<string | null> {
35
36
  // 描述是否必填,默认非必填
36
37
  const requireDescription =
37
38
  type === "feature"
38
- ? config.featureRequireDescription ?? false
39
- : config.hotfixRequireDescription ?? false;
39
+ ? (config.featureRequireDescription ?? false)
40
+ : (config.hotfixRequireDescription ?? false);
40
41
  const descMessage = requireDescription
41
42
  ? "请输入描述:"
42
43
  : "请输入描述 (可跳过):";
@@ -62,7 +63,7 @@ export async function getBranchName(type: BranchType): Promise<string | null> {
62
63
 
63
64
  export async function createBranch(
64
65
  type: BranchType,
65
- baseBranchArg?: string | null
66
+ baseBranchArg?: string | null,
66
67
  ): Promise<void> {
67
68
  const config = getConfig();
68
69
 
@@ -142,12 +143,16 @@ export async function createBranch(
142
143
 
143
144
  if (shouldPush) {
144
145
  const pushSpinner = ora("正在推送到远程...").start();
145
- try {
146
- execSync(`git push -u origin "${branchName}"`, { stdio: "pipe" });
147
- pushSpinner.succeed(`已推送到远程: origin/${branchName}`);
148
- } catch {
149
- pushSpinner.warn(
150
- "远程推送失败,可稍后手动执行: git push -u origin " + branchName
146
+ const success = await execWithSpinner(
147
+ `git push -u origin "${branchName}"`,
148
+ pushSpinner,
149
+ `已推送到远程: origin/${branchName}`,
150
+ "远程推送失败",
151
+ );
152
+
153
+ if (!success) {
154
+ console.log(
155
+ colors.dim(` 可稍后手动执行: git push -u origin ${branchName}`),
151
156
  );
152
157
  }
153
158
  }
@@ -175,20 +180,23 @@ export async function deleteBranch(branchArg?: string): Promise<void> {
175
180
  if (!branch) {
176
181
  // 获取本地分支
177
182
  const localBranches = execOutput(
178
- "git for-each-ref --sort=-committerdate refs/heads/ --format='%(refname:short)'"
183
+ "git for-each-ref --sort=-committerdate refs/heads/ --format='%(refname:short)'",
179
184
  )
180
185
  .split("\n")
181
186
  .filter((b) => b && b !== currentBranch);
182
187
 
183
188
  // 获取远程分支(排除 HEAD 和已有本地分支的)
184
189
  const remoteBranches = execOutput(
185
- "git for-each-ref --sort=-committerdate refs/remotes/origin/ --format='%(refname:short)'"
190
+ "git for-each-ref --sort=-committerdate refs/remotes/origin/ --format='%(refname:short)'",
186
191
  )
187
192
  .split("\n")
188
193
  .map((b) => b.replace("origin/", ""))
189
194
  .filter(
190
195
  (b) =>
191
- b && b !== "HEAD" && b !== currentBranch && !localBranches.includes(b)
196
+ b &&
197
+ b !== "HEAD" &&
198
+ b !== currentBranch &&
199
+ !localBranches.includes(b),
192
200
  );
193
201
 
194
202
  if (localBranches.length === 0 && remoteBranches.length === 0) {
@@ -252,14 +260,12 @@ export async function deleteBranch(branchArg?: string): Promise<void> {
252
260
  }
253
261
 
254
262
  const spinner = ora(`正在删除远程分支: origin/${remoteBranch}`).start();
255
- try {
256
- execSync(`git push origin --delete "${remoteBranch}"`, {
257
- stdio: "pipe",
258
- });
259
- spinner.succeed(`远程分支已删除: origin/${remoteBranch}`);
260
- } catch {
261
- spinner.fail("远程分支删除失败");
262
- }
263
+ await execWithSpinner(
264
+ `git push origin --delete "${remoteBranch}"`,
265
+ spinner,
266
+ `远程分支已删除: origin/${remoteBranch}`,
267
+ "远程分支删除失败",
268
+ );
263
269
  return;
264
270
  }
265
271
  }
@@ -275,7 +281,7 @@ export async function deleteBranch(branchArg?: string): Promise<void> {
275
281
  if (!localExists) {
276
282
  if (hasRemote) {
277
283
  console.log(
278
- colors.yellow(`本地分支不存在,但远程分支存在: origin/${branch}`)
284
+ colors.yellow(`本地分支不存在,但远程分支存在: origin/${branch}`),
279
285
  );
280
286
  const deleteRemote = await select({
281
287
  message: `确认删除远程分支 origin/${branch}?`,
@@ -288,12 +294,12 @@ export async function deleteBranch(branchArg?: string): Promise<void> {
288
294
 
289
295
  if (deleteRemote) {
290
296
  const spinner = ora(`正在删除远程分支: origin/${branch}`).start();
291
- try {
292
- execSync(`git push origin --delete "${branch}"`, { stdio: "pipe" });
293
- spinner.succeed(`远程分支已删除: origin/${branch}`);
294
- } catch {
295
- spinner.fail("远程分支删除失败");
296
- }
297
+ await execWithSpinner(
298
+ `git push origin --delete "${branch}"`,
299
+ spinner,
300
+ `远程分支已删除: origin/${branch}`,
301
+ "远程分支删除失败",
302
+ );
297
303
  }
298
304
  } else {
299
305
  console.log(colors.red(`分支不存在: ${branch}`));
@@ -319,21 +325,24 @@ export async function deleteBranch(branchArg?: string): Promise<void> {
319
325
  }
320
326
 
321
327
  const localSpinner = ora(`正在删除本地分支: ${branch}`).start();
322
- try {
323
- execSync(`git branch -D "${branch}"`, { stdio: "pipe" });
324
- localSpinner.succeed(`本地分支已删除: ${branch}`);
325
- } catch {
326
- localSpinner.fail("本地分支删除失败");
328
+ const localSuccess = await execWithSpinner(
329
+ `git branch -D "${branch}"`,
330
+ localSpinner,
331
+ `本地分支已删除: ${branch}`,
332
+ "本地分支删除失败",
333
+ );
334
+
335
+ if (!localSuccess) {
327
336
  return;
328
337
  }
329
338
 
330
339
  if (hasRemote) {
331
340
  const remoteSpinner = ora(`正在删除远程分支: origin/${branch}`).start();
332
- try {
333
- execSync(`git push origin --delete "${branch}"`, { stdio: "pipe" });
334
- remoteSpinner.succeed(`远程分支已删除: origin/${branch}`);
335
- } catch {
336
- remoteSpinner.fail("远程分支删除失败");
337
- }
341
+ await execWithSpinner(
342
+ `git push origin --delete "${branch}"`,
343
+ remoteSpinner,
344
+ `远程分支已删除: origin/${branch}`,
345
+ "远程分支删除失败",
346
+ );
338
347
  }
339
348
  }
@@ -1,4 +1,4 @@
1
- import { execSync, spawn } from "child_process";
1
+ import { spawn } from "child_process";
2
2
  import { select, input } from "@inquirer/prompts";
3
3
  import ora from "ora";
4
4
  import boxen from "boxen";
@@ -7,6 +7,8 @@ import {
7
7
  theme,
8
8
  divider,
9
9
  execOutput,
10
+ execAsync,
11
+ execWithSpinner,
10
12
  type BranchType,
11
13
  } from "../utils.js";
12
14
  import { getBranchName } from "./branch.js";
@@ -40,7 +42,7 @@ function parseStashList(): StashEntry[] {
40
42
  message = message || "(no message)";
41
43
 
42
44
  const filesRaw = execOutput(
43
- `git stash show stash@{${index}} --name-only 2>/dev/null`
45
+ `git stash show stash@{${index}} --name-only 2>/dev/null`,
44
46
  );
45
47
  const files = filesRaw ? filesRaw.split("\n").filter(Boolean) : [];
46
48
 
@@ -145,10 +147,10 @@ async function showStashActions(entry: StashEntry): Promise<void> {
145
147
 
146
148
  switch (action) {
147
149
  case "apply":
148
- applyStash(entry.index, false);
150
+ await applyStash(entry.index, false);
149
151
  break;
150
152
  case "pop":
151
- applyStash(entry.index, true);
153
+ await applyStash(entry.index, true);
152
154
  break;
153
155
  case "branch":
154
156
  await createBranchFromStash(entry.index);
@@ -193,27 +195,34 @@ async function createStash(): Promise<void> {
193
195
  });
194
196
 
195
197
  const spinner = ora("创建 stash...").start();
196
- try {
197
- let cmd = "git stash push";
198
- if (includeUntracked) cmd += " -u";
199
- if (message) cmd += ` -m "${message.replace(/"/g, '\\"')}"`;
200
- execSync(cmd, { stdio: "pipe" });
201
- spinner.succeed("Stash 创建成功");
198
+ let cmd = "git stash push";
199
+ if (includeUntracked) cmd += " -u";
200
+ if (message) cmd += ` -m "${message.replace(/"/g, '\\"')}"`;
201
+
202
+ const success = await execWithSpinner(
203
+ cmd,
204
+ spinner,
205
+ "Stash 创建成功",
206
+ "Stash 创建失败",
207
+ );
208
+
209
+ if (success) {
202
210
  await stash();
203
- } catch {
204
- spinner.fail("Stash 创建失败");
205
211
  }
206
212
  }
207
213
 
208
- function applyStash(index: number, pop: boolean): void {
214
+ async function applyStash(index: number, pop: boolean): Promise<void> {
209
215
  const action = pop ? "pop" : "apply";
210
216
  const spinner = ora(`${pop ? "弹出" : "应用"} stash...`).start();
211
217
 
212
- try {
213
- execSync(`git stash ${action} stash@{${index}}`, { stdio: "pipe" });
214
- spinner.succeed(`Stash ${pop ? "已弹出" : "已应用"}`);
215
- } catch {
216
- spinner.fail("操作失败,可能存在冲突");
218
+ const success = await execWithSpinner(
219
+ `git stash ${action} stash@{${index}}`,
220
+ spinner,
221
+ `Stash ${pop ? "已弹出" : "已应用"}`,
222
+ "操作失败,可能存在冲突",
223
+ );
224
+
225
+ if (!success) {
217
226
  const status = execOutput("git status --porcelain");
218
227
  if (status.includes("UU") || status.includes("AA")) {
219
228
  console.log(colors.yellow("\n存在冲突,请手动解决后提交"));
@@ -225,7 +234,7 @@ async function showDiff(index: number): Promise<void> {
225
234
  try {
226
235
  // 获取差异内容(不使用颜色,我们自己格式化)
227
236
  const diffOutput = execOutput(
228
- `git stash show -p --no-color stash@{${index}}`
237
+ `git stash show -p --no-color stash@{${index}}`,
229
238
  );
230
239
 
231
240
  if (!diffOutput) {
@@ -424,14 +433,12 @@ async function createBranchFromStash(index: number): Promise<void> {
424
433
  if (!branchName) return;
425
434
 
426
435
  const spinner = ora(`创建分支 ${branchName}...`).start();
427
- try {
428
- execSync(`git stash branch "${branchName}" stash@{${index}}`, {
429
- stdio: "pipe",
430
- });
431
- spinner.succeed(`分支已创建: ${branchName} (stash 已自动弹出)`);
432
- } catch {
433
- spinner.fail("创建分支失败");
434
- }
436
+ await execWithSpinner(
437
+ `git stash branch "${branchName}" stash@{${index}}`,
438
+ spinner,
439
+ `分支已创建: ${branchName} (stash 已自动弹出)`,
440
+ "创建分支失败",
441
+ );
435
442
  }
436
443
 
437
444
  async function dropStash(index: number): Promise<void> {
@@ -450,10 +457,10 @@ async function dropStash(index: number): Promise<void> {
450
457
  }
451
458
 
452
459
  const spinner = ora("删除 stash...").start();
453
- try {
454
- execSync(`git stash drop stash@{${index}}`, { stdio: "pipe" });
455
- spinner.succeed("Stash 已删除");
456
- } catch {
457
- spinner.fail("删除失败");
458
- }
460
+ await execWithSpinner(
461
+ `git stash drop stash@{${index}}`,
462
+ spinner,
463
+ "Stash 已删除",
464
+ "删除失败",
465
+ );
459
466
  }