@zjex/git-workflow 0.0.1 → 0.1.0

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.
@@ -49,6 +49,38 @@ export async function createBranch(
49
49
  ): Promise<void> {
50
50
  const config = getConfig();
51
51
 
52
+ // 检查是否有未提交的更改
53
+ const hasChanges = execOutput("git status --porcelain");
54
+ if (hasChanges) {
55
+ console.log(colors.yellow("检测到未提交的更改:"));
56
+ console.log(colors.dim(hasChanges));
57
+ divider();
58
+
59
+ const shouldStash = await select({
60
+ message: "是否暂存 (stash) 这些更改后继续?",
61
+ choices: [
62
+ { name: "是", value: true },
63
+ { name: "否,取消操作", value: false },
64
+ ],
65
+ theme,
66
+ });
67
+
68
+ if (!shouldStash) {
69
+ console.log(colors.yellow("已取消"));
70
+ return;
71
+ }
72
+
73
+ const stashSpinner = ora("正在暂存更改...").start();
74
+ try {
75
+ exec('git stash push -m "auto stash before branch switch"', true);
76
+ stashSpinner.succeed("更改已暂存,切换分支后可用 gw s 恢复");
77
+ } catch {
78
+ stashSpinner.fail("暂存失败");
79
+ return;
80
+ }
81
+ divider();
82
+ }
83
+
52
84
  const branchName = await getBranchName(type);
53
85
  if (!branchName) return;
54
86
 
@@ -118,15 +150,32 @@ export async function deleteBranch(branchArg?: string): Promise<void> {
118
150
 
119
151
  let branch = branchArg;
120
152
 
153
+ // 如果传入的是 origin/xxx 格式,提取分支名
154
+ if (branch?.startsWith("origin/")) {
155
+ branch = branch.replace("origin/", "");
156
+ }
157
+
121
158
  if (!branch) {
122
- const recentBranches = execOutput(
159
+ // 获取本地分支
160
+ const localBranches = execOutput(
123
161
  "git for-each-ref --sort=-committerdate refs/heads/ --format='%(refname:short)'"
124
162
  )
125
163
  .split("\n")
126
164
  .filter((b) => b && b !== currentBranch);
127
165
 
128
- if (recentBranches.length === 0) {
129
- console.log(colors.yellow("没有可删除的本地分支"));
166
+ // 获取远程分支(排除 HEAD 和已有本地分支的)
167
+ const remoteBranches = execOutput(
168
+ "git for-each-ref --sort=-committerdate refs/remotes/origin/ --format='%(refname:short)'"
169
+ )
170
+ .split("\n")
171
+ .map((b) => b.replace("origin/", ""))
172
+ .filter(
173
+ (b) =>
174
+ b && b !== "HEAD" && b !== currentBranch && !localBranches.includes(b)
175
+ );
176
+
177
+ if (localBranches.length === 0 && remoteBranches.length === 0) {
178
+ console.log(colors.yellow("没有可删除的分支"));
130
179
  return;
131
180
  }
132
181
 
@@ -135,13 +184,25 @@ export async function deleteBranch(branchArg?: string): Promise<void> {
135
184
  value: string;
136
185
  }
137
186
 
138
- const choices: BranchChoice[] = recentBranches.map((b) => {
139
- const hasRemote = execOutput(`git branch -r | grep "origin/${b}$"`);
140
- return {
187
+ const choices: BranchChoice[] = [];
188
+
189
+ // 本地分支
190
+ localBranches.forEach((b) => {
191
+ const hasRemote = execOutput(`git branch -r --list "origin/${b}"`);
192
+ choices.push({
141
193
  name: hasRemote ? `${b} (本地+远程)` : `${b} (仅本地)`,
142
194
  value: b,
143
- };
195
+ });
144
196
  });
197
+
198
+ // 仅远程分支
199
+ remoteBranches.forEach((b) => {
200
+ choices.push({
201
+ name: `${b} (仅远程)`,
202
+ value: `__remote__${b}`,
203
+ });
204
+ });
205
+
145
206
  choices.push({ name: "取消", value: "__cancel__" });
146
207
 
147
208
  branch = await select({
@@ -154,6 +215,36 @@ export async function deleteBranch(branchArg?: string): Promise<void> {
154
215
  console.log(colors.yellow("已取消"));
155
216
  return;
156
217
  }
218
+
219
+ // 处理仅远程分支的情况
220
+ if (branch.startsWith("__remote__")) {
221
+ const remoteBranch = branch.replace("__remote__", "");
222
+
223
+ const confirm = await select({
224
+ message: `确认删除远程分支 origin/${remoteBranch}?`,
225
+ choices: [
226
+ { name: "是", value: true },
227
+ { name: "否", value: false },
228
+ ],
229
+ theme,
230
+ });
231
+
232
+ if (!confirm) {
233
+ console.log(colors.yellow("已取消"));
234
+ return;
235
+ }
236
+
237
+ const spinner = ora(`正在删除远程分支: origin/${remoteBranch}`).start();
238
+ try {
239
+ execSync(`git push origin --delete "${remoteBranch}"`, {
240
+ stdio: "pipe",
241
+ });
242
+ spinner.succeed(`远程分支已删除: origin/${remoteBranch}`);
243
+ } catch {
244
+ spinner.fail("远程分支删除失败");
245
+ }
246
+ return;
247
+ }
157
248
  }
158
249
 
159
250
  if (branch === currentBranch) {
@@ -162,7 +253,7 @@ export async function deleteBranch(branchArg?: string): Promise<void> {
162
253
  }
163
254
 
164
255
  const localExists = execOutput(`git branch --list "${branch}"`);
165
- const hasRemote = execOutput(`git branch -r | grep "origin/${branch}$"`);
256
+ const hasRemote = execOutput(`git branch -r --list "origin/${branch}"`);
166
257
 
167
258
  if (!localExists) {
168
259
  if (hasRemote) {
@@ -170,7 +261,7 @@ export async function deleteBranch(branchArg?: string): Promise<void> {
170
261
  colors.yellow(`本地分支不存在,但远程分支存在: origin/${branch}`)
171
262
  );
172
263
  const deleteRemote = await select({
173
- message: `是否删除远程分支 origin/${branch}?`,
264
+ message: `确认删除远程分支 origin/${branch}?`,
174
265
  choices: [
175
266
  { name: "是", value: true },
176
267
  { name: "否", value: false },
@@ -193,6 +284,23 @@ export async function deleteBranch(branchArg?: string): Promise<void> {
193
284
  return;
194
285
  }
195
286
 
287
+ // 删除本地分支前确认
288
+ const confirmDelete = await select({
289
+ message: `确认删除分支 ${branch}?${
290
+ hasRemote ? " (本地+远程)" : " (仅本地)"
291
+ }`,
292
+ choices: [
293
+ { name: "是", value: true },
294
+ { name: "否", value: false },
295
+ ],
296
+ theme,
297
+ });
298
+
299
+ if (!confirmDelete) {
300
+ console.log(colors.yellow("已取消"));
301
+ return;
302
+ }
303
+
196
304
  const localSpinner = ora(`正在删除本地分支: ${branch}`).start();
197
305
  try {
198
306
  execSync(`git branch -D "${branch}"`, { stdio: "pipe" });
@@ -203,25 +311,12 @@ export async function deleteBranch(branchArg?: string): Promise<void> {
203
311
  }
204
312
 
205
313
  if (hasRemote) {
206
- divider();
207
-
208
- const deleteRemote = await select({
209
- message: `是否同时删除远程分支 origin/${branch}?`,
210
- choices: [
211
- { name: "", value: true },
212
- { name: "否", value: false },
213
- ],
214
- theme,
215
- });
216
-
217
- if (deleteRemote) {
218
- const remoteSpinner = ora(`正在删除远程分支: origin/${branch}`).start();
219
- try {
220
- execSync(`git push origin --delete "${branch}"`, { stdio: "pipe" });
221
- remoteSpinner.succeed(`远程分支已删除: origin/${branch}`);
222
- } catch {
223
- remoteSpinner.fail("远程分支删除失败");
224
- }
314
+ const remoteSpinner = ora(`正在删除远程分支: origin/${branch}`).start();
315
+ try {
316
+ execSync(`git push origin --delete "${branch}"`, { stdio: "pipe" });
317
+ remoteSpinner.succeed(`远程分支已删除: origin/${branch}`);
318
+ } catch {
319
+ remoteSpinner.fail("远程分支删除失败");
225
320
  }
226
321
  }
227
322
  }
@@ -0,0 +1,263 @@
1
+ import { execSync } from "child_process";
2
+ import { select, input, confirm, checkbox } from "@inquirer/prompts";
3
+ import ora from "ora";
4
+ import { colors, theme, execOutput, divider } from "../utils.js";
5
+ import { getConfig } from "../config.js";
6
+
7
+ // Conventional Commits 类型 + Gitmoji
8
+ const DEFAULT_COMMIT_TYPES = [
9
+ { type: "feat", emoji: "✨", description: "新功能" },
10
+ { type: "fix", emoji: "🐛", description: "修复 Bug" },
11
+ { type: "docs", emoji: "📝", description: "文档更新" },
12
+ { type: "style", emoji: "💄", description: "代码格式 (不影响功能)" },
13
+ { type: "refactor", emoji: "♻️", description: "重构 (非新功能/修复)" },
14
+ { type: "perf", emoji: "⚡️", description: "性能优化" },
15
+ { type: "test", emoji: "✅", description: "测试相关" },
16
+ { type: "build", emoji: "📦", description: "构建/依赖相关" },
17
+ { type: "ci", emoji: "👷", description: "CI/CD 相关" },
18
+ { type: "chore", emoji: "🔧", description: "其他杂项" },
19
+ { type: "revert", emoji: "⏪", description: "回退提交" },
20
+ ] as const;
21
+
22
+ type CommitType = (typeof DEFAULT_COMMIT_TYPES)[number]["type"];
23
+
24
+ function getCommitTypes(config: ReturnType<typeof getConfig>) {
25
+ const customEmojis = config.commitEmojis || {};
26
+ return DEFAULT_COMMIT_TYPES.map((item) => ({
27
+ ...item,
28
+ emoji: customEmojis[item.type as CommitType] || item.emoji,
29
+ }));
30
+ }
31
+
32
+ interface FileStatus {
33
+ status: string;
34
+ file: string;
35
+ }
36
+
37
+ function parseGitStatus(): { staged: FileStatus[]; unstaged: FileStatus[] } {
38
+ const output = execOutput("git status --porcelain");
39
+ if (!output) return { staged: [], unstaged: [] };
40
+
41
+ const staged: FileStatus[] = [];
42
+ const unstaged: FileStatus[] = [];
43
+
44
+ for (const line of output.split("\n")) {
45
+ if (!line) continue;
46
+ const indexStatus = line[0];
47
+ const workTreeStatus = line[1];
48
+ const file = line.slice(3);
49
+
50
+ // 已暂存的更改 (index 有状态)
51
+ if (indexStatus !== " " && indexStatus !== "?") {
52
+ staged.push({ status: indexStatus, file });
53
+ }
54
+
55
+ // 未暂存的更改 (work tree 有状态,或者是未跟踪文件)
56
+ if (workTreeStatus !== " " || indexStatus === "?") {
57
+ const status = indexStatus === "?" ? "?" : workTreeStatus;
58
+ unstaged.push({ status, file });
59
+ }
60
+ }
61
+
62
+ return { staged, unstaged };
63
+ }
64
+
65
+ function formatFileStatus(status: string): string {
66
+ const statusMap: Record<string, string> = {
67
+ M: colors.yellow("M"),
68
+ A: colors.green("A"),
69
+ D: colors.red("D"),
70
+ R: colors.yellow("R"),
71
+ C: colors.yellow("C"),
72
+ "?": colors.green("?"),
73
+ };
74
+ return statusMap[status] || status;
75
+ }
76
+
77
+ export async function commit(): Promise<void> {
78
+ const config = getConfig();
79
+ let { staged, unstaged } = parseGitStatus();
80
+
81
+ // 没有暂存的更改
82
+ if (staged.length === 0) {
83
+ if (unstaged.length === 0) {
84
+ console.log(colors.yellow("工作区干净,没有需要提交的更改"));
85
+ return;
86
+ }
87
+
88
+ console.log(colors.yellow("没有暂存的更改"));
89
+ divider();
90
+ console.log("未暂存的文件:");
91
+ for (const { status, file } of unstaged) {
92
+ console.log(` ${formatFileStatus(status)} ${file}`);
93
+ }
94
+ divider();
95
+
96
+ // 根据配置决定是否自动暂存
97
+ const autoStage = config.autoStage ?? true;
98
+
99
+ if (autoStage) {
100
+ // 自动暂存所有文件
101
+ execSync("git add -A", { stdio: "pipe" });
102
+ console.log(colors.green("✔ 已自动暂存所有更改"));
103
+ divider();
104
+ // 重新获取状态
105
+ const newStatus = parseGitStatus();
106
+ staged = newStatus.staged;
107
+ } else {
108
+ // 让用户选择要暂存的文件
109
+ const filesToStage = await checkbox({
110
+ message: "选择要暂存的文件:",
111
+ choices: unstaged.map(({ status, file }) => ({
112
+ name: `${formatFileStatus(status)} ${file}`,
113
+ value: file,
114
+ checked: true,
115
+ })),
116
+ theme,
117
+ });
118
+
119
+ if (filesToStage.length === 0) {
120
+ console.log(colors.yellow("没有选择任何文件,已取消"));
121
+ return;
122
+ }
123
+
124
+ // 暂存选中的文件
125
+ for (const file of filesToStage) {
126
+ execSync(`git add "${file}"`, { stdio: "pipe" });
127
+ }
128
+ console.log(colors.green(`✔ 已暂存 ${filesToStage.length} 个文件`));
129
+ divider();
130
+
131
+ // 重新获取状态
132
+ const newStatus = parseGitStatus();
133
+ staged = newStatus.staged;
134
+ }
135
+ } else {
136
+ console.log("已暂存的文件:");
137
+ for (const { status, file } of staged) {
138
+ console.log(` ${formatFileStatus(status)} ${file}`);
139
+ }
140
+ divider();
141
+ }
142
+
143
+ // 获取提交类型(支持自定义 emoji)
144
+ const commitTypes = getCommitTypes(config);
145
+
146
+ // 选择提交类型
147
+ const typeChoice = await select({
148
+ message: "选择提交类型:",
149
+ choices: commitTypes.map((t) => ({
150
+ name: `${t.emoji} ${t.type.padEnd(10)} ${colors.dim(t.description)}`,
151
+ value: t,
152
+ })),
153
+ theme,
154
+ });
155
+
156
+ // 输入 scope (可选)
157
+ const scope = await input({
158
+ message: "输入影响范围 scope (可跳过):",
159
+ theme,
160
+ });
161
+
162
+ // 输入简短描述
163
+ const subject = await input({
164
+ message: "输入简短描述:",
165
+ validate: (value) => {
166
+ if (!value.trim()) return "描述不能为空";
167
+ if (value.length > 72) return "描述不能超过 72 个字符";
168
+ return true;
169
+ },
170
+ theme,
171
+ });
172
+
173
+ // 输入详细描述 (可选)
174
+ const body = await input({
175
+ message: "输入详细描述 (可跳过):",
176
+ theme,
177
+ });
178
+
179
+ // 是否有破坏性变更
180
+ const hasBreaking = await confirm({
181
+ message: "是否包含破坏性变更 (BREAKING CHANGE)?",
182
+ default: false,
183
+ theme,
184
+ });
185
+
186
+ let breakingDesc = "";
187
+ if (hasBreaking) {
188
+ breakingDesc = await input({
189
+ message: "描述破坏性变更:",
190
+ validate: (value) => (value.trim() ? true : "请描述破坏性变更"),
191
+ theme,
192
+ });
193
+ }
194
+
195
+ // 关联 Issue (可选)
196
+ const issues = await input({
197
+ message: "关联 Issue (如 #123, 可跳过):",
198
+ theme,
199
+ });
200
+
201
+ // 构建 commit message
202
+ const { type, emoji } = typeChoice;
203
+ const scopePart = scope ? `(${scope})` : "";
204
+ const breakingMark = hasBreaking ? "!" : "";
205
+
206
+ // 根据配置决定是否使用 emoji
207
+ const useEmoji = config.useEmoji ?? true;
208
+ const emojiPrefix = useEmoji ? `${emoji} ` : "";
209
+
210
+ // Header: [emoji] type(scope)!: subject
211
+ let message = `${emojiPrefix}${type}${scopePart}${breakingMark}: ${subject}`;
212
+
213
+ // Body
214
+ if (body || hasBreaking || issues) {
215
+ message += "\n";
216
+
217
+ if (body) {
218
+ message += `\n${body}`;
219
+ }
220
+
221
+ if (hasBreaking) {
222
+ message += `\n\nBREAKING CHANGE: ${breakingDesc}`;
223
+ }
224
+
225
+ if (issues) {
226
+ message += `\n\n${issues}`;
227
+ }
228
+ }
229
+
230
+ divider();
231
+ console.log("提交信息预览:");
232
+ console.log(colors.green(message));
233
+ divider();
234
+
235
+ const shouldCommit = await confirm({
236
+ message: "确认提交?",
237
+ default: true,
238
+ theme,
239
+ });
240
+
241
+ if (!shouldCommit) {
242
+ console.log(colors.yellow("已取消"));
243
+ return;
244
+ }
245
+
246
+ const spinner = ora("正在提交...").start();
247
+
248
+ try {
249
+ // 使用 -m 参数,需要转义引号
250
+ const escapedMessage = message.replace(/"/g, '\\"');
251
+ execSync(`git commit -m "${escapedMessage}"`, { stdio: "pipe" });
252
+ spinner.succeed("提交成功");
253
+
254
+ // 显示提交信息
255
+ const commitHash = execOutput("git rev-parse --short HEAD");
256
+ console.log(colors.dim(`commit: ${commitHash}`));
257
+ } catch (error) {
258
+ spinner.fail("提交失败");
259
+ if (error instanceof Error) {
260
+ console.log(colors.red(error.message));
261
+ }
262
+ }
263
+ }
@@ -4,43 +4,52 @@ export function showHelp(): string {
4
4
  return `
5
5
  分支命令:
6
6
  gw feature [--base <branch>] 创建 feature 分支
7
- gw feat [--base <branch>] 同上 (简写)
8
- gw f [--base <branch>] 同上 (简写)
7
+ gw feat [--base <branch>] 同上 (别名)
8
+ gw f [--base <branch>] 同上 (别名)
9
9
 
10
10
  gw hotfix [--base <branch>] 创建 hotfix 分支
11
- gw fix [--base <branch>] 同上 (简写)
12
- gw h [--base <branch>] 同上 (简写)
11
+ gw fix [--base <branch>] 同上 (别名)
12
+ gw h [--base <branch>] 同上 (别名)
13
13
 
14
14
  gw delete [branch] 删除本地/远程分支
15
- gw del [branch] 同上 (简写)
16
- gw d [branch] 同上 (简写)
15
+ gw del [branch] 同上 (别名)
16
+ gw d [branch] 同上 (别名)
17
17
 
18
18
  Tag 命令:
19
19
  gw tags [prefix] 列出所有 tag,可按前缀过滤
20
- gw ts [prefix] 同上 (简写)
20
+ gw ts [prefix] 同上 (别名)
21
21
 
22
22
  gw tag [prefix] 交互式选择版本类型并创建 tag
23
- gw t [prefix] 同上 (简写)
23
+ gw t [prefix] 同上 (别名)
24
24
 
25
25
  发布命令:
26
26
  gw release 交互式选择版本号并更新 package.json
27
- gw r 同上 (简写)
27
+ gw r 同上 (别名)
28
28
 
29
29
  配置命令:
30
30
  gw init 初始化配置文件 .gwrc.json
31
31
 
32
32
  Stash 命令:
33
33
  gw stash 交互式管理 stash
34
- gw s 同上 (简写)
34
+ gw s 同上 (别名)
35
+ gw st 同上 (别名)
36
+
37
+ Commit 命令:
38
+ gw commit 交互式提交 (Conventional Commits + Gitmoji)
39
+ gw c 同上 (别名)
40
+ gw cm 同上 (别名)
35
41
 
36
42
  示例:
37
- gw feat 基于 main/master 创建 feature 分支
38
- gw feat --base develop 基于 develop 分支创建 feature 分支
39
- gw fix --base release 基于 release 分支创建 hotfix 分支
40
- gw del 交互式选择并删除分支
41
- gw del feature/xxx 直接删除指定分支
42
- gw tags v 列出所有 v 开头的 tag
43
- gw tag 交互式创建 tag
43
+ gw f 基于 main/master 创建 feature 分支
44
+ gw f --base develop 基于 develop 分支创建 feature 分支
45
+ gw h --base release 基于 release 分支创建 hotfix 分支
46
+ gw d 交互式选择并删除分支
47
+ gw d feature/xxx 直接删除指定分支
48
+ gw ts v 列出所有 v 开头的 tag
49
+ gw t 交互式创建 tag
50
+ gw r 交互式发布版本
51
+ gw s 交互式管理 stash
52
+ gw c 交互式提交代码
44
53
 
45
54
  分支命名格式:
46
55
  feature/${TODAY}-<Story ID>-<描述>
@@ -5,6 +5,21 @@ import type { GwConfig } from "../config.js";
5
5
 
6
6
  const CONFIG_FILE = ".gwrc.json";
7
7
 
8
+ // 默认的 commit emoji 配置
9
+ const DEFAULT_COMMIT_EMOJIS = {
10
+ feat: "✨",
11
+ fix: "🐛",
12
+ docs: "📝",
13
+ style: "💄",
14
+ refactor: "♻️",
15
+ perf: "⚡️",
16
+ test: "✅",
17
+ build: "📦",
18
+ ci: "👷",
19
+ chore: "🔧",
20
+ revert: "⏪",
21
+ };
22
+
8
23
  export async function init(): Promise<void> {
9
24
  if (existsSync(CONFIG_FILE)) {
10
25
  const overwrite = await confirm({
@@ -94,10 +109,35 @@ export async function init(): Promise<void> {
94
109
 
95
110
  divider();
96
111
 
112
+ // Commit 配置
113
+ const autoStage = await confirm({
114
+ message: "Commit 时是否自动暂存所有更改?",
115
+ default: true,
116
+ theme,
117
+ });
118
+ if (!autoStage) config.autoStage = false;
119
+
120
+ const useEmoji = await confirm({
121
+ message: "Commit 时是否使用 emoji?",
122
+ default: true,
123
+ theme,
124
+ });
125
+ if (!useEmoji) config.useEmoji = false;
126
+
127
+ // 始终写入默认的 commitEmojis 配置,方便用户修改
128
+ config.commitEmojis = DEFAULT_COMMIT_EMOJIS;
129
+
130
+ divider();
131
+
97
132
  // 写入配置
98
133
  const content = JSON.stringify(config, null, 2);
99
134
  writeFileSync(CONFIG_FILE, content + "\n");
100
135
 
101
136
  console.log(colors.green(`✓ 配置已保存到 ${CONFIG_FILE}`));
137
+ console.log(
138
+ colors.dim(
139
+ "\n提示: 可以在配置文件中修改 commitEmojis 来自定义各类型的 emoji"
140
+ )
141
+ );
102
142
  console.log(colors.dim("\n" + content));
103
143
  }