git-sync-tui 0.1.6 → 0.1.8

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/README.md CHANGED
@@ -15,14 +15,15 @@
15
15
 
16
16
  <p align="center">
17
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.
18
+ Multi-select commits, preview diff stats, handle conflicts interactively, and sync safely with backup &amp; stash protection.
19
19
  </p>
20
20
 
21
21
  <p align="center">
22
22
  <a href="#-features">Features</a> ·
23
23
  <a href="#-quick-start">Quick Start</a> ·
24
24
  <a href="#-installation">Installation</a> ·
25
- <a href="#-workflow">Workflow</a>
25
+ <a href="#-workflow">Workflow</a> ·
26
+ <a href="#%EF%B8%8F-cli-options">CLI Options</a>
26
27
  </p>
27
28
 
28
29
  <p align="center">
@@ -35,11 +36,18 @@
35
36
 
36
37
  ## ✨ Features
37
38
 
38
- - 🎯 **Multi-select commits** — Cherry-pick non-consecutive commits with Space / Enter
39
+ - 🎯 **Multi-select commits** — Select non-consecutive commits with Space, range-select with Shift+↑↓, toggle all with `a`, invert with `i`
39
40
  - 🔍 **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
41
+ - 👀 **Diff preview** — Scrollable `--stat` summary panel with `j`/`k` navigation
42
+ - ⚡ **Dual mode** — `--no-commit` stages changes for review, or commit individually preserving original messages
43
+ - 🔀 **One-by-one cherry-pick** — Executes commits sequentially, pausing on conflicts for interactive resolution
44
+ - ⚠️ **Conflict handling** — Shows conflicted files, resolve in another terminal, then continue/abort/quit
45
+ - 🛡️ **Safe backup** — Creates a backup branch before execution; full rollback on abort
46
+ - 📦 **Auto stash** — Detects uncommitted changes, offers to stash, auto-restores after sync
47
+ - 🔄 **Stash recovery** — Detects interrupted sessions and offers to recover stashed changes
48
+ - 🌿 **Branch check** — Auto-creates target branch from main/master if not on it
49
+ - ✅ **Synced markers** — Marks already-synced commits as `[synced]` in the commit list
50
+ - 🖥️ **CLI mode** — Non-interactive mode with `-r -b -c` flags for scripting
43
51
  - 🌐 **Universal** — Works in any git repository, any language
44
52
 
45
53
  ## 🚀 Quick Start
@@ -64,35 +72,95 @@ npm install -g git-sync-tui
64
72
  ## 🔄 Workflow
65
73
 
66
74
  ```
67
- Select Remote → Select Branch → Multi-select Commits → Preview Changes
68
-
69
- Review & Commit manually ← Cherry-pick --no-commit (staged, not committed)
75
+ Check workspace → Select Remote → Select Branch → Branch Check → Multi-select Commits
76
+
77
+ Auto stash Preview diff stats
78
+ (if needed) ↓
79
+ Confirm & choose mode
80
+
81
+ Cherry-pick one-by-one (with backup)
82
+
83
+ Handle conflicts / Done
84
+
85
+ Restore stash & exit
70
86
  ```
71
87
 
72
88
  ## ⌨️ Keyboard Shortcuts
73
89
 
90
+ ### Commit Selection
91
+
74
92
  | Key | Action |
75
93
  |-----|--------|
76
- | `↑` `↓` | Navigate items |
94
+ | `↑` `↓` | Navigate commits |
77
95
  | `Space` | Toggle commit selection |
96
+ | `Shift`+`↑`/`↓` | Range select |
97
+ | `a` | Select all / Deselect all |
98
+ | `i` | Invert selection |
99
+ | `r` | Select from top to cursor |
100
+ | `j` / `k` | Scroll diff stat preview |
78
101
  | `Enter` | Confirm selection |
79
- | `y` / `n` | Confirm / cancel execution |
80
- | `/` | Search (in branch list) |
102
+ | `Esc` | Go back |
81
103
 
82
- ## 📋 After Sync
104
+ ### Confirm Panel
105
+
106
+ | Key | Action |
107
+ |-----|--------|
108
+ | `y` | Confirm execution |
109
+ | `n` | Cancel |
110
+ | `c` | Toggle commit mode (--no-commit / individual) |
111
+ | `m` | Toggle `-m 1` for merge commits |
112
+ | `Esc` | Go back |
83
113
 
84
- Changes are staged in your working tree (not committed). You can:
114
+ ### Conflict Handling
85
115
 
86
- ```bash
87
- # Review staged changes
88
- git diff --cached
116
+ | Key | Action |
117
+ |-----|--------|
118
+ | `c` | Continue (after resolving conflicts) |
119
+ | `a` | Abort (rollback all changes) |
120
+ | `q` | Quit (keep current state) |
89
121
 
90
- # Commit when ready
91
- git commit -m "sync: cherry-picked commits from feature-branch"
122
+ ## ⚙️ CLI Options
92
123
 
93
- # Or discard all changes
94
- git reset HEAD
95
124
  ```
125
+ Usage
126
+ $ git-sync-tui [options]
127
+
128
+ Options
129
+ -r, --remote <name> Remote name
130
+ -b, --branch <name> Remote branch name
131
+ -c, --commits <hashes> Commit hashes (comma-separated)
132
+ -n, --count <number> Number of commits to show (default: 100)
133
+ -m, --mainline Use -m 1 for merge commits
134
+ -y, --yes Skip confirmation
135
+ --no-stash Skip stash prompt
136
+ --list List remote branch commits and exit
137
+
138
+ Modes
139
+ No arguments Interactive TUI mode
140
+ -r -b --list List commits (plain text)
141
+ -r -b -c CLI mode, confirm before execution
142
+ -r -b -c --yes CLI mode, execute directly
143
+ -r or -r -b only TUI mode, skip completed steps
144
+
145
+ Examples
146
+ $ git-sync-tui # TUI mode
147
+ $ git-sync-tui -r upstream -b main --list # List commits
148
+ $ git-sync-tui -r upstream -b main -c abc1234 --yes # Execute directly
149
+ $ git-sync-tui -r upstream -b main -c abc1234,def5678 # Confirm then execute
150
+ $ git-sync-tui -r upstream # TUI mode, skip remote select
151
+ ```
152
+
153
+ ## 📋 After Sync
154
+
155
+ **--no-commit mode** — Changes are staged in your working tree (not committed):
156
+
157
+ ```bash
158
+ git diff --cached # Review staged changes
159
+ git commit -m "sync: cherry-picked commits from feature-branch" # Commit
160
+ git reset HEAD # Or discard all changes
161
+ ```
162
+
163
+ **Individual commit mode** — Original commit messages are preserved. Check with `git log`.
96
164
 
97
165
  ## 💡 Use Cases
98
166
 
@@ -107,8 +175,8 @@ git reset HEAD
107
175
  ```bash
