@nemo-cli/git 0.1.1 → 0.1.3

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
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";
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",
@@ -56,6 +399,18 @@ const getLocalOptions = async () => {
56
399
  currentBranch
57
400
  };
58
401
  };
402
+ const getRemoteOptionsForRemotes = 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
  */
@@ -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) => {
@@ -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 remotes;
1093
+ try {
1094
+ remotes = (await getRemoteOptionsForRemotes()).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 (remotes.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 selectedRemote = remotes[0];
1106
+ if (remotes.length > 1) selectedRemote = await createSelect({
1107
+ message: "Select remote repository",
1108
+ options: remotes.map((remote) => ({
1109
+ label: remote,
1110
+ value: remote
1111
+ })),
1112
+ initialValue: remotes[0]
1113
+ });
1114
+ if (await createConfirm({ message: `Do you want to push ${colors.bgGreen(currentBranch)} to ${selectedRemote}?` })) {
1115
+ await handlePush(currentBranch, selectedRemote);
637
1116
  return;
638
1117
  }
639
1118
  const { options } = await getRemoteOptions();
640
- await handlePush(await createSelect({
1119
+ const selectedBranch = await createSelect({
641
1120
  message: "Select the branch to push",
642
1121
  options,
643
1122
  initialValue: "main"
644
- }));
1123
+ });
1124
+ let pushRemote = selectedRemote;
1125
+ if (remotes.length > 1) {
1126
+ if (!await createConfirm({ message: `Push ${colors.bgGreen(selectedBranch)} to ${selectedRemote}?` })) pushRemote = await createSelect({
1127
+ message: "Select remote repository",
1128
+ options: remotes.map((remote) => ({
1129
+ label: remote,
1130
+ value: remote
1131
+ })),
1132
+ initialValue: selectedRemote
1133
+ });
1134
+ }
1135
+ await handlePush(selectedBranch, pushRemote);
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) {
@@ -855,11 +1808,17 @@ function listCommand(command) {
855
1808
  const handleMerge = async (branch) => {
856
1809
  const spinner = createSpinner(`Merging branch ${branch}...`);
857
1810
  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);
1811
+ const stashResult = await handleGitStash(void 0, {
1812
+ branch,
1813
+ operation: "merge"
1814
+ });
1815
+ try {
1816
+ const [error] = await xASync("git", args, { nodeOptions: { stdio: "inherit" } });
1817
+ if (error) log.show(`Failed to merge branch ${branch}.`, { type: "error" });
1818
+ else spinner.stop(`Successfully merged branch ${branch}.`);
1819
+ } finally {
1820
+ stashResult && handleGitPop(stashResult);
1821
+ }
863
1822
  };
864
1823
  function mergeCommand(command) {
865
1824
  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) => {
@@ -928,16 +1887,19 @@ function pullCommand(command) {
928
1887
  }],
929
1888
  initialValue: "merge"
930
1889
  }) === "rebase";
931
- const stashName = await handleGitStash();
1890
+ const stashResult = await handleGitStash(void 0, {
1891
+ branch: selectedBranch,
1892
+ operation: "pull"
1893
+ });
932
1894
  await handleGitPull(selectedBranch, { rebase: useRebase });
933
- stashName && handleGitPop(stashName);
1895
+ stashResult && handleGitPop(stashResult);
934
1896
  });
935
1897
  }
936
1898
 
937
1899
  //#endregion
938
1900
  //#region src/constants/stash.ts
939
1901
  const HELP_MESSAGE = {
940
- main: createHelpExample("ng stash", "ng stash save \"work in progress\"", "ng stash ls", "ng stash pop", "ng stash drop"),
1902
+ 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
1903
  save: createHelpExample("ng stash save \"work in progress\""),
942
1904
  list: createHelpExample("ng stash ls"),
943
1905
  pop: createHelpExample("ng stash pop"),
@@ -1002,17 +1964,31 @@ const handlePop = handleCheck(async (stashes) => {
1002
1964
  else log.show("Successfully popped changes.", { type: "success" });
1003
1965
  }
1004
1966
  });
