git-sync-tui 0.1.0 → 0.1.2

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.
Files changed (4) hide show
  1. package/README.md +92 -34
  2. package/README.zh-CN.md +126 -0
  3. package/dist/cli.js +576 -140
  4. package/package.json +16 -2
package/README.md CHANGED
@@ -1,68 +1,126 @@
1
- # git-sync-tui
2
-
3
- Interactive TUI tool for cross-repo git commit synchronization.
4
-
5
- Cherry-pick commits from remote branches with an interactive terminal UI — select specific commits, preview changes, and sync with `--no-commit` mode for review before committing.
6
-
7
- ## Features
8
-
9
- - **Multi-select commits** — pick non-consecutive commits with Space/Enter
10
- - **`--no-commit` mode** — changes are staged, not committed, so you can review and edit before committing
11
- - **Diff preview** — see `--stat` summary of selected commits before executing
12
- - **Branch search** — filter branches by keyword
13
- - **Conflict handling** shows conflicted files when cherry-pick fails
14
- - **Language agnostic** — works in any git repo (Node.js, Go, Python, Java, etc.)
15
-
16
- ## Install
1
+ <h1 align="center">🔄 git-sync-tui</h1>
2
+
3
+ <p align="center">
4
+ <a href="https://www.npmjs.com/package/git-sync-tui"><img src="https://img.shields.io/npm/v/git-sync-tui.svg?color=0ea5e9" alt="npm version"></a>
5
+ <a href="https://www.npmjs.com/package/git-sync-tui"><img src="https://img.shields.io/npm/dm/git-sync-tui.svg?color=10b981" alt="downloads"></a>
6
+ <a href="https://github.com/KiWi233333/git-sync-tui/actions/workflows/publish.yml"><img src="https://github.com/KiWi233333/git-sync-tui/actions/workflows/publish.yml/badge.svg" alt="publish"></a>
7
+ <a href="https://nodejs.org"><img src="https://img.shields.io/node/v/git-sync-tui.svg?color=8b5cf6" alt="node version"></a>
8
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-f59e0b.svg" alt="license"></a>
9
+ <a href="https://github.com/KiWi233333/git-sync-tui"><img src="https://img.shields.io/github/stars/KiWi233333/git-sync-tui?style=social" alt="GitHub Stars"></a>
10
+ </p>
11
+
12
+ <p align="center">
13
+ <b>Interactive TUI for cross-repository git commit synchronization</b>
14
+ </p>
15
+
16
+ <p align="center">
17
+ Cherry-pick commits from remote branches with an intuitive terminal UI.<br>
18
+ Select specific commits, preview changes, and sync with <code>--no-commit</code> mode for safe review.
19
+ </p>
20
+
21
+ <p align="center">
22
+ <a href="#-features">Features</a> ·
23
+ <a href="#-quick-start">Quick Start</a> ·
24
+ <a href="#-installation">Installation</a> ·
25
+ <a href="#-workflow">Workflow</a>
26
+ </p>
27
+
28
+ <p align="center">
29
+ <a href="./README.md">English</a> | <a href="./README.zh-CN.md">中文</a>
30
+ </p>
31
+
32
+ <p align="center">
33
+ <img src="./assets/demo.gif" alt="git-sync-tui demo" width="680">
34
+ </p>
35
+
36
+ ## ✨ Features
37
+
38
+ - 🎯 **Multi-select commits** — Cherry-pick non-consecutive commits with Space / Enter
39
+ - 🔍 **Branch search** — Fuzzy filter branches by keyword
40
+ - 👀 **Diff preview** — See `--stat` summary of selected commits before executing
41
+ - ⚡ **Safe mode** — `--no-commit` stages changes for review, never auto-commits
42
+ - ⚠️ **Conflict handling** — Clear display of conflicted files when cherry-pick fails
43
+ - 🌐 **Universal** — Works in any git repository, any language
44
+
45
+ ## 🚀 Quick Start
17
46
 
18
47
  ```bash
48
+ # Install globally
19
49
  npm install -g git-sync-tui
20
- ```
21
50
 
22
- Requires Node.js >= 20.
51
+ # Navigate to your git repo and run
52
+ cd your-project
53
+ git-sync-tui
54
+ ```
23
55
 
24
- ## Usage
56
+ ## 📦 Installation
25
57
 
26
58
  ```bash
27
- # Run in any git repository
28
- git-sync-tui
59
+ npm install -g git-sync-tui
29
60
  ```
30
61
 
31
- ### Workflow
62
+ > **Requirements:** Node.js >= 20
63
+
64
+ ## 🔄 Workflow
32
65
 
33
66
  ```
34
- [Select remote] [Select branch] [Multi-select commits] [Preview stat] → [Confirm] → [Cherry-pick --no-commit] → [Review & commit manually]
67
+ Select Remote Select Branch Multi-select Commits Preview Changes
68
+
69
+ Review & Commit manually ← Cherry-pick --no-commit (staged, not committed)
35
70
  ```
36
71
 
37
- ### Keyboard Shortcuts
72
+ ## ⌨️ Keyboard Shortcuts
38
73
 
39
74
  | Key | Action |
40
75
  |-----|--------|
41
- | `↑` / `↓` | Navigate |
76
+ | `↑` `↓` | Navigate items |
42
77
  | `Space` | Toggle commit selection |
43
78
  | `Enter` | Confirm selection |
44
79
  | `y` / `n` | Confirm / cancel execution |
80
+ | `/` | Search (in branch list) |
45
81
 
46
- ### After sync
82
+ ## 📋 After Sync
47
83
 
48
84
  Changes are staged in your working tree (not committed). You can:
49
85
 
50
86
  ```bash
51
- git diff --cached # Review changes
52
- git commit -m "sync: ..." # Commit when ready
53
- git reset HEAD # Or discard all changes
87
+ # Review staged changes
88
+ git diff --cached
89
+
90
+ # Commit when ready
91
+ git commit -m "sync: cherry-picked commits from feature-branch"
92
+
93
+ # Or discard all changes
94
+ git reset HEAD
54
95
  ```
55
96
 
56
- ## Development
97
+ ## 💡 Use Cases
98
+
99
+ | Scenario | Description |
100
+ |----------|-------------|
101
+ | **Backport fixes** | Cherry-pick critical fixes from main to release branches |
102
+ | **Sync features** | Copy specific commits between feature branches |
103
+ | **Selective merge** | Pick individual commits instead of merging entire branches |
104
+
105
+ ## 🛠️ Development
57
106
 
58
107
  ```bash
59
108
  git clone https://github.com/KiWi233333/git-sync-tui.git
60
109
  cd git-sync-tui
61
110
  npm install