108
176
  git clone https://github.com/KiWi233333/git-sync-tui.git
109
177
  cd git-sync-tui
110
- npm install
111
- npm start
178
+ pnpm install
179
+ pnpm start
112
180
  ```
113
181
 
114
182
  ## 🏗️ Tech Stack
@@ -116,6 +184,7 @@ npm start
116
184
  - [Ink](https://github.com/vadimdemedes/ink) — React for interactive CLI apps
117
185
  - [@inkjs/ui](https://github.com/inkjs/ui) — UI components for Ink
118
186
  - [simple-git](https://github.com/steveukx/git-js) — Git commands interface
187
+ - [meow](https://github.com/sindresorhus/meow) — CLI argument parsing
119
188
 
120
189
  ## 🤝 Contributing
121
190
 
package/README.zh-CN.md CHANGED
@@ -15,14 +15,15 @@
15
15
 
16
16
  <p align="center">
17
17
  通过直观的终端界面从远程分支 cherry-pick 提交。<br>
18
- 选择特定提交、预览变更,并使用 <code>--no-commit</code> 模式在提交前安全审查。
18
+ 多选提交、预览 diff 统计、交互式处理冲突,支持备份分支和 stash 保护机制。
19
19
  </p>
20
20
 
21
21
  <p align="center">
22
22
  <a href="#-功能特性">功能</a> ·
23
23
  <a href="#-快速开始">快速开始</a> ·
24
24
  <a href="#-安装">安装</a> ·
25
- <a href="#-工作流程">工作流程</a>
25
+ <a href="#-工作流程">工作流程</a> ·
26
+ <a href="#%EF%B8%8F-命令行选项">命令行选项</a>
26
27
  </p>
27
28
 
28
29
  <p align="center">
@@ -35,11 +36,18 @@
35
36
 
36
37
  ## ✨ 功能特性
37
38
 
38
- - 🎯 **多选提交** — 使用 Space / Enter 选择不连续的提交进行 cherry-pick
39
+ - 🎯 **多选提交** — 使用 Space 选择不连续的提交,Shift+↑↓ 连选,`a` 全选,`i` 反选
39
40
  - 🔍 **分支搜索** — 按关键词模糊过滤分支
40
- - 👀 **差异预览** — 执行前查看所选提交的 `--stat` 摘要
41
- - ⚡ **安全模式** — `--no-commit` 仅暂存变更供审查,不会自动提交
42
- - ⚠️ **冲突处理** cherry-pick 失败时清晰显示冲突文件
41
+ - 👀 **差异预览** — 可滚动的 `--stat` 摘要面板,支持 `j`/`k` 上下滚动
42
+ - ⚡ **双模式** — `--no-commit` 仅暂存变更供审查,或逐个提交保留原始 commit 信息
43
+ - 🔀 **逐个 cherry-pick** — 按顺序执行提交,遇到冲突时暂停等待交互处理
44
+ - ⚠️ **冲突处理** — 显示冲突文件列表,在另一终端解决后继续/放弃/退出
45
+ - 🛡️ **安全备份** — 执行前自动创建备份分支,放弃时完整回滚
46
+ - 📦 **自动 stash** — 检测未提交变更,提示 stash 保存,同步后自动恢复
47
+ - 🔄 **Stash 恢复** — 检测上次中断的会话,提供恢复 stash 的选项
48
+ - 🌿 **分支检查** — 若当前不在目标分支,自动从 main/master 创建并切换
49
+ - ✅ **已同步标记** — 在 commit 列表中标记已同步的提交为 `[已同步]`
50
+ - 🖥️ **CLI 模式** — 支持 `-r -b -c` 参数的非交互模式,适用于脚本
43
51
  - 🌐 **通用性** — 适用于任何 Git 仓库,不限语言
44
52
 
45
53
  ## 🚀 快速开始
@@ -64,35 +72,95 @@ npm install -g git-sync-tui
64
72
  ## 🔄 工作流程
65
73
 
66
74
  ```
67
- 选择远程仓库 → 选择分支 → 多选提交预览变更
68
-
69
- 手动审查并提交 ← Cherry-pick --no-commit(已暂存,未提交)
75
+ 检查工作区 → 选择远程仓库 → 选择分支 → 分支检查多选提交
76
+
77
+ 自动 stash 预览 diff 统计
78
+ (如需要) ↓
79
+ 确认并选择模式
80
+
81
+ 逐个 cherry-pick(带备份)
82
+
83
+ 处理冲突 / 完成
84
+
85
+ 恢复 stash 并退出
70
86
  ```
71
87
 
72
88
  ## ⌨️ 快捷键
73
89
 
90
+ ### 提交选择
91
+
74
92
  | 按键 | 操作 |
75
93
  |-----|------|
76
94
  | `↑` `↓` | 上下导航 |
77
95
  | `Space` | 切换提交选择 |
96
+ | `Shift`+`↑`/`↓` | 连续选择 |
97
+ | `a` | 全选 / 取消全选 |
98
+ | `i` | 反选 |
99
+ | `r` | 从开头选至光标 |
100
+ | `j` / `k` | 滚动 diff stat 预览 |
78
101
  | `Enter` | 确认选择 |
79
- | `y` / `n` | 确认 / 取消执行 |
80
- | `/` | 搜索(在分支列表中) |
102
+ | `Esc` | 返回上一步 |
81
103
 
82
- ## 📋 同步后操作
104
+ ### 确认面板
105
+
106
+ | 按键 | 操作 |
107
+ |-----|------|
108
+ | `y` | 确认执行 |
109
+ | `n` | 取消 |
110
+ | `c` | 切换提交模式(--no-commit / 逐个提交) |
111
+ | `m` | 切换 `-m 1`(merge commit 时) |
112
+ | `Esc` | 返回 |
83
113
 
84
- 变更已暂存在工作区(未提交)。你可以:
114
+ ### 冲突处理
85
115
 
86
- ```bash
87
- # 查看暂存的变更
88
- git diff --cached
116
+ | 按键 | 操作 |
117
+ |-----|------|
118
+ | `c` | 继续(冲突已解决) |
119
+ | `a` | 放弃(回滚全部变更) |
120
+ | `q` | 退出(保留当前状态) |
89
121
 
