@nemo-cli/git 0.1.2 → 0.1.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.
package/dist/index.js CHANGED
@@ -1,10 +1,220 @@
1
- import { addFiles, colors, createCheckbox, createCommand, createConfirm, createHelpExample, createInput, createNote, createOptions, createSearch, createSelect, createSpinner, exit, getCurrentBranch, getGitStatus, intro, isEmpty, isString, loadConfig, log, outro, readPackage, x, xASync } from "@nemo-cli/shared";
2
- import { BigText, ErrorMessage, Message } from "@nemo-cli/ui";
1
+ import { addFiles, colors, createCheckbox, createCommand, createConfirm, createHelpExample, createInput, createNote, createOptions, createSelect, createSpinner, exit, getCurrentBranch, getGitStatus, intro, isEmpty, isString, loadConfig, log, outro, readPackage, safeAwait, x, xASync } from "@nemo-cli/shared";
2
+ import { BigText, ErrorMessage, Message, renderHistViewer, renderStashList, renderStatusViewer } from "@nemo-cli/ui";
3
+ import path, { dirname, join } from "node:path";
4
+ import readline from "node:readline";
3
5
  import { spawn } from "node:child_process";
4
- import { unlinkSync, writeFileSync } from "node:fs";
5
- import { tmpdir } from "node:os";
6
- import { join } from "node:path";
6
+ import { readFileSync, unlinkSync, writeFileSync } from "node:fs";
7
+ import { homedir, tmpdir } from "node:os";
8
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
7
9
 
10
+ //#region src/commands/blame.ts
11
+ const MAX_DIFF_LINES = 50;
12
+ /**
13
+ * Registers the 'ng blame' command for viewing file commit history with interactive navigation
14
+ * @param command - The commander Command instance
15
+ * @returns The modified command instance
16
+ */
17
+ const blameCommand = (command) => {
18
+ command.command("blame [file]").description("View file commit history with interactive navigation").action(async (file) => {
19
+ if (!file) {
20
+ log.show("Please specify a file path.", { type: "error" });
21
+ return;
22
+ }
23
+ await handleBlame(file);
24
+ });
25
+ return command;
26
+ };
27
+ const commitCache = /* @__PURE__ */ new Map();
28
+ /**
29
+ * Finalizes a commit object from partial data
30
+ * @param currentCommit - Partial commit data
31
+ * @param diffLines - Array of diff lines
32
+ * @returns Complete Commit object or null if invalid
33
+ */
34
+ const finalizeCommit = (currentCommit, diffLines) => {
35
+ if (!currentCommit?.hash || !currentCommit.author || !currentCommit.message) return null;
36
+ return {
37
+ hash: currentCommit.hash,
38
+ author: currentCommit.author,
39
+ date: currentCommit.date ?? "",
40
+ message: currentCommit.message,
41
+ diff: diffLines.join("\n")
42
+ };
43
+ };
44
+ const handleBlame = async (filePath) => {
45
+ const resolvedPath = path.resolve(filePath);
46
+ const cwd = process.cwd();
47
+ if (!resolvedPath.startsWith(cwd + path.sep) && resolvedPath !== cwd) {
48
+ log.show("❌ Error: File path must be within current directory", { type: "error" });
49
+ return;
50
+ }
51
+ const [statError] = await xASync("test", ["-f", resolvedPath], { quiet: true });
52
+ if (statError) {
53
+ log.show(`❌ Error: File not found: ${filePath}`, { type: "error" });
54
+ return;
55
+ }
56
+ if (!commitCache.has(resolvedPath)) {
57
+ const [error, result] = await xASync("git", [
58
+ "log",
59
+ "--follow",
60
+ "-p",
61
+ `--pretty=format:%H%x00%an%x00%ad%x00%s`,
62
+ "--",
63
+ resolvedPath
64
+ ]);
65
+ if (error) {
66
+ handleGitError(error, "git log");
67
+ return;
68
+ }
69
+ commitCache.set(resolvedPath, parseCommitsFromLog(result.stdout));
70
+ }
71
+ const commits = commitCache.get(resolvedPath);
72
+ if (commits.length === 0) {
73
+ log.show(`No git history found for ${filePath}`, { type: "warn" });
74
+ return;
75
+ }
76
+ log.show(colors.bold(`Found ${commits.length} commits for ${filePath}`));
77
+ log.show(colors.dim("Use [n/p] to navigate, [j] to jump, [q] to quit"));
78
+ await enterInteractiveBlameMode(resolvedPath, commits);
79
+ };
80
+ /**
81
+ * Parses git log output into structured Commit objects
82
+ * @param logOutput - Raw git log output with null separators
83
+ * @returns Array of parsed Commit objects
84
+ */
85
+ const parseCommitsFromLog = (logOutput) => {
86
+ const commits = [];
87
+ const lines = logOutput.split("\n");
88
+ let currentCommit = null;
89
+ let diffLines = [];
90
+ for (const line of lines) if (line.includes("\0")) {
91
+ const finalized = finalizeCommit(currentCommit, diffLines);
92
+ if (finalized) commits.push(finalized);
93
+ const [hash, author, date, message] = line.split("\0");
94
+ if (hash && hash.length >= 8) {
95
+ currentCommit = {
96
+ hash: hash.substring(0, 8),
97
+ author: author?.trim() ?? "",
98
+ date: date?.trim() ?? "",
99
+ message: message?.trim() ?? ""
100
+ };
101
+ diffLines = [];
102
+ }
103
+ } else if (currentCommit) diffLines.push(line);
104
+ const finalized = finalizeCommit(currentCommit, diffLines);
105
+ if (finalized) commits.push(finalized);
106
+ return commits;
107
+ };
108
+ const showCommit = (commit, index, total) => {
109
+ log.clearScreen();
110
+ log.show(`📝 Commit ${index}/${total}`);
111
+ log.show(`${colors.cyan(commit.hash)} - ${colors.yellow(commit.author)} - ${colors.dim(commit.date)}`);
112
+ log.show(`${commit.message}`);
113
+ log.show(colors.bold("--- Diff ---"));
114
+ if (commit.diff.includes("Binary files differ")) {
115
+ log.show("📄 Binary file - diff not available");
116
+ log.show(commit.diff);
117
+ } else {
118
+ const diffLines = commit.diff.split("\n");
119
+ if (diffLines.length > MAX_DIFF_LINES) {
120
+ log.show(colors.dim(`(Showing first ${MAX_DIFF_LINES} lines of ${diffLines.length})`));
121
+ log.show(diffLines.slice(0, MAX_DIFF_LINES).join("\n"));
122
+ log.show(colors.dim("\n... (truncated)"));
123
+ } else log.show(commit.diff);
124
+ }
125
+ log.show(colors.bold("\n--- Actions ---"));
126
+ log.show("[n] Next commit [p] Previous commit [j] Jump [q] Quit");
127
+ };
128
+ const enterInteractiveBlameMode = async (filePath, commits) => {
129
+ if (commits.length === 0) {
130
+ log.show("No commits to display", { type: "warn" });
131
+ return;
132
+ }
133
+ let currentIndex = 0;
134
+ while (true) {
135
+ const currentCommit = commits[currentIndex];
136
+ if (!currentCommit) break;
137
+ await showCommit(currentCommit, currentIndex + 1, commits.length);
138
+ let input;
139
+ try {
140
+ input = await createCheckbox({
141
+ message: "Enter action:",
142
+ options: [
143
+ {
144
+ label: currentIndex < commits.length - 1 ? "Next commit" : "Next commit (already at latest)",
145
+ value: "n"
146
+ },
147
+ {
148
+ label: currentIndex > 0 ? "Previous commit" : "Previous commit (already at earliest)",
149
+ value: "p"
150
+ },
151
+ {
152
+ label: "Jump to commit",
153
+ value: "j"
154
+ },
155
+ {
156
+ label: "Quit",
157
+ value: "q"
158
+ }
159
+ ]
160
+ });
161
+ } catch (error) {
162
+ log.show("Error reading input. Exiting...", { type: "error" });
163
+ return;
164
+ }
165
+ if (!input || input.length === 0) {
166
+ log.show("No input received. Exiting...", { type: "warn" });
167
+ break;
168
+ }
169
+ if (input[0] === "q") exit(0);
170
+ if (input[0] === "n" && currentIndex < commits.length - 1) currentIndex++;
171
+ else if (input[0] === "p" && currentIndex > 0) currentIndex--;
172
+ else if (input[0] === "j") {
173
+ log.show(`\nEnter commit number (1-${commits.length}):`);
174
+ const jumpInput = await readUserInput();
175
+ const jumpNum = Number.parseInt(jumpInput, 10);
176
+ if (!isNaN(jumpNum) && jumpNum >= 1 && jumpNum <= commits.length) currentIndex = jumpNum - 1;
177
+ else {
178
+ log.show(`Invalid number. Please enter 1-${commits.length}`, { type: "error" });
179
+ await new Promise((resolve) => setTimeout(resolve, 1500));
180
+ }
181
+ }
182
+ }
183
+ };
184
+ const readUserInput = () => {
185
+ return new Promise((resolve) => {
186
+ const rl = readline.createInterface({
187
+ input: process.stdin,
188
+ output: process.stdout
189
+ });
190
+ const cleanup = () => {
191
+ rl.close();
192
+ rl.removeAllListeners();
193
+ };
194
+ rl.on("SIGINT", () => {
195
+ cleanup();
196
+ exit(0);
197
+ });
198
+ rl.question("", (answer) => {
199
+ cleanup();
200
+ resolve(answer.trim());
201
+ });
202
+ });
203
+ };
204
+ const handleGitError = (error, context) => {
205
+ const errorMessage = error.message || String(error);
206
+ if (errorMessage.includes("not a git repository")) {
207
+ log.show("❌ Error: Not in a git repository", { type: "error" });
208
+ return;
209
+ }
210
+ if (errorMessage.includes("does not exist") || errorMessage.includes("no such file")) {
211
+ log.show("❌ Error: File not found", { type: "error" });
212
+ return;
213
+ }
214
+ log.show(`❌ Error: ${errorMessage}`, { type: "error" });
215
+ };
216
+
217
+ //#endregion
8
218
  //#region src/constants/index.ts