62
- npm start # Run with tsx
63
- npm run build # Build with tsup
111
+ npm start
64
112
  ```
65
113
 
66
- ## License
114
+ ## 🏗️ Tech Stack
115
+
116
+ - [Ink](https://github.com/vadimdemedes/ink) — React for interactive CLI apps
117
+ - [@inkjs/ui](https://github.com/inkjs/ui) — UI components for Ink
118
+ - [simple-git](https://github.com/steveukx/git-js) — Git commands interface
119
+
120
+ ## 🤝 Contributing
121
+
122
+ Contributions are welcome! Please feel free to submit a Pull Request.
123
+
124
+ ## 📄 License
67
125
 
68
- MIT
126
+ [MIT](./LICENSE) © [KiWi233333](https://github.com/KiWi233333)
@@ -0,0 +1,126 @@
1
+ <h1 align="center">🔄 git-sync-tui</h1>
2
+
3
+ <p align="center">
4
+ <a href="https://www.npmjs.com/package/git-sync-tui"><img src="https://img.shields.io/npm/v/git-sync-tui.svg?color=0ea5e9" alt="npm version"></a>
5
+ <a href="https://www.npmjs.com/package/git-sync-tui"><img src="https://img.shields.io/npm/dm/git-sync-tui.svg?color=10b981" alt="downloads"></a>
6
+ <a href="https://github.com/KiWi233333/git-sync-tui/actions/workflows/publish.yml"><img src="https://github.com/KiWi233333/git-sync-tui/actions/workflows/publish.yml/badge.svg" alt="publish"></a>
7
+ <a href="https://nodejs.org"><img src="https://img.shields.io/node/v/git-sync-tui.svg?color=8b5cf6" alt="node version"></a>
8
+ <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-f59e0b.svg" alt="license"></a>
9
+ <a href="https://github.com/KiWi233333/git-sync-tui"><img src="https://img.shields.io/github/stars/KiWi233333/git-sync-tui?style=social" alt="GitHub Stars"></a>
10
+ </p>
11
+
12
+ <p align="center">
13
+ <b>跨仓库 Git 提交同步的交互式 TUI 工具</b>
14
+ </p>
15
+
16
+ <p align="center">
17
+ 通过直观的终端界面从远程分支 cherry-pick 提交。<br>
18
+ 选择特定提交、预览变更,并使用 <code>--no-commit</code> 模式在提交前安全审查。
19
+ </p>
20
+
21
+ <p align="center">
22
+ <a href="#-功能特性">功能</a> ·
23
+ <a href="#-快速开始">快速开始</a> ·
24
+ <a href="#-安装">安装</a> ·
25
+ <a href="#-工作流程">工作流程</a>
26
+ </p>
27
+
28
+ <p align="center">
29
+ <a href="./README.md">English</a> | <a href="./README.zh-CN.md">中文</a>
30
+ </p>
31
+
32
+ <!-- <p align="center">
33
+ <img src="./assets/demo.gif" alt="git-sync-tui 演示" width="680">
34
+ </p> -->
35
+
36
+ ## ✨ 功能特性
37
+
38
+ - 🎯 **多选提交** — 使用 Space / Enter 选择不连续的提交进行 cherry-pick
39
+ - 🔍 **分支搜索** — 按关键词模糊过滤分支
40
+ - 👀 **差异预览** — 执行前查看所选提交的 `--stat` 摘要
41
+ - ⚡ **安全模式** — `--no-commit` 仅暂存变更供审查,不会自动提交
42
+ - ⚠️ **冲突处理** — cherry-pick 失败时清晰显示冲突文件
43
+ - 🌐 **通用性** — 适用于任何 Git 仓库,不限语言
44
+
45
+ ## 🚀 快速开始
46
+
47
+ ```bash
48
+ # 全局安装
49
+ npm install -g git-sync-tui
50
+
51
+ # 进入你的 Git 仓库并运行
52
+ cd your-project
53
+ git-sync-tui
54
+ ```
55
+
56
+ ## 📦 安装
57
+
58
+ ```bash
59
+ npm install -g git-sync-tui
60
+ ```
61
+
62
+ > **环境要求:** Node.js >= 20
63
+
64
+ ## 🔄 工作流程
65
+
66
+ ```
67
+ 选择远程仓库 → 选择分支 → 多选提交 → 预览变更
68
+
69
+ 手动审查并提交 ← Cherry-pick --no-commit(已暂存,未提交)
70
+ ```
71
+
72
+ ## ⌨️ 快捷键
73
+
74
+ | 按键 | 操作 |
75
+ |-----|------|
76
+ | `↑` `↓` | 上下导航 |
77
+ | `Space` | 切换提交选择 |
78
+ | `Enter` | 确认选择 |
79
+ | `y` / `n` | 确认 / 取消执行 |
80
+ | `/` | 搜索(在分支列表中) |
81
+
82
+ ## 📋 同步后操作
83
+
84
+ 变更已暂存在工作区(未提交)。你可以:
85
+
86
+ ```bash
87
+ # 查看暂存的变更
88
+ git diff --cached
89
+
90
+ # 准备好后提交
91
+ git commit -m "sync: 从 feature-branch cherry-pick 提交"
92
+
93
+ # 或放弃所有变更
94
+ git reset HEAD
95
+ ```
96
+
97
+ ## 💡 使用场景
98
+
99
+ | 场景 | 描述 |
100
+ |------|------|
101
+ | **回溯修复** | 从主分支 cherry-pick 关键修复到发布分支 |
102
+ | **同步特性** | 在特性分支间复制特定提交 |
103
+ | **选择性合并** | 选择单个提交而非合并整个分支 |
104
+
105
+ ## 🛠️ 开发
106
+
107
+ ```bash
108
+ git clone https://github.com/KiWi233333/git-sync-tui.git
109
+ cd git-sync-tui
110
+ npm install
111
+ npm start
112
+ ```
113
+
114
+ ## 🏗️ 技术栈
115
+
116
+ - [Ink](https://github.com/vadimdemedes/ink) — 用于构建交互式 CLI 应用的 React 框架
117
+ - [@inkjs/ui](https://github.com/inkjs/ui) — Ink 的 UI 组件库
118
+ - [simple-git](https://github.com/steveukx/git-js) — Git 命令接口
119
+
120
+ ## 🤝 贡献
121
+
122
+ 欢迎贡献代码!请随时提交 Pull Request。
123
+
124
+ ## 📄 许可证
125
+
126
+ [MIT](./LICENSE) © [KiWi233333](https://github.com/KiWi233333)
package/dist/cli.js CHANGED
@@ -5,12 +5,40 @@ import { render } from "ink";
5
5
  import meow from "meow";
6
6
 
7
7
  // src/app.tsx
8
- import { useState as useState5 } from "react";
9
- import { Box as Box6, Text as Text6 } from "ink";
8
+ import { useState as useState6, useEffect as useEffect3, useRef as useRef2, useCallback as useCallback2 } from "react";
9
+ import { Box as Box7, Text as Text7, useApp } from "ink";
10
+ import { Spinner as Spinner5 } from "@inkjs/ui";
11
+
12
+ // src/components/stash-prompt.tsx
13
+ import { Box, Text, useInput } from "ink";
14
+ import { jsx, jsxs } from "react/jsx-runtime";
15
+ function StashPrompt({ onConfirm, onSkip }) {
16
+ useInput((input) => {
17
+ if (input === "y" || input === "Y") {
18
+ onConfirm();
19
+ } else if (input === "n" || input === "N") {
20
+ onSkip();
21
+ }
22
+ });
23
+ return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", gap: 1, children: [
24
+ /* @__PURE__ */ jsxs(Box, { borderStyle: "single", borderColor: "yellow", paddingX: 1, flexDirection: "column", children: [
25
+ /* @__PURE__ */ jsx(Text, { bold: true, color: "yellow", children: "\u68C0\u6D4B\u5230\u5DE5\u4F5C\u533A\u6709\u672A\u63D0\u4EA4\u7684\u53D8\u66F4" }),
26
+ /* @__PURE__ */ jsx(Text, { color: "gray", children: "Cherry-pick \u64CD\u4F5C\u53EF\u80FD\u4F1A\u4E0E\u672A\u63D0\u4EA4\u7684\u5185\u5BB9\u51B2\u7A81" })
27
+ ] }),
28
+ /* @__PURE__ */ jsxs(Box, { children: [
29
+ /* @__PURE__ */ jsx(Text, { bold: true, children: "\u662F\u5426\u81EA\u52A8 stash \u4FDD\u5B58\u5F53\u524D\u53D8\u66F4? " }),
30
+ /* @__PURE__ */ jsx(Text, { color: "green", children: "[y]" }),
31
+ /* @__PURE__ */ jsx(Text, { children: " \u662F / " }),
32
+ /* @__PURE__ */ jsx(Text, { color: "red", children: "[n]" }),
33
+ /* @__PURE__ */ jsx(Text, { children: " \u5426\uFF0C\u7EE7\u7EED\u64CD\u4F5C" })
34
+ ] })
35
+ ] });
36
+ }
10
37
 
11
38
  // src/components/remote-select.tsx
12
- import { Box, Text } from "ink";
13
- import { Select, Spinner } from "@inkjs/ui";
39
+ import { useState as useState2 } from "react";
40
+ import { Box as Box2, Text as Text2 } from "ink";
41
+ import { Select, Spinner, TextInput } from "@inkjs/ui";
14
42
 
15
43
  // src/hooks/use-git.ts
16
44
  import { useState, useEffect, useCallback } from "react";
@@ -32,6 +60,10 @@ async function getRemotes() {
32
60
  fetchUrl: r.refs.fetch
33
61
  }));
34
62
  }
63
+ async function addRemote(name, url) {
64
+ const git = getGit();
65
+ await git.addRemote(name, url);
66
+ }
35
67
  async function getRemoteBranches(remote) {
36
68
  const git = getGit();
37
69
  try {
@@ -80,12 +112,29 @@ ${result.trim()}`);
80
112
  return "(\u65E0\u6CD5\u83B7\u53D6 stat \u4FE1\u606F)";
81
113
  }
82
114
  }