90
- # 准备好后提交
91
- git commit -m "sync: 从 feature-branch cherry-pick 提交"
122
+ ## ⚙️ 命令行选项
92
123
 
93
- # 或放弃所有变更
94
- git reset HEAD
95
124
  ```
125
+ 用法
126
+ $ git-sync-tui [options]
127
+
128
+ 选项
129
+ -r, --remote <name> 指定远程仓库名称
130
+ -b, --branch <name> 指定远程分支名称
131
+ -c, --commits <hashes> 指定 commit hash(逗号分隔)
132
+ -n, --count <number> 显示 commit 数量(默认 100)
133
+ -m, --mainline 对 merge commit 使用 -m 1
134
+ -y, --yes 跳过确认直接执行
135
+ --no-stash 跳过 stash 提示
136
+ --list 列出远程分支的 commit 后退出
137
+
138
+ 模式
139
+ 无参数 交互式 TUI 模式
140
+ -r -b --list 列出 commit(纯文本)
141
+ -r -b -c CLI 模式,确认后执行
142
+ -r -b -c --yes CLI 模式,直接执行
143
+ 仅 -r 或 -r -b TUI 模式,跳过已指定步骤
144
+
145
+ 示例
146
+ $ git-sync-tui # TUI 模式
147
+ $ git-sync-tui -r upstream -b main --list # 列出 commits
148
+ $ git-sync-tui -r upstream -b main -c abc1234 --yes # 直接执行
149
+ $ git-sync-tui -r upstream -b main -c abc1234,def5678 # 确认后执行
150
+ $ git-sync-tui -r upstream # TUI 模式,跳过选择仓库
151
+ ```
152
+
153
+ ## 📋 同步后操作
154
+
155
+ **--no-commit 模式** — 变更已暂存在工作区(未提交):
156
+
157
+ ```bash
158
+ git diff --cached # 查看暂存的变更
159
+ git commit -m "sync: 从 feature-branch cherry-pick 提交" # 提交
160
+ git reset HEAD # 或放弃所有变更
161
+ ```
162
+
163
+ **逐个提交模式** — 保留原始 commit 信息,可通过 `git log` 查看。
96
164
 
97
165
  ## 💡 使用场景
98
166
 
@@ -107,8 +175,8 @@ git reset HEAD
107
175
  ```bash
108
176
  git clone https://github.com/KiWi233333/git-sync-tui.git
109
177
  cd git-sync-tui
110
- npm install
111
- npm start
178
+ pnpm install
179
+ pnpm start
112
180
  ```
113
181
 
114
182
  ## 🏗️ 技术栈
