@youngwat/git-file-manager 0.0.0-stage → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +441 -2
- package/package.json +38 -3
- package/src/backup.js +78 -0
- package/src/cli.js +183 -0
- package/src/commands/rescue.js +403 -0
- package/src/commands/sync.js +295 -0
- package/src/commands/undo.js +66 -0
- package/src/commitSpec.js +122 -0
- package/src/git.js +194 -0
- package/src/log.js +79 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 youngwat
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,442 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @youngwat/git-file-manager
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Git 救火工具。解决两件在真实开发里高频、但用原生 git 命令做起来很别扭的事:
|
|
4
|
+
|
|
5
|
+
1. **`rescue`** —— 代码提交错分支了:把指定的 commit(连续 / 不连续都行)剪切到功能分支,
|
|
6
|
+
同时让**错误分支的历史彻底还原**——那些 commit 连同改动一起消失,`git log` 干净,不留 revert 记录。
|
|
7
|
+
2. **`sync`** —— 把指定 commit(连续 / 不连续)里的文件改动,**按原目录结构**同步到另一个目录。
|
|
8
|
+
|
|
9
|
+
零运行时依赖,全部基于 `child_process` 调用本机 git,Node.js >= 18.20.4。
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 1. 安装
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm i -g @youngwat/git-file-manager
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
安装后有三种等价写法(`git-file-manager` 这个 bin 名让 git 能直接转发子命令):
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
git file-manager rescue ...
|
|
23
|
+
git-file-manager rescue ...
|
|
24
|
+
gfm rescue ...
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
临时用一次也可以:`npx @youngwat/git-file-manager rescue ...`
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 2. 场景一:提交错分支,剪切到功能分支
|
|
32
|
+
|
|
33
|
+
### 2.1 典型情形
|
|
34
|
+
|
|
35
|
+
你在 `develop` 上顺手写了功能代码,连提了 3 个 commit,其中第 1、3 个属于 `feature/login`,
|
|
36
|
+
第 2 个是无关的依赖升级。现在想:
|
|
37
|
+
|
|
38
|
+
- 第 1、3 个 commit **连同改动**搬到 `feature/login`;
|
|
39
|
+
- `develop` 上**看不到这两个 commit**,也不要有 revert 提交;
|
|
40
|
+
- 第 2 个 commit 原样留在 `develop`。
|
|
41
|
+
|
|
42
|
+
### 2.2 一条命令
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
# commit id 直接跟在命令后面(可以有多个,也可以混区间)
|
|
46
|
+
git file-manager rescue --from develop --to feature/login a1b2c3d 9f8e7d6 --dry-run
|
|
47
|
+
|
|
48
|
+
# 确认无误再真正执行
|
|
49
|
+
git file-manager rescue --from develop --to feature/login a1b2c3d 9f8e7d6
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
输出示例:
|
|
53
|
+
|
|
54
|
+
```text
|
|
55
|
+
>> rescue 计划
|
|
56
|
+
源分支 : develop (a1b2c3d)
|
|
57
|
+
目标分支 : feature/login (9f8e7d6)
|
|
58
|
+
基点 : 5c4b3a2
|
|
59
|
+
方式 : 迁移并保留原 commit
|
|
60
|
+
|
|
61
|
+
迁移到 feature/login 的 commit(旧 -> 新,共 2 个)
|
|
62
|
+
+ a1b2c3d feat: 登录页 UI
|
|
63
|
+
+ 9f8e7d6 fix: 表单校验
|
|
64
|
+
|
|
65
|
+
将从 develop 的历史中移除(共 2 个)
|
|
66
|
+
- a1b2c3d feat: 登录页 UI
|
|
67
|
+
- 9f8e7d6 fix: 表单校验
|
|
68
|
+
|
|
69
|
+
保留并重放(共 1 个)
|
|
70
|
+
= b2c3d4e chore: 依赖升级
|
|
71
|
+
|
|
72
|
+
! develop 已经推送到远端(origin/develop),本次历史重写后必须 force-push:
|
|
73
|
+
git push --force-with-lease origin develop
|
|
74
|
+
协作方需要 git fetch && git reset --hard origin/develop
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### 2.3 怎么指定 commit
|
|
78
|
+
|
|
79
|
+
commit id 直接跟在命令后面就行,也可以写 `--commits`,两者等价且可以混用:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
git file-manager rescue --from develop --to feature/login a1b2c3d 9f8e7d6
|
|
83
|
+
git file-manager rescue --from develop --to feature/login --commits "a1b2c3d,9f8e7d6"
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
> **PowerShell 用户注意**:`--commits a1b2c3d,9f8e7d6` 里的逗号会被 PowerShell 当成数组分隔符,命令被拆成两个参数。
|
|
87
|
+
> 本工具会自动把它们合并回来,但最省心的写法是**直接跟在命令后面**,或者用引号包起来。
|
|
88
|
+
|
|
89
|
+
支持的表达式:
|
|
90
|
+
|
|
91
|
+
| 写法 | 含义 |
|
|
92
|
+
| --- | --- |
|
|
93
|
+
| `A..B` | 从 A 到 B,**含两端** |
|
|
94
|
+
| `A..B` + `--exclusive` | 从 A 到 B,**不含 A**(等价 git 原生语义) |
|
|
95
|
+
| `A,B,C` | 逐个列举,可任意混用 |
|
|
96
|
+
| `A..C,E` | 混合:A~C 区间加 E 这个单独提交 |
|
|
97
|
+
| `--last 3` | 取 `--from` 分支顶端最近 3 个(替代 `--commits`) |
|
|
98
|
+
| `HEAD~3` | 任何 git 能解析的 revision 都可以 |
|
|
99
|
+
|
|
100
|
+
解析结果会在计划里**逐个列出**(sha + subject),执行前一定看得到。
|
|
101
|
+
`--commits` 与 `--last` 二选一。
|
|
102
|
+
|
|
103
|
+
### 2.4 它到底做了什么
|
|
104
|
+
|
|
105
|
+
```text
|
|
106
|
+
① 把选中的 commit 按旧 -> 新顺序 cherry-pick 到 --to 分支(内容先落地,不会丢);
|
|
107
|
+
② 源分支:detach 到基点 base,把「没被选中的 commit」按原顺序重放一遍,
|
|
108
|
+
然后 git branch -f 把源分支指到新历史。
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
选中的 commit 从此**不在源分支历史里**——不是 `git revert`,不会留下多余记录,文件改动也随之回退。
|
|
112
|
+
|
|
113
|
+
### 2.5 为什么不用 `git rebase --onto`
|
|
114
|
+
|
|
115
|
+
手工做法是每个要丢的 commit 跑一次:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
git rebase --onto C2^ C2 develop # 丢掉 C2
|
|
119
|
+
git rebase --onto C4^ C4 develop # 丢掉 C4
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
一旦**选中的 commit 相邻**、或者**不连续且改动互相重叠**,第二次 rebase 就找不到 C4 了,
|
|
123
|
+
或者冲突反复出现,要人工反复分段。本工具用「重置到 base + 重放保留集合」一次做完,不依赖 commit 之间的相对位置。
|
|
124
|
+
|
|
125
|
+
### 2.6 安全网与撤销
|
|
126
|
+
|
|
127
|
+
- 执行前会建两条 backup ref:`refs/gfm/backup/<时间戳>/<分支>`(源分支和目标分支各一条),
|
|
128
|
+
被 ref 引用的对象不会被 gc 回收,随时能回去;
|
|
129
|
+
- 现场记录在 `.git/gfm/last-operation.json`;
|
|
130
|
+
- 撤销:`git file-manager undo`;查看历史备份:`git file-manager undo --list`;
|
|
131
|
+
- `--dry-run` 只打印计划,不动任何东西。
|
|
132
|
+
|
|
133
|
+
### 2.7 遇到冲突怎么办
|
|
134
|
+
|
|
135
|
+
工具会停住并保存进度,不会乱来:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
# 手工解决冲突
|
|
139
|
+
git status
|
|
140
|
+
# 编辑冲突文件后
|
|
141
|
+
git add <已解决的文件>
|
|
142
|
+
git file-manager rescue --continue # 接着跑完剩下的
|
|
143
|
+
|
|
144
|
+
# 或者整体放弃,两个分支都回到操作前
|
|
145
|
+
git file-manager rescue --abort
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
### 2.8 注意事项(重要)
|
|
149
|
+
|
|
150
|
+
- **源分支已推送**:工具只提示、不替你 push。历史重写后需要
|
|
151
|
+
`git push --force-with-lease origin <源分支>`,并通知协作方重新拉取。
|
|
152
|
+
- **merge commit**:默认拒绝剪切含 merge commit 的区间(重放会丢合并信息),
|
|
153
|
+
确实要跑可以加 `--allow-merges`,风险自负。
|
|
154
|
+
- **工作区必须干净**:默认拒绝在脏工作区执行,除非 `--allow-dirty`。
|
|
155
|
+
- 源分支和目标分支必须是**本地分支**,不能写 `origin/xxx`。
|
|
156
|
+
|
|
157
|
+
---
|
|
158
|
+
|
|
159
|
+
## 3. 场景二:按 commit 把文件改动同步到另一个目录
|
|
160
|
+
|
|
161
|
+
### 3.1 典型情形
|
|
162
|
+
|
|
163
|
+
- 把某个组件在若干 commit 里的改动,同步到另一个工程(目录结构相同);
|
|
164
|
+
- 从主仓库挑几个 commit 的文件,铺到部署目录 / 另一个工作副本 / 非 git 目录。
|
|
165
|
+
|
|
166
|
+
### 3.2 一条命令
|
|
167
|
+
|
|
168
|
+
```bash
|
|
169
|
+
# commit id 直接跟在命令后面(也可以写 --commits "a1b2c3d,9f8e7d6")
|
|
170
|
+
git file-manager sync a1b2c3d 9f8e7d6 --to ../another-app --dry-run
|
|
171
|
+
|
|
172
|
+
# 真正同步
|
|
173
|
+
git file-manager sync a1b2c3d 9f8e7d6 --to ../another-app
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
```text
|
|
177
|
+
>> sync 计划
|
|
178
|
+
目标目录 : D:\work\another-app
|
|
179
|
+
模式 : content(按 commit 时点的内容写入)
|
|
180
|
+
commit : 2 个 -> a1b2c3d, 9f8e7d6
|
|
181
|
+
|
|
182
|
+
动作 文件 大小 说明
|
|
183
|
+
覆盖 src/api/user.ts 12.4 KB 覆盖
|
|
184
|
+
新增 src/api/login.ts 3.1 KB 新建
|
|
185
|
+
删除 src/api/old.ts 2.0 KB 跳过(未加 --delete)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### 3.3 两种模式怎么选
|
|
189
|
+
|
|
190
|
+
| | `--mode content`(默认) | `--mode patch` |
|
|
191
|
+
| --- | --- | --- |
|
|
192
|
+
| 做法 | 取文件在该 commit 时点的**完整内容**,写到目标目录同相对路径 | 生成这些 commit 的 diff,`git apply` 到目标目录 |
|
|
193
|
+
| 目标目录要求 | 无要求,普通目录也行,两边基线可以不同 | 需要与源 commit 的父版本一致,否则 apply 失败 |
|
|
194
|
+
| 目标目录已有本地改动 | 会被覆盖(用 `--backup` 可留副本) | 一般能保留,冲突时 apply 失败 |
|
|
195
|
+
| 幂等 | 是,内容相同自动跳过、不落盘 | 否,重复 apply 会失败 |
|
|
196
|
+
| 适用 | 「把这批文件按当时的样子铺过去」 | 「把这几个改动打到同基线的另一个副本上」 |
|
|
197
|
+
|
|
198
|
+
### 3.4 常用选项
|
|
199
|
+
|
|
200
|
+
| 选项 | 说明 |
|
|
201
|
+
| --- | --- |
|
|
202
|
+
| `--paths a,b,glob` | 只同步匹配的路径,支持 `*` `?` `**`;不含通配符时按目录前缀匹配 |
|
|
203
|
+
| `--exclude a,b` | 排除路径,语法同上 |
|
|
204
|
+
| `--delete` | 同步删除源 commit 中删掉的文件(**默认只报告不删**) |
|
|
205
|
+
| `--backup` | 覆盖/删除前,把目标旧文件备份到 `<to>/.gfm-backup/<时间戳>/` |
|
|
206
|
+
| `-n, --dry-run` | 只打印计划,不写任何文件 |
|
|
207
|
+
|
|
208
|
+
### 3.5 行为细节
|
|
209
|
+
|
|
210
|
+
- 同一文件在多个 commit 被改动时,取**最后一次**改动的版本(content 模式);
|
|
211
|
+
- 重命名(R)按「写新路径 + 旧路径删除」处理,旧路径删除同样受 `--delete` 控制;
|
|
212
|
+
- 文件内容按二进制读写,图片/字体等二进制文件不会被破坏;
|
|
213
|
+
- 写入前会校验目标路径没有越出 `--to` 目录;
|
|
214
|
+
- 和 `rescue` 联动:rescue 打印的原 sha 依然可用,直接拿它跑 sync 即可。
|
|
215
|
+
|
|
216
|
+
---
|
|
217
|
+
|
|
218
|
+
## 4. 命令参考
|
|
219
|
+
|
|
220
|
+
```text
|
|
221
|
+
git file-manager <命令> [选项]
|
|
222
|
+
git file-manager rescue --from <分支> --to <分支> <commit id...>
|
|
223
|
+
git file-manager sync <commit id...> --to <目录>
|
|
224
|
+
|
|
225
|
+
命令
|
|
226
|
+
rescue 把提交错分支的 commit 剪切到功能分支,并把源分支彻底还原
|
|
227
|
+
sync 把指定 commit 的文件改动,按原目录结构同步到另一个目录
|
|
228
|
+
undo 回滚最近一次 rescue;--list 查看历史备份
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
rescue:
|
|
232
|
+
|
|
233
|
+
| 选项 | 说明 |
|
|
234
|
+
| --- | --- |
|
|
235
|
+
| `--from <分支>` | 错误提交所在的分支(默认当前分支) |
|
|
236
|
+
| `--to <分支>` | 目标功能分支(必须是已存在的本地分支) |
|
|
237
|
+
| `--commits <表达式>` | 见 2.3 |
|
|
238
|
+
| `--last <N>` | 取 `--from` 上最近 N 个 commit |
|
|
239
|
+
| `--exclusive` | 区间不含左端(git 原生语义) |
|
|
240
|
+
| `--base <commit>` | 源分支重建基点,默认取最早已选中 commit 的父提交 |
|
|
241
|
+
| `--no-move` | 只清理源分支,不把 commit 迁到 `--to` |
|
|
242
|
+
| `--squash` | 迁到 `--to` 时压成一个 commit(该模式不支持冲突续跑) |
|
|
243
|
+
| `--allow-dirty` | 允许脏工作区执行 |
|
|
244
|
+
| `--allow-merges` | 允许 merge commit |
|
|
245
|
+
| `--continue` / `--abort` | 冲突续跑 / 整体回滚 |
|
|
246
|
+
| `-n, --dry-run` | 只打印计划 |
|
|
247
|
+
|
|
248
|
+
sync:
|
|
249
|
+
|
|
250
|
+
| 选项 | 说明 |
|
|
251
|
+
| --- | --- |
|
|
252
|
+
| `--commits` / `--last` / `--exclusive` | 同 rescue |
|
|
253
|
+
| `--from <分支>` | 解析 `--last` 的起点(默认 HEAD) |
|
|
254
|
+
| `--to <目录>` | 目标目录 |
|
|
255
|
+
| `--mode content\|patch` | 默认 content |
|
|
256
|
+
| `--paths` / `--exclude` / `--delete` / `--backup` | 见 3.4 |
|
|
257
|
+
| `-n, --dry-run` | 只打印计划 |
|
|
258
|
+
|
|
259
|
+
undo:
|
|
260
|
+
|
|
261
|
+
| 选项 | 说明 |
|
|
262
|
+
| --- | --- |
|
|
263
|
+
| (无) | 回滚最近一次 rescue(两个分支都回到操作前) |
|
|
264
|
+
| `--list` | 列出所有 backup ref 与最近一次操作 |
|
|
265
|
+
| `--ref <ref>` | 把对应分支回滚到指定的 backup ref |
|
|
266
|
+
|
|
267
|
+
通用:`-v, --verbose` 打印执行的每条 git 命令,`-h, --help`,`--version`。
|
|
268
|
+
|
|
269
|
+
---
|
|
270
|
+
|
|
271
|
+
## 5. 常见问题
|
|
272
|
+
|
|
273
|
+
**Q:源分支已经 push 过了,还能用吗?**
|
|
274
|
+
能,但历史重写后必须 force-push(`--force-with-lease`)。工具会在计划阶段就把这一点提示出来,但不会替你执行。
|
|
275
|
+
|
|
276
|
+
**Q:`--commits A..B` 为什么少了 A?**
|
|
277
|
+
默认是含两端的(`A..B` = A 到 B)。如果你习惯了 git 原生语义(不含 A),加 `--exclusive`。
|
|
278
|
+
|
|
279
|
+
**Q:cherry-pick 到目标分支时提示「内容已存在,跳过」?**
|
|
280
|
+
说明该 commit 的改动已经在目标分支上了(可能是之前手工搬过一部分)。工具会跳过并生成空提交提示,不会制造无意义的空 commit。
|
|
281
|
+
|
|
282
|
+
**Q:rescue 之后我发现搞错了?**
|
|
283
|
+
`git file-manager undo` 一条命令把两个分支都还原到操作前。backup ref 会一直保留,`undo --list` 能看到历史现场。
|
|
284
|
+
|
|
285
|
+
**Q:为什么不自动 push?**
|
|
286
|
+
改写历史和推送是不可逆的对外操作。工具只做本地变换并打印建议命令,由你决定何时、往哪推。
|
|
287
|
+
|
|
288
|
+
**Q:sync 为什么默认不删文件?**
|
|
289
|
+
删除比覆盖危险。默认只报告「源 commit 里删了哪些文件」,要真删请显式加 `--delete`(配 `--backup` 更稳)。
|
|
290
|
+
|
|
291
|
+
**Q:`--mode patch` 报 apply 失败?**
|
|
292
|
+
说明目标目录的基线和你给的 commit 父版本不一致。改用默认的 `--mode content` 即可(它是覆盖式的,不依赖基线)。
|
|
293
|
+
|
|
294
|
+
**Q:会不会把 `.git` 里的东西搞坏?**
|
|
295
|
+
所有操作都通过 git 命令完成;`.git/gfm/` 下只放状态文件与 backup ref(`refs/gfm/`),不碰其他 ref 和对象。
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
## 6. 开发
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
git clone <repo> && cd git-file-manager
|
|
303
|
+
npm test # node --test test/*.test.js
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
### 6.1 手工自测(约 10 分钟)
|
|
307
|
+
|
|
308
|
+
不想安装也能测:用 `node <仓库路径>/src/cli.js` 代替下文的 `gfm`。要顺便验证 bin 名与 `git file-manager` 转发,先在 git-file-manager 目录跑一次 `npm link`。
|
|
309
|
+
|
|
310
|
+
> 下面所有命令**不使用任何 shell 变量**,可以一行行复制执行。commit id 用
|
|
311
|
+
> `$(git log --format=%H --grep='dev-1' main)` 现取;末尾那个 `main` 是分支限定,
|
|
312
|
+
> 免得 undo 之后又从 `feature/login` 上取到同名的提交。
|
|
313
|
+
|
|
314
|
+
**准备 playground**(临时目录,随便造,不碰真实项目):
|
|
315
|
+
|
|
316
|
+
```powershell
|
|
317
|
+
Set-Location $env:TEMP
|
|
318
|
+
Remove-Item -Recurse -Force gfm-selftest -ErrorAction SilentlyContinue
|
|
319
|
+
New-Item -ItemType Directory gfm-selftest | Out-Null
|
|
320
|
+
Set-Location gfm-selftest
|
|
321
|
+
git init -q -b main
|
|
322
|
+
git config user.name selftest; git config user.email selftest@example.com
|
|
323
|
+
git config commit.gpgsign false; git config core.autocrlf false
|
|
324
|
+
'base' > a.txt; git add -A; git commit -qm 'base'; git branch feature/login
|
|
325
|
+
'b' > b.txt; git add -A; git commit -qm 'dev-1 要搬走'
|
|
326
|
+
'deps' > deps.txt; git add -A; git commit -qm 'dev-2 留在 main'
|
|
327
|
+
'c' > c.txt; git add -A; git commit -qm 'dev-3 要搬走'
|
|
328
|
+
'doc' > README.md; git add -A; git commit -qm 'dev-4 留在 main'
|
|
329
|
+
git log --oneline # dev-4 / dev-3 / dev-2 / dev-1 / base
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
**① 先看计划,零副作用**
|
|
333
|
+
|
|
334
|
+
```powershell
|
|
335
|
+
gfm rescue --from main --to feature/login $(git log --format=%H --grep='dev-1' main) $(git log --format=%H --grep='dev-3' main) --dry-run
|
|
336
|
+
git rev-parse main
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
通过判据:「迁移到 feature/login」和「将从 main 的历史中移除」各 2 条(dev-1、dev-3),「保留并重放」是 dev-2、dev-4;`git rev-parse main` 前后一致。
|
|
340
|
+
|
|
341
|
+
**② 真正剪切**
|
|
342
|
+
|
|
343
|
+
```powershell
|
|
344
|
+
gfm rescue --from main --to feature/login $(git log --format=%H --grep='dev-1' main) $(git log --format=%H --grep='dev-3' main)
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
| 检查项 | 命令 | 期望 |
|
|
348
|
+
| --- | --- | --- |
|
|
349
|
+
| 功能分支拿到 2 个提交 | `git log --format='%h %s' feature/login` | dev-3、dev-1、base |
|
|
350
|
+
| 源分支没有多余记录 | `git log --format='%h %s' main` | dev-4、dev-2、base |
|
|
351
|
+
| 文件改动一起回退 | `git show main:c.txt` | 报 path 不存在(报错即通过) |
|
|
352
|
+
| 保留的提交没被动过 | `git log --format='%s' -1 main` | dev-4 留在 main |
|
|
353
|
+
| 旧顶端不再属于任何分支 | `git branch --contains (git rev-parse "main@{1}")` | 空输出 |
|
|
354
|
+
| 安全网在位 | `git for-each-ref --format='%(refname)' refs/gfm/backup` | 2 条(main、feature_login) |
|
|
355
|
+
|
|
356
|
+
**③ 撤销**
|
|
357
|
+
|
|
358
|
+
```powershell
|
|
359
|
+
gfm undo
|
|
360
|
+
git log --format='%h %s' main # dev-4、dev-3、dev-2、dev-1、base 全回来
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
**④ sync 到另一个目录**(在 gfm-selftest 目录里执行,`--to` 用的是相对路径)
|
|
364
|
+
|
|
365
|
+
```powershell
|
|
366
|
+
Remove-Item -Recurse -Force ..\gfm-selftest-out -ErrorAction SilentlyContinue
|
|
367
|
+
gfm sync $(git log --format=%H --grep='dev-1' main) $(git log --format=%H --grep='dev-3' main) --to ..\gfm-selftest-out --dry-run
|
|
368
|
+
New-Item -ItemType Directory ..\gfm-selftest-out -Force | Out-Null
|
|
369
|
+
'OLD' > ..\gfm-selftest-out\b.txt
|
|
370
|
+
gfm sync $(git log --format=%H --grep='dev-1' main) $(git log --format=%H --grep='dev-3' main) --to ..\gfm-selftest-out --backup
|
|
371
|
+
get-content ..\gfm-selftest-out\b.txt # 应输出 b
|
|
372
|
+
Get-ChildItem -Recurse -File ..\gfm-selftest-out | Select-Object FullName
|
|
373
|
+
gfm sync $(git log --format=%H --grep='dev-1' main) $(git log --format=%H --grep='dev-3' main) --to ..\gfm-selftest-out # 写入 0 个,跳过 2 个
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
通过判据:目标目录结构与源一致;`--backup` 那次旧文件出现在 `<out>\.gfm-backup\<时间戳>\`;最后一次执行不写盘。
|
|
377
|
+
|
|
378
|
+
**⑤ 冲突 -> abort / continue**
|
|
379
|
+
|
|
380
|
+
```powershell
|
|
381
|
+
node -e "require('fs').writeFileSync('x.txt','1\n2\n')"; git add -A; git commit -qm 'x-1 要搬走'
|
|
382
|
+
node -e "require('fs').writeFileSync('x.txt','1\n2\n3\n')"; git add -A; git commit -qm 'x-2 留在 main'
|
|
383
|
+
git branch -f feature/c $(git log --format=%H --grep='base' main)
|
|
384
|
+
|
|
385
|
+
# 第一次:迁移成功,重写源分支时必然冲突,退出码 1
|
|
386
|
+
gfm rescue --from main --to feature/c $(git log --format=%H --grep='x-1' main)
|
|
387
|
+
gfm rescue --abort # 整体放弃,main 回到操作前
|
|
388
|
+
git log --format='%s' main
|
|
389
|
+
|
|
390
|
+
# 再来一次:解决冲突后接着跑完
|
|
391
|
+
gfm rescue --from main --to feature/c $(git log --format=%H --grep='x-1' main)
|
|
392
|
+
node -e "require('fs').writeFileSync('x.txt','1\n3\n')"
|
|
393
|
+
git add x.txt
|
|
394
|
+
gfm rescue --continue
|
|
395
|
+
git log --format='%h %s' main # x-2、dev-4、dev-3、dev-2、dev-1、base
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
**⑥ 负向用例**(都应退出码 1 并给出明确提示)
|
|
399
|
+
|
|
400
|
+
| 场景 | 做法 | 期望提示 |
|
|
401
|
+
| --- | --- | --- |
|
|
402
|
+
| 脏工作区 | 改一个已跟踪文件后 rescue | 工作区不干净 |
|
|
403
|
+
| 目标分支不存在 | `--to nope` | 不是本地分支 |
|
|
404
|
+
| commit 不在源分支 | 传 feature 上的 sha | 不在分支 xxx 上 |
|
|
405
|
+
| 表达式为空 | `--commits ,` | --commits 没有解析出任何 commit |
|
|
406
|
+
| 冲突没解决就续跑 | 冲突后不 `git add` 直接 `--continue` | 仍有未解决的冲突 |
|
|
407
|
+
| 路径越界 | `sync --paths ../x` | 目标路径越界 |
|
|
408
|
+
|
|
409
|
+
退出码在 PowerShell 里看 `$LASTEXITCODE`。想从头再来,重新执行「准备 playground」那段即可。
|
|
410
|
+
### 6.2 自动化测试覆盖
|
|
411
|
+
|
|
412
|
+
测试会在**系统临时目录**里 `git init` 造夹具仓库,不触碰任何真实仓库。当前覆盖:
|
|
413
|
+
|
|
414
|
+
- rescue:连续区间、不连续、`--last`、`--squash`、`--dry-run`、参数守卫(不在分支上 / 脏工作区 / 分支不存在)、冲突后 `--abort` 回滚;
|
|
415
|
+
- sync:content 新增与修改、幂等跳过、中文路径、`--paths` 过滤、`--delete`、`--dry-run`、patch 模式、`--backup`。
|
|
416
|
+
|
|
417
|
+
代码结构:
|
|
418
|
+
|
|
419
|
+
```text
|
|
420
|
+
src/cli.js 入口:参数解析、命令分发、帮助
|
|
421
|
+
src/git.js git 封装(spawnSync,参数数组直传,中文/空格安全)
|
|
422
|
+
src/commitSpec.js commit 表达式解析与拓扑排序
|
|
423
|
+
src/backup.js backup ref 与操作状态文件
|
|
424
|
+
src/log.js 终端输出与错误类型
|
|
425
|
+
src/commands/rescue.js 功能一
|
|
426
|
+
src/commands/sync.js 功能二
|
|
427
|
+
src/commands/undo.js 回滚
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
## 7. 发布
|
|
431
|
+
|
|
432
|
+
```bash
|
|
433
|
+
release\release.bat # 按当前版本发布
|
|
434
|
+
release\release.bat patch # 先 bump patch 再发布
|
|
435
|
+
release\release.bat --dry-run # 只做体检
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
发布脚本会校验包名、git 干净、npm 登录状态、`npm pack --dry-run` 产物清单,然后 `npm publish`。
|
|
439
|
+
|
|
440
|
+
## 8. License
|
|
441
|
+
|
|
442
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@youngwat/git-file-manager",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Git 救火工具:把提交错分支的 commit 剪切到功能分支并彻底还原源分支;把指定 commit 的文件改动按原目录结构同步到另一个目录",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"git",
|
|
7
|
+
"cli",
|
|
8
|
+
"cherry-pick",
|
|
9
|
+
"commit",
|
|
10
|
+
"branch",
|
|
11
|
+
"sync",
|
|
12
|
+
"rescue"
|
|
13
|
+
],
|
|
14
|
+
"license": "MIT",
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18.20.4"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"git-file-manager": "src/cli.js",
|
|
20
|
+
"gfm": "src/cli.js"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"src",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"scripts": {
|
|
28
|
+
"test": "node --test test/*.test.js",
|
|
29
|
+
"pack:check": "npm pack --dry-run",
|
|
30
|
+
"release": "release\\release.bat",
|
|
31
|
+
"release:pack": "npm pack --dry-run"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public",
|
|
35
|
+
"registry": "https://registry.npmjs.org/"
|
|
36
|
+
},
|
|
37
|
+
"repository": {
|
|
38
|
+
"type": "git",
|
|
39
|
+
"url": "git+https://gitee.com/harmonious-serenity/s-sh.git"
|
|
40
|
+
}
|
|
6
41
|
}
|
package/src/backup.js
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 安全网:backup ref + 操作状态文件。
|
|
5
|
+
*
|
|
6
|
+
* - backup ref 挂在 refs/gfm/backup/<时间戳>/<分支> 下,被 ref 引用的对象不会被 gc
|
|
7
|
+
* 回收,所以 undo 可以可靠地把分支指回操作前的 sha;
|
|
8
|
+
* - .git/gfm/last-operation.json 记录最近一次 rescue 的现场,供 --continue / undo 使用。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const git = require('./git');
|
|
14
|
+
|
|
15
|
+
const REF_PREFIX = 'refs/gfm/backup';
|
|
16
|
+
|
|
17
|
+
function gfmDir() {
|
|
18
|
+
const dir = path.join(git.gitDir(), 'gfm');
|
|
19
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
20
|
+
return dir;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const stateFile = () => path.join(gfmDir(), 'last-operation.json');
|
|
24
|
+
|
|
25
|
+
function stamp(date = new Date()) {
|
|
26
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
27
|
+
return `${date.getFullYear()}${p(date.getMonth() + 1)}${p(date.getDate())}-`
|
|
28
|
+
+ `${p(date.getHours())}${p(date.getMinutes())}${p(date.getSeconds())}`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const sanitize = (name) => String(name).replace(/[^A-Za-z0-9._-]+/g, '_');
|
|
32
|
+
|
|
33
|
+
/** 建一条 backup ref,返回 ref 名 */
|
|
34
|
+
function createRef(label, sha, id) {
|
|
35
|
+
const ref = `${REF_PREFIX}/${id}/${sanitize(label)}`;
|
|
36
|
+
git.run(['update-ref', ref, sha]);
|
|
37
|
+
return ref;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const refSha = (ref) => git.revParse(ref);
|
|
41
|
+
const deleteRef = (ref) => git.run(['update-ref', '-d', ref], { allowFail: true });
|
|
42
|
+
|
|
43
|
+
function saveState(state) {
|
|
44
|
+
fs.writeFileSync(stateFile(), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function readState() {
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(fs.readFileSync(stateFile(), 'utf8'));
|
|
50
|
+
} catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function clearState() {
|
|
56
|
+
try { fs.rmSync(stateFile(), { force: true }); } catch { /* ignore */ }
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function listBackups() {
|
|
60
|
+
return git.lines(['for-each-ref', '--format=%(refname)%09%(objectname)%09%(creatordate:iso8601)', REF_PREFIX])
|
|
61
|
+
.map((line) => {
|
|
62
|
+
const [ref, sha, date] = line.split('\t');
|
|
63
|
+
return { ref, sha, date };
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
module.exports = {
|
|
68
|
+
REF_PREFIX,
|
|
69
|
+
gfmDir,
|
|
70
|
+
stamp,
|
|
71
|
+
createRef,
|
|
72
|
+
refSha,
|
|
73
|
+
deleteRef,
|
|
74
|
+
saveState,
|
|
75
|
+
readState,
|
|
76
|
+
clearState,
|
|
77
|
+
listBackups,
|
|
78
|
+
};
|