83
- async function cherryPick(hashes) {
115
+ async function hasMergeCommits(hashes) {
116
+ const git = getGit();
117
+ try {
118
+ for (const hash of hashes) {
119
+ const result = await git.raw(["rev-list", "--merges", "-n", "1", hash]);
120
+ if (result.trim()) return true;
121
+ }
122
+ return false;
123
+ } catch {
124
+ return false;
125
+ }
126
+ }
127
+ async function cherryPick(hashes, useMainline = false) {
84
128
  const git = getGit();
85
129
  try {
86
130
  const orderedHashes = [...hashes].reverse();
87
131
  for (const hash of orderedHashes) {
88
- await git.raw(["cherry-pick", "--no-commit", hash]);
132
+ const args = ["cherry-pick", "--no-commit"];
133
+ if (useMainline) {
134
+ args.push("-m", "1");
135
+ }
136
+ args.push(hash);
137
+ await git.raw(args);
89
138
  }
90
139
  return { success: true };
91
140
  } catch (err) {
@@ -111,6 +160,29 @@ async function getStagedStat() {
111
160
  return "";
112
161
  }
113
162
  }
163
+ async function isWorkingDirClean() {
164
+ const git = getGit();
165
+ const status = await git.status();
166
+ return status.isClean();
167
+ }
168
+ async function stash() {
169
+ const git = getGit();
170
+ try {
171
+ await git.stash(["push", "--include-untracked", "-m", "Auto-stash by git-sync-tui"]);
172
+ return true;
173
+ } catch {
174
+ return false;
175
+ }
176
+ }
177
+ async function stashPop() {
178
+ const git = getGit();
179
+ try {
180
+ await git.stash(["pop"]);
181
+ return true;
182
+ } catch {
183
+ return false;
184
+ }
185
+ }
114
186
 
115
187
  // src/hooks/use-git.ts
116
188
  function useAsync(fn, deps = []) {
@@ -169,259 +241,609 @@ function useCommitStat(hashes) {
169
241
  }
170
242
 
171
243
  // src/components/remote-select.tsx
172
- import { jsx, jsxs } from "react/jsx-runtime";
244
+ import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
173
245
  function RemoteSelect({ onSelect }) {
174
- const { data: remotes, loading, error } = useRemotes();
246
+ const { data: remotes, loading, error, reload } = useRemotes();
247
+ const [phase, setPhase] = useState2("list");
248
+ const [customUrl, setCustomUrl] = useState2("");
249
+ const [addError, setAddError] = useState2(null);
175
250
  if (loading) {
176
- return /* @__PURE__ */ jsx(Box, { children: /* @__PURE__ */ jsx(Spinner, { label: "\u6B63\u5728\u83B7\u53D6\u8FDC\u7A0B\u4ED3\u5E93\u5217\u8868..." }) });
251
+ return /* @__PURE__ */ jsx2(Box2, { children: /* @__PURE__ */ jsx2(Spinner, { label: "\u6B63\u5728\u83B7\u53D6\u8FDC\u7A0B\u4ED3\u5E93\u5217\u8868..." }) });
177
252
  }
178
253
  if (error) {
179
- return /* @__PURE__ */ jsxs(Text, { color: "red", children: [
254
+ return /* @__PURE__ */ jsxs2(Text2, { color: "red", children: [
180
255
  "\u83B7\u53D6\u8FDC\u7A0B\u4ED3\u5E93\u5931\u8D25: ",
181
256
  error
182
257
  ] });
183
258
  }
184
- if (!remotes || remotes.length === 0) {
185
- return /* @__PURE__ */ jsx(Text, { color: "red", children: "\u672A\u627E\u5230\u4EFB\u4F55\u8FDC\u7A0B\u4ED3\u5E93\uFF0C\u8BF7\u5148 git remote add" });
259
+ if (phase === "adding") {
260
+ return /* @__PURE__ */ jsx2(Box2, { children: /* @__PURE__ */ jsx2(Spinner, { label: "\u6B63\u5728\u6DFB\u52A0\u8FDC\u7A0B\u4ED3\u5E93..." }) });
186
261
  }
187
- const options = remotes.map((r) => ({
188
- label: `${r.name} ${r.fetchUrl}`,
189
- value: r.name
190
- }));
191
- return /* @__PURE__ */ jsxs(Box, { flexDirection: "column", gap: 1, children: [
192
- /* @__PURE__ */ jsx(Text, { bold: true, color: "cyan", children: "[1/5] \u9009\u62E9\u8FDC\u7A0B\u4ED3\u5E93" }),
193
- /* @__PURE__ */ jsx(Select, { options, onChange: onSelect })
262
+ if (phase === "input-url") {
263
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", gap: 1, children: [
264
+ /* @__PURE__ */ jsx2(Text2, { bold: true, color: "cyan", children: "[1/5] \u6DFB\u52A0\u8FDC\u7A0B\u4ED3\u5E93" }),
265
+ addError && /* @__PURE__ */ jsx2(Text2, { color: "red", children: addError }),
266
+ /* @__PURE__ */ jsxs2(Box2, { children: [
267
+ /* @__PURE__ */ jsx2(Text2, { children: "\u4ED3\u5E93\u5730\u5740: " }),
268
+ /* @__PURE__ */ jsx2(
269
+ TextInput,
270
+ {
271
+ placeholder: "https://github.com/user/repo.git",
272
+ onSubmit: (url) => {
273
+ if (!url.trim()) {
274
+ setAddError("\u5730\u5740\u4E0D\u80FD\u4E3A\u7A7A");
275
+ return;
276
+ }
277
+ setCustomUrl(url.trim());
278
+ setPhase("input-name");
279
+ }
280
+ }
281
+ )
282
+ ] }),
283
+ /* @__PURE__ */ jsx2(Text2, { color: "gray", dimColor: true, children: "\u652F\u6301 HTTPS / SSH \u5730\u5740" })
284
+ ] });
285
+ }
286
+ if (phase === "input-name") {
287
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", gap: 1, children: [
288
+ /* @__PURE__ */ jsx2(Text2, { bold: true, color: "cyan", children: "[1/5] \u6DFB\u52A0\u8FDC\u7A0B\u4ED3\u5E93" }),
289
+ /* @__PURE__ */ jsxs2(Text2, { color: "gray", children: [
290
+ "\u5730\u5740: ",
291
+ customUrl
292
+ ] }),
293
+ addError && /* @__PURE__ */ jsx2(Text2, { color: "red", children: addError }),
294
+ /* @__PURE__ */ jsxs2(Box2, { children: [
295
+ /* @__PURE__ */ jsx2(Text2, { children: "\u8FDC\u7A0B\u540D\u79F0: " }),
296
+ /* @__PURE__ */ jsx2(
297
+ TextInput,
298
+ {
299
+ placeholder: "upstream",
300
+ onSubmit: async (name) => {
301
+ const remoteName = name.trim();
302
+ if (!remoteName) {
303
+ setAddError("\u540D\u79F0\u4E0D\u80FD\u4E3A\u7A7A");
304
+ return;
305
+ }
306
+ if (remotes?.some((r) => r.name === remoteName)) {
307
+ setAddError(`\u8FDC\u7A0B "${remoteName}" \u5DF2\u5B58\u5728`);
308
+ return;
309
+ }
310
+ setAddError(null);
311
+ setPhase("adding");
312
+ try {
313
+ await addRemote(remoteName, customUrl);
314
+ reload();
315
+ onSelect(remoteName);
316
+ } catch (err) {
317
+ setAddError(err.message);
318
+ setPhase("input-name");
319
+ }
320
+ }
321
+ }
322
+ )
323
+ ] })
324
+ ] });
325
+ }
326
+ const options = [
327
+ ...(remotes || []).map((r) => ({
328
+ label: `${r.name} ${r.fetchUrl}`,
329
+ value: r.name
330
+ })),
331
+ {
332
+ label: "+ \u6DFB\u52A0\u8FDC\u7A0B\u4ED3\u5E93...",
333
+ value: "__add_custom__"
334
+ }
335
+ ];
336
+ return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", gap: 1, children: [
337
+ /* @__PURE__ */ jsx2(Text2, { bold: true, color: "cyan", children: "[1/5] \u9009\u62E9\u8FDC\u7A0B\u4ED3\u5E93" }),
338
+ /* @__PURE__ */ jsx2(
339
+ Select,
340
+ {
341
+ options,
342
+ onChange: (value) => {
343
+ if (value === "__add_custom__") {
344
+ setPhase("input-url");
345
+ } else {
346
+ onSelect(value);
347
+ }
348
+ }
349
+ }
350
+ )
194
351
  ] });
195
352
  }
196
353
 
197
354
  // src/components/branch-select.tsx
198
- import { useState as useState2, useMemo } from "react";
199
- import { Box as Box2, Text as Text2 } from "ink";
200
- import { Select as Select2, Spinner as Spinner2, TextInput } from "@inkjs/ui";
201
- import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
355
+ import { useState as useState3, useMemo } from "react";
356
+ import { Box as Box3, Text as Text3 } from "ink";
357
+ import { Select as Select2, Spinner as Spinner2, TextInput as TextInput2 } from "@inkjs/ui";
358
+ import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
202
359
  function BranchSelect({ remote, onSelect }) {
203
360
  const { data: branches, loading, error } = useBranches(remote);
204
- const [filter, setFilter] = useState2("");
361
+ const [filter, setFilter] = useState3("");
205
362
  const filteredOptions = useMemo(() => {
206
363
  if (!branches) return [];
207
364
  const filtered = filter ? branches.filter((b) => b.toLowerCase().includes(filter.toLowerCase())) : branches;
208
365
  return filtered.map((b) => ({ label: b, value: b }));
209
366
  }, [branches, filter]);
210
367
  if (loading) {
211
- return /* @__PURE__ */ jsx2(Box2, { children: /* @__PURE__ */ jsx2(Spinner2, { label: `\u6B63\u5728\u83B7\u53D6 ${remote} \u7684\u5206\u652F\u5217\u8868...` }) });
368
+ return /* @__PURE__ */ jsx3(Box3, { children: /* @__PURE__ */ jsx3(Spinner2, { label: `\u6B63\u5728\u83B7\u53D6 ${remote} \u7684\u5206\u652F\u5217\u8868...` }) });
212
369
  }
213
370
  if (error) {
214
- return /* @__PURE__ */ jsxs2(Text2, { color: "red", children: [
371
+ return /* @__PURE__ */ jsxs3(Text3, { color: "red", children: [
215
372
  "\u83B7\u53D6\u5206\u652F\u5217\u8868\u5931\u8D25: ",
216
373
  error
217
374
  ] });
218
375
  }
219
376
  if (!branches || branches.length === 0) {
220
- return /* @__PURE__ */ jsx2(Text2, { color: "red", children: "\u672A\u627E\u5230\u8FDC\u7A0B\u5206\u652F" });
377
+ return /* @__PURE__ */ jsx3(Text3, { color: "red", children: "\u672A\u627E\u5230\u8FDC\u7A0B\u5206\u652F" });
221
378
  }
222
- return /* @__PURE__ */ jsxs2(Box2, { flexDirection: "column", gap: 1, children: [
223
- /* @__PURE__ */ jsxs2(Text2, { bold: true, color: "cyan", children: [
379
+ return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", gap: 1, children: [
380
+ /* @__PURE__ */ jsxs3(Text3, { bold: true, color: "cyan", children: [
224
381
  "[2/5] \u9009\u62E9\u5206\u652F (",
225
382
  remote,
226
383
  ")"
227
384
  ] }),
228
- /* @__PURE__ */ jsxs2(Box2, { children: [
229
- /* @__PURE__ */ jsx2(Text2, { color: "gray", children: "\u641C\u7D22: " }),
230
- /* @__PURE__ */ jsx2(
231
- TextInput,
385
+ /* @__PURE__ */ jsxs3(Box3, { children: [
386
+ /* @__PURE__ */ jsx3(Text3, { color: "gray", children: "\u641C\u7D22: " }),
387
+ /* @__PURE__ */ jsx3(
388
+ TextInput2,
232
389
  {
233
390
  placeholder: "\u8F93\u5165\u5173\u952E\u5B57\u8FC7\u6EE4\u5206\u652F...",
234
391
  onChange: setFilter
235
392
  }
236
393
  )
237
394
  ] }),
238
- /* @__PURE__ */ jsxs2(Text2, { color: "gray", dimColor: true, children: [
395
+ /* @__PURE__ */ jsxs3(Text3, { color: "gray", dimColor: true, children: [
239
396
  "\u5171 ",
240
397
  branches.length,
241
398
  " \u4E2A\u5206\u652F",
242
399
  filter ? `\uFF0C\u5339\u914D ${filteredOptions.length} \u4E2A` : ""
243
400
  ] }),
244
- filteredOptions.length > 0 ? /* @__PURE__ */ jsx2(Select2, { options: filteredOptions, onChange: onSelect }) : /* @__PURE__ */ jsx2(Text2, { color: "yellow", children: "\u65E0\u5339\u914D\u5206\u652F" })
401
+ filteredOptions.length > 0 ? /* @__PURE__ */ jsx3(Select2, { options: filteredOptions, onChange: onSelect }) : /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: "\u65E0\u5339\u914D\u5206\u652F" })
245
402
  ] });
246
403
  }
247
404
 
248
405
  // src/components/commit-list.tsx
249
- import { useState as useState3 } from "react";
250
- import { Box as Box3, Text as Text3 } from "ink";
251
- import { MultiSelect, Spinner as Spinner3 } from "@inkjs/ui";
252
- import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
406
+ import { useState as useState4, useMemo as useMemo2, useRef } from "react";
407
+ import { Box as Box4, Text as Text4, useInput as useInput2 } from "ink";
408
+ import { Spinner as Spinner3 } from "@inkjs/ui";
409
+ import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
253
410
  function CommitList({ remote, branch, onSelect }) {
254
411
  const { data: commits, loading, error } = useCommits(remote, branch, 30);
255
- const [selectedHashes, setSelectedHashes] = useState3([]);
256
- const { stat, loading: statLoading } = useCommitStat(selectedHashes);
412
+ const [selectedIndex, setSelectedIndex] = useState4(0);
413
+ const [selectedHashes, setSelectedHashes] = useState4(/* @__PURE__ */ new Set());
414
+ const [shiftMode, setShiftMode] = useState4(false);
415
+ const anchorIndexRef = useRef(null);
416
+ const selectedKey = useMemo2(() => Array.from(selectedHashes).sort().join(","), [selectedHashes]);
417
+ const selectedArray = useMemo2(() => Array.from(selectedHashes), [selectedKey]);
418
+ const { stat, loading: statLoading } = useCommitStat(selectedArray);
419
+ const toggleCurrent = () => {
420
+ if (!commits || commits.length === 0) return;
421
+ const hash = commits[selectedIndex].hash;
422
+ setSelectedHashes((prev) => {
423
+ const next = new Set(prev);
424
+ if (next.has(hash)) {
425
+ next.delete(hash);
426
+ anchorIndexRef.current = null;
427
+ } else {
428
+ next.add(hash);
429
+ anchorIndexRef.current = selectedIndex;
430
+ }
431
+ return next;
432
+ });
433
+ };
434
+ const selectRange = (anchor, current) => {
435
+ if (!commits) return;
436
+ const start = Math.min(anchor, current);
437
+ const end = Math.max(anchor, current);
438
+ setSelectedHashes((prev) => {
439
+ const next = new Set(prev);
440
+ for (let i = start; i <= end; i++) {
441
+ next.add(commits[i].hash);
442
+ }
443
+ return next;
444
+ });
445
+ };
446
+ const toggleAll = () => {
447
+ if (!commits || commits.length === 0) return;
448
+ setSelectedHashes((prev) => {
449
+ if (prev.size === commits.length) {
450
+ anchorIndexRef.current = null;
451
+ return /* @__PURE__ */ new Set();
452
+ }
453
+ return new Set(commits.map((c) => c.hash));
454
+ });
455
+ };
456
+ const invertSelection = () => {
457
+ if (!commits || commits.length === 0) return;
458
+ setSelectedHashes((prev) => {
459
+ const next = /* @__PURE__ */ new Set();
460
+ for (const c of commits) {
461
+ if (!prev.has(c.hash)) {
462
+ next.add(c.hash);
463
+ }
464
+ }
465
+ return next;
466
+ });
467
+ };
468
+ const selectToCurrent = () => {
469
+ if (!commits || commits.length === 0) return;
470
+ setSelectedHashes((prev) => {
471
+ const next = new Set(prev);
472
+ for (let i = 0; i <= selectedIndex; i++) {
473
+ next.add(commits[i].hash);
474
+ }
475
+ return next;
476
+ });
477
+ };
478
+ useInput2((input, key) => {
479
+ if (!commits || commits.length === 0) return;
480
+ if (key.shift) {
481
+ if (!shiftMode) {
482
+ setShiftMode(true);
483
+ if (anchorIndexRef.current === null) {
484
+ anchorIndexRef.current = selectedIndex;
485
+ }
486
+ }
487
+ if (key.upArrow) {
488
+ const newIndex = Math.max(0, selectedIndex - 1);
489
+ setSelectedIndex(newIndex);
490
+ selectRange(anchorIndexRef.current, newIndex);
491
+ } else if (key.downArrow) {
492
+ const newIndex = Math.min(commits.length - 1, selectedIndex + 1);
493
+ setSelectedIndex(newIndex);
494
+ selectRange(anchorIndexRef.current, newIndex);
495
+ } else if (input === " ") {
496
+ if (anchorIndexRef.current !== null) {
497
+ selectRange(anchorIndexRef.current, selectedIndex);
498
+ } else {
499
+ toggleCurrent();
500
+ }
501
+ }
502
+ return;
503
+ }
504
+ if (shiftMode) {
505
+ setShiftMode(false);
506
+ }
507
+ if (key.upArrow) {
508
+ setSelectedIndex((prev) => Math.max(0, prev - 1));
509
+ } else if (key.downArrow) {
510
+ setSelectedIndex((prev) => Math.min(commits.length - 1, prev + 1));
511
+ } else if (input === " ") {
512
+ toggleCurrent();
513
+ } else if (input === "a" || input === "A") {
514
+ toggleAll();
515
+ } else if (input === "i" || input === "I") {
516
+ invertSelection();
517
+ } else if (input === "r" || input === "R") {
518
+ selectToCurrent();
519
+ } else if (key.return) {
520
+ if (selectedHashes.size > 0) {
521
+ onSelect(Array.from(selectedHashes), commits);
522
+ }
523
+ }
524
+ });
257
525
  if (loading) {
258
- return /* @__PURE__ */ jsx3(Box3, { children: /* @__PURE__ */ jsx3(Spinner3, { label: `\u6B63\u5728\u83B7\u53D6 ${remote}/${branch} \u7684 commit \u5217\u8868...` }) });
526
+ return /* @__PURE__ */ jsx4(Box4, { children: /* @__PURE__ */ jsx4(Spinner3, { label: `\u6B63\u5728\u83B7\u53D6 ${remote}/${branch} \u7684 commit \u5217\u8868...` }) });
259
527
  }
260
528
  if (error) {
261
- return /* @__PURE__ */ jsxs3(Text3, { color: "red", children: [
529
+ return /* @__PURE__ */ jsxs4(Text4, { color: "red", children: [
262
530
  "\u83B7\u53D6 commit \u5217\u8868\u5931\u8D25: ",
263
531
  error
264
532
  ] });
265
533
  }
266
534
  if (!commits || commits.length === 0) {
267
- return /* @__PURE__ */ jsx3(Text3, { color: "yellow", children: "\u8BE5\u5206\u652F\u6CA1\u6709 commit" });
535
+ return /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: "\u8BE5\u5206\u652F\u6CA1\u6709 commit" });
268
536
  }
269
- const options = commits.map((c) => ({
270
- label: `${c.shortHash} ${c.message} (${c.author}, ${c.date})`,
271
- value: c.hash
272
- }));
273
- return /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", gap: 1, children: [
274
- /* @__PURE__ */ jsx3(Text3, { bold: true, color: "cyan", children: "[3/5] \u9009\u62E9\u8981\u540C\u6B65\u7684 commit (Space \u9009\u62E9, Enter \u786E\u8BA4)" }),
275
- /* @__PURE__ */ jsxs3(Text3, { color: "gray", dimColor: true, children: [
537
+ const visibleCount = 10;
538
+ const startIdx = Math.max(0, Math.min(selectedIndex - Math.floor(visibleCount / 2), commits.length - visibleCount));
539
+ const visibleCommits = commits.slice(startIdx, startIdx + visibleCount);
540
+ return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", gap: 1, children: [
541
+ /* @__PURE__ */ jsx4(Text4, { bold: true, color: "cyan", children: "[3/5] \u9009\u62E9\u8981\u540C\u6B65\u7684 commit" }),
542
+ /* @__PURE__ */ jsxs4(Text4, { color: "gray", dimColor: true, children: [
276
543
  remote,
277
544
  "/",
278
545
  branch,
279
546
  " \u6700\u8FD1 ",
280
547
  commits.length,
281
- " \u4E2A commit"
548
+ " \u4E2A commit | \u5DF2\u9009 ",
549
+ selectedHashes.size,
550
+ " \u4E2A",
551
+ shiftMode && /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: " | Shift \u6A21\u5F0F" })
282
552
  ] }),
283
- /* @__PURE__ */ jsx3(
284
- MultiSelect,
285
- {
286
- options,
287
- onChange: setSelectedHashes,
288
- onSubmit: (hashes) => {
289
- if (hashes.length > 0) {
290
- onSelect(hashes, commits);
291
- }
292
- }
293
- }
294
- ),
295
- selectedHashes.length > 0 && /* @__PURE__ */ jsxs3(Box3, { flexDirection: "column", marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: [
296
- /* @__PURE__ */ jsxs3(Text3, { bold: true, color: "yellow", children: [
553
+ /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", children: [
554
+ startIdx > 0 && /* @__PURE__ */ jsxs4(Text4, { color: "gray", dimColor: true, children: [
555
+ " \u2191 ",
556
+ startIdx,
557
+ " more..."
558
+ ] }),
559
+ visibleCommits.map((c, i) => {
560
+ const actualIdx = startIdx + i;
561
+ const isSelected = selectedHashes.has(c.hash);
562
+ const isCursor = actualIdx === selectedIndex;
563
+ const isAnchor = actualIdx === anchorIndexRef.current;
564
+ return /* @__PURE__ */ jsxs4(Text4, { children: [
565
+ /* @__PURE__ */ jsxs4(Text4, { backgroundColor: isCursor ? "blue" : void 0, color: isSelected ? "green" : "white", children: [
566
+ isCursor ? "\u25B6 " : " ",
567
+ isAnchor ? "\u2693 " : isSelected ? "\u25CF " : "\u25CB ",
568
+ c.shortHash,
569
+ " ",
570
+ c.message
571
+ ] }),
572
+ /* @__PURE__ */ jsxs4(Text4, { color: "gray", dimColor: true, children: [
573
+ " (",
574
+ c.author,
575
+ ")"
576
+ ] })
577
+ ] }, c.hash);
578
+ }),
579
+ startIdx + visibleCount < commits.length && /* @__PURE__ */ jsxs4(Text4, { color: "gray", dimColor: true, children: [
580
+ " \u2193 ",
581
+ commits.length - startIdx - visibleCount,
582
+ " more..."
583
+ ] })
584
+ ] }),
585
+ /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", gap: 0, children: [
586
+ /* @__PURE__ */ jsxs4(Box4, { gap: 2, children: [
587
+ /* @__PURE__ */ jsxs4(Text4, { children: [
588
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "\u2191/\u2193" }),
589
+ " \u5BFC\u822A"
590
+ ] }),
591
+ /* @__PURE__ */ jsxs4(Text4, { children: [
592
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "Space" }),
593
+ " \u9009\u62E9"
594
+ ] }),
595
+ /* @__PURE__ */ jsxs4(Text4, { children: [
596
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "a" }),
597
+ " \u5168\u9009"
598
+ ] }),
599
+ /* @__PURE__ */ jsxs4(Text4, { children: [
600
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "i" }),
601
+ " \u53CD\u9009"
602
+ ] }),
603
+ /* @__PURE__ */ jsxs4(Text4, { children: [
604
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "Enter" }),
605
+ " \u786E\u8BA4"
606
+ ] })
607
+ ] }),
608
+ /* @__PURE__ */ jsxs4(Box4, { gap: 2, children: [
609
+ /* @__PURE__ */ jsxs4(Text4, { children: [
610
+ /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: "Shift+\u2191/\u2193" }),
611
+ " \u8FDE\u7EED\u9009\u62E9"
612
+ ] }),
613
+ /* @__PURE__ */ jsxs4(Text4, { children: [
614
+ /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: "Shift+Space" }),
615
+ " \u8303\u56F4\u9009\u62E9"
616
+ ] }),
617
+ /* @__PURE__ */ jsxs4(Text4, { children: [
618
+ /* @__PURE__ */ jsx4(Text4, { color: "cyan", children: "r" }),
619
+ " \u9009\u81F3\u5F00\u5934"
620
+ ] })
621
+ ] })
622
+ ] }),
623
+ selectedHashes.size > 0 && /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, children: [
624
+ /* @__PURE__ */ jsxs4(Text4, { bold: true, color: "yellow", children: [
297
625
  "\u5DF2\u9009 ",
298
- selectedHashes.length,
626
+ selectedHashes.size,
299
627
  " \u4E2A commit \u2014 diff --stat \u9884\u89C8:"
300
628
  ] }),
301
- statLoading ? /* @__PURE__ */ jsx3(Spinner3, { label: "\u52A0\u8F7D\u4E2D..." }) : /* @__PURE__ */ jsx3(Text3, { color: "gray", children: stat || "(\u65E0\u53D8\u66F4)" })
629
+ statLoading ? /* @__PURE__ */ jsx4(Spinner3, { label: "\u52A0\u8F7D\u4E2D..." }) : /* @__PURE__ */ jsx4(Text4, { color: "gray", children: stat || "(\u65E0\u53D8\u66F4)" })
302
630
  ] })