@@ -116,6 +184,7 @@ npm start
116
184
  - [Ink](https://github.com/vadimdemedes/ink) — 用于构建交互式 CLI 应用的 React 框架
117
185
  - [@inkjs/ui](https://github.com/inkjs/ui) — Ink 的 UI 组件库
118
186
  - [simple-git](https://github.com/steveukx/git-js) — Git 命令接口
187
+ - [meow](https://github.com/sindresorhus/meow) — CLI 参数解析
119
188
 
120
189
  ## 🤝 贡献
121
190
 
package/dist/cli.js CHANGED
@@ -1,18 +1,12 @@
1
1
  #!/usr/bin/env node
2
- var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
3
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
4
- }) : x)(function(x) {
5
- if (typeof require !== "undefined") return require.apply(this, arguments);
6
- throw Error('Dynamic require of "' + x + '" is not supported');
7
- });
8
2
 
9
3
  // src/cli.tsx
10
4
  import { render } from "ink";
11
5
  import meow from "meow";
12
6
 
13
7
  // src/app.tsx
14
- import { useState as useState8, useEffect as useEffect6, useRef as useRef5, useCallback as useCallback3 } from "react";
15
- import { Box as Box10, useApp } from "ink";
8
+ import { useState as useState9, useEffect as useEffect7, useRef as useRef5, useCallback as useCallback4 } from "react";
9
+ import { Box as Box11, useApp } from "ink";
16
10
  import { Spinner as Spinner7 } from "@inkjs/ui";
17
11
 
18
12
  // src/components/ui.tsx
@@ -140,11 +134,13 @@ import { Spinner } from "@inkjs/ui";
140
134
  // src/utils/git.ts
141
135
  import simpleGit from "simple-git";
142
136
  import { existsSync, writeFileSync, unlinkSync, readFileSync } from "fs";
137
+ import { execSync } from "child_process";
143
138
  import { join } from "path";
144
139
  var gitInstance = null;
145
140
  function getGit(cwd) {
146
- if (!gitInstance || cwd) {
147
- gitInstance = simpleGit(cwd);
141
+ if (cwd) return simpleGit(cwd);
142
+ if (!gitInstance) {
143
+ gitInstance = simpleGit();
148
144
  }
149
145
  return gitInstance;
150
146
  }
@@ -227,13 +223,14 @@ async function getMultiCommitStat(hashes) {
227
223
  if (hashes.length === 0) return "";
228
224
  const git = getGit();
229
225
  try {
230
- const stats = [];
231
- for (const hash of hashes) {
232
- const result = await git.raw(["diff-tree", "--stat", "--no-commit-id", "-r", hash]);
233
- if (result.trim()) stats.push(`${hash.substring(0, 7)}:
234
- ${result.trim()}`);
235
- }
236
- return stats.join("\n\n");
226
+ const results = await Promise.all(
227
+ hashes.map(async (hash) => {
228
+ const result = await git.raw(["diff-tree", "--stat", "--no-commit-id", "-r", hash]);
229
+ return result.trim() ? `${hash.substring(0, 7)}:
230
+ ${result.trim()}` : "";
231
+ })
232
+ );
233
+ return results.filter(Boolean).join("\n\n");
237
234
  } catch {
238
235
  return "(\u65E0\u6CD5\u83B7\u53D6 stat \u4FE1\u606F)";
239
236
  }
@@ -454,8 +451,7 @@ async function removeStashGuard() {
454
451
  }
455
452
  function removeStashGuardSync() {
456
453
  try {
457
- const { execSync: execSync2 } = __require("child_process");
458
- const gitDir = String(execSync2("git rev-parse --git-dir", { encoding: "utf-8" })).trim();
454
+ const gitDir = String(execSync("git rev-parse --git-dir", { encoding: "utf-8" })).trim();
459
455
  const guardPath = join(gitDir, STASH_GUARD_FILE);
460
456
  if (existsSync(guardPath)) {
461
457
  unlinkSync(guardPath);
@@ -542,10 +538,12 @@ function useAsync(fn, deps = []) {
542
538
  loading: true,
543
539
  error: null
544
540
  });
541
+ const fnRef = useRef(fn);
542
+ fnRef.current = fn;
545
543
  const load = useCallback(async () => {
546
544
  setState({ data: null, loading: true, error: null });
547
545
  try {
548
- const data = await fn();
546
+ const data = await fnRef.current();
549
547
  setState({ data, loading: false, error: null });
550
548
  } catch (err) {
551
549
  setState({ data: null, loading: false, error: err.message });
@@ -612,20 +610,23 @@ function useCommits(remote2, branch2, pageSize = 100) {
612
610
  function useCommitStat(hashes) {
613
611
  const [stat, setStat] = useState2("");
614
612
  const [loading, setLoading] = useState2(false);
613
+ const hashKey = hashes.join(",");
614
+ const stableHashes = useRef(hashes);
615
+ stableHashes.current = hashes;
615
616
  useEffect2(() => {
616
- if (hashes.length === 0) {
617
+ if (stableHashes.current.length === 0) {
617
618
  setStat("");
618
619
  return;
619
620
  }
620
621
  setLoading(true);
621
- getMultiCommitStat(hashes).then((s) => {
622
+ getMultiCommitStat(stableHashes.current).then((s) => {
622
623
  setStat(s);
623
624
  setLoading(false);
624
625
  }).catch(() => {
625
626
  setStat("(\u83B7\u53D6\u5931\u8D25)");
626
627
  setLoading(false);
627
628
  });
628
- }, [hashes.join(",")]);
629
+ }, [hashKey]);
629
630
  return { stat, loading };
630
631
  }
631
632
 
@@ -1180,6 +1181,8 @@ function BranchCheck({ targetBranch, onContinue, onBack }) {
1180
1181
  const [error2, setError] = useState6(null);
1181
1182
  const [matched, setMatched] = useState6(false);
1182
1183
  const autoCreated = useRef3(false);
1184
+ const onContinueRef = useRef3(onContinue);
1185
+ onContinueRef.current = onContinue;
1183
1186
  useEffect4(() => {
1184
1187
  getCurrentBranch().then((branch2) => {
1185
1188
  setCurrentBranch(branch2);
@@ -1189,7 +1192,7 @@ function BranchCheck({ targetBranch, onContinue, onBack }) {
1189
1192
  });
1190
1193
  }, [targetBranch]);
1191
1194
  useEffect4(() => {
1192
- if (matched) onContinue();
1195
+ if (matched) onContinueRef.current();
1193
1196
  }, [matched]);
1194
1197
  useEffect4(() => {
1195
1198
  if (currentBranch === null || matched || autoCreated.current) return;
@@ -1197,7 +1200,7 @@ function BranchCheck({ targetBranch, onContinue, onBack }) {
1197
1200
  autoCreated.current = true;
1198
1201
  setCreating(true);
1199
1202
  createBranchFrom(targetBranch, currentBranch).then(() => {
1200
- onContinue();
1203
+ onContinueRef.current();
1201
1204
  }).catch((err) => {
1202
1205
  setCreating(false);
1203
1206
  setError(err.message);
@@ -1333,7 +1336,7 @@ function ConfirmPanel({ commits: commits2, selectedHashes, hasMerge, useMainline
1333
1336
  }
1334
1337
 
1335
1338
  // src/components/result-panel.tsx
1336
- import { useState as useState7, useEffect as useEffect5, useCallback as useCallback2, useRef as useRef4 } from "react";
1339
+ import { useState as useState7, useEffect as useEffect5, useCallback as useCallback3, useRef as useRef4 } from "react";
1337
1340
  import { Box as Box9, Text as Text9, useInput as useInput8 } from "ink";
1338
1341
  import { Spinner as Spinner6 } from "@inkjs/ui";
1339
1342
  import { jsx as jsx9, jsxs as jsxs9 } from "react/jsx-runtime";
@@ -1349,7 +1352,7 @@ function ResultPanel({ selectedHashes, useMainline, noCommit, stashed, onStashRe
1349
1352
  const remainingRef = useRef4([]);
1350
1353
  const backupBranchRef = useRef4("");
1351
1354
  const orderedHashes = useRef4([...selectedHashes].reverse());
1352
- const tryRestoreStash = useCallback2(async () => {
1355
+ const tryRestoreStash = useCallback3(async () => {
1353
1356
  if (!stashed) return true;
1354
1357
  setPhase("restoring");
1355
1358
  const ok = await stashPop();
@@ -1357,7 +1360,7 @@ function ResultPanel({ selectedHashes, useMainline, noCommit, stashed, onStashRe
1357
1360
  if (ok) onStashRestored();
1358
1361
  return ok;
1359
1362
  }, [stashed, onStashRestored]);
1360
- const finishAll = useCallback2(async () => {
1363
+ const finishAll = useCallback3(async () => {
1361
1364
  if (noCommit) {
1362
1365
  const stat = await getStagedStat();
1363
1366
  setStagedStat(stat);
@@ -1368,7 +1371,7 @@ function ResultPanel({ selectedHashes, useMainline, noCommit, stashed, onStashRe
1368
1371
  await tryRestoreStash();
1369
1372
  setPhase("done");
1370
1373
  }, [noCommit, tryRestoreStash]);
1371
- const executeFrom = useCallback2(async (startIndex) => {
1374
+ const executeFrom = useCallback3(async (startIndex) => {
1372
1375
  const hashes = orderedHashes.current;
1373
1376
  for (let i = startIndex; i < hashes.length; i++) {
1374
1377
  setCurrentIndex(i);
@@ -1389,7 +1392,7 @@ function ResultPanel({ selectedHashes, useMainline, noCommit, stashed, onStashRe
1389
1392
  executeFrom(0);
1390
1393
  });
1391
1394
  }, []);
1392
- const continueRemaining = useCallback2(async () => {
1395
+ const continueRemaining = useCallback3(async () => {
1393
1396
  const remaining = remainingRef.current;
1394
1397
  if (remaining.length === 0) {
1395
1398
  await finishAll();
@@ -1409,7 +1412,7 @@ function ResultPanel({ selectedHashes, useMainline, noCommit, stashed, onStashRe
1409
1412
  }
1410
1413
  await finishAll();
1411
1414
  }, [useMainline, noCommit, finishAll]);
1412
- const handleContinue = useCallback2(async () => {
1415
+ const handleContinue = useCallback3(async () => {
1413
1416
  const conflicts = await getConflictFiles();
1414
1417
  if (conflicts.length > 0) {
1415
1418
  setConflictFiles(conflicts);
@@ -1442,14 +1445,14 @@ function ResultPanel({ selectedHashes, useMainline, noCommit, stashed, onStashRe
1442
1445
  await continueRemaining();
1443
1446
  }
1444
1447
  }, [noCommit, continueRemaining]);
1445
- const handleSkip = useCallback2(async () => {
1448
+ const handleSkip = useCallback3(async () => {
1446
1449
  setPhase("continuing");
1447
1450
  setErrorMsg("");
1448
1451
  await skipCherryPick();
1449
1452
  setSkippedCount((c) => c + 1);
1450
1453
  await continueRemaining();
1451
1454
  }, [continueRemaining]);
1452
- const handleAbort = useCallback2(async () => {
1455
+ const handleAbort = useCallback3(async () => {
1453
1456
  setPhase("aborting");
1454
1457
  await abortCherryPick();
1455
1458
  if (backupBranchRef.current) {
@@ -1463,6 +1466,10 @@ function ResultPanel({ selectedHashes, useMainline, noCommit, stashed, onStashRe
1463
1466
  setPhase("aborted");
1464
1467
  }, [tryRestoreStash]);
1465
1468
  useInput8((input, key) => {
1469
+ if (phase === "done" || phase === "aborted") {
1470
+ onDone();
1471
+ return;
1472
+ }
1466
1473
  if (phase === "conflict") {
1467
1474
  if (input === "c" || input === "C") {
1468
1475
  handleContinue();
@@ -1571,7 +1578,8 @@ function ResultPanel({ selectedHashes, useMainline, noCommit, stashed, onStashRe
1571
1578
  stashed && stashRestored === true && /* @__PURE__ */ jsxs9(Text9, { color: "green", children: [
1572
1579
  "\u2714 ",
1573
1580
  "\u5DF2\u6062\u590D\u5DE5\u4F5C\u533A\u53D8\u66F4 (stash pop)"
1574
- ] })
1581
+ ] }),
1582
+ /* @__PURE__ */ jsx9(Text9, { color: "gray", dimColor: true, children: "\u6309\u4EFB\u610F\u952E\u9000\u51FA" })
1575
1583
  ] });
1576
1584
  }
1577
1585
  const total = orderedHashes.current.length;
@@ -1608,13 +1616,137 @@ function ResultPanel({ selectedHashes, useMainline, noCommit, stashed, onStashRe
1608
1616
  " git reset HEAD ",
1609
1617
  /* @__PURE__ */ jsx9(Text9, { color: "gray", dimColor: true, children: "# \u6216\u653E\u5F03" })
1610
1618
  ] })
1611
- ] }) : /* @__PURE__ */ jsx9(Box9, { flexDirection: "column", children: /* @__PURE__ */ jsx9(Text9, { color: "gray", dimColor: true, children: " \u5DF2\u4FDD\u7559\u539F\u59CB commit \u4FE1\u606F\uFF0C\u53EF\u901A\u8FC7 git log \u67E5\u770B" }) })
1619
+ ] }) : /* @__PURE__ */ jsx9(Box9, { flexDirection: "column", children: /* @__PURE__ */ jsx9(Text9, { color: "gray", dimColor: true, children: " \u5DF2\u4FDD\u7559\u539F\u59CB commit \u4FE1\u606F\uFF0C\u53EF\u901A\u8FC7 git log \u67E5\u770B" }) }),
1620
+ /* @__PURE__ */ jsx9(Text9, { color: "gray", dimColor: true, children: "\u6309\u4EFB\u610F\u952E\u9000\u51FA" })
1612
1621
  ] });
1613
1622
  }
1614
1623
 
1615
- // src/app.tsx
1616
- import { execSync } from "child_process";
1624
+ // src/components/update-banner.tsx
1625
+ import { useState as useState8, useEffect as useEffect6 } from "react";
1626
+ import { Box as Box10, Text as Text10 } from "ink";
1627
+
1628
+ // src/utils/update-check.ts
1629
+ import https from "https";
1630
+ import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, mkdirSync } from "fs";
1631
+ import { join as join2 } from "path";
1632
+ import { homedir } from "os";
1633
+ var PKG_NAME = "git-sync-tui";
1634
+ var CHECK_INTERVAL = 24 * 60 * 60 * 1e3;
1635
+ var REQUEST_TIMEOUT = 3e3;
1636
+ function getCachePath() {
1637
+ const dir = join2(homedir(), ".config", "git-sync-tui");
1638
+ return join2(dir, "update-check.json");
1639
+ }
1640
+ function readCache() {
1641
+ try {
1642
+ const raw = readFileSync2(getCachePath(), "utf-8");
1643
+ return JSON.parse(raw);
1644
+ } catch {
1645
+ return null;
1646
+ }
1647
+ }
1648
+ function writeCache(data) {
1649
+ try {
1650
+ const dir = join2(homedir(), ".config", "git-sync-tui");
1651
+ mkdirSync(dir, { recursive: true });
1652
+ writeFileSync2(getCachePath(), JSON.stringify(data));
1653
+ } catch {
1654
+ }
1655
+ }
1656
+ function fetchLatestVersion() {
1657
+ return new Promise((resolve) => {
1658
+ const req = https.get(
1659
+ `https://registry.npmjs.org/${PKG_NAME}/latest`,
1660
+ { timeout: REQUEST_TIMEOUT, headers: { Accept: "application/json" } },
1661
+ (res) => {
1662
+ if (res.statusCode !== 200) {
1663
+ resolve(null);
1664
+ return;
1665
+ }
1666
+ let data = "";
1667
+ res.on("data", (chunk) => {
1668
+ data += chunk.toString();
1669
+ });
1670
+ res.on("end", () => {
1671
+ try {
1672
+ const json = JSON.parse(data);
1673
+ resolve(json.version || null);
1674
+ } catch {
1675
+ resolve(null);
1676
+ }
1677
+ });
1678
+ }
1679
+ );
1680
+ req.on("error", () => resolve(null));
1681
+ req.on("timeout", () => {
1682
+ req.destroy();
1683
+ resolve(null);
1684
+ });
1685
+ });
1686
+ }
1687
+ function compareVersions(current, latest) {
1688
+ const parse = (v) => v.replace(/^v/, "").split(".").map(Number);
1689
+ const c = parse(current);
1690
+ const l = parse(latest);
1691
+ for (let i = 0; i < 3; i++) {
1692
+ if ((l[i] || 0) > (c[i] || 0)) return true;
1693
+ if ((l[i] || 0) < (c[i] || 0)) return false;
1694
+ }
1695
+ return false;
1696
+ }
1697
+ async function checkForUpdate(currentVersion) {
1698
+ const noUpdate = { hasUpdate: false, current: currentVersion, latest: currentVersion };
1699
+ const cache = readCache();
1700
+ if (cache && Date.now() - cache.checkedAt < CHECK_INTERVAL) {
1701
+ return {
1702
+ hasUpdate: compareVersions(currentVersion, cache.latest),
1703
+ current: currentVersion,
1704
+ latest: cache.latest
1705
+ };
1706
+ }
1707
+ const latest = await fetchLatestVersion();
1708
+ if (!latest) return noUpdate;
1709
+ writeCache({ latest, checkedAt: Date.now() });
1710
+ return {
1711
+ hasUpdate: compareVersions(currentVersion, latest),
1712
+ current: currentVersion,
1713
+ latest
1714
+ };
1715
+ }
1716
+
1717
+ // src/components/update-banner.tsx
1617
1718
  import { jsx as jsx10, jsxs as jsxs10 } from "react/jsx-runtime";
1719
+ function UpdateBanner({ currentVersion }) {
1720
+ const [info, setInfo] = useState8(null);
1721
+ useEffect6(() => {
1722
+ let cancelled = false;
1723
+ checkForUpdate(currentVersion).then((result) => {
1724
+ if (!cancelled && result.hasUpdate) {
1725
+ setInfo(result);
1726
+ }
1727
+ });
1728
+ return () => {
1729
+ cancelled = true;
1730
+ };
1731
+ }, [currentVersion]);
1732
+ if (!info) return null;
1733
+ return /* @__PURE__ */ jsx10(Box10, { marginTop: 1, children: /* @__PURE__ */ jsxs10(Text10, { color: "yellow", children: [
1734
+ "\u{1F4A1} ",
1735
+ "\u65B0\u7248\u672C\u53EF\u7528 ",
1736
+ /* @__PURE__ */ jsx10(Text10, { bold: true, color: "green", children: info.latest }),
1737
+ /* @__PURE__ */ jsxs10(Text10, { color: "gray", children: [
1738
+ " (\u5F53\u524D ",
1739
+ info.current,
1740
+ ")"
1741
+ ] }),
1742
+ /* @__PURE__ */ jsx10(Text10, { color: "cyan", children: " \u2192 npm i -g git-sync-tui" })
1743
+ ] }) });
1744
+ }
1745
+
1746
+ // src/app.tsx
1747
+ import { execSync as execSync2 } from "child_process";
1748
+ import { jsx as jsx11, jsxs as jsxs11 } from "react/jsx-runtime";
1749
+ var APP_VERSION = "0.1.8";
1618
1750
  var STEP_NUMBER = {
1619
1751
  checking: 0,
1620
1752
  "stash-recovery": 0,
@@ -1630,22 +1762,22 @@ var STEP_DEBOUNCE = 100;
1630
1762
  function App({ initialRemote, initialBranch }) {
1631
1763
  const { exit } = useApp();
1632
1764
  const entryStep = initialRemote && initialBranch ? "branch-check" : initialRemote ? "branch" : "remote";
1633
- const [step, setStepRaw] = useState8("checking");
1634
- const [inputReady, setInputReady] = useState8(true);
1635
- const [remote2, setRemote] = useState8(initialRemote || "");
1636
- const [branch2, setBranch] = useState8(initialBranch || "");
1637
- const [selectedHashes, setSelectedHashes] = useState8([]);
1638
- const [commits2, setCommits] = useState8([]);
1639
- const [hasMerge, setHasMerge] = useState8(false);
1640
- const [useMainline, setUseMainline] = useState8(false);
1641
- const [noCommit, setNoCommit] = useState8(false);
1642
- const [stashed, setStashed] = useState8(false);
1643
- const [guardTimestamp, setGuardTimestamp] = useState8();
1765
+ const [step, setStepRaw] = useState9("checking");
1766
+ const [inputReady, setInputReady] = useState9(true);
1767
+ const [remote2, setRemote] = useState9(initialRemote || "");
1768
+ const [branch2, setBranch] = useState9(initialBranch || "");
1769
+ const [selectedHashes, setSelectedHashes] = useState9([]);
1770
+ const [commits2, setCommits] = useState9([]);
1771
+ const [hasMerge, setHasMerge] = useState9(false);
1772
+ const [useMainline, setUseMainline] = useState9(false);
1773
+ const [noCommit, setNoCommit] = useState9(false);
1774
+ const [stashed, setStashed] = useState9(false);
1775
+ const [guardTimestamp, setGuardTimestamp] = useState9();
1644
1776
  const stashedRef = useRef5(false);
1645
1777
  const stashRestoredRef = useRef5(false);
1646
1778
  const mountedRef = useRef5(true);
1647
1779
  const debounceTimer = useRef5(null);
1648
- const setStep = useCallback3((newStep) => {
1780
+ const setStep = useCallback4((newStep) => {
1649
1781
  setInputReady(false);
1650
1782
  setStepRaw(newStep);
1651
1783
  if (debounceTimer.current) clearTimeout(debounceTimer.current);
@@ -1653,10 +1785,10 @@ function App({ initialRemote, initialBranch }) {
1653
1785
  if (mountedRef.current) setInputReady(true);
1654
1786
  }, STEP_DEBOUNCE);
1655
1787
  }, []);
1656
- const restoreStashSync = useCallback3(() => {
1788
+ const restoreStashSync = useCallback4(() => {
1657
1789
  if (stashedRef.current && !stashRestoredRef.current) {
1658
1790
  try {
1659
- execSync("git stash pop", { stdio: "ignore" });
1791
+ execSync2("git stash pop", { stdio: "ignore" });
1660
1792
  stashRestoredRef.current = true;
1661
1793
  removeStashGuardSync();
1662
1794
  } catch {
@@ -1667,11 +1799,11 @@ function App({ initialRemote, initialBranch }) {
1667
1799
  }
1668
1800
  }
1669
1801
  }, []);
1670
- const markStashRestored = useCallback3(() => {
1802
+ const markStashRestored = useCallback4(() => {
1671
1803
  stashRestoredRef.current = true;
1672
1804
  removeStashGuard();
1673
1805
  }, []);
1674
- useEffect6(() => {
1806
+ useEffect7(() => {
1675
1807
  mountedRef.current = true;
1676
1808
  async function check() {
1677
1809
  const guard = await checkStashGuard();
@@ -1726,7 +1858,7 @@ function App({ initialRemote, initialBranch }) {
1726
1858
  const clean = await isWorkingDirClean();
1727
1859
  if (mountedRef.current) setStep(clean ? entryStep : "stash-prompt");
1728
1860
  };
1729
- const goBack = useCallback3((fromStep) => {
1861
+ const goBack = useCallback4((fromStep) => {
1730
1862
  const backMap = {
1731
1863
  branch: "remote",
1732
1864
  "branch-check": "branch",
@@ -1741,10 +1873,10 @@ function App({ initialRemote, initialBranch }) {
1741
1873
  exit();
1742
1874
  }
1743
1875
  }, [setStep, restoreStashSync, exit]);
1744
- return /* @__PURE__ */ jsxs10(Box10, { flexDirection: "column", children: [
1745
- /* @__PURE__ */ jsx10(AppHeader, { step: STEP_NUMBER[step], stashed, noCommit }),
1746
- step === "checking" && /* @__PURE__ */ jsx10(Spinner7, { label: "\u68C0\u67E5\u5DE5\u4F5C\u533A\u72B6\u6001..." }),
1747
- step === "stash-recovery" && inputReady && /* @__PURE__ */ jsx10(
1876
+ return /* @__PURE__ */ jsxs11(Box11, { flexDirection: "column", children: [
1877
+ /* @__PURE__ */ jsx11(AppHeader, { step: STEP_NUMBER[step], stashed, noCommit }),
1878
+ step === "checking" && /* @__PURE__ */ jsx11(Spinner7, { label: "\u68C0\u67E5\u5DE5\u4F5C\u533A\u72B6\u6001..." }),
1879
+ step === "stash-recovery" && inputReady && /* @__PURE__ */ jsx11(
1748
1880
  StashRecovery,
1749
1881
  {
1750
1882
  timestamp: guardTimestamp,
@@ -1752,14 +1884,14 @@ function App({ initialRemote, initialBranch }) {
1752
1884
  onSkip: skipStashRecover
1753
1885
  }
1754
1886
  ),
1755
- step === "stash-prompt" && inputReady && /* @__PURE__ */ jsx10(
1887
+ step === "stash-prompt" && inputReady && /* @__PURE__ */ jsx11(
1756
1888
  StashPrompt,
1757
1889
  {
1758
1890
  onConfirm: doStash,
1759
1891
  onSkip: () => setStep(entryStep)
1760
1892
  }
1761
1893
  ),
1762
- step === "remote" && inputReady && /* @__PURE__ */ jsx10(
1894
+ step === "remote" && inputReady && /* @__PURE__ */ jsx11(
1763
1895
  RemoteSelect,
1764
1896
  {
1765
1897
  onSelect: (r) => {
@@ -1769,7 +1901,7 @@ function App({ initialRemote, initialBranch }) {
1769
1901
  onBack: () => goBack("remote")
1770
1902
  }
1771
1903
  ),
1772
- step === "branch" && inputReady && /* @__PURE__ */ jsx10(
1904
+ step === "branch" && inputReady && /* @__PURE__ */ jsx11(
1773
1905
  BranchSelect,
1774
1906
  {
1775
1907
  remote: remote2,
@@ -1780,7 +1912,7 @@ function App({ initialRemote, initialBranch }) {
1780
1912
  onBack: () => goBack("branch")
1781
1913
  }
1782
1914
  ),
1783
- step === "branch-check" && inputReady && /* @__PURE__ */ jsx10(
1915
+ step === "branch-check" && inputReady && /* @__PURE__ */ jsx11(
1784
1916
  BranchCheck,
1785
1917
  {
1786
1918
  targetBranch: branch2,
@@ -1788,20 +1920,22 @@ function App({ initialRemote, initialBranch }) {
1788
1920
  onBack: () => goBack("branch-check")
1789
1921
  }
1790
1922
  ),
1791
- step === "commits" && inputReady && /* @__PURE__ */ jsx10(
1923
+ step === "commits" && inputReady && /* @__PURE__ */ jsx11(
1792
1924
  CommitList,
1793
1925
  {
1794
1926
  remote: remote2,
1795
1927
  branch: branch2,
1796
- onSelect: (hashes, loadedCommits) => {
1928
+ onSelect: async (hashes, loadedCommits) => {
1797
1929
  setSelectedHashes(hashes);
1798
1930
  setCommits(loadedCommits);
1931
+ const merge = await hasMergeCommits(hashes);
1932
+ setHasMerge(merge);
1799
1933
  setStep("confirm");
1800
1934
  },
1801
1935
  onBack: () => goBack("commits")
1802
1936
  }
1803
1937
  ),
1804
- step === "confirm" && inputReady && /* @__PURE__ */ jsx10(
1938
+ step === "confirm" && inputReady && /* @__PURE__ */ jsx11(
1805
1939
  ConfirmPanel,
1806
1940
  {
1807
1941
  commits: commits2,
@@ -1815,7 +1949,7 @@ function App({ initialRemote, initialBranch }) {
1815
1949
  onCancel: () => goBack("confirm")
1816
1950
  }
1817
1951
  ),
1818
- step === "result" && /* @__PURE__ */ jsx10(
1952
+ step === "result" && /* @__PURE__ */ jsx11(
1819
1953
  ResultPanel,
1820
1954
  {
1821
1955
  selectedHashes,
@@ -1828,21 +1962,20 @@ function App({ initialRemote, initialBranch }) {
1828
1962
  exit();
1829
1963
  }
1830
1964
  }
1831
- )
1965
+ ),
1966
+ /* @__PURE__ */ jsx11(UpdateBanner, { currentVersion: APP_VERSION })
1832
1967
  ] });
1833
1968
  }
1834
1969
 
1835
1970
  // src/cli-runner.ts
1836
1971
  import { createInterface } from "readline";
1972
+ var APP_VERSION2 = "0.1.8";
1837
1973
  function log(msg) {
1838
1974
  process.stdout.write(msg + "\n");
1839
1975
  }
1840
1976
  function error(msg) {
1841
1977
  process.stderr.write(msg + "\n");
1842
1978
  }
1843
- function padEnd(str, len) {
1844
- return str.length >= len ? str : str + " ".repeat(len - str.length);
1845
- }
1846
1979
  async function confirm(message) {
1847
1980
  const rl = createInterface({ input: process.stdin, output: process.stdout });
1848
1981
  return new Promise((resolve) => {
@@ -1904,7 +2037,7 @@ async function validateBranch(remote2, branch2) {
1904
2037
  log(`\u2714 \u5206\u652F '${remote2}/${branch2}'`);
1905
2038
  }
1906
2039
  function formatCommitLine(c) {
1907
- return ` ${c.shortHash} ${padEnd(c.message.slice(0, 60), 62)} ${padEnd(c.author, 16)} ${c.date}`;
2040
+ return ` ${c.shortHash} ${c.message.slice(0, 60).padEnd(62)} ${c.author.padEnd(16)} ${c.date}`;
1908
2041
  }
1909
2042
  async function runList(opts) {
1910
2043
  await validateRemote(opts.remote);
@@ -1988,16 +2121,24 @@ ${stat}`);
1988
2121
  process.exit(1);
1989
2122
  }
1990
2123
  }
2124
+ async function printUpdateNotice() {
2125
+ const info = await checkForUpdate(APP_VERSION2);
2126
+ if (info.hasUpdate) {
2127
+ log(`
2128
+ \u{1F4A1} \u65B0\u7248\u672C\u53EF\u7528 ${info.latest} (\u5F53\u524D ${info.current}) \u2192 npm i -g git-sync-tui`);
2129
+ }
2130
+ }
1991
2131
  async function runCli(opts) {
1992
2132
  if (opts.list) {
1993
2133
  await runList(opts);
1994
2134
  } else {
1995
2135
  await runExec(opts);
1996
2136
  }
2137
+ await printUpdateNotice();
1997
2138
  }
1998
2139
 
1999
2140
  // src/cli.tsx
2000
- import { jsx as jsx11 } from "react/jsx-runtime";
2141
+ import { jsx as jsx12 } from "react/jsx-runtime";
2001
2142
  var cli = meow(
2002
2143
  `
2003
2144
  \u7528\u6CD5
@@ -2071,5 +2212,5 @@ if (isCliMode) {
2071
2212
  process.exit(1);
2072
2213
  });
2073
2214
  } else {
2074
- render(/* @__PURE__ */ jsx11(App, { initialRemote: remote, initialBranch: branch }));
2215
+ render(/* @__PURE__ */ jsx12(App, { initialRemote: remote, initialBranch: branch }));
2075
2216
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "git-sync-tui",
3
3
  "type": "module",
4
- "version": "0.1.6",
4
+ "version": "0.1.8",
5
5
  "packageManager": "pnpm@10.32.1",
6
6
  "description": "Interactive TUI tool for cross-repo git commit synchronization (cherry-pick --no-commit)",
7
7
  "author": "KiWi233333",