1967
+ const parseStashEntry = (stashEntry) => {
1968
+ const match = stashEntry.match(/^(stash@\{\d+\}):\s+On\s+(\S+):\s+(.+)$/);
1969
+ if (match) return {
1970
+ ref: match[1] || stashEntry,
1971
+ branch: match[2] || "unknown",
1972
+ message: match[3] || stashEntry
1973
+ };
1974
+ return {
1975
+ ref: stashEntry.match(/^(stash@\{\d+\})/)?.[1] ?? stashEntry.split(":")[0] ?? stashEntry,
1976
+ branch: "unknown",
1977
+ message: stashEntry
1978
+ };
1979
+ };
1005
1980
  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
- }
1981
+ await renderStashList(await Promise.all(stashes.map(async (stash) => {
1982
+ const stashInfo = parseStashEntry(stash);
1983
+ const files = await getStashFiles(stashInfo.ref);
1984
+ return {
1985
+ ref: stashInfo.ref,
1986
+ branch: stashInfo.branch,
1987
+ message: stashInfo.message,
1988
+ files,
1989
+ fileCount: files.length
1990
+ };
1991
+ })));
1016
1992
  });
1017
1993
  const handleDrop = handleCheck(async (stashes) => {
1018
1994
  const selectedStashes = await createCheckbox({
@@ -1039,10 +2015,58 @@ const handleClear = handleCheck(async () => {
1039
2015
  if (error) log.show("Failed to clear stashes.", { type: "error" });
1040
2016
  else log.show("Successfully cleared stashes.", { type: "success" });
1041
2017
  });
2018
+ const handleHistory = async (options) => {
2019
+ if (options.clean) {
2020
+ const days = Number.parseInt(options.clean, 10) || 30;
2021
+ const count = await cleanOldStashes(days);
2022
+ log.show(`Cleaned ${count} old stash records (${days} days)`, { type: "success" });
2023
+ return;
2024
+ }
2025
+ const stashes = await getAllStashes(options.active ? "active" : void 0);
2026
+ const displayList = options.all ? stashes : stashes.slice(0, 10);
2027
+ displayList.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime());
2028
+ if (displayList.length === 0) {
2029
+ log.show("No stash records found.", { type: "info" });
2030
+ return;
2031
+ }
2032
+ log.show(`\n${colors.bold(`📚 Stash History (${displayList.length} records)`)}\n`);
2033
+ for (const stash of displayList) {
2034
+ const statusEmoji = stash.status === "active" ? "📦" : "✅";
2035
+ const statusColor = stash.status === "active" ? colors.yellow : colors.green;
2036
+ log.show(colors.cyan(`━━━ ${statusEmoji} ${stash.message} ━━━`));
2037
+ log.show(colors.dim(` Operation: ${stash.operation || "unknown"}`));
2038
+ log.show(colors.dim(` Status: ${statusColor(stash.status || "unknown")}`));
2039
+ log.show(colors.dim(` Branch: ${stash.currentBranch || "unknown"}`));
2040
+ log.show(colors.dim(` Time: ${new Date(stash.timestamp).toLocaleString()}`));
2041
+ if (stash.files && stash.files.length > 0) {
2042
+ log.show(colors.dim(` Files (${stash.files.length}):`));
2043
+ const filesToShow = stash.files.slice(0, 5);
2044
+ for (const file of filesToShow) log.show(colors.yellow(` • ${file}`));
2045
+ if (stash.files.length > 5) log.show(colors.dim(` ... and ${stash.files.length - 5} more`));
2046
+ }
2047
+ if (stash.error) log.show(colors.red(` Error: ${stash.error}`));
2048
+ log.show("");
2049
+ }
2050
+ if (!options.all && stashes.length > 10) log.show(colors.dim(`\nShowing 10 of ${stashes.length} records. Use --all to see all.\n`));
2051
+ };
1042
2052
  const stashCommand = (command) => {
1043
2053
  const stashCmd = command.command("stash").alias("st").description("Git stash management").addHelpText("after", HELP_MESSAGE.main);
2054
+ stashCmd.action(async () => {
2055
+ if (!await handleGitStash(void 0, { operation: "manual" })) log.show("No changes to stash.", { type: "info" });
2056
+ });
1044
2057
  stashCmd.command("save [message]").alias("s").description("Save current changes to stash").action(async (message) => {
1045
- await handleGitStash(message);
2058
+ if (message) {
2059
+ if (!await handleGitStash(message, { operation: "manual" })) log.show("No changes to stash.", { type: "info" });
2060
+ } else try {
2061
+ if (!await handleGitStash(await createInput({
2062
+ message: "Enter stash message (press Enter to use default)",
2063
+ validate: (value) => {
2064
+ if (!value?.trim()) return "Message cannot be empty";
2065
+ }
2066
+ }), { operation: "manual" })) log.show("No changes to stash.", { type: "info" });
2067
+ } catch (error) {
2068
+ log.show("Stash cancelled.", { type: "info" });
2069
+ }
1046
2070
  });
1047
2071
  stashCmd.command("list").alias("ls").alias("l").description("List all stashes").action(async () => {
1048
2072
  await handleList();
@@ -1056,9 +2080,57 @@ const stashCommand = (command) => {
1056
2080
  stashCmd.command("clear").alias("c").description("clear stashes").action(async () => {
1057
2081
  await handleClear();
1058
2082
  });
2083
+ 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) => {
2084
+ await handleHistory(options);
2085
+ });
1059
2086
  return stashCmd;
1060
2087
  };
1061
2088
 
2089
+ //#endregion
2090
+ //#region src/commands/status.ts
2091
+ /**
2092
+ * 获取修改的文件列表
2093
+ */
2094
+ const getStatusFiles = async () => {
2095
+ const [error, result] = await xASync("git", ["status", "--porcelain"], { quiet: true });
2096
+ if (error) return [];
2097
+ const lines = result.stdout.split("\n").filter((line) => line.trim());
2098
+ const files = [];
2099
+ for (const line of lines) {
2100
+ if (line.length < 3) continue;
2101
+ const stagedStatus = line[0] ?? " ";
2102
+ const unstagedStatus = line[1] ?? " ";
2103
+ const path = line.slice(3).trim();
2104
+ let status = unstagedStatus;
2105
+ let staged = false;
2106
+ if (unstagedStatus === " " && stagedStatus !== " " && stagedStatus !== "?") {
2107
+ status = stagedStatus;
2108
+ staged = true;
2109
+ } else if (unstagedStatus !== " ") {
2110
+ status = unstagedStatus;
2111
+ staged = false;
2112
+ }
2113
+ if (status === "?") continue;
2114
+ files.push({
2115
+ path,
2116
+ status,
2117
+ staged
2118
+ });
2119
+ }
2120
+ return files;
2121
+ };
2122
+ const statusCommand = (command) => {
2123
+ command.command("status").alias("s").description("Show working tree status (interactive viewer)").action(async () => {
2124
+ const files = await getStatusFiles();
2125
+ if (files.length === 0) {
2126
+ log.show("✓ Working directory clean", { type: "success" });
2127
+ return;
2128
+ }
2129
+ renderStatusViewer(files);
2130
+ });
2131
+ return command;
2132
+ };
2133
+
1062
2134
  //#endregion
1063
2135
  //#region src/index.ts
1064
2136
  const pkg = readPackage(import.meta, "..");
@@ -1072,7 +2144,11 @@ const init = () => {
1072
2144
  diffCommand(command);
1073
2145
  mergeCommand(command);
1074
2146
  stashCommand(command);
2147
+ blameCommand(command);
1075
2148
  commitCommand(command);
2149
+ statusCommand(command);
2150
+ histCommand(command);
2151
+ configCommand(command);
1076
2152
  return command;
1077
2153
  };
1078
2154
  const run = async () => {