9
219
  const HELP_MESSAGE$1 = {
10
220
  main: createHelpExample("ng --version", "ng --help", "ng <command> [option]"),
@@ -13,9 +223,142 @@ const HELP_MESSAGE$1 = {
13
223
  branchClean: createHelpExample("ng branch clean --version", "ng branch clean --help")
14
224
  };
15
225
 
226
+ //#endregion
227
+ //#region src/utils/stash-index.ts
228
+ /**
229
+ * Stash 索引文件路径
230
+ */
231
+ const STASH_INDEX_FILENAME = "ng-stash-index.json";
232
+ /**
233
+ * 获取 Stash 索引文件的完整路径
234
+ * @returns Stash 索引文件路径,如果不在 Git 仓库中则返回 null
235
+ */
236
+ async function getStashIndexPath() {
237
+ const gitRoot = await getGitRoot();
238
+ if (!gitRoot) return null;
239
+ return join(gitRoot, ".git", STASH_INDEX_FILENAME);
240
+ }
241
+ /**
242
+ * 读取 Stash 索引文件
243
+ * @returns Stash 索引对象,如果文件不存在或读取失败则返回空对象
244
+ */
245
+ async function readStashIndex() {
246
+ const indexPath = await getStashIndexPath();
247
+ if (!indexPath) {
248
+ log.show("Not in a Git repository. Cannot read stash index.", { type: "error" });
249
+ return {};
250
+ }
251
+ try {
252
+ const content = await readFile(indexPath, "utf-8");
253
+ const index = JSON.parse(content);
254
+ if (typeof index !== "object" || index === null || Array.isArray(index)) throw new Error("Invalid stash index format: expected an object");
255
+ for (const [branchName, stashes] of Object.entries(index)) {
256
+ if (!Array.isArray(stashes)) throw new Error(`Invalid stash index format: branch "${branchName}" should have an array of stashes`);
257
+ for (const stash of stashes) if (typeof stash.stashRef !== "string" || typeof stash.timestamp !== "string" || typeof stash.createdAt !== "string" || typeof stash.message !== "string") throw new Error(`Invalid stash index format: stash metadata for branch "${branchName}" is missing required fields`);
258
+ }
259
+ return index;
260
+ } catch (error) {
261
+ const err = error;
262
+ if (err.code === "ENOENT") return {};
263
+ if (err instanceof SyntaxError) {
264
+ log.show(`Failed to parse stash index file: ${err.message}. The file may be corrupted.`, { type: "error" });
265
+ return {};
266
+ }
267
+ log.show(`Failed to read stash index file: ${err.message}`, { type: "error" });
268
+ return {};
269
+ }
270
+ }
271
+ /**
272
+ * 写入 Stash 索引文件
273
+ * @param index - Stash 索引对象
274
+ * @throws 如果写入失败会抛出错误
275
+ */
276
+ async function writeStashIndex(index) {
277
+ const indexPath = await getStashIndexPath();
278
+ if (!indexPath) throw new Error("Not in a Git repository. Cannot write stash index.");
279
+ try {
280
+ await mkdir(dirname(indexPath), { recursive: true });
281
+ await writeFile(indexPath, JSON.stringify(index, null, 2), "utf-8");
282
+ } catch (error) {
283
+ const err = error;
284
+ log.show(`Failed to write stash index file: ${err.message}`, { type: "error" });
285
+ throw new Error(`Failed to write stash index: ${err.message}`);
286
+ }
287
+ }
288
+ /**
289
+ * 获取分支的所有 Stash 元数据
290
+ * @param branchName - 分支名称
291
+ * @returns Stash 元数据数组,如果分支不存在则返回空数组
292
+ */
293
+ async function getBranchStashes(branchName) {
294
+ return (await readStashIndex())[branchName] ?? [];
295
+ }
296
+ async function addStashMetadataWithDetails(branchName, metadata) {
297
+ const index = await readStashIndex();
298
+ if (!index[branchName]) index[branchName] = [];
299
+ index[branchName].push(metadata);
300
+ const indexPath = await getStashIndexPath();
301
+ if (!indexPath) throw new Error("Not in a Git repository");
302
+ const tmpPath = `${indexPath}.tmp.${Date.now()}`;
303
+ await mkdir(dirname(indexPath), { recursive: true });
304
+ await writeFile(tmpPath, JSON.stringify(index, null, 2), "utf-8");
305
+ try {
306
+ await rename(tmpPath, indexPath);
307
+ } catch (renameError) {
308
+ const err = renameError;
309
+ if (err.code === "EACCES" || err.code === "EBUSY" || err.code === "ENOENT") for (let i = 0; i < 3; i++) {
310
+ await new Promise((resolve) => setTimeout(resolve, 100));
311
+ try {
312
+ await rename(tmpPath, indexPath);
313
+ return;
314
+ } catch {
315
+ if (i === 2) throw new Error(`Failed to write stash index after 3 retries: ${err.message}`);
316
+ }
317
+ }
318
+ else throw err;
319
+ }
320
+ }
321
+ async function updateStashStatus(branchName, internalId, status, error) {
322
+ const index = await readStashIndex();
323
+ if (!index[branchName]) return;
324
+ const stash = index[branchName].find((s) => s.internalId === internalId);
325
+ if (!stash) return;
326
+ stash.status = status;
327
+ if (error) stash.error = error;
328
+ await writeStashIndex(index);
329
+ }
330
+ async function cleanOldStashes(days = 30) {
331
+ const index = await readStashIndex();
332
+ const cutoffDate = Date.now() - days * 24 * 60 * 60 * 1e3;
333
+ let count = 0;
334
+ for (const [branchName, stashes] of Object.entries(index)) {
335
+ const before = stashes.length;
336
+ index[branchName] = stashes.filter((stash) => {
337
+ if (stash.status === "active") return true;
338
+ return new Date(stash.timestamp).getTime() >= cutoffDate;
339
+ });
340
+ count += before - index[branchName].length;
341
+ if (index[branchName].length === 0) delete index[branchName];
342
+ }
343
+ await writeStashIndex(index);
344
+ return count;
345
+ }
346
+ async function getAllStashes(filterStatus) {
347
+ const index = await readStashIndex();
348
+ const allStashes = [];
349
+ for (const stashes of Object.values(index)) allStashes.push(...stashes);
350
+ if (filterStatus) return allStashes.filter((s) => s.status === filterStatus);
351
+ return allStashes;
352
+ }
353
+
16
354
  //#endregion
17
355
  //#region src/utils.ts
18
356
  const remotePrefix = /^origin\//;
357
+ const getRemotes = async () => {
358
+ const [error, result] = await xASync("git", ["remote"]);
359
+ if (error) throw new Error(`Failed to get remote repositories: ${error instanceof Error ? error.message : String(error)}`);
360
+ return { remotes: result.stdout.split("\n").filter((line) => line.trim()) };
361
+ };
19
362
  const getRemoteBranches = async () => {
20
363
  return { branches: (await x("git", [
21
364
  "branch",
@@ -33,7 +376,7 @@ const getLocalBranches = async () => {
33
376
  currentBranch: formatBranch$1(currentBranch)
34
377
  };
35
378
  };
36
- const getRemoteOptions = async () => {
379
+ const getRemoteBranchOptions = async () => {
37
380
  const { branches } = await getRemoteBranches();
38
381
  const currentBranch = await getCurrentBranch();
39
382
  return {
@@ -45,7 +388,7 @@ const getRemoteOptions = async () => {
45
388
  currentBranch
46
389
  };
47
390
  };
48
- const getLocalOptions = async () => {
391
+ const getLocalBranchOptions = async () => {
49
392
  const { branches, currentBranch } = await getLocalBranches();
50
393
  return {
51
394
  options: branches.map((branch) => ({
@@ -56,6 +399,18 @@ const getLocalOptions = async () => {
56
399
  currentBranch
57
400
  };
58
401
  };
402
+ const getRemoteRepositoryOptions = async () => {
403
+ const { remotes } = await getRemotes();
404
+ const validRemotes = remotes.filter((remote) => remote.trim().length > 0);
405
+ const uniqueRemotes = Array.from(new Set(validRemotes));
406
+ return {
407
+ options: uniqueRemotes.map((remote) => ({
408
+ label: remote,
409
+ value: remote
410
+ })),
411
+ remotes: uniqueRemotes
412
+ };
413
+ };
59
414
  /**
60
415
  * 处理合并提交信息
61
416
  */
@@ -139,18 +494,18 @@ const handleMergeCommit = async () => {
139
494
  }
140
495
  };
141
496
  const handleGitPull = async (branch, options = {}) => {
142
- const { rebase = false } = options;
497
+ const { rebase = false, remote = "origin" } = options;
143
498
  const modeText = rebase ? "rebase" : "merge";
144
499
  log.show(`Pulling from remote (${modeText} mode)...`, { type: "step" });
145
500
  try {
146
501
  const [error, result] = await xASync("git", rebase ? [
147
502
  "pull",
148
503
  "--rebase",
149
- "origin",
504
+ remote,
150
505
  branch
151
506
  ] : [
152
507
  "pull",
153
- "origin",
508
+ remote,
154
509
  branch
155
510
  ], { nodeOptions: { stdio: "inherit" } });
156
511
  if (error) {
@@ -164,30 +519,100 @@ const handleGitPull = async (branch, options = {}) => {
164
519
  return;
165
520
  }
166
521
  };
167
- const createStashName = () => `NEMO-CLI-STASH:${Date.now()}`;
168
- const handleGitStash = async (name = createStashName()) => {
522
+ const handleGitStash = async (message, options) => {
523
+ const [unstagedError, unstagedResult] = await xASync("git", ["diff", "--name-only"]);
524
+ const unstagedFiles = unstagedError ? [] : unstagedResult.stdout.split("\n").filter(Boolean);
525
+ const [stagedError, stagedResult] = await xASync("git", [
526
+ "diff",
527
+ "--cached",
528
+ "--name-only"
529
+ ]);
530
+ const stagedFiles = stagedError ? [] : stagedResult.stdout.split("\n").filter(Boolean);
531
+ const [untrackedError, untrackedResult] = await xASync("git", [
532
+ "ls-files",
533
+ "--others",
534
+ "--exclude-standard"
535
+ ]);
536
+ const untrackedFiles = untrackedError ? [] : untrackedResult.stdout.split("\n").filter(Boolean);
537
+ const files = [...new Set([
538
+ ...unstagedFiles,
539
+ ...stagedFiles,
540
+ ...untrackedFiles
541
+ ])];
542
+ if (files.length === 0) {
543
+ log.show("No file changes to stash.");
544
+ return null;
545
+ }
546
+ const [, commitResult] = await xASync("git", ["rev-parse", "HEAD"]);
547
+ const commitHash = commitResult?.stdout.trim() || "";
548
+ const currentBranch = await getCurrentBranch();
549
+ const { branch = void 0, operation = "manual" } = options || {};
550
+ const now = /* @__PURE__ */ new Date();
551
+ let stashName;
552
+ if (message && message.trim()) stashName = message.trim();
553
+ else stashName = `${operation}:${currentBranch}@${now.toISOString().replace(/[:.]/g, "-").slice(0, 19)}`;
554
+ const internalId = `${Date.now()}_${operation}_${currentBranch.replace(/[/]/g, "_")}`;
169
555
  const [error, result] = await xASync("git", [
170
556
  "stash",
171
557
  "save",
172
- name
558
+ "-u",
559
+ stashName
173
560
  ]);
174
561
  if (error) {
175
- log.show(`Failed to stash changes. ${name}`, { type: "error" });
562
+ log.show(`Failed to stash changes. ${stashName}`, { type: "error" });
176
563
  return null;
177
564
  }
178
- if (result?.stdout.includes(name)) {
179
- log.show(`Successfully stashed changes. ${name}`, { type: "success" });
180
- return name;
565
+ if (!result?.stdout.includes(stashName)) {
566
+ log.show("No file changes to stash.");
567
+ return null;
181
568
  }
182
- log.show("No file changes.");
183
- return null;
569
+ const stashRefMatch = result.stdout.match(/stash@\{(\d+)\}/);
570
+ const metadata = {
571
+ stashRef: stashRefMatch ? stashRefMatch[0] : "stash@{0}",
572
+ timestamp: now.toISOString(),
573
+ createdAt: now.toISOString(),
574
+ message: stashName,
575
+ internalId,
576
+ operation,
577
+ currentBranch,
578
+ targetBranch: branch,
579
+ files,
580
+ status: "active",
581
+ commitHash
582
+ };
583
+ await addStashMetadataWithDetails(currentBranch, metadata);
584
+ log.show(`Successfully stashed changes. ${stashName}`, { type: "success" });
585
+ return {
586
+ metadata,
587
+ stashName
588
+ };
184
589
  };
185
590
  const handleGitStashCheck = async () => {
186
591
  const [error, result] = await xASync("git", ["stash", "list"]);
187
592
  if (error) return [];
188
593
  return result.stdout.split("\n").filter((line) => line.trim());
189
594
  };
190
- const handleGitPop = async (branch) => {
595
+ const handleGitPop = async (stashOrResult) => {
596
+ if (typeof stashOrResult !== "string") {
597
+ const { metadata } = stashOrResult;
598
+ const [error, result] = await xASync("git", [
599
+ "stash",
600
+ "pop",
601
+ metadata.stashRef
602
+ ]);
603
+ if (error) {
604
+ log.show(`Failed to pop stash: ${error.message}`, { type: "error" });
605
+ if (metadata.internalId && metadata.currentBranch) await updateStashStatus(metadata.currentBranch, metadata.internalId, "popped", error.message);
606
+ return;
607
+ }
608
+ createNote({
609
+ message: result.stdout,
610
+ title: "Successfully popped changes."
611
+ });
612
+ if (metadata.internalId && metadata.currentBranch) await updateStashStatus(metadata.currentBranch, metadata.internalId, "popped");
613
+ return;
614
+ }
615
+ const branch = stashOrResult;
191
616
  const stashName = (await handleGitStashCheck()).find((stash) => stash.includes(branch));
192
617
  if (!stashName) {
193
618
  log.show(`No stash found for this branch: ${colors.bgRed(branch)}.`, { type: "warn" });
@@ -231,12 +656,17 @@ const isBranchMergedToMain = async (branches) => {
231
656
  };
232
657
  }));
233
658
  };
659
+ const getGitRoot = async () => {
660
+ const [error, result] = await xASync("git", ["rev-parse", "--show-toplevel"]);
661
+ if (error) return "";
662
+ return result.stdout.trim();
663
+ };
234
664
  const checkGitRepository = async () => {
235
665
  try {
236
666
  const [error, result] = await xASync("git", ["rev-parse", "--is-inside-work-tree"], { quiet: true });
237
667
  if (error) return false;
238
668
  return result.stdout.trim() === "true";
239
- } catch (err) {
669
+ } catch (_err) {
240
670
  return false;
241
671
  }
242
672
  };
@@ -373,20 +803,46 @@ function branchCommand(command) {
373
803
 
374
804
  //#endregion
375
805
  //#region src/commands/checkout.ts
806
+ /**
807
+ * 查找并恢复当前分支的 stash
808
+ * 当用户 checkout 回之前的分支时,自动恢复该分支的 stash
809
+ */
810
+ const restoreBranchStash = async (branchName) => {
811
+ try {
812
+ const activeStashes = (await getBranchStashes(branchName)).filter((s) => s.status === "active" && s.operation === "checkout").sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
813
+ if (activeStashes.length === 0) return;
814
+ const latestStash = activeStashes[0];
815
+ if (!latestStash) return;
816
+ log.show(`Found stashed changes from previous checkout to ${colors.bgYellow(latestStash.targetBranch || "unknown")}. Restoring...`, { type: "info" });
817
+ await handleGitPop({
818
+ metadata: latestStash,
819
+ stashName: latestStash.message
820
+ });
821
+ } catch (_error) {
822
+ log.show(`Failed to restore stash for branch ${branchName}`, { type: "warn" });
823
+ }
824
+ };
376
825
  const handleCheckout = async (branch, { isNew = false, isRemote = false } = {}) => {
826
+ const currentBranchName = await getCurrentBranch();
377
827
  const args = ["checkout"];
378
828
  if (isNew || isRemote) args.push("-b");
379
829
  args.push(branch);
380
830
  if (isRemote) args.push(`origin/${branch}`);
381
- const stashName = await handleGitStash(branch);
831
+ const stashResult = branch !== currentBranchName ? await handleGitStash(void 0, {
832
+ branch,
833
+ operation: "checkout"
834
+ }) : null;
382
835
  const process = x("git", args);
836
+ log.show(stashResult?.stashName, { type: "info" });
383
837
  for await (const line of process) log.show(line);
384
838
  const { exitCode, stderr } = await process;
385
839
  if (exitCode) {
386
840
  log.show(`Failed to checkout branch ${branch}. Command exited with code ${exitCode}.`, { type: "error" });
387
841
  log.show(stderr, { type: "error" });
388
- } else log.show(`Successfully checked out branch ${branch}.`, { type: "success" });
389
- stashName && handleGitPop(stashName);
842
+ } else {
843
+ log.show(`Successfully checked out branch ${branch}.`, { type: "success" });
844
+ await restoreBranchStash(branch);
845
+ }
390
846
  };
391
847
  function checkoutCommand(command) {
392
848
  command.command("checkout").alias("co").option("-l, --local", "Checkout a local branch", true).option("-r, --remote", "Checkout a remote branch").option("-b, --branch [branch]", "Create and checkout a new branch").description("Checkout a branch").action(async (params) => {
@@ -425,13 +881,13 @@ function checkoutCommand(command) {
425
881
  initialValue: true
426
882
  });
427
883
  if (isLocal) {
428
- const { options } = await getLocalOptions();
884
+ const { options } = await getLocalBranchOptions();
429
885
  handleCheckout(await createSelect({
430
886
  message: `Select the ${colors.bgGreen(" local ")} branch to checkout`,
431
887
  options
432
888
  }));
433
889
  } else {
434
- const { options } = await getRemoteOptions();
890
+ const { options } = await getRemoteBranchOptions();
435
891
  const selectedBranch = await createSelect({
436
892
  message: `Select the ${colors.bgYellow(" remote ")} branch to checkout`,
437
893
  options
@@ -581,7 +1037,7 @@ const commitlintConfig = {
581
1037
  };
582
1038
  const mergeCommitTypeEnumOptions = (options) => {
583
1039
  return options.map((option) => {
584
- const commitTypeOption = commitTypeOptions.find((commitTypeOption$1) => commitTypeOption$1.value === option);
1040
+ const commitTypeOption = commitTypeOptions.find((commitTypeOption) => commitTypeOption.value === option);
585
1041
  return {
586
1042
  value: commitTypeOption?.value ?? option,
587
1043
  label: commitTypeOption?.label ?? option,
@@ -593,7 +1049,7 @@ const mergeCommitTypeEnumOptions = (options) => {
593
1049
  };
594
1050
  const mergeCommitScopeEnumOptions = (options) => {
595
1051
  return (options.includes("none") ? options : options.concat("none")).map((option) => {
596
- const commitScopeOption = commitScopeOptions.find((commitScopeOption$1) => commitScopeOption$1.label === option);
1052
+ const commitScopeOption = commitScopeOptions.find((commitScopeOption) => commitScopeOption.label === option);
597
1053
  return {
598
1054
  value: commitScopeOption?.value ?? option,
599
1055
  label: commitScopeOption?.label ?? option
@@ -603,21 +1059,22 @@ const mergeCommitScopeEnumOptions = (options) => {
603
1059
 
604
1060
  //#endregion
605
1061
  //#region src/commands/push.ts
606
- const handlePush = async (branch) => {
607
- const spinner = createSpinner(`Pushing branch ${branch} to remote...`);
1062
+ const handlePush = async (branch, remote = "origin") => {
1063
+ const spinner = createSpinner(`Pushing branch ${branch} to ${remote}...`);
608
1064
  try {
609
- const process = x("git", [
1065
+ const [error, result] = await xASync("git", [
610
1066
  "push",
611
- "origin",
1067
+ remote,
612
1068
  branch
613
1069
  ]);
614
- for await (const line of process) spinner.message(line);
615
- const code = process.exitCode;
616
- if (code) throw new Error(`Failed code ${code}`);
617
- spinner.stop(colors.green(`Successfully pushed branch ${colors.bgGreen(branch)} to remote.`));
1070
+ if (error) {
1071
+ const errorMessage = error instanceof Error ? error.message : String(error);
1072
+ throw new Error(`Failed with error: ${errorMessage}`);
1073
+ }
1074
+ spinner.stop(colors.green(`Successfully pushed branch ${colors.bgGreen(branch)} to ${remote}.`));
618
1075
  } catch (error) {
619
1076
  spinner.stop();
620
- BigText({ text: `Failed to push branch ${branch}.` });
1077
+ BigText({ text: `Failed to push branch ${branch} to ${remote}.` });
621
1078
  log.error(error);
622
1079
  }
623
1080
  };
@@ -632,16 +1089,50 @@ const pushInteractive = async () => {
632
1089
  log.error("No branch selected. Aborting push operation.");
633
1090
  return;
634
1091
  }
635
- if (await createConfirm({ message: `Do you want to push ${colors.bgGreen(currentBranch)} to remote?` })) {
636
- await handlePush(currentBranch);
1092
+ let repositories;
1093
+ try {
1094
+ repositories = (await getRemoteRepositoryOptions()).remotes;
1095
+ } catch (error) {
1096
+ log.error(`Failed to get remote repositories: ${error instanceof Error ? error.message : String(error)}`);
1097
+ log.show("Hint: Make sure you're in a git repository and have configured remotes.", { type: "info" });
1098
+ return;
1099
+ }
1100
+ if (repositories.length === 0) {
1101
+ log.error("No remote repositories found. Aborting push operation.");
1102
+ log.show("Hint: Run `git remote add <name> <url>` to add a remote repository.", { type: "info" });
1103
+ return;
1104
+ }
1105
+ let selectedRepository = repositories[0];
1106
+ if (repositories.length > 1) selectedRepository = await createSelect({
1107
+ message: "Select remote repository",
1108
+ options: repositories.map((repo) => ({
1109
+ label: repo,
1110
+ value: repo
1111
+ })),
1112
+ initialValue: repositories[0]
1113
+ });
1114
+ if (await createConfirm({ message: `Do you want to push ${colors.bgGreen(currentBranch)} to ${selectedRepository}?` })) {
1115
+ await handlePush(currentBranch, selectedRepository);
637
1116
  return;
638
1117
  }
639
- const { options } = await getRemoteOptions();
640
- await handlePush(await createSelect({
1118
+ const { options } = await getRemoteBranchOptions();
1119
+ const selectedBranch = await createSelect({
641
1120
  message: "Select the branch to push",
642
1121
  options,
643
1122
  initialValue: "main"
644
- }));
1123
+ });
1124
+ selectedRepository = repositories[0];
1125
+ if (repositories.length > 1) {
1126
+ if (!await createConfirm({ message: `Push ${colors.bgGreen(selectedBranch)} to ${selectedRepository}?` })) selectedRepository = await createSelect({
1127
+ message: "Select remote repository",
1128
+ options: repositories.map((repo) => ({
1129
+ label: repo,
1130
+ value: repo
1131
+ })),
1132
+ initialValue: selectedRepository
1133
+ });
1134
+ }
1135
+ await handlePush(selectedBranch, selectedRepository);
645
1136
  };
646
1137
 
647
1138
  //#endregion
@@ -765,6 +1256,459 @@ const getTicket = async () => {
765
1256
  return branch;
766
1257
  };
767
1258
 
1259
+ //#endregion
1260
+ //#region src/commands/config.ts
1261
+ /** biome-ignore-all lint/suspicious/noExplicitAny: need any */
1262
+ const GITCONFIG_PATH = join(homedir(), ".gitconfig");
1263
+ const readGitConfig = () => {
1264
+ try {
1265
+ const content = readFileSync(GITCONFIG_PATH, "utf-8");
1266
+ const config = {};
1267
+ let currentSection = null;
1268
+ let subsection = null;
1269
+ for (const line of content.split("\n")) {
1270
+ const trimmed = line.trim();
1271
+ if (!trimmed || trimmed.startsWith("#")) continue;
1272
+ const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
1273
+ if (sectionMatch) {
1274
+ currentSection = sectionMatch[1] || null;
1275
+ subsection = null;
1276
+ if (currentSection && !config[currentSection]) config[currentSection] = {};
1277
+ continue;
1278
+ }
1279
+ const subsectionMatch = trimmed.match(/^\[([^\s]+) "([^"]+)"\]$/);
1280
+ if (subsectionMatch) {
1281
+ currentSection = subsectionMatch[1] || null;
1282
+ subsection = subsectionMatch[2] || null;
1283
+ if (currentSection && !config[currentSection]) config[currentSection] = {};
1284
+ if (subsection && currentSection && typeof config[currentSection] === "object") {
1285
+ if (!config[currentSection][subsection]) config[currentSection][subsection] = {};
1286
+ }
1287
+ continue;
1288
+ }
1289
+ const keyValueMatch = trimmed.match(/^(\w+)\s*=\s*(.+)$/);
1290
+ if (keyValueMatch && currentSection) {
1291
+ const key = keyValueMatch[1];
1292
+ const value = keyValueMatch[2];
1293
+ let target = config[currentSection];
1294
+ if (subsection && typeof target === "object") {
1295
+ if (!target[subsection]) target[subsection] = {};
1296
+ target = target[subsection];
1297
+ }
1298
+ if (key && value === "true") target[key] = true;
1299
+ else if (key && value === "false") target[key] = false;
1300
+ else if (key) target[key] = value;
1301
+ }
1302
+ }
1303
+ return config;
1304
+ } catch {
1305
+ return {};
1306
+ }
1307
+ };
1308
+ const writeGitConfig = (config) => {
1309
+ const lines = [];
1310
+ const sections = Object.keys(config).sort();
1311
+ for (const section of sections) {
1312
+ const value = config[section];
1313
+ if (!value || typeof value !== "object") continue;
1314
+ if (Object.keys(value).length === 0) continue;
1315
+ let isSubsection = false;
1316
+ for (const key of Object.keys(value)) if (typeof value[key] === "object" && key !== "alias") {
1317
+ isSubsection = true;
1318
+ break;
1319
+ }
1320
+ if (isSubsection) for (const [subsection, subValue] of Object.entries(value)) {
1321
+ if (typeof subValue !== "object" || subValue === null) continue;
1322
+ lines.push(`[${section} "${subsection}"]`);
1323
+ for (const [key, val] of Object.entries(subValue)) lines.push(`\t${key} = ${val}`);
1324
+ lines.push("");
1325
+ }
1326
+ else {
1327
+ lines.push(`[${section}]`);
1328
+ for (const [key, val] of Object.entries(value)) if (typeof val === "object" && val !== null) for (const [subKey, subVal] of Object.entries(val)) lines.push(`\t${subKey} = ${subVal}`);
1329
+ else lines.push(`\t${key} = ${val}`);
1330
+ lines.push("");
1331
+ }
1332
+ }
1333
+ writeFileSync(GITCONFIG_PATH, lines.join("\n"), "utf-8");
1334
+ };
1335
+ const displayConfig = (config) => {
1336
+ const lines = [];
1337
+ if (config.user) {
1338
+ lines.push("👤 User Information:");
1339
+ if (config.user.name) lines.push(` Name: ${config.user.name}`);
1340
+ if (config.user.email) lines.push(` Email: ${config.user.email}`);
1341
+ lines.push("");
1342
+ }
1343
+ if (config.init?.defaultBranch) {
1344
+ lines.push("🌳 Initial Branch:");
1345
+ lines.push(` Default: ${config.init.defaultBranch}`);
1346
+ lines.push("");
1347
+ }
1348
+ if (config.pull) {
1349
+ lines.push("📥 Pull Strategy:");
1350
+ if (config.pull.rebase !== void 0) lines.push(` Rebase: ${config.pull.rebase}`);
1351
+ lines.push("");
1352
+ }
1353
+ if (config.push) {
1354
+ lines.push("📤 Push Strategy:");
1355
+ if (config.push.default) lines.push(` Default: ${config.push.default}`);
1356
+ if (config.push.autoSetupRemote !== void 0) lines.push(` Auto Setup Remote: ${config.push.autoSetupRemote}`);
1357
+ lines.push("");
1358
+ }
1359
+ if (config.color) {
1360
+ lines.push("🎨 Color:");
1361
+ if (config.color.ui !== void 0) lines.push(` UI: ${config.color.ui}`);
1362
+ lines.push("");
1363
+ }
1364
+ if (config.alias && Object.keys(config.alias).length > 0) {
1365
+ lines.push("⚡ Aliases:");
1366
+ for (const [alias, command] of Object.entries(config.alias)) lines.push(` ${alias} = ${String(command)}`);
1367
+ lines.push("");
1368
+ }
1369
+ return lines.join("\n");
1370
+ };
1371
+ const configureUserInfo = async (config) => {
1372
+ const action = await createSelect({
1373
+ message: "What would you like to configure?",
1374
+ options: [
1375
+ {
1376
+ label: "Name",
1377
+ value: "name"
1378
+ },
1379
+ {
1380
+ label: "Email",
1381
+ value: "email"
1382
+ },
1383
+ {
1384
+ label: "Both",
1385
+ value: "both"
1386
+ }
1387
+ ]
1388
+ });
1389
+ if (action === "name" || action === "both") {
1390
+ const name = await createInput({
1391
+ message: "Enter your name:",
1392
+ initialValue: config.user?.name || "",
1393
+ placeholder: config.user?.name || "Your name"
1394
+ });
1395
+ if (!config.user) config.user = {};
1396
+ config.user.name = name || config.user.name;
1397
+ }
1398
+ if (action === "email" || action === "both") {
1399
+ const email = await createInput({
1400
+ message: "Enter your email:",
1401
+ initialValue: config.user?.email || "",
1402
+ placeholder: config.user?.email || "your@email.com"
1403
+ });
1404
+ if (!config.user) config.user = {};
1405
+ config.user.email = email || config.user.email;
1406
+ }
1407
+ return config;
1408
+ };
1409
+ const configureCommonOptions = async (config) => {
1410
+ const options = await createCheckbox({
1411
+ message: "Select options to configure:",
1412
+ options: [
1413
+ {
1414
+ label: "Default branch name",
1415
+ value: "defaultBranch"
1416
+ },
1417
+ {
1418
+ label: "Pull rebase strategy",
1419
+ value: "pullRebase"
1420
+ },
1421
+ {
1422
+ label: "Push default strategy",
1423
+ value: "pushDefault"
1424
+ },
1425
+ {
1426
+ label: "Enable color output",
1427
+ value: "colorUi"
1428
+ },
1429
+ {
1430
+ label: "Auto stash on rebase",
1431
+ value: "autoStash"
1432
+ }
1433
+ ],
1434
+ required: false
1435
+ });
1436
+ for (const option of options) switch (option) {
1437
+ case "defaultBranch": {
1438
+ const branch = await createInput({
1439
+ message: "Enter default branch name:",
1440
+ initialValue: config.init?.defaultBranch || "main",
1441
+ placeholder: "main"
1442
+ });
1443
+ if (!config.init) config.init = {};
1444
+ config.init.defaultBranch = branch || config.init.defaultBranch || "main";
1445
+ break;
1446
+ }
1447
+ case "pullRebase": {
1448
+ const rebase = await createSelect({
1449
+ message: "Select pull rebase strategy:",
1450
+ options: [
1451
+ {
1452
+ label: "False (merge)",
1453
+ value: "false"
1454
+ },
1455
+ {
1456
+ label: "True (rebase)",
1457
+ value: "true"
1458
+ },
1459
+ {
1460
+ label: "Interactive",
1461
+ value: "interactive"
1462
+ }
1463
+ ]
1464
+ });
1465
+ if (!config.pull) config.pull = {};
1466
+ config.pull.rebase = rebase === "false" ? false : rebase;
1467
+ break;
1468
+ }
1469
+ case "pushDefault": {
1470
+ const pushDefault = await createSelect({
1471
+ message: "Select push default strategy:",
1472
+ options: [
1473
+ {
1474
+ label: "current",
1475
+ value: "current"
1476
+ },
1477
+ {
1478
+ label: "upstream",
1479
+ value: "upstream"
1480
+ },
1481
+ {
1482
+ label: "simple",
1483
+ value: "simple"
1484
+ },
1485
+ {
1486
+ label: "matching",
1487
+ value: "matching"
1488
+ }
1489
+ ]
1490
+ });
1491
+ if (!config.push) config.push = {};
1492
+ config.push.default = pushDefault;
1493
+ break;
1494
+ }
1495
+ case "colorUi": {
1496
+ const color = await createSelect({
1497
+ message: "Select color output:",
1498
+ options: [
1499
+ {
1500
+ label: "Auto",
1501
+ value: "auto"
1502
+ },
1503
+ {
1504
+ label: "Always",
1505
+ value: "always"
1506
+ },
1507
+ {
1508
+ label: "Never",
1509
+ value: "false"
1510
+ }
1511
+ ]
1512
+ });
1513
+ if (!config.color) config.color = {};
1514
+ config.color.ui = color === "false" ? false : color;
1515
+ break;
1516
+ }
1517
+ case "autoStash": {
1518
+ const autoStash = await createConfirm({
1519
+ message: "Enable auto stash on rebase?",
1520
+ initialValue: config.rebase?.autoStash === true
1521
+ });
1522
+ if (!config.rebase) config.rebase = {};
1523
+ config.rebase.autoStash = autoStash;
1524
+ break;
1525
+ }
1526
+ }
1527
+ return config;
1528
+ };
1529
+ const configureAliases = async (config) => {
1530
+ const action = await createSelect({
1531
+ message: "What would you like to do?",
1532
+ options: [
1533
+ {
1534
+ label: "Add new alias",
1535
+ value: "add"
1536
+ },
1537
+ {
1538
+ label: "Remove alias",
1539
+ value: "remove"
1540
+ },
1541
+ {
1542
+ label: "List all aliases",
1543
+ value: "list"
1544
+ }
1545
+ ]
1546
+ });
1547
+ if (action === "add") {
1548
+ const alias = await createInput({
1549
+ message: "Enter alias name:",
1550
+ initialValue: "",
1551
+ placeholder: "st"
1552
+ });
1553
+ const command = await createInput({
1554
+ message: "Enter git command:",
1555
+ initialValue: "",
1556
+ placeholder: "status"
1557
+ });
1558
+ if (!alias || !command) {
1559
+ Message({ text: "Alias name and command cannot be empty." });
1560
+ return config;
1561
+ }
1562
+ if (!config.alias) config.alias = {};
1563
+ config.alias[alias] = command;
1564
+ Message({ text: `Added alias: ${alias} = ${command}` });
1565
+ } else if (action === "remove") {
1566
+ if (!config.alias || Object.keys(config.alias).length === 0) {
1567
+ Message({ text: "No aliases to remove." });
1568
+ return config;
1569
+ }
1570
+ const toRemove = await createSelect({
1571
+ message: "Select alias to remove:",
1572
+ options: Object.keys(config.alias).map((key) => ({
1573
+ label: `${key} = ${config.alias[key]}`,
1574
+ value: key
1575
+ }))
1576
+ });
1577
+ delete config.alias[toRemove];
1578
+ Message({ text: `Removed alias: ${toRemove}` });
1579
+ } else if (action === "list") if (!config.alias || Object.keys(config.alias).length === 0) Message({ text: "No aliases configured." });
1580
+ else {
1581
+ const aliasList = Object.entries(config.alias).map(([alias, command]) => `${alias} = ${command}`).join("\n");
1582
+ log.show(aliasList, { type: "info" });
1583
+ }
1584
+ return config;
1585
+ };
1586
+ const initializeConfig = async (config) => {
1587
+ Message({ text: "🚀 Let's set up your git configuration!" });
1588
+ const name = await createInput({
1589
+ message: "What is your name?",
1590
+ initialValue: config.user?.name || "",
1591
+ placeholder: "GaoZimeng"
1592
+ });
1593
+ if (name) {
1594
+ if (!config.user) config.user = {};
1595
+ config.user.name = name;
1596
+ }
1597
+ const email = await createInput({
1598
+ message: "What is your email?",
1599
+ initialValue: config.user?.email || "",
1600
+ placeholder: "your@email.com"
1601
+ });
1602
+ if (email) {
1603
+ if (!config.user) config.user = {};
1604
+ config.user.email = email;
1605
+ }
1606
+ const defaultBranch = await createInput({
1607
+ message: "What should be the default branch name?",
1608
+ initialValue: config.init?.defaultBranch || "main",
1609
+ placeholder: "main"
1610
+ });
1611
+ if (defaultBranch) {
1612
+ if (!config.init) config.init = {};
1613
+ config.init.defaultBranch = defaultBranch;
1614
+ }
1615
+ const enableColor = await createConfirm({
1616
+ message: "Enable colored output?",
1617
+ initialValue: config.color?.ui !== false
1618
+ });
1619
+ if (!config.color) config.color = {};
1620
+ config.color.ui = enableColor ? "auto" : false;
1621
+ const autoSetupRemote = await createConfirm({
1622
+ message: "Automatically setup remote branch when pushing?",
1623
+ initialValue: config.push?.autoSetupRemote !== false
1624
+ });
1625
+ if (!config.push) config.push = {};
1626
+ config.push.autoSetupRemote = autoSetupRemote;
1627
+ const autoStash = await createConfirm({
1628
+ message: "Auto stash before rebase?",
1629
+ initialValue: config.rebase?.autoStash !== false
1630
+ });
1631
+ if (!config.rebase) config.rebase = {};
1632
+ config.rebase.autoStash = autoStash;
1633
+ return config;
1634
+ };
1635
+ const configCommand = (command) => {
1636
+ const subCommand = command.command("config").description("Interactive git configuration manager");
1637
+ subCommand.command("init").description("Quick setup basic git configuration").action(async () => {
1638
+ const updatedConfig = await initializeConfig(readGitConfig());
1639
+ const spinner = createSpinner("Saving configuration...");
1640
+ writeGitConfig(updatedConfig);
1641
+ spinner.stop("✓ Git configuration initialized successfully!");
1642
+ const display = displayConfig(updatedConfig);
1643
+ if (display) log.show(`\n${display}`);
1644
+ });
1645
+ subCommand.action(async () => {
1646
+ let continueRunning = true;
1647
+ while (continueRunning) {
1648
+ const action = await createSelect({
1649
+ message: "What would you like to do?",
1650
+ options: [
1651
+ {
1652
+ label: "📖 View current config",
1653
+ value: "view"
1654
+ },
1655
+ {
1656
+ label: "👤 Configure user info",
1657
+ value: "user"
1658
+ },
1659
+ {
1660
+ label: "⚙️ Configure options",
1661
+ value: "options"
1662
+ },
1663
+ {
1664
+ label: "⚡ Manage aliases",
1665
+ value: "aliases"
1666
+ },
1667
+ {
1668
+ label: "🧹 Clear all config",
1669
+ value: "clear"
1670
+ },
1671
+ {
1672
+ label: "❌ Exit",
1673
+ value: "exit"
1674
+ }
1675
+ ]
1676
+ });
1677
+ if (action === "exit") {
1678
+ continueRunning = false;
1679
+ continue;
1680
+ }
1681
+ if (action === "view") {
1682
+ const display = displayConfig(readGitConfig());
1683
+ if (display) log.show(`\n${display}`);
1684
+ else Message({ text: "No configuration found." });
1685
+ } else if (action === "user") {
1686
+ const updatedConfig = await configureUserInfo(readGitConfig());
1687
+ const spinner = createSpinner("Saving configuration...");
1688
+ writeGitConfig(updatedConfig);
1689
+ spinner.stop("✓ Configuration saved successfully!");
1690
+ } else if (action === "options") {
1691
+ const updatedConfig = await configureCommonOptions(readGitConfig());
1692
+ const spinner = createSpinner("Saving configuration...");
1693
+ writeGitConfig(updatedConfig);
1694
+ spinner.stop("✓ Configuration saved successfully!");
1695
+ } else if (action === "aliases") {
1696
+ const updatedConfig = await configureAliases(readGitConfig());
1697
+ const spinner = createSpinner("Saving configuration...");
1698
+ writeGitConfig(updatedConfig);
1699
+ spinner.stop("✓ Configuration saved successfully!");
1700
+ } else if (action === "clear") {
1701
+ if (await createConfirm({ message: "Are you sure you want to clear all git configuration?" })) {
1702
+ const spinner = createSpinner("Clearing configuration...");
1703
+ writeGitConfig({});
1704
+ spinner.stop("✓ Configuration cleared successfully!");
1705
+ }
1706
+ }
1707
+ }
1708
+ });
1709
+ return command;
1710
+ };
1711
+
768
1712
  //#endregion
769
1713
  //#region src/commands/diff.ts
770
1714
  const handleDiff = async (branch, { isLocal }) => {
@@ -807,6 +1751,15 @@ function diffCommand(command) {
807
1751
  });
808
1752
  }
809
1753
 
1754
+ //#endregion
1755
+ //#region src/commands/hist.ts
1756
+ const histCommand = (command) => {
1757
+ command.command("hist").alias("history").description("Show git history with beautiful graph format").option("-n, --number <count>", "Limit number of commits to show").action(async (options) => {
1758
+ await renderHistViewer(options.number ? Number.parseInt(options.number, 10) : void 0);
1759
+ });
1760
+ return command;
1761
+ };
1762
+
810
1763
  //#endregion
811
1764
  //#region src/commands/list.ts
812
1765
  function listCommand(command) {
@@ -854,18 +1807,43 @@ function listCommand(command) {
854
1807
  //#region src/commands/merge.ts
855
1808
  const handleMerge = async (branch) => {
856
1809
  const spinner = createSpinner(`Merging branch ${branch}...`);
857
- const args = ["merge", branch];
858
- const stashName = await handleGitStash();
859
- const [error] = await xASync("git", args, { nodeOptions: { stdio: "inherit" } });
860
- if (error) return;
861
- spinner.stop(`Successfully merged branch ${branch}.`);
862
- stashName && handleGitPop(stashName);
1810
+ const args = [
1811
+ "merge",
1812
+ "--no-edit",
1813
+ branch
1814
+ ];
1815
+ const stashResult = await handleGitStash(void 0, {
1816
+ branch,
1817
+ operation: "merge"
1818
+ });
1819
+ if (!stashResult) {
1820
+ spinner.stop("Cannot proceed with merge: failed to stash changes.");
1821
+ return;
1822
+ }
1823
+ try {
1824
+ const [error, result] = await xASync("git", args, { nodeOptions: { stdio: "inherit" } });
1825
+ if (error) {
1826
+ spinner.stop();
1827
+ const errorMessage = error instanceof Error ? error.message : String(error);
1828
+ throw new Error(`Failed to merge branch ${branch}: ${errorMessage}`);
1829
+ }
1830
+ spinner.stop();
1831
+ if (result.stdout.includes("Merge branch") || result.stdout.includes("Merge made by")) await handleMergeCommit();
1832
+ log.show(colors.green(`Successfully merged branch ${colors.bgGreen(branch)}.`), { type: "success" });
1833
+ } catch (error) {
1834
+ spinner.stop();
1835
+ log.show(`Failed to merge branch ${branch}.`, { type: "error" });
1836
+ log.error(error);
1837
+ throw error;
1838
+ } finally {
1839
+ if (stashResult) await handleGitPop(stashResult);
1840
+ }
863
1841
  };
864
1842
  function mergeCommand(command) {
865
1843
  command.command("merge").alias("mg").argument("[branch]", "The branch to merge").option("-l, --local", "Merge a local branch").option("-r, --remote", "Merge a remote branch").option("-b, --branch <branch>", "Create and merge a new branch").description("Merge a branch").action(async (branch, params) => {
866
1844
  let isLocal = params.local;
867
1845
  if (branch) {
868
- handleMerge(branch);
1846
+ await handleMerge(branch);
869
1847
  return;
870
1848
  }
871
1849
  if (isEmpty(params)) isLocal = await createSelect({
@@ -880,18 +1858,20 @@ function mergeCommand(command) {
880
1858
  initialValue: false
881
1859
  });
882
1860
  if (isLocal) {
883
- const { options } = await getLocalOptions();
884
- handleMerge(await createSearch({
1861
+ const { options, currentBranch } = await getLocalBranchOptions();
1862
+ await handleMerge(await createSelect({
885
1863
  message: "Select the branch to merge",
886
- options
1864
+ options,
1865
+ initialValue: currentBranch
887
1866
  }));
888
1867
  } else {
889
- const { options } = await getRemoteOptions();
890
- const selectedBranch = await createSearch({
1868
+ const { options, currentBranch } = await getRemoteBranchOptions();
1869
+ const selectedBranch = await createSelect({
891
1870
  message: "Select the branch to merge",
892
- options
1871
+ options,
1872
+ initialValue: currentBranch
893
1873
  });
894
- if (await createConfirm({ message: `Do you want to merge ${colors.bgRed(selectedBranch)}?` })) handleMerge(selectedBranch);
1874
+ if (await createConfirm({ message: `Do you want to merge ${colors.bgRed(selectedBranch)}?` })) await handleMerge(selectedBranch);
895
1875
  }
896
1876
  });
897
1877
  }
@@ -900,7 +1880,40 @@ function mergeCommand(command) {
900
1880
  //#region src/commands/pull.ts
901
1881
  function pullCommand(command) {
902
1882
  command.command("pull").alias("pl").description("Pull git branch").option("-r, --rebase", "Use rebase mode instead of merge").option("-m, --merge", "Use merge mode (default)").action(async (options) => {
903
- const { options: branchOptions, currentBranch } = await getRemoteOptions();
1883
+ const [error, result] = await safeAwait(getRemoteRepositoryOptions());
1884
+ if (error) {
1885
+ log.error(`Failed to get remote repositories: ${error.message}`);
1886
+ return;
1887
+ }
1888
+ const repositories = result.remotes;
1889
+ if (repositories.length === 0) {
1890
+ log.error("No remote repositories found. Aborting pull operation.");
1891
+ log.show("Hint: Use \"git remote add <name> <url>\" to add a remote repository.", { type: "info" });
1892
+ return;
1893
+ }
1894
+ const selectedRepository = repositories.length > 1 ? (await safeAwait(createSelect({
1895
+ message: "Select remote repository",
1896
+ options: result.options,
1897
+ initialValue: repositories[0]
1898
+ })))[1] : repositories[0];
1899
+ if (!selectedRepository) {
1900
+ log.error("No remote selected. Aborting pull operation.");
1901
+ return;
1902
+ }
1903
+ const spinner = createSpinner("Fetching latest remote branches...");
1904
+ const [fetchError] = await xASync("git", [
1905
+ "fetch",
1906
+ selectedRepository,
1907
+ "--prune"
1908
+ ], { timeout: 3e4 });
1909
+ if (fetchError) {
1910
+ spinner.stop("Failed to fetch latest remote branches");
1911
+ log.error(`Failed to fetch from ${selectedRepository}: ${fetchError.message}`);
1912
+ log.show("Please check your network connection and try again.", { type: "info" });
1913
+ return;
1914
+ }
1915
+ spinner.stop("Successfully fetched latest remote branches");
1916
+ const { options: branchOptions, currentBranch } = await getRemoteBranchOptions();
904
1917
  if (!branchOptions.length) {
905
1918
  log.error("No branches found. Please check your git repository.");
906
1919
  return;
@@ -920,24 +1933,30 @@ function pullCommand(command) {
920
1933
  options: [{
921
1934
  label: "Merge (default)",
922
1935
  value: "merge",
923
- hint: "git pull origin <branch>"
1936
+ hint: `git pull ${selectedRepository} ${selectedBranch}`
924
1937
  }, {
925
1938
  label: "Rebase",
926
1939
  value: "rebase",
927
- hint: "git pull --rebase origin <branch>"
1940
+ hint: `git pull --rebase ${selectedRepository} ${selectedBranch}`
928
1941
  }],
929
1942
  initialValue: "merge"
930
1943
  }) === "rebase";
931
- const stashName = await handleGitStash();
932
- await handleGitPull(selectedBranch, { rebase: useRebase });
933
- stashName && handleGitPop(stashName);
1944
+ const stashResult = await handleGitStash(void 0, {
1945
+ branch: selectedBranch,
1946
+ operation: "pull"
1947
+ });
1948
+ await handleGitPull(selectedBranch, {
1949
+ remote: selectedRepository,
1950
+ rebase: useRebase
1951
+ });
1952
+ stashResult && handleGitPop(stashResult);
934
1953
  });
935
1954
  }
936
1955
 
937
1956
  //#endregion
938
1957
  //#region src/constants/stash.ts
939
1958
  const HELP_MESSAGE = {
940
- main: createHelpExample("ng stash", "ng stash save \"work in progress\"", "ng stash ls", "ng stash pop", "ng stash drop"),
1959
+ main: createHelpExample("ng stash", "ng blame [file]", "ng blame [file] [line]", "ng stash save \"work in progress\"", "ng stash ls", "ng stash pop", "ng stash drop"),
941
1960
  save: createHelpExample("ng stash save \"work in progress\""),
942
1961
  list: createHelpExample("ng stash ls"),
943
1962
  pop: createHelpExample("ng stash pop"),
@@ -1002,17 +2021,31 @@ const handlePop = handleCheck(async (stashes) => {
1002
2021
  else log.show("Successfully popped changes.", { type: "success" });
1003
2022
  }
1004
2023
  });
2024
+ const parseStashEntry = (stashEntry) => {
2025
+ const match = stashEntry.match(/^(stash@\{\d+\}):\s+On\s+(\S+):\s+(.+)$/);
2026
+ if (match) return {
2027
+ ref: match[1] || stashEntry,
2028
+ branch: match[2] || "unknown",
2029
+ message: match[3] || stashEntry
2030
+ };
2031
+ return {
2032
+ ref: stashEntry.match(/^(stash@\{\d+\})/)?.[1] ?? stashEntry.split(":")[0] ?? stashEntry,
2033
+ branch: "unknown",
2034
+ message: stashEntry
2035
+ };
2036
+ };
1005
2037
  const handleList = handleCheck(async (stashes) => {
1006
- log.show(`\n${colors.bold(`📦 Found ${stashes.length} stash(es)`)}\n`);
1007
- for await (const stash of stashes) {
1008
- const files = await getStashFiles(extractStashRef(stash));
1009
- log.show(colors.cyan(`━━━ ${stash} ━━━`));
1010
- if (files.length > 0) {
1011
- log.show(colors.dim(` ${files.length} file(s) changed:`));
1012
- for (const file of files) log.show(colors.yellow(` • ${file}`));
1013
- } else log.show(colors.dim(" (no files)"));
1014
- log.show("");
1015
- }
2038
+ await renderStashList(await Promise.all(stashes.map(async (stash) => {
2039
+ const stashInfo = parseStashEntry(stash);
2040
+ const files = await getStashFiles(stashInfo.ref);
2041
+ return {
2042
+ ref: stashInfo.ref,
2043
+ branch: stashInfo.branch,
2044
+ message: stashInfo.message,
2045
+ files,
2046
+ fileCount: files.length
2047
+ };
2048
+ })));
1016
2049
  });
1017
2050
  const handleDrop = handleCheck(async (stashes) => {
1018
2051
  const selectedStashes = await createCheckbox({
@@ -1039,10 +2072,58 @@ const handleClear = handleCheck(async () => {
1039
2072
  if (error) log.show("Failed to clear stashes.", { type: "error" });
1040
2073
  else log.show("Successfully cleared stashes.", { type: "success" });
1041
2074
  });
2075
+ const handleHistory = async (options) => {
2076
+ if (options.clean) {
2077
+ const days = Number.parseInt(options.clean, 10) || 30;
2078
+ const count = await cleanOldStashes(days);
2079
+ log.show(`Cleaned ${count} old stash records (${days} days)`, { type: "success" });
2080
+ return;
2081
+ }
2082
+ const stashes = await getAllStashes(options.active ? "active" : void 0);
2083
+ const displayList = options.all ? stashes : stashes.slice(0, 10);
2084
+ displayList.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
2085
+ if (displayList.length === 0) {
2086
+ log.show("No stash records found.", { type: "info" });
2087
+ return;
2088
+ }
2089
+ log.show(`\n${colors.bold(`📚 Stash History (${displayList.length} records)`)}\n`);
2090
+ for (const stash of displayList) {
2091
+ const statusEmoji = stash.status === "active" ? "📦" : "✅";
2092
+ const statusColor = stash.status === "active" ? colors.yellow : colors.green;
2093
+ log.show(colors.cyan(`━━━ ${statusEmoji} ${stash.message} ━━━`));
2094
+ log.show(colors.dim(` Operation: ${stash.operation || "unknown"}`));
2095
+ log.show(colors.dim(` Status: ${statusColor(stash.status || "unknown")}`));
2096
+ log.show(colors.dim(` Branch: ${stash.currentBranch || "unknown"}`));
2097
+ log.show(colors.dim(` Time: ${new Date(stash.timestamp).toLocaleString()}`));
2098
+ if (stash.files && stash.files.length > 0) {
2099
+ log.show(colors.dim(` Files (${stash.files.length}):`));
2100
+ const filesToShow = stash.files.slice(0, 5);
2101
+ for (const file of filesToShow) log.show(colors.yellow(` • ${file}`));
2102
+ if (stash.files.length > 5) log.show(colors.dim(` ... and ${stash.files.length - 5} more`));
2103
+ }
2104
+ if (stash.error) log.show(colors.red(` Error: ${stash.error}`));
2105
+ log.show("");
2106
+ }
2107
+ if (!options.all && stashes.length > 10) log.show(colors.dim(`\nShowing 10 of ${stashes.length} records. Use --all to see all.\n`));
2108
+ };
1042
2109
  const stashCommand = (command) => {
1043
2110
  const stashCmd = command.command("stash").alias("st").description("Git stash management").addHelpText("after", HELP_MESSAGE.main);
2111
+ stashCmd.action(async () => {
2112
+ if (!await handleGitStash(void 0, { operation: "manual" })) log.show("No changes to stash.", { type: "info" });
2113
+ });
1044
2114
  stashCmd.command("save [message]").alias("s").description("Save current changes to stash").action(async (message) => {
1045
- await handleGitStash(message);
2115
+ if (message) {
2116
+ if (!await handleGitStash(message, { operation: "manual" })) log.show("No changes to stash.", { type: "info" });
2117
+ } else try {
2118
+ if (!await handleGitStash(await createInput({
2119
+ message: "Enter stash message (press Enter to use default)",
2120
+ validate: (value) => {
2121
+ if (!value?.trim()) return "Message cannot be empty";
2122
+ }
2123
+ }), { operation: "manual" })) log.show("No changes to stash.", { type: "info" });
2124
+ } catch (error) {
2125
+ log.show("Stash cancelled.", { type: "info" });
2126
+ }
1046
2127
  });
1047
2128
  stashCmd.command("list").alias("ls").alias("l").description("List all stashes").action(async () => {
1048
2129
  await handleList();
@@ -1056,9 +2137,57 @@ const stashCommand = (command) => {
1056
2137
  stashCmd.command("clear").alias("c").description("clear stashes").action(async () => {
1057
2138
  await handleClear();
1058
2139
  });
2140
+ stashCmd.command("history").alias("his").alias("h").description("View stash history from persistent index").option("--all", "Show all records (no limit)").option("--active", "Show only active records").option("--clean [days]", "Clean old records (default: 30 days)").action(async (options) => {
2141
+ await handleHistory(options);
2142
+ });
1059
2143
  return stashCmd;
1060
2144
  };
1061
2145
 
2146
+ //#endregion
2147
+ //#region src/commands/status.ts
2148
+ /**
2149
+ * 获取修改的文件列表
2150
+ */
2151
+ const getStatusFiles = async () => {
2152
+ const [error, result] = await xASync("git", ["status", "--porcelain"], { quiet: true });
2153
+ if (error) return [];
2154
+ const lines = result.stdout.split("\n").filter((line) => line.trim());
2155
+ const files = [];
2156
+ for (const line of lines) {
2157
+ if (line.length < 3) continue;
2158
+ const stagedStatus = line[0] ?? " ";
2159
+ const unstagedStatus = line[1] ?? " ";
2160
+ const path = line.slice(3).trim();
2161
+ let status = unstagedStatus;
2162
+ let staged = false;
2163
+ if (unstagedStatus === " " && stagedStatus !== " " && stagedStatus !== "?") {
2164
+ status = stagedStatus;
2165
+ staged = true;
2166
+ } else if (unstagedStatus !== " ") {
2167
+ status = unstagedStatus;
2168
+ staged = false;
2169
+ }
2170
+ if (status === "?") continue;
2171
+ files.push({
2172
+ path,
2173
+ status,
2174
+ staged
2175
+ });
2176
+ }
2177
+ return files;
2178
+ };
2179
+ const statusCommand = (command) => {
2180
+ command.command("status").alias("s").description("Show working tree status (interactive viewer)").action(async () => {
2181
+ const files = await getStatusFiles();
2182
+ if (files.length === 0) {
2183
+ log.show("✓ Working directory clean", { type: "success" });
2184
+ return;
2185
+ }
2186
+ renderStatusViewer(files);
2187
+ });
2188
+ return command;
2189
+ };
2190
+
1062
2191
  //#endregion
1063
2192
  //#region src/index.ts
1064
2193
  const pkg = readPackage(import.meta, "..");
@@ -1072,7 +2201,11 @@ const init = () => {
1072
2201
  diffCommand(command);
1073
2202
  mergeCommand(command);
1074
2203
  stashCommand(command);
2204
+ blameCommand(command);
1075
2205
  commitCommand(command);
2206
+ statusCommand(command);
2207
+ histCommand(command);
2208
+ configCommand(command);
1076
2209
  return command;
1077
2210
  };
1078
2211
  const run = async () => {