303
631
  ] });
304
632
  }
305
633
 
306
634
  // src/components/confirm-panel.tsx
307
- import { Box as Box4, Text as Text4, useInput } from "ink";
308
- import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
309
- function ConfirmPanel({ commits, selectedHashes, onConfirm, onCancel }) {
310
- useInput((input) => {
635
+ import { Box as Box5, Text as Text5, useInput as useInput3 } from "ink";
636
+ import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
637
+ function ConfirmPanel({ commits, selectedHashes, hasMerge, useMainline, onToggleMainline, onConfirm, onCancel }) {
638
+ useInput3((input) => {
311
639
  if (input === "y" || input === "Y") {
312
640
  onConfirm();
313
641
  } else if (input === "n" || input === "N" || input === "q") {
314
642
  onCancel();
643
+ } else if (hasMerge && (input === "m" || input === "M")) {
644
+ onToggleMainline();
315
645
  }
316
646
  });
317
647
  const selectedCommits = selectedHashes.map((hash) => commits.find((c) => c.hash === hash)).filter(Boolean);
318
- return /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", gap: 1, children: [
319
- /* @__PURE__ */ jsx4(Text4, { bold: true, color: "cyan", children: "[4/5] \u786E\u8BA4\u6267\u884C" }),
320
- /* @__PURE__ */ jsxs4(Box4, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
321
- /* @__PURE__ */ jsxs4(Text4, { bold: true, children: [
648
+ return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", gap: 1, children: [
649
+ /* @__PURE__ */ jsx5(Text5, { bold: true, color: "cyan", children: "[4/5] \u786E\u8BA4\u6267\u884C" }),
650
+ /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [
651
+ /* @__PURE__ */ jsxs5(Text5, { bold: true, children: [
322
652
  "\u5C06 cherry-pick --no-commit \u4EE5\u4E0B ",
323
653
  selectedCommits.length,
324
654
  " \u4E2A commit:"
325
655
  ] }),
326
- selectedCommits.map((c) => /* @__PURE__ */ jsxs4(Text4, { children: [
327
- /* @__PURE__ */ jsxs4(Text4, { color: "green", children: [
656
+ selectedCommits.map((c) => /* @__PURE__ */ jsxs5(Text5, { children: [
657
+ /* @__PURE__ */ jsxs5(Text5, { color: "green", children: [
328
658
  " ",
329
659
  c.shortHash
330
660
  ] }),
331
- /* @__PURE__ */ jsxs4(Text4, { children: [
661
+ /* @__PURE__ */ jsxs5(Text5, { children: [
332
662
  " ",
333
663
  c.message
334
664
  ] }),
335
- /* @__PURE__ */ jsxs4(Text4, { color: "gray", dimColor: true, children: [
665
+ /* @__PURE__ */ jsxs5(Text5, { color: "gray", dimColor: true, children: [
336
666
  " (",
337
667
  c.author,
338
668
  ")"
339
669
  ] })
340
670
  ] }, c.hash))
341
671
  ] }),
342
- /* @__PURE__ */ jsxs4(Box4, { children: [
343
- /* @__PURE__ */ jsx4(Text4, { color: "yellow", children: "\u26A0 " }),
344
- /* @__PURE__ */ jsx4(Text4, { children: "\u4F7F\u7528 --no-commit \u6A21\u5F0F\uFF0C\u6539\u52A8\u5C06\u6682\u5B58\u5230\u5DE5\u4F5C\u533A\uFF0C\u9700\u624B\u52A8 commit" })
672
+ hasMerge && /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", borderStyle: "single", borderColor: "red", paddingX: 1, children: [
673
+ /* @__PURE__ */ jsx5(Text5, { bold: true, color: "red", children: "\u68C0\u6D4B\u5230 Merge Commit" }),
674
+ /* @__PURE__ */ jsx5(Text5, { color: "yellow", children: "Cherry-pick \u5408\u5E76\u63D0\u4EA4\u9700\u8981\u6307\u5B9A\u7236\u8282\u70B9 (-m 1)" }),
675
+ /* @__PURE__ */ jsxs5(Text5, { children: [
676
+ /* @__PURE__ */ jsx5(Text5, { color: "cyan", children: "[m]" }),
677
+ /* @__PURE__ */ jsx5(Text5, { children: " \u5207\u6362 -m 1: " }),
678
+ useMainline ? /* @__PURE__ */ jsx5(Text5, { color: "green", children: "\u5DF2\u542F\u7528" }) : /* @__PURE__ */ jsx5(Text5, { color: "gray", children: "\u672A\u542F\u7528" })
679
+ ] })
345
680
  ] }),
346
- /* @__PURE__ */ jsxs4(Box4, { children: [
347
- /* @__PURE__ */ jsx4(Text4, { bold: true, children: "\u786E\u8BA4\u6267\u884C? " }),
348
- /* @__PURE__ */ jsx4(Text4, { color: "green", children: "[y]" }),
349
- /* @__PURE__ */ jsx4(Text4, { children: " \u786E\u8BA4 / " }),
350
- /* @__PURE__ */ jsx4(Text4, { color: "red", children: "[n]" }),
351
- /* @__PURE__ */ jsx4(Text4, { children: " \u53D6\u6D88" })
681
+ /* @__PURE__ */ jsxs5(Box5, { children: [
682
+ /* @__PURE__ */ jsx5(Text5, { color: "yellow", children: "\u26A0 " }),
683
+ /* @__PURE__ */ jsx5(Text5, { children: "\u4F7F\u7528 --no-commit \u6A21\u5F0F\uFF0C\u6539\u52A8\u5C06\u6682\u5B58\u5230\u5DE5\u4F5C\u533A\uFF0C\u9700\u624B\u52A8 commit" })
684
+ ] }),
685
+ /* @__PURE__ */ jsxs5(Box5, { children: [
686
+ /* @__PURE__ */ jsx5(Text5, { bold: true, children: "\u786E\u8BA4\u6267\u884C? " }),
687
+ /* @__PURE__ */ jsx5(Text5, { color: "green", children: "[y]" }),
688
+ /* @__PURE__ */ jsx5(Text5, { children: " \u786E\u8BA4 / " }),
689
+ /* @__PURE__ */ jsx5(Text5, { color: "red", children: "[n]" }),
690
+ /* @__PURE__ */ jsx5(Text5, { children: " \u53D6\u6D88" }),
691
+ hasMerge && /* @__PURE__ */ jsxs5(Text5, { children: [
692
+ " / ",
693
+ /* @__PURE__ */ jsx5(Text5, { color: "cyan", children: "[m]" }),
694
+ " \u5207\u6362 -m 1"
695
+ ] })
352
696
  ] })
353
697
  ] });
354
698
  }
355
699
 
356
700
  // src/components/result-panel.tsx
357
- import { useState as useState4, useEffect as useEffect3 } from "react";
358
- import { Box as Box5, Text as Text5 } from "ink";
701
+ import { useState as useState5, useEffect as useEffect2 } from "react";
702
+ import { Box as Box6, Text as Text6 } from "ink";
359
703
  import { Spinner as Spinner4 } from "@inkjs/ui";
360
- import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
361
- function ResultPanel({ selectedHashes, onDone }) {
362
- const [phase, setPhase] = useState4("executing");
363
- const [result, setResult] = useState4(null);
364
- const [stagedStat, setStagedStat] = useState4("");
365
- useEffect3(() => {
704
+ import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
705
+ function ResultPanel({ selectedHashes, useMainline, stashed, onStashRestored, onDone }) {
706
+ const [phase, setPhase] = useState5("executing");
707
+ const [result, setResult] = useState5(null);
708
+ const [stagedStat, setStagedStat] = useState5("");
709
+ const [stashRestored, setStashRestored] = useState5(null);
710
+ const tryRestoreStash = async () => {
711
+ if (!stashed) return true;
712
+ setPhase("restoring");
713
+ const ok = await stashPop();
714
+ setStashRestored(ok);
715
+ if (ok) onStashRestored();
716
+ return ok;
717
+ };
718
+ useEffect2(() => {
366
719
  async function run() {
367
- const res = await cherryPick(selectedHashes);
720
+ const res = await cherryPick(selectedHashes, useMainline);
368
721
  setResult(res);
369
722
  if (res.success) {
370
723
  const stat = await getStagedStat();
371
724
  setStagedStat(stat);
725
+ await tryRestoreStash();
372
726
  setPhase("done");
373
727
  } else {
728
+ await tryRestoreStash();
374
729
  setPhase("error");
375
730
  }
376
731
  }
377
732
  run();
378
733
  }, []);
379
734
  if (phase === "executing") {
380
- return /* @__PURE__ */ jsx5(Box5, { children: /* @__PURE__ */ jsx5(Spinner4, { label: `\u6B63\u5728\u6267\u884C cherry-pick --no-commit (${selectedHashes.length} \u4E2A commit)...` }) });
735
+ return /* @__PURE__ */ jsx6(Box6, { children: /* @__PURE__ */ jsx6(Spinner4, { label: `\u6B63\u5728\u6267\u884C cherry-pick --no-commit (${selectedHashes.length} \u4E2A commit)...` }) });
736
+ }
737
+ if (phase === "restoring") {
738
+ return /* @__PURE__ */ jsx6(Box6, { children: /* @__PURE__ */ jsx6(Spinner4, { label: "\u6B63\u5728\u6062\u590D\u5DE5\u4F5C\u533A (git stash pop)..." }) });
381
739
  }
382
740
  if (phase === "error" && result) {
383
- return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", gap: 1, children: [
384
- /* @__PURE__ */ jsx5(Text5, { bold: true, color: "red", children: "[5/5] Cherry-pick \u9047\u5230\u51B2\u7A81" }),
385
- result.conflictFiles && result.conflictFiles.length > 0 && /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", borderStyle: "single", borderColor: "red", paddingX: 1, children: [
386
- /* @__PURE__ */ jsx5(Text5, { bold: true, children: "\u51B2\u7A81\u6587\u4EF6:" }),
387
- result.conflictFiles.map((f) => /* @__PURE__ */ jsxs5(Text5, { color: "red", children: [
741
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", gap: 1, children: [
742
+ /* @__PURE__ */ jsx6(Text6, { bold: true, color: "red", children: "[5/5] Cherry-pick \u9047\u5230\u51B2\u7A81" }),
743
+ result.conflictFiles && result.conflictFiles.length > 0 && /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", borderStyle: "single", borderColor: "red", paddingX: 1, children: [
744
+ /* @__PURE__ */ jsx6(Text6, { bold: true, children: "\u51B2\u7A81\u6587\u4EF6:" }),
745
+ result.conflictFiles.map((f) => /* @__PURE__ */ jsxs6(Text6, { color: "red", children: [
388
746
  " ",
389
747
  f
390
748
  ] }, f))
391
749
  ] }),
392
- /* @__PURE__ */ jsx5(Text5, { color: "yellow", children: "\u8BF7\u624B\u52A8\u89E3\u51B3\u51B2\u7A81\u540E\u6267\u884C git add \u548C git commit" }),
393
- /* @__PURE__ */ jsx5(Text5, { color: "gray", dimColor: true, children: "\u6216\u6267\u884C git cherry-pick --abort \u653E\u5F03\u64CD\u4F5C" })
750
+ /* @__PURE__ */ jsx6(Text6, { color: "yellow", children: "\u8BF7\u624B\u52A8\u89E3\u51B3\u51B2\u7A81\u540E\u6267\u884C git add \u548C git commit" }),
751
+ /* @__PURE__ */ jsx6(Text6, { color: "gray", dimColor: true, children: "\u6216\u6267\u884C git cherry-pick --abort \u653E\u5F03\u64CD\u4F5C" }),
752
+ stashed && stashRestored === false && /* @__PURE__ */ jsx6(Text6, { color: "yellow", children: "\u6CE8\u610F: stash \u6062\u590D\u5931\u8D25\uFF0C\u8BF7\u624B\u52A8 git stash pop" }),
753
+ stashed && stashRestored === true && /* @__PURE__ */ jsx6(Text6, { color: "green", children: "\u5DF2\u6062\u590D\u5DE5\u4F5C\u533A\u53D8\u66F4 (stash pop)" })
394
754
  ] });
395
755
  }
396
- return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", gap: 1, children: [
397
- /* @__PURE__ */ jsx5(Text5, { bold: true, color: "green", children: "[5/5] \u540C\u6B65\u5B8C\u6210!" }),
398
- /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [
399
- /* @__PURE__ */ jsx5(Text5, { bold: true, children: "\u6682\u5B58\u533A\u53D8\u66F4\u6982\u89C8 (git diff --cached --stat):" }),
400
- /* @__PURE__ */ jsx5(Text5, { color: "gray", children: stagedStat || "(\u65E0\u53D8\u66F4)" })
756
+ return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", gap: 1, children: [
757
+ /* @__PURE__ */ jsx6(Text6, { bold: true, color: "green", children: "[5/5] \u540C\u6B65\u5B8C\u6210!" }),
758
+ /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 1, children: [
759
+ /* @__PURE__ */ jsx6(Text6, { bold: true, children: "\u6682\u5B58\u533A\u53D8\u66F4\u6982\u89C8 (git diff --cached --stat):" }),
760
+ /* @__PURE__ */ jsx6(Text6, { color: "gray", children: stagedStat || "(\u65E0\u53D8\u66F4)" })
401
761
  ] }),
402
- /* @__PURE__ */ jsx5(Text5, { color: "yellow", children: "\u6539\u52A8\u5DF2\u6682\u5B58\u5230\u5DE5\u4F5C\u533A (--no-commit \u6A21\u5F0F)" }),
403
- /* @__PURE__ */ jsx5(Text5, { children: "\u8BF7\u5BA1\u67E5\u540E\u624B\u52A8\u6267\u884C:" }),
404
- /* @__PURE__ */ jsx5(Text5, { color: "cyan", children: " git diff --cached # \u67E5\u770B\u8BE6\u7EC6 diff" }),
405
- /* @__PURE__ */ jsx5(Text5, { color: "cyan", children: ' git commit -m "\u540C\u6B65 commit" # \u63D0\u4EA4' }),
406
- /* @__PURE__ */ jsx5(Text5, { color: "cyan", children: " git reset HEAD # \u6216\u653E\u5F03\u6240\u6709\u6539\u52A8" })
762
+ stashed && (stashRestored ? /* @__PURE__ */ jsx6(Text6, { color: "green", children: "\u5DF2\u6062\u590D\u5DE5\u4F5C\u533A\u53D8\u66F4 (stash pop)" }) : /* @__PURE__ */ jsx6(Text6, { color: "yellow", children: "stash pop \u5931\u8D25\uFF0C\u8BF7\u624B\u52A8 git stash pop" })),
763
+ /* @__PURE__ */ jsx6(Text6, { color: "yellow", children: "\u6539\u52A8\u5DF2\u6682\u5B58\u5230\u5DE5\u4F5C\u533A (--no-commit \u6A21\u5F0F)" }),
764
+ /* @__PURE__ */ jsx6(Text6, { children: "\u8BF7\u5BA1\u67E5\u540E\u624B\u52A8\u6267\u884C:" }),
765
+ /* @__PURE__ */ jsx6(Text6, { color: "cyan", children: " git diff --cached # \u67E5\u770B\u8BE6\u7EC6 diff" }),
766
+ /* @__PURE__ */ jsx6(Text6, { color: "cyan", children: ' git commit -m "\u540C\u6B65 commit" # \u63D0\u4EA4' }),
767
+ /* @__PURE__ */ jsx6(Text6, { color: "cyan", children: " git reset HEAD # \u6216\u653E\u5F03\u6240\u6709\u6539\u52A8" })
407
768
  ] });
408
769
  }
409
770
 
410
771
  // src/app.tsx
411
- import { jsx as jsx6, jsxs as jsxs6 } from "react/jsx-runtime";
772
+ import { execSync } from "child_process";
773
+ import { jsx as jsx7, jsxs as jsxs7 } from "react/jsx-runtime";
412
774
  function App() {
413
- const [step, setStep] = useState5("remote");
414
- const [remote, setRemote] = useState5("");
415
- const [branch, setBranch] = useState5("");
416
- const [selectedHashes, setSelectedHashes] = useState5([]);
417
- const [commits, setCommits] = useState5([]);
418
- return /* @__PURE__ */ jsxs6(Box6, { flexDirection: "column", children: [
419
- /* @__PURE__ */ jsxs6(Box6, { marginBottom: 1, children: [
420
- /* @__PURE__ */ jsx6(Text6, { bold: true, inverse: true, color: "white", children: " git-sync-tui " }),
421
- /* @__PURE__ */ jsx6(Text6, { children: " " }),
422
- /* @__PURE__ */ jsx6(Text6, { color: "gray", children: "\u4EA4\u4E92\u5F0F commit \u540C\u6B65\u5DE5\u5177 (cherry-pick --no-commit)" })
775
+ const { exit } = useApp();
776
+ const [step, setStep] = useState6("checking");
777
+ const [remote, setRemote] = useState6("");
778
+ const [branch, setBranch] = useState6("");
779
+ const [selectedHashes, setSelectedHashes] = useState6([]);
780
+ const [commits, setCommits] = useState6([]);
781
+ const [hasMerge, setHasMerge] = useState6(false);
782
+ const [useMainline, setUseMainline] = useState6(false);
783
+ const [stashed, setStashed] = useState6(false);
784
+ const stashedRef = useRef2(false);
785
+ const stashRestoredRef = useRef2(false);
786
+ const restoreStashSync = useCallback2(() => {
787
+ if (stashedRef.current && !stashRestoredRef.current) {
788
+ try {
789
+ execSync("git stash pop", { stdio: "ignore" });
790
+ stashRestoredRef.current = true;
791
+ } catch {
792
+ try {
793
+ process.stderr.write("\n\u26A0 stash \u81EA\u52A8\u6062\u590D\u5931\u8D25\uFF0C\u8BF7\u624B\u52A8\u6267\u884C: git stash pop\n");
794
+ } catch {
795
+ }
796
+ }
797
+ }
798
+ }, []);
799
+ const markStashRestored = useCallback2(() => {
800
+ stashRestoredRef.current = true;
801
+ }, []);
802
+ useEffect3(() => {
803
+ isWorkingDirClean().then((clean) => {
804
+ if (clean) {
805
+ setStep("remote");
806
+ } else {
807
+ setStep("stash-prompt");
808
+ }
809
+ });
810
+ const onSignal = () => {
811
+ restoreStashSync();
812
+ process.exit(0);
813
+ };
814
+ process.on("SIGINT", onSignal);
815
+ process.on("SIGTERM", onSignal);
816
+ process.on("beforeExit", restoreStashSync);
817
+ return () => {
818
+ process.off("SIGINT", onSignal);
819
+ process.off("SIGTERM", onSignal);
820
+ process.off("beforeExit", restoreStashSync);
821
+ };
822
+ }, [restoreStashSync]);
823
+ const doStash = async () => {
824
+ const ok = await stash();
825
+ if (ok) {
826
+ setStashed(true);
827
+ stashedRef.current = true;
828
+ }
829
+ setStep("remote");
830
+ };
831
+ return /* @__PURE__ */ jsxs7(Box7, { flexDirection: "column", children: [
832
+ /* @__PURE__ */ jsxs7(Box7, { marginBottom: 1, children: [
833
+ /* @__PURE__ */ jsx7(Text7, { bold: true, inverse: true, color: "white", children: " git-sync-tui " }),
834
+ /* @__PURE__ */ jsx7(Text7, { children: " " }),
835
+ /* @__PURE__ */ jsx7(Text7, { color: "gray", children: "\u4EA4\u4E92\u5F0F commit \u540C\u6B65\u5DE5\u5177 (cherry-pick --no-commit)" }),
836
+ stashed && /* @__PURE__ */ jsx7(Text7, { color: "yellow", children: " (\u5DF2\u81EA\u52A8 stash)" })
423
837
  ] }),
424
- step === "remote" && /* @__PURE__ */ jsx6(
838
+ step === "checking" && /* @__PURE__ */ jsx7(Spinner5, { label: "\u68C0\u67E5\u5DE5\u4F5C\u533A\u72B6\u6001..." }),
839
+ step === "stash-prompt" && /* @__PURE__ */ jsx7(
840
+ StashPrompt,
841
+ {
842
+ onConfirm: doStash,
843
+ onSkip: () => setStep("remote")
844
+ }
845
+ ),
846
+ step === "remote" && /* @__PURE__ */ jsx7(
425
847
  RemoteSelect,
426
848
  {
427
849
  onSelect: (r) => {
@@ -430,7 +852,7 @@ function App() {
430
852
  }
431
853
  }
432
854
  ),
433
- step === "branch" && /* @__PURE__ */ jsx6(
855
+ step === "branch" && /* @__PURE__ */ jsx7(
434
856
  BranchSelect,
435
857
  {
436
858
  remote,
@@ -440,39 +862,50 @@ function App() {
440
862
  }
441
863
  }
442
864
  ),
443
- step === "commits" && /* @__PURE__ */ jsx6(
865
+ step === "commits" && /* @__PURE__ */ jsx7(
444
866
  CommitList,
445
867
  {
446
868
  remote,
447
869
  branch,
448
- onSelect: (hashes, loadedCommits) => {
870
+ onSelect: async (hashes, loadedCommits) => {
449
871
  setSelectedHashes(hashes);
450
872
  setCommits(loadedCommits);
873
+ const merge = await hasMergeCommits(hashes);
874
+ setHasMerge(merge);
451
875
  setStep("confirm");
452
876
  }
453
877
  }
454
878
  ),
455
- step === "confirm" && /* @__PURE__ */ jsx6(
879
+ step === "confirm" && /* @__PURE__ */ jsx7(
456
880
  ConfirmPanel,
457
881
  {
458
882
  commits,
459
883
  selectedHashes,
884
+ hasMerge,
885
+ useMainline,
886
+ onToggleMainline: () => setUseMainline((v) => !v),
460
887
  onConfirm: () => setStep("result"),
461
888
  onCancel: () => setStep("commits")
462
889
  }
463
890
  ),
464
- step === "result" && /* @__PURE__ */ jsx6(
891
+ step === "result" && /* @__PURE__ */ jsx7(
465
892
  ResultPanel,
466
893
  {
467
894
  selectedHashes,
468
- onDone: () => process.exit(0)
895
+ useMainline,
896
+ stashed,
897
+ onStashRestored: markStashRestored,
898
+ onDone: () => {
899
+ restoreStashSync();
900
+ exit();
901
+ }
469
902
  }
470
903
  )
471
904
  ] });
472
905
  }
473
906
 
474
907
  // src/cli.tsx
475
- import { jsx as jsx7 } from "react/jsx-runtime";
908
+ import { jsx as jsx8 } from "react/jsx-runtime";
476
909
  var cli = meow(
477
910
  `
478
911
  \u7528\u6CD5
@@ -487,13 +920,16 @@ var cli = meow(
487
920
  \u4F7F\u7528 cherry-pick --no-commit \u6A21\u5F0F\uFF0C\u540C\u6B65\u540E\u53EF\u5BA1\u67E5\u518D\u63D0\u4EA4\u3002
488
921
 
489
922
  \u5FEB\u6377\u952E
490
- Space \u9009\u62E9/\u53D6\u6D88 commit
491
- Enter \u786E\u8BA4\u9009\u62E9
492
- \u2191/\u2193 \u5BFC\u822A
493
- y/n \u786E\u8BA4/\u53D6\u6D88\u6267\u884C
923
+ Space \u9009\u62E9/\u53D6\u6D88 commit
924
+ Shift+\u2191/\u2193 \u8FDE\u7EED\u9009\u62E9
925
+ a \u5168\u9009/\u53D6\u6D88\u5168\u9009
926
+ i \u53CD\u9009
927
+ r \u9009\u81F3\u5F00\u5934
928
+ Enter \u786E\u8BA4\u9009\u62E9
929
+ y/n \u786E\u8BA4/\u53D6\u6D88\u6267\u884C
494
930
  `,
495
931
  {
496
932
  importMeta: import.meta
497
933
  }
498
934
  );
499
- render(/* @__PURE__ */ jsx7(App, {}));
935
+ render(/* @__PURE__ */ jsx8(App, {}));
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "git-sync-tui",
3
3
  "type": "module",
4
- "version": "0.1.0",
4
+ "version": "0.1.2",
5
+ "packageManager": "pnpm@10.32.1",
5
6
  "description": "Interactive TUI tool for cross-repo git commit synchronization (cherry-pick --no-commit)",
6
7
  "author": "KiWi233333",
7
8
  "repository": {
@@ -12,6 +13,10 @@
12
13
  "bugs": {
13
14
  "url": "https://github.com/KiWi233333/git-sync-tui/issues"
14
15
  },
16
+ "publishConfig": {
17
+ "registry": "https://registry.npmjs.org",
18
+ "access": "public"
19
+ },
15
20
  "bin": {
16
21
  "git-sync-tui": "dist/cli.js"
17
22
  },
@@ -21,11 +26,20 @@
21
26
  "engines": {
22
27
  "node": ">=20"
23
28
  },
29
+ "volta": {
30
+ "node": "24.14.0"
31
+ },
24
32
  "scripts": {
25
33
  "start": "tsx src/cli.tsx",
26
34
  "dev": "tsx watch src/cli.tsx",
27
35
  "build": "tsup",
28
- "prepublishOnly": "npm run build"
36
+ "prepublishOnly": "pnpm run build",
37
+ "release": "pnpm run release:patch",
38
+ "release:patch": "pnpm version patch && git push --follow-tags",
39
+ "release:minor": "pnpm version minor && git push --follow-tags",
40
+ "release:major": "pnpm version major && git push --follow-tags",
41
+ "release:beta": "pnpm version prerelease --preid=beta && git push --follow-tags",
42
+ "release:retag": "tsx scripts/retag.ts"
29
43
  },
30
44
  "keywords": [
31
45
